Files
OnboardXFront/src/views/onboarding/fill/index.vue
T
2026-09-18 16:08:27 +08:00

3185 lines
104 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<main class="fill-page" :class="{ 'is-error-state': Boolean(error) }" v-loading="loading">
<!-- 异常状态展示(居中悬浮卡片,对齐 public-status 风格) -->
<div v-if="error" class="public-error-container">
<section class="error-status-card">
<div class="error-status-mark">!</div>
<h1 class="error-status-title">{{ errorTitle }}</h1>
<p class="error-status-desc">{{ errorDescription }}</p>
<div class="error-status-actions">
<el-button type="primary" class="error-retry-btn" @click="load()">重新加载</el-button>
</div>
</section>
</div>
<!-- 正常入职内容 -->
<template v-else>
<!-- 顶部品牌横幅 -->
<header class="brand-header">
<div class="brand-content">
<div class="brand-badge">
<el-icon>
<OfficeBuilding />
</el-icon>
<span>智能入职服务</span>
</div>
<h1 class="brand-title">很高兴与你相遇,欢迎加入!</h1>
<p class="brand-subtitle">请完成以下入职流程:填写登记信息、上传证件、阅读并签署相关入职材料,开启你的职场新旅程。</p>
</div>
</header>
<!-- 容器卡片 -->
<div class="main-container">
<template v-if="task">
<!-- 任务状态提示条 -->
<div class="status-banner" :class="task.taskStatus">
<div class="status-banner-main">
<div class="status-info">
<span class="task-no">任务编号:{{ task.taskNo }}</span>
<el-tag :type="statusTagType(task.taskStatus)" size="small" effect="plain" class="status-tag">
{{ statusText(task.taskStatus) }}
</el-tag>
</div>
<div v-if="task.linkExpireAt" class="expire-time">
链接有效期至:{{ formatDateTime(task.linkExpireAt) }}
</div>
</div>
<div class="status-badge-art" aria-hidden="true">
<svg width="44" height="44" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="8" width="36" height="32" rx="6" fill="#ECFDF5" stroke="#10B981" stroke-width="1.5" />
<rect x="12" y="14" width="10" height="12" rx="3" fill="#059669" />
<rect x="25" y="16" width="12" height="2.5" rx="1.25" fill="#059669" />
<rect x="25" y="21" width="8" height="2" rx="1" fill="#34D399" />
<rect x="12" y="29" width="24" height="2" rx="1" fill="#10B981" />
<rect x="12" y="33" width="16" height="2" rx="1" fill="#34D399" opacity="0.7" />
<circle cx="24" cy="7" r="3" fill="#10B981" />
<path d="M21 7H27" stroke="#ECFDF5" stroke-width="1.5" stroke-linecap="round" />
</svg>
</div>
</div>
<el-alert v-if="isLinkExpired" class="link-expired-alert" title="入职链接已过期"
description="该链接已超过有效期,不能继续填写或提交。请联系工作人员重新获取入职链接。" type="error" show-icon :closable="false" />
<!-- 审核退回后向候选人明确展示需要补充或修改的内容。 -->
<el-alert v-if="task.taskStatus === 'rejected'" class="reject-reason-alert" title="退回原因"
:description="task.rejectReason?.trim() || '资料已退回,请联系工作人员确认需要补充的内容。'" type="warning" show-icon
:closable="false" />
<!-- 分步向导模式 -->
<div class="wizard-card">
<div v-if="!canEdit && !isLinkExpired" class="readonly-banner">
<el-alert title="当前为查阅模式(入职资料已提交审核)" type="info" show-icon :closable="false" />
</div>
<!-- 步骤导航条:PC端展示完整步骤,移动端展示紧凑自适应导航 -->
<div class="steps-wrapper desktop-only">
<el-steps :active="currentStep" finish-status="success" align-center>
<el-step v-for="step in visibleSteps" :key="step.key" :title="step.title" :icon="step.icon" />
</el-steps>
</div>
<div class="mobile-steps-wrapper mobile-only">
<div class="mobile-steps-track">
<div v-for="(step, index) in visibleSteps" :key="step.key"
class="mobile-step-item" :class="{
'is-active': currentStep === index,
'is-finished': currentStep > index,
'is-pending': currentStep < index
}">
<div class="step-indicator-bar" />
<span class="step-label">{{ (index + 1) + '. ' + step.shortTitle }}</span>
</div>
</div>
<div class="mobile-current-indicator">
当前进行:<span class="highlight-step">第 {{ currentStep + 1 }}/{{ visibleSteps.length }} 步 · {{ currentStepDefinition.description }}</span>
</div>
</div>
<!-- 步骤 1: 入职登记表 -->
<div v-show="currentStepKey === 'registration'" class="step-content">
<div class="registration-header">
<h2 class="reg-title">入 职 登 记 表</h2>
<p class="reg-subtitle">请如实填写个人基础档案、教育经历及家庭相关信息,标注 * 为必填项,信息将用于建立人事档案,请务必保证资料真实准确。</p>
</div>
<el-form ref="regFormRef" :model="form" :rules="regRules" label-position="top" class="reg-form">
<!-- 1. 基本信息板块 -->
<div class="card-section">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<User />
</el-icon></div>
<span>基本个人信息</span>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="8">
<el-form-item label="真实姓名" prop="employeeName">
<el-input v-model="form.employeeName" placeholder="请输入真实姓名" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="身份证号码" prop="idCardNumber">
<el-input v-model="form.idCardNumber" placeholder="请输入18位身份证号" :disabled="!canEdit" maxlength="18"
clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="出生年月日" prop="birthday">
<el-input v-model="form.birthday" placeholder="根据身份证号自动生成" readonly :disabled="!canEdit"
class="full-width readonly-derived-input">
<template #prefix>
<el-icon><Calendar /></el-icon>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="性别" prop="gender">
<el-select v-model="form.gender" placeholder="请选择性别" :disabled="!canEdit" class="full-width">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="民族" prop="nation">
<el-input v-model="form.nation" placeholder="如 汉族" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="户籍所在地" prop="registeredResidence">
<el-input v-model="form.registeredResidence" placeholder="如 某省某市某区/县" :disabled="!canEdit"
clearable />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
<el-form-item label="身高 (cm)" prop="height">
<el-input v-model="form.height" placeholder="如 175" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
<el-form-item label="体重 (kg)" prop="weight">
<el-input v-model="form.weight" placeholder="如 65" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="健康状况" prop="healthCondition">
<el-input v-model="form.healthCondition" placeholder="如 良好 / 无既往病史" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="婚育情况" prop="maritalStatus">
<el-select v-model="form.maritalStatus" placeholder="请选择" :disabled="!canEdit" class="full-width">
<el-option label="未婚" value="未婚" />
<el-option label="已婚已育" value="已婚已育" />
<el-option label="已婚未育" value="已婚未育" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 2. 学历与资质 -->
<div class="card-section">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<School />
</el-icon></div>
<span>学历与职业资格</span>
</div>
<el-row :gutter="16">
<el-col :xs="12" :sm="8">
<el-form-item label="最高学历" prop="education">
<el-select v-model="form.education" placeholder="请选择学历" :disabled="!canEdit" class="full-width">
<el-option label="博士研究生" value="博士研究生" />
<el-option label="硕士研究生" value="硕士研究生" />
<el-option label="本科" value="本科" />
<el-option label="大专" value="大专" />
<el-option label="中专/高中及以下" value="高中及以下" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="所学专业" prop="major">
<el-input v-model="form.major" placeholder="请输入专业全称" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="毕业院校" prop="graduatedSchool">
<el-input v-model="form.graduatedSchool" placeholder="请输入毕业院校全称" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="职业资格/职称" prop="vocationalQualification">
<el-input v-model="form.vocationalQualification" placeholder="如 注册会计师/无" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="资格等级" prop="qualificationLevel">
<el-input v-model="form.qualificationLevel" placeholder="如 高级/中级/无" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="8">
<el-form-item label="特长技能" prop="technicalSpecialty">
<el-input v-model="form.technicalSpecialty" placeholder="如 驾驶/英语六级" :disabled="!canEdit" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 3. 联系方式与银行卡 -->
<div class="card-section">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<Phone />
</el-icon></div>
<span>联系方式与工资银行卡</span>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="8">
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="手机号码" :disabled="!canEdit" maxlength="11" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="电子邮箱" prop="email">
<el-input v-model="form.email" placeholder="常用电子邮箱" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="现居住地址" prop="currentAddress">
<el-input v-model="form.currentAddress" placeholder="目前常住的详细地址" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="房东联系方式" prop="landlordContact">
<el-input v-model="form.landlordContact" placeholder="租房房东姓名及电话 (选填)" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="工资银行卡号" prop="bankCardNumber">
<el-input v-model="form.bankCardNumber" placeholder="用于发放工资的银行卡号" :disabled="!canEdit"
clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="开户银行全称" prop="bankName">
<el-input v-model="form.bankName" placeholder="如 招商银行深圳高新支行" :disabled="!canEdit" clearable />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 4. 紧急联系人 -->
<div class="card-section">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<FirstAidKit />
</el-icon></div>
<span>紧急联系人</span>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="8">
<el-form-item label="紧急联系人姓名" prop="emergencyContact">
<el-input v-model="form.emergencyContact" placeholder="亲属或紧急联系人姓名" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="紧急联系人电话" prop="emergencyPhone">
<el-input v-model="form.emergencyPhone" placeholder="联系电话" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="紧急联系人地址" prop="emergencyAddress">
<el-input v-model="form.emergencyAddress" placeholder="联系地址" :disabled="!canEdit" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 5. 教育学习经历动态列表 -->
<div id="education-history-section" class="card-section">
<div class="card-section-header">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<Reading />
</el-icon></div>
<span><span class="required-asterisk">*</span>教育学习经历</span>
</div>
<el-button v-if="canEdit" size="small" class="section-action-btn" icon="Plus" @click="addEducation">
添加经历
</el-button>
</div>
<div v-if="!educationHistory.length" class="empty-hint">暂未添加学习经历,可点击右上角添加(至少填写一条最高学历)</div>
<div v-for="(item, idx) in educationHistory" :id="`education-history-${idx}`" :key="idx"
class="dynamic-row-box">
<div class="row-badge-header">
<span class="row-badge">学习经历 #{{ idx + 1 }}</span>
<el-button v-if="canEdit" link type="danger" icon="Delete"
@click="educationHistory.splice(idx, 1)">删除本条</el-button>
</div>
<el-row :gutter="12">
<el-col :xs="24" :sm="6">
<el-form-item label="时间范围">
<el-input v-model="item.timeRange" placeholder="如 2018.09-2022.06" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6">
<el-form-item label="学校/机构">
<el-input v-model="item.location" placeholder="毕业学校全称" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6">
<el-form-item label="专业/学习内容">
<el-input v-model="item.content" placeholder="所学专业" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6">
<el-form-item label="结果/学位">
<el-input v-model="item.result" placeholder="如 毕业/获学士学位" :disabled="!canEdit" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 6. 工作经历动态列表 -->
<div id="work-history-section" class="card-section">
<div class="card-section-header">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<Briefcase />
</el-icon></div>
<span><span class="required-asterisk">*</span>主要工作经历</span>
</div>
<el-button v-if="canEdit" size="small" class="section-action-btn" icon="Plus" @click="addWork">
添加经历
</el-button>
</div>
<div v-if="!workHistory.length" class="empty-hint">暂未添加工作经历,可点击右上角添加(应届生可填兼职、在校或实习经历)</div>
<div v-for="(item, idx) in workHistory" :id="`work-history-${idx}`" :key="idx" class="dynamic-row-box">
<div class="row-badge-header">
<span class="row-badge">工作经历 #{{ idx + 1 }}</span>
<el-button v-if="canEdit" link type="danger" icon="Delete"
@click="workHistory.splice(idx, 1)">删除本条</el-button>
</div>
<el-row :gutter="12">
<el-col :xs="24" :sm="5">
<el-form-item label="起止时间">
<el-input v-model="item.timeRange" placeholder="如 2022.07-2024.05" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6">
<el-form-item label="工作单位名称">
<el-input v-model="item.companyName" placeholder="工作单位/实习单位全称" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
<el-form-item label="担任职务">
<el-input v-model="item.position" placeholder="担任职务/在校岗位" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
<el-form-item label="薪资待遇">
<el-input v-model="item.salary" placeholder="薪资 (选填)" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="5">
<el-form-item label="离职原因">
<el-input v-model="item.leaveReason" placeholder="简述离职/实习结束原因" :disabled="!canEdit" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 7. 家庭成员动态列表 -->
<div id="family-members-section" class="card-section">
<div class="card-section-header">
<div class="card-section-title">
<div class="title-icon-box"><el-icon>
<HomeFilled />
</el-icon></div>
<span><span class="required-asterisk">*</span>家庭成员</span>
</div>
<el-button v-if="canEdit" size="small" class="section-action-btn" icon="Plus" @click="addFamily">
添加成员
</el-button>
</div>
<div v-if="!familyMembers.length" class="empty-hint">暂未添加家庭成员信息,可点击右上角添加(至少填写一位直系亲属)</div>
<div v-for="(item, idx) in familyMembers" :id="`family-member-${idx}`" :key="idx"
class="dynamic-row-box">
<div class="row-badge-header">
<span class="row-badge">家庭成员 #{{ idx + 1 }}</span>
<el-button v-if="canEdit" link type="danger" icon="Delete"
@click="familyMembers.splice(idx, 1)">删除本条</el-button>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="4">
<el-form-item label="姓名">
<el-input v-model="item.name" placeholder="成员姓名" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="4">
<el-form-item label="关系">
<el-input v-model="item.relationship" placeholder="如 父母/配偶" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="3">
<el-form-item label="年龄">
<el-input v-model="item.age" placeholder="年龄" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="工作单位">
<el-input v-model="item.company" placeholder="工作单位及职务(无则填无)" :disabled="!canEdit" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="5">
<el-form-item label="联系电话">
<el-input v-model="item.phone" placeholder="手机号" :disabled="!canEdit" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<!-- 步骤 2: 合同专属增量补充项 (自适应差集) -->
<div v-if="extraVariables.length" v-show="currentStepKey === 'extra'" class="step-content">
<div class="section-header">
<h3 class="section-title">签署材料专属补充信息</h3>
<p class="section-desc">以下是本次签署材料中特殊约定的增量字段,登记表未涵盖的项需在此补充确认</p>
</div>
<el-form label-position="top" class="extra-form">
<el-alert title="请补充填写以下签署材料专有信息:" type="info" show-icon :closable="false" class="mb-3" />
<el-row :gutter="16">
<el-col v-for="(v, index) in extraVariables" :id="`extra-variable-${index}`" :key="v.variableCode"
:xs="24" :sm="12">
<el-form-item :label="v.variableName || v.variableCode" :required="v.required"
:error="extraVariableErrors[v.variableCode]">
<el-select v-if="getVariableOptions(v).length" v-model="customVariables[v.variableCode]"
:placeholder="`请选择${v.variableName}`" :disabled="!canEdit" class="full-width" clearable
@change="clearExtraVariableError(v.variableCode)">
<el-option v-for="option in getVariableOptions(v)" :key="option.value" :label="option.label"
:value="option.value" />
</el-select>
<el-date-picker v-else-if="getVariableDataType(v) === 'date'"
v-model="customVariables[v.variableCode]" type="date" value-format="YYYY-MM-DD"
:placeholder="`请选择${v.variableName}`" :disabled="!canEdit" class="full-width"
@change="clearExtraVariableError(v.variableCode)" />
<el-input v-else v-model="customVariables[v.variableCode]"
:type="getVariableDataType(v) === 'number' ? 'number' : 'text'"
:inputmode="getVariableDataType(v) === 'number' ? 'decimal' : 'text'"
:placeholder="v.defaultValue || `请输入${v.variableName}`" :disabled="!canEdit" clearable
@input="clearExtraVariableError(v.variableCode)" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<!-- 步骤 3: 证件材料上传 -->
<div v-show="currentStepKey === 'materials'" class="step-content">
<div class="section-header">
<h3 class="section-title">证件与材料上传</h3>
<p class="section-desc">请上传清晰真实有效的证件扫描件或照片(支持 PNG、JPG、PDF)</p>
</div>
<el-empty v-if="!materialRequirements.length" description="本次入职无需上传附加证件材料" />
<div v-else class="materials-grid">
<div v-for="mat in materialRequirements" :id="`material-requirement-${mat.materialTypeId}`"
:key="mat.materialTypeId" class="material-item-card">
<div class="mat-card-header">
<div class="mat-title-box">
<span class="mat-name">{{ mat.materialName }}</span>
<el-tag v-if="mat.required" size="small" type="danger" effect="plain">必传</el-tag>
<el-tag v-else size="small" type="info" effect="plain">选传</el-tag>
</div>
<span class="mat-limit">{{ getMaterialLimitText(mat) }}</span>
</div>
<div class="uploaded-files-list">
<div v-for="f in getUploadedMaterials(mat.materialTypeId)" :key="`${f.id}-${f.fileId}`"
class="file-badge">
<!-- 图片点击缩略图即可在手机端放大预览,非图片继续显示文件图标。 -->
<el-image v-if="isImageFile(f) && getMaterialSourceUrl(f)" class="material-thumbnail"
:src="getMaterialSourceUrl(f)" :preview-src-list="[getMaterialSourceUrl(f)]" fit="cover" preview-teleported />
<el-icon v-else class="material-file-icon" color="#059669">
<Document />
</el-icon>
<div class="file-content">
<span class="file-name" :title="f.fileName || f.materialName">
{{ f.fileName || f.materialName }}
</span>
<span v-if="f.fileSizeInfo" class="file-size">{{ f.fileSizeInfo }}</span>
<span v-if="isImageFile(f)" class="preview-tip">点击图片可放大预览</span>
</div>
<div v-if="canEdit" class="file-actions">
<el-upload action="#" :accept="getUploadAccept(mat)"
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
:http-request="(options: any) => uploadMaterial(options, mat.materialTypeId, f.id)"
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
:before-upload="(file: File) => beforeUpload(file, mat)">
<el-button link type="primary" icon="Refresh"
:loading="isMaterialUploading(mat.materialTypeId)">
重新上传
</el-button>
</el-upload>
<el-button link type="danger" icon="Delete" @click="deleteFile(f.id)">
删除
</el-button>
</div>
</div>
</div>
<div v-if="canEdit && getRemainingFileCount(mat) > 0" class="upload-action-box">
<el-upload action="#" :accept="getUploadAccept(mat)"
:disabled="isMaterialUploading(mat.materialTypeId)" :show-file-list="false"
:http-request="(options: any) => uploadMaterial(options, mat.materialTypeId)"
:on-success="(response: any) => onUploadSuccess(response, mat.materialTypeId)"
:on-error="(error: Error) => onUploadError(error, mat.materialTypeId)"
:before-upload="(file: File) => beforeUpload(file, mat)">
<el-button size="small" type="primary" plain icon="Upload"
:loading="isMaterialUploading(mat.materialTypeId)">
{{ getUploadedMaterials(mat.materialTypeId).length ? "继续上传" : "上传文件 / 照片" }}
</el-button>
</el-upload>
<span class="upload-count">
已上传 {{ getUploadedMaterials(mat.materialTypeId).length }}/{{ getMaxFileCount(mat) }},
还可上传 {{ getRemainingFileCount(mat) }} 份
</span>
</div>
<div v-else-if="canEdit && getUploadedMaterials(mat.materialTypeId).length"
class="upload-limit-reached">
已上传 {{ getUploadedMaterials(mat.materialTypeId).length }}/{{ getMaxFileCount(mat) }};
如需更换,请点击对应文件的“重新上传”
</div>
</div>
</div>
</div>
<!-- 步骤 4: 合同查阅与签署 -->
<div v-show="currentStepKey === 'confirmation'" class="step-content">
<div v-if="canEdit" id="identity-confirmation-card" class="identity-confirmation-card">
<div class="identity-confirmation-heading">
<div>
<h3 class="identity-confirmation-title">实名认证信息核对确认</h3>
<p class="identity-confirmation-desc">
以下三项将直接用于电子签约实名核验与法定人事建档,请对照二代身份证及本人实名手机号逐项核对。
</p>
</div>
<el-tag type="primary" effect="plain">签约实名核验</el-tag>
</div>
<el-alert title="请核对姓名、证件号码及手机号与实际完全一致,信息有误将导致电子签约证书核发失败。" type="info" show-icon :closable="false" />
<div class="identity-confirmation-list">
<div v-for="field in identityFields" :key="field.key" class="identity-confirmation-item">
<div class="identity-field-row">
<span class="identity-field-label">{{ field.label }}</span>
<span class="identity-field-value">{{ identityDisplayValue(field.key) }}</span>
</div>
<el-checkbox :model-value="isIdentityFieldConfirmed(field.key)" class="identity-confirmation-checkbox"
@change="setIdentityFieldConfirmation(field.key, $event)">
{{ field.confirmText }}
</el-checkbox>
</div>
</div>
<div class="identity-confirmation-footer">
<span class="identity-confirmation-progress" :class="{ 'is-complete': isIdentityConfirmationComplete }">
已确认 {{ confirmedIdentityFieldCount }}/{{ identityFields.length }} 项
</span>
<el-button type="primary" plain @click="returnToIdentityInformation">
返回第一步修改实名信息
</el-button>
</div>
</div>
<div class="section-header">
<h3 class="section-title">入职签署材料查阅与确认</h3>
<p class="section-desc">系统已为您代入真实登记信息生成专属签署材料,请认真查阅条款并勾选确认</p>
</div>
<el-alert v-if="preparingContract" class="contract-preparing-alert" title="正在生成您的专属签署材料"
:description="contractPreparingDescription" type="info" show-icon :closable="false" />
<el-empty v-if="!contracts.length" description="暂无待确认签署材料" />
<div v-else class="contracts-list">
<div v-for="c in contracts" :id="`contract-confirm-${c.id}`" :key="c.id" class="contract-sign-card">
<div class="contract-card-top">
<div class="contract-meta">
<el-icon color="#059669" size="20">
<Tickets />
</el-icon>
<span class="contract-title font-bold">{{ c.contractName }}</span>
<el-tag size="small" :type="c.contractStatus === 'confirmed' ? 'success' : 'warning'">
{{ c.contractStatus === 'confirmed' ? '已阅并同意' : '待查阅确认' }}
</el-tag>
</div>
<div class="contract-desc">
请点击下方按钮下载/查阅带您专属信息的签署材料全文,仔细核对您的基本信息与条款内容。
</div>
</div>
<el-alert v-if="getContractPrepareError(c.id)" class="contract-prepare-error"
:title="getContractPrepareError(c.id)" type="error" show-icon :closable="false" />
<div class="contract-card-bottom">
<el-button :type="isContractPrepareFailed(c.id) ? 'danger' : 'primary'" plain
:icon="isContractPrepareFailed(c.id) ? 'Refresh' : 'Download'" :loading="isContractBusy(c.id)"
:disabled="isContractDownloading(c.id) || (!isContractPrepared(c.id) && !isContractPrepareFailed(c.id))"
@click="handleContractAction(c)">
{{ getContractActionText(c.id) }}
</el-button>
<el-checkbox v-if="canEdit" v-model="c.isAgreed" class="agree-checkbox"
:disabled="!isContractPrepared(c.id) || !c.hasViewed" :label="`我已完整阅读并同意《${c.contractName}》全部条款`" />
<span v-else class="confirmed-text">✓ 候选人已确认同意</span>
</div>
<div v-if="canEdit && isContractPrepared(c.id) && !c.hasViewed" class="contract-view-hint">
请先点击“下载并查看签署材料”,查看后才能勾选确认。
</div>
</div>
</div>
</div>
<!-- 底部向导操作栏 -->
<div class="wizard-footer">
<el-button v-if="currentStep > 0" size="large" :disabled="savingStep || isAnyMaterialUploading" @click="currentStep--">上一步</el-button>
<el-button v-if="currentStep < visibleSteps.length - 1" type="primary" size="large"
:loading="savingStep || (currentStepKey === 'materials' && isAnyMaterialUploading)"
:disabled="savingStep || isAnyMaterialUploading"
@click="nextStep">
{{ (currentStepKey === 'materials' && isAnyMaterialUploading) ? "材料上传中..." : (savingStep ? "保存中..." : "下一步") }}
</el-button>
<el-button v-if="currentStepKey === 'confirmation' && canEdit" type="success" size="large" icon="Check"
:loading="submitting" :disabled="savingStep || isAnyMaterialUploading" @click="submitAll">
确认无误并提交入职资料
</el-button>
</div>
</div>
</template>
</div>
</template>
</main>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage as RawElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
/**
* 移动端/公开端单例独占式 Toast 封装:
* 1. 弹出新提示前自动 closeAll(),彻底杜绝手机端多条提示同时排队堆叠满屏的问题
* 2. 启用 grouping: true 自动合并短时间内同一文案的提示
*/
const ElMessage = {
success(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.success({ grouping: true, ...config });
},
warning(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.warning({ grouping: true, ...config });
},
error(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.error({ grouping: true, ...config });
},
info(options: any) {
RawElMessage.closeAll();
const config = typeof options === "string" ? { message: options } : options;
return RawElMessage.info({ grouping: true, ...config });
},
closeAll() {
RawElMessage.closeAll();
}
};
import {
OnboardingPublicTask,
onboardingPublicApi
} from "@/api/modules/open/onboarding";
import {
getIdCardBirthday,
getIdCardGender,
getIdCardValidationMessage,
isBankCardNumber,
isContactPhone,
isEmail,
isMobilePhone,
isNumberInRange,
normalizeBankCardNumber,
normalizeIdCardNumber
} from "@/utils/onboardingValidation";
const route = useRoute();
const token = ref((route.query.token as string) || "");
const viewportMeta = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
const originalViewportContent = viewportMeta?.content;
const loading = ref(false);
const submitting = ref(false);
const savingStep = ref(false);
const error = ref("");
const errorTitle = computed(() => {
const msg = error.value || "";
if (msg.includes("链接不存在") || msg.includes("Token") || msg.includes("无效")) {
return "入职链接无效";
}
if (msg.includes("过期")) {
return "入职链接已过期";
}
return "访问受限";
});
const errorDescription = computed(() => {
const msg = error.value || "";
if (msg.includes("链接不存在") || msg.includes("Token") || msg.includes("无效")) {
return "入职链接不存在或已失效,请联系招聘人员重新获取入职链接。";
}
if (msg.includes("过期")) {
return "该链接已超过有效期,不能继续填写。请联系招聘人员重新获取入职链接。";
}
return `${msg},请检查网络或联系招聘人员协助处理。`;
});
const currentTimestamp = ref(Date.now());
let linkExpiryTimer: number | undefined;
const currentStep = ref(0);
const uploadingMaterials = reactive<Record<string, boolean>>({});
const isAnyMaterialUploading = computed(() => Object.values(uploadingMaterials).some(Boolean));
type ContractPrepareStatus = "idle" | "preparing" | "ready" | "error";
const contractPrepareStatus = reactive<Record<string, ContractPrepareStatus>>({});
const contractPrepareErrors = reactive<Record<string, string>>({});
const contractDownloading = reactive<Record<string, boolean>>({});
const materialPreviewUrls = reactive<Record<string, string>>({});
let materialPreviewGeneration = 0;
const task = ref<OnboardingPublicTask | null>(null);
const contracts = ref<any[]>([]);
const materialRequirements = ref<any[]>([]);
const uploadedMaterials = ref<any[]>([]);
const contractVariables = ref<any[]>([]);
const formFieldRequirements = ref<any[]>([]);
const regFormRef = ref<FormInstance>();
// 1. 入职登记表主表单
const form = reactive<any>({
employeeName: "",
gender: "男",
nation: "汉族",
birthday: "",
registeredResidence: "",
height: "",
weight: "",
healthCondition: "良好",
maritalStatus: "未婚",
education: "本科",
major: "",
graduatedSchool: "",
vocationalQualification: "",
qualificationLevel: "",
technicalSpecialty: "",
phone: "",
email: "",
idCardNumber: "",
currentAddress: "",
landlordContact: "",
bankCardNumber: "",
bankName: "",
bankAccountName: "",
emergencyContact: "",
emergencyPhone: "",
emergencyAddress: "",
onboardingDepartment: "",
onboardingDate: "",
onboardingPosition: "",
otherRemarks: ""
});
type IdentityField = "employeeName" | "idCardNumber" | "phone";
const identityFields: Array<{
key: IdentityField;
label: string;
confirmText: string;
}> = [
{ key: "employeeName", label: "真实姓名", confirmText: "我已核对姓名,与本人身份证一致" },
{ key: "idCardNumber", label: "身份证号码", confirmText: "我已核对身份证号码完整且无误" },
{ key: "phone", label: "手机号码", confirmText: "我已核对手机号,可用于本人实名认证" }
];
const identityConfirmations = reactive<Record<IdentityField, boolean>>({
employeeName: false,
idCardNumber: false,
phone: false
});
const identityConfirmedValues = reactive<Record<IdentityField, string>>({
employeeName: "",
idCardNumber: "",
phone: ""
});
function normalizeIdentityValue(field: IdentityField, value: unknown) {
const normalized = String(value ?? "").trim();
return field === "idCardNumber" ? normalized.toUpperCase() : normalized;
}
function identityDisplayValue(field: IdentityField) {
return normalizeIdentityValue(field, form[field]) || "未填写";
}
function isIdentityFieldConfirmed(field: IdentityField) {
return identityConfirmations[field]
&& identityConfirmedValues[field] === normalizeIdentityValue(field, form[field]);
}
function setIdentityFieldConfirmation(field: IdentityField, value: unknown) {
const checked = Boolean(value);
identityConfirmations[field] = checked;
identityConfirmedValues[field] = checked
? normalizeIdentityValue(field, form[field])
: "";
}
function resetIdentityConfirmations() {
identityFields.forEach(({ key }) => {
identityConfirmations[key] = false;
identityConfirmedValues[key] = "";
});
}
const confirmedIdentityFieldCount = computed(() =>
identityFields.filter(({ key }) => isIdentityFieldConfirmed(key)).length
);
const isIdentityConfirmationComplete = computed(() =>
confirmedIdentityFieldCount.value === identityFields.length
);
function returnToIdentityInformation() {
resetIdentityConfirmations();
currentStep.value = 0;
window.scrollTo({ top: 0, behavior: "smooth" });
nextTick(() => regFormRef.value?.scrollToField("employeeName"));
}
watch(
() => identityFields.map(({ key }) => normalizeIdentityValue(key, form[key])),
values => {
identityFields.forEach(({ key }, index) => {
if (identityConfirmations[key] && identityConfirmedValues[key] !== values[index]) {
identityConfirmations[key] = false;
identityConfirmedValues[key] = "";
}
});
},
{ flush: "sync" }
);
// 多条动态经历
const educationHistory = ref<{ timeRange: string; location: string; content: string; result: string }[]>([]);
const workHistory = ref<{ timeRange: string; companyName: string; salary: string; position: string; leaveReason: string }[]>([]);
const familyMembers = ref<{ name: string; relationship: string; age: string; company: string; phone: string }[]>([]);
// 2. 特殊合同增量变量 (差集)
const customVariables = reactive<Record<string, string>>({});
function validateEmployeeName(_rule: any, value: unknown, callback: (error?: Error) => void) {
const employeeName = String(value ?? "").trim();
if (!employeeName) {
return callback(new Error("请输入真实姓名"));
}
if (employeeName.length > 100) {
return callback(new Error("姓名不能超过100个字符"));
}
callback();
}
function validatePhone(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (!isMobilePhone(value)) {
return callback(new Error("请输入正确的11位手机号码"));
}
callback();
}
function validateIdCardNumber(_rule: any, value: unknown, callback: (error?: Error) => void) {
const message = getIdCardValidationMessage(value);
if (message) {
return callback(new Error(message));
}
callback();
}
function validateBirthday(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (!value) {
return callback();
}
const birthday = String(value);
const idCardBirthday = getIdCardBirthday(form.idCardNumber);
if (idCardBirthday && birthday !== idCardBirthday) {
return callback(new Error("出生日期与身份证号码不一致"));
}
callback();
}
// 监听身份证号码输入,自动提取出生年月日并联动推导性别
watch(
() => form.idCardNumber,
(newVal) => {
const rawIdCard = normalizeIdCardNumber(newVal);
if (!rawIdCard) {
form.birthday = "";
return;
}
const birthday = getIdCardBirthday(rawIdCard);
if (birthday) {
form.birthday = birthday;
// 联动推导性别(若尚未手动选择过)
const gender = getIdCardGender(rawIdCard);
if (gender && (!form.gender || form.gender === "男" || form.gender === "女")) {
form.gender = gender;
}
nextTick(() => {
regFormRef.value?.clearValidate(["birthday", "gender"]);
});
} else if (rawIdCard.length < 18) {
form.birthday = "";
}
},
{ immediate: true }
);
function validateBankCardNumber(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (value && !isBankCardNumber(value)) {
return callback(new Error("请输入12至19位银行卡号"));
}
callback();
}
function validateOptionalEmail(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (value && !isEmail(value)) {
return callback(new Error("请输入正确的电子邮箱"));
}
callback();
}
function validateOptionalHeight(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (value && !isNumberInRange(value, 100, 250)) {
return callback(new Error("身高应为100至250厘米之间的数字"));
}
callback();
}
function validateOptionalWeight(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (value && !isNumberInRange(value, 30, 300)) {
return callback(new Error("体重应为30至300公斤之间的数字"));
}
callback();
}
function validateOptionalContactPhone(_rule: any, value: unknown, callback: (error?: Error) => void) {
if (value && !isContactPhone(value)) {
return callback(new Error("请输入正确的手机号码或座机号码"));
}
callback();
}
/** 将 employee.EmployeeName 转为登记表模型使用的 employeeName。 */
function getRegistrationFieldKey(fieldCode: unknown) {
const code = String(fieldCode || "").trim();
if (!code.toLowerCase().startsWith("employee.")) {
return "";
}
const propertyName = code.substring(code.lastIndexOf(".") + 1);
return propertyName
? propertyName.charAt(0).toLowerCase() + propertyName.slice(1)
: "";
}
const requiredRegistrationFields = computed(() => {
const result = new Map<string, any>();
formFieldRequirements.value.forEach(requirement => {
const fieldKey = getRegistrationFieldKey(requirement.fieldCode);
if (!fieldKey || !(fieldKey in form)) {
return;
}
const existing = result.get(fieldKey);
if (!existing || requirement.required) {
result.set(fieldKey, requirement);
}
});
return result;
});
function appendRegistrationRule(rules: FormRules, fieldKey: string, rule: any) {
const existingRules = rules[fieldKey];
const fieldRules = Array.isArray(existingRules)
? existingRules
: existingRules
? [existingRules]
: [];
rules[fieldKey] = [...fieldRules, rule];
}
// 固定登记表必填状态读取任务快照;姓名、身份证和手机号仍是电子签约的系统必填项。
const regRules = computed<FormRules>(() => {
const rules: FormRules = {};
const changeTriggerFields = new Set(["gender", "birthday", "maritalStatus", "education"]);
requiredRegistrationFields.value.forEach((requirement, fieldKey) => {
if (!requirement.required) {
return;
}
const useChangeTrigger = changeTriggerFields.has(fieldKey);
const message = fieldKey === "birthday"
? "请先输入正确的身份证号码以生成出生日期"
: `${useChangeTrigger ? "请选择" : "请填写"}${requirement.fieldName || fieldKey}`;
rules[fieldKey] = [{
required: true,
message,
trigger: useChangeTrigger ? "change" : "blur"
}];
});
rules.employeeName = [{ required: true, validator: validateEmployeeName, trigger: "blur" }];
rules.phone = [{ required: true, validator: validatePhone, trigger: "blur" }];
rules.idCardNumber = [{ required: true, validator: validateIdCardNumber, trigger: "blur" }];
appendRegistrationRule(rules, "birthday", { validator: validateBirthday, trigger: "change" });
appendRegistrationRule(rules, "height", { validator: validateOptionalHeight, trigger: "blur" });
appendRegistrationRule(rules, "weight", { validator: validateOptionalWeight, trigger: "blur" });
appendRegistrationRule(rules, "email", { validator: validateOptionalEmail, trigger: "blur" });
appendRegistrationRule(rules, "bankCardNumber", { validator: validateBankCardNumber, trigger: "blur" });
appendRegistrationRule(rules, "bankName", {
max: 100,
message: "开户银行名称不能超过100个字符",
trigger: "blur"
});
appendRegistrationRule(rules, "emergencyPhone", {
validator: validateOptionalContactPhone,
trigger: "blur"
});
return rules;
});
/** 根据当前 Token 的实际有效期实时锁定已打开的填写页面。 */
const isLinkExpired = computed(() => {
if (!task.value?.linkExpireAt) {
return false;
}
const expiresAt = Date.parse(task.value.linkExpireAt.replace(" ", "T"));
return !Number.isNaN(expiresAt) && expiresAt <= currentTimestamp.value;
});
const canEdit = computed(() => {
const editableStatus = task.value?.taskStatus === "pending_fill"
|| task.value?.taskStatus === "rejected";
return editableStatus && !isLinkExpired.value;
});
/** 当前正在校验变量并生成文件的合同。 */
const preparingContract = computed(() => {
return contracts.value.find(contract => isContractPreparing(contract.id));
});
/** 合同准备提示,明确告诉员工当前进度及正在处理的合同。 */
const contractPreparingDescription = computed(() => {
const contract = preparingContract.value;
if (!contract) {
return "";
}
const index = contracts.value.findIndex(item => item.id === contract.id);
return `正在校验材料信息并生成第 ${index + 1}/${contracts.value.length} 份《${contract.contractName}》,请稍候,不要关闭页面。`;
});
// 计算差集增量变量 (合同需要的 employee. 变量中,登记表没有的字段)
const registrationStandardFields = [
"employeename", "name", "username", "gender", "nation", "birthday", "registeredresidence",
"height", "weight", "healthcondition", "maritalstatus", "education", "major", "graduatedschool",
"college", "vocationalqualification", "qualificationlevel", "technicalspecialty", "phone",
"mobile", "email", "idcardnumber", "idcard", "currentaddress", "contactaddress", "landlordcontact",
"bankcardnumber", "bankcard", "bankname", "bankaccountname", "emergencycontact", "emergencyphone",
"emergencyaddress", "onboardingdepartment", "onboardingdate", "onboardingposition", "otherremarks"
];
const extraVariables = computed(() => {
const result: any[] = [];
const visited = new Set<string>();
for (const v of contractVariables.value) {
if (v.variableType === "employee") {
const fieldCode = (v.variableCode.split(".")[1] || v.variableCode).toLowerCase();
if (!registrationStandardFields.includes(fieldCode) && !visited.has(v.variableCode)) {
visited.add(v.variableCode);
result.push(v);
}
}
}
return result;
});
type OnboardingStepKey = "registration" | "extra" | "materials" | "confirmation";
const visibleSteps = computed(() => {
const steps = [
{ key: "registration" as OnboardingStepKey, title: "入职登记表", shortTitle: "登记表", description: "入职登记表填写", icon: "User" }
];
if (extraVariables.value.length) {
steps.push({ key: "extra", title: "材料补充项", shortTitle: "补充项", description: "签署材料补充项", icon: "Document" });
}
steps.push(
{ key: "materials", title: "证件材料", shortTitle: "证件材料", description: "证件材料上传", icon: "FolderChecked" },
{ key: "confirmation", title: "材料确认", shortTitle: "材料确认", description: "签署材料查阅与确认", icon: "Tickets" }
);
return steps;
});
const currentStepDefinition = computed(() => visibleSteps.value[currentStep.value] || visibleSteps.value[0]);
const currentStepKey = computed<OnboardingStepKey>(() => currentStepDefinition.value.key);
function addEducation() {
educationHistory.value.push({ timeRange: "", location: "", content: "", result: "" });
}
function addWork() {
workHistory.value.push({ timeRange: "", companyName: "", salary: "", position: "", leaveReason: "" });
}
function addFamily() {
familyMembers.value.push({ name: "", relationship: "", age: "", company: "", phone: "" });
}
const extraVariableErrors = reactive<Record<string, string>>({});
function getVariableDataType(variable: any) {
return String(variable?.dataType || "string").trim().toLowerCase();
}
function getVariableOptions(variable: any) {
if (!variable?.optionsJson) {
return [];
}
try {
const options = JSON.parse(variable.optionsJson);
if (!Array.isArray(options)) {
return [];
}
return options
.map((option: any) => {
if (option && typeof option === "object") {
const value = option.value ?? option.label;
const label = option.label ?? option.value;
return value == null
? null
: { value: String(value), label: String(label ?? value) };
}
return option == null
? null
: { value: String(option), label: String(option) };
})
.filter(Boolean) as { value: string; label: string }[];
} catch {
return [];
}
}
function clearExtraVariableError(variableCode: string) {
extraVariableErrors[variableCode] = "";
}
function isIsoDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return false;
}
const date = new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3])
);
return date.getFullYear() === Number(match[1])
&& date.getMonth() === Number(match[2]) - 1
&& date.getDate() === Number(match[3]);
}
/**
* 将长表单平滑定位到首个错误区域,并尽量聚焦其中可编辑的控件。
*/
function locateValidationTarget(target: string | HTMLElement | null | undefined) {
nextTick(() => {
const element = typeof target === "string"
? document.getElementById(target)
: target;
if (!element) {
return;
}
element.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
const focusable = element.matches("input:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])")
? element as HTMLElement
: element.querySelector<HTMLElement>(
"input:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])"
);
focusable?.focus({ preventScroll: true });
}, 350);
});
}
function validateExtraVariables() {
let firstMessage = "";
let firstTargetId = "";
for (const [index, variable] of extraVariables.value.entries()) {
const variableCode = variable.variableCode;
const variableName = variable.variableName || variableCode;
const value = String(customVariables[variableCode] ?? "").trim();
const options = getVariableOptions(variable);
const dataType = getVariableDataType(variable);
customVariables[variableCode] = value;
extraVariableErrors[variableCode] = "";
if (!value && variable.required) {
extraVariableErrors[variableCode] = `请填写${variableName}`;
} else if (value && options.length && !options.some(option => option.value === value)) {
extraVariableErrors[variableCode] = `${variableName}必须从配置选项中选择`;
} else if (value && dataType === "number" && !/^-?\d+(\.\d+)?$/.test(value)) {
extraVariableErrors[variableCode] = `${variableName}必须填写数字`;
} else if (value && dataType === "date" && !isIsoDate(value)) {
extraVariableErrors[variableCode] = `${variableName}必须填写正确日期`;
}
if (!firstMessage && extraVariableErrors[variableCode]) {
firstMessage = extraVariableErrors[variableCode];
firstTargetId = `extra-variable-${index}`;
}
}
if (!firstMessage) {
return true;
}
ElMessage.warning(firstMessage);
locateValidationTarget(firstTargetId);
return false;
}
function validateRegistrationLists() {
if (!educationHistory.value.length) {
ElMessage.warning("请至少添加一条教育学习经历(最高学历)");
locateValidationTarget("education-history-section");
return false;
}
for (let index = 0; index < educationHistory.value.length; index++) {
const row = educationHistory.value[index];
const requiredFields = [
["timeRange", "时间范围"],
["location", "学校或机构"],
["content", "专业或学习内容"],
["result", "结果或学位"]
] as const;
const missing = requiredFields.find(([field]) => !String(row[field] || "").trim());
if (missing) {
ElMessage.warning(`第 ${index + 1} 条教育经历请填写${missing[1]},或删除整条经历`);
locateValidationTarget(`education-history-${index}`);
return false;
}
}
if (!workHistory.value.length) {
ElMessage.warning("请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
locateValidationTarget("work-history-section");
return false;
}
for (let index = 0; index < workHistory.value.length; index++) {
const row = workHistory.value[index];
const requiredFields = [
["timeRange", "起止时间"],
["companyName", "工作单位"],
["position", "担任职务"],
["leaveReason", "离职原因"]
] as const;
const missing = requiredFields.find(([field]) => !String(row[field] || "").trim());
if (missing) {
ElMessage.warning(`第 ${index + 1} 条工作经历请填写${missing[1]},或删除整条经历`);
locateValidationTarget(`work-history-${index}`);
return false;
}
}
if (!familyMembers.value.length) {
ElMessage.warning("请至少添加一位家庭成员信息(如父母或配偶)");
locateValidationTarget("family-members-section");
return false;
}
for (let index = 0; index < familyMembers.value.length; index++) {
const row = familyMembers.value[index];
const requiredFields = [
["name", "姓名"],
["relationship", "关系"],
["age", "年龄"],
["phone", "联系电话"]
] as const;
const missing = requiredFields.find(([field]) => !String(row[field] || "").trim());
if (missing) {
ElMessage.warning(`第 ${index + 1} 条家庭成员请填写${missing[1]},或删除整条记录`);
locateValidationTarget(`family-member-${index}`);
return false;
}
const age = Number(row.age);
if (!Number.isInteger(age) || age < 0 || age > 120) {
ElMessage.warning(`第 ${index + 1} 条家庭成员的年龄应为0至120之间的整数`);
locateValidationTarget(`family-member-${index}`);
return false;
}
if (!isContactPhone(row.phone)) {
ElMessage.warning(`第 ${index + 1} 条家庭成员的联系电话格式不正确`);
locateValidationTarget(`family-member-${index}`);
return false;
}
}
return true;
}
function getUploadedMaterials(matId: number) {
return uploadedMaterials.value.filter(item => item.materialTypeId === matId);
}
/** 防止旧任务中的异常数量导致上传入口被错误隐藏。 */
function getMaxFileCount(mat: any) {
const maxFileCount = Number(mat.maxFileCount);
return Number.isFinite(maxFileCount) && maxFileCount > 0
? Math.floor(maxFileCount)
: 1;
}
function getRemainingFileCount(mat: any) {
return Math.max(
getMaxFileCount(mat) - getUploadedMaterials(mat.materialTypeId).length,
0
);
}
const PUBLIC_MATERIAL_MAX_FILE_SIZE_MB = 20;
const PUBLIC_MATERIAL_DEFAULT_EXTENSIONS = ["png", "jpg", "jpeg", "pdf"];
function getAllowedExtensions(mat: any) {
const configured = String(mat.allowedExtensions || "")
.toLowerCase()
.split(",")
.map((item: string) => item.trim().replace(/^\./, ""))
.filter(Boolean);
return configured.length ? configured : [...PUBLIC_MATERIAL_DEFAULT_EXTENSIONS];
}
function getUploadAccept(mat: any) {
return getAllowedExtensions(mat)
.map((item: string) => `.${item}`)
.join(",");
}
function getMaterialLimitText(mat: any) {
const allowedExtensions = getAllowedExtensions(mat);
const formatText = allowedExtensions.length
? allowedExtensions.join("、").toUpperCase()
: "不限";
const configuredSize = Number(mat.maxFileSizeMb);
const maxFileSizeMb = configuredSize > 0
? Math.min(configuredSize, PUBLIC_MATERIAL_MAX_FILE_SIZE_MB)
: PUBLIC_MATERIAL_MAX_FILE_SIZE_MB;
const sizeText = ` · 单个不超过 ${maxFileSizeMb} MB`;
return `最多 ${getMaxFileCount(mat)} 份 · 格式:${formatText}${sizeText}`;
}
function isImageFile(file: any) {
const suffix = String(
file.fileSuffix || file.fileName?.split(".").pop() || ""
).toLowerCase().replace(/^\./, "");
return ["png", "jpg", "jpeg", "gif", "bmp", "webp"].includes(suffix);
}
function statusTagType(status?: string) {
switch (status) {
case "pending_fill": return "warning";
case "reviewing": return "primary";
case "approved":
case "archived": return "success";
case "rejected":
case "revoked": return "danger";
default: return "info";
}
}
function statusText(status?: string) {
switch (status) {
case "pending_fill": return "待填写";
case "reviewing": return "审核中";
case "approved": return "已通过";
case "archived": return "已归档";
case "rejected": return "已退回";
case "revoked": return "已撤销";
default: return status || "未知";
}
}
function formatDateTime(str?: string) {
return str ? str.replace("T", " ").substring(0, 16) : "";
}
async function load(silent = false) {
if (!token.value) {
error.value = "缺少入职访问 Token";
return;
}
if (!silent) {
loading.value = true;
}
error.value = "";
try {
const res = await onboardingPublicApi.detail({ token: token.value });
const data = res.data;
task.value = data.task;
contracts.value = (data.contracts || []).map((c: any) => ({
...c,
hasViewed: Boolean(c.readAt),
isAgreed: c.contractStatus === "confirmed"
}));
contracts.value.forEach((contract: any) => {
const key = String(contract.id);
contractPrepareStatus[key] = contract.generatedDocxFileId ? "ready" : "idle";
contractPrepareErrors[key] = "";
});
materialRequirements.value = data.materialRequirements || [];
uploadedMaterials.value = data.materials || [];
contractVariables.value = data.contractVariables || [];
formFieldRequirements.value = data.formFields || [];
Object.keys(customVariables).forEach(key => delete customVariables[key]);
extraVariables.value.forEach((variable: any) => {
if (!(variable.variableCode in customVariables) && variable.defaultValue != null) {
customVariables[variable.variableCode] = String(variable.defaultValue);
}
});
// 回填入职登记表
if (data.employeeInfo) {
Object.assign(form, data.employeeInfo);
if (data.employeeInfo.educationHistoryJson) {
try { educationHistory.value = JSON.parse(data.employeeInfo.educationHistoryJson); } catch { }
}
if (data.employeeInfo.workHistoryJson) {
try { workHistory.value = JSON.parse(data.employeeInfo.workHistoryJson); } catch { }
}
if (data.employeeInfo.familyMembersJson) {
try { familyMembers.value = JSON.parse(data.employeeInfo.familyMembersJson); } catch { }
}
if (data.employeeInfo.customVariablesJson) {
try {
const savedVariables = JSON.parse(data.employeeInfo.customVariablesJson);
extraVariables.value.forEach((variable: any) => {
if (Object.prototype.hasOwnProperty.call(savedVariables, variable.variableCode)) {
customVariables[variable.variableCode] = savedVariables[variable.variableCode];
}
});
} catch { }
}
}
await refreshMaterialPreviews();
} catch (err: any) {
error.value = err.message || "无法加载入职任务";
} finally {
if (!silent) {
loading.value = false;
}
}
}
async function saveCurrentEmployeeInfo() {
form.employeeName = String(form.employeeName || "").trim();
form.phone = String(form.phone || "").trim();
form.email = String(form.email || "").trim();
form.idCardNumber = normalizeIdCardNumber(form.idCardNumber);
form.bankCardNumber = normalizeBankCardNumber(form.bankCardNumber);
form.bankName = String(form.bankName || "").trim();
form.bankAccountName = form.employeeName; // 开户名始终与员工姓名保持一致
const payload = {
...form,
token: token.value,
educationHistoryJson: JSON.stringify(educationHistory.value),
workHistoryJson: JSON.stringify(workHistory.value),
familyMembersJson: JSON.stringify(familyMembers.value),
customVariablesJson: JSON.stringify(Object.fromEntries(
extraVariables.value.map((variable: any) => [variable.variableCode, customVariables[variable.variableCode] ?? ""])
))
};
await onboardingPublicApi.saveEmployeeInfo(payload);
}
/**
* Element Plus 校验失败时会拒绝 Promise 并返回字段错误对象。
* 这是正常的表单提示,不应继续交给全局异常处理显示“未知错误”。
*/
async function validateRegistrationForm() {
if (!regFormRef.value) {
return false;
}
try {
await regFormRef.value.validate();
return true;
} catch (fields: any) {
const firstField = Object.keys(fields || {})[0];
const firstMessage = firstField ? fields[firstField]?.[0]?.message : "";
if (firstField) {
regFormRef.value.scrollToField(firstField);
}
ElMessage.warning(firstMessage || "请检查并完善标红的登记信息");
await nextTick();
locateValidationTarget(document.querySelector<HTMLElement>(".reg-form .el-form-item.is-error"));
return false;
}
}
async function nextStep() {
if (savingStep.value) {
return;
}
if (isAnyMaterialUploading.value) {
ElMessage.closeAll();
ElMessage.warning("材料正在上传中,请稍候再点击下一步");
return;
}
savingStep.value = true;
try {
if (currentStepKey.value === "registration") {
if (!await validateRegistrationForm()) {
return;
}
if (!validateRegistrationLists()) {
return;
}
if (canEdit.value) {
await saveCurrentEmployeeInfo();
ElMessage.closeAll();
ElMessage.success("入职登记表已临时保存");
}
} else if (currentStepKey.value === "extra") {
if (!validateExtraVariables()) {
return;
}
if (canEdit.value) {
await saveCurrentEmployeeInfo();
}
} else if (currentStepKey.value === "materials") {
if (isAnyMaterialUploading.value) {
ElMessage.closeAll();
ElMessage.warning("材料正在上传中,请稍候再点击下一步");
return;
}
const missing = materialRequirements.value.filter(m => m.required && !getUploadedMaterials(m.materialTypeId).length);
if (missing.length) {
ElMessage.closeAll();
ElMessage.warning(`请先上传必传材料:${missing.map((m: any) => m.materialName).join("、")}`);
locateValidationTarget(`material-requirement-${missing[0].materialTypeId}`);
return;
}
}
currentStep.value++;
window.scrollTo({ top: 0, behavior: "smooth" });
if (currentStepKey.value === "confirmation") {
await prepareContracts();
}
} catch (error: any) {
const message = error?.msg || error?.message || "临时保存失败,请稍后重试";
ElMessage.closeAll();
await ElMessageBox.alert(message, "无法进入下一步", {
type: "warning",
confirmButtonText: "我知道了",
closeOnClickModal: false
}).catch(() => undefined);
} finally {
savingStep.value = false;
}
}
function materialPreviewKey(file: any) {
return `${file.id}-${file.fileId || 0}`;
}
function getMaterialSourceUrl(file: any) {
return materialPreviewUrls[materialPreviewKey(file)] || "";
}
function revokeMaterialPreviewUrls() {
Object.values(materialPreviewUrls).forEach(url => URL.revokeObjectURL(url));
Object.keys(materialPreviewUrls).forEach(key => delete materialPreviewUrls[key]);
}
async function refreshMaterialPreviews() {
const generation = ++materialPreviewGeneration;
revokeMaterialPreviewUrls();
await Promise.all(uploadedMaterials.value
.filter(file => isImageFile(file))
.map(async file => {
try {
const result = await onboardingPublicApi.downloadMaterial({
token: token.value,
taskMaterialId: file.id,
fileId: file.fileId
}, false);
const objectUrl = URL.createObjectURL(result.blob);
if (generation !== materialPreviewGeneration) {
URL.revokeObjectURL(objectUrl);
return;
}
materialPreviewUrls[materialPreviewKey(file)] = objectUrl;
} catch {
// 单张历史图片损坏时保留文件信息,不阻断整个入职任务。
}
}));
}
async function uploadMaterial(
options: any,
materialTypeId: number,
taskMaterialId?: number
) {
try {
const response = await onboardingPublicApi.uploadMaterial({
token: token.value,
materialTypeId,
taskMaterialId,
file: options.file
});
options.onSuccess(response);
} catch (error: any) {
options.onError(error);
}
}
function isMaterialUploading(materialTypeId: number) {
return Boolean(uploadingMaterials[String(materialTypeId)]);
}
function setMaterialUploading(materialTypeId: number, value: boolean) {
uploadingMaterials[String(materialTypeId)] = value;
}
function beforeUpload(file: File, mat: any) {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const allowed = getAllowedExtensions(mat);
if (allowed.length && !allowed.includes(ext)) {
ElMessage.error(`仅支持格式:${mat.allowedExtensions}`);
return false;
}
const configuredSize = Number(mat.maxFileSizeMb);
const maxFileSizeMb = configuredSize > 0
? Math.min(configuredSize, PUBLIC_MATERIAL_MAX_FILE_SIZE_MB)
: PUBLIC_MATERIAL_MAX_FILE_SIZE_MB;
if (file.size > maxFileSizeMb * 1024 * 1024) {
ElMessage.error(`单个文件不能超过 ${maxFileSizeMb} MB,请压缩后重新上传`);
return false;
}
setMaterialUploading(mat.materialTypeId, true);
return true;
}
async function onUploadSuccess(res: any, materialTypeId: number) {
if (res.code !== 200 && res.code !== 0) {
setMaterialUploading(materialTypeId, false);
return ElMessage.error(res.msg || "上传失败");
}
try {
await load(true);
ElMessage.success("材料上传成功");
} finally {
setMaterialUploading(materialTypeId, false);
}
}
function onUploadError(error: any, materialTypeId: number) {
setMaterialUploading(materialTypeId, false);
if (!error?.reported) {
ElMessage.error(error?.message || "材料上传失败,请稍后重试");
}
}
async function deleteFile(id: number) {
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm("确认删除已上传材料?", "删除确认", { type: "warning" })
);
if (!confirmed) {
return;
}
await onboardingPublicApi.deleteMaterial({ token: token.value, taskMaterialId: id });
ElMessage.success("材料已删除");
await load();
}
/**
* Element Plus 在取消或关闭确认框时会拒绝 Promise。
* 取消属于正常操作,不应继续交给全局异常提示处理。
*/
async function isMessageBoxConfirmed(confirmPromise: Promise<unknown>) {
try {
await confirmPromise;
return true;
} catch (action) {
if (action === "cancel" || action === "close") {
return false;
}
throw action;
}
}
function getContractPrepareStatus(taskContractId: number) {
return contractPrepareStatus[String(taskContractId)] || "idle";
}
function getContractPrepareError(taskContractId: number) {
return contractPrepareErrors[String(taskContractId)] || "";
}
function isContractPreparing(taskContractId: number) {
return getContractPrepareStatus(taskContractId) === "preparing";
}
function isContractDownloading(taskContractId: number) {
return Boolean(contractDownloading[String(taskContractId)]);
}
function isContractBusy(taskContractId: number) {
return isContractPreparing(taskContractId) || isContractDownloading(taskContractId);
}
function isContractPrepared(taskContractId: number) {
return getContractPrepareStatus(taskContractId) === "ready";
}
function isContractPrepareFailed(taskContractId: number) {
return getContractPrepareStatus(taskContractId) === "error";
}
function getContractActionText(taskContractId: number) {
if (isContractDownloading(taskContractId)) {
return "正在下载签署材料";
}
const status = getContractPrepareStatus(taskContractId);
if (status === "preparing") {
return "正在准备签署材料";
}
if (status === "error") {
return "重新准备签署材料";
}
if (status === "ready") {
return "下载并查看签署材料";
}
return "等待准备签署材料";
}
/**
* 提前校验变量并生成合同,让业务异常能够在当前页面直接展示。
*/
async function prepareContract(contract: any) {
const key = String(contract.id);
contractPrepareStatus[key] = "preparing";
contractPrepareErrors[key] = "";
try {
const previousFileId = contract.generatedDocxFileId;
const response = await onboardingPublicApi.prepareContract({
token: token.value,
taskContractId: contract.id
});
// 使用后端返回的新文件 Id 作为下载版本,避免手机浏览器复用旧合同缓存。
contract.generatedDocxFileId = response.data.fileId;
contract.generatedAt = response.data.generatedAt;
if (previousFileId && previousFileId !== response.data.fileId) {
contract.hasViewed = false;
contract.isAgreed = false;
}
contractPrepareStatus[key] = "ready";
return true;
} catch (err: any) {
const message = err?.msg || err?.message || "签署材料准备失败,请稍后重试";
contractPrepareStatus[key] = "error";
contractPrepareErrors[key] = message;
// 业务异常已由统一请求拦截器提示;仅为网络等非标准异常补充提示,避免重复弹窗。
if (!err?.msg) {
ElMessage.error(`${contract.contractName}:${message}`);
}
return false;
}
}
/**
* 进入合同确认步骤时逐份准备,避免同时生成多个大文件造成服务端瞬时压力。
*/
async function prepareContracts() {
for (const contract of contracts.value) {
await prepareContract(contract);
}
}
async function handleContractAction(contract: any) {
if (isContractPrepareFailed(contract.id)) {
const prepared = await prepareContract(contract);
if (prepared) {
ElMessage.success("签署材料已重新准备完成,请再次点击下载查看");
}
return;
}
if (isContractPrepared(contract.id)) {
await previewAndDownloadContract(contract);
}
}
function normalizeDownloadFileName(fileName: string | undefined, fallback: string) {
const normalized = String(fileName || fallback)
.replace(/[\\/:*?"<>|]/g, "_")
.trim();
return normalized || fallback;
}
function triggerContractDownload(blob: Blob, fileName: string) {
if (!blob.size) {
throw new Error("服务器返回的合同文件为空");
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
link.rel = "noopener noreferrer";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
// 安卓 WebView 可能延迟接管 Blob 下载,不能过早释放。
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}
function buildDirectContractDownloadUrl(c: any) {
const url = new URL("/api/open/onboarding/contract-source", window.location.origin);
url.searchParams.set("token", token.value);
url.searchParams.set("taskContractId", String(c.id));
if (c.generatedDocxFileId) {
url.searchParams.set("version", String(c.generatedDocxFileId));
}
url.searchParams.set("_t", String(Date.now()));
return `${url.pathname}${url.search}`;
}
function triggerDirectContractDownload(url: string) {
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
}
async function previewAndDownloadContract(c: any) {
const key = String(c.id);
if (contractDownloading[key]) {
return;
}
contractDownloading[key] = true;
try {
const userAgent = window.navigator.userAgent;
if (/Android/i.test(userAgent)) {
// 安卓 WebView 通常不支持 blob: 下载,直接交给系统的 HTTP 下载能力处理。
// 此处在用户点击事件后的第一个同步分支触发,避免被 WebView 当成弹窗拦截。
triggerDirectContractDownload(buildDirectContractDownloadUrl(c));
c.hasViewed = true;
c.isAgreed = false;
ElMessage.success("签署材料下载已发起,请使用 Word/WPS 打开核对");
if (/MicroMessenger/i.test(userAgent)) {
void ElMessageBox.alert(
"如果微信中没有出现文件,请点击右上角“…”选择“在浏览器打开”,然后再次点击下载。",
"安卓微信下载提示",
{ confirmButtonText: "我知道了" }
).catch(() => undefined);
}
return;
}
const result = await onboardingPublicApi.downloadContract({
token: token.value,
taskContractId: c.id,
fileVersion: c.generatedDocxFileId
});
const fallbackName = `${c.contractName || "入职签署材料"}.docx`;
triggerContractDownload(
result.blob,
normalizeDownloadFileName(result.fileName, fallbackName)
);
c.hasViewed = true;
c.isAgreed = false;
ElMessage.success("签署材料已开始下载,请使用 Word/WPS 打开核对");
} catch (err: any) {
if (!err?.reported) {
ElMessage.error(err?.message || "签署材料下载失败,请稍后重试");
}
} finally {
contractDownloading[key] = false;
}
}
async function submitAll() {
if (!isIdentityConfirmationComplete.value) {
const missingLabels = identityFields
.filter(({ key }) => !isIdentityFieldConfirmed(key))
.map(item => item.label);
ElMessage.warning(`请逐项核对并确认实名认证信息:${missingLabels.join("、")}`);
locateValidationTarget("identity-confirmation-card");
return;
}
const unprepared = contracts.value.filter(c => !isContractPrepared(c.id));
if (unprepared.length) {
ElMessage.warning(`请先等待签署材料准备完成:${unprepared.map(c => c.contractName).join("、")}`);
locateValidationTarget(`contract-confirm-${unprepared[0].id}`);
return;
}
const unviewed = contracts.value.filter(c => !c.hasViewed);
if (unviewed.length) {
ElMessage.warning(`请先下载并查看签署材料:${unviewed.map(c => c.contractName).join("、")}`);
locateValidationTarget(`contract-confirm-${unviewed[0].id}`);
return;
}
const unconfirmed = contracts.value.filter(c => !c.isAgreed);
if (unconfirmed.length) {
ElMessage.warning(`请先勾选同意全部签署材料(未勾选:${unconfirmed.map(c => c.contractName).join("、")})`);
locateValidationTarget(`contract-confirm-${unconfirmed[0].id}`);
return;
}
const confirmed = await isMessageBoxConfirmed(
ElMessageBox.confirm("请再次确认姓名、身份证号码和手机号码均准确无误。提交后将进入人事复核建档流程,您不能再自行修改。", "最终提交确认", {
type: "success",
confirmButtonText: "确认提交",
cancelButtonText: "再检查一下"
})
);
if (!confirmed) {
return;
}
submitting.value = true;
try {
// 员工资料在进入材料步骤前已经保存;此处重复保存会使刚生成的合同失效。
for (const c of contracts.value) {
await onboardingPublicApi.confirmContract({ token: token.value, taskContractId: c.id });
}
await onboardingPublicApi.submit({
token: token.value,
identityInformationConfirmed: true,
confirmedEmployeeName: identityConfirmedValues.employeeName,
confirmedIdCardNumber: identityConfirmedValues.idCardNumber,
confirmedPhone: identityConfirmedValues.phone
});
ElMessage.success("入职资料已成功提交!请等待人事审核。");
await load();
} finally {
submitting.value = false;
}
}
/** 入职填写页使用固定移动端比例,离开页面后恢复系统原有 viewport 配置。 */
onMounted(() => {
if (viewportMeta) {
viewportMeta.content = [
"width=device-width",
"initial-scale=1.0",
"minimum-scale=1.0",
"maximum-scale=1.0",
"user-scalable=no",
"viewport-fit=cover"
].join(", ");
}
currentTimestamp.value = Date.now();
linkExpiryTimer = window.setInterval(() => {
currentTimestamp.value = Date.now();
}, 1000);
load();
});
onBeforeUnmount(() => {
materialPreviewGeneration++;
revokeMaterialPreviewUrls();
if (linkExpiryTimer !== undefined) {
window.clearInterval(linkExpiryTimer);
}
if (viewportMeta && originalViewportContent) {
viewportMeta.content = originalViewportContent;
}
});
</script>
<style scoped>
/* ============================================================
Solid Fresh Light Green (纯色清爽淡绿,无渐变) 视觉系统
============================================================ */
.fill-page {
min-height: 100vh;
background: #f8faf9;
padding-bottom: 60px;
}
/* 异常状态居中卡片样式(对齐 public-status 风格) */
.fill-page.is-error-state {
display: flex;
min-height: 100vh;
padding: 24px;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f0fdf7;
}
.public-error-container {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
}
.error-status-card {
width: min(100%, 420px);
padding: 42px 28px;
text-align: center;
background: #ffffff;
border: 1px solid #d1fae5;
border-radius: 16px;
box-shadow: 0 12px 36px rgb(5 150 105 / 10%);
}
.error-status-mark {
display: flex;
width: 56px;
height: 56px;
margin: 0 auto 20px;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 30px;
font-weight: 700;
background: #059669;
border-radius: 50%;
}
.error-status-title {
margin: 0;
color: #064e3b;
font-size: 24px;
font-weight: 700;
line-height: 1.3;
}
.error-status-desc {
margin: 14px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.8;
}
.error-status-actions {
margin-top: 24px;
}
.error-retry-btn {
height: 40px;
padding: 0 26px;
font-size: 15px;
font-weight: 500;
border-radius: 8px;
background-color: #059669 !important;
border-color: #059669 !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.2);
transition: all 0.2s ease;
}
.error-retry-btn:hover {
background-color: #047857 !important;
border-color: #047857 !important;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(5, 150, 105, 0.3);
}
/* 顶部大背景 (Hero Header Background) - 纯色柔和淡绿,去渐变 */
.brand-header {
background: #ecfdf5;
color: #111827;
padding: 46px 20px 54px;
text-align: center;
position: relative;
border-bottom: 1px solid #d1fae5;
}
/* 顶部平台标识 (Header Tag) */
.brand-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: #ffffff;
border: 1px solid #a7f3d0;
padding: 5px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: #065f46;
letter-spacing: 0.5px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.brand-badge .el-icon {
color: #059669;
font-size: 14px;
}
.brand-title {
margin: 0 0 8px 0;
font-size: 26px;
font-weight: 700;
letter-spacing: 1.5px;
color: #064e3b;
}
.brand-subtitle {
margin: 0 auto;
font-size: 14px;
line-height: 1.6;
color: #374151;
max-width: 680px;
}
.main-container {
max-width: 980px;
margin: -26px auto 0;
padding: 0 16px;
position: relative;
z-index: 1;
}
/* 任务状态卡片 (Status Banner) */
.status-banner {
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
padding: 14px 22px;
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.status-banner-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
}
.status-badge-art {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.task-no {
font-weight: 600;
color: #111827;
font-size: 14px;
}
.status-tag {
border-color: #a7f3d0 !important;
color: #065f46 !important;
background-color: #ecfdf5 !important;
}
.expire-time {
font-size: 13px;
color: #4b5563;
}
.link-expired-alert,
.reject-reason-alert {
margin-bottom: 16px;
}
.reject-reason-alert :deep(.el-alert__description) {
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* 主向导卡片 (Wizard Card) */
.wizard-card {
background: #ffffff;
border-radius: 10px;
padding: 28px 26px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
}
/* 流程指示器 (Step Bar) */
.steps-wrapper {
margin-bottom: 30px;
padding: 6px 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.steps-wrapper :deep(.el-step__title) {
font-size: 13px;
font-weight: 500;
color: #9ca3af;
}
.steps-wrapper :deep(.el-step__title.is-process) {
font-weight: 700;
color: #059669;
}
.steps-wrapper :deep(.el-step__head.is-process) {
color: #059669;
border-color: #059669;
}
.steps-wrapper :deep(.el-step__head.is-process .el-step__icon.is-text) {
background: #059669;
border-color: #059669;
color: #ffffff;
}
.steps-wrapper :deep(.el-step__title.is-wait) {
color: #9ca3af;
}
.steps-wrapper :deep(.el-step__head.is-wait) {
color: #9ca3af;
border-color: #e5e7eb;
}
.steps-wrapper :deep(.el-step__head.is-wait .el-step__icon.is-text) {
background: #f9fafb;
color: #9ca3af;
}
.steps-wrapper :deep(.el-step__title.is-success) {
color: #374151;
font-weight: 600;
}
.steps-wrapper :deep(.el-step__head.is-success) {
color: #059669;
border-color: #059669;
}
.steps-wrapper :deep(.el-step__head.is-success .el-step__icon.is-text) {
background: #ecfdf5;
color: #059669;
}
.steps-wrapper :deep(.el-step__line) {
background-color: #e5e7eb;
}
.steps-wrapper :deep(.el-step__head.is-success .el-step__line) {
background-color: #059669;
}
.registration-header {
text-align: center;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 18px;
margin-bottom: 26px;
}
.reg-title {
letter-spacing: 6px;
font-size: 22px;
font-weight: 700;
color: #111827;
margin: 0 0 8px 0;
}
.reg-subtitle {
font-size: 13px;
line-height: 1.6;
color: #4b5563;
margin: 0 auto;
max-width: 720px;
}
/* 卡片分区样式:淡绿顶边框与纯白背景 */
.card-section {
background: #ffffff;
border: 1px solid #e5e7eb;
border-top: 2px solid #059669;
border-radius: 8px;
padding: 20px 22px;
margin-bottom: 22px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
}
.card-section-title {
font-weight: 700;
font-size: 15px;
color: #111827;
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 10px;
}
/* 模块标题图标 (Section Icon) - 纯色无渐变 */
.title-icon-box {
width: 28px;
height: 28px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #059669;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.card-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.card-section-header .card-section-title {
margin-bottom: 0;
}
.required-asterisk {
color: #e11d48;
margin-right: 4px;
font-weight: 700;
font-size: 15px;
}
.section-action-btn {
background-color: #ecfdf5 !important;
border: 1px solid #a7f3d0 !important;
color: #059669 !important;
font-weight: 500;
border-radius: 6px;
height: 28px;
line-height: 26px;
padding: 0 10px;
font-size: 12px;
transition: all 0.2s ease-in-out;
}
.section-action-btn :deep(.el-icon) {
color: inherit !important;
}
.section-action-btn:hover {
background-color: #059669 !important;
border-color: #059669 !important;
color: #ffffff !important;
}
.section-action-btn:active {
background-color: #047857 !important;
border-color: #047857 !important;
color: #ffffff !important;
}
.dynamic-row-box {
background: #fbfdfb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.row-badge-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.row-badge {
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
border: 1px solid #a7f3d0;
padding: 2px 8px;
border-radius: 4px;
}
.empty-hint {
font-size: 13px;
color: #9ca3af;
padding: 10px 0;
text-align: center;
}
.full-width {
width: 100%;
}
.section-header {
margin-bottom: 22px;
}
.section-title {
margin: 0 0 6px 0;
font-size: 18px;
font-weight: 700;
color: #111827;
}
.section-desc {
margin: 0;
font-size: 13px;
color: #4b5563;
}
.materials-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.material-item-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
}
.mat-card-header {
margin-bottom: 12px;
}
.mat-title-box {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.mat-name {
font-weight: 700;
color: #111827;
}
.mat-limit {
font-size: 12px;
color: #6b7280;
}
.file-badge {
display: flex;
align-items: center;
gap: 10px;
background: #fbfdfb;
border: 1px solid #e5e7eb;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 8px;
}
.material-thumbnail {
width: 68px;
height: 68px;
flex: 0 0 68px;
border-radius: 6px;
cursor: zoom-in;
}
.material-file-icon {
width: 36px;
height: 36px;
flex: 0 0 36px;
font-size: 26px;
}
.file-content {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size,
.preview-tip,
.upload-count {
font-size: 12px;
color: #6b7280;
}
.preview-tip {
color: #059669;
}
.file-actions,
.upload-action-box {
display: flex;
align-items: center;
gap: 8px;
}
.upload-action-box {
flex-wrap: wrap;
margin-top: 4px;
}
.upload-limit-reached {
margin-top: 4px;
padding: 8px 10px;
border-radius: 6px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
line-height: 1.5;
}
/* 身份确认卡片 */
.identity-confirmation-card {
margin-bottom: 26px;
padding: 20px 22px;
border: 1px solid #bbf7d0;
border-top: 3px solid #059669;
border-radius: 8px;
background: #f0fdf4;
box-sizing: border-box;
}
.identity-confirmation-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}
.identity-confirmation-title {
margin: 0 0 6px;
color: #111827;
font-size: 16px;
font-weight: 700;
}
.identity-confirmation-desc {
margin: 0;
color: #4b5563;
font-size: 13px;
line-height: 1.6;
}
.identity-confirmation-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.identity-confirmation-item {
min-width: 0;
padding: 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #ffffff;
transition: all 0.2s;
}
.identity-confirmation-item:hover {
border-color: #059669;
}
.identity-field-row {
display: flex;
align-items: baseline;
gap: 14px;
margin-bottom: 9px;
min-width: 0;
}
.identity-field-label {
flex: 0 0 86px;
color: #4b5563;
font-size: 13px;
}
.identity-field-value {
min-width: 0;
color: #111827;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
overflow-wrap: anywhere;
}
.identity-confirmation-checkbox {
max-width: 100%;
height: auto;
margin-right: 0;
white-space: normal;
color: #111827;
}
.identity-confirmation-checkbox :deep(.el-checkbox__input) {
flex: 0 0 auto;
margin-top: 3px;
}
.identity-confirmation-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #059669;
border-color: #059669;
}
.identity-confirmation-checkbox :deep(.el-checkbox__label) {
min-width: 0;
padding-left: 8px;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.5;
}
.identity-confirmation-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed #d1d5db;
}
.identity-confirmation-progress {
color: #4b5563;
font-size: 13px;
font-weight: 600;
}
.identity-confirmation-progress.is-complete {
color: #059669;
}
.contract-sign-card {
border: 1px solid #e5e7eb;
border-left: 3px solid #059669;
border-radius: 8px;
padding: 20px;
background: #ffffff;
margin-bottom: 16px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
}
.contract-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
min-width: 0;
}
.contract-title {
font-size: 16px;
font-weight: 700;
color: #111827;
min-width: 0;
overflow-wrap: anywhere;
}
.contract-desc {
font-size: 13px;
color: #4b5563;
margin-bottom: 14px;
}
.contract-prepare-error {
margin-bottom: 14px;
}
.contract-preparing-alert {
margin-bottom: 16px;
}
.contract-card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
border-top: 1px dashed #e5e7eb;
padding-top: 14px;
min-width: 0;
max-width: 100%;
}
.agree-checkbox {
font-weight: 600;
min-width: 0;
max-width: 100%;
height: auto;
margin-right: 0;
white-space: normal;
}
.agree-checkbox :deep(.el-checkbox__input) {
flex: 0 0 auto;
margin-top: 3px;
}
.agree-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #059669;
border-color: #059669;
}
.agree-checkbox :deep(.el-checkbox__label) {
min-width: 0;
max-width: 100%;
padding-left: 8px;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.5;
}
.contract-view-hint {
margin-top: 10px;
color: #059669;
font-size: 13px;
line-height: 1.5;
}
.confirmed-text {
color: #059669;
font-weight: 600;
font-size: 14px;
}
/* 底部操作栏吸底优化 (Wizard Footer) - 纯色无渐变 */
.wizard-footer {
display: flex;
justify-content: flex-end;
gap: 14px;
margin-top: 32px;
border-top: 1px solid #e5e7eb;
padding-top: 20px;
position: sticky;
bottom: 0;
background: rgba(248, 250, 249, 0.96);
backdrop-filter: blur(8px);
padding-bottom: max(12px, env(safe-area-inset-bottom));
z-index: 10;
}
.wizard-footer .el-button--primary {
background-color: #059669;
border: 1px solid #059669;
color: #ffffff;
font-weight: 600;
border-radius: 6px;
padding: 10px 24px;
height: 40px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: all 0.2s;
}
.wizard-footer .el-button--primary:hover {
background-color: #047857;
border-color: #047857;
color: #ffffff;
}
.wizard-footer .el-button--primary:active {
background-color: #065f46;
border-color: #065f46;
color: #ffffff;
}
.wizard-footer .el-button--default {
border-radius: 6px;
padding: 10px 20px;
height: 40px;
color: #374151;
border-color: #d1d5db;
background-color: #ffffff;
}
.wizard-footer .el-button--default:hover {
color: #059669;
border-color: #059669;
background-color: #ecfdf5;
}
.wizard-footer .el-button--success {
background-color: #059669 !important;
border-color: #059669 !important;
color: #ffffff !important;
font-weight: 600;
border-radius: 6px;
padding: 10px 24px;
height: 40px;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.25);
transition: all 0.2s;
}
.wizard-footer .el-button--success:hover {
background-color: #047857 !important;
border-color: #047857 !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.35);
}
.wizard-footer .el-button--success:active {
background-color: #065f46 !important;
border-color: #065f46 !important;
color: #ffffff !important;
}
/* 个人信息已完整覆盖卡片与图标 */
.all-covered-card {
padding: 38px 24px;
background: #ffffff;
border: 1px solid #d1fae5;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.06);
text-align: center;
margin: 16px 0 24px;
}
.covered-status-mark {
display: flex;
width: 56px;
height: 56px;
margin: 0 auto 16px;
align-items: center;
justify-content: center;
color: #ffffff;
background: #059669;
border-radius: 50%;
box-shadow: 0 4px 14px rgba(5, 150, 105, 0.25);
}
.covered-status-title {
margin: 0 0 10px;
color: #064e3b;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.5px;
}
.covered-status-desc {
max-width: 480px;
margin: 0 auto;
color: #64748b;
font-size: 14px;
line-height: 1.7;
}
.readonly-banner {
margin-bottom: 24px;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
/* 移动端步骤条 - 纯色无渐变 */
.mobile-steps-wrapper {
margin-bottom: 24px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 14px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
}
.mobile-steps-track {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.mobile-step-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.step-indicator-bar {
width: 100%;
height: 4px;
border-radius: 2px;
background: #e5e7eb;
transition: all 0.3s;
}
.mobile-step-item.is-active .step-indicator-bar {
background: #059669;
height: 4px;
}
.mobile-step-item.is-finished .step-indicator-bar {
background: #059669;
}
.step-label {
font-size: 11px;
color: #9ca3af;
white-space: nowrap;
}
.mobile-step-item.is-active .step-label {
color: #059669;
font-weight: 700;
}
.mobile-step-item.is-finished .step-label {
color: #374151;
font-weight: 600;
}
.mobile-current-indicator {
font-size: 12px;
color: #4b5563;
text-align: center;
border-top: 1px dashed #e5e7eb;
padding-top: 8px;
}
.highlight-step {
color: #059669;
font-weight: 700;
}
/* 表单与交互元素 (Form Controls) */
.reg-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #111827;
margin-bottom: 6px;
}
/* 必填星号 * 颜色:#e11d48 */
.reg-form :deep(.el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label:before),
.reg-form :deep(.el-form-item.is-required:not(.is-no-asterisk) .el-form-item__label-wrap > .el-form-item__label:before) {
color: #e11d48 !important;
}
/* 输入框边框默认态与聚焦态纯色清晰 */
.reg-form :deep(.el-input__wrapper),
.reg-form :deep(.el-select__wrapper),
.reg-form :deep(.el-textarea__inner) {
box-shadow: 0 0 0 1px #d1d5db inset !important;
border-radius: 6px;
background-color: #ffffff;
transition: all 0.2s ease-in-out;
}
.reg-form :deep(.el-input__wrapper:hover),
.reg-form :deep(.el-select__wrapper:hover),
.reg-form :deep(.el-textarea__inner:hover) {
box-shadow: 0 0 0 1px #059669 inset !important;
}
.reg-form :deep(.el-input__wrapper.is-focus),
.reg-form :deep(.el-select__wrapper.is-focused),
.reg-form :deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px #059669 inset !important;
border-color: #059669 !important;
}
.readonly-derived-input :deep(.el-input__wrapper) {
background-color: #f9fafb !important;
cursor: default;
}
.readonly-derived-input :deep(.el-input__inner) {
color: #374151 !important;
-webkit-text-fill-color: #374151 !important;
font-weight: 500;
}
.readonly-derived-input :deep(.el-input__prefix) {
color: #059669;
}
@media (max-width: 640px) {
.fill-page :deep(.el-input__inner),
.fill-page :deep(.el-textarea__inner),
.fill-page :deep(.el-select__input) {
font-size: 16px !important;
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: block !important;
}
.brand-header {
padding: 32px 16px 40px;
}
.brand-title {
font-size: 22px;
}
.main-container {
padding: 0 10px;
}
.wizard-card {
padding: 20px 16px;
}
.status-banner {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.status-badge-art {
display: none;
}
.file-badge {
align-items: flex-start;
flex-wrap: wrap;
}
.file-actions {
width: 100%;
justify-content: flex-end;
}
.identity-confirmation-card {
padding: 14px;
}
.identity-confirmation-heading,
.identity-confirmation-footer {
flex-direction: column;
align-items: stretch;
}
.identity-field-row {
flex-direction: column;
gap: 4px;
}
.identity-field-label {
flex-basis: auto;
}
.identity-confirmation-footer>.el-button {
width: 100%;
}
.contract-card-bottom {
flex-direction: column;
align-items: stretch;
}
.contract-sign-card {
padding: 16px;
}
.contract-card-bottom>.el-button,
.contract-card-bottom>.agree-checkbox {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
}
</style>