电子签基础功能
This commit is contained in:
@@ -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
Reference in New Issue
Block a user