电子签基础功能
This commit is contained in:
Vendored
+2
@@ -29,6 +29,8 @@ declare module 'vue' {
|
|||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
|||||||
@@ -4,8 +4,59 @@ import type { ReqId, ResPage } from "@/api/interface";
|
|||||||
const taskHttp = moduleRequest("/biz/onboarding/task/");
|
const taskHttp = moduleRequest("/biz/onboarding/task/");
|
||||||
const auditHttp = moduleRequest("/biz/onboarding/audit/");
|
const auditHttp = moduleRequest("/biz/onboarding/audit/");
|
||||||
const contractDocumentHttp = moduleRequest("/biz/onboarding/contract-document/");
|
const contractDocumentHttp = moduleRequest("/biz/onboarding/contract-document/");
|
||||||
|
const electronicSignHttp = moduleRequest("/biz/onboarding/electronic-sign/");
|
||||||
const fileHttp = moduleRequest("/sys/dev/file/");
|
const fileHttp = moduleRequest("/sys/dev/file/");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载创建任务前的合同预览,并将统一响应中的 JSON 错误转换成可读异常。
|
||||||
|
*/
|
||||||
|
async function requestContractPreview(
|
||||||
|
action: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
fallbackMessage: string
|
||||||
|
) {
|
||||||
|
let response: any;
|
||||||
|
try {
|
||||||
|
response = await contractDocumentHttp.post<any>(
|
||||||
|
action,
|
||||||
|
params,
|
||||||
|
{ responseType: "blob", loading: false, cancel: false }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorBlob = error?.response?.data;
|
||||||
|
if (errorBlob instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(await errorBlob.text());
|
||||||
|
throw new Error(result?.msg || fallbackMessage);
|
||||||
|
} catch (parseError) {
|
||||||
|
if (!(parseError instanceof SyntaxError)) throw parseError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = response?.data;
|
||||||
|
const contentType = String(
|
||||||
|
response?.headers?.["content-type"] || blob?.type || ""
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
blob instanceof Blob &&
|
||||||
|
contentType.toLowerCase().includes("application/json")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(await blob.text());
|
||||||
|
throw new Error(result?.msg || fallbackMessage);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw new Error(fallbackMessage);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OnboardingTask {
|
export interface OnboardingTask {
|
||||||
id: number;
|
id: number;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
@@ -59,6 +110,62 @@ export interface OnboardingPreparedContract {
|
|||||||
generatedAt?: string;
|
generatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一次电子签流程中的单份合同文件。 */
|
||||||
|
export interface OnboardingElectronicSignDocument {
|
||||||
|
id: number;
|
||||||
|
taskContractId: number;
|
||||||
|
providerDocumentId?: string;
|
||||||
|
documentName: string;
|
||||||
|
documentSort: number;
|
||||||
|
originalDocxFileId?: number;
|
||||||
|
signedPdfFileId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理端查看的入职电子签流程详情。 */
|
||||||
|
export interface OnboardingElectronicSignDetail {
|
||||||
|
taskId: number;
|
||||||
|
taskNo: string;
|
||||||
|
canInitiate: boolean;
|
||||||
|
cannotInitiateReason?: string;
|
||||||
|
canReinitiate: boolean;
|
||||||
|
cannotReinitiateReason?: string;
|
||||||
|
hasProcess: boolean;
|
||||||
|
processId?: number;
|
||||||
|
providerContractId?: string;
|
||||||
|
bizId?: string;
|
||||||
|
subject?: string;
|
||||||
|
signStatus?: string;
|
||||||
|
providerStatus?: string;
|
||||||
|
employeeName?: string;
|
||||||
|
employeeMobile?: string;
|
||||||
|
signUrl?: string;
|
||||||
|
signUrlExpireAt?: string;
|
||||||
|
signUrlValid: boolean;
|
||||||
|
startedAt?: string;
|
||||||
|
sentAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
archivedAt?: string;
|
||||||
|
archiveFileId?: number;
|
||||||
|
lastError?: string;
|
||||||
|
documents: OnboardingElectronicSignDocument[];
|
||||||
|
historyProcesses: OnboardingElectronicSignHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已被新流程替代的电子签历史记录。 */
|
||||||
|
export interface OnboardingElectronicSignHistory {
|
||||||
|
processId: number;
|
||||||
|
bizId: string;
|
||||||
|
providerContractId?: string;
|
||||||
|
signStatus?: string;
|
||||||
|
providerStatus?: string;
|
||||||
|
employeeName?: string;
|
||||||
|
employeeMobile?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
reissuedAt?: string;
|
||||||
|
reissueReason?: string;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OnboardingCompanyOption {
|
export interface OnboardingCompanyOption {
|
||||||
id: number;
|
id: number;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
@@ -282,48 +389,22 @@ export const onboardingTaskApi = {
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 创建任务前生成并下载单份临时 DOCX 合同预览。 */
|
/** 创建任务前生成并下载指定的一份临时 DOCX 合同预览。 */
|
||||||
async previewContractBeforeCreate(params: Record<string, unknown>) {
|
async previewContractBeforeCreate(params: Record<string, unknown>) {
|
||||||
let response: any;
|
return requestContractPreview(
|
||||||
try {
|
"preview-before-create",
|
||||||
response = await contractDocumentHttp.post<any>(
|
params,
|
||||||
"preview-before-create",
|
"合同预览生成失败"
|
||||||
params,
|
|
||||||
{ responseType: "blob", loading: false, cancel: false }
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorBlob = error?.response?.data;
|
|
||||||
if (errorBlob instanceof Blob) {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(await errorBlob.text());
|
|
||||||
throw new Error(result?.msg || "合同预览生成失败");
|
|
||||||
} catch (parseError) {
|
|
||||||
if (!(parseError instanceof SyntaxError)) throw parseError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = response?.data;
|
|
||||||
const contentType = String(
|
|
||||||
response?.headers?.["content-type"] || blob?.type || ""
|
|
||||||
);
|
);
|
||||||
if (
|
},
|
||||||
blob instanceof Blob &&
|
|
||||||
contentType.toLowerCase().includes("application/json")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(await blob.text());
|
|
||||||
throw new Error(result?.msg || "合同预览生成失败");
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SyntaxError) {
|
|
||||||
throw new Error("合同预览生成失败");
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
/** 创建任务前生成全部临时 DOCX 合同预览,并以 ZIP 下载。 */
|
||||||
|
async previewAllContractsBeforeCreate(params: Record<string, unknown>) {
|
||||||
|
return requestContractPreview(
|
||||||
|
"preview-all-before-create",
|
||||||
|
params,
|
||||||
|
"合同预览打包生成失败"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
create(params: Record<string, unknown>) {
|
create(params: Record<string, unknown>) {
|
||||||
@@ -359,6 +440,52 @@ export const onboardingTaskApi = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 查询任务当前电子签流程和全部合同文件。 */
|
||||||
|
electronicSignDetail(taskId: number) {
|
||||||
|
return electronicSignHttp.get<OnboardingElectronicSignDetail>("detail", {
|
||||||
|
id: taskId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 手动发起;失败流程会从已保存的本地断点继续。 */
|
||||||
|
initiateElectronicSign(taskId: number) {
|
||||||
|
return electronicSignHttp.post<OnboardingElectronicSignDetail>("initiate", {
|
||||||
|
taskId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 保留旧流程记录,并以新的业务编号重新发起一次签约。 */
|
||||||
|
reinitiateElectronicSign(taskId: number, reason: string) {
|
||||||
|
return electronicSignHttp.post<OnboardingElectronicSignDetail>("reinitiate", {
|
||||||
|
taskId,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 主动同步签署状态,完成时后端自动执行归档。 */
|
||||||
|
syncElectronicSign(taskId: number) {
|
||||||
|
return electronicSignHttp.post<OnboardingElectronicSignDetail>("sync", {
|
||||||
|
taskId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取有效的员工签署链接,旧链接失效时由后端重新生成。 */
|
||||||
|
getElectronicSignLink(taskId: number) {
|
||||||
|
return electronicSignHttp.post<OnboardingElectronicSignDetail>("get-link", {
|
||||||
|
taskId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 催办当前待签员工。 */
|
||||||
|
noticeElectronicSign(taskId: number) {
|
||||||
|
return electronicSignHttp.post("notice", { taskId });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 作废尚未完成的电子签流程。 */
|
||||||
|
invalidElectronicSign(taskId: number, reason: string) {
|
||||||
|
return electronicSignHttp.post("invalid", { taskId, reason });
|
||||||
|
},
|
||||||
|
|
||||||
archive(params: {
|
archive(params: {
|
||||||
taskId: number;
|
taskId: number;
|
||||||
auditOpinion?: string;
|
auditOpinion?: string;
|
||||||
|
|||||||
@@ -152,38 +152,116 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 2. 归档合同与协议 -->
|
<!-- 2. 员工入职资料与合同文件 -->
|
||||||
<el-tab-pane label="归档签署材料" name="contracts">
|
<el-tab-pane label="员工资料信息" name="materials">
|
||||||
<el-empty v-if="!detail.contracts?.length" description="暂无已归档的签署材料" />
|
<el-card shadow="never" class="section-card">
|
||||||
<div v-else class="contracts-list">
|
<template #header>
|
||||||
<div v-for="contract in detail.contracts" :key="contract.id" class="contract-box">
|
<div class="card-header-row">
|
||||||
<div class="contract-info-left">
|
<div class="card-header-title">入职上传资料</div>
|
||||||
<div class="contract-title font-bold">{{ contract.contractName }}</div>
|
<el-tag type="info" effect="plain">{{ detail.materials?.length || 0 }} 份</el-tag>
|
||||||
<div class="contract-meta-text text-secondary">
|
</div>
|
||||||
<span>状态:{{ contract.contractStatus }}</span>
|
</template>
|
||||||
<span v-if="contract.offlineSignedAt">· 线下签署于:{{ formatTime(contract.offlineSignedAt) }}</span>
|
|
||||||
|
<el-empty v-if="!detail.materials?.length" description="暂无员工上传资料" />
|
||||||
|
<div v-else class="material-grid">
|
||||||
|
<div v-for="item in detail.materials" :key="item.id" class="material-file-card">
|
||||||
|
<div class="material-preview-box">
|
||||||
|
<el-image
|
||||||
|
v-if="isImageMaterial(item) && materialPreviewUrls[item.fileId]"
|
||||||
|
class="material-preview-image"
|
||||||
|
:src="materialPreviewUrls[item.fileId]"
|
||||||
|
:preview-src-list="[materialPreviewUrls[item.fileId]]"
|
||||||
|
fit="cover"
|
||||||
|
preview-teleported
|
||||||
|
hide-on-click-modal
|
||||||
|
/>
|
||||||
|
<div v-else class="material-preview-placeholder">
|
||||||
|
<el-icon :size="34"><Document /></el-icon>
|
||||||
|
<span>{{ materialPreviewText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="material-file-info">
|
||||||
|
<div class="material-name-row">
|
||||||
|
<span class="material-name">{{ item.materialName || "员工资料" }}</span>
|
||||||
|
<el-tag :type="materialReviewTagType(item.reviewStatus)" size="small" effect="plain">
|
||||||
|
{{ materialReviewText(item.reviewStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="material-file-name" :title="item.fileName">{{ materialFileName(item) }}</div>
|
||||||
|
<div class="material-file-meta">
|
||||||
|
<span>{{ item.fileSizeInfo || "大小未知" }}</span>
|
||||||
|
<span>上传于 {{ formatTime(item.uploadedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.reviewRemark" class="material-review-remark">审核备注:{{ item.reviewRemark }}</div>
|
||||||
|
<div class="material-file-actions">
|
||||||
|
<span v-if="isImageMaterial(item)" class="preview-hint">
|
||||||
|
{{ materialPreviewUrls[item.fileId] ? "点击图片可放大预览" : materialPreviewText(item) }}
|
||||||
|
</span>
|
||||||
|
<el-button link type="primary" icon="Download" @click="download(item.fileId, materialFileName(item))"> 下载原件 </el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="contract-action-right">
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="section-card mt-4">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header-row">
|
||||||
|
<div class="card-header-title">合同与签署归档</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="contract.generatedDocxFileId"
|
v-if="detail.electronicSignArchiveFileId"
|
||||||
type="primary"
|
type="success"
|
||||||
plain
|
plain
|
||||||
size="small"
|
size="small"
|
||||||
icon="Download"
|
icon="Download"
|
||||||
@click="
|
@click="download(detail.electronicSignArchiveFileId, electronicSignArchiveName())"
|
||||||
download(
|
|
||||||
contract.generatedDocxFileId,
|
|
||||||
`${detail?.employee?.employeeName || '员工'}_${contract.contractName || '入职合同'}.docx`
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
下载签署材料 DOCX
|
下载完整电子签归档 ZIP
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-tag v-else type="info">尚未生成电子文件</el-tag>
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-if="!detail.contracts?.length && !detail.electronicSignArchiveFileId" description="暂无合同或签署归档文件" />
|
||||||
|
<div v-if="detail.contracts?.length" class="contracts-list">
|
||||||
|
<div v-for="contract in detail.contracts" :key="contract.id" class="contract-box">
|
||||||
|
<div class="contract-info-left">
|
||||||
|
<div class="contract-title font-bold">{{ contract.contractName }}</div>
|
||||||
|
<div class="contract-meta-text text-secondary">
|
||||||
|
<span>状态:{{ contractStatusText(contract.contractStatus) }}</span>
|
||||||
|
<span v-if="contract.offlineSignedAt">· 线下签署于:{{ formatTime(contract.offlineSignedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contract-action-right">
|
||||||
|
<el-button
|
||||||
|
v-if="contract.generatedPdfFileId"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
icon="Download"
|
||||||
|
@click="download(contract.generatedPdfFileId, contractDownloadName(contract, 'pdf'))"
|
||||||
|
>
|
||||||
|
下载签后 PDF
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="contract.generatedDocxFileId"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
icon="Download"
|
||||||
|
@click="
|
||||||
|
download(
|
||||||
|
contract.generatedDocxFileId,
|
||||||
|
`${detail?.employee?.employeeName || '员工'}_${contract.contractName || '入职合同'}.docx`
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
下载生成 DOCX
|
||||||
|
</el-button>
|
||||||
|
<el-tag v-if="!contract.generatedPdfFileId && !contract.generatedDocxFileId" type="info">尚未生成电子文件</el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</el-card>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 3. 入职流转与审核日志 -->
|
<!-- 3. 入职流转与审核日志 -->
|
||||||
@@ -213,8 +291,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="onboardingEmployee">
|
<script setup lang="ts" name="onboardingEmployee">
|
||||||
import { computed, ref, onMounted } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||||
import { UserFilled, Download } from "@element-plus/icons-vue";
|
import { Document, Download, UserFilled } from "@element-plus/icons-vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import {
|
import {
|
||||||
onboardingTaskApi,
|
onboardingTaskApi,
|
||||||
@@ -230,6 +308,18 @@ const detailVisible = ref(false);
|
|||||||
const activeTab = ref("basic");
|
const activeTab = ref("basic");
|
||||||
const detail = ref<any>();
|
const detail = ref<any>();
|
||||||
const employeeInfo = computed<Record<string, any>>(() => detail.value?.employeeInfo || {});
|
const employeeInfo = computed<Record<string, any>>(() => detail.value?.employeeInfo || {});
|
||||||
|
const materialPreviewUrls = reactive<Record<number, string>>({});
|
||||||
|
const materialPreviewLoading = reactive<Record<number, boolean>>({});
|
||||||
|
const materialPreviewFailed = reactive<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
const imageMimeTypes: Record<string, string> = {
|
||||||
|
bmp: "image/bmp",
|
||||||
|
gif: "image/gif",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp"
|
||||||
|
};
|
||||||
|
|
||||||
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
|
||||||
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
|
||||||
@@ -324,6 +414,114 @@ function parseJsonList(value?: string) {
|
|||||||
const educationHistory = computed(() => parseJsonList(employeeInfo.value.educationHistoryJson));
|
const educationHistory = computed(() => parseJsonList(employeeInfo.value.educationHistoryJson));
|
||||||
const workHistory = computed(() => parseJsonList(employeeInfo.value.workHistoryJson));
|
const workHistory = computed(() => parseJsonList(employeeInfo.value.workHistoryJson));
|
||||||
const familyMembers = computed(() => parseJsonList(employeeInfo.value.familyMembersJson));
|
const familyMembers = computed(() => parseJsonList(employeeInfo.value.familyMembersJson));
|
||||||
|
function materialFileSuffix(item: any) {
|
||||||
|
const suffix = String(item?.fileSuffix || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^\./, "")
|
||||||
|
.toLowerCase();
|
||||||
|
if (suffix) return suffix;
|
||||||
|
|
||||||
|
const fileName = String(item?.fileName || "");
|
||||||
|
const dotIndex = fileName.lastIndexOf(".");
|
||||||
|
return dotIndex >= 0 ? fileName.substring(dotIndex + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageMaterial(item: any) {
|
||||||
|
return Boolean(imageMimeTypes[materialFileSuffix(item)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialFileName(item: any) {
|
||||||
|
if (item?.fileName) return String(item.fileName);
|
||||||
|
|
||||||
|
const suffix = materialFileSuffix(item);
|
||||||
|
return "资料文件" + (suffix ? "." + suffix : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialPreviewText(item: any) {
|
||||||
|
if (!isImageMaterial(item)) return "非图片文件";
|
||||||
|
if (materialPreviewLoading[item.fileId]) return "图片加载中";
|
||||||
|
if (materialPreviewFailed[item.fileId]) return "图片预览失败";
|
||||||
|
return "图片待加载";
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialReviewText(status?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: "待复核",
|
||||||
|
passed: "审核合格",
|
||||||
|
need_supplement: "需补充"
|
||||||
|
};
|
||||||
|
return map[status || ""] || status || "未知状态";
|
||||||
|
}
|
||||||
|
|
||||||
|
function materialReviewTagType(status?: string): "success" | "danger" | "warning" | "info" {
|
||||||
|
if (status === "passed") return "success";
|
||||||
|
if (status === "need_supplement") return "danger";
|
||||||
|
if (status === "pending") return "warning";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractStatusText(status?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending_generate: "待生成",
|
||||||
|
pending_confirm: "待员工确认",
|
||||||
|
confirmed: "员工已确认",
|
||||||
|
offline_pending_sign: "待线下签署",
|
||||||
|
electronic_signing: "电子签署中",
|
||||||
|
electronic_signed: "电子签已完成",
|
||||||
|
archived: "已归档"
|
||||||
|
};
|
||||||
|
return map[status || ""] || status || "未知状态";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDownloadName(value: string) {
|
||||||
|
return value.replace(/[\\/:*?"<>|]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractDownloadName(contract: any, extension: "pdf" | "docx") {
|
||||||
|
const employeeName = detail.value?.employee?.employeeName || "员工";
|
||||||
|
const contractName = contract?.contractName || "入职合同";
|
||||||
|
return safeDownloadName(employeeName + "_" + contractName) + "." + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
function electronicSignArchiveName() {
|
||||||
|
const employeeName = detail.value?.employee?.employeeName || "员工";
|
||||||
|
return safeDownloadName(employeeName + "_电子签完整归档") + ".zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMaterialPreviews() {
|
||||||
|
Object.values(materialPreviewUrls).forEach(url => URL.revokeObjectURL(url));
|
||||||
|
Object.keys(materialPreviewUrls).forEach(key => delete materialPreviewUrls[Number(key)]);
|
||||||
|
Object.keys(materialPreviewLoading).forEach(key => delete materialPreviewLoading[Number(key)]);
|
||||||
|
Object.keys(materialPreviewFailed).forEach(key => delete materialPreviewFailed[Number(key)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMaterialPreviews(materials: any[]) {
|
||||||
|
clearMaterialPreviews();
|
||||||
|
const imageMaterials = (materials || []).filter(item => item?.fileId && isImageMaterial(item));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
imageMaterials.map(async item => {
|
||||||
|
materialPreviewLoading[item.fileId] = true;
|
||||||
|
materialPreviewFailed[item.fileId] = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: any = await onboardingTaskApi.downloadFile(item.fileId);
|
||||||
|
const sourceBlob = response?.data;
|
||||||
|
if (!(sourceBlob instanceof Blob)) {
|
||||||
|
throw new Error("服务器未返回有效的图片文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = imageMimeTypes[materialFileSuffix(item)];
|
||||||
|
const imageBlob = sourceBlob.type.startsWith("image/") ? sourceBlob : new Blob([sourceBlob], { type: mimeType });
|
||||||
|
materialPreviewUrls[item.fileId] = URL.createObjectURL(imageBlob);
|
||||||
|
} catch {
|
||||||
|
materialPreviewFailed[item.fileId] = true;
|
||||||
|
} finally {
|
||||||
|
materialPreviewLoading[item.fileId] = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnProps<OnboardingEmployee>[] = [
|
const columns: ColumnProps<OnboardingEmployee>[] = [
|
||||||
{
|
{
|
||||||
@@ -439,9 +637,11 @@ async function showDetail(row: OnboardingEmployee) {
|
|||||||
id: row.id
|
id: row.id
|
||||||
});
|
});
|
||||||
|
|
||||||
detail.value = data;
|
const employeeDetail = data as any;
|
||||||
|
detail.value = employeeDetail;
|
||||||
activeTab.value = "basic";
|
activeTab.value = "basic";
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
|
void loadMaterialPreviews(employeeDetail?.materials || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
||||||
@@ -456,7 +656,7 @@ function resolveDownloadFileName(response: any, fallbackFileName?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackFileName || "入职签署材料.docx";
|
return fallbackFileName || "员工资料文件";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function download(fileId: number, fallbackFileName?: string) {
|
async function download(fileId: number, fallbackFileName?: string) {
|
||||||
@@ -476,7 +676,7 @@ async function download(fileId: number, fallbackFileName?: string) {
|
|||||||
link.remove();
|
link.remove();
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.msg || error?.message || "签署材料文件下载失败");
|
ElMessage.error(error?.msg || error?.message || "文件下载失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +721,7 @@ async function loadSearchOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSearchOptions);
|
onMounted(loadSearchOptions);
|
||||||
|
onBeforeUnmount(clearMaterialPreviews);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -604,6 +805,132 @@ onMounted(loadSearchOptions);
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 员工资料信息 */
|
||||||
|
.card-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-file-card {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-preview-box {
|
||||||
|
flex: 0 0 112px;
|
||||||
|
width: 112px;
|
||||||
|
height: 90px;
|
||||||
|
margin-right: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-preview-placeholder {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-file-info {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-file-name {
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-file-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-review-remark {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-file-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-action-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.material-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 合同列表 */
|
/* 合同列表 */
|
||||||
.contracts-list {
|
.contracts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1976,18 +1976,25 @@ onBeforeUnmount(() => {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract-meta {
|
.contract-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract-title {
|
.contract-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract-desc {
|
.contract-desc {
|
||||||
@@ -2012,10 +2019,32 @@ onBeforeUnmount(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-top: 1px dashed #cbd5e1;
|
border-top: 1px dashed #cbd5e1;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agree-checkbox {
|
.agree-checkbox {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agree-checkbox :deep(.el-checkbox__input) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agree-checkbox :deep(.el-checkbox__label) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-left: 8px;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contract-view-hint {
|
.contract-view-hint {
|
||||||
@@ -2170,5 +2199,14 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
.contract-sign-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.contract-card-bottom > .el-button,
|
||||||
|
.contract-card-bottom > .agree-checkbox {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user