电子签基础功能

This commit is contained in:
fuenmao
2026-09-07 16:18:01 +08:00
parent 7e74fb3b4b
commit 802d9cde89
5 changed files with 1392 additions and 116 deletions
+2
View File
@@ -29,6 +29,8 @@ declare module 'vue' {
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
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']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+166 -39
View File
@@ -4,8 +4,59 @@ import type { ReqId, ResPage } from "@/api/interface";
const taskHttp = moduleRequest("/biz/onboarding/task/");
const auditHttp = moduleRequest("/biz/onboarding/audit/");
const contractDocumentHttp = moduleRequest("/biz/onboarding/contract-document/");
const electronicSignHttp = moduleRequest("/biz/onboarding/electronic-sign/");
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 {
id: number;
taskNo: string;
@@ -59,6 +110,62 @@ export interface OnboardingPreparedContract {
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 {
id: number;
companyName: string;
@@ -282,48 +389,22 @@ export const onboardingTaskApi = {
return response;
},
/** 创建任务前生成并下载单份临时 DOCX 合同预览。 */
/** 创建任务前生成并下载指定的一份临时 DOCX 合同预览。 */
async previewContractBeforeCreate(params: Record<string, unknown>) {
let response: any;
try {
response = await contractDocumentHttp.post<any>(
"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 || ""
return requestContractPreview(
"preview-before-create",
params,
"合同预览生成失败"
);
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>) {
@@ -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: {
taskId: number;
auditOpinion?: string;
+354 -27
View File
@@ -152,38 +152,116 @@
</el-card>
</el-tab-pane>
<!-- 2. 归档合同与协议 -->
<el-tab-pane label="归档签署材料" name="contracts">
<el-empty v-if="!detail.contracts?.length" description="暂无已归档的签署材料" />
<div v-else 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>状态:{{ contract.contractStatus }}</span>
<span v-if="contract.offlineSignedAt">· 线下签署于:{{ formatTime(contract.offlineSignedAt) }}</span>
<!-- 2. 员工入职资料与合同文件 -->
<el-tab-pane label="员工资料信息" name="materials">
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header-row">
<div class="card-header-title">入职上传资料</div>
<el-tag type="info" effect="plain">{{ detail.materials?.length || 0 }} 份</el-tag>
</div>
</template>
<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 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
v-if="contract.generatedDocxFileId"
type="primary"
v-if="detail.electronicSignArchiveFileId"
type="success"
plain
size="small"
icon="Download"
@click="
download(
contract.generatedDocxFileId,
`${detail?.employee?.employeeName || '员工'}_${contract.contractName || '入职合同'}.docx`
)
"
@click="download(detail.electronicSignArchiveFileId, electronicSignArchiveName())"
>
下载签署材料 DOCX
下载完整电子签归档 ZIP
</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>
</el-card>
</el-tab-pane>
<!-- 3. 入职流转与审核日志 -->
@@ -213,8 +291,8 @@
</template>
<script setup lang="ts" name="onboardingEmployee">
import { computed, ref, onMounted } from "vue";
import { UserFilled, Download } from "@element-plus/icons-vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { Document, Download, UserFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import {
onboardingTaskApi,
@@ -230,6 +308,18 @@ const detailVisible = ref(false);
const activeTab = ref("basic");
const detail = ref<any>();
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 searchDepartments = ref<OnboardingDepartmentOption[]>([]);
@@ -324,6 +414,114 @@ function parseJsonList(value?: string) {
const educationHistory = computed(() => parseJsonList(employeeInfo.value.educationHistoryJson));
const workHistory = computed(() => parseJsonList(employeeInfo.value.workHistoryJson));
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>[] = [
{
@@ -439,9 +637,11 @@ async function showDetail(row: OnboardingEmployee) {
id: row.id
});
detail.value = data;
const employeeDetail = data as any;
detail.value = employeeDetail;
activeTab.value = "basic";
detailVisible.value = true;
void loadMaterialPreviews(employeeDetail?.materials || []);
}
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) {
@@ -476,7 +676,7 @@ async function download(fileId: number, fallbackFileName?: string) {
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || "签署材料文件下载失败");
ElMessage.error(error?.msg || error?.message || "文件下载失败");
}
}
@@ -521,6 +721,7 @@ async function loadSearchOptions() {
}
onMounted(loadSearchOptions);
onBeforeUnmount(clearMaterialPreviews);
</script>
<style scoped>
@@ -604,6 +805,132 @@ onMounted(loadSearchOptions);
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 {
display: flex;
File diff suppressed because it is too large Load Diff
+38
View File
@@ -1976,18 +1976,25 @@ onBeforeUnmount(() => {
padding: 18px;
background: #f8fafc;
margin-bottom: 16px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.contract-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
min-width: 0;
}
.contract-title {
font-size: 16px;
color: #0f172a;
min-width: 0;
overflow-wrap: anywhere;
}
.contract-desc {
@@ -2012,10 +2019,32 @@ onBeforeUnmount(() => {
gap: 12px;
border-top: 1px dashed #cbd5e1;
padding-top: 14px;
min-width: 0;
max-width: 100%;
}
.agree-checkbox {
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 {
@@ -2170,5 +2199,14 @@ onBeforeUnmount(() => {
flex-direction: column;
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>