فهرست منبع

feat(onboard): 完善员工入职流程 — 新建/详情/材料上传/状态持久化

新增
- src/pages/manage/onboard-create.vue:新建入职流程表单(基础信息、联系方式、入职安排、备注)
- src/pages/manage/onboard-detail.vue:入职详情页(进度条、时间线、材料清单、信息模块、材料上传/重命名/删除/预览)

mock 数据
- src/data/mock.js:新增 onboardStatus / onboardStages / onboardings 示例数据、
  onboardOptions(部门/招聘来源/试用期)、generateEmployeeId 工号生成工具;
  同步把 resign 相关 mock 数据和 workModules 离职入口一起加入(无法拆 hunk)

持久化
- src/utils/storage.js:新增 ONBOARD_MATERIALS_KEY / ONBOARD_LIST_KEY 两条本地存储;
  addOnboarding / updateOnboarding / getOnboardingById / mergeOnboardings 等入职 CRUD,
  以及材料上传 mockUpload / readFileAsBase64(H5/小程序/App 三端文件读取)
- 同步加入离职本地状态函数(getResignations / getResignDetail / setResignStatus /
  addResignAssetReturn / setResignLocalState)和 addApplication 支持服务端 id

人事管理整合
- src/pages/manage/index.vue:configs 改为 computed 响应式;onboard 类型渲染
  列表 + 顶部「发起新的入职流程」banner;新增 resign 类型配置;showDetail 按 id
  跳转到对应详情页;status 过滤增加「已离职/已逾期/已撤回」

注:本提交包含 mock.js 与 storage.js 中为离职模块预置的数据,下一提交再补完离职专属页面与 API。
xieyong 1 هفته پیش
والد
کامیت
f20421862f
5فایلهای تغییر یافته به همراه2016 افزوده شده و 43 حذف شده
  1. 366 1
      src/data/mock.js
  2. 137 40
      src/pages/manage/index.vue
  3. 424 0
      src/pages/manage/onboard-create.vue
  4. 852 0
      src/pages/manage/onboard-detail.vue
  5. 237 2
      src/utils/storage.js

+ 366 - 1
src/data/mock.js

@@ -17,7 +17,7 @@ export const quickActions = [
   { key: 'shift', label: '调班', icon: '调', color: '#7262fd' },
   { key: 'check', label: '补卡', icon: '卡', color: '#78d3f8' },
   { key: 'certificate', label: '证明', icon: '证', color: '#f6903d' },
-  { key: 'more', label: '更多', icon: '•••', color: '#8b939f' },
+  { key: 'resign', label: '离职', icon: '离', color: '#ee6666' },
 ]
 
 export const applicationTypes = {
@@ -55,6 +55,181 @@ export const notices = [
   { id: 3, title: '新版考勤管理制度自8月1日起执行', date: '07-28', tag: '制度发布', read: true },
 ]
 
+export const onboardStatus = {
+  pending: { label: '待入职', class: 'status-pending' },
+  processing: { label: '入职中', class: 'status-processing' },
+  done: { label: '已入职', class: 'status-approved' },
+  overdue: { label: '已逾期', class: 'status-rejected' },
+}
+
+// 入职流程节点定义(详情页时间线用)
+export const onboardStages = [
+  { key: 'offer', label: 'Offer 确认', desc: '候选人确认入职意向与到岗日期' },
+  { key: 'materials', label: '资料收集', desc: '身份证、学历、体检等入职材料' },
+  { key: 'contract', label: '合同签订', desc: '电子合同 / 纸质合同签署' },
+  { key: 'social', label: '社保办理', desc: '社保、公积金参保登记' },
+  { key: 'account', label: '账号开通', desc: '邮箱、门禁、办公系统账号' },
+  { key: 'training', label: '岗前培训', desc: '新员工入职培训与考试' },
+  { key: 'onboard', label: '部门接收', desc: '工位分配、直属上级、试用期安排' },
+]
+
+export const onboardings = [
+  {
+    id: 'ZY20260915',
+    name: '孙浩',
+    avatar: '孙',
+    department: '生产一部',
+    position: '工艺员',
+    source: '社会招聘',
+    status: 'pending',
+    expectedEntryDate: '2026-09-15',
+    entryDate: null,
+    probationMonths: 3,
+    probationEndDate: '2026-12-15',
+    mentor: '李明',
+    phone: '186 1234 5678',
+    email: 'sunhao@aimill.com',
+    materials: {
+      identityCard: true,
+      degreeCertificate: true,
+      physicalReport: true,
+      photo: true,
+      bankCard: true,
+      resignationCert: false,
+    },
+    contract: { status: '待签订', signMethod: '电子签', deadline: '2026-09-14' },
+    socialSecurity: { status: '办理中', city: '北京', deadline: '2026-09-30' },
+    training: { status: '未开始', name: '新员工入职培训', duration: '2天', deadline: '2026-09-20' },
+    workstation: { status: '已分配', location: '生产一部 3号工位' },
+    itAccount: { status: '待开通', items: ['邮箱', '门禁', 'OA'] },
+    stage: 'materials',
+  },
+  {
+    id: 'ZY20260918',
+    name: '胡静',
+    avatar: '胡',
+    department: '财务部',
+    position: '出纳',
+    source: '校园招聘',
+    status: 'processing',
+    expectedEntryDate: '2026-09-18',
+    entryDate: '2026-09-18',
+    probationMonths: 6,
+    probationEndDate: '2027-03-18',
+    mentor: '周倩',
+    phone: '135 7721 4521',
+    email: 'hujing@aimill.com',
+    materials: {
+      identityCard: true,
+      degreeCertificate: true,
+      physicalReport: true,
+      photo: true,
+      bankCard: true,
+      resignationCert: true,
+    },
+    contract: { status: '已签订', signMethod: '电子签', signDate: '2026-09-10' },
+    socialSecurity: { status: '已办理', city: '北京', deadline: '2026-09-25' },
+    training: { status: '进行中', name: '新员工入职培训', duration: '2天', deadline: '2026-09-20' },
+    workstation: { status: '已分配', location: '财务部 2楼 201' },
+    itAccount: { status: '已开通', items: ['邮箱', '门禁', 'OA', '财务系统'] },
+    stage: 'training',
+  },
+  {
+    id: 'ZY20260804',
+    name: '郑凯',
+    avatar: '郑',
+    department: '设备管理部',
+    position: '技工',
+    source: '社会招聘',
+    status: 'done',
+    expectedEntryDate: '2026-08-04',
+    entryDate: '2026-08-04',
+    probationMonths: 3,
+    probationEndDate: '2026-11-04',
+    mentor: '王建国',
+    phone: '137 2098 3201',
+    email: 'zhengkai@aimill.com',
+    materials: {
+      identityCard: true,
+      degreeCertificate: true,
+      physicalReport: true,
+      photo: true,
+      bankCard: true,
+      resignationCert: true,
+    },
+    contract: { status: '已签订', signMethod: '纸质签', signDate: '2026-08-03' },
+    socialSecurity: { status: '已办理', city: '北京', deadline: '2026-08-20' },
+    training: { status: '已完成', name: '新员工入职培训', duration: '2天', deadline: '2026-08-08' },
+    workstation: { status: '已分配', location: '设备管理部 主楼 B102' },
+    itAccount: { status: '已开通', items: ['邮箱', '门禁', 'OA', 'EAM'] },
+    stage: 'onboard',
+  },
+  {
+    id: 'ZY20260905',
+    name: '刘洋',
+    avatar: '刘',
+    department: '品质管理部',
+    position: '质量工程师',
+    source: '社会招聘',
+    status: 'overdue',
+    expectedEntryDate: '2026-09-05',
+    entryDate: null,
+    probationMonths: 3,
+    probationEndDate: null,
+    mentor: '赵磊',
+    phone: '186 9988 1100',
+    email: 'liuyang@aimill.com',
+    materials: {
+      identityCard: true,
+      degreeCertificate: true,
+      physicalReport: false,
+      photo: false,
+      bankCard: false,
+      resignationCert: true,
+    },
+    contract: { status: '待签订', signMethod: '电子签', deadline: '2026-09-04' },
+    socialSecurity: { status: '未办理', city: '北京', deadline: null },
+    training: { status: '未开始', name: '新员工入职培训', duration: '2天', deadline: null },
+    workstation: { status: '待分配', location: '' },
+    itAccount: { status: '未开通', items: [] },
+    stage: 'contract',
+  },
+]
+
+// 内置入职记录(mock);用户通过表单新增的记录由 storage.js 维护
+export const defaultOnboardings = onboardings
+
+// 入职流程可选项
+export const onboardOptions = {
+  sources: ['社会招聘', '校园招聘', '内部转岗', '实习留用'],
+  departments: [
+    '总经办',
+    '人力资源部',
+    '财务部',
+    '生产一部',
+    '生产二部',
+    '研发中心',
+    '品质管理部',
+    '设备管理部',
+    '采购部',
+    '市场部',
+  ],
+  probationMonths: [1, 2, 3, 6],
+  signMethods: ['电子签', '纸质签'],
+}
+
+// 工号生成:ZY + YYYYMMDD + 3 位序号;同一日期去重
+export function generateEmployeeId(usedIds = []) {
+  const d = new Date()
+  const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
+  for (let i = 0; i < 999; i++) {
+    const seq = String(i + 1).padStart(3, '0')
+    const id = `ZY${ymd}${seq}`
+    if (!usedIds.includes(id)) return id
+  }
+  return `ZY${ymd}${Date.now().toString().slice(-3)}`
+}
+
 export const workModules = [
   { group: '员工服务', items: [
     { label: '我的申请', icon: '申', color: '#5b8ff9', url: '/pages/apply/list' },
@@ -65,6 +240,7 @@ export const workModules = [
     { label: '培训学习', icon: '学', color: '#269a99', url: '/pages/training/index' },
     { label: '个人档案', icon: '档', color: '#f6903d', url: '/pages/profile/edit' },
     { label: '证明申请', icon: '证', color: '#6dc8ec', url: '/pages/apply/form?type=certificate' },
+    { label: '离职申请', icon: '离', color: '#ee6666', url: '/pages/apply/form?type=resign' },
   ] },
   { group: '人事管理', items: [
     { label: '审批中心', icon: '审', color: '#1677ff', url: '/pages/approval/index', badge: 3 },
@@ -74,6 +250,195 @@ export const workModules = [
     { label: '合同管理', icon: '合', color: '#7262fd', url: '/pages/manage/index?type=contract' },
     { label: '员工入职', icon: '入', color: '#78d3f8', url: '/pages/manage/index?type=onboard' },
     { label: '人事异动', icon: '动', color: '#f6903d', url: '/pages/manage/index?type=transfer' },
+    { label: '离职管理', icon: '离', color: '#ee6666', url: '/pages/manage/index?type=resign' },
     { label: '数据看板', icon: '数', color: '#5ad8a6', url: '/pages/manage/index?type=dashboard' },
   ] },
 ]
+
+export const resignStatus = {
+  pending:    { label: '待审批',  class: 'status-pending' },
+  processing: { label: '进行中',  class: 'status-processing' },
+  done:       { label: '已离职',  class: 'status-approved' },
+  overdue:    { label: '已逾期',  class: 'status-rejected' },
+  cancelled:  { label: '已撤回',  class: 'status-rejected' },
+}
+
+export const resignStages = [
+  { key: 'submit',      label: '申请提交',          desc: '员工提交离职申请并填写预计离职日' },
+  { key: 'manager',     label: '直属主管审批',      desc: '直属上级确认业务交接可行性' },
+  { key: 'department',  label: '部门负责人审批',    desc: '部门负责人审批离职安排' },
+  { key: 'hrbp',        label: 'HRBP 审批',         desc: 'HRBP 复核离职条件与补偿方案' },
+  { key: 'handover',    label: '工作交接',          desc: '交接人确认工作内容、客户与文档移交' },
+  { key: 'assetReturn', label: '资产归还',          desc: '办公设备、工牌、门禁卡归还登记' },
+  { key: 'finance',     label: '财务结算',          desc: '工资、补偿金、年假折算与社保停缴' },
+  { key: 'certificate', label: '离职证明/账号注销', desc: '开具离职证明并注销 OA/门禁/邮箱账号' },
+]
+
+export const resignings = [
+  {
+    id: 'ZY20260812',
+    name: '郭峰',
+    avatar: '郭',
+    department: '设备管理部',
+    position: '设备维修工',
+    entryDate: '2019-03-18',
+    status: 'processing',
+    expectedLeaveDate: '2026-08-25',
+    lastWorkDate: null,
+    submitDate: '2026-08-12',
+    type: '主动离职',
+    reason: '寻求个人发展',
+    handover: {
+      person: '王建国',
+      dept: '设备管理部',
+      progress: '70%',
+      deadline: '2026-08-25',
+      items: [
+        'EAM 系统权限与账号交接',
+        '3 号产线设备维护记录归档',
+        '测量仪器校准台账移交',
+        '备件库存盘点表交接',
+      ],
+    },
+    compensation: { scheme: 'N+1', amount: '¥ 48,200', status: '审批中', note: '按最近 12 个月平均工资计算' },
+    assetReturn: {
+      status: '部分归还',
+      items: [
+        { name: '工牌',      returned: true,  remark: '已交还前台' },
+        { name: '门禁卡',    returned: true,  remark: '已注销' },
+        { name: '办公电脑',  returned: false, remark: '预计 8/24 归还' },
+        { name: '测量仪器',  returned: false, remark: '等待王建国验收' },
+      ],
+    },
+    contractTermination: { method: '协商解除', deadline: '2026-08-25', status: '办理中' },
+    socialSecurity: { stopMonth: '2026-08', city: '北京', status: '办理中' },
+    financeSettle: { lastSalary: '¥ 12,800', annualLeave: '3 天', compensation: '¥ 23,400', payoutDate: '2026-08-31', status: '办理中' },
+    accountOff: { status: '办理中', items: ['OA', '门禁', 'EAM'] },
+    certificate: { status: '待开具', needCopy: true },
+    stage: 'handover',
+  },
+  {
+    id: 'ZY20260710',
+    name: '陈静',
+    avatar: '陈',
+    department: '财务部',
+    position: '成本会计',
+    entryDate: '2018-06-01',
+    status: 'done',
+    expectedLeaveDate: '2026-07-31',
+    lastWorkDate: '2026-07-31',
+    submitDate: '2026-07-10',
+    type: '主动离职',
+    reason: '家庭原因',
+    handover: {
+      person: '周倩',
+      dept: '财务部',
+      progress: '100%',
+      deadline: '2026-07-31',
+      items: [
+        '成本核算月度结转 SOP',
+        '供应商对账单归档',
+        'ERP 系统操作权限交接',
+        '银行回单与发票管理移交',
+      ],
+    },
+    compensation: { scheme: '协商一致', amount: '¥ 32,000', status: '已发放', note: '一次性协商补偿' },
+    assetReturn: {
+      status: '已归还',
+      items: [
+        { name: '工牌',     returned: true, remark: '已归档' },
+        { name: '门禁卡',   returned: true, remark: '已注销' },
+        { name: '办公电脑', returned: true, remark: '已回收' },
+      ],
+    },
+    contractTermination: { method: '协商解除', deadline: '2026-07-31', status: '已解除' },
+    socialSecurity: { stopMonth: '2026-07', city: '北京', status: '已停缴' },
+    financeSettle: { lastSalary: '¥ 14,500', annualLeave: '5 天', compensation: '¥ 32,000', payoutDate: '2026-08-15', status: '已结算' },
+    accountOff: { status: '已注销', items: ['OA', '门禁', '邮箱', 'ERP'] },
+    certificate: { status: '已开具', needCopy: true },
+    stage: 'certificate',
+  },
+  {
+    id: 'ZY20260825',
+    name: '黄亮',
+    avatar: '黄',
+    department: '研发中心',
+    position: '前端工程师',
+    entryDate: '2022-04-11',
+    status: 'pending',
+    expectedLeaveDate: '2026-09-30',
+    lastWorkDate: null,
+    submitDate: '2026-08-25',
+    type: '主动离职',
+    reason: '职业转型',
+    handover: {
+      person: '待指派',
+      dept: '研发中心',
+      progress: '0%',
+      deadline: '2026-09-30',
+      items: [
+        'HR H5 前端代码仓库交接',
+        'BPM 表单设计文档移交',
+        '组件库版本与权限交接',
+        '未完成工单与发布计划',
+      ],
+    },
+    compensation: { scheme: 'N', amount: '—', status: '待核算', note: 'HRBP 复核中' },
+    assetReturn: {
+      status: '未开始',
+      items: [
+        { name: '工牌',     returned: false, remark: '' },
+        { name: '门禁卡',   returned: false, remark: '' },
+        { name: '办公电脑', returned: false, remark: '' },
+      ],
+    },
+    contractTermination: { method: '提前 30 日通知', deadline: '2026-09-30', status: '待解除' },
+    socialSecurity: { stopMonth: '2026-09', city: '北京', status: '待办理' },
+    financeSettle: { lastSalary: '¥ 18,000', annualLeave: '2 天', compensation: '—', payoutDate: '', status: '待核算' },
+    accountOff: { status: '待注销', items: [] },
+    certificate: { status: '待开具', needCopy: true },
+    stage: 'submit',
+  },
+  {
+    id: 'ZY20260805',
+    name: '马涛',
+    avatar: '马',
+    department: '生产一部',
+    position: '工艺工程师',
+    entryDate: '2020-09-15',
+    status: 'overdue',
+    expectedLeaveDate: '2026-08-05',
+    lastWorkDate: '2026-08-05',
+    submitDate: '2026-07-25',
+    type: '协商离职',
+    reason: '公司调整',
+    handover: {
+      person: '李明',
+      dept: '生产一部',
+      progress: '40%',
+      deadline: '2026-08-05',
+      items: [
+        '工艺路线文档移交',
+        '3 号产线 SOP 交接',
+        '质量异常台账',
+        '设备参数与配方归档',
+      ],
+    },
+    compensation: { scheme: 'N+1', amount: '¥ 56,800', status: '审批中', note: '需 HRBP 加急复核' },
+    assetReturn: {
+      status: '部分归还',
+      items: [
+        { name: '工牌',      returned: true,  remark: '已交还' },
+        { name: '门禁卡',    returned: false, remark: '未归还' },
+        { name: '办公电脑',  returned: false, remark: '未归还' },
+        { name: '测量仪器',  returned: true,  remark: '已交接' },
+      ],
+    },
+    contractTermination: { method: '协商解除', deadline: '2026-08-05', status: '已逾期' },
+    socialSecurity: { stopMonth: '2026-08', city: '北京', status: '已逾期' },
+    financeSettle: { lastSalary: '¥ 13,200', annualLeave: '4 天', compensation: '¥ 56,800', payoutDate: '', status: '已逾期' },
+    accountOff: { status: '部分注销', items: ['门禁'] },
+    certificate: { status: '待开具', needCopy: true },
+    stage: 'assetReturn',
+  },
+]

+ 137 - 40
src/pages/manage/index.vue

@@ -21,6 +21,13 @@
           >{{ statusFilter }}⌄</text
         ></view
       >
+      <view v-if="type === 'onboard'" class="create-banner press" @click="onCreateOnboarding">
+        <view class="banner-icon">+</view>
+        <view class="banner-main"
+          ><text class="banner-title">发起新的入职流程</text
+          ><text class="banner-sub">录入候选人信息,自动生成员工编号并跟踪入职进度</text></view
+        ><text class="banner-arrow">›</text>
+      </view>
       <view class="manage-list">
         <view
           v-for="item in filteredItems"
@@ -39,7 +46,10 @@
             ><text
               class="status-tag"
               :class="
-                item.status === '待处理' ? 'status-pending' : 'status-approved'
+                item.statusClass ||
+                (item.status === '待处理'
+                  ? 'status-pending'
+                  : 'status-approved')
               "
               >{{ item.status }}</text
             ></view
@@ -101,11 +111,63 @@
 import { computed, onBeforeUnmount, onMounted, ref } from "vue";
 import { onLoad, onShow } from "@dcloudio/uni-app";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
+import { onboardings, onboardStatus, resignings, resignStatus } from "@/data/mock";
+import { mergeOnboardings } from "@/utils/storage";
+import { go } from "@/utils/router";
 const type = ref("recruit");
 useAuthGuard();
 const keyword = ref("");
 const statusFilter = ref("全部状态");
-const configs = {
+const onboardItems = computed(() =>
+  mergeOnboardings(onboardings)
+    .slice()
+    .sort((a, b) => a.expectedEntryDate.localeCompare(b.expectedEntryDate))
+    .map((o) => {
+      const total = Object.keys(o.materials).length;
+      const done = Object.values(o.materials).filter(Boolean).length;
+      const statusInfo = onboardStatus[o.status] || onboardStatus.pending;
+      return {
+        id: o.id,
+        avatar: o.avatar,
+        name: o.name,
+        sub: `${o.department} · ${o.position}`,
+        status: statusInfo.label,
+        statusClass: statusInfo.class,
+        label1: o.entryDate ? "入职日期" : "预计入职",
+        value1: o.entryDate || o.expectedEntryDate,
+        label2: o.entryDate ? "员工编号" : "资料进度",
+        value2: o.entryDate ? o.id : `${done}/${total}`,
+      };
+    }),
+);
+const pendingOnboardCount = computed(
+  () => mergeOnboardings(onboardings).filter((o) => o.status !== "done").length,
+);
+const resignItems = computed(() =>
+  resignings
+    .slice()
+    .sort((a, b) => (a.expectedLeaveDate || "").localeCompare(b.expectedLeaveDate || ""))
+    .map((r) => {
+      const statusInfo = resignStatus[r.status] || resignStatus.pending;
+      return {
+        id: r.id,
+        avatar: r.avatar,
+        name: r.name,
+        sub: `${r.department} · ${r.position}`,
+        status: statusInfo.label,
+        statusClass: statusInfo.class,
+        subType: "resign",
+        label1: r.lastWorkDate ? "最后工作日" : "预计离职",
+        value1: r.lastWorkDate || r.expectedLeaveDate,
+        label2: r.stage === "certificate" ? "证明状态" : "交接进度",
+        value2: r.stage === "certificate" ? "已开具" : r.handover?.progress || "—",
+      };
+    }),
+);
+const pendingResignCount = computed(
+  () => resignings.filter((r) => r.status !== "done" && r.status !== "cancelled").length,
+);
+const configs = computed(() => ({
   recruit: {
     title: "招聘面试",
     subtitle: "候选人进度与今日面试",
@@ -189,42 +251,11 @@ const configs = {
   onboard: {
     title: "员工入职",
     subtitle: "入职资料、合同与参保进度",
-    count: "5",
-    unit: "本周待入职",
+    count: String(pendingOnboardCount.value),
+    unit: "待入职",
     search: "新员工",
     gradient: "linear-gradient(145deg,#4ab8e8,#78d3f8)",
-    items: [
-      {
-        avatar: "孙",
-        name: "孙浩",
-        sub: "生产一部 · 工艺员",
-        status: "待处理",
-        label1: "预计入职",
-        value1: "08月07日",
-        label2: "资料进度",
-        value2: "80%",
-      },
-      {
-        avatar: "胡",
-        name: "胡静",
-        sub: "财务部 · 出纳",
-        status: "进行中",
-        label1: "预计入职",
-        value1: "08月10日",
-        label2: "资料进度",
-        value2: "60%",
-      },
-      {
-        avatar: "郑",
-        name: "郑凯",
-        sub: "设备管理部 · 技工",
-        status: "已完成",
-        label1: "入职日期",
-        value1: "08月04日",
-        label2: "员工编号",
-        value2: "ZY20260804",
-      },
-    ],
+    items: onboardItems.value,
   },
   transfer: {
     title: "人事异动",
@@ -255,19 +286,31 @@ const configs = {
         value2: "优秀",
       },
       {
+        id: "ZY20260812",
         avatar: "郭",
         name: "郭峰",
         sub: "离职 · 设备维修工",
         status: "进行中",
+        statusClass: "status-processing",
+        subType: "resign",
         label1: "预计离职",
-        value1: "08月12日",
+        value1: "08月25日",
         label2: "交接进度",
         value2: "70%",
       },
     ],
   },
-};
-const config = computed(() => configs[type.value] || configs.recruit);
+  resign: {
+    title: "离职管理",
+    subtitle: "离职审批、工作交接与离职证明",
+    count: String(pendingResignCount.value),
+    unit: "进行中",
+    search: "员工或流程",
+    gradient: "linear-gradient(145deg,#ee6666,#f08a8a)",
+    items: resignItems.value,
+  },
+}));
+const config = computed(() => configs.value[type.value] || configs.value.recruit);
 const filteredItems = computed(() =>
   config.value.items.filter(
     (i) =>
@@ -320,14 +363,25 @@ onBeforeUnmount(() => {
     window.removeEventListener("hashchange", syncHash);
 });
 function showDetail(item) {
+  if (type.value === "onboard" && item.id) {
+    go(`/pages/manage/onboard-detail?id=${item.id}`);
+    return;
+  }
+  if (item.subType === "resign" && item.id) {
+    go(`/pages/manage/resign-detail?id=${item.id}`);
+    return;
+  }
   uni.showModal({
     title: item.name,
     content: `${item.sub}\n${item.label1}:${item.value1}\n${item.label2}:${item.value2}\n\n当前为静态演示数据。`,
     showCancel: false,
   });
 }
+function onCreateOnboarding() {
+  go("/pages/manage/onboard-create");
+}
 function chooseStatus() {
-  const items = ["全部状态", "待处理", "进行中", "已完成"];
+  const items = ["全部状态", "待审批", "进行中", "已离职", "已逾期", "已撤回"];
   uni.showActionSheet({
     itemList: items,
     success: (r) => (statusFilter.value = items[r.tapIndex]),
@@ -413,6 +467,49 @@ function showWarning(w) {
 .manage-list {
   padding: 24rpx;
 }
+.create-banner {
+  margin: 18rpx 24rpx 0;
+  padding: 22rpx 26rpx;
+  border-radius: 18rpx;
+  background: linear-gradient(135deg, #4ab8e8 0%, #1677ff 100%);
+  color: #fff;
+  display: flex;
+  align-items: center;
+  gap: 18rpx;
+  box-shadow: 0 6rpx 20rpx rgba(22, 119, 255, 0.18);
+}
+.banner-icon {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 18rpx;
+  background: rgba(255, 255, 255, 0.22);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 44rpx;
+  font-weight: 300;
+  flex-shrink: 0;
+}
+.banner-main {
+  flex: 1;
+  min-width: 0;
+}
+.banner-title {
+  display: block;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.banner-sub {
+  display: block;
+  margin-top: 6rpx;
+  font-size: 22rpx;
+  opacity: 0.86;
+}
+.banner-arrow {
+  font-size: 36rpx;
+  opacity: 0.7;
+  flex-shrink: 0;
+}
 .manage-card {
   margin-bottom: 20rpx;
   padding: 26rpx;

+ 424 - 0
src/pages/manage/onboard-create.vue

@@ -0,0 +1,424 @@
+<template>
+  <view class="page-no-tab">
+    <!-- 顶部候选人预览 -->
+    <view class="create-head">
+      <view class="head-avatar">{{ avatar }}</view>
+      <view class="head-info">
+        <text class="head-name">{{ form.name || '新员工姓名' }}</text>
+        <text class="head-sub">{{ subPreview }}</text>
+      </view>
+      <text class="status-tag status-pending">待入职</text>
+    </view>
+
+    <!-- 入职流程提示 -->
+    <view class="steps">
+      <view class="step active"><view>1</view><text>录入信息</text></view>
+      <view class="step-line"></view>
+      <view class="step"><view>2</view><text>材料收集</text></view>
+      <view class="step-line"></view>
+      <view class="step"><view>3</view><text>签订合同</text></view>
+      <view class="step-line"></view>
+      <view class="step"><view>4</view><text>到岗入职</text></view>
+    </view>
+
+    <!-- 基础信息 -->
+    <view class="section-title"><text>基础信息</text></view>
+    <view class="form-card card section">
+      <view class="field"
+        ><text class="label required">姓名</text>
+        <input v-model="form.name" class="value-input" placeholder="请输入候选人姓名" maxlength="20" />
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseDept"
+        ><text class="label required">部门</text
+        ><text :class="form.department ? 'value' : 'placeholder'">{{ form.department || '请选择' }}</text
+        ><text class="arrow">›</text
+      ></view>
+      <view class="divider"></view>
+      <view class="field"
+        ><text class="label required">岗位</text>
+        <input v-model="form.position" class="value-input" placeholder="请输入岗位名称" maxlength="30" />
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseSource"
+        ><text class="label required">招聘来源</text
+        ><text :class="form.source ? 'value' : 'placeholder'">{{ form.source || '请选择' }}</text
+        ><text class="arrow">›</text
+      ></view>
+    </view>
+
+    <!-- 联系方式 -->
+    <view class="section-title"><text>联系方式</text></view>
+    <view class="form-card card section">
+      <view class="field"
+        ><text class="label required">手机号</text>
+        <input
+          v-model="form.phone"
+          class="value-input"
+          type="number"
+          placeholder="11位手机号"
+          maxlength="11"
+        />
+      </view>
+      <view class="divider"></view>
+      <view class="field"
+        ><text class="label required">邮箱</text>
+        <input v-model="form.email" class="value-input" placeholder="公司邮箱或外部邮箱" />
+      </view>
+    </view>
+
+    <!-- 入职安排 -->
+    <view class="section-title"><text>入职安排</text></view>
+    <view class="form-card card section">
+      <view class="field"
+        ><text class="label required">预计入职日期</text>
+        <picker mode="date" :value="form.expectedEntryDate" :start="todayIso" @change="onDateChange"
+          ><text :class="form.expectedEntryDate ? 'value' : 'placeholder'">{{
+            form.expectedEntryDate || '请选择'
+          }}</text></picker
+        ><text class="arrow">›</text>
+      </view>
+      <view class="divider"></view>
+      <view class="field press" @click="chooseProbation"
+        ><text class="label">试用期</text
+        ><text class="value">{{ form.probationMonths }} 个月</text
+        ><text class="arrow">›</text
+      ></view>
+      <view class="divider"></view>
+      <view class="field"
+        ><text class="label">带教人</text>
+        <input v-model="form.mentor" class="value-input" placeholder="可选" maxlength="20" />
+      </view>
+    </view>
+
+    <!-- 备注 -->
+    <view class="section-title"><text>备注</text></view>
+    <view class="form-card card section">
+      <textarea
+        v-model="form.remark"
+        class="textarea"
+        placeholder="例如:提前到岗日期、特殊情况说明等"
+        maxlength="200"
+      />
+      <text class="textarea-count">{{ form.remark.length }} / 200</text>
+    </view>
+
+    <view class="footer-tip muted">
+      <text>提交后将生成员工编号,并自动出现在「员工入职」列表中。</text>
+    </view>
+
+    <view class="footer-actions">
+      <view class="ghost-btn" @click="onCancel">取消</view>
+      <view class="primary-btn" @click="onSubmit">提交入职流程</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, reactive } from "vue";
+import { onLoad } from "@dcloudio/uni-app";
+import { useAuthGuard } from "@/hooks/useAuthGuard";
+import { onboardOptions, generateEmployeeId } from "@/data/mock";
+import { addOnboarding, mergeOnboardings } from "@/utils/storage";
+import { go } from "@/utils/router";
+
+useAuthGuard();
+
+const today = new Date();
+const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
+
+const form = reactive({
+  name: '',
+  department: '',
+  position: '',
+  source: '',
+  phone: '',
+  email: '',
+  expectedEntryDate: '',
+  probationMonths: 3,
+  mentor: '',
+  remark: '',
+});
+
+const avatar = computed(() => {
+  const n = String(form.name || '').trim();
+  return n ? n.charAt(0) : '新';
+});
+
+const subPreview = computed(() => {
+  const parts = [];
+  if (form.department) parts.push(form.department);
+  if (form.position) parts.push(form.position);
+  return parts.length ? parts.join(' · ') : '请填写基础信息';
+});
+
+function onDateChange(e) {
+  form.expectedEntryDate = e.detail.value;
+}
+
+function chooseDept() {
+  uni.showActionSheet({
+    itemList: onboardOptions.departments,
+    success: (r) => (form.department = onboardOptions.departments[r.tapIndex]),
+  });
+}
+
+function chooseSource() {
+  uni.showActionSheet({
+    itemList: onboardOptions.sources,
+    success: (r) => (form.source = onboardOptions.sources[r.tapIndex]),
+  });
+}
+
+function chooseProbation() {
+  const labels = onboardOptions.probationMonths.map((n) => `${n} 个月`);
+  uni.showActionSheet({
+    itemList: labels,
+    success: (r) => (form.probationMonths = onboardOptions.probationMonths[r.tapIndex]),
+  });
+}
+
+function onCancel() {
+  uni.showModal({
+    title: '放弃创建',
+    content: '当前填写的内容将不会保留,确定离开?',
+    success: (r) => {
+      if (r.confirm) go('/pages/manage/index?type=onboard');
+    },
+  });
+}
+
+function validate() {
+  const required = [
+    ['name', '请填写姓名'],
+    ['department', '请选择部门'],
+    ['position', '请填写岗位'],
+    ['source', '请选择招聘来源'],
+    ['phone', '请填写手机号'],
+    ['email', '请填写邮箱'],
+    ['expectedEntryDate', '请选择预计入职日期'],
+  ];
+  for (const [key, msg] of required) {
+    if (!form[key]) {
+      uni.showToast({ title: msg, icon: 'none' });
+      return false;
+    }
+  }
+  if (!/^1[3-9]\d{9}$/.test(form.phone)) {
+    uni.showToast({ title: '手机号格式不正确', icon: 'none' });
+    return false;
+  }
+  if (!/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(form.email)) {
+    uni.showToast({ title: '邮箱格式不正确', icon: 'none' });
+    return false;
+  }
+  return true;
+}
+
+function buildOnboarding() {
+  const id = generateEmployeeId(mergeOnboardings().map((o) => o.id));
+  const [yyyy, mm, dd] = form.expectedEntryDate.split('-');
+  const expected = form.expectedEntryDate;
+  const probationEnd = form.probationMonths
+    ? `${yyyy}-${mm}-${String(Number(dd)).padStart(2, '0')}`.replace(/^(\d{4})-(\d{2})-(\d{2})$/, (_m, y, m, d) => {
+        const dt = new Date(Number(y), Number(m) - 1, Number(d));
+        dt.setMonth(dt.getMonth() + Number(form.probationMonths));
+        return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
+      })
+    : null;
+  return {
+    id,
+    name: form.name.trim(),
+    avatar: form.name.trim().charAt(0),
+    department: form.department,
+    position: form.position.trim(),
+    source: form.source,
+    status: 'pending',
+    expectedEntryDate: expected,
+    entryDate: null,
+    probationMonths: Number(form.probationMonths) || 3,
+    probationEndDate: probationEnd,
+    mentor: form.mentor.trim() || '待安排',
+    phone: form.phone.trim(),
+    email: form.email.trim(),
+    remark: form.remark.trim(),
+    materials: {
+      identityCard: false,
+      degreeCertificate: false,
+      physicalReport: false,
+      photo: false,
+      bankCard: false,
+      resignationCert: false,
+    },
+    contract: { status: '待签订', signMethod: '电子签', deadline: '' },
+    socialSecurity: { status: '未办理', city: '北京', deadline: '' },
+    training: { status: '未开始', name: '新员工入职培训', duration: '2天', deadline: '' },
+    workstation: { status: '待分配', location: '' },
+    itAccount: { status: '未开通', items: [] },
+    stage: 'offer',
+    createdAt: new Date().toISOString(),
+  };
+}
+
+function onSubmit() {
+  if (!validate()) return;
+  const staff = buildOnboarding();
+  addOnboarding(staff);
+  uni.showToast({ title: '入职流程已创建', icon: 'success' });
+  setTimeout(() => {
+    go(`/pages/manage/onboard-detail?id=${staff.id}`);
+  }, 600);
+}
+
+onLoad(() => {
+  uni.setNavigationBarTitle({ title: '新建入职流程' });
+});
+</script>
+
+<style scoped>
+.create-head {
+  padding: 32rpx 28rpx;
+  display: flex;
+  align-items: center;
+  color: #fff;
+  background: linear-gradient(145deg, #4ab8e8, #1677ff);
+}
+.head-avatar {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 22rpx;
+  background: rgba(255, 255, 255, 0.22);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.head-info {
+  flex: 1;
+  margin-left: 20rpx;
+  min-width: 0;
+}
+.head-name {
+  display: block;
+  font-size: 32rpx;
+  font-weight: 600;
+}
+.head-sub {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 22rpx;
+  opacity: 0.86;
+}
+.steps {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 28rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #edf0f2;
+}
+.step {
+  display: flex;
+  align-items: center;
+  font-size: 22rpx;
+  color: #8b939f;
+}
+.step view {
+  width: 38rpx;
+  height: 38rpx;
+  border-radius: 50%;
+  background: #edf0f2;
+  color: #8b939f;
+  font-size: 22rpx;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 8rpx;
+}
+.step.active text {
+  color: #1677ff;
+  font-weight: 600;
+}
+.step.active view {
+  background: #1677ff;
+  color: #fff;
+}
+.step-line {
+  flex: 1;
+  height: 2rpx;
+  background: #edf0f2;
+  margin: 0 12rpx;
+}
+.form-card {
+  padding: 4rpx 28rpx;
+}
+.field {
+  display: flex;
+  align-items: center;
+  min-height: 96rpx;
+  font-size: 28rpx;
+}
+.field .label {
+  width: 180rpx;
+  color: #4b5563;
+}
+.field .label.required::before {
+  content: '*';
+  color: #ee4d4d;
+  margin-right: 4rpx;
+}
+.field .value,
+.field input {
+  flex: 1;
+  text-align: right;
+  color: #1f2329;
+  font-size: 28rpx;
+}
+.value-input {
+  text-align: right;
+  height: 96rpx;
+}
+.placeholder {
+  color: #b5bac3;
+}
+.arrow {
+  margin-left: 12rpx;
+  color: #c4c8ce;
+  font-size: 32rpx;
+}
+.divider {
+  height: 1rpx;
+  background: #edf0f2;
+  margin-left: 180rpx;
+}
+.textarea {
+  width: 100%;
+  min-height: 180rpx;
+  padding: 24rpx 0;
+  font-size: 28rpx;
+  color: #1f2329;
+  box-sizing: border-box;
+}
+.textarea-count {
+  display: block;
+  text-align: right;
+  font-size: 22rpx;
+  color: #8b939f;
+  padding-bottom: 24rpx;
+}
+.footer-tip {
+  padding: 28rpx 28rpx 8rpx;
+  font-size: 22rpx;
+  text-align: center;
+}
+.footer-actions {
+  display: flex;
+  gap: 18rpx;
+  padding: 24rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
+}
+.footer-actions > view {
+  flex: 1;
+}
+</style>

+ 852 - 0
src/pages/manage/onboard-detail.vue

@@ -0,0 +1,852 @@
+<template>
+  <view class="page-no-tab">
+    <!-- 顶部基础信息 -->
+    <view class="detail-head" :style="{ background: headerGradient }">
+      <view class="head-base"
+        ><view class="avatar head-avatar">{{ staff.avatar }}</view>
+        <view class="head-main"
+          ><text class="head-name">{{ staff.name }}</text
+          ><text class="head-sub"
+            >{{ staff.department }} · {{ staff.position }}</text
+          ></view
+        >
+        <text class="status-tag" :class="statusInfo.class">{{ statusInfo.label }}</text>
+      </view>
+      <view class="head-meta">
+        <view
+          ><text>员工编号</text><text>{{ staff.id }}</text></view
+        >
+        <view
+          ><text>入职日期</text><text>{{ staff.entryDate || staff.expectedEntryDate }}</text></view
+        >
+        <view
+          ><text>试用期</text
+          ><text
+            >{{ staff.probationMonths ? staff.probationMonths + ' 个月' : '—' }}</text
+          ></view
+        >
+        <view
+          ><text>带教人</text><text>{{ staff.mentor || '—' }}</text></view
+        >
+      </view>
+    </view>
+
+    <!-- 倒计时 / 进度 -->
+    <view class="progress-card card section">
+      <view class="progress-head"
+        ><text class="progress-title">{{ summary.title }}</text
+        ><text class="progress-value">{{ summary.value }}</text></view
+      >
+      <view class="progress-bar"
+        ><view class="progress-fill" :style="{ width: summary.rate + '%' }"></view
+      ></view>
+      <text class="progress-tip">{{ summary.tip }}</text>
+    </view>
+
+    <!-- 入职流程时间线 -->
+    <view class="section-title"><text>入职流程</text></view>
+    <view class="timeline card section">
+      <view
+        v-for="(s, idx) in stages"
+        :key="s.key"
+        class="timeline-row"
+        :class="{ 'is-done': stageState(s.key) === 'done', 'is-current': stageState(s.key) === 'current' }"
+      >
+        <view class="timeline-node">
+          <view class="node-dot"></view>
+          <view v-if="idx !== stages.length - 1" class="node-line"></view>
+        </view>
+        <view class="timeline-main">
+          <text class="timeline-label">{{ s.label }}</text>
+          <text class="timeline-desc">{{ s.desc }}</text>
+        </view>
+        <text v-if="stageState(s.key) === 'done'" class="status-tag status-approved">已完成</text>
+        <text v-else-if="stageState(s.key) === 'current'" class="status-tag status-processing">进行中</text>
+        <text v-else class="status-tag status-pending">待开始</text>
+      </view>
+    </view>
+
+    <!-- 入职材料清单 -->
+    <view class="section-title"
+      ><text>入职材料</text
+      ><text class="section-more">{{ materialsDone }} / {{ materialList.length }} 已完成</text></view
+    >
+    <view class="materials card section">
+      <view v-for="m in materialList" :key="m.key" class="material-row">
+        <view class="material-icon" :class="effectiveMaterial(m.key) ? 'is-on' : 'is-off'">
+          {{ effectiveMaterial(m.key) ? '✓' : '·' }}
+        </view>
+        <view class="material-main">
+          <view class="material-line">
+            <view class="material-name-wrap"
+              ><text class="material-name">{{ m.label }}</text
+              ><text class="material-accept">{{ m.accept.join(' / ') }} · 最多 {{ m.maxCount }} 个</text></view
+            >
+            <text class="status-tag" :class="effectiveMaterial(m.key) ? 'status-approved' : 'status-pending'">
+              {{ effectiveMaterial(m.key) ? '已收齐' : '待补充' }}
+            </text>
+          </view>
+          <text class="muted material-desc">{{ m.desc }}</text>
+
+          <!-- 已上传文件缩略图 -->
+          <scroll-view
+            v-if="getFiles(m.key).length"
+            class="files-scroll"
+            scroll-x
+            :show-scrollbar="false"
+          >
+            <view
+              v-for="f in getFiles(m.key)"
+              :key="f.id"
+              class="file-chip press"
+              @click="onPreviewMaterial(f)"
+              @longpress="onRenameMaterial(m.key, f)"
+            >
+              <image
+                v-if="isImage(f)"
+                class="file-thumb"
+                :src="fileDisplayUrl(f)"
+                mode="aspectFill"
+              />
+              <view v-else class="file-thumb file-icon">📄</view>
+              <view class="file-name">{{ f.name }}</view>
+              <view class="file-remove" @click.stop="onRemoveMaterial(m.key, f)">×</view>
+            </view>
+          </scroll-view>
+
+          <view class="upload-row">
+            <text v-if="getFiles(m.key).length" class="file-count">
+              已上传 {{ getFiles(m.key).length }} / {{ m.maxCount }} 个
+            </text>
+            <text v-else class="file-count muted">尚未上传</text>
+          </view>
+        </view>
+        <view
+          class="material-action press"
+          :class="{ 'is-disabled': getFiles(m.key).length >= m.maxCount }"
+          @click="onPickMaterial(m.key)"
+        >
+          <text class="action-plus">+</text>
+          <text>{{ getFiles(m.key).length >= m.maxCount ? '已满' : (getFiles(m.key).length ? '继续上传' : '上传') }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 各模块进度(合同、社保、培训、工位、账号)-->
+    <view class="section-title"><text>信息模块</text></view>
+    <view class="info-grid section">
+      <view class="info-card card">
+        <view class="info-head"
+          ><text class="info-title">合同签订</text
+          ><text class="status-tag" :class="moduleClass(staff.contract.status)">{{ staff.contract.status }}</text></view
+        >
+        <view class="info-row"><text>签署方式</text><text>{{ staff.contract.signMethod || '—' }}</text></view>
+        <view class="info-row"
+          ><text>{{ staff.contract.signDate ? '签订日期' : '截止日期' }}</text
+          ><text>{{ staff.contract.signDate || staff.contract.deadline || '—' }}</text
+        ></view
+        >
+      </view>
+
+      <view class="info-card card">
+        <view class="info-head"
+          ><text class="info-title">社保办理</text
+          ><text class="status-tag" :class="moduleClass(staff.socialSecurity.status)">{{ staff.socialSecurity.status }}</text></view
+        >
+        <view class="info-row"><text>参保城市</text><text>{{ staff.socialSecurity.city }}</text></view>
+        <view class="info-row"><text>截止日期</text><text>{{ staff.socialSecurity.deadline || '—' }}</text></view>
+      </view>
+
+      <view class="info-card card">
+        <view class="info-head"
+          ><text class="info-title">岗前培训</text
+          ><text class="status-tag" :class="moduleClass(staff.training.status)">{{ staff.training.status }}</text></view
+        >
+        <view class="info-row"><text>课程</text><text>{{ staff.training.name }}</text></view>
+        <view class="info-row"><text>时长</text><text>{{ staff.training.duration }}</text></view>
+      </view>
+
+      <view class="info-card card">
+        <view class="info-head"
+          ><text class="info-title">工位分配</text
+          ><text class="status-tag" :class="moduleClass(staff.workstation.status)">{{ staff.workstation.status }}</text></view
+        >
+        <view class="info-row"><text>位置</text><text>{{ staff.workstation.location || '—' }}</text></view>
+      </view>
+
+      <view class="info-card card info-card-wide">
+        <view class="info-head"
+          ><text class="info-title">IT 账号</text
+          ><text class="status-tag" :class="moduleClass(staff.itAccount.status)">{{ staff.itAccount.status }}</text></view
+        >
+        <view class="info-row info-items">
+          <text
+            v-for="i in staff.itAccount.items"
+            :key="i"
+            class="info-chip"
+            >{{ i }}</text
+          >
+          <text v-if="!staff.itAccount.items.length" class="muted">尚未开通任何账号</text>
+        </view>
+      </view>
+
+      <view class="info-card card info-card-wide">
+        <view class="info-head"
+          ><text class="info-title">联系方式</text
+          ></view>
+        <view class="info-row"><text>手机</text><text>{{ staff.phone }}</text></view>
+        <view class="info-row"><text>邮箱</text><text>{{ staff.email }}</text></view>
+        <view class="info-row"
+          ><text>招聘来源</text><text>{{ staff.source }}</text></view
+        >
+      </view>
+    </view>
+
+    <view class="footer-tip muted">
+      <text>演示版:操作按钮仅作展示,真实数据接入后可对接审批 / 资料上传等接口。</text>
+    </view>
+
+    <view class="footer-actions">
+      <view class="ghost-btn" @click="onRemind">催办</view>
+      <view class="primary-btn" @click="onConfirm">确认入职</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref, watch } from "vue";
+import { onLoad } from "@dcloudio/uni-app";
+import { useAuthGuard } from "@/hooks/useAuthGuard";
+import { onboardings, onboardStatus, onboardStages } from "@/data/mock";
+import { go } from "@/utils/router";
+import {
+  getOnboardMaterials,
+  addOnboardMaterial,
+  removeOnboardMaterial,
+  renameOnboardMaterial,
+  readFileAsBase64,
+  mockUpload,
+  formatNow,
+  getOnboardingById,
+  mergeOnboardings,
+} from "@/utils/storage";
+
+useAuthGuard();
+
+const stages = onboardStages;
+const statusMap = onboardStatus;
+const id = ref("");
+
+const staff = computed(() => {
+  return getOnboardingById(id.value, mergeOnboardings(onboardings)) || mergeOnboardings(onboardings)[0];
+});
+
+// 入职材料上传:按员工 id 索引,{ [materialKey]: FileInfo[] }
+const materialsMap = ref({});
+
+function refreshMaterials(empId) {
+  materialsMap.value = getOnboardMaterials(empId);
+}
+
+watch(
+  () => staff.value.id,
+  (next) => refreshMaterials(next),
+  { immediate: true },
+);
+
+const statusInfo = computed(() => statusMap[staff.value.status] || statusMap.pending);
+
+const headerGradient = computed(() => {
+  const map = {
+    pending: "linear-gradient(145deg,#4ab8e8,#78d3f8)",
+    processing: "linear-gradient(145deg,#2479e9,#1363ce)",
+    done: "linear-gradient(145deg,#52c41a,#389e0d)",
+    overdue: "linear-gradient(145deg,#ee4d4d,#cf1322)",
+  };
+  return map[staff.value.status] || map.pending;
+});
+
+const materialList = [
+  { key: "identityCard", label: "身份证", desc: "正反面复印件", accept: ["jpg", "png"], maxCount: 4 },
+  { key: "degreeCertificate", label: "学历证书", desc: "原件 + 学信网截图", accept: ["jpg", "png", "pdf"], maxCount: 5 },
+  { key: "physicalReport", label: "体检报告", desc: "3 个月内有效", accept: ["jpg", "png", "pdf"], maxCount: 5 },
+  { key: "photo", label: "一寸照片", desc: "蓝底 / 白底各 2 张", accept: ["jpg", "png"], maxCount: 6 },
+  { key: "bankCard", label: "工资卡", desc: "本人储蓄卡复印件", accept: ["jpg", "png"], maxCount: 2 },
+  { key: "resignationCert", label: "离职证明", desc: "前雇主开具", accept: ["jpg", "png", "pdf"], maxCount: 3 },
+];
+
+function extOf(name) {
+  const m = String(name || "").match(/\.([a-zA-Z0-9]+)$/);
+  return m ? m[1].toLowerCase() : "";
+}
+
+function fileMatchesAccept(file, accept) {
+  if (!accept || !accept.length) return true;
+  const ext = extOf(file.name) || extOf(file.path);
+  if (ext) return accept.includes(ext);
+  // 兜底:拍照/相册默认是图片
+  if (file.sourceType === "camera" || file.sourceType === "album") return accept.includes("jpg") || accept.includes("png");
+  return true;
+}
+
+const materialsDone = computed(
+  () => materialList.filter((m) => effectiveMaterial(m.key)).length,
+);
+
+function getFiles(materialKey) {
+  const list = materialsMap.value?.[materialKey];
+  return Array.isArray(list) ? list : [];
+}
+
+// 已收齐 = mock 中已 true 或 本地已有任意上传文件
+function effectiveMaterial(materialKey) {
+  if (staff.value.materials?.[materialKey]) return true;
+  return getFiles(materialKey).length > 0;
+}
+
+function isImage(file) {
+  if (!file) return false;
+  const name = file.name || file.path || '';
+  return /\.(jpe?g|png|gif|webp|bmp)$/i.test(name) || file.sourceType === 'camera';
+}
+
+const summary = computed(() => {
+  const total = stages.length;
+  const currentIdx = stages.findIndex((s) => s.key === staff.value.stage);
+  const done = currentIdx >= 0 ? currentIdx : total;
+  const rate = Math.round((done / total) * 100);
+  let tip = "";
+  if (staff.value.status === "overdue") {
+    tip = "已超过预计入职日期,请尽快联系候选人或调整时间。";
+  } else if (staff.value.status === "done") {
+    tip = "入职流程已全部完成,进入试用期管理。";
+  } else {
+    tip = `当前进行:${stages[currentIdx]?.label || "—"}`;
+  }
+  return { title: "入职进度", value: rate + "%", rate, tip };
+});
+
+function stageState(key) {
+  const currentIdx = stages.findIndex((s) => s.key === staff.value.stage);
+  const idx = stages.findIndex((s) => s.key === key);
+  if (idx < currentIdx) return "done";
+  if (idx === currentIdx) return "current";
+  return "pending";
+}
+
+function moduleClass(status) {
+  if (status === "已签订" || status === "已办理" || status === "已完成" || status === "已分配" || status === "已开通") {
+    return "status-approved";
+  }
+  if (status === "办理中" || status === "进行中") {
+    return "status-processing";
+  }
+  if (status === "未办理" || status === "未开通" || status === "未开始" || status === "待分配") {
+    return "status-pending";
+  }
+  return "status-pending";
+}
+
+function onRemind() {
+  uni.showToast({ title: "演示版:催办消息未发送", icon: "none" });
+}
+
+function getRemaining(materialKey) {
+  const m = materialList.find((x) => x.key === materialKey);
+  if (!m) return 1;
+  const used = getFiles(materialKey).length;
+  return Math.max(1, (m.maxCount || 1) - used);
+}
+
+function onPickMaterial(materialKey) {
+  const m = materialList.find((x) => x.key === materialKey);
+  if (!m) return;
+  const remain = getRemaining(materialKey);
+  if (remain <= 0) {
+    uni.showToast({ title: `最多上传 ${m.maxCount} 个文件`, icon: "none" });
+    return;
+  }
+  uni.chooseImage({
+    count: remain,
+    sizeType: ["original", "compressed"],
+    sourceType: ["album", "camera"],
+    success: async (res) => {
+      const tempFiles = res.tempFiles || [];
+      const paths = res.tempFilePaths || [];
+      if (!paths.length) return;
+
+      const valid = [];
+      const rejected = [];
+      tempFiles.forEach((f, i) => {
+        const meta = {
+          name: f.name || `${m.label}_${Date.now()}_${i + 1}.jpg`,
+          path: paths[i],
+          size: f.size || 0,
+          sourceType: f.origin === "camera" ? "camera" : "album",
+        };
+        if (fileMatchesAccept(meta, m.accept)) valid.push(meta);
+        else rejected.push(meta);
+      });
+
+      if (rejected.length) {
+        uni.showToast({
+          title: `已跳过 ${rejected.length} 个不支持的格式`,
+          icon: "none",
+        });
+      }
+      if (!valid.length) return;
+
+      uni.showLoading({ title: "上传中 0%", mask: true });
+      for (let i = 0; i < valid.length; i++) {
+        const meta = valid[i];
+        const baseTotal = Math.round((i / valid.length) * 100);
+        try {
+          await mockUpload(meta.path, {
+            onProgress: (p) => {
+              const overall = Math.round(baseTotal + (p / valid.length));
+              uni.showLoading({ title: `上传中 ${Math.min(overall, 99)}%`, mask: true });
+            },
+          });
+          // 真实接入时替换 mockUpload 为 uni.uploadFile:
+          //   uni.uploadFile({ url: realEndpoint, filePath: meta.path, name: 'file', ... })
+          //   并把返回的 OSS URL 写入 path;演示版直接用临时路径
+          const base64 = await readFileAsBase64(meta.path);
+          const stored = {
+            ...meta,
+            base64,
+            uploadedAt: formatNow(),
+          };
+          addOnboardMaterial(staff.value.id, materialKey, stored);
+        } catch (e) {
+          // 单条失败不影响其它
+        }
+      }
+      uni.hideLoading();
+      refreshMaterials(staff.value.id);
+      uni.showToast({ title: `已上传 ${valid.length} 个文件`, icon: "success" });
+    },
+    fail: () => {
+      // 用户取消选择时静默
+    },
+  });
+}
+
+function onRenameMaterial(materialKey, file) {
+  uni.showModal({
+    title: "重命名",
+    editable: true,
+    placeholderText: "请输入新文件名",
+    content: file.name || "",
+    success: (r) => {
+      if (!r.confirm) return;
+      const next = String(r.content || "").trim();
+      if (!next || next === file.name) {
+        uni.showToast({ title: "名称未变更", icon: "none" });
+        return;
+      }
+      renameOnboardMaterial(staff.value.id, materialKey, file.id, next);
+      refreshMaterials(staff.value.id);
+      uni.showToast({ title: "已重命名", icon: "success" });
+    },
+  });
+}
+
+function onRemoveMaterial(materialKey, file) {
+  uni.showModal({
+    title: "删除材料",
+    content: `确认删除「${file.name}」?该操作仅影响本机演示数据。`,
+    success: (r) => {
+      if (!r.confirm) return;
+      removeOnboardMaterial(staff.value.id, materialKey, file.id);
+      refreshMaterials(staff.value.id);
+      uni.showToast({ title: "已删除", icon: "none" });
+    },
+  });
+}
+
+function onPreviewMaterial(file) {
+  const url = file?.base64 || file?.path;
+  if (!url) {
+    uni.showToast({ title: "演示版:无可预览文件", icon: "none" });
+    return;
+  }
+  uni.previewImage({
+    urls: [url],
+    current: url,
+  });
+}
+
+function fileDisplayUrl(file) {
+  return file?.base64 || file?.path || "";
+}
+
+function onConfirm() {
+  uni.showModal({
+    title: "确认入职",
+    content: "演示版:将把该员工标记为已入职状态(实际不会修改 mock 数据)。",
+    success: (r) => {
+      if (r.confirm) {
+        uni.showToast({ title: "操作成功", icon: "success" });
+        setTimeout(() => go("/pages/manage/index?type=onboard"), 600);
+      }
+    },
+  });
+}
+
+onLoad((options) => {
+  id.value = options?.id || "";
+  uni.setNavigationBarTitle({ title: "入职详情" });
+});
+</script>
+
+<style scoped>
+.detail-head {
+  padding: 32rpx 28rpx 28rpx;
+  color: #fff;
+}
+.head-base {
+  display: flex;
+  align-items: center;
+}
+.head-avatar {
+  width: 96rpx;
+  height: 96rpx;
+  font-size: 36rpx;
+  border-radius: 24rpx;
+  background: rgba(255, 255, 255, 0.22);
+}
+.head-main {
+  flex: 1;
+  margin-left: 20rpx;
+}
+.head-name {
+  display: block;
+  font-size: 36rpx;
+  font-weight: 600;
+}
+.head-sub {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 24rpx;
+  opacity: 0.86;
+}
+.head-meta {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 18rpx 28rpx;
+  margin-top: 28rpx;
+  padding-top: 24rpx;
+  border-top: 1rpx solid rgba(255, 255, 255, 0.2);
+}
+.head-meta > view text {
+  display: block;
+  font-size: 22rpx;
+  opacity: 0.78;
+}
+.head-meta > view text + text {
+  margin-top: 6rpx;
+  font-size: 26rpx;
+  font-weight: 600;
+  opacity: 1;
+}
+.progress-card {
+  padding: 28rpx;
+}
+.progress-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.progress-title {
+  font-size: 28rpx;
+  font-weight: 600;
+}
+.progress-value {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1677ff;
+}
+.progress-bar {
+  margin-top: 18rpx;
+  height: 12rpx;
+  border-radius: 6rpx;
+  background: #edf0f2;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #4ab8e8, #1677ff);
+  border-radius: 6rpx;
+}
+.progress-tip {
+  display: block;
+  margin-top: 14rpx;
+  color: #8b939f;
+  font-size: 23rpx;
+}
+.timeline {
+  padding: 8rpx 0;
+}
+.timeline-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 18rpx 28rpx;
+}
+.timeline-row.is-done .node-dot {
+  background: #52c41a;
+}
+.timeline-row.is-done .timeline-label {
+  color: #52c41a;
+}
+.timeline-row.is-current .node-dot {
+  background: #1677ff;
+  box-shadow: 0 0 0 6rpx rgba(22, 119, 255, 0.16);
+}
+.timeline-row.is-current .timeline-label {
+  color: #1677ff;
+}
+.timeline-node {
+  width: 28rpx;
+  position: relative;
+  padding-top: 12rpx;
+}
+.node-dot {
+  width: 16rpx;
+  height: 16rpx;
+  border-radius: 50%;
+  background: #c4c8ce;
+  margin: 0 auto;
+}
+.node-line {
+  position: absolute;
+  top: 32rpx;
+  left: 50%;
+  bottom: -32rpx;
+  width: 2rpx;
+  background: #edf0f2;
+  transform: translateX(-50%);
+}
+.timeline-main {
+  flex: 1;
+  margin-left: 18rpx;
+}
+.timeline-label {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #4b5563;
+}
+.timeline-desc {
+  display: block;
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #8b939f;
+}
+.timeline-row > .status-tag {
+  margin-top: 6rpx;
+  flex-shrink: 0;
+}
+.materials {
+  padding: 8rpx 0;
+}
+.material-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 24rpx 28rpx;
+}
+.material-row + .material-row {
+  border-top: 1rpx solid #edf0f2;
+}
+.material-icon {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32rpx;
+  font-weight: 700;
+  margin-top: 4rpx;
+}
+.material-icon.is-on {
+  background: #eaf8ef;
+  color: #07893f;
+}
+.material-icon.is-off {
+  background: #fff2df;
+  color: #d9780b;
+}
+.material-main {
+  flex: 1;
+  margin-left: 18rpx;
+  min-width: 0;
+}
+.material-line {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12rpx;
+}
+.material-name {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2329;
+}
+.material-name-wrap {
+  display: flex;
+  flex-direction: column;
+}
+.material-accept {
+  margin-top: 4rpx;
+  font-size: 20rpx;
+  color: #8b939f;
+  font-weight: 400;
+}
+.material-desc {
+  display: block;
+  margin-top: 6rpx;
+  font-size: 22rpx;
+}
+.files-scroll {
+  margin-top: 16rpx;
+  white-space: nowrap;
+}
+.file-chip {
+  display: inline-flex;
+  position: relative;
+  margin-right: 14rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  background: #f7f8fa;
+}
+.file-thumb {
+  width: 112rpx;
+  height: 112rpx;
+  display: block;
+  background: #eef1f4;
+}
+.file-name {
+  display: block;
+  margin-top: 4rpx;
+  max-width: 112rpx;
+  font-size: 18rpx;
+  color: #6f7782;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.file-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 44rpx;
+  color: #6f7782;
+}
+.file-remove {
+  position: absolute;
+  top: -8rpx;
+  right: -8rpx;
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  background: #1f2329;
+  color: #fff;
+  font-size: 24rpx;
+  line-height: 36rpx;
+  text-align: center;
+  font-weight: 600;
+}
+.file-remove:active {
+  opacity: 0.6;
+}
+.upload-row {
+  margin-top: 12rpx;
+}
+.file-count {
+  font-size: 22rpx;
+  color: #4b5563;
+}
+.material-action {
+  flex-shrink: 0;
+  margin-left: 16rpx;
+  margin-top: 4rpx;
+  padding: 12rpx 22rpx;
+  border-radius: 999rpx;
+  background: #e7f0ff;
+  color: #1f6feb;
+  font-size: 24rpx;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  gap: 4rpx;
+}
+.material-action.is-disabled {
+  background: #f2f3f5;
+  color: #8b939f;
+}
+.action-plus {
+  font-size: 28rpx;
+  font-weight: 600;
+  line-height: 1;
+}
+.info-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 18rpx;
+  margin: 18rpx 24rpx 0;
+}
+.info-card {
+  padding: 24rpx;
+}
+.info-card-wide {
+  grid-column: span 2;
+}
+.info-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 14rpx;
+}
+.info-title {
+  font-size: 28rpx;
+  font-weight: 600;
+}
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  font-size: 24rpx;
+  padding: 8rpx 0;
+  color: #4b5563;
+}
+.info-row text:first-child {
+  color: #8b939f;
+}
+.info-row.info-items {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+  justify-content: flex-end;
+  padding-top: 12rpx;
+}
+.info-chip {
+  padding: 6rpx 16rpx;
+  border-radius: 999rpx;
+  background: #e7f0ff;
+  color: #1f6feb;
+  font-size: 22rpx;
+  font-weight: 500;
+}
+.footer-tip {
+  padding: 32rpx 28rpx 8rpx;
+  font-size: 22rpx;
+  text-align: center;
+}
+.footer-actions {
+  display: flex;
+  gap: 18rpx;
+  padding: 24rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
+}
+.footer-actions > view {
+  flex: 1;
+}
+</style>

+ 237 - 2
src/utils/storage.js

@@ -5,6 +5,9 @@ const DRAFT_KEY = 'aimill_hr_application_drafts'
 const APPROVAL_KEY = 'aimill_hr_approval_results'
 const TRAINING_KEY = 'aimill_hr_training_progress'
 const PROFILE_KEY = 'aimill_hr_profile_changes'
+const ONBOARD_MATERIALS_KEY = 'aimill_hr_onboard_materials'
+const ONBOARD_LIST_KEY = 'aimill_hr_onboardings'
+const RESIGN_KEY = 'aimill_hr_resignations'
 const AUTH_KEY = 'aimill_hr_auth'
 const REMEMBER_KEY = 'aimill_hr_remembered_account'
 const PASSWORD_MEMO_KEY = 'passwordMemo'
@@ -180,11 +183,188 @@ export function getAuthCredentials() {
 }
 
 export function clearDemoStorage() {
-  [APPLY_KEY, DRAFT_KEY, APPROVAL_KEY, TRAINING_KEY, PROFILE_KEY].forEach((key) =>
+  [APPLY_KEY, DRAFT_KEY, APPROVAL_KEY, TRAINING_KEY, PROFILE_KEY, ONBOARD_MATERIALS_KEY, ONBOARD_LIST_KEY, RESIGN_KEY].forEach((key) =>
     uni.removeStorageSync(key),
   )
 }
 
+// 入职记录持久化:mock 中的 defaultOnboardings 是只读示例,
+// 用户通过创建表单新增 / 编辑的记录存在 ONBOARD_LIST_KEY
+export function getCustomOnboardings() {
+  const saved = uni.getStorageSync(ONBOARD_LIST_KEY)
+  return Array.isArray(saved) ? saved : []
+}
+
+export function addOnboarding(staff) {
+  if (!staff || !staff.id) return getCustomOnboardings()
+  const list = getCustomOnboardings()
+  list.unshift(staff)
+  uni.setStorageSync(ONBOARD_LIST_KEY, list)
+  return list
+}
+
+export function updateOnboarding(id, patch) {
+  if (!id) return getCustomOnboardings()
+  const list = getCustomOnboardings()
+  const idx = list.findIndex((o) => o.id === id)
+  if (idx >= 0) {
+    list[idx] = { ...list[idx], ...patch }
+    uni.setStorageSync(ONBOARD_LIST_KEY, list)
+  }
+  return list
+}
+
+export function removeOnboarding(id) {
+  if (!id) return getCustomOnboardings()
+  const list = getCustomOnboardings().filter((o) => o.id !== id)
+  uni.setStorageSync(ONBOARD_LIST_KEY, list)
+  return list
+}
+
+export function getOnboardingById(id, defaults = []) {
+  const custom = getCustomOnboardings().find((o) => o.id === id)
+  if (custom) return custom
+  return defaults.find((o) => o.id === id) || null
+}
+
+// 把 mock 默认数据 + 用户新增的合并;用户数据优先
+export function mergeOnboardings(defaults = []) {
+  const custom = getCustomOnboardings()
+  const ids = new Set(custom.map((o) => o.id))
+  return [...custom, ...defaults.filter((o) => !ids.has(o.id))]
+}
+
+// 入职材料上传:按 employeeId → materialKey → FileInfo[] 索引
+// FileInfo = { id, name, path, size, uploadedAt, sourceType }
+// 演示版只存元信息 + 本地临时路径,不上传到对象存储
+export function getOnboardMaterials(employeeId) {
+  if (!employeeId) return {}
+  const all = uni.getStorageSync(ONBOARD_MATERIALS_KEY) || {}
+  const record = all[employeeId]
+  return record && typeof record === 'object' ? record : {}
+}
+
+export function getOnboardMaterialList(employeeId, materialKey) {
+  const record = getOnboardMaterials(employeeId)
+  const list = record[materialKey]
+  return Array.isArray(list) ? list : []
+}
+
+export function addOnboardMaterial(employeeId, materialKey, file) {
+  if (!employeeId || !materialKey) return []
+  const all = uni.getStorageSync(ONBOARD_MATERIALS_KEY) || {}
+  const prev = all[employeeId] && typeof all[employeeId] === 'object' ? all[employeeId] : {}
+  const list = Array.isArray(prev[materialKey]) ? prev[materialKey] : []
+  const item = {
+    id: `mat_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
+    name: file.name || `材料_${materialKey}`,
+    path: file.path || '',
+    size: Number(file.size) || 0,
+    sourceType: file.sourceType || 'album',
+    uploadedAt: formatNow(),
+  }
+  const next = [...list, item]
+  all[employeeId] = { ...prev, [materialKey]: next }
+  uni.setStorageSync(ONBOARD_MATERIALS_KEY, all)
+  return next
+}
+
+export function removeOnboardMaterial(employeeId, materialKey, fileId) {
+  if (!employeeId || !materialKey) return []
+  const all = uni.getStorageSync(ONBOARD_MATERIALS_KEY) || {}
+  const prev = all[employeeId] && typeof all[employeeId] === 'object' ? all[employeeId] : {}
+  const list = Array.isArray(prev[materialKey]) ? prev[materialKey] : []
+  const next = list.filter((f) => f.id !== fileId)
+  all[employeeId] = { ...prev, [materialKey]: next }
+  uni.setStorageSync(ONBOARD_MATERIALS_KEY, all)
+  return next
+}
+
+export function renameOnboardMaterial(employeeId, materialKey, fileId, newName) {
+  const trimmed = String(newName || '').trim()
+  if (!employeeId || !materialKey || !trimmed) return []
+  const all = uni.getStorageSync(ONBOARD_MATERIALS_KEY) || {}
+  const prev = all[employeeId] && typeof all[employeeId] === 'object' ? all[employeeId] : {}
+  const list = Array.isArray(prev[materialKey]) ? prev[materialKey] : []
+  const next = list.map((f) => (f.id === fileId ? { ...f, name: trimmed } : f))
+  all[employeeId] = { ...prev, [materialKey]: next }
+  uni.setStorageSync(ONBOARD_MATERIALS_KEY, all)
+  return next
+}
+
+// 把临时路径读取为 base64 dataURL,用于在 storage 中持久化预览
+// - H5:tempFilePath 通常是 blob://,用 fetch + FileReader
+// - 小程序:通过 uni.getFileSystemManager.readFile({ encoding: 'base64' })
+// - App:由原生 chooseImage 返回本地绝对路径,读不到时回退空字符串
+export async function readFileAsBase64(tempPath) {
+  if (!tempPath) return ''
+  // 已是 dataURL:直接返回
+  if (tempPath.startsWith('data:')) return tempPath
+  // #ifdef H5
+  try {
+    const resp = await fetch(tempPath)
+    const blob = await resp.blob()
+    return await new Promise((resolve) => {
+      const reader = new FileReader()
+      reader.onload = () => resolve(String(reader.result || ''))
+      reader.onerror = () => resolve('')
+      reader.readAsDataURL(blob)
+    })
+  } catch (e) {
+    return ''
+  }
+  // #endif
+  // #ifdef MP-WEIXIN
+  return new Promise((resolve) => {
+    if (!uni.getFileSystemManager) return resolve('')
+    uni.getFileSystemManager().readFile({
+      filePath: tempPath,
+      encoding: 'base64',
+      success: (res) => {
+        const ext = (tempPath.match(/\.(\w+)$/)?.[1] || 'jpg').toLowerCase()
+        const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg'
+        resolve(`data:${mime};base64,${res.data}`)
+      },
+      fail: () => resolve(''),
+    })
+  })
+  // #endif
+  // #ifdef APP-PLUS
+  return new Promise((resolve) => {
+    if (!plus || !plus.io) return resolve('')
+    plus.io.resolveLocalFileSystemURL(
+      tempPath,
+      (entry) => entry.file(
+        (file) => {
+          const reader = new plus.nativeUI.FileReader()
+          reader.onloadend = (e) => resolve(`data:image/jpeg;base64,${e.target.result}`)
+          reader.onerror = () => resolve('')
+          reader.readAsDataURL(file)
+        },
+        () => resolve(''),
+      ),
+      () => resolve(''),
+    )
+  })
+  // #endif
+}
+
+// 演示版上传:模拟 0-100% 进度回调。真实接入时替换为 uni.uploadFile({ url: realEndpoint })
+// 返回 { uploaded, url },url 在演示版等于原临时路径
+export function mockUpload(tempPath, { onProgress } = {}) {
+  return new Promise((resolve) => {
+    let progress = 0
+    const tick = () => {
+      progress += Math.floor(Math.random() * 25) + 15
+      if (progress > 100) progress = 100
+      onProgress?.(progress)
+      if (progress >= 100) resolve({ uploaded: true, url: tempPath, mock: true })
+      else setTimeout(tick, 120)
+    }
+    setTimeout(tick, 80)
+  })
+}
+
 export function getApplications() {
   const saved = uni.getStorageSync(APPLY_KEY)
   return Array.isArray(saved) ? saved : []
@@ -192,7 +372,11 @@ export function getApplications() {
 
 export function addApplication(item) {
   const list = getApplications()
-  list.unshift({ ...item, id: Date.now(), status: '审批中', createdAt: formatNow() })
+  // 若调用方已传 id(如服务器模式返回的单据主键),保留以便与后端对齐;
+  // 否则按本地时间戳生成唯一 id
+  const id = item && item.id ? item.id : Date.now()
+  const { id: _omit, ...rest } = item || {}
+  list.unshift({ ...rest, id, status: '审批中', createdAt: formatNow() })
   uni.setStorageSync(APPLY_KEY, list)
   return list
 }
@@ -244,6 +428,57 @@ export function saveProfileChange(item) {
   return list
 }
 
+export function getResignations() {
+  const saved = uni.getStorageSync(RESIGN_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function getResignDetail(id) {
+  if (!id) return null
+  const all = getResignations()
+  return all[id] && typeof all[id] === 'object' ? all[id] : null
+}
+
+export function setResignStage(id, stageKey, state = 'done') {
+  if (!id || !stageKey) return null
+  const all = getResignations()
+  const prev = all[id] && typeof all[id] === 'object' ? all[id] : {}
+  const stages = prev.stages && typeof prev.stages === 'object' ? prev.stages : {}
+  stages[stageKey] = state
+  all[id] = { ...prev, stages, updatedAt: formatNow() }
+  uni.setStorageSync(RESIGN_KEY, all)
+  return all[id]
+}
+
+export function setResignStatus(id, status) {
+  if (!id || !status) return null
+  const all = getResignations()
+  const prev = all[id] && typeof all[id] === 'object' ? all[id] : {}
+  all[id] = { ...prev, status, updatedAt: formatNow() }
+  uni.setStorageSync(RESIGN_KEY, all)
+  return all[id]
+}
+
+export function addResignAssetReturn(id, assetName, returned = true) {
+  if (!id || !assetName) return null
+  const all = getResignations()
+  const prev = all[id] && typeof all[id] === 'object' ? all[id] : {}
+  const map = prev.assetReturnMap && typeof prev.assetReturnMap === 'object' ? prev.assetReturnMap : {}
+  map[assetName] = !!returned
+  all[id] = { ...prev, assetReturnMap: map, updatedAt: formatNow() }
+  uni.setStorageSync(RESIGN_KEY, all)
+  return all[id]
+}
+
+export function setResignLocalState(id, patch) {
+  if (!id || !patch || typeof patch !== 'object') return null
+  const all = getResignations()
+  const prev = all[id] && typeof all[id] === 'object' ? all[id] : {}
+  all[id] = { ...prev, ...patch, updatedAt: formatNow() }
+  uni.setStorageSync(RESIGN_KEY, all)
+  return all[id]
+}
+
 export function formatNow() {
   const d = new Date()
   const pad = (n) => String(n).padStart(2, '0')