4014 lines
126 KiB
Vue
4014 lines
126 KiB
Vue
<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>
|