前端双部署

This commit is contained in:
fuenmao
2026-09-15 13:43:22 +08:00
parent 7de33e5f13
commit 70bf1907b5
28 changed files with 1125 additions and 96 deletions
+13
View File
@@ -0,0 +1,13 @@
<template>
<el-config-provider :locale="zhCn" :button="buttonConfig">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
const buttonConfig = reactive({ autoInsertSpace: false });
</script>
-44
View File
@@ -104,13 +104,6 @@ export interface OnboardingTaskCopyResult {
tasks: OnboardingTaskCreatedResult[];
}
/** 员工端完成合同准备后返回的当前文件版本。 */
export interface OnboardingPreparedContract {
/** 当前生成合同对应的系统文件 Id,同时作为下载缓存版本。 */
fileId: number;
/** 当前合同文件生成时间。 */
generatedAt?: string;
}
/** 一次电子签流程中的单份合同文件。 */
export interface OnboardingElectronicSignDocument {
@@ -964,40 +957,3 @@ export const onboardingTaskApi = {
return moduleRequest("/biz/onboarding/variable-definition/").post("delete", { ids });
}
};
const publicHttp = moduleRequest("/open/onboarding/");
export const onboardingPublicApi = {
detail(params: { token: string }) {
return publicHttp.post<any>("detail", params);
},
saveEmployeeInfo(params: Record<string, unknown>) {
return publicHttp.post("save-employee-info", params);
},
confirmContract(params: { token: string; taskContractId: number }) {
return publicHttp.post("confirm-contract", params);
},
submit(params: {
token: string;
identityInformationConfirmed: boolean;
confirmedEmployeeName: string;
confirmedIdCardNumber: string;
confirmedPhone: string;
}) {
return publicHttp.post("submit", params);
},
prepareContract(params: { token: string; taskContractId: number }) {
return publicHttp.post<OnboardingPreparedContract>("prepare-contract", params);
},
contractSourceUrl(params: { token: string; taskContractId: number; fileVersion?: number }) {
const version = params.fileVersion ? `&version=${params.fileVersion}` : `&_t=${Date.now()}`;
return `/api/open/onboarding/contract-source?token=${encodeURIComponent(params.token)}&taskContractId=${params.taskContractId}${version}`;
},
materialSourceUrl(params: { token: string; taskMaterialId: number; fileId?: number }) {
const version = params.fileId ? `&v=${params.fileId}` : "";
return `/api/open/onboarding/material-source?token=${encodeURIComponent(params.token)}&taskMaterialId=${params.taskMaterialId}${version}`;
},
deleteMaterial(params: { token: string; taskMaterialId: number }) {
return publicHttp.post("delete-material", params);
}
};
+91
View File
@@ -0,0 +1,91 @@
import {
publicDownload,
publicPost,
publicUpload,
type PublicFileResult
} from "@/api/request/publicRequest";
export interface OnboardingPublicTask {
id: number;
taskNo: string;
taskStatus: string;
rejectReason?: string;
linkExpireAt?: string;
}
export interface OnboardingPreparedContract {
fileId: number;
generatedAt?: string;
}
export const onboardingPublicApi = {
detail(params: { token: string }) {
return publicPost<any>("detail", params);
},
saveEmployeeInfo(params: Record<string, unknown>) {
return publicPost<never>("save-employee-info", params);
},
confirmContract(params: { token: string; taskContractId: number }) {
return publicPost<never>("confirm-contract", params);
},
submit(params: {
token: string;
identityInformationConfirmed: boolean;
confirmedEmployeeName: string;
confirmedIdCardNumber: string;
confirmedPhone: string;
}) {
return publicPost<never>("submit", params);
},
prepareContract(params: { token: string; taskContractId: number }) {
return publicPost<OnboardingPreparedContract>("prepare-contract", params);
},
downloadContract(params: {
token: string;
taskContractId: number;
fileVersion?: number;
}): Promise<PublicFileResult> {
return publicDownload("contract-source", {
token: params.token,
taskContractId: params.taskContractId,
version: params.fileVersion,
_t: params.fileVersion ? undefined : Date.now()
});
},
downloadMaterial(params: {
token: string;
taskMaterialId: number;
fileId?: number;
}, notifyError = true): Promise<PublicFileResult> {
return publicDownload("material-source", {
token: params.token,
taskMaterialId: params.taskMaterialId,
v: params.fileId
}, { notifyError });
},
uploadMaterial(params: {
token: string;
materialTypeId: number;
file: File;
taskMaterialId?: number;
}) {
const data = new FormData();
data.append("file", params.file);
return publicUpload<number>("upload-material", data, {
token: params.token,
materialTypeId: params.materialTypeId,
taskMaterialId: params.taskMaterialId
});
},
deleteMaterial(params: { token: string; taskMaterialId: number }) {
return publicPost<never>("delete-material", params);
}
};
+216
View File
@@ -0,0 +1,216 @@
import { ElMessage } from "element-plus";
const PUBLIC_ONBOARDING_PREFIX = "/api/open/onboarding/";
const DEFAULT_TIMEOUT = 90_000;
export interface PublicApiResult<T> {
code: number;
msg?: string;
data: T;
extras?: unknown;
time?: string;
}
export interface PublicFileResult {
blob: Blob;
fileName?: string;
}
interface PublicRequestOptions {
timeout?: number;
notifyError?: boolean;
}
export class PublicApiError extends Error {
code?: number;
status?: number;
msg: string;
reported: boolean;
constructor(message: string, options: {
code?: number;
status?: number;
reported?: boolean;
} = {}) {
super(message);
this.name = "PublicApiError";
this.msg = message;
this.code = options.code;
this.status = options.status ?? 0;
this.reported = Boolean(options.reported);
}
}
function assertPublicAction(action: string) {
if (!/^[a-z0-9-]+$/i.test(action)) {
throw new PublicApiError("公开接口地址不合法");
}
}
function buildPublicUrl(
action: string,
query?: Record<string, string | number | undefined>
) {
assertPublicAction(action);
const url = new URL(`${PUBLIC_ONBOARDING_PREFIX}${action}`, window.location.origin);
Object.entries(query || {}).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
});
return `${url.pathname}${url.search}`;
}
function reportError(error: PublicApiError, notifyError: boolean) {
if (notifyError && !error.reported) {
ElMessage.error(error.message);
error.reported = true;
}
return error;
}
async function readErrorMessage(response: Response) {
try {
const payload = await response.clone().json();
return payload?.msg || payload?.message || `请求失败(${response.status})`;
} catch {
return `请求失败(${response.status})`;
}
}
async function requestRaw(
action: string,
init: RequestInit,
query?: Record<string, string | number | undefined>,
options: PublicRequestOptions = {}
) {
const notifyError = options.notifyError !== false;
const controller = new AbortController();
const timeoutId = window.setTimeout(
() => controller.abort(),
options.timeout ?? DEFAULT_TIMEOUT
);
try {
const response = await fetch(buildPublicUrl(action, query), {
...init,
credentials: "omit",
cache: "no-store",
redirect: "error",
signal: controller.signal
});
if (!response.ok) {
throw new PublicApiError(await readErrorMessage(response), {
status: response.status
});
}
return response;
} catch (error: any) {
const normalized = error instanceof PublicApiError
? error
: new PublicApiError(
error?.name === "AbortError"
? "请求超时,请检查网络后重试"
: "网络请求失败,请检查网络后重试"
);
throw reportError(normalized, notifyError);
} finally {
window.clearTimeout(timeoutId);
}
}
async function readApiResult<T>(
response: Response,
notifyError: boolean
): Promise<PublicApiResult<T>> {
let result: PublicApiResult<T>;
try {
result = await response.json();
} catch {
throw reportError(new PublicApiError("服务器返回了无法识别的数据"), notifyError);
}
if (result.code !== 0 && result.code !== 200) {
throw reportError(
new PublicApiError(result.msg || "请求失败", { code: result.code }),
notifyError
);
}
return result;
}
export async function publicPost<T>(
action: string,
body: unknown,
options: PublicRequestOptions = {}
) {
const response = await requestRaw(action, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}, undefined, options);
return readApiResult<T>(response, options.notifyError !== false);
}
export async function publicUpload<T>(
action: string,
formData: FormData,
query?: Record<string, string | number | undefined>,
options: PublicRequestOptions = {}
) {
const response = await requestRaw(action, {
method: "POST",
headers: { Accept: "application/json" },
body: formData
}, query, {
timeout: options.timeout ?? 300_000,
notifyError: options.notifyError
});
return readApiResult<T>(response, options.notifyError !== false);
}
export async function publicDownload(
action: string,
query: Record<string, string | number | undefined>,
options: PublicRequestOptions = {}
): Promise<PublicFileResult> {
const response = await requestRaw(action, {
method: "GET",
headers: { Accept: "application/octet-stream" }
}, query, {
timeout: options.timeout ?? 300_000,
notifyError: options.notifyError
});
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("application/json")) {
const result = await readApiResult<never>(
response,
options.notifyError !== false
);
throw reportError(
new PublicApiError(result.msg || "文件下载失败", { code: result.code }),
options.notifyError !== false
);
}
const disposition = response.headers.get("content-disposition") || "";
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|| disposition.match(/filename="?([^";]+)"?/i)?.[1];
let fileName: string | undefined;
if (encodedName) {
try {
fileName = decodeURIComponent(encodedName.replace(/\+/g, " "));
} catch {
fileName = encodedName;
}
}
return {
blob: await response.blob(),
fileName
};
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const USER_CENTER_URL: string = "/userCenter";
export const DEFAULT_PRIMARY: string = "#409EFF";
// 路由白名单地址(本地存在的路由 staticRouter.ts 中)
export const ROUTER_WHITE_LIST: string[] = ["/500", "/onboarding/fill"];
export const ROUTER_WHITE_LIST: string[] = ["/500"];
// 高德地图 key
export const AMAP_MAP_KEY: string = "";
+60
View File
@@ -0,0 +1,60 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import {
Briefcase,
Calendar,
Check,
Delete,
Document,
Download,
FirstAidKit,
FolderChecked,
HomeFilled,
OfficeBuilding,
Phone,
Plus,
Reading,
Refresh,
School,
Tickets,
Upload,
User
} from "@element-plus/icons-vue";
import PublicApp from "./PublicApp.vue";
import publicRouter from "@/routers/public";
import errorHandler from "@/utils/errorHandler";
import "@/styles/reset.scss";
import "@/styles/common.scss";
import "element-plus/dist/index.css";
import "virtual:uno.css";
import "default-passive-events";
const app = createApp(PublicApp);
const publicIcons = {
Briefcase,
Calendar,
Check,
Delete,
Document,
Download,
FirstAidKit,
FolderChecked,
HomeFilled,
OfficeBuilding,
Phone,
Plus,
Reading,
Refresh,
School,
Tickets,
Upload,
User
};
app.config.errorHandler = errorHandler;
Object.entries(publicIcons).forEach(([name, component]) => {
app.component(name, component);
});
app.use(ElementPlus).use(publicRouter).mount("#app");
+4 -1
View File
@@ -19,7 +19,10 @@ import { useUserStore, useAuthStore, useDictStore, useConfigStore } from "@/stor
import router from "@/routers";
// 引入 views 文件夹下所有 vue 文件
const modules = import.meta.glob("@/views/**/*.vue");
const modules = import.meta.glob([
"@/views/**/*.vue",
"!@/views/onboarding/**/*.vue"
]);
/**
* @description 初始化动态路由
-6
View File
@@ -31,12 +31,6 @@ export const staticRouter: RouteRecordRaw[] = [
title: "登录"
}
},
{
path: "/onboarding/fill",
name: "onboardingFill",
component: () => import("@/views/onboarding/fill/index.vue"),
meta: { title: "入职服务", standaloneTitle: true }
},
{
path: "/layout",
name: "layout",
+55
View File
@@ -0,0 +1,55 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "invalidOnboardingLink",
component: () => import("@/views/onboarding/public-status/index.vue"),
props: {
title: "入职链接无效",
description: "请联系招聘人员重新获取入职链接。"
},
meta: { title: "入职链接无效" }
},
{
path: "/onboarding/fill",
name: "onboardingFill",
component: () => import("@/views/onboarding/fill/index.vue"),
meta: { title: "入职服务" }
},
{
path: "/invalid-link",
redirect: "/"
},
{
path: "/404",
name: "publicNotFound",
component: () => import("@/views/onboarding/public-status/index.vue"),
props: {
title: "页面不存在",
description: "请检查入职链接是否完整,或联系招聘人员重新获取。"
},
meta: { title: "页面不存在" }
},
{
path: "/:pathMatch(.*)*",
redirect: "/404"
}
];
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior: () => ({ left: 0, top: 0 })
});
router.beforeEach(to => {
if (to.path === "/onboarding/fill" && !String(to.query.token || "").trim()) {
return { name: "invalidOnboardingLink", replace: true };
}
document.title = String(to.meta.title || "入职服务");
return true;
});
export default router;
+4
View File
@@ -57,6 +57,9 @@ declare type Recordable<T = any> = Record<string, T>;
declare interface ViteEnv {
VITE_USER_NODE_ENV: "development" | "production" | "test";
VITE_APP_VARIANT: "admin" | "public";
VITE_APP_ENTRY: string;
VITE_APP_FAVICON: string;
VITE_GLOB_APP_TITLE: string;
VITE_PORT: number;
VITE_OPEN: boolean;
@@ -69,6 +72,7 @@ declare interface ViteEnv {
VITE_DEVTOOLS: boolean;
VITE_PUBLIC_PATH: string;
VITE_API_URL: string;
VITE_PUBLIC_ONBOARDING_URL: string;
VITE_PROXY: [string, string][];
VITE_CODEINSPECTOR: boolean;
}
+12 -2
View File
@@ -2516,6 +2516,16 @@ async function downloadAllContractPreviews() {
}
}
function buildPublicOnboardingLink(linkToken: string) {
const publicOrigin = String(import.meta.env.VITE_PUBLIC_ONBOARDING_URL || "")
.trim()
.replace(/\/+$/, "");
if (!publicOrigin) {
throw new Error("未配置员工公开入职域名 VITE_PUBLIC_ONBOARDING_URL");
}
return `${publicOrigin}/#/onboarding/fill?token=${encodeURIComponent(linkToken)}`;
}
async function createTask() {
if (!validateContractPreviewForm()) return;
if (!form.remark.trim()) {
@@ -2532,7 +2542,7 @@ async function createTask() {
createVisible.value = false;
proTable.value?.refresh();
const linkUrl = `${location.origin}/#/onboarding/fill?token=${result.linkToken}`;
const linkUrl = buildPublicOnboardingLink(result.linkToken);
const selectedCompany = companies.value.find(c => c.id === form.companyId)?.companyName || "";
const selectedDepartment = departments.value.find(d => d.id === form.departmentId)?.departmentName || "";
const selectedPosition = positions.value.find(p => p.id === form.positionId)?.positionName || "";
@@ -2571,7 +2581,7 @@ function copyInviteMessage() {
async function copyOrShareTaskLink(row: OnboardingTask) {
try {
const { data }: any = await onboardingTaskApi.getTaskLink({ id: row.id });
const linkUrl = `${location.origin}/#/onboarding/fill?token=${data.linkToken}`;
const linkUrl = buildPublicOnboardingLink(data.linkToken);
createdTaskInfo.value = {
taskNo: row.taskNo,
+178 -38
View File
@@ -514,8 +514,8 @@
<div v-for="f in getUploadedMaterials(mat.materialTypeId)" :key="`${f.id}-${f.fileId}`"
class="file-badge">
<!-- 图片点击缩略图即可在手机端放大预览,非图片继续显示文件图标。 -->
<el-image v-if="isImageFile(f)" class="material-thumbnail" :src="getMaterialSourceUrl(f)"
:preview-src-list="[getMaterialSourceUrl(f)]" fit="cover" preview-teleported />
<el-image v-if="isImageFile(f) && getMaterialSourceUrl(f)" class="material-thumbnail"
:src="getMaterialSourceUrl(f)" :preview-src-list="[getMaterialSourceUrl(f)]" fit="cover" preview-teleported />
<el-icon v-else class="material-file-icon" color="#059669">
<Document />
</el-icon>
@@ -529,8 +529,9 @@
</div>
<div v-if="canEdit" class="file-actions">
<el-upload :action="getMaterialUploadUrl(mat.materialTypeId, f.id)" :accept="getUploadAccept(mat)"
<el-upload action="#" :accept="getUploadAccept(mat)"
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
:http-request="(options: any) => uploadMaterial(options, mat.materialTypeId, f.id)"
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
:before-upload="(file: File) => beforeUpload(file, mat)">
@@ -547,8 +548,9 @@
</div>
<div v-if="canEdit && getRemainingFileCount(mat) > 0" class="upload-action-box">
<el-upload :action="getMaterialUploadUrl(mat.materialTypeId)" :accept="getUploadAccept(mat)"
<el-upload action="#" :accept="getUploadAccept(mat)"
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
:http-request="(options: any) => uploadMaterial(options, mat.materialTypeId)"
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
:before-upload="(file: File) => beforeUpload(file, mat)">
@@ -642,8 +644,8 @@
<div class="contract-card-bottom">
<el-button :type="isContractPrepareFailed(c.id) ? 'danger' : 'primary'" plain
:icon="isContractPrepareFailed(c.id) ? 'Refresh' : 'Download'" :loading="isContractPreparing(c.id)"
:disabled="!isContractPrepared(c.id) && !isContractPrepareFailed(c.id)"
:icon="isContractPrepareFailed(c.id) ? 'Refresh' : 'Download'" :loading="isContractBusy(c.id)"
:disabled="isContractDownloading(c.id) || (!isContractPrepared(c.id) && !isContractPrepareFailed(c.id))"
@click="handleContractAction(c)">
{{ getContractActionText(c.id) }}
</el-button>
@@ -682,9 +684,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import {
OnboardingTask,
OnboardingPublicTask,
onboardingPublicApi
} from "@/api/modules/biz/onboarding";
} from "@/api/modules/open/onboarding";
import {
getIdCardBirthday,
getIdCardGender,
@@ -713,8 +715,11 @@ const uploadingMaterials = reactive<Record<string, boolean>>({});
type ContractPrepareStatus = "idle" | "preparing" | "ready" | "error";
const contractPrepareStatus = reactive<Record<string, ContractPrepareStatus>>({});
const contractPrepareErrors = reactive<Record<string, string>>({});
const contractDownloading = reactive<Record<string, boolean>>({});
const materialPreviewUrls = reactive<Record<string, string>>({});
let materialPreviewGeneration = 0;
const task = ref<OnboardingTask | null>(null);
const task = ref<OnboardingPublicTask | null>(null);
const contracts = ref<any[]>([]);
const materialRequirements = ref<any[]>([]);
const uploadedMaterials = ref<any[]>([]);
@@ -1405,6 +1410,7 @@ async function load(silent = false) {
try { Object.assign(customVariables, JSON.parse(data.employeeInfo.customVariablesJson)); } catch { }
}
}
await refreshMaterialPreviews();
} catch (err: any) {
error.value = err.message || "无法加载入职任务";
} finally {
@@ -1492,23 +1498,60 @@ async function nextStep() {
}
}
function getMaterialUploadUrl(materialTypeId: number, taskMaterialId?: number) {
const query = new URLSearchParams({
token: token.value,
materialTypeId: String(materialTypeId)
});
if (taskMaterialId) {
query.set("taskMaterialId", String(taskMaterialId));
}
return `/api/open/onboarding/upload-material?${query.toString()}`;
function materialPreviewKey(file: any) {
return `${file.id}-${file.fileId || 0}`;
}
function getMaterialSourceUrl(file: any) {
return onboardingPublicApi.materialSourceUrl({
token: token.value,
taskMaterialId: file.id,
fileId: file.fileId
});
return materialPreviewUrls[materialPreviewKey(file)] || "";
}
function revokeMaterialPreviewUrls() {
Object.values(materialPreviewUrls).forEach(url => URL.revokeObjectURL(url));
Object.keys(materialPreviewUrls).forEach(key => delete materialPreviewUrls[key]);
}
async function refreshMaterialPreviews() {
const generation = ++materialPreviewGeneration;
revokeMaterialPreviewUrls();
await Promise.all(uploadedMaterials.value
.filter(file => isImageFile(file))
.map(async file => {
try {
const result = await onboardingPublicApi.downloadMaterial({
token: token.value,
taskMaterialId: file.id,
fileId: file.fileId
}, false);
const objectUrl = URL.createObjectURL(result.blob);
if (generation !== materialPreviewGeneration) {
URL.revokeObjectURL(objectUrl);
return;
}
materialPreviewUrls[materialPreviewKey(file)] = objectUrl;
} catch {
// 单张历史图片损坏时保留文件信息,不阻断整个入职任务。
}
}));
}
async function uploadMaterial(
options: any,
materialTypeId: number,
taskMaterialId?: number
) {
try {
const response = await onboardingPublicApi.uploadMaterial({
token: token.value,
materialTypeId,
taskMaterialId,
file: options.file
});
options.onSuccess(response);
} catch (error: any) {
options.onError(error);
}
}
function isMaterialUploading(materialTypeId: number) {
@@ -1547,9 +1590,11 @@ async function onUploadSuccess(res: any, materialTypeId: number) {
}
}
function onUploadError(error: Error, materialTypeId: number) {
function onUploadError(error: any, materialTypeId: number) {
setMaterialUploading(materialTypeId, false);
ElMessage.error(error.message || "材料上传失败,请稍后重试");
if (!error?.reported) {
ElMessage.error(error?.message || "材料上传失败,请稍后重试");
}
}
async function deleteFile(id: number) {
@@ -1594,6 +1639,14 @@ function isContractPreparing(taskContractId: number) {
return getContractPrepareStatus(taskContractId) === "preparing";
}
function isContractDownloading(taskContractId: number) {
return Boolean(contractDownloading[String(taskContractId)]);
}
function isContractBusy(taskContractId: number) {
return isContractPreparing(taskContractId) || isContractDownloading(taskContractId);
}
function isContractPrepared(taskContractId: number) {
return getContractPrepareStatus(taskContractId) === "ready";
}
@@ -1603,6 +1656,9 @@ function isContractPrepareFailed(taskContractId: number) {
}
function getContractActionText(taskContractId: number) {
if (isContractDownloading(taskContractId)) {
return "正在下载签署材料";
}
const status = getContractPrepareStatus(taskContractId);
if (status === "preparing") {
return "正在准备签署材料";
@@ -1670,23 +1726,105 @@ async function handleContractAction(contract: any) {
}
if (isContractPrepared(contract.id)) {
previewAndDownloadContract(contract);
await previewAndDownloadContract(contract);
}
}
function previewAndDownloadContract(c: any) {
const url = onboardingPublicApi.contractSourceUrl({
token: token.value,
taskContractId: c.id,
fileVersion: c.generatedDocxFileId
});
function normalizeDownloadFileName(fileName: string | undefined, fallback: string) {
const normalized = String(fileName || fallback)
.replace(/[\\/:*?"<>|]/g, "_")
.trim();
return normalized || fallback;
}
c.hasViewed = true;
c.isAgreed = false;
// 下载地址必须在用户点击事件中直接打开,微信及部分手机浏览器会拦截异步跳转空白页。
const downloadWindow = window.open(url, "_blank");
if (!downloadWindow) {
window.location.assign(url);
function triggerContractDownload(blob: Blob, fileName: string) {
if (!blob.size) {
throw new Error("服务器返回的合同文件为空");
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
link.rel = "noopener noreferrer";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
// 安卓 WebView 可能延迟接管 Blob 下载,不能过早释放。
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
function buildDirectContractDownloadUrl(c: any) {
const url = new URL("/api/open/onboarding/contract-source", window.location.origin);
url.searchParams.set("token", token.value);
url.searchParams.set("taskContractId", String(c.id));
if (c.generatedDocxFileId) {
url.searchParams.set("version", String(c.generatedDocxFileId));
}
url.searchParams.set("_t", String(Date.now()));
return `${url.pathname}${url.search}`;
}
function triggerDirectContractDownload(url: string) {
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
}
async function previewAndDownloadContract(c: any) {
const key = String(c.id);
if (contractDownloading[key]) {
return;
}
contractDownloading[key] = true;
try {
const userAgent = window.navigator.userAgent;
if (/Android/i.test(userAgent)) {
// 安卓 WebView 通常不支持 blob: 下载,直接交给系统的 HTTP 下载能力处理。
// 此处在用户点击事件后的第一个同步分支触发,避免被 WebView 当成弹窗拦截。
triggerDirectContractDownload(buildDirectContractDownloadUrl(c));
c.hasViewed = true;
c.isAgreed = false;
ElMessage.success("签署材料下载已发起,请使用 Word/WPS 打开核对");
if (/MicroMessenger/i.test(userAgent)) {
void ElMessageBox.alert(
"如果微信中没有出现文件,请点击右上角“…”选择“在浏览器打开”,然后再次点击下载。",
"安卓微信下载提示",
{ confirmButtonText: "我知道了" }
).catch(() => undefined);
}
return;
}
const result = await onboardingPublicApi.downloadContract({
token: token.value,
taskContractId: c.id,
fileVersion: c.generatedDocxFileId
});
const fallbackName = `${c.contractName || "入职签署材料"}.docx`;
triggerContractDownload(
result.blob,
normalizeDownloadFileName(result.fileName, fallbackName)
);
c.hasViewed = true;
c.isAgreed = false;
ElMessage.success("签署材料已开始下载,请使用 Word/WPS 打开核对");
} catch (err: any) {
if (!err?.reported) {
ElMessage.error(err?.message || "签署材料下载失败,请稍后重试");
}
} finally {
contractDownloading[key] = false;
}
}
@@ -1773,6 +1911,8 @@ onMounted(() => {
});
onBeforeUnmount(() => {
materialPreviewGeneration++;
revokeMaterialPreviewUrls();
if (linkExpiryTimer !== undefined) {
window.clearInterval(linkExpiryTimer);
}
@@ -0,0 +1,68 @@
<template>
<main class="public-status-page">
<section class="status-card">
<div class="status-mark">!</div>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</section>
</main>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
title?: string;
description?: string;
}>(), {
title: "入职链接无效",
description: "请联系招聘人员重新获取入职链接。"
});
</script>
<style scoped>
.public-status-page {
display: flex;
min-height: 100vh;
padding: 24px;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f0fdf7;
}
.status-card {
width: min(100%, 420px);
padding: 42px 28px;
text-align: center;
background: #ffffff;
border: 1px solid #d1fae5;
border-radius: 16px;
box-shadow: 0 12px 36px rgb(5 150 105 / 10%);
}
.status-mark {
display: flex;
width: 56px;
height: 56px;
margin: 0 auto 20px;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30px;
font-weight: 700;
background: #059669;
border-radius: 50%;
}
h1 {
margin: 0;
color: #064e3b;
font-size: 24px;
}
p {
margin: 14px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.8;
}
</style>