Files
OnboardXFront/src/views/biz/onboarding/task/index.vue
T
2026-09-14 16:45:50 +08:00

4014 lines
126 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="main-box task-page-box">
<!-- 表格区域 -->
<div class="table-box-flex">
<ProTable
ref="proTable"
title="入职任务列表"
:columns="columns"
:request-api="getTableData"
>
<template #tableHeader>
<div class="task-table-toolbar">
<el-button v-auth="'onboardingTaskCreate'" type="primary" icon="Plus" @click="openCreate">
发起入职任务
</el-button>
<div class="task-metric-filters">
<el-tooltip
v-for="item in metricFilters"
:key="item.status"
:content="activeStatusFilter === item.status ? '再次点击取消筛选' : '点击筛选此状态'"
placement="top"
>
<button
type="button"
class="metric-filter"
:class="[`tone-${item.tone}`, { 'is-active': activeStatusFilter === item.status }]"
:aria-pressed="activeStatusFilter === item.status"
@click="toggleMetricFilter(item.status)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span class="metric-filter-label">{{ item.label }}</span>
<strong class="metric-filter-count">{{ item.count }}</strong>
</button>
</el-tooltip>
</div>
</div>
</template>
<template #companyName="scope">
<div class="org-position-cell">
<div class="company-title">{{ scope.row.companyName || '-' }}</div>
<div class="dept-pos-desc">
<span class="dept-text">{{ scope.row.departmentName || '-' }}</span>
<span class="divider">/</span>
<span class="pos-text">{{ scope.row.positionName || '-' }}</span>
</div>
</div>
</template>
<template #remark="scope">
<span class="remark-text">{{ scope.row.remark || '-' }}</span>
</template>
<template #taskStatus="scope">
<el-tag :type="statusType(scope.row.taskStatus)" effect="light">
{{ statusText(scope.row.taskStatus) }}
</el-tag>
</template>
<template #operation="scope">
<el-button link type="primary" icon="View" @click="showDetail(scope.row)">
详情与审核
</el-button>
<el-button
v-auth="'onboardingTaskVariableEdit'"
v-if="['pending_fill', 'rejected'].includes(scope.row.taskStatus)"
link
type="primary"
icon="Setting"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openContractVariables(scope.row)"
>
签署材料参数
</el-button>
<el-button
v-auth="'onboardingTaskCopy'"
link
type="success"
icon="CopyDocument"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="openCopyTask(scope.row)"
>
复制任务
</el-button>
<el-button
v-auth="'onboardingTaskLink'"
v-if="['pending_fill', 'rejected'].includes(scope.row.taskStatus)"
link
type="warning"
icon="CopyDocument"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="copyOrShareTaskLink(scope.row)"
>
复制链接
</el-button>
<el-button
v-auth="'onboardingTaskAudit'"
v-if="scope.row.taskStatus === 'reviewing'"
link
type="success"
icon="Check"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="approve(scope.row)"
>
通过
</el-button>
<el-button
v-auth="'onboardingTaskAudit'"
v-if="scope.row.taskStatus === 'reviewing'"
link
type="danger"
icon="Close"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="reject(scope.row)"
>
退回
</el-button>
<el-button
v-auth="'onboardingTaskArchive'"
v-if="scope.row.taskStatus === 'approved'"
link
type="info"
icon="FolderChecked"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="archive(scope.row)"
>
归档
</el-button>
<el-button
v-if="['approved', 'archived'].includes(scope.row.taskStatus)"
link
type="primary"
icon="EditPen"
@click="openElectronicSign(scope.row)"
>
电子签约
</el-button>
<el-button
v-auth="'onboardingTaskRevoke'"
v-if="['pending_fill', 'reviewing', 'rejected'].includes(scope.row.taskStatus)"
link
type="danger"
icon="Delete"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="revoke(scope.row)"
>
撤销
</el-button>
<el-button
v-auth="'onboardingTaskDelete'"
v-if="scope.row.taskStatus === 'revoked'"
link
type="danger"
icon="Delete"
:disabled="!canManage(scope.row)"
:title="disabledReason(scope.row)"
@click="removeTask(scope.row)"
>
删除
</el-button>
</template>
</ProTable>
</div>
<!-- 发起任务弹窗 -->
<el-dialog
v-model="createVisible"
title="发起入职任务"
width="1050px"
top="4vh"
destroy-on-close
append-to-body
class="create-task-dialog"
>
<div class="create-task-dialog-body">
<el-form :model="form" label-position="top">
<!-- 1. 基础岗位选择板块 -->
<div class="form-card-section">
<div class="section-title">
<span class="title-text">入职基础信息</span>
<span class="title-tip">请选择新员工入职的公司主体、岗位及适用的入职模板</span>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="8">
<el-form-item label="所属公司" required>
<el-select
v-model="form.companyId"
filterable
placeholder="请选择公司主体"
class="full-width"
@change="onCompanyChange"
>
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="所属部门" required>
<el-select
v-model="form.departmentId"
filterable
placeholder="请选择所属部门"
class="full-width"
:disabled="!form.companyId"
@change="onDepartmentChange"
>
<el-option v-for="item in departments" :key="item.id" :label="item.departmentName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="入职岗位" required>
<el-select
v-model="form.positionId"
filterable
placeholder="请选择岗位"
class="full-width"
:disabled="!form.departmentId"
@change="onPositionChange"
>
<el-option v-for="item in positions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="入职模板" required>
<el-select
v-model="form.onboardingTemplateId"
filterable
placeholder="请选择入职模板"
class="full-width"
:disabled="!form.positionId"
@change="onOnboardingTemplateChange"
>
<el-option v-for="item in onboardingTemplates" :key="item.id" :label="item.templateName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="用工类型" required>
<el-select v-model="form.employmentType" class="full-width">
<el-option label="正式员工" value="full_time" />
<el-option label="兼职员工" value="part_time" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 2. 合同人事参数卡片 -->
<div v-if="form.contractVariables.length" class="form-card-section">
<div class="section-title">
<span class="title-text">签署材料人事参数预设</span>
<span class="title-tip">以下参数将自动替换进 DOCX 签署材料中,请在向员工发送链接前核对填写</span>
</div>
<el-alert
title="提示:此处填写的内容将作为签署材料正式条款,如薪资、协议起止日期、试用期等,请谨慎填写。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<!-- 按材料分组展示人事参数 -->
<div class="contract-vars-group-list">
<div
v-for="group in groupedContractVariables"
:key="group.contractTemplateVersionId"
class="contract-var-group-card"
>
<div class="var-group-header">
<div class="var-group-title">
<el-icon class="var-group-icon"><Document /></el-icon>
<span class="var-group-name">{{ group.contractName }}</span>
</div>
<el-tag size="small" type="primary" effect="plain" class="var-group-count">
{{ group.items.length }} 项人事参数
</el-tag>
</div>
<div class="var-group-content">
<el-row :gutter="20">
<el-col
v-for="item in group.items"
:key="`${item.contractTemplateVersionId}-${item.variableCode}`"
:xs="24"
:sm="12"
>
<el-form-item>
<template #label>
<div class="var-label-row">
<span v-if="item.required" class="required-star">*</span>
<span class="var-name">{{ item.variableName }}</span>
</div>
</template>
<el-date-picker
v-if="item.dataType === 'date'"
v-model="item.variableValue"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="var-input-field"
/>
<el-input-number
v-else-if="item.dataType === 'number' || item.dataType === 'decimal'"
v-model="item.variableValue"
controls-position="right"
class="var-input-field"
placeholder="输入金额或数值"
/>
<el-input
v-else
v-model="item.variableValue"
:placeholder="`请输入${item.variableName}`"
class="var-input-field"
clearable
/>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 3. 合同逐份预览与确认 -->
<div v-if="contractPreviews.length" class="form-card-section">
<div class="section-title contract-preview-section-title">
<div>
<span class="title-text">合同预览核对</span>
<span class="title-tip">
共 {{ contractPreviews.length }} 份,必须逐份下载并确认后才能发起入职
</span>
</div>
<el-button
icon="Download"
:loading="previewingAllContracts"
@click="downloadAllContractPreviews"
>
打包下载全部
</el-button>
</div>
<div class="contract-preview-list">
<div
v-for="(contract, index) in contractPreviews"
:key="contract.contractTemplateVersionId"
class="contract-preview-item"
>
<div class="contract-preview-info">
<span class="contract-preview-order">{{ index + 1 }}</span>
<div>
<div class="contract-preview-name">
{{ contract.contractName }}
<el-tag
v-if="contract.required"
size="small"
type="danger"
effect="plain"
>
必签
</el-tag>
</div>
<div class="contract-preview-state">
<el-tag v-if="contract.confirmed" size="small" type="success">
已核对
</el-tag>
<el-tag v-else-if="contract.downloaded" size="small" type="warning">
已下载,待确认
</el-tag>
<el-tag v-else size="small" type="info">待下载</el-tag>
</div>
</div>
</div>
<div class="contract-preview-actions">
<el-button
link
type="primary"
icon="Download"
:loading="contract.loading"
@click="downloadContractPreview(contract)"
>
{{ contract.downloaded ? "重新下载" : "下载预览" }}
</el-button>
<el-checkbox
v-model="contract.confirmed"
:disabled="!contract.downloaded"
>
我已核对
</el-checkbox>
</div>
</div>
</div>
</div>
<!-- 4. 其他备注 -->
<div class="form-card-section">
<div class="section-title">
<span class="title-text">补充备注</span>
<span class="title-tip">填写该候选人入职的相关流转备注(仅人事可见,必填)</span>
</div>
<el-form-item label="人事备注" required>
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入人事备注说明" />
</el-form-item>
</div>
</el-form>
</div>
<template #footer>
<div class="create-task-footer">
<div class="preview-confirm-area">
<span>合同核对进度</span>
<el-tag v-if="allContractPreviewsConfirmed" type="success">
{{ confirmedContractCount }}/{{ contractPreviews.length }} 已全部核对
</el-tag>
<el-tag v-else type="warning">
{{ confirmedContractCount }}/{{ contractPreviews.length }},请逐份核对
</el-tag>
</div>
<div class="create-task-actions">
<el-button @click="createVisible = false">取消</el-button>
<el-button
icon="Download"
:loading="previewingAllContracts"
:disabled="!contractPreviews.length"
@click="downloadAllContractPreviews"
>
打包下载全部预览
</el-button>
<el-button
type="primary"
:loading="creating"
:disabled="!allContractPreviewsConfirmed"
@click="createTask"
>
立即发起入职
</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- 批量复制任务弹窗 -->
<el-dialog
v-model="copyVisible"
title="复制入职任务"
width="720px"
destroy-on-close
append-to-body
>
<el-alert
title="新任务将继承原任务的公司、部门、岗位、合同版本、材料要求和人事合同参数,不会复制员工资料、上传文件及审核记录。"
type="info"
:closable="false"
show-icon
class="copy-task-alert"
/>
<el-descriptions :column="2" border>
<el-descriptions-item label="原任务编号">
{{ copySourceTask?.taskNo || "-" }}
</el-descriptions-item>
<el-descriptions-item label="所属公司">
{{ copySourceTask?.companyName || "-" }}
</el-descriptions-item>
<el-descriptions-item label="所属部门">
{{ copySourceTask?.departmentName || "-" }}
</el-descriptions-item>
<el-descriptions-item label="入职岗位">
{{ copySourceTask?.positionName || "-" }}
</el-descriptions-item>
<el-descriptions-item label="用工类型">
{{ employmentTypeText(copySourceTask?.employmentType) }}
</el-descriptions-item>
</el-descriptions>
<div class="copy-count-row">
<span class="copy-count-label">复制数量</span>
<el-input-number
v-model="copyForm.count"
:min="1"
:max="50"
:step="1"
step-strictly
/>
<span class="copy-count-tip">
将在原任务之外新增 {{ copyForm.count }} 条独立任务
</span>
</div>
<el-divider content-position="left">逐条填写人事备注</el-divider>
<div class="copy-remark-list">
<el-form label-position="top">
<el-form-item
v-for="(item, index) in copyForm.items"
:key="index"
:label="`第 ${index + 1} 份人事备注`"
required
>
<el-input
v-model="item.remark"
maxlength="200"
show-word-limit
clearable
:placeholder="`请输入第 ${index + 1} 份任务的人事备注,例如员工姓名`"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="copyVisible = false">取消</el-button>
<el-button type="primary" :loading="copying" @click="submitCopyTasks">
确认复制 {{ copyForm.count }} 份
</el-button>
</template>
</el-dialog>
<!-- 任务创建成功与分享弹窗 -->
<el-dialog v-model="shareVisible" title="入职任务已创建" width="560px" destroy-on-close>
<el-result icon="success" title="任务创建成功!" sub-title="请先确认签署材料人事参数,再将专属链接发送给候选人">
<template #extra>
<div class="share-box">
<div class="share-item">
<span class="share-label">任务编号:</span>
<span class="share-value font-bold">{{ createdTaskInfo?.taskNo }}</span>
</div>
<div class="share-item">
<span class="share-label">专属链接:</span>
<el-input :model-value="createdTaskInfo?.linkUrl" readonly size="small">
<template #append>
<el-button icon="CopyDocument" @click="copyText(createdTaskInfo?.linkUrl, '专属链接已复制')">
复制
</el-button>
</template>
</el-input>
</div>
<div class="share-item">
<span class="share-label">有效期至:</span>
<span class="share-value text-secondary">{{ createdTaskInfo?.expiresAt }}</span>
</div>
<div class="share-actions">
<el-button type="primary" plain icon="ChatDotRound" @click="copyInviteMessage">
复制短信/微信邀请文案
</el-button>
</div>
</div>
</template>
</el-result>
<template #footer>
<el-button type="primary" @click="shareVisible = false">完成</el-button>
</template>
</el-dialog>
<!-- 任务详情与审核大抽屉 -->
<el-drawer
v-model="detailVisible"
:title="`入职任务详情 · ${detail?.task?.taskNo || ''}`"
size="1180px"
destroy-on-close
@closed="clearMaterialPreviews"
>
<div v-if="detail" class="drawer-content">
<!-- 头部固定状态栏 (100% 满宽卡片) -->
<div class="drawer-sticky-header">
<div class="drawer-status-banner">
<div class="banner-left">
<div class="banner-title-row">
<span class="task-no-large">{{ detail.task.taskNo }}</span>
<el-tag :type="statusType(detail.task.taskStatus)" size="large" effect="dark">
{{ statusText(detail.task.taskStatus) }}
</el-tag>
<span v-if="detail.task.submitCount > 0" class="submit-count-badge">
第 {{ detail.task.submitCount }} 次提交
</span>
</div>
<div class="banner-meta-row">
<span>所属公司:<strong>{{ detail.task.companyName || '-' }}</strong></span>
<span>所属部门:<strong>{{ detail.task.departmentName || '-' }}</strong></span>
<span>入职岗位:<strong>{{ detail.task.positionName || '-' }}</strong></span>
<span>用工类型:<strong>{{ employmentTypeText(detail.task.employmentType) }}</strong></span>
<span>入职模板:<strong>{{ detail.task.onboardingTemplateName || '-' }}</strong></span>
</div>
</div>
<div class="banner-right">
<div class="meta-item">发起时间:{{ formatTime(detail.task.createTime) }}</div>
<div v-if="detail.task.linkExpireAt" class="meta-item">链接有效期至:{{ formatTime(detail.task.linkExpireAt) }}</div>
</div>
</div>
<!-- 驳回说明 -->
<el-alert
v-if="detail.task.rejectReason && detail.task.taskStatus === 'rejected'"
:title="`最近驳回原因:${detail.task.rejectReason}`"
type="warning"
show-icon
:closable="false"
class="mb-3"
/>
</div>
<!-- Tab 切换 -->
<el-tabs v-model="activeDetailTab" class="detail-tabs">
<!-- 1. 候选人档案与动态信息 -->
<el-tab-pane label="候选人档案 (入职登记表)" name="profile">
<div v-if="!detail.employeeInfo || !detail.employeeInfo.employeeName" class="empty-profile-box">
<el-empty description="候选人尚未填写或提交《入职登记表》" :image-size="100" />
</div>
<div v-else class="profile-details-wrapper">
<!-- 1. 基本个人档案 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><UserFilled /></el-icon> 基本个人信息</span>
<el-tag size="small" type="primary">档案标准项</el-tag>
</div>
</template>
<el-descriptions :column="3" border size="default">
<el-descriptions-item label="真实姓名">
<strong class="text-primary font-bold">{{ detail.employeeInfo.employeeName }}</strong>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ detail.employeeInfo.gender || '-' }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ detail.employeeInfo.nation || '-' }}</el-descriptions-item>
<el-descriptions-item label="出生年月日">{{ detail.employeeInfo.birthday || '-' }}</el-descriptions-item>
<el-descriptions-item label="户籍所在地" :span="2">{{ detail.employeeInfo.registeredResidence || '-' }}</el-descriptions-item>
<el-descriptions-item label="身高 / 体重">
{{ detail.employeeInfo.height ? detail.employeeInfo.height + ' cm' : '-' }} /
{{ detail.employeeInfo.weight ? detail.employeeInfo.weight + ' kg' : '-' }}
</el-descriptions-item>
<el-descriptions-item label="健康状况">{{ detail.employeeInfo.healthCondition || '-' }}</el-descriptions-item>
<el-descriptions-item label="婚育情况">{{ detail.employeeInfo.maritalStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号码" :span="3">
<code class="code-font font-bold">{{ detail.employeeInfo.idCardNumber || '-' }}</code>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 2. 学历与职业资质 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><School /></el-icon> 学历与职业资格</span>
</div>
</template>
<el-descriptions :column="3" border size="default">
<el-descriptions-item label="最高学历">{{ detail.employeeInfo.education || '-' }}</el-descriptions-item>
<el-descriptions-item label="所学专业">{{ detail.employeeInfo.major || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业院校">{{ detail.employeeInfo.graduatedSchool || detail.employeeInfo.college || '-' }}</el-descriptions-item>
<el-descriptions-item label="职业资格/职称">{{ detail.employeeInfo.vocationalQualification || '-' }}</el-descriptions-item>
<el-descriptions-item label="资格等级">{{ detail.employeeInfo.qualificationLevel || '-' }}</el-descriptions-item>
<el-descriptions-item label="特长技能">{{ detail.employeeInfo.technicalSpecialty || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 3. 联系方式与银行卡 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><Phone /></el-icon> 联系方式与工资银行卡</span>
</div>
</template>
<el-descriptions :column="3" border size="default">
<el-descriptions-item label="联系电话">{{ detail.employeeInfo.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱" :span="2">{{ detail.employeeInfo.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="现居住详细地址" :span="2">{{ detail.employeeInfo.currentAddress || detail.employeeInfo.contactAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="房东联系方式">{{ detail.employeeInfo.landlordContact || '-' }}</el-descriptions-item>
<el-descriptions-item label="工资银行卡号" :span="2">
<code class="code-font font-bold">{{ detail.employeeInfo.bankCardNumber || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="开户银行全称">{{ detail.employeeInfo.bankName || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 4. 紧急联系人 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><FirstAidKit /></el-icon> 紧急联系人</span>
</div>
</template>
<el-descriptions :column="3" border size="default">
<el-descriptions-item label="紧急联系人姓名">{{ detail.employeeInfo.emergencyContact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.employeeInfo.emergencyPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系地址">{{ detail.employeeInfo.emergencyAddress || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 5. 教育学习经历 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><Reading /></el-icon> 教育学习经历</span>
</div>
</template>
<el-table :data="detailEducationList" border stripe empty-text="未填写教育学习经历">
<el-table-column prop="timeRange" label="起止时间" min-width="160" />
<el-table-column prop="location" label="学校 / 机构" min-width="200" />
<el-table-column prop="content" label="所学专业 / 学习内容" min-width="200" />
<el-table-column prop="result" label="学习结果 / 获得学位" min-width="180" />
</el-table>
</el-card>
<!-- 6. 主要工作经历 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><Briefcase /></el-icon> 主要工作经历</span>
</div>
</template>
<el-table :data="detailWorkList" border stripe empty-text="未填写工作经历(应届生无记录)">
<el-table-column prop="timeRange" label="起止时间" min-width="160" />
<el-table-column prop="companyName" label="工作单位" min-width="220" />
<el-table-column prop="position" label="担任职务" width="140" />
<el-table-column prop="salary" label="薪资待遇" width="120" />
<el-table-column prop="leaveReason" label="离职原因" min-width="180" />
</el-table>
</el-card>
<!-- 7. 家庭成员 -->
<el-card shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text"><el-icon class="mr-1"><HomeFilled /></el-icon> 家庭成员</span>
</div>
</template>
<el-table :data="detailFamilyList" border stripe empty-text="未填写家庭成员">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="relationship" label="与本人关系" width="120" />
<el-table-column prop="age" label="年龄" width="90" align="center" />
<el-table-column prop="company" label="工作单位及职务" min-width="240" />
<el-table-column prop="phone" label="联系电话" width="150" />
</el-table>
</el-card>
<!-- 8. 合同专有自定义补充项 (如果有) -->
<el-card v-if="detailCustomVars.length" shadow="never" class="mb-3 border-card">
<template #header>
<div class="card-header-flex">
<span class="font-bold card-title-text">签署材料专属补充信息</span>
<el-tag size="small" type="warning">签署材料专有项</el-tag>
</div>
</template>
<el-descriptions :column="2" border size="default">
<el-descriptions-item
v-for="item in detailCustomVars"
:key="item.key"
:label="item.key"
>
{{ item.value || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</el-tab-pane>
<!-- 2. 证件与材料复核画廊 -->
<el-tab-pane label="证件与材料复核" name="materials">
<el-empty v-if="!detail.materials || !detail.materials.length" description="候选人尚未上传任何材料" />
<div v-else class="materials-gallery">
<div
v-for="item in detail.materials"
:key="item.id"
class="material-gallery-item"
:class="{
'border-pass': item.reviewStatus === 'passed',
'border-reject': item.reviewStatus === 'need_supplement'
}"
>
<div class="material-img-wrapper">
<el-image
v-if="isImageMaterial(item) && materialPreviewUrls[item.fileId]"
class="material-thumb"
:src="materialPreviewUrls[item.fileId]"
fit="cover"
@click="previewImage(item.fileId)"
/>
<div v-else class="material-thumb-placeholder">
<el-icon :size="32"><Document /></el-icon>
<span>{{ materialPreviewText(item) }}</span>
</div>
<div class="material-overlay-status">
<el-tag
:type="item.reviewStatus === 'passed' ? 'success' : item.reviewStatus === 'need_supplement' ? 'danger' : 'warning'"
size="small"
>
{{ item.reviewStatus === 'passed' ? '审核合格' : item.reviewStatus === 'need_supplement' ? '需补充' : '待复核' }}
</el-tag>
</div>
</div>
<div class="material-info-box">
<div class="material-name font-bold">{{ item.materialName }}</div>
<div v-if="item.reviewRemark" class="material-reject-tip">
理由:{{ item.reviewRemark }}
</div>
<div class="material-actions-flex">
<el-button
v-if="isImageMaterial(item)"
link
type="primary"
size="small"
icon="View"
:disabled="!materialPreviewUrls[item.fileId]"
@click="previewImage(item.fileId)"
>
图片预览
</el-button>
<el-button link type="primary" size="small" icon="Download" @click="download(item.fileId, item.fileName)">
原件下载
</el-button>
<div v-if="detail.task.taskStatus === 'reviewing'" class="review-btn-group">
<el-button
v-auth="'onboardingTaskMaterialReview'"
size="small"
type="success"
plain
:disabled="item.reviewStatus === 'passed' || !canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="reviewMaterial(item.id, 'passed')"
>
合格
</el-button>
<el-button
v-auth="'onboardingTaskMaterialReview'"
size="small"
type="danger"
plain
:disabled="item.reviewStatus === 'need_supplement' || !canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="reviewMaterial(item.id, 'need_supplement')"
>
需补充
</el-button>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
<!-- 3. 合同管理与归档 -->
<el-tab-pane label="入职签署材料列表" name="contracts">
<el-empty v-if="!detail.contracts || !detail.contracts.length" description="暂无关联签署材料" />
<div v-else class="contracts-panel">
<div v-for="item in detail.contracts" :key="item.id" class="contract-card-item">
<div class="contract-left">
<div class="contract-title font-bold">{{ item.contractName }}</div>
<div class="contract-sub-meta">
<el-tag size="small" type="info">{{ item.contractType || '劳动合同' }}</el-tag>
<span v-if="item.confirmedAt" class="confirmed-text text-success">
✓ 候选人已于 {{ formatTime(item.confirmedAt) }} 确认
</span>
<span v-else class="text-secondary">未确认</span>
</div>
</div>
<div class="contract-right">
<el-button
v-auth="'onboardingTaskContractGenerate'"
v-if="!item.generatedDocxFileId"
type="primary"
plain
size="small"
icon="DocumentChecked"
:disabled="!canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="generate(item.id)"
>
生成DOCX签署材料
</el-button>
<el-button
v-else
type="success"
plain
size="small"
icon="Download"
@click="download(item.generatedDocxFileId)"
>
下载已生成DOCX
</el-button>
</div>
</div>
</div>
</el-tab-pane>
<!-- 4. 审核流转时间轴 -->
<el-tab-pane label="流转记录与操作日志" name="audits">
<el-empty v-if="!detail.audits || !detail.audits.length" description="暂无审核操作记录" />
<div v-else class="timeline-container">
<el-timeline>
<el-timeline-item
v-for="item in detail.audits"
:key="item.id"
:type="auditActionType(item.auditAction)"
:timestamp="formatTime(item.auditedAt)"
placement="top"
>
<div class="audit-item-box">
<div class="audit-action-title font-bold">
{{ auditActionText(item.auditAction) }}
<span v-if="item.createUser" class="audit-user">(操作人:{{ item.createUser }})</span>
</div>
<div v-if="item.auditOpinion" class="audit-opinion-text">
审核意见:{{ item.auditOpinion }}
</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 抽屉底部操作栏 -->
<template #footer>
<div v-if="detail" class="drawer-footer-actions">
<div class="drawer-footer-right">
<el-button
v-auth="'onboardingTaskAudit'"
v-if="detail.task.taskStatus === 'reviewing'"
type="danger"
icon="Close"
:disabled="!canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="reject(detail.task)"
>
退回修改
</el-button>
<el-button
v-auth="'onboardingTaskAudit'"
v-if="detail.task.taskStatus === 'reviewing'"
type="success"
icon="Check"
:disabled="!canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="approve(detail.task)"
>
审核通过并建档
</el-button>
<el-button
v-auth="'onboardingTaskArchive'"
v-if="detail.task.taskStatus === 'approved'"
type="info"
icon="FolderChecked"
:disabled="!canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="archive(detail.task)"
>
归档该任务
</el-button>
<el-button
v-if="['approved', 'archived'].includes(detail.task.taskStatus)"
type="primary"
icon="EditPen"
@click="openElectronicSign(detail.task)"
>
电子签约
</el-button>
<el-button
v-auth="'onboardingTaskDelete'"
v-if="detail.task.taskStatus === 'revoked'"
type="danger"
icon="Delete"
:disabled="!canManage(detail.task)"
:title="disabledReason(detail.task)"
@click="removeTask(detail.task)"
>
删除该任务
</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</div>
</template>
</el-drawer>
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageViewerUrls"
hide-on-click-modal
@close="closeImageViewer"
/>
<!-- 签署材料参数录入弹窗 -->
<el-dialog
v-model="contractVariablesVisible"
:title="`${contractVariableTask?.taskNo || ''} · 签署材料参数录入`"
width="1180px"
top="4vh"
destroy-on-close
>
<div class="contract-vars-dialog-body">
<el-alert
title="说明:此处展示本次入职任务关联的所有签署材料变量。带有【人事变量】标签的项为本次需要 HR 填写的核心条款(如薪资、试用期、协议起止日等);员工变量与公司变量将在流程中自动带入。"
type="info"
show-icon
:closable="false"
style="margin-bottom: 16px;"
/>
<el-empty v-if="!contractVariableGroups.length" description="当前任务签署材料无需配置人事变量" />
<div v-else>
<!-- 顶部合同分组切换 Tab -->
<el-tabs v-model="activeContractGroupTab" type="card" class="contract-group-tabs">
<el-tab-pane
v-for="(group, gIdx) in contractVariableGroups"
:key="group.taskContractId"
:label="group.contractName"
:name="String(gIdx)"
>
<div class="contract-group-pane">
<!-- 变量类型过滤栏 + 单合同保存按钮 -->
<div class="pane-header-bar">
<div class="filter-radio-group">
<span class="filter-label">变量分类筛选:</span>
<el-radio-group v-model="varTypeFilter" size="small">
<el-radio-button label="all">全部 ({{ group.variables.length }})</el-radio-button>
<el-radio-button label="contract">
💼 人事参数 ({{ countVarType(group.variables, 'contract') }})
</el-radio-button>
<el-radio-button label="employee">
👤 员工填写 ({{ countVarType(group.variables, 'employee') }})
</el-radio-button>
<el-radio-button label="company">
🏢 公司变量 ({{ countVarType(group.variables, 'company') }})
</el-radio-button>
<el-radio-button label="system">
⚙️ 系统预置 ({{ countVarType(group.variables, 'system') }})
</el-radio-button>
</el-radio-group>
</div>
<el-button
v-auth="'onboardingTaskVariableEdit'"
type="primary"
size="small"
icon="Check"
:disabled="!canManage(contractVariableTask)"
:title="disabledReason(contractVariableTask)"
@click="saveContractVariables(group)"
>
保存《{{ group.contractName }}》参数
</el-button>
</div>
<!-- 变量表格,限制高度,带上下滚动条 -->
<el-table
:data="getFilteredVariables(group.variables)"
border
stripe
max-height="450px"
class="contract-vars-table"
>
<el-table-column prop="variableCode" label="变量编码" min-width="200">
<template #default="scope">
<code class="code-font">{{ scope.row.variableCode }}</code>
</template>
</el-table-column>
<el-table-column prop="variableName" label="变量名称" min-width="180">
<template #default="scope">
<span class="font-bold text-slate-800">{{ scope.row.variableName }}</span>
</template>
</el-table-column>
<el-table-column prop="variableType" label="类型" width="150" align="center">
<template #default="scope">
<el-tag size="small" :type="variableTypeTag(scope.row.variableType)">
{{ variableTypeText(scope.row.variableType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="必填" width="90" align="center">
<template #default="scope">
<el-tag size="small" :type="scope.row.required ? 'danger' : 'info'" effect="plain">
{{ scope.row.required ? "必填" : "选填" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="参数设定值 (HR录入 / 自动生成)" min-width="280">
<template #default="scope">
<div v-if="scope.row.editable" class="editable-cell">
<el-date-picker
v-if="scope.row.dataType === 'date'"
v-model="scope.row.variableValue"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%;"
/>
<el-input-number
v-else-if="scope.row.dataType === 'number' || scope.row.dataType === 'decimal'"
v-model="scope.row.variableValue"
controls-position="right"
placeholder="输入金额或数值"
style="width: 100%;"
/>
<el-input
v-else
v-model="scope.row.variableValue"
:placeholder="scope.row.required ? '请输入必填人事参数' : '请输入参数设定值'"
clearable
/>
</div>
<div v-else class="readonly-val-cell">
<span v-if="scope.row.variableValue" class="text-primary font-medium">{{ scope.row.variableValue }}</span>
<span v-else class="text-placeholder">系统自动替换</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<template #footer>
<div class="dialog-footer-flex">
<el-button @click="contractVariablesVisible = false">关闭</el-button>
<el-button
v-auth="'onboardingTaskVariableEdit'"
type="primary"
icon="Check"
:loading="savingAllVars"
:disabled="!canManage(contractVariableTask)"
:title="disabledReason(contractVariableTask)"
@click="saveAllContractVariables"
>
一键保存全部签署材料参数
</el-button>
</div>
</template>
</el-dialog>
<!-- 审核通过后的契约锁电子签流程管理 -->
<el-dialog
v-model="electronicSignVisible"
:title="`${electronicSignTask?.taskNo || ''} · 电子签约`"
width="760px"
top="5vh"
destroy-on-close
>
<div v-loading="electronicSignLoading" class="electronic-sign-dialog">
<template v-if="electronicSignDetail">
<el-alert
v-if="electronicSignDetail.lastError"
:title="electronicSignDetail.lastError"
type="error"
show-icon
:closable="false"
class="mb-3"
/>
<el-alert
v-if="!electronicSignDetail.hasProcess"
title="审核已通过,可将下列全部合同作为同一个签约流程发起。员工签完后,人事逐份核对,再由后台确认企业盖章。"
type="info"
show-icon
:closable="false"
class="mb-3"
/>
<el-alert
v-if="electronicSignDetail.canReinitiate"
title="当前签约流程已经结束,可以保留原记录并重新发起一条全新的签约流程。"
type="warning"
show-icon
:closable="false"
class="mb-3"
/>
<el-steps
:active="electronicSignStep"
finish-status="success"
align-center
class="electronic-sign-steps"
>
<el-step title="审核建档" />
<el-step title="员工签署" />
<el-step title="企业盖章" />
<el-step title="签后归档" />
</el-steps>
<el-descriptions
v-if="electronicSignDetail.hasProcess"
:column="2"
border
class="electronic-sign-summary"
>
<el-descriptions-item label="当前状态">
<el-tag :type="electronicSignStatusType(electronicSignDetail.signStatus)">
{{ electronicSignStatusText(electronicSignDetail.signStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="员工">
{{ electronicSignDetail.employeeName || '-' }}
<span class="text-secondary">{{ electronicSignDetail.employeeMobile || '' }}</span>
</el-descriptions-item>
<el-descriptions-item label="契约锁流程ID">
<code>{{ electronicSignDetail.providerContractId || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="业务编号">
<code>{{ electronicSignDetail.bizId || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="发起时间">
{{ formatTime(electronicSignDetail.sentAt || electronicSignDetail.startedAt) }}
</el-descriptions-item>
<el-descriptions-item
v-if="electronicSignDetail.completedAt"
label="签署完成"
>
{{ formatTime(electronicSignDetail.completedAt) }}
</el-descriptions-item>
<el-descriptions-item
v-if="electronicSignDetail.archivedAt"
label="归档完成"
>
{{ formatTime(electronicSignDetail.archivedAt) }}
</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="electronicSignDetail.signStatus === 'pending_company_seal'"
title="员工已完成签署,请逐份查看下方员工已签文件。全部核对后,才能确认企业盖章。"
type="warning"
show-icon
:closable="false"
class="electronic-sign-seal-alert"
/>
<div class="electronic-sign-section-title">
本次签约文件({{ electronicSignDetail.documents.length }} 份)
</div>
<div v-if="electronicSignDetail.documents.length" class="electronic-sign-documents">
<div
v-for="documentItem in electronicSignDetail.documents"
:key="documentItem.taskContractId"
class="electronic-sign-document"
>
<div class="electronic-sign-document-main">
<el-icon><Document /></el-icon>
<div>
<div class="font-bold">
{{ documentItem.documentSort }}. {{ documentItem.documentName }}
</div>
<div class="text-secondary document-state-text">
{{ documentItem.providerDocumentId ? '已上传契约锁' : '发起时自动生成并上传' }}
</div>
</div>
</div>
<div class="electronic-sign-document-actions">
<el-tag
v-if="
electronicSignDetail.signStatus === 'pending_company_seal'
&& documentItem.employeeSignedPreviewed
"
type="success"
size="small"
effect="plain"
>
已查看
</el-tag>
<el-button
v-if="
electronicSignDetail.signStatus === 'pending_company_seal'
&& documentItem.providerDocumentId
"
link
type="warning"
:loading="electronicSignPreviewingDocumentId === documentItem.id"
@click="previewEmployeeSignedDocument(documentItem)"
>
{{ documentItem.employeeSignedPreviewed ? '再次查看员工已签文件' : '查看员工已签文件' }}
</el-button>
<el-button
v-if="documentItem.originalDocxFileId"
link
type="primary"
@click="download(documentItem.originalDocxFileId, `${documentItem.documentName}.docx`)"
>
原始DOCX
</el-button>
<el-button
v-if="documentItem.signedPdfFileId"
link
type="success"
@click="download(documentItem.signedPdfFileId, `${documentItem.documentName}_已签署.pdf`)"
>
已签PDF
</el-button>
</div>
</div>
</div>
<el-empty v-else description="当前任务没有合同文件" :image-size="80" />
<div
v-if="electronicSignDetail.signStatus === 'pending_employee_sign'"
class="electronic-sign-link-card"
>
<div>
<div class="font-bold">员工签署链接</div>
<div class="text-secondary">
{{ electronicSignDetail.signUrlValid
? `有效期至 ${formatTime(electronicSignDetail.signUrlExpireAt)}`
: '链接尚未生成或已过期,点击下方按钮自动刷新' }}
</div>
</div>
<el-button
v-auth="'onboardingTaskElectronicSign'"
type="primary"
plain
:disabled="!canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="copyElectronicSignLink"
>
获取并复制链接
</el-button>
</div>
<el-collapse
v-if="electronicSignDetail.historyProcesses?.length"
class="electronic-sign-history"
>
<el-collapse-item
:title="`历史签约记录(${electronicSignDetail.historyProcesses.length})`"
name="history"
>
<el-table :data="electronicSignDetail.historyProcesses" size="small" border>
<el-table-column label="发起时间" width="155">
<template #default="scope">
{{ formatTime(scope.row.startedAt) }}
</template>
</el-table-column>
<el-table-column label="员工" min-width="130">
<template #default="scope">
{{ scope.row.employeeName || '-' }}
<div class="text-secondary">{{ scope.row.employeeMobile || '' }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="electronicSignStatusType(scope.row.signStatus)" size="small">
{{ electronicSignStatusText(scope.row.signStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="契约锁流程ID" min-width="165">
<template #default="scope">
<code>{{ scope.row.providerContractId || '-' }}</code>
</template>
</el-table-column>
<el-table-column label="重新发起原因/失败原因" min-width="200">
<template #default="scope">
{{ scope.row.reissueReason || scope.row.lastError || '-' }}
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</template>
</div>
<template #footer>
<div class="electronic-sign-footer">
<div>
<el-button
v-auth="'onboardingTaskElectronicSign'"
v-if="electronicSignDetail?.hasProcess && canInvalidElectronicSign"
type="danger"
plain
:disabled="electronicSignLoading || !canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="invalidElectronicSign"
>
作废流程
</el-button>
</div>
<div>
<el-button @click="electronicSignVisible = false">关闭</el-button>
<el-button
v-auth="'onboardingTaskElectronicSign'"
v-if="electronicSignDetail?.hasProcess && electronicSignDetail.signStatus === 'pending_employee_sign'"
:disabled="electronicSignLoading || !canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="noticeElectronicSign"
>
催签
</el-button>
<el-button
v-auth="'onboardingTaskElectronicSign'"
v-if="electronicSignDetail?.hasProcess && electronicSignDetail.providerContractId"
:loading="electronicSignLoading"
:disabled="!canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="syncElectronicSign"
>
同步状态
</el-button>
<el-button
v-auth="'onboardingTaskElectronicSign'"
v-if="
electronicSignDetail?.hasProcess
&& electronicSignDetail.signStatus === 'pending_company_seal'
"
type="primary"
:loading="electronicSignLoading"
:disabled="!allEmployeeSignedDocumentsPreviewed || !canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="confirmCompanySeal"
>
确认企业盖章
</el-button>
<el-button
v-if="electronicSignDetail?.archiveFileId"
type="success"
@click="download(electronicSignDetail.archiveFileId, `${electronicSignDetail.taskNo}_电子签完整归档.zip`)"
>
下载完整归档
</el-button>
<el-button
v-auth="'onboardingTaskElectronicSign'"
v-if="electronicSignDetail?.canReinitiate"
type="warning"
plain
:loading="electronicSignLoading"
:disabled="!electronicSignDetail.documents.length || !canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="reinitiateElectronicSign"
>
重新发起签约
</el-button>
<el-button
v-auth="'onboardingTaskElectronicSign'"
v-if="electronicSignDetail?.canInitiate"
type="primary"
:loading="electronicSignLoading"
:disabled="!electronicSignDetail.documents.length || !canManage(electronicSignTask)"
:title="disabledReason(electronicSignTask)"
@click="initiateElectronicSign"
>
{{ electronicSignDetail.hasProcess ? '继续/重试发起' : '确认发起电子签' }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="onboardingTask">
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
DocumentAdd,
Clock,
WarningFilled,
CircleCheckFilled,
Plus,
View,
Setting,
Check,
Close,
FolderChecked,
Delete,
CopyDocument,
ChatDotRound,
Document,
DocumentChecked,
Download
} from "@element-plus/icons-vue";
import {
onboardingTaskApi,
type OnboardingCompanyOption,
type OnboardingDepartmentOption,
type OnboardingPositionOption,
type OnboardingTemplate,
type OnboardingContractTemplate,
type OnboardingTask,
type OnboardingTaskContractVariableGroup,
type OnboardingElectronicSignDetail,
type OnboardingElectronicSignDocument
} from "@/api/modules/biz/onboarding";
import type { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
import { getMessageBoxPromptResult, isMessageBoxConfirmed } from "@/utils/messageBox";
import { useOnboardingPermission } from "@/utils/onboardingPermission";
import { copyToClipboard } from "@/utils";
const { canManage, disabledReason } = useOnboardingPermission("onboardingTaskManageAll");
const proTable = ref<ProTableInstance>();
const companies = ref<OnboardingCompanyOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]);
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
const searchPositions = ref<OnboardingPositionOption[]>([]);
const allSearchDepartments = ref<OnboardingDepartmentOption[]>([]);
const allSearchPositions = ref<OnboardingPositionOption[]>([]);
const companySearchOptions = computed(() =>
searchCompanies.value.map(c => ({
label: c.companyName,
value: c.id
}))
);
const departmentSearchOptions = computed(() =>
searchDepartments.value.map(d => ({
label: d.departmentName,
value: d.id
}))
);
const positionSearchOptions = computed(() =>
searchPositions.value.map(p => ({
label: p.positionName,
value: p.id
}))
);
async function onSearchCompanyChange(companyId?: number) {
if (companyId) {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
]);
searchDepartments.value = depRes.data || [];
searchPositions.value = posRes.data || [];
} else {
searchDepartments.value = allSearchDepartments.value;
searchPositions.value = allSearchPositions.value;
}
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (proTable.value?.searchParam) {
const currentDept = proTable.value.searchParam.departmentId;
if (currentDept && !searchDepartments.value.some(d => d.id === currentDept)) {
proTable.value.searchParam.departmentId = undefined;
}
const currentPos = proTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
proTable.value.searchParam.positionId = undefined;
}
}
}
async function onSearchDepartmentChange(departmentId?: number) {
if (departmentId) {
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
const selectedCompanyId = proTable.value?.searchParam?.companyId;
if (selectedCompanyId) {
const posRes = await onboardingTaskApi.listPositions(selectedCompanyId).catch(() => ({ data: [] }));
searchPositions.value = posRes.data || [];
} else {
searchPositions.value = allSearchPositions.value;
}
}
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
if (proTable.value?.searchParam) {
const currentPos = proTable.value.searchParam.positionId;
if (currentPos && !searchPositions.value.some(p => p.id === currentPos)) {
proTable.value.searchParam.positionId = undefined;
}
}
}
const onboardingTemplates = ref<OnboardingTemplate[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
const createVisible = ref(false);
const creating = ref(false);
const previewingAllContracts = ref(false);
interface ContractPreviewState {
contractTemplateId: number;
contractTemplateVersionId: number;
contractName: string;
required: boolean;
sortCode: number;
downloaded: boolean;
confirmed: boolean;
loading: boolean;
}
const contractPreviews = ref<ContractPreviewState[]>([]);
const confirmedContractCount = computed(
() => contractPreviews.value.filter(item => item.confirmed).length
);
const allContractPreviewsConfirmed = computed(
() =>
contractPreviews.value.length > 0 &&
contractPreviews.value.every(item => item.downloaded && item.confirmed)
);
const shareVisible = ref(false);
const createdTaskInfo = ref<any>(null);
const copyVisible = ref(false);
const copying = ref(false);
const copySourceTask = ref<OnboardingTask>();
const copyForm = reactive<{
count: number;
items: Array<{ remark: string }>;
}>({
count: 1,
items: [{ remark: "" }]
});
watch(
() => copyForm.count,
value => resizeCopyRemarkItems(value)
);
const detailVisible = ref(false);
const activeDetailTab = ref("profile");
const detail = ref<any>();
const materialPreviewUrls = reactive<Record<number, string>>({});
const materialPreviewLoading = reactive<Record<number, boolean>>({});
const materialPreviewFailed = reactive<Record<number, boolean>>({});
const imageViewerVisible = ref(false);
const imageViewerUrls = ref<string[]>([]);
const imageMimeTypes: Record<string, string> = {
bmp: "image/bmp",
gif: "image/gif",
jpeg: "image/jpeg",
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp"
};
const detailEducationList = computed(() => {
try {
return JSON.parse(detail.value?.employeeInfo?.educationHistoryJson || "[]");
} catch {
return [];
}
});
const detailWorkList = computed(() => {
try {
return JSON.parse(detail.value?.employeeInfo?.workHistoryJson || "[]");
} catch {
return [];
}
});
const detailFamilyList = computed(() => {
try {
return JSON.parse(detail.value?.employeeInfo?.familyMembersJson || "[]");
} catch {
return [];
}
});
const detailCustomVars = computed(() => {
try {
const obj = JSON.parse(detail.value?.employeeInfo?.customVariablesJson || "{}");
return Object.entries(obj).map(([k, v]) => ({ key: k, value: String(v) }));
} catch {
return [];
}
});
const contractVariablesVisible = ref(false);
const contractVariableTask = ref<OnboardingTask>();
const contractVariableGroups = ref<OnboardingTaskContractVariableGroup[]>([]);
const activeContractGroupTab = ref("0");
const varTypeFilter = ref("all");
const savingAllVars = ref(false);
const electronicSignVisible = ref(false);
const electronicSignLoading = ref(false);
const electronicSignPreviewingDocumentId = ref<number>();
const electronicSignTask = ref<OnboardingTask>();
const electronicSignDetail = ref<OnboardingElectronicSignDetail>();
const electronicSignStep = computed(() => {
const status = electronicSignDetail.value?.signStatus;
if (status === "archived") return 4;
if (status === "completed") return 3;
if (status === "pending_company_seal") return 2;
return 1;
});
const canInvalidElectronicSign = computed(() => {
const current = electronicSignDetail.value;
if (!current?.providerContractId) return false;
return !["completed", "archived", "rejected", "invalid", "expired"].includes(
current.signStatus || ""
);
});
const allEmployeeSignedDocumentsPreviewed = computed(() => {
const current = electronicSignDetail.value;
return Boolean(
current?.documents.length
&& current.documents.every(item => item.employeeSignedPreviewed)
);
});
function countVarType(variables: any[], type: string) {
if (!variables) return 0;
return variables.filter(v => v.variableType === type).length;
}
function getFilteredVariables(variables: any[]) {
if (!variables) return [];
if (varTypeFilter.value === "all") return variables;
return variables.filter(v => v.variableType === varTypeFilter.value);
}
// 统计数量
const statusCounts = reactive({
pending_fill: 0,
reviewing: 0,
rejected: 0,
approved: 0
});
const activeStatusFilter = ref("");
const metricFilters = computed(() => [
{
status: "pending_fill",
label: "待候选人填写",
count: statusCounts.pending_fill,
icon: DocumentAdd,
tone: "blue"
},
{
status: "reviewing",
label: "待人事审核",
count: statusCounts.reviewing,
icon: Clock,
tone: "orange"
},
{
status: "rejected",
label: "已退回待修改",
count: statusCounts.rejected,
icon: WarningFilled,
tone: "red"
},
{
status: "approved_or_archived",
label: "已通过 / 已归档",
count: statusCounts.approved,
icon: CircleCheckFilled,
tone: "green"
}
]);
/** 点击统计项筛选任务,再次点击同一项取消筛选。 */
function toggleMetricFilter(status: string) {
const nextStatus = activeStatusFilter.value === status ? "" : status;
activeStatusFilter.value = nextStatus;
if (proTable.value?.searchParam) {
proTable.value.searchParam.taskStatus = nextStatus || undefined;
}
proTable.value?.search();
}
const form = reactive({
companyId: undefined as number | undefined,
departmentId: undefined as number | undefined,
positionId: undefined as number | undefined,
onboardingTemplateId: undefined as number | undefined,
employmentType: "full_time" as "full_time" | "part_time",
contractVariables: [] as Array<{
contractTemplateVersionId: number;
variableCode: string;
variableName: string;
variableValue?: string | number;
dataType: string;
required: boolean;
contractName: string;
}>,
remark: ""
});
const groupedContractVariables = computed(() => {
const groups: Array<{
contractTemplateVersionId: number;
contractName: string;
items: typeof form.contractVariables;
}> = [];
form.contractVariables.forEach(item => {
let group = groups.find(g => g.contractTemplateVersionId === item.contractTemplateVersionId);
if (!group) {
group = {
contractTemplateVersionId: item.contractTemplateVersionId,
contractName: item.contractName || "签署材料",
items: []
};
groups.push(group);
}
group.items.push(item);
});
return groups;
});
watch(
() => [
form.companyId,
form.departmentId,
form.positionId,
form.onboardingTemplateId,
...form.contractVariables.map(
item =>
`${item.contractTemplateVersionId}|${item.variableCode}|${String(
item.variableValue ?? ""
)}`
)
],
() => resetContractPreviewState()
);
const columns: ColumnProps<OnboardingTask>[] = [
{
prop: "taskNo",
label: "任务编号",
minWidth: 180,
search: { el: "input" }
},
{
prop: "companyId",
label: "所属公司",
isShow: false,
enum: companySearchOptions,
search: {
el: "select",
props: {
filterable: true,
clearable: true,
placeholder: "请选择所属公司",
onChange: (val?: number) => onSearchCompanyChange(val)
}
}
},
{
prop: "departmentId",
label: "所属部门",
isShow: false,
enum: departmentSearchOptions,
search: {
el: "select",
props: {
filterable: true,
clearable: true,
placeholder: "请选择所属部门",
onChange: (val?: number) => onSearchDepartmentChange(val)
}
}
},
{
prop: "positionId",
label: "入职岗位",
isShow: false,
enum: positionSearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择入职岗位" }
}
},
{
prop: "companyName",
label: "所属公司 / 部门 / 岗位",
minWidth: 220
},
{
prop: "employmentType",
label: "用工类型",
minWidth: 100,
align: "center",
render: scope => employmentTypeText(scope.row.employmentType)
},
{
prop: "taskStatus",
label: "当前状态",
minWidth: 130,
align: "center",
enum: [
{ label: "待填写", value: "pending_fill" },
{ label: "审核中", value: "reviewing" },
{ label: "已退回", value: "rejected" },
{ label: "已通过 / 已归档", value: "approved_or_archived" },
{ label: "已通过", value: "approved" },
{ label: "已归档", value: "archived" }
],
search: {
el: "select",
props: { placeholder: "请选择状态" }
}
},
{
prop: "submitCount",
label: "提交次数",
minWidth: 95,
align: "center"
},
{
prop: "remark",
label: "入职人事备注",
minWidth: 160
},
{
prop: "linkExpireAt",
label: "链接到期时间",
minWidth: 170,
render: scope => formatTime(scope.row.linkExpireAt)
},
{
prop: "createTime",
label: "创建时间",
minWidth: 170,
render: scope => formatTime(scope.row.createTime)
},
{
prop: "operation",
label: "操作",
fixed: "right",
width: 430
}
];
const texts: Record<string, string> = {
pending_fill: "待候选人填写",
reviewing: "待人事审核",
rejected: "已退回修改",
approved: "已通过 / 待归档",
archived: "已归档",
revoked: "已撤销"
};
const statusText = (status: string) => texts[status] || status;
type EpTagType = "success" | "warning" | "info" | "primary" | "danger";
const statusType = (status: string): EpTagType =>
status === "approved"
? "success"
: status === "rejected"
? "danger"
: status === "reviewing"
? "warning"
: status === "archived"
? "info"
: "primary";
const electronicSignStatusText = (status?: string) => {
const map: Record<string, string> = {
creating: "正在创建",
draft: "草稿待发送",
pending_employee_sign: "待员工签署",
pending_company_seal: "员工已签 / 待企业盖章",
completed: "签署完成 / 正在归档",
archived: "签署完成 / 已归档",
rejected: "员工拒签",
invalid: "已作废",
expired: "已过期",
failed: "发起或同步失败"
};
return status ? map[status] || status : "未发起";
};
const electronicSignStatusType = (status?: string): EpTagType => {
if (status === "archived" || status === "completed") return "success";
if (status === "pending_employee_sign" || status === "pending_company_seal") return "warning";
if (status === "failed" || status === "rejected" || status === "invalid") return "danger";
return "info";
};
const variableTypeTag = (type: string): EpTagType => {
const map: Record<string, EpTagType> = {
employee: "primary",
company: "success",
contract: "warning",
system: "info"
};
return map[type] || "info";
};
const variableTypeText = (type: string) => {
const map: Record<string, string> = {
employee: "员工变量",
company: "公司变量",
contract: "人事预设参数",
system: "系统变量"
};
return map[type] || type;
};
const auditActionType = (action: string): EpTagType => {
const map: Record<string, EpTagType> = {
submit: "primary",
approve: "success",
reject: "danger",
archive: "info",
revoke: "danger"
};
return map[action] || "primary";
};
const auditActionText = (action: string) => {
const map: Record<string, string> = {
submit: "候选人提交入职资料",
approve: "人事审核通过并创建员工档案",
reject: "人事驳回退回材料",
archive: "人事完成归档",
revoke: "人事撤销任务"
};
return map[action] || action;
};
const formatTime = (timeStr?: string) => {
if (!timeStr) return "-";
return timeStr.replace("T", " ").substring(0, 19);
};
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 materialPreviewText(item: any) {
if (!isImageMaterial(item)) return "非图片文件";
if (materialPreviewLoading[item.fileId]) return "图片加载中";
if (materialPreviewFailed[item.fileId]) return "图片预览加载失败";
return "图片待加载";
}
function closeImageViewer() {
imageViewerVisible.value = false;
imageViewerUrls.value = [];
}
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)]);
closeImageViewer();
}
function previewImage(fileId: number) {
const previewUrl = materialPreviewUrls[fileId];
if (!previewUrl) {
ElMessage.warning("图片正在加载或暂时无法预览");
return;
}
imageViewerUrls.value = [previewUrl];
imageViewerVisible.value = true;
}
async function loadMaterialPreviews(materials: any[]) {
clearMaterialPreviews();
const imageMaterials = (materials || []).filter(isImageMaterial);
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;
}
}));
}
function resolveDownloadFileName(response: any, fallbackFileName?: string) {
const disposition = String(response?.headers?.["content-disposition"] || "");
const encodedName = disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1]
|| disposition.match(/filename="?([^";]+)"?/i)?.[1];
if (encodedName) {
try {
return decodeURIComponent(encodedName.replace(/\+/g, "%20"));
} catch {
return encodedName;
}
}
return fallbackFileName || "入职材料";
}
// 列表查询并统计
async function getTableData(params: any) {
const res = await onboardingTaskApi.page(params);
activeStatusFilter.value = String(params.taskStatus || "");
const statistics = res.data?.statistics;
if (statistics) {
statusCounts.pending_fill = statistics.pendingFill;
statusCounts.reviewing = statistics.reviewing;
statusCounts.rejected = statistics.rejected;
statusCounts.approved = statistics.approved;
}
return res;
}
async function loadCompanies() {
try {
const [companyResult, contractResult] = await Promise.all([
onboardingTaskApi.listCompanies().catch(() => ({ data: [] })),
onboardingTaskApi.templatePage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } }))
]);
companies.value = companyResult.data || [];
searchCompanies.value = companyResult.data || [];
contractTemplates.value = contractResult.data?.list || [];
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
if (companies.value && companies.value.length > 0) {
const promises = companies.value.map(async comp => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
}
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
const finalDepts = Array.from(uniqueDeptMap.values());
allSearchDepartments.value = finalDepts;
searchDepartments.value = finalDepts;
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
const finalPositions = Array.from(uniquePosMap.values());
allSearchPositions.value = finalPositions;
searchPositions.value = finalPositions;
proTable.value?.enumMap.set("companyId", companySearchOptions.value);
proTable.value?.enumMap.set("departmentId", departmentSearchOptions.value);
proTable.value?.enumMap.set("positionId", positionSearchOptions.value);
} catch (e) {
console.error("加载公司与搜索选项失败", e);
}
}
async function onCompanyChange(companyId: number) {
resetContractPreviewState(true);
form.departmentId = undefined;
form.positionId = undefined;
form.onboardingTemplateId = undefined;
form.contractVariables = [];
positions.value = [];
onboardingTemplates.value = [];
departments.value = [];
if (!companyId) return;
const { data } = await onboardingTaskApi.listDepartments(companyId);
departments.value = data || [];
}
async function onDepartmentChange(departmentId: number) {
resetContractPreviewState(true);
form.positionId = undefined;
form.onboardingTemplateId = undefined;
form.contractVariables = [];
positions.value = [];
onboardingTemplates.value = [];
if (!departmentId) return;
const { data } = await onboardingTaskApi.listPositionsByDepartment(departmentId);
positions.value = data || [];
}
async function onPositionChange(positionId: number) {
resetContractPreviewState(true);
form.onboardingTemplateId = undefined;
form.contractVariables = [];
onboardingTemplates.value = [];
if (!form.companyId || !form.departmentId || !positionId) return;
const { data } = await onboardingTaskApi.listEnabledOnboardingTemplates(
form.companyId,
form.departmentId,
positionId
);
onboardingTemplates.value = data || [];
if (onboardingTemplates.value.length === 1) {
form.onboardingTemplateId = onboardingTemplates.value[0].id;
await onOnboardingTemplateChange(form.onboardingTemplateId!);
}
}
async function onOnboardingTemplateChange(templateId: number) {
resetContractPreviewState(true);
form.contractVariables = [];
if (!templateId) return;
const { data: template } = await onboardingTaskApi.onboardingTemplateDetail({ id: templateId });
const contracts = [...(template.contracts || [])].sort((left, right) => {
const sortResult = (left.sortCode ?? 100) - (right.sortCode ?? 100);
return sortResult || left.contractTemplateVersionId - right.contractTemplateVersionId;
});
const variableResults = await Promise.all(
contracts.map(contract => onboardingTaskApi.variablePage({
templateVersionId: contract.contractTemplateVersionId,
pageNum: 1,
pageSize: 100
}))
);
form.contractVariables = contracts.flatMap((contract, index) => {
const contractName = contractTemplates.value.find(item => item.id === contract.contractTemplateId)?.templateName || "合同";
return (variableResults[index].data?.list || [])
.filter(variable => variable.hrEditable && ["contract", "company"].includes(variable.variableType))
.sort((left, right) => {
const sortResult = (left.sortCode ?? 100) - (right.sortCode ?? 100);
return sortResult || left.variableCode.localeCompare(right.variableCode);
})
.map(variable => ({
contractTemplateVersionId: contract.contractTemplateVersionId,
variableCode: variable.variableCode,
variableName: variable.variableName,
variableValue: variable.defaultValue,
dataType: variable.dataType,
required: variable.required,
contractName
}));
});
contractPreviews.value = contracts.map((contract, index) => ({
contractTemplateId: contract.contractTemplateId,
contractTemplateVersionId: contract.contractTemplateVersionId,
contractName:
contractTemplates.value.find(item => item.id === contract.contractTemplateId)
?.templateName || `合同${index + 1}`,
required: contract.required,
sortCode: contract.sortCode ?? 100,
downloaded: false,
confirmed: false,
loading: false
}));
}
function resizeCopyRemarkItems(value?: number) {
const normalizedCount = Math.min(
50,
Math.max(1, Math.trunc(Number(value) || 1))
);
if (copyForm.count !== normalizedCount) {
copyForm.count = normalizedCount;
return;
}
while (copyForm.items.length < normalizedCount) {
copyForm.items.push({ remark: "" });
}
if (copyForm.items.length > normalizedCount) {
copyForm.items.splice(normalizedCount);
}
}
function openCopyTask(row: OnboardingTask) {
copySourceTask.value = row;
copyForm.count = 1;
copyForm.items.splice(0, copyForm.items.length, { remark: "" });
copyVisible.value = true;
}
async function submitCopyTasks() {
if (!copySourceTask.value) return;
const missingRemarkIndex = copyForm.items.findIndex(
item => !item.remark.trim()
);
if (missingRemarkIndex >= 0) {
return ElMessage.warning(
`请填写第 ${missingRemarkIndex + 1} 份任务的人事备注`
);
}
copying.value = true;
try {
const { data: result } = await onboardingTaskApi.copy({
sourceTaskId: copySourceTask.value.id,
count: copyForm.count,
items: copyForm.items.map(item => ({
remark: item.remark.trim()
}))
});
copyVisible.value = false;
proTable.value?.refresh();
ElMessage.success(`已成功复制 ${result.tasks.length} 条入职任务`);
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || "复制入职任务失败");
} finally {
copying.value = false;
}
}
function openCreate() {
resetContractPreviewState(true);
Object.assign(form, {
companyId: undefined,
departmentId: undefined,
positionId: undefined,
onboardingTemplateId: undefined,
employmentType: "full_time",
contractVariables: [],
remark: ""
});
departments.value = [];
positions.value = [];
onboardingTemplates.value = [];
createVisible.value = true;
}
function validateContractPreviewForm() {
if (!form.companyId || !form.departmentId || !form.positionId || !form.onboardingTemplateId) {
ElMessage.warning("请选择所属公司、所属部门、入职岗位和入职模板");
return false;
}
const missingVariable = form.contractVariables.find(
item => item.required && isBlankContractVariableValue(item.variableValue)
);
if (missingVariable) {
ElMessage.warning(`请填写签署材料人事参数“${missingVariable.variableName}”`);
return false;
}
return true;
}
function buildTaskCreatePayload() {
return {
companyId: form.companyId,
departmentId: form.departmentId,
positionId: form.positionId,
onboardingTemplateId: form.onboardingTemplateId,
employmentType: form.employmentType,
remark: form.remark.trim(),
contractVariables: form.contractVariables.map(item => ({
contractTemplateVersionId: item.contractTemplateVersionId,
variableCode: item.variableCode,
variableValue: normalizeContractVariableValue(item.variableValue)
}))
};
}
function resetContractPreviewState(clearContracts = false) {
if (clearContracts) {
contractPreviews.value = [];
return;
}
contractPreviews.value.forEach(item => {
item.downloaded = false;
item.confirmed = false;
});
}
function triggerPreviewDownload(response: any, fallbackFileName: string) {
const blob = response?.data;
if (!(blob instanceof Blob)) {
throw new Error("服务器未返回有效的合同预览文件");
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = resolveDownloadFileName(response, fallbackFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
async function downloadContractPreview(contract: ContractPreviewState) {
if (!validateContractPreviewForm()) return;
contract.loading = true;
try {
const response: any = await onboardingTaskApi.previewContractBeforeCreate(
{
...buildTaskCreatePayload(),
contractTemplateVersionId: contract.contractTemplateVersionId
}
);
triggerPreviewDownload(response, `${contract.contractName}_人事预览.docx`);
contract.downloaded = true;
contract.confirmed = false;
ElMessage.success(
`“${contract.contractName}”预览已下载,请使用 Word/WPS 核对后确认`
);
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || "合同预览生成失败");
} finally {
contract.loading = false;
}
}
async function downloadAllContractPreviews() {
if (!validateContractPreviewForm()) return;
if (!contractPreviews.value.length) {
return ElMessage.warning("当前入职模板未配置合同");
}
previewingAllContracts.value = true;
try {
const response: any =
await onboardingTaskApi.previewAllContractsBeforeCreate(
buildTaskCreatePayload()
);
triggerPreviewDownload(response, "全部合同_人事预览.zip");
contractPreviews.value.forEach(item => {
item.downloaded = true;
item.confirmed = false;
});
ElMessage.success(
`已打包下载 ${contractPreviews.value.length} 份合同,请逐份核对并确认`
);
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || "合同预览打包生成失败");
} finally {
previewingAllContracts.value = false;
}
}
async function createTask() {
if (!validateContractPreviewForm()) return;
if (!form.remark.trim()) {
return ElMessage.warning("请填写人事备注");
}
if (!allContractPreviewsConfirmed.value) {
return ElMessage.warning("请先下载并逐份核对全部合同预览");
}
creating.value = true;
try {
const payload = buildTaskCreatePayload();
const { data: result }: any = await onboardingTaskApi.create(payload);
createVisible.value = false;
proTable.value?.refresh();
const linkUrl = `${location.origin}/#/onboarding/fill?token=${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 || "";
createdTaskInfo.value = {
taskNo: result.taskNo || "已生成",
linkUrl,
expiresAt: result.expiresAt,
companyName: selectedCompany,
positionName: selectedPosition
};
shareVisible.value = true;
} catch (err: any) {
ElMessage.error(err?.message || "创建任务失败");
} finally {
creating.value = false;
}
}
async function copyText(text?: string, successMsg = "复制成功") {
if (!text) return;
const ok = await copyToClipboard(text);
if (ok) {
ElMessage.success(successMsg);
} else {
ElMessage.warning("自动复制失败,请在弹窗中长按或手动复制");
}
}
function copyInviteMessage() {
if (!createdTaskInfo.value) return;
const msg = `【入职邀请】您好!欢迎加入${createdTaskInfo.value.companyName},担任${createdTaskInfo.value.positionName}一职。请点击以下链接完成您的入职登记与签署材料确认:${createdTaskInfo.value.linkUrl} (链接有效期至:${createdTaskInfo.value.expiresAt})`;
copyText(msg, "邀请文案已复制至剪贴板");
}
async function copyOrShareTaskLink(row: OnboardingTask) {
try {
const { data }: any = await onboardingTaskApi.getTaskLink({ id: row.id });
const linkUrl = `${location.origin}/#/onboarding/fill?token=${data.linkToken}`;
createdTaskInfo.value = {
taskNo: row.taskNo,
linkUrl,
expiresAt: formatTime(data.expiresAt),
companyName: row.companyName || "公司",
positionName: row.positionName || "岗位"
};
// get-link 可能已经为过期链接自动续期,立即同步列表中的展示时间。
row.linkExpireAt = data.expiresAt;
proTable.value?.refresh();
await copyText(linkUrl, "专属入职链接已复制到剪贴板!");
shareVisible.value = true;
} catch (err: any) {
ElMessage.error(err?.message || "获取专属链接失败");
}
}
async function showDetail(row: OnboardingTask, preserveTab: boolean = false) {
const currentTab = activeDetailTab.value;
const { data }: any = await onboardingTaskApi.detail({ id: row.id });
detail.value = data;
if (!preserveTab) {
activeDetailTab.value = "profile";
} else {
activeDetailTab.value = currentTab || "profile";
}
detailVisible.value = true;
void loadMaterialPreviews(data?.materials || []);
}
async function openContractVariables(row: OnboardingTask) {
const { data } = await onboardingTaskApi.taskContractVariables(row.id);
contractVariableTask.value = row;
contractVariableGroups.value = data || [];
activeContractGroupTab.value = "0";
varTypeFilter.value = "all";
contractVariablesVisible.value = true;
}
async function saveContractVariables(group: OnboardingTaskContractVariableGroup) {
const requiredBlank = group.variables.some(
item => item.editable
&& item.required
&& isBlankContractVariableValue(item.variableValue)
);
if (requiredBlank) {
return ElMessage.warning("请填写所有必填签署材料人事参数");
}
await onboardingTaskApi.saveTaskContractVariables({
taskContractId: group.taskContractId,
values: buildContractVariableSaveValues(group)
});
ElMessage.success("《" + group.contractName + "》参数已保存");
}
async function saveAllContractVariables() {
if (!contractVariableGroups.value.length) return;
for (const group of contractVariableGroups.value) {
const requiredBlank = group.variables.some(
item => item.editable
&& item.required
&& isBlankContractVariableValue(item.variableValue)
);
if (requiredBlank) {
return ElMessage.warning(`请填写《${group.contractName}》中的所有必填人事参数`);
}
}
savingAllVars.value = true;
try {
for (const group of contractVariableGroups.value) {
await onboardingTaskApi.saveTaskContractVariables({
taskContractId: group.taskContractId,
values: buildContractVariableSaveValues(group)
});
}
ElMessage.success("全部签署材料参数已保存成功!");
contractVariablesVisible.value = false;
} finally {
savingAllVars.value = false;
}
}
/**
* 合同数值控件会返回 number,文本和日期控件返回 string;统一判断可避免数字调用 trim。
* 使用空值合并判断而不是逻辑或,确保合法数值 0 不会被当成未填写。
*/
function isBlankContractVariableValue(value: unknown) {
return value == null || String(value).trim() === "";
}
/** 后端合同变量快照使用字符串存储,请求前统一规范化控件返回值。 */
function normalizeContractVariableValue(value: unknown) {
return value == null ? "" : String(value).trim();
}
function buildContractVariableSaveValues(group: OnboardingTaskContractVariableGroup) {
return group.variables
.filter(item => item.editable)
.map(item => ({
id: item.id,
variableValue: normalizeContractVariableValue(item.variableValue)
}));
}
async function approve(row: OnboardingTask) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(
"确认审核通过?系统将自动创建员工档案并锁定签署材料快照。",
"审核通过确认",
{ type: "success", confirmButtonText: "审核通过", cancelButtonText: "取消" }
)
);
if (!confirmed) return;
await onboardingTaskApi.approve({ taskId: row.id });
ElMessage.success("审核通过,已成功建档!");
detailVisible.value = false;
proTable.value?.refresh();
}
async function reject(row: OnboardingTask) {
const result = await getMessageBoxPromptResult(
ElMessageBox.prompt(
"请输入退回补充说明(将展示给候选人查看):",
"退回入职任务",
{ inputPattern: /\S+/, inputErrorMessage: "退回原因不能为空", confirmButtonText: "确认退回", type: "warning" }
)
);
if (!result) return;
const { value } = result;
await onboardingTaskApi.reject({ taskId: row.id, auditOpinion: value });
ElMessage.warning("已退回修改,候选人可重新补录");
detailVisible.value = false;
proTable.value?.refresh();
}
async function generate(taskContractId: number) {
await onboardingTaskApi.generateContract(taskContractId);
ElMessage.success("DOCX签署材料文件已生成");
if (detail.value?.task?.id) {
await showDetail(detail.value.task, true);
}
}
async function download(fileId: number, fallbackFileName?: string) {
try {
const response: any = await onboardingTaskApi.downloadFile(fileId);
const blob = response?.data;
if (!(blob instanceof Blob)) {
throw new Error("服务器未返回有效的文件");
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = resolveDownloadFileName(response, fallbackFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || "文件下载失败");
}
}
onBeforeUnmount(clearMaterialPreviews);
async function reviewMaterial(taskMaterialId: number, reviewStatus: string) {
let reviewRemark = "";
if (reviewStatus === "need_supplement") {
const result = await getMessageBoxPromptResult(
ElMessageBox.prompt("请输入该材料退回补充说明:", "材料不合格退回", {
inputPattern: /\S+/,
inputErrorMessage: "请填写说明"
})
);
if (!result) return;
reviewRemark = result.value;
}
await onboardingTaskApi.reviewMaterial({ taskMaterialId, reviewStatus, reviewRemark });
ElMessage.success(reviewStatus === "passed" ? "该材料已标记为合格" : "已标记需补充");
if (detail.value?.task) {
await showDetail(detail.value.task, true);
}
}
async function openElectronicSign(row: OnboardingTask) {
electronicSignTask.value = row;
electronicSignDetail.value = undefined;
electronicSignVisible.value = true;
await loadElectronicSignDetail();
}
async function loadElectronicSignDetail() {
if (!electronicSignTask.value) return;
electronicSignLoading.value = true;
try {
const { data } = await onboardingTaskApi.electronicSignDetail(
electronicSignTask.value.id
);
electronicSignDetail.value = data;
} finally {
electronicSignLoading.value = false;
}
}
async function initiateElectronicSign() {
const current = electronicSignDetail.value;
const task = electronicSignTask.value;
if (!current || !task) return;
const documentCount = current.documents.length;
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(
`将把 ${documentCount} 份合同放入同一个契约锁签约流程。员工签完后,需要人事逐份核对并在后台确认企业盖章。确认现在发起吗?`,
current.hasProcess ? "继续电子签流程" : "发起电子签确认",
{
type: "warning",
confirmButtonText: current.hasProcess ? "继续发起" : "确认发起",
cancelButtonText: "取消"
}
)
);
if (!confirmed) return;
electronicSignLoading.value = true;
try {
const { data } = await onboardingTaskApi.initiateElectronicSign(task.id);
electronicSignDetail.value = data;
ElMessage.success(`电子签已发起,共包含 ${data.documents.length} 份合同`);
} catch (error) {
// 后端会保存断点和具体失败原因,重新读取后展示,管理员可安全继续。
await loadElectronicSignDetail().catch(() => undefined);
throw error;
} finally {
electronicSignLoading.value = false;
}
}
async function syncElectronicSign() {
const task = electronicSignTask.value;
if (!task) return;
electronicSignLoading.value = true;
try {
const { data } = await onboardingTaskApi.syncElectronicSign(task.id);
electronicSignDetail.value = data;
ElMessage.success(`状态已同步:${electronicSignStatusText(data.signStatus)}`);
} catch (error) {
await loadElectronicSignDetail().catch(() => undefined);
throw error;
} finally {
electronicSignLoading.value = false;
}
}
function employmentTypeText(value?: string) {
return value === "part_time" ? "兼职员工" : "正式员工";
}
async function previewEmployeeSignedDocument(
documentItem: OnboardingElectronicSignDocument
) {
const task = electronicSignTask.value;
if (!task || !documentItem.id) return;
// 必须在用户点击时先打开空白页,否则等待接口返回后再打开可能被浏览器拦截。
const previewWindow = window.open("", "_blank");
if (!previewWindow) {
ElMessage.warning("浏览器拦截了预览窗口,请允许本站打开新窗口后重试");
return;
}
previewWindow.opener = null;
try {
previewWindow.document.title = "员工已签合同加载中";
previewWindow.document.body.innerText = "正在打开契约锁员工已签文件,请稍候……";
} catch {
// 空白页提示失败不影响后续跳转。
}
electronicSignPreviewingDocumentId.value = documentItem.id;
try {
const { data } = await onboardingTaskApi.previewEmployeeSignedDocument(
task.id,
documentItem.id
);
if (!data.previewUrl) throw new Error("契约锁没有返回合同浏览地址");
documentItem.employeeSignedPreviewed = true;
previewWindow.location.replace(data.previewUrl);
} catch (error) {
previewWindow.close();
throw error;
} finally {
electronicSignPreviewingDocumentId.value = undefined;
}
}
async function confirmCompanySeal() {
const task = electronicSignTask.value;
const current = electronicSignDetail.value;
if (!task || current?.signStatus !== "pending_company_seal") return;
if (!allEmployeeSignedDocumentsPreviewed.value) {
ElMessage.warning("请先逐份查看所有员工已签文件");
return;
}
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(
`已逐份核对 ${current.documents.length} 份员工已签文件。确认使用当前公司的印章执行企业盖章吗?盖章后合同内容不能修改。`,
"确认企业盖章",
{
type: "warning",
confirmButtonText: "确认盖章",
cancelButtonText: "取消"
}
)
);
if (!confirmed) return;
electronicSignLoading.value = true;
try {
const { data } = await onboardingTaskApi.companySealElectronicSign(task.id);
electronicSignDetail.value = data;
if (["completed", "archived"].includes(data.signStatus || "")) {
ElMessage.success("企业盖章已完成");
} else {
ElMessage.success("企业盖章请求已提交,系统正在同步签署结果");
}
} catch (error) {
await loadElectronicSignDetail().catch(() => undefined);
throw error;
} finally {
electronicSignLoading.value = false;
}
}
async function reinitiateElectronicSign() {
const task = electronicSignTask.value;
const current = electronicSignDetail.value;
if (!task || !current?.canReinitiate) return;
const result = await getMessageBoxPromptResult(
ElMessageBox.prompt(
"系统会保留当前流程,并使用新的业务编号创建一条全新的契约锁签约流程。这可能产生新的签约费用,请填写重新发起原因:",
"重新发起电子签",
{
type: "warning",
inputPattern: /\S+/,
inputErrorMessage: "重新发起原因不能为空",
confirmButtonText: "确认重新发起",
cancelButtonText: "取消"
}
)
);
if (!result) return;
electronicSignLoading.value = true;
try {
const { data } = await onboardingTaskApi.reinitiateElectronicSign(
task.id,
result.value.trim()
);
electronicSignDetail.value = data;
ElMessage.success(`新的电子签流程已发起,共包含 ${data.documents.length} 份合同`);
} catch (error) {
await loadElectronicSignDetail().catch(() => undefined);
throw error;
} finally {
electronicSignLoading.value = false;
}
}
async function copyElectronicSignLink() {
const task = electronicSignTask.value;
if (!task) return;
electronicSignLoading.value = true;
try {
const { data } = await onboardingTaskApi.getElectronicSignLink(task.id);
electronicSignDetail.value = data;
if (!data.signUrl) {
ElMessage.warning("契约锁没有返回员工签署链接");
return;
}
copyText(data.signUrl, "员工签署链接已复制到剪贴板");
} finally {
electronicSignLoading.value = false;
}
}
async function noticeElectronicSign() {
const task = electronicSignTask.value;
if (!task) return;
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(
"确认由契约锁向当前待签员工发送一次催签通知吗?",
"催签确认",
{ type: "info", confirmButtonText: "确认催签", cancelButtonText: "取消" }
)
);
if (!confirmed) return;
electronicSignLoading.value = true;
try {
await onboardingTaskApi.noticeElectronicSign(task.id);
ElMessage.success("催签通知已发送");
} finally {
electronicSignLoading.value = false;
}
}
async function invalidElectronicSign() {
const task = electronicSignTask.value;
if (!task) return;
const result = await getMessageBoxPromptResult(
ElMessageBox.prompt(
"作废后员工不能继续签署,请填写作废原因:",
"作废电子签流程",
{
type: "error",
inputPattern: /\S+/,
inputErrorMessage: "作废原因不能为空",
confirmButtonText: "确认作废",
cancelButtonText: "取消"
}
)
);
if (!result) return;
electronicSignLoading.value = true;
try {
await onboardingTaskApi.invalidElectronicSign(task.id, result.value);
ElMessage.success("电子签流程已作废");
await loadElectronicSignDetail();
} finally {
electronicSignLoading.value = false;
}
}
async function archive(row: OnboardingTask) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm("确认归档该入职任务?", "归档任务", { type: "info" })
);
if (!confirmed) return;
await onboardingTaskApi.archive({ taskId: row.id });
ElMessage.success("任务已归档");
detailVisible.value = false;
proTable.value?.refresh();
}
async function revoke(row: OnboardingTask) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(
"撤销后候选人填写链接将立即失效,确定继续撤销吗?",
"撤销任务",
{ type: "error", confirmButtonText: "确定撤销", cancelButtonText: "取消" }
)
);
if (!confirmed) return;
await onboardingTaskApi.revoke({ taskId: row.id });
ElMessage.success("任务已撤销");
detailVisible.value = false;
proTable.value?.refresh();
}
async function removeTask(row: OnboardingTask) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm(
`确认永久删除已撤销的入职任务【${row.taskNo}】吗?此操作将清理该任务全部数据,且不可恢复。`,
"删除任务确认",
{ type: "error", confirmButtonText: "确定删除", cancelButtonText: "取消" }
)
);
if (!confirmed) return;
await onboardingTaskApi.deleteTask(row.id);
ElMessage.success("入职任务已删除");
detailVisible.value = false;
proTable.value?.refresh();
}
onMounted(loadCompanies);
</script>
<style scoped>
.task-page-box {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
width: 100% !important;
box-sizing: border-box;
padding: 0 !important;
background: transparent !important;
overflow: hidden;
}
.task-table-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
.task-metric-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.metric-filter {
--metric-color: #64748b;
--metric-bg: #f8fafc;
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
color: var(--el-text-color-regular);
font: inherit;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid var(--el-border-color);
border-radius: 6px;
transition: all 0.18s ease;
}
.metric-filter:hover {
color: var(--metric-color);
border-color: var(--metric-color);
transform: translateY(-1px);
}
.metric-filter.is-active {
color: var(--metric-color);
background: var(--metric-bg);
border-color: var(--metric-color);
box-shadow: inset 0 0 0 1px var(--metric-color);
}
.metric-filter.tone-blue {
--metric-color: #2563eb;
--metric-bg: #eff6ff;
}
.metric-filter.tone-orange {
--metric-color: #d97706;
--metric-bg: #fffbeb;
}
.metric-filter.tone-red {
--metric-color: #dc2626;
--metric-bg: #fef2f2;
}
.metric-filter.tone-green {
--metric-color: #059669;
--metric-bg: #ecfdf5;
}
.metric-filter .el-icon {
color: var(--metric-color);
font-size: 15px;
}
.metric-filter-label {
line-height: 1;
}
.metric-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
color: var(--metric-color);
font-size: 12px;
line-height: 20px;
background: var(--metric-bg);
border-radius: 10px;
}
.metric-filter.is-active .metric-filter-count {
color: #fff;
background: var(--metric-color);
}
.table-box-flex :deep(.table-header) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.table-box-flex :deep(.header-button-lf) {
flex: 1;
float: none;
min-width: 0;
}
.table-box-flex :deep(.header-button-ri) {
flex-shrink: 0;
float: none;
}
.table-box-flex {
flex: 1;
height: 0;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.table-box-flex :deep(.table-search) {
margin-bottom: 12px !important;
border-radius: 8px;
}
.table-box-flex :deep(.table-main) {
border-radius: 8px;
}
.full-width {
width: 100%;
}
/* 分享弹窗 */
.share-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.share-link-row,
.share-text-box {
margin-top: 8px;
}
.share-text-area {
margin-bottom: 8px;
}
.share-copy-btn {
width: 100%;
}
/* 详情抽屉 */
.drawer-container {
padding: 0 4px;
}
.drawer-header-summary {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.header-left-info {
display: flex;
align-items: center;
gap: 12px;
}
.task-no-large {
font-size: 18px;
font-weight: 700;
color: #1e293b;
}
.header-meta-items {
display: flex;
gap: 16px;
font-size: 13px;
color: #64748b;
}
.detail-tabs {
margin-top: 8px;
}
.border-card {
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.card-header-flex {
display: flex;
justify-content: space-between;
align-items: center;
}
.font-bold {
font-weight: 600;
}
.text-primary {
color: #2563eb;
}
.text-secondary {
color: #64748b;
}
.materials-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.material-gallery-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
background: #f8fafc;
display: flex;
flex-direction: column;
gap: 8px;
}
.material-img-wrapper {
position: relative;
width: 100%;
height: 150px;
overflow: hidden;
background: #e2e8f0;
border-radius: 6px;
}
.material-thumb {
width: 100%;
height: 100%;
cursor: zoom-in;
}
.material-thumb-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
gap: 8px;
color: #64748b;
}
.material-overlay-status {
position: absolute;
top: 8px;
right: 8px;
}
.material-info-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.material-actions-flex,
.review-btn-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.border-pass {
border-color: #10b981;
background: #f0fdf4;
}
.border-reject {
border-color: #ef4444;
background: #fef2f2;
}
.gallery-preview-box {
width: 100%;
height: 140px;
background: #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.gallery-thumb-image {
width: 100%;
height: 100%;
}
.file-type-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #64748b;
}
.gallery-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.gallery-title-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.material-title {
font-size: 14px;
}
.material-reject-reason {
font-size: 12px;
color: #dc2626;
background: #fee2e2;
padding: 4px 8px;
border-radius: 4px;
}
.gallery-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
.contracts-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.contract-audit-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.variables-edit-area {
margin-top: 10px;
}
.drawer-footer-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
/* 发起任务大弹窗样式 */
.create-task-dialog-body {
max-height: 74vh;
overflow-y: auto;
padding-right: 6px;
}
.form-card-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 18px;
margin-bottom: 16px;
}
.section-title {
margin-bottom: 14px;
}
.title-text {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-right: 10px;
}
.title-tip {
font-size: 12px;
color: #64748b;
}
.contract-preview-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.contract-preview-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.contract-preview-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
background: #fff;
border: 1px solid #dbe4f0;
border-radius: 8px;
}
.contract-preview-info,
.contract-preview-actions {
display: flex;
align-items: center;
gap: 12px;
}
.contract-preview-order {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
color: #2563eb;
font-weight: 700;
background: #eff6ff;
border-radius: 50%;
}
.contract-preview-name {
display: flex;
align-items: center;
gap: 8px;
color: #1e293b;
font-weight: 600;
}
.contract-preview-state {
margin-top: 5px;
}
.contract-vars-group-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.contract-var-group-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
background-color: #f8fafc;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.contract-var-group-card:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.var-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e2e8f0;
}
.var-group-title {
display: flex;
align-items: center;
gap: 8px;
}
.var-group-icon {
font-size: 16px;
color: #3b82f6;
}
.var-group-name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.var-group-count {
font-weight: 500;
border-radius: 4px;
}
.var-group-content {
padding: 16px 16px 0 16px;
background-color: #ffffff;
}
.var-group-content :deep(.el-form-item) {
margin-bottom: 16px;
}
.var-label-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.contract-source-tag {
font-weight: normal;
border-radius: 4px;
}
.required-star {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
line-height: 1;
}
.var-input-field {
width: 100% !important;
}
:deep(.var-input-field.el-date-editor) {
width: 100% !important;
}
:deep(.var-input-field.el-input-number) {
width: 100% !important;
}
:deep(.var-input-field.el-input-number .el-input__inner) {
text-align: left;
}
/* 详情抽屉顶部横幅与档案卡片样式 (头部固定 + 内容区滚动) */
:deep(.el-drawer__body) {
padding: 0 !important;
overflow: hidden !important;
display: flex;
flex-direction: column;
}
.drawer-content {
flex: 1;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 20px 24px 0;
}
.drawer-sticky-header {
flex-shrink: 0;
background: #ffffff;
z-index: 10;
}
.detail-tabs {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
:deep(.detail-tabs > .el-tabs__header) {
flex-shrink: 0;
margin-bottom: 16px;
background: #fff;
z-index: 9;
}
:deep(.detail-tabs > .el-tabs__content) {
flex: 1;
height: 0;
overflow-y: auto;
padding-right: 6px;
padding-bottom: 20px;
}
.drawer-status-banner {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 14px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.banner-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.banner-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.task-no-large {
font-size: 18px;
font-weight: 800;
color: #0f172a;
}
.submit-count-badge {
font-size: 12px;
color: #2563eb;
background: #eff6ff;
border: 1px solid #bfdbfe;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.banner-meta-row {
display: flex;
gap: 20px;
font-size: 13px;
color: #64748b;
flex-wrap: wrap;
}
.banner-right {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
font-size: 13px;
color: #64748b;
}
.card-title-text {
font-size: 14px;
color: #1e293b;
display: inline-flex;
align-items: center;
}
.mr-1 {
margin-right: 4px;
}
.code-font {
font-family: Consolas, Monaco, monospace;
background: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
color: #2563eb;
}
/* 签署材料参数录入弹窗优化 */
.contract-vars-dialog-body {
max-height: 68vh;
overflow-y: auto;
padding-right: 6px;
}
.contract-group-tabs {
margin-top: 4px;
}
.contract-group-pane {
display: flex;
flex-direction: column;
gap: 12px;
}
.pane-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
}
.filter-radio-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 13px;
font-weight: 600;
color: #475569;
}
.contract-vars-table {
width: 100%;
}
:deep(.contract-vars-table .el-input__inner) {
text-align: center !important;
}
:deep(.contract-vars-table .el-date-editor .el-input__inner) {
text-align: center !important;
}
:deep(.contract-vars-table .el-input-number .el-input__inner) {
text-align: center !important;
}
.text-slate-800 {
color: #1e293b;
}
.readonly-val-cell {
color: #64748b;
font-size: 13px;
text-align: center;
}
.text-placeholder {
color: #94a3b8;
font-style: italic;
text-align: center;
}
.create-task-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.preview-confirm-area,
.create-task-actions {
display: flex;
align-items: center;
gap: 10px;
}
.preview-status-tip {
color: #909399;
font-size: 12px;
}
@media (max-width: 760px) {
.contract-preview-section-title,
.contract-preview-item {
align-items: stretch;
flex-direction: column;
}
.contract-preview-actions {
justify-content: space-between;
}
.create-task-footer {
flex-direction: column;
align-items: stretch;
}
.preview-confirm-area,
.create-task-actions {
flex-wrap: wrap;
}
.create-task-actions {
justify-content: flex-end;
}
}
.dialog-footer-flex {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.org-position-cell {
line-height: 1.45;
padding: 2px 0;
}
.org-position-cell .company-title {
font-weight: 600;
color: var(--el-text-color-primary);
font-size: 13px;
}
.org-position-cell .dept-pos-desc {
font-size: 12px;
margin-top: 2px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.org-position-cell .dept-text {
color: var(--el-text-color-secondary);
}
.org-position-cell .divider {
color: var(--el-border-color-darker);
font-size: 11px;
}
.org-position-cell .pos-text {
color: var(--el-color-primary);
font-weight: 500;
}
.remark-text {
color: var(--el-text-color-regular);
font-size: 13px;
}
.electronic-sign-history {
margin-top: 16px;
}
.electronic-sign-history :deep(.el-collapse-item__header) {
font-weight: 600;
}
.copy-task-alert {
margin-bottom: 16px;
}
.copy-count-row {
display: flex;
align-items: center;
gap: 14px;
margin-top: 18px;
padding: 14px 16px;
border-radius: 6px;
background: var(--el-fill-color-light);
}
.copy-count-label {
flex-shrink: 0;
font-weight: 600;
}
.copy-count-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.copy-remark-list {
max-height: 360px;
padding-right: 8px;
overflow-y: auto;
}
.electronic-sign-dialog {
min-height: 260px;
}
.electronic-sign-steps {
margin: 8px 0 24px;
}
.electronic-sign-summary {
margin-bottom: 20px;
}
.electronic-sign-seal-alert {
margin-bottom: 20px;
}
.electronic-sign-section-title {
margin-bottom: 10px;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
}
.electronic-sign-documents {
display: flex;
flex-direction: column;
gap: 10px;
}
.electronic-sign-document {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 12px 14px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.electronic-sign-document-main,
.electronic-sign-document-actions,
.electronic-sign-footer {
display: flex;
align-items: center;
}
.electronic-sign-document-main {
gap: 10px;
min-width: 0;
}
.electronic-sign-document-actions {
flex-shrink: 0;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.electronic-sign-document-actions :deep(.el-button) {
margin-left: 0;
}
.document-state-text {
margin-top: 3px;
font-size: 12px;
}
.electronic-sign-link-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 16px;
padding: 14px 16px;
border: 1px solid var(--el-color-primary-light-7);
border-radius: 8px;
background: var(--el-color-primary-light-9);
}
.electronic-sign-footer {
justify-content: space-between;
gap: 16px;
}
@media (max-width: 768px) {
.copy-count-row {
align-items: flex-start;
flex-direction: column;
}
.electronic-sign-document,
.electronic-sign-link-card,
.electronic-sign-footer {
align-items: stretch;
flex-direction: column;
}
.electronic-sign-document-actions {
justify-content: flex-start;
}
}
</style>