Просмотр исходного кода

feat(resign): 离职流程接入服务端接口,新增交接单页面

- src/api/resign.js:封装离职单接口,新增 registerSubmittedResignation
- 新增 src/pages/manage/resign-handover.vue:离职交接单页
- src/pages/manage/resign-detail.vue / index.vue:列表与详情接入 M02 服务端
- src/pages/apply/form.vue:离职申请提交后登记本地/服务端单据
- src/utils/storage.js:新增 RESIGN_CREATED/HANDOVER/ID 三个存储键及对应 helper
- src/data/mock.js:新增一条离职申请 mock 数据用于演示
- src/utils/router.js:新增 back(fallbackUrl) 通用返回 helper
- src/pages.json:注册 resign-handover 路由
xieyong 1 месяц назад
Родитель
Сommit
abf742585e

+ 955 - 17
src/api/resign.js

@@ -1,4 +1,22 @@
 import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+import { resignings, resignStages } from '@/data/mock'
+import {
+  addCreatedResignation,
+  formatNow,
+  getCreatedResignations,
+  getCurrentUser,
+  getHandoverById,
+  getHandoverByResignationId,
+  getHandovers,
+  getResignations,
+  getResignIds,
+  rememberResignId,
+  removeHandoverById,
+  saveHandover,
+  setResignLocalState,
+  updateCreatedResignation,
+  updateHandoverItemState,
+} from '@/utils/storage'
 
 // ============ 模式判断 ============
 function isServerMode() {
@@ -13,8 +31,7 @@ function serverPath() {
 
 // ============ 枚举 ============
 
-// 前端离职类型(form.vue actionSheet 选项)→ 后端 resignationType 枚举
-// 后端枚举值未定,先用与前端一致的中文 key,由后端后续给出映射表时再调整
+// 员工端申请表单(form.vue actionSheet)→ 后端 resignationType 枚举
 export const RESIGNATION_TYPE_MAP = {
   '主动离职': 'VOLUNTARY',
   '协商离职': 'NEGOTIATED',
@@ -32,18 +49,884 @@ export const RESIGN_REASON_MAP = {
   '健康原因': 'HEALTH',
 }
 
+// M02 后端枚举 → 中文(与 PC 端 src/api/hr/resignation.js 对齐)
+export const RESIGNATION_TYPE_LABELS = {
+  VOLUNTARY: '主动离职',
+  NEGOTIATED: '协商解除',
+  PROBATION_FAILED: '试用不合格',
+  PERFORMANCE_FAILED: '绩效不合格',
+  TERMINATED: '辞退',
+  DISMISSED: '辞退',
+  CONTRACT_EXPIRED: '合同到期',
+  RETIRE: '退休',
+  RETIREMENT: '退休',
+  OTHER: '其他',
+}
+
+export const APPROVAL_STATUS_LABELS = {
+  DRAFT: '草稿',
+  PENDING: '审批中',
+  APPROVING: '审批中',
+  APPROVED: '已通过',
+  REJECTED: '已驳回',
+  WITHDRAWN: '已撤回',
+  CANCELLED: '已取消',
+}
+
+export const HANDOVER_STATUS_LABELS = {
+  NOT_STARTED: '未开始',
+  PENDING: '待交接',
+  IN_PROGRESS: '交接中',
+  PROCESSING: '交接中',
+  COMPLETED: '已完成',
+  FORCE_COMPLETED: '强制办结',
+}
+
+export const EFFECTIVE_STATUS_LABELS = {
+  PENDING: '待生效',
+  SCHEDULED: '已排期',
+  EFFECTIVE: '已生效',
+  CANCELLED: '已取消',
+}
+
+export const ARCHIVE_STATUS_LABELS = {
+  NOT_ARCHIVED: '未归档',
+  PENDING: '待归档',
+  ARCHIVED: '已归档',
+}
+
+export const HANDOVER_CATEGORY_LABELS = {
+  WORK: '工作交接',
+  ASSET: '资产交接',
+  FINANCE: '财务交接',
+  HR: 'HR 交接',
+}// 流程展示状态 → 标签 / 样式(对应 H5 现有 .status-* 原子类)
+export const RESIGN_DISPLAY_STATUS = {
+  draft: { label: '草稿', class: 'status-pending' },
+  pending: { label: '审批中', class: 'status-pending' },
+  rejected: { label: '已驳回', class: 'status-rejected' },
+  withdrawn: { label: '已撤回', class: 'status-rejected' },
+  cancelled: { label: '已取消', class: 'status-rejected' },
+  overdue: { label: '已逾期', class: 'status-rejected' },
+  approved: { label: '已通过', class: 'status-approved' },
+  handover: { label: '交接中', class: 'status-processing' },
+  completed: { label: '已完成', class: 'status-approved' },
+}
+
+// 服务器模式流程节点:与 M02 状态机(提交 → 审批 → 交接 → 归档)一一对应
+export const RESIGN_FLOW_STAGES = [
+  { key: 'submit', label: '申请提交', desc: '员工提交离职申请并填写预计离职日' },
+  { key: 'approval', label: '离职审批', desc: '直属主管、部门负责人与 HRBP 审批' },
+  { key: 'handover', label: '工作交接', desc: '交接人确认工作、资产与文档移交' },
+  { key: 'archive', label: '归档生效', desc: '交接完成后归档,劳动关系解除' },
+]
+
+// 列表页状态筛选:value 对应 displayStatus
+export const RESIGN_STATUS_FILTERS = [
+  { value: 'all', label: '全部状态' },
+  { value: 'pending', label: '审批中' },
+  { value: 'handover', label: '交接中' },
+  { value: 'completed', label: '已完成' },
+  { value: 'rejected', label: '已驳回' },
+  { value: 'withdrawn', label: '已撤回' },
+]
+
+// 生成交接单时的默认事项(工作 / 资产 / 财务 / HR 四类,与 PC 端服务端默认项一致)
+export const DEFAULT_HANDOVER_ITEMS = [
+  { category: 'WORK', name: '在办工作事项与项目进度移交', detail: '未完成事项清单、排期与对接人说明' },
+  { category: 'WORK', name: '文档、资料与系统权限交接', detail: '共享盘文档归档、系统账号与权限移交' },
+  { category: 'ASSET', name: '办公设备与门禁工牌归还', detail: '电脑、仪器、工牌、门禁卡归还登记' },
+  { category: 'FINANCE', name: '借款、报销与备用金结清', detail: '未清借款、报销单据与备用金核对' },
+  { category: 'HR', name: '考勤、假期与档案信息确认', detail: '剩余年假折算、社保公积金停缴确认' },
+]
+
+// 演示数据状态 → 流程展示状态
+const DEMO_STATUS_TO_DISPLAY = {
+  draft: 'draft',
+  pending: 'pending',
+  approved: 'approved',
+  processing: 'handover',
+  overdue: 'overdue',
+  done: 'completed',
+  cancelled: 'withdrawn',
+  rejected: 'rejected',
+}
+
+// ============ 工具函数 ============
+
+function labelOf(map, raw, fallback = '') {
+  const text = String(raw || '').trim()
+  if (!text) return fallback
+  return map[text] || map[text.toUpperCase()] || text
+}
+
+function dateOnly(value) {
+  if (!value) return ''
+  return String(value).slice(0, 10)
+}
+
+function compact(object = {}) {
+  return Object.keys(object).reduce((result, key) => {
+    const value = object[key]
+    if (value !== '' && value !== null && value !== undefined) result[key] = value
+    return result
+  }, {})
+}
+
+function asList(data) {
+  if (Array.isArray(data)) return data
+  if (Array.isArray(data?.list)) return data.list
+  if (Array.isArray(data?.records)) return data.records
+  if (data && typeof data === 'object' && data.id != null) return [data]
+  return []
+}
+
+function isDoneItemStatus(status) {
+  return ['COMPLETED', 'DONE', 'FINISHED'].includes(String(status || '').toUpperCase())
+}
+
+function approvalCode(raw = {}) {
+  return String(raw.approvalStatus || raw.statusCode || '').toUpperCase()
+}
+
+// ============ 状态推导与操作可用性(与 PC 端保持一致) ============
+
+export function deriveResignationDisplayStatus(raw = {}) {
+  const archive = String(raw.archiveStatus || '').toUpperCase()
+  const effective = String(raw.effectiveStatus || '').toUpperCase()
+  const handover = String(raw.handoverStatus || '').toUpperCase()
+  const approval = String(raw.approvalStatus || '').toUpperCase()
+
+  if (approval === 'CANCELLED') return 'cancelled'
+  if (archive === 'ARCHIVED' || effective === 'EFFECTIVE') return 'completed'
+  if (approval === 'APPROVED') {
+    if (!handover || ['IN_PROGRESS', 'PROCESSING', 'PENDING', 'NOT_STARTED'].includes(handover)) {
+      return 'handover'
+    }
+    return 'approved'
+  }
+  if (approval === 'REJECTED') return 'rejected'
+  if (approval === 'PENDING' || approval === 'APPROVING') return 'pending'
+  if (approval === 'WITHDRAWN') return 'withdrawn'
+  return 'draft'
+}
+
+export function canSubmitResignation(row = {}) {
+  if (!row.id) return false
+  const code = approvalCode(row)
+  return code === 'DRAFT' || code === 'REJECTED' || code === ''
+}
+
+export function canWithdrawResignation(row = {}) {
+  const code = approvalCode(row)
+  return code === 'PENDING' || code === 'APPROVING' || row.displayStatus === 'pending'
+}
+
+export function canCancelResignation(row = {}) {
+  if (!row.id) return false
+  if (approvalCode(row) === 'CANCELLED') return false
+  return row.displayStatus !== 'completed'
+}
+
+export function canGenerateHandover(row = {}) {
+  if (!row.id || row.handover) return false
+  if (approvalCode(row) === 'APPROVED') return true
+  // 演示模式没有真实审批流,审批中即可先建交接单,便于走完整流程
+  return row.source === 'demo' && ['pending', 'approved', 'handover'].includes(row.displayStatus)
+}
+
+export function canArchiveResignation(row = {}) {
+  if (!row.id) return false
+  if (!row.handover || row.handover.status !== 'completed') return false
+  return String(row.archiveStatus || '').toUpperCase() !== 'ARCHIVED'
+}
+
+export function canForceCompleteResignation(row = {}) {
+  if (!row.id) return false
+  return (
+    String(row.archiveStatus || '').toUpperCase() !== 'ARCHIVED' &&
+    row.displayStatus !== 'completed'
+  )
+}
+
+export function matchDisplayStatus(row = {}, filter = '') {
+  if (!filter || filter === 'all') return true
+  if (filter === 'rejected') return ['rejected', 'cancelled'].includes(row.displayStatus)
+  if (filter === 'withdrawn') return ['withdrawn', 'cancelled'].includes(row.displayStatus)
+  return row.displayStatus === filter
+}
+// ============ VO → 页面形状映射 ============
+
+export function adaptHandoverItem(raw = {}) {
+  const done = isDoneItemStatus(raw.itemStatus || raw.status)
+  const category = raw.itemCategory || raw.category || 'WORK'
+  return {
+    id: raw.id,
+    name: raw.itemName || raw.name || '',
+    detail: raw.itemContent || raw.detail || raw.completionRemark || '',
+    category,
+    categoryLabel: labelOf(HANDOVER_CATEGORY_LABELS, category),
+    receiverUserId: raw.receiverUserId,
+    receiverName: raw.receiverName || '',
+    status: raw.itemStatus || raw.status || '',
+    done,
+    doneAt: dateOnly(raw.completeTime || raw.doneAt),
+  }
+}
+
+export function adaptHandover(raw = {}) {
+  if (!raw || typeof raw !== 'object') return null
+  const items = (raw.items || []).map(adaptHandoverItem)
+  const doneCount = items.filter((item) => item.done).length
+  const itemsProgress = items.length ? Math.round((doneCount / items.length) * 100) : 0
+  const code = String(raw.handoverStatus || '').toUpperCase()
+  const completed =
+    ['COMPLETED', 'FORCE_COMPLETED'].includes(code) || (items.length > 0 && doneCount === items.length)
+  const progress = completed ? 100 : itemsProgress
+
+  return {
+    id: raw.id,
+    resignationId: raw.resignationId,
+    resignationNo: raw.resignationNo || '',
+    userId: raw.userId,
+    person: raw.handoverPerson || items.find((item) => item.receiverName)?.receiverName || '',
+    handoverDate: dateOnly(raw.handoverDate),
+    deadline: dateOnly(raw.deadline || raw.handoverDate),
+    remark: raw.remark || '',
+    status: completed ? 'completed' : progress > 0 ? 'processing' : 'not_started',
+    statusLabel: completed ? '已完成' : progress > 0 ? '交接中' : '待交接',
+    statusCode: raw.handoverStatus || '',
+    statusCodeLabel: labelOf(HANDOVER_STATUS_LABELS, raw.handoverStatus),
+    progress,
+    items,
+    updated: dateOnly(raw.handoverDate || raw.createTime),
+  }
+}
+
+function stageProgressOf(stageKey, stages, handover) {
+  const index = stages.findIndex((stage) => stage.key === stageKey)
+  if (index < 0) return 0
+  if (stageKey === 'handover' && handover) {
+    // 交接阶段按事项完成度在节点内平滑推进(50% ~ 99%)
+    return Math.min(99, 50 + Math.round((handover.progress || 0) / 2))
+  }
+  return Math.round(((index + 1) / stages.length) * 100)
+}
+
+function progressTipOf(displayStatus, handover) {
+  switch (displayStatus) {
+    case 'draft':
+      return '草稿未提交,请提交审批后进入离职流程。'
+    case 'pending':
+      return '离职审批中,请等待审批结果。'
+    case 'rejected':
+      return '审批已驳回,可修改后重新提交。'
+    case 'withdrawn':
+      return '本次离职申请已撤回。'
+    case 'cancelled':
+      return '本次离职申请已取消。'
+    case 'approved':
+      return '审批已通过,待生成离职交接单。'
+    case 'handover':
+      return handover
+        ? `交接进行中,事项已完成 ${handover.progress}%。`
+        : '审批已通过,待生成离职交接单。'
+    case 'completed':
+      return '离职流程已全部完成。'
+    case 'overdue':
+      return '已超过最后工作日,请尽快推进交接与结算。'
+    default:
+      return ''
+  }
+}
+
+/** 服务器 VO → 页面形状 */
+export function adaptResignation(vo = {}, extras = {}) {
+  if (!vo || typeof vo !== 'object') return null
+  const displayStatus = deriveResignationDisplayStatus(vo)
+  const handover = vo.handover ? adaptHandover(vo.handover) : null
+  const statusMeta = RESIGN_DISPLAY_STATUS[displayStatus] || RESIGN_DISPLAY_STATUS.pending
+  const stages = RESIGN_FLOW_STAGES
+  const stageKey =
+    displayStatus === 'completed'
+      ? 'archive'
+      : displayStatus === 'handover' || displayStatus === 'approved'
+        ? 'handover'
+        : displayStatus === 'pending'
+          ? 'approval'
+          : 'submit'
+
+  return {
+    id: vo.id,
+    source: 'server',
+    resignationNo: vo.resignationNo || '',
+    userId: vo.userId,
+    name: extras.name || vo.employeeName || vo.userName || '',
+    avatar: String(extras.name || vo.employeeName || '').charAt(0),
+    employeeNo: extras.employeeNo || vo.employeeNo || '',
+    department: extras.department || vo.departmentName || vo.deptName || '',
+    position: extras.position || vo.positionName || '',
+    entryDate: dateOnly(extras.hireDate || vo.hireDate),
+    expectedLeaveDate: dateOnly(vo.plannedLastWorkDate || vo.effectiveDate),
+    lastWorkDate: dateOnly(vo.effectiveDate),
+    applyDate: dateOnly(vo.applyDate || vo.createTime),
+    effectiveDate: dateOnly(vo.effectiveDate),
+    type: labelOf(RESIGNATION_TYPE_LABELS, vo.resignationType),
+    typeCode: vo.resignationType || '',
+    reason: vo.reason || '',
+    userComment: vo.userComment || '',
+    hrComment: vo.hrComment || '',
+    approvalStatus: vo.approvalStatus || '',
+    approvalStatusLabel: labelOf(APPROVAL_STATUS_LABELS, vo.approvalStatus),
+    handoverStatus: vo.handoverStatus || '',
+    handoverStatusLabel: labelOf(HANDOVER_STATUS_LABELS, vo.handoverStatus),
+    effectiveStatus: vo.effectiveStatus || '',
+    effectiveStatusLabel: labelOf(EFFECTIVE_STATUS_LABELS, vo.effectiveStatus),
+    archiveStatus: vo.archiveStatus || '',
+    archiveStatusLabel: labelOf(ARCHIVE_STATUS_LABELS, vo.archiveStatus),
+    displayStatus,
+    statusLabel: statusMeta.label,
+    statusClass: statusMeta.class,
+    stageKey,
+    stages,
+    progress: displayStatus === 'completed' ? 100 : stageProgressOf(stageKey, stages, handover),
+    progressTip: progressTipOf(displayStatus, handover),
+    handover,
+    onlineSign: Boolean(vo.approvalDocumentFileId || vo.onlineDocumentId),
+    createTime: vo.createTime || '',
+    raw: vo,
+  }
+}
+// ============ 演示数据 → 页面形状 ============
+
+function buildDemoHandoverItems(record = {}) {
+  const raw = record.handover || {}
+  return (raw.items || []).map((item, index) => {
+    const name = typeof item === 'string' ? item : item.name || ''
+    const detail = typeof item === 'string' ? '' : item.detail || item.content || ''
+    return {
+      id: `demo-item-${record.id}-${index}`,
+      name,
+      detail,
+      category: 'WORK',
+      categoryLabel: HANDOVER_CATEGORY_LABELS.WORK,
+      receiverName: raw.person || '',
+      done: false,
+      status: 'PENDING',
+    }
+  })
+}
+
+// 演示模式交接单:优先取本地已生成的,否则把 mock 交接信息归一成只读交接单
+export function getDemoHandover(record = {}) {
+  const stored = getHandoverByResignationId(record.id)
+  if (stored) return adaptHandover(stored)
+
+  const raw = record.handover || {}
+  const items = buildDemoHandoverItems(record)
+  if (!items.length && !raw.person) return null
+
+  // mock 只给了交接百分比,这里按比例把前 N 项标记为已交接,保证进度条与事项数一致
+  const rawProgress = Number(String(raw.progress || '').replace('%', '')) || 0
+  const readyCount = record.status === 'done' ? items.length : Math.round((rawProgress / 100) * items.length)
+  items.forEach((item, index) => {
+    item.done = index < readyCount
+    item.status = item.done ? 'COMPLETED' : 'PENDING'
+  })
+
+  const doneCount = items.filter((item) => item.done).length
+  const progress = items.length ? Math.round((doneCount / items.length) * 100) : rawProgress
+  const completed = record.status === 'done' || (items.length > 0 && doneCount === items.length)
+
+  return {
+    id: `demo-handover-${record.id}`,
+    resignationId: record.id,
+    resignationNo: record.id,
+    userId: record.id,
+    person: raw.person || '',
+    handoverDate: '',
+    deadline: dateOnly(raw.deadline || record.expectedLeaveDate),
+    remark: raw.note || '',
+    status: completed ? 'completed' : progress > 0 ? 'processing' : 'not_started',
+    statusLabel: completed ? '已完成' : progress > 0 ? '交接中' : '待交接',
+    statusCode: completed ? 'COMPLETED' : progress > 0 ? 'IN_PROGRESS' : 'NOT_STARTED',
+    statusCodeLabel: completed ? '已完成' : progress > 0 ? '交接中' : '未开始',
+    progress: completed ? 100 : progress,
+    items,
+    updated: '',
+  }
+}
+
+/** mock / 本地新建的离职单 → 页面形状 */
+export function mapDemoResignation(record = {}, override = {}) {
+  const merged = { ...record, ...override }
+  const handover = getDemoHandover(merged)
+  const displayStatus = DEMO_STATUS_TO_DISPLAY[merged.status] || 'pending'
+  const statusMeta = RESIGN_DISPLAY_STATUS[displayStatus] || RESIGN_DISPLAY_STATUS.pending
+  const stageKey = merged.stage || 'submit'
+  const index = resignStages.findIndex((stage) => stage.key === stageKey)
+  const progress =
+    displayStatus === 'completed'
+      ? 100
+      : Math.round((Math.max(index, 0) / resignStages.length) * 100)
+  const currentStage = resignStages.find((stage) => stage.key === stageKey)
+
+  return {
+    ...merged,
+    id: merged.id,
+    source: 'demo',
+    avatar: merged.avatar || String(merged.name || '').charAt(0),
+    employeeNo: merged.id,
+    entryDate: dateOnly(merged.entryDate),
+    expectedLeaveDate: dateOnly(merged.expectedLeaveDate),
+    lastWorkDate: dateOnly(merged.lastWorkDate),
+    applyDate: dateOnly(merged.submitDate),
+    typeCode: merged.typeCode || '',
+    approvalStatus: merged.approvalStatus || '',
+    archiveStatus: merged.archiveStatus || (displayStatus === 'completed' ? 'ARCHIVED' : ''),
+    displayStatus,
+    statusLabel: statusMeta.label,
+    statusClass: statusMeta.class,
+    stageKey,
+    stages: resignStages,
+    progress,
+    progressTip:
+      displayStatus === 'overdue'
+        ? '已超过最后工作日,请尽快推进交接与结算。'
+        : displayStatus === 'completed'
+          ? '离职流程已全部完成。'
+          : displayStatus === 'withdrawn'
+            ? '本次离职申请已撤回。'
+            : `当前进行:${currentStage?.label || '—'}`,
+    handover,
+    createTime: merged.submitDate || '',
+    raw: merged,
+  }
+}
+// ============ 员工信息补全(服务器模式) ============
+
+const employeeCache = new Map()
+
+async function fetchEmployeeBrief(userId) {
+  if (userId === undefined || userId === null || userId === '') return null
+  const key = String(userId)
+  if (employeeCache.has(key)) return employeeCache.get(key)
+  try {
+    const res = await request({ url: `${serverPath()}/main/user/getById/${key}`, method: 'GET' })
+    const vo = res.data || {}
+    const brief = {
+      name: vo.name || vo.userName || '',
+      employeeNo: vo.jobNumber || '',
+      department: vo.deptName || '',
+      position: vo.postName || vo.position || '',
+      hireDate: dateOnly(vo.joinDate || vo.entryDate),
+    }
+    employeeCache.set(key, brief)
+    return brief
+  } catch (error) {
+    employeeCache.set(key, null)
+    return null
+  }
+}
+
+function extrasOf(brief) {
+  if (!brief) return {}
+  return {
+    name: brief.name,
+    employeeNo: brief.employeeNo,
+    department: brief.department,
+    position: brief.position,
+    hireDate: brief.hireDate,
+  }
+}
+
+// ============ 服务器接口(M02-员工离职) ============
+
+/** 查询离职单详情 GET /main/resignations/{id} */
+export async function getResignationById(id) {
+  const res = await request({ url: `${serverPath()}/main/resignations/${id}`, method: 'GET' })
+  return res.data
+}
+
+/** 提交离职审批 POST /main/resignations/{id}/submit */
+export async function submitResignation(id) {
+  const res = await request({ url: `${serverPath()}/main/resignations/${id}/submit`, method: 'POST' })
+  return res.data
+}
+
+/** 撤回离职审批 POST /main/resignations/{id}/withdraw */
+export async function withdrawResignation(id, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/resignations/${id}/withdraw`,
+    method: 'POST',
+    data: compact({ reason }),
+  })
+  return res.data
+}
+
+/** 取消离职单 POST /main/resignations/cancel/{id}(注意:cancel 在路径中间) */
+export async function cancelResignation(id, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/resignations/cancel/${id}`,
+    method: 'POST',
+    data: compact({ reason }),
+  })
+  return res.data
+}
+
+/** 归档离职单 POST /main/resignations/{id}/archive */
+export async function archiveResignation(id) {
+  const res = await request({ url: `${serverPath()}/main/resignations/${id}/archive`, method: 'POST' })
+  return res.data
+}
+
+/** 强制办结离职单 POST /main/resignations/{id}/force-complete(原因必填) */
+export async function forceCompleteResignation(id, reason) {
+  const res = await request({
+    url: `${serverPath()}/main/resignations/${id}/force-complete`,
+    method: 'POST',
+    data: { reason },
+  })
+  return res.data
+}
+
+/** 生成离职交接单 POST /main/resignations/{id}/generate-handover */
+export async function generateResignationHandover(id) {
+  const res = await request({
+    url: `${serverPath()}/main/resignations/${id}/generate-handover`,
+    method: 'POST',
+  })
+  return res.data
+}
+
+/** 完成离职交接事项 PUT /main/resignations/{id}/handover/items/{itemId}/complete */
+export async function completeResignationHandoverItem(resignationId, itemId, remark = '') {
+  const res = await request({
+    url: `${serverPath()}/main/resignations/${resignationId}/handover/items/${itemId}/complete`,
+    method: 'PUT',
+    data: compact({ reason: remark }),
+  })
+  return res.data
+}
+
+/** 分页查询离职交接单 POST /main/handovers/page */
+export async function getHandoverPage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/main/handovers/page`,
+    method: 'POST',
+    data: compact({
+      pageNum: params.pageNum,
+      size: params.size,
+      handoverStatus: params.handoverStatus || undefined,
+      resignationNo: params.keyword || undefined,
+    }),
+  })
+  const data = res.data || {}
+  return { list: asList(data), count: Number(data?.count ?? data?.total ?? 0) }
+}
+
+/** 查询离职交接单 GET /main/handovers/{id} */
+export async function getHandoverDetail(id) {
+  const res = await request({ url: `${serverPath()}/main/handovers/${id}`, method: 'GET' })
+  return res.data
+}
+
+/** 删除离职交接单 DELETE /main/handovers/{id} */
+export async function deleteHandover(id) {
+  const res = await request({ url: `${serverPath()}/main/handovers/${id}`, method: 'DELETE' })
+  return res.data
+}
+
+/** 回退离职交接事项 PUT /main/handovers/{id}/items/{itemId}/rollback */
+export async function rollbackHandoverItem(handoverId, itemId, reason = '') {
+  const res = await request({
+    url: `${serverPath()}/main/handovers/${handoverId}/items/${itemId}/rollback`,
+    method: 'PUT',
+    data: compact({ rollbackReason: reason }),
+  })
+  return res.data
+}
+
+/** 套用离职交接模板 POST /main/handovers/{id}/templates/{templateId}/apply */
+export async function applyHandoverTemplate(handoverId, templateId) {
+  const res = await request({
+    url: `${serverPath()}/main/handovers/${handoverId}/templates/${templateId}/apply`,
+    method: 'POST',
+  })
+  return res.data
+}
+// ============ 演示模式本地实现 ============
+
+function mergeDemoRecords() {
+  const overrides = getResignations()
+  const created = getCreatedResignations()
+  const seen = new Set(created.map((item) => String(item.id)))
+  const base = [...created, ...resignings.filter((item) => !seen.has(String(item.id)))]
+  return base.map((record) => mapDemoResignation(record, overrides[String(record.id)] || {}))
+}
+
+function updateDemoResignation(id, patch) {
+  setResignLocalState(id, patch)
+  updateCreatedResignation(id, patch)
+  return mergeDemoRecords().find((item) => String(item.id) === String(id)) || null
+}
+
+function buildDefaultDemoItems(resignationId) {
+  return DEFAULT_HANDOVER_ITEMS.map((item, index) => ({
+    id: `demo-item-${resignationId}-new-${index}`,
+    itemName: item.name,
+    itemContent: item.detail,
+    itemCategory: item.category,
+    itemStatus: 'PENDING',
+  }))
+}
+
+// 演示模式的「生成交接单」:把当前事项落成本地交接单,供勾选 / 回退
+function materializeDemoHandover(resignationId) {
+  const existing = getHandoverByResignationId(resignationId)
+  if (existing) return existing
+  const record = mergeDemoRecords().find((item) => String(item.id) === String(resignationId))
+  if (!record) return null
+  const items = (record.handover?.items || []).map((item, index) => ({
+    id: `demo-item-${resignationId}-${index}`,
+    itemName: item.name,
+    itemContent: item.detail,
+    itemCategory: item.category || 'WORK',
+    itemStatus: item.done ? 'COMPLETED' : 'PENDING',
+    receiverName: record.handover?.person || '',
+  }))
+  return saveHandover({
+    id: record.handover?.id || `demo-handover-${resignationId}`,
+    resignationId,
+    resignationNo: record.resignationNo || resignationId,
+    handoverDate: record.expectedLeaveDate || '',
+    remark: record.handover?.remark || '',
+    items: items.length ? items : buildDefaultDemoItems(resignationId),
+  })
+}
+
+function syncDemoHandoverStatus(resignationId, handover) {
+  if (!handover) return
+  const items = handover.items || []
+  const done = items.filter((item) => isDoneItemStatus(item.itemStatus)).length
+  const finished = items.length > 0 && done === items.length
+  updateDemoResignation(resignationId, {
+    handoverStatus: finished ? 'COMPLETED' : done > 0 ? 'IN_PROGRESS' : 'NOT_STARTED',
+  })
+}
+
+// ============ 对外统一入口(页面只调用这些) ============
+
+/**
+ * 离职单列表。
+ * 服务器模式:POST /main/resignations/page 分页查询(分页体 {list, count});
+ *            集合查询不可用时,按本地记住的主键逐个拉详情兜底(与 PC 端策略一致)。
+ * 演示模式:mock 数据 + 本地新建 / 覆盖。
+ */
+export async function loadResignationList(params = {}) {
+  const { keyword = '', status = 'all', pageNum = 1, size = 50 } = params
+
+  if (!isServerMode()) {
+    const list = mergeDemoRecords()
+      .filter((row) => {
+        if (!matchDisplayStatus(row, status)) return false
+        if (!keyword) return true
+        return `${row.name}${row.employeeNo}${row.department}${row.position}`.includes(keyword)
+      })
+      .sort((a, b) =>
+        String(b.applyDate || b.createTime || '').localeCompare(
+          String(a.applyDate || a.createTime || ''),
+        ),
+      )
+    return { list, count: list.length, source: 'demo' }
+  }
+
+  let rows = []
+  try {
+    const res = await request({
+      url: `${serverPath()}/main/resignations/page`,
+      method: 'POST',
+      data: compact({ pageNum, size, resignationNo: keyword || undefined }),
+    })
+    rows = asList(res.data)
+  } catch (error) {
+    rows = []
+  }
+
+  if (!rows.length) {
+    const details = await Promise.all(
+      getResignIds().map(async (id) => {
+        try {
+          return await getResignationById(id)
+        } catch (error) {
+          return null
+        }
+      }),
+    )
+    rows = details.filter(Boolean)
+  }
+
+  const list = []
+  for (const vo of rows) {
+    const brief = await fetchEmployeeBrief(vo.userId)
+    const row = adaptResignation(vo, extrasOf(brief))
+    if (!row) continue
+    if (row.id != null) rememberResignId(row.id)
+    if (!matchDisplayStatus(row, status)) continue
+    if (keyword && !`${row.name}${row.employeeNo}${row.resignationNo}${row.department}`.includes(keyword)) {
+      continue
+    }
+    list.push(row)
+  }
+
+  return { list, count: list.length, source: 'api' }
+}
+
+/** 离职单详情 */
+export async function loadResignationDetail(id) {
+  if (!id) return null
+
+  if (!isServerMode()) {
+    return mergeDemoRecords().find((item) => String(item.id) === String(id)) || null
+  }
+
+  const vo = await getResignationById(id)
+  if (!vo) return null
+  rememberResignId(id)
+  const brief = await fetchEmployeeBrief(vo.userId)
+  return adaptResignation(vo, extrasOf(brief))
+}
+
+/** 离职交接单列表 */
+export async function loadHandoverList(params = {}) {
+  const { keyword = '', status = 'all' } = params
+
+  if (!isServerMode()) {
+    const local = Object.values(getHandovers()).map(adaptHandover).filter(Boolean)
+    const localIds = new Set(local.map((item) => String(item.resignationId)))
+    const fromResignations = mergeDemoRecords()
+      .filter((row) => row.handover && !localIds.has(String(row.id)))
+      .map((row) => row.handover)
+    const list = [...local, ...fromResignations]
+      .filter((item) => (status === 'all' ? true : item.status === status))
+      .filter((item) => (keyword ? String(item.resignationNo || '').includes(keyword) : true))
+    return { list, count: list.length, source: 'demo' }
+  }
+
+  const page = await getHandoverPage({ keyword })
+  const list = page.list.map(adaptHandover).filter(Boolean)
+  return { list, count: page.count || list.length, source: 'api' }
+}
+
+/** 交接单详情(含事项) */
+export async function loadHandoverDetail(id) {
+  if (!id) return null
+  if (!isServerMode()) {
+    const stored = getHandoverById(id)
+    return stored ? adaptHandover(stored) : null
+  }
+  const vo = await getHandoverDetail(id)
+  return adaptHandover(vo)
+}
+
+// —— 流程动作:服务器模式走接口,演示模式改本地态 ——
+
+export async function actSubmitResignation(row) {
+  if (isServerMode()) return submitResignation(row.id)
+  return updateDemoResignation(row.id, { status: 'pending', stage: 'manager', approvalStatus: 'PENDING' })
+}
+
+export async function actWithdrawResignation(row, reason = '') {
+  if (isServerMode()) return withdrawResignation(row.id, reason)
+  return updateDemoResignation(row.id, {
+    status: 'cancelled',
+    stage: 'submit',
+    approvalStatus: 'WITHDRAWN',
+    hrComment: reason,
+  })
+}
+
+export async function actCancelResignation(row, reason = '') {
+  if (isServerMode()) return cancelResignation(row.id, reason)
+  return updateDemoResignation(row.id, {
+    status: 'cancelled',
+    stage: 'submit',
+    approvalStatus: 'CANCELLED',
+    hrComment: reason,
+  })
+}
+
+export async function actGenerateHandover(row) {
+  if (isServerMode()) return generateResignationHandover(row.id)
+  const handover = materializeDemoHandover(row.id)
+  updateDemoResignation(row.id, { status: 'processing', stage: 'handover', handoverStatus: 'PENDING' })
+  return handover
+}
+
+export async function actCompleteHandoverItem(resignationId, itemId, remark = '') {
+  if (isServerMode()) return completeResignationHandoverItem(resignationId, itemId, remark)
+  const handover = materializeDemoHandover(resignationId)
+  if (!handover) return null
+  const updated = updateHandoverItemState(handover.id, itemId, {
+    itemStatus: 'COMPLETED',
+    completionRemark: remark,
+    completeTime: formatNow(),
+  })
+  syncDemoHandoverStatus(resignationId, updated)
+  return updated
+}
+
+export async function actRollbackHandoverItem(resignationId, itemId, reason = '') {
+  if (isServerMode()) {
+    const stored = getHandoverByResignationId(resignationId)
+    if (!stored?.id) throw new Error('缺少交接单主键,无法回退')
+    return rollbackHandoverItem(stored.id, itemId, reason)
+  }
+  const handover = materializeDemoHandover(resignationId)
+  if (!handover) return null
+  const updated = updateHandoverItemState(handover.id, itemId, {
+    itemStatus: 'PENDING',
+    completionRemark: reason,
+  })
+  syncDemoHandoverStatus(resignationId, updated)
+  return updated
+}
+
+export async function actArchiveResignation(row) {
+  if (isServerMode()) return archiveResignation(row.id)
+  return updateDemoResignation(row.id, {
+    status: 'done',
+    stage: 'certificate',
+    archiveStatus: 'ARCHIVED',
+    effectiveStatus: 'EFFECTIVE',
+    lastWorkDate: row.lastWorkDate || row.expectedLeaveDate,
+  })
+}
+
+export async function actForceCompleteResignation(row, reason) {
+  if (isServerMode()) return forceCompleteResignation(row.id, reason)
+  return updateDemoResignation(row.id, {
+    status: 'done',
+    stage: 'certificate',
+    archiveStatus: 'ARCHIVED',
+    effectiveStatus: 'EFFECTIVE',
+    hrComment: reason,
+  })
+}
+
+export async function actDeleteHandover(handover) {
+  if (!handover?.id) return null
+  if (isServerMode()) return deleteHandover(handover.id)
+  removeHandoverById(handover.id)
+  updateDemoResignation(handover.resignationId, { handoverStatus: '' })
+  return true
+}
+// ============ 员工端申请表单 ============
+
 /**
  * 把表单字段映射成 /hr/ess/mobile/applications/resignation 请求参数。
- * 后端 M02 创建单据后进入既有 Flowable 审批 + 离职交接流程,部门和申请人由服务端按当前任职确定,
- * 前端不需要传 userId / deptId。
- *
- * @param {object} form               form reactive(resignType / resignReason / expectedLeaveDate / reason / handover / handoverNote / compensation / needCertificate)
- * @returns {{
- *   plannedLastWorkDate: string,
- *   reason: string,
- *   resignationType: string,
- *   userComment?: string,
- * }}
+ * 后端创建 M02 离职单据后进入既有 Flowable 审批 + 离职交接流程,
+ * 部门和申请人由服务端按当前任职确定,前端不需要传 userId / deptId。
  */
 export function buildResignationPayload(form) {
   if (!form) return null
@@ -78,11 +961,7 @@ export function buildResignationPayload(form) {
 /**
  * 提交我的离职申请
  * POST /hr/ess/mobile/applications/resignation
- *
  * 响应 { code: 0, data: long, message },data 是新建单据的主键。
- *
- * @param {object} payload 见 buildResignationPayload 返回结构
- * @returns {Promise<number>} 服务端返回的单据主键(演示模式返回时间戳假 id)
  */
 export async function submitResignationApplication(payload) {
   if (!payload) throw new Error('申请参数为空')
@@ -94,7 +973,66 @@ export async function submitResignationApplication(payload) {
 
   const path = `${serverPath()}/hr/ess/mobile/applications/resignation`
   const res = await request({ url: path, method: 'POST', data: payload })
-  // 接口约定的 data 是 long;防止后端返回 string(PHP/Java 都可能出现),统一转 number
   const id = Number(res.data)
   return Number.isFinite(id) ? id : 0
 }
+
+/**
+ * 提交成功后登记离职单,让「离职管理」列表与详情能串起来:
+ *   - 服务器模式:记住服务端返回的主键,集合接口不可用时按主键拉详情
+ *   - 演示模式:本地新建一条离职单,走演示态流程
+ */
+export function registerSubmittedResignation({ serverId, form } = {}) {
+  if (serverId) {
+    rememberResignId(serverId)
+    return { id: serverId, source: 'server' }
+  }
+  const user = getCurrentUser() || {}
+  const record = {
+    id: `RS${Date.now()}`,
+    name: user.name || '',
+    avatar: user.initials || String(user.name || '').charAt(0),
+    department: user.department || '',
+    position: user.position || '',
+    entryDate: dateOnly(user.entryDate),
+    status: 'pending',
+    expectedLeaveDate: dateOnly(form?.expectedLeaveDate),
+    lastWorkDate: null,
+    submitDate: dateOnly(formatNow()),
+    type: form?.resignType || '主动离职',
+    reason: form?.resignReason || '',
+    handover: {
+      person: form?.handover || '待指派',
+      dept: user.department || '',
+      progress: '0%',
+      deadline: dateOnly(form?.expectedLeaveDate),
+      items: DEFAULT_HANDOVER_ITEMS.map((item) => item.name),
+      note: form?.handoverNote || '',
+    },
+    compensation: { scheme: form?.compensation || '待核算', amount: '—', status: '待核算', note: '' },
+    assetReturn: { status: '未开始', items: [] },
+    contractTermination: {
+      method: '待办理',
+      deadline: dateOnly(form?.expectedLeaveDate),
+      status: '待解除',
+    },
+    socialSecurity: { stopMonth: '', city: '', status: '待办理' },
+    financeSettle: {
+      lastSalary: '—',
+      annualLeave: '—',
+      compensation: '—',
+      payoutDate: '',
+      status: '待核算',
+    },
+    accountOff: { status: '待注销', items: [] },
+    certificate: {
+      status: form?.needCertificate === false ? '无需开具' : '待开具',
+      needCopy: false,
+    },
+    stage: 'manager',
+    approvalStatus: 'PENDING',
+    createdByApp: true,
+  }
+  addCreatedResignation(record)
+  return { id: record.id, source: 'demo' }
+}

+ 14 - 0
src/data/mock.js

@@ -37,6 +37,20 @@ export const approvals = [
   { id: 101, type: '请假申请', applicant: '李明', dept: '生产一部', avatar: '李', summary: '年假 · 8月7日 08:30 至 8月8日 17:30', time: '今天 09:42', status: '待审批', color: '#5b8ff9', days: '2天', reason: '陪同家人前往医院复查,工作已与王强完成交接。' },
   { id: 102, type: '补卡申请', applicant: '周倩', dept: '财务部', avatar: '周', summary: '8月4日 下班卡 · 忘记打卡', time: '昨天 18:20', status: '待审批', color: '#78d3f8', days: '', reason: '下班时临时接待审计人员,离开公司时忘记打卡。' },
   { id: 103, type: '加班申请', applicant: '王建国', dept: '设备管理部', avatar: '王', summary: '8月6日 18:00-21:00 · 设备检修', time: '昨天 16:08', status: '待审批', color: '#61cdbb', days: '3小时', reason: '完成3号产线月度预防性维护。' },
+  {
+    id: 104,
+    type: '离职申请',
+    applicant: '黄亮',
+    dept: '研发中心',
+    avatar: '黄',
+    summary: '主动离职 · 预计离职日 2026-09-30',
+    time: '今天 08:36',
+    status: '待审批',
+    color: '#ee6666',
+    days: '交接 4 项',
+    reason: '职业转型,已完成 HR H5 前端代码仓库与 BPM 表单设计文档整理。',
+    businessId: 'ZY20260825',
+  },
 ]
 
 export const contacts = [

+ 2 - 1
src/pages.json

@@ -20,7 +20,8 @@
     { "path": "pages/manage/index", "style": { "navigationBarTitleText": "人事管理" } },
     { "path": "pages/manage/onboard-detail", "style": { "navigationBarTitleText": "入职详情" } },
     { "path": "pages/manage/onboard-create", "style": { "navigationBarTitleText": "新建入职流程" } },
-    { "path": "pages/manage/resign-detail", "style": { "navigationBarTitleText": "离职详情" } }
+    { "path": "pages/manage/resign-detail", "style": { "navigationBarTitleText": "离职详情" } },
+    { "path": "pages/manage/resign-handover", "style": { "navigationBarTitleText": "离职交接单" } }
   ],
   "globalStyle": {
     "navigationBarTextStyle": "white",

+ 9 - 2
src/pages/apply/form.vue

@@ -157,6 +157,7 @@ import {
 } from "@/api/attendance";
 import {
   buildResignationPayload,
+  registerSubmittedResignation,
   submitResignationApplication,
 } from "@/api/resign";
 import { uploadAttachment } from "@/api/file";
@@ -469,12 +470,18 @@ async function submit() {
   //   → 部门和申请人由服务端当前任职确定,前端只送表单字段
   //   → 响应 data 是后端新建单据主键,作为本地申请 id 保持两端一致
   let serverApplicationId = null;
-  if (type.value === "resign" && getServerConfig().mode === "server") {
+  if (type.value === "resign") {
     try {
       uni.showLoading({ title: "提交中..." });
       const payload = buildResignationPayload(form);
       if (!payload) throw new Error("申请参数为空");
-      serverApplicationId = await submitResignationApplication(payload);
+      serverApplicationId =
+        getServerConfig().mode === "server"
+          ? await submitResignationApplication(payload)
+          : null;
+      // 登记离职单:服务器模式记住服务端主键,演示模式落一条本地离职单,
+      // 使「人事管理 → 离职管理」能直接看到这条申请并继续交接 / 归档
+      registerSubmittedResignation({ serverId: serverApplicationId, form });
       uni.hideLoading();
     } catch (e) {
       uni.hideLoading();

+ 79 - 28
src/pages/manage/index.vue

@@ -28,6 +28,17 @@
           ><text class="banner-sub">录入候选人信息,自动生成员工编号并跟踪入职进度</text></view
         ><text class="banner-arrow">›</text>
       </view>
+      <view
+        v-if="type === 'resign'"
+        class="create-banner resign-banner press"
+        @click="onOpenHandover"
+      >
+        <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"
@@ -111,7 +122,7 @@
 import { computed, onBeforeUnmount, onMounted, ref } from "vue";
 import { onLoad, onShow } from "@dcloudio/uni-app";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
-import { onboardings, resignings, resignStatus } from "@/data/mock";
+import { onboardings } from "@/data/mock";
 import { mergeOnboardings } from "@/utils/storage";
 import { getServerConfig } from "@/utils/auth";
 import {
@@ -119,11 +130,17 @@ import {
   getOnboardingRecords,
   mapOnboardingForList,
 } from "@/api/onboarding";
+import {
+  RESIGN_STATUS_FILTERS,
+  loadResignationList,
+  matchDisplayStatus,
+} from "@/api/resign";
 import { go } from "@/utils/router";
 const type = ref("recruit");
 useAuthGuard();
 const keyword = ref("");
 const statusFilter = ref("全部状态");
+const resignStatusValue = ref("all");
 
 // 入职记录原始数据(mock 或 API VO 经 adaptOnboardingRecord 归一后的形状)。
 // 服务器模式按 getOnboardingRecords 拉取;演示模式合并本地 mock + storage。
@@ -158,29 +175,41 @@ const onboardItems = computed(() =>
 const pendingOnboardCount = computed(
   () => onboardRecords.value.filter((o) => o.status !== "done").length,
 );
+// 离职单:服务器模式走 M02 接口,演示模式走 mock + 本地态(由 api/resign 统一处理)
+const resignRecords = ref([]);
+async function loadResignList() {
+  try {
+    const page = await loadResignationList();
+    resignRecords.value = page.list || [];
+  } catch (e) {
+    console.warn("[manage/resign] 离职列表加载失败:", e?.message || e);
+    uni.showToast({ title: "离职列表加载失败", icon: "none" });
+    resignRecords.value = [];
+  }
+}
+
 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 || "—",
-      };
-    }),
+  resignRecords.value
+    .filter((r) => matchDisplayStatus(r, resignStatusValue.value))
+    .filter((r) =>
+      `${r.name}${r.employeeNo}${r.department}${r.position}`.includes(keyword.value),
+    )
+    .map((r) => ({
+      id: r.id,
+      avatar: r.avatar || String(r.name || "").charAt(0),
+      name: r.name || "—",
+      sub: `${r.department || "—"} · ${r.position || "—"}`,
+      status: r.statusLabel,
+      statusClass: r.statusClass,
+      subType: "resign",
+      label1: r.lastWorkDate ? "最后工作日" : "预计离职",
+      value1: r.lastWorkDate || r.expectedLeaveDate || "—",
+      label2: "交接进度",
+      value2: r.handover ? `${r.handover.progress}%` : "待生成交接单",
+    })),
 );
 const pendingResignCount = computed(
-  () => resignings.filter((r) => r.status !== "done" && r.status !== "cancelled").length,
+  () => resignRecords.value.filter((r) => !["completed", "withdrawn", "cancelled"].includes(r.displayStatus)).length,
 );
 const configs = computed(() => ({
   recruit: {
@@ -326,13 +355,15 @@ const configs = computed(() => ({
   },
 }));
 const config = computed(() => configs.value[type.value] || configs.value.recruit);
-const filteredItems = computed(() =>
-  config.value.items.filter(
+const filteredItems = computed(() => {
+  // 离职列表已在 resignItems 里按接口状态 + 关键词过滤,这里不再二次过滤
+  if (type.value === "resign") return config.value.items;
+  return config.value.items.filter(
     (i) =>
       `${i.name}${i.sub}${i.value1}${i.value2}`.includes(keyword.value) &&
       (statusFilter.value === "全部状态" || i.status === statusFilter.value),
-  ),
-);
+  );
+});
 const kpis = [
   { label: "在职员工", value: "326", trend: "+1.2%" },
   { label: "本月入职", value: "12", trend: "+3人" },
@@ -354,6 +385,7 @@ function syncType(o = {}) {
   type.value = o.type || "recruit";
   keyword.value = "";
   statusFilter.value = "全部状态";
+  resignStatusValue.value = "all";
   uni.setNavigationBarTitle({
     title: type.value === "dashboard" ? "数据看板" : config.value.title,
   });
@@ -363,6 +395,7 @@ onShow(() => {
   const pages = getCurrentPages();
   syncType(pages[pages.length - 1]?.options || {});
   loadOnboardList();
+  loadResignList();
 });
 function syncHash() {
   if (typeof location === "undefined") return;
@@ -396,11 +429,25 @@ function showDetail(item) {
 function onCreateOnboarding() {
   go("/pages/manage/onboard-create");
 }
+function onOpenHandover() {
+  go("/pages/manage/resign-handover");
+}
 function chooseStatus() {
-  const items = ["全部状态", "待入职", "入职中", "已入职", "已逾期"];
+  const options =
+    type.value === "resign"
+      ? RESIGN_STATUS_FILTERS
+      : ["全部状态", "待入职", "入职中", "已入职", "已逾期"].map((label) => ({
+          value: label,
+          label,
+        }));
   uni.showActionSheet({
-    itemList: items,
-    success: (r) => (statusFilter.value = items[r.tapIndex]),
+    itemList: options.map((o) => o.label),
+    success: (r) => {
+      const picked = options[r.tapIndex];
+      if (!picked) return;
+      statusFilter.value = picked.label;
+      if (type.value === "resign") resignStatusValue.value = picked.value;
+    },
   });
 }
 function showWarning(w) {
@@ -494,6 +541,10 @@ function showWarning(w) {
   gap: 18rpx;
   box-shadow: 0 6rpx 20rpx rgba(22, 119, 255, 0.18);
 }
+.resign-banner {
+  background: linear-gradient(135deg, #ee6666 0%, #f08a8a 100%);
+  box-shadow: 0 6rpx 20rpx rgba(238, 102, 102, 0.18);
+}
 .banner-icon {
   width: 72rpx;
   height: 72rpx;

+ 368 - 284
src/pages/manage/resign-detail.vue

@@ -3,376 +3,444 @@
     <!-- 顶部基础信息 -->
     <view class="detail-head" :style="{ background: headerGradient }">
       <view class="head-base">
-        <view class="avatar head-avatar">{{ staff.avatar }}</view>
+        <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>
+          <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>
+        <text class="status-tag head-status" :class="staff?.statusClass">{{ staff?.statusLabel }}</text>
       </view>
       <view class="head-meta">
-        <view><text>员工编号</text><text>{{ staff.id }}</text></view>
-        <view><text>入职日期</text><text>{{ staff.entryDate || '—' }}</text></view>
-        <view><text>预计离职</text><text>{{ staff.expectedLeaveDate || '—' }}</text></view>
-        <view><text>最后工作日</text><text>{{ staff.lastWorkDate || '—' }}</text></view>
-        <view><text>交接人</text><text>{{ staff.handover?.person || '—' }}</text></view>
-        <view><text>审批人</text><text>系统自动匹配</text></view>
+        <view><text>员工编号</text><text>{{ staff?.employeeNo || '—' }}</text></view>
+        <view><text>入职日期</text><text>{{ staff?.entryDate || '—' }}</text></view>
+        <view><text>预计离职</text><text>{{ staff?.expectedLeaveDate || '—' }}</text></view>
+        <view><text>最后工作日</text><text>{{ staff?.lastWorkDate || '—' }}</text></view>
+        <view><text>交接人</text><text>{{ staff?.handover?.person || '—' }}</text></view>
+        <view><text>审批状态</text><text>{{ approvalLabel }}</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 v-if="loading" class="loading card section">加载中…</view>
+    <view v-else-if="!staff" class="loading card section">未找到该离职单</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>
+    <template v-else>
+      <!-- 进度 -->
+      <view class="progress-card card section">
+        <view class="progress-head">
+          <text class="progress-title">离职进度</text>
+          <text class="progress-value">{{ staff.progress }}%</text>
         </view>
-        <view class="timeline-main">
-          <text class="timeline-label">{{ s.label }}</text>
-          <text class="timeline-desc">{{ s.desc }}</text>
+        <view class="progress-bar">
+          <view class="progress-fill" :style="{ width: staff.progress + '%' }"></view>
         </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>
+        <text class="progress-tip">{{ staff.progressTip }}</text>
       </view>
-    </view>
 
-    <!-- 工作交接清单 -->
-    <view class="section-title">
-      <text>工作交接</text>
-      <text class="section-more">{{ handoverDone }} / {{ handoverItems.length }} 已完成</text>
-    </view>
-    <view class="materials card section">
-      <view v-for="item in handoverItems" :key="item" class="material-row press" @click="onToggleHandover(item)">
-        <view class="material-icon" :class="effectiveHandoverDone(item) ? 'is-on' : 'is-off'">
-          {{ effectiveHandoverDone(item) ? '✓' : '·' }}
-        </view>
-        <view class="material-main">
-          <text class="material-name">{{ item }}</text>
-          <text class="muted material-desc">点击勾选表示交接完成</text>
-        </view>
-        <text
-          class="status-tag"
-          :class="effectiveHandoverDone(item) ? 'status-approved' : 'status-pending'"
+      <!-- 离职流程时间线 -->
+      <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' }"
         >
-          {{ effectiveHandoverDone(item) ? '已交接' : '待交接' }}
-        </text>
-      </view>
-      <view v-if="!handoverItems.length" class="muted empty-tip">尚未配置交接事项</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.contractTermination?.status)">
-            {{ staff.contractTermination?.status || '—' }}
-          </text>
+          <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 class="info-row"><text>解除方式</text><text>{{ staff.contractTermination?.method || '—' }}</text></view>
-        <view class="info-row"><text>截止日期</text><text>{{ staff.contractTermination?.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?.stopMonth || '—' }}</text></view>
-        <view class="info-row"><text>参保城市</text><text>{{ staff.socialSecurity?.city || '—' }}</text></view>
-      </view>
-
-      <view class="info-card card">
-        <view class="info-head">
-          <text class="info-title">工作交接</text>
-          <text class="status-tag" :class="moduleClass('已交接')">
-            {{ staff.handover?.progress || '—' }}
-          </text>
-        </view>
-        <view class="info-row"><text>交接人</text><text>{{ staff.handover?.person || '—' }}</text></view>
-        <view class="info-row"><text>截止日期</text><text>{{ staff.handover?.deadline || '—' }}</text></view>
+      <!-- 工作交接 -->
+      <view class="section-title">
+        <text>工作交接</text>
+        <text class="section-more">{{ handoverDone }} / {{ handoverItems.length }} 已完成</text>
       </view>
-
-      <view class="info-card card">
-        <view class="info-head">
-          <text class="info-title">资产归还</text>
-          <text class="status-tag" :class="moduleClass(staff.assetReturn?.status)">
-            {{ staff.assetReturn?.status || '—' }}
+      <view class="materials card section">
+        <view
+          v-for="item in handoverItems"
+          :key="item.id || item.name"
+          class="material-row press"
+          @click="onToggleHandover(item)"
+        >
+          <view class="material-icon" :class="item.done ? 'is-on' : 'is-off'">
+            {{ item.done ? '✓' : '·' }}
+          </view>
+          <view class="material-main">
+            <text class="material-name">{{ item.name }}</text>
+            <text class="muted material-desc">{{ item.detail || item.categoryLabel || '点击切换交接状态' }}</text>
+          </view>
+          <text class="status-tag" :class="item.done ? 'status-approved' : 'status-pending'">
+            {{ item.done ? '已交接' : '待交接' }}
           </text>
         </view>
-        <view class="info-row info-items">
-          <text
-            v-for="a in assetList"
-            :key="a.name"
-            class="info-chip asset-chip press"
-            :class="effectiveAssetReturned(a.name) ? 'is-returned' : 'is-pending'"
-            @click="onToggleAsset(a.name)"
-          >
-            {{ effectiveAssetReturned(a.name) ? '✓' : '○' }} {{ a.name }}
-          </text>
+        <view v-if="!handoverItems.length" class="muted empty-tip">
+          {{ canGenerateHandover(staff) ? '审批已通过,可生成离职交接单' : '尚未生成交接事项' }}
         </view>
       </view>
 
-      <view class="info-card card info-card-wide">
+      <!-- 申请信息 -->
+      <view class="section-title"><text>申请信息</text></view>
+      <view class="info-card card section">
         <view class="info-head">
-          <text class="info-title">财务结算</text>
-          <text class="status-tag" :class="moduleClass(staff.financeSettle?.status)">
-            {{ staff.financeSettle?.status || '—' }}
-          </text>
+          <text class="info-title">离职单信息</text>
+          <text class="status-tag" :class="staff.statusClass">{{ staff.statusLabel }}</text>
         </view>
-        <view class="info-row"><text>最后月薪</text><text>{{ staff.financeSettle?.lastSalary || '—' }}</text></view>
-        <view class="info-row"><text>年假折算</text><text>{{ staff.financeSettle?.annualLeave || '—' }}</text></view>
-        <view class="info-row"><text>补偿金</text><text>{{ staff.financeSettle?.compensation || '—' }}</text></view>
-        <view class="info-row"><text>发放日期</text><text>{{ staff.financeSettle?.payoutDate || '—' }}</text></view>
-        <view class="info-row"><text>补偿方案</text><text>{{ staff.compensation?.scheme || '—' }}</text></view>
-        <view v-if="staff.compensation?.note" class="info-row"><text>说明</text><text>{{ staff.compensation.note }}</text></view>
+        <view class="info-row"><text>离职单号</text><text>{{ staff.resignationNo || staff.id || '—' }}</text></view>
+        <view class="info-row"><text>离职类型</text><text>{{ staff.type || '—' }}</text></view>
+        <view class="info-row"><text>申请日期</text><text>{{ staff.applyDate || '—' }}</text></view>
+        <view class="info-row"><text>生效日期</text><text>{{ staff.effectiveDate || '—' }}</text></view>
+        <view class="info-row"><text>离职原因</text><text>{{ staff.reason || '—' }}</text></view>
+        <view v-if="staff.userComment" class="info-row"><text>员工说明</text><text>{{ staff.userComment }}</text></view>
+        <view v-if="staff.hrComment" class="info-row"><text>HR 意见</text><text>{{ staff.hrComment }}</text></view>
       </view>
 
-      <view class="info-card card info-card-wide">
-        <view class="info-head">
-          <text class="info-title">账号注销 / 离职证明</text>
-          <text class="status-tag" :class="moduleClass(staff.accountOff?.status)">
-            {{ staff.accountOff?.status || '—' }}
-          </text>
-        </view>
-        <view class="info-row info-items">
-          <text
-            v-for="i in (staff.accountOff?.items || [])"
-            :key="i"
-            class="info-chip"
-          >{{ i }}</text>
-          <text v-if="!staff.accountOff?.items?.length" class="muted">尚未注销任何账号</text>
+      <!-- 演示模式下的其它模块(服务端未提供时隐藏) -->
+      <template v-if="staff.contractTermination">
+        <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.contractTermination?.status)">
+                {{ staff.contractTermination?.status || '—' }}
+              </text>
+            </view>
+            <view class="info-row"><text>解除方式</text><text>{{ staff.contractTermination?.method || '—' }}</text></view>
+            <view class="info-row"><text>截止日期</text><text>{{ staff.contractTermination?.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?.stopMonth || '—' }}</text></view>
+            <view class="info-row"><text>参保城市</text><text>{{ staff.socialSecurity?.city || '—' }}</text></view>
+          </view>
+
+          <view class="info-card card">
+            <view class="info-head">
+              <text class="info-title">资产归还</text>
+              <text class="status-tag" :class="moduleClass(staff.assetReturn?.status)">
+                {{ staff.assetReturn?.status || '—' }}
+              </text>
+            </view>
+            <view class="info-row info-items">
+              <text
+                v-for="a in (staff.assetReturn?.items || [])"
+                :key="a.name"
+                class="info-chip asset-chip press"
+                :class="effectiveAssetReturned(a.name) ? 'is-returned' : 'is-pending'"
+                @click="onToggleAsset(a.name)"
+              >{{ a.name }}</text>
+              <text v-if="!staff.assetReturn?.items?.length" class="muted">暂无资产记录</text>
+            </view>
+          </view>
+
+          <view class="info-card card">
+            <view class="info-head">
+              <text class="info-title">财务结算</text>
+              <text class="status-tag" :class="moduleClass(staff.financeSettle?.status)">
+                {{ staff.financeSettle?.status || '—' }}
+              </text>
+            </view>
+            <view class="info-row"><text>最后月薪</text><text>{{ staff.financeSettle?.lastSalary || '—' }}</text></view>
+            <view class="info-row"><text>年假折算</text><text>{{ staff.financeSettle?.annualLeave || '—' }}</text></view>
+            <view class="info-row"><text>补偿金</text><text>{{ staff.financeSettle?.compensation || '—' }}</text></view>
+            <view class="info-row"><text>补偿方案</text><text>{{ staff.compensation?.scheme || '—' }}</text></view>
+          </view>
+
+          <view class="info-card card info-card-wide">
+            <view class="info-head">
+              <text class="info-title">账号注销 / 离职证明</text>
+              <text class="status-tag" :class="moduleClass(staff.accountOff?.status)">
+                {{ staff.accountOff?.status || '—' }}
+              </text>
+            </view>
+            <view class="info-row info-items">
+              <text v-for="i in (staff.accountOff?.items || [])" :key="i" class="info-chip">{{ i }}</text>
+              <text v-if="!staff.accountOff?.items?.length" class="muted">尚未注销任何账号</text>
+            </view>
+            <view class="info-row"><text>离职证明</text><text>{{ staff.certificate?.status || '—' }}</text></view>
+          </view>
         </view>
-        <view class="info-row"><text>离职证明</text><text :class="staff.certificate?.status === '已开具' ? 'value' : 'placeholder'">{{ staff.certificate?.status || '—' }}</text></view>
-        <view v-if="staff.certificate?.needCopy" class="info-row"><text>副本需求</text><text>需要纸质副本</text></view>
-      </view>
-    </view>
+      </template>
 
-    <view class="footer-tip muted">
-      <text>演示版:操作按钮仅作展示,真实数据接入后可对接审批 / 资产交接等接口。</text>
-    </view>
+      <view class="footer-tip muted">
+        <text>{{ footerTip }}</text>
+      </view>
 
-    <view class="footer-actions">
-      <template v-if="['pending', 'processing'].includes(staff.status)">
-        <view class="ghost-btn" @click="onReject">驳回</view>
-        <view class="primary-btn" @click="onRemind">催办</view>
-      </template>
-      <template v-else-if="staff.status === 'overdue'">
-        <view class="ghost-btn" @click="onRemind">催办</view>
-        <view class="primary-btn" @click="onForceComplete">强制归档</view>
-      </template>
-      <template v-else>
-        <view class="primary-btn full" @click="onConfirm">确认离职</view>
-      </template>
-    </view>
+      <view v-if="actions.length" class="footer-actions">
+        <view
+          v-for="action in actions"
+          :key="action.key"
+          :class="action.primary ? 'primary-btn' : 'ghost-btn'"
+          @click="runAction(action.key)"
+        >{{ action.label }}</view>
+      </view>
+    </template>
   </view>
-</template>
-
-<script setup>
-import { computed, ref, watch } from "vue";
-import { onLoad } from "@dcloudio/uni-app";
+</template><script setup>
+import { computed, ref } from "vue";
+import { onLoad, onShow } from "@dcloudio/uni-app";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
-import { resignings, resignStatus, resignStages } from "@/data/mock";
-import { go } from "@/utils/router";
 import {
-  getResignDetail,
-  setResignStatus,
-  addResignAssetReturn,
-  setResignLocalState,
-} from "@/utils/storage";
+  actArchiveResignation,
+  actCancelResignation,
+  actCompleteHandoverItem,
+  actForceCompleteResignation,
+  actGenerateHandover,
+  actRollbackHandoverItem,
+  actWithdrawResignation,
+  canArchiveResignation,
+  canCancelResignation,
+  canGenerateHandover,
+  canWithdrawResignation,
+  loadResignationDetail,
+} from "@/api/resign";
+import { addResignAssetReturn, getResignDetail } from "@/utils/storage";
+import { back } from "@/utils/router";
 
 useAuthGuard();
 
-const stages = resignStages;
-const statusMap = resignStatus;
 const id = ref("");
-
-const staff = computed(() => {
-  return resignings.find((r) => r.id === id.value) || resignings[0];
-});
-
-// 本地状态:来自 storage
+const staff = ref(null);
+const loading = ref(false);
 const localState = ref({});
 
-function refreshLocal(empId) {
-  const detail = getResignDetail(empId);
-  localState.value = detail || {};
-}
-
-watch(
-  () => staff.value.id,
-  (next) => refreshLocal(next),
-  { immediate: true },
+const stages = computed(() => staff.value?.stages || []);
+const handoverItems = computed(() => staff.value?.handover?.items || []);
+const handoverDone = computed(() => handoverItems.value.filter((x) => x.done).length);
+const approvalLabel = computed(
+  () => staff.value?.approvalStatusLabel || staff.value?.statusLabel || "—",
 );
 
-const statusInfo = computed(() => statusMap[staff.value.status] || statusMap.pending);
-
 const headerGradient = computed(() => {
   const map = {
+    draft: "linear-gradient(145deg,#9aa1aa,#6f7782)",
     pending: "linear-gradient(145deg,#f6bd16,#f59a23)",
-    processing: "linear-gradient(145deg,#2479e9,#1363ce)",
-    done: "linear-gradient(145deg,#52c41a,#389e0d)",
+    approved: "linear-gradient(145deg,#2479e9,#1363ce)",
+    handover: "linear-gradient(145deg,#2479e9,#1363ce)",
+    completed: "linear-gradient(145deg,#52c41a,#389e0d)",
     overdue: "linear-gradient(145deg,#ee4d4d,#cf1322)",
+    rejected: "linear-gradient(145deg,#9aa1aa,#6f7782)",
+    withdrawn: "linear-gradient(145deg,#9aa1aa,#6f7782)",
     cancelled: "linear-gradient(145deg,#9aa1aa,#6f7782)",
   };
-  return map[staff.value.status] || map.pending;
-});
-
-// 进度计算
-const summary = computed(() => {
-  const total = stages.length;
-  const currentIdx = stages.findIndex((s) => s.key === staff.value.stage);
-  const done = currentIdx >= 0 ? currentIdx : 0;
-  const rate = Math.round((done / total) * 100);
-  let tip = "";
-  if (staff.value.status === "overdue") {
-    tip = "已超过最后工作日,请尽快推进交接与结算。";
-  } else if (staff.value.status === "done") {
-    tip = "离职流程已全部完成。";
-  } else if (staff.value.status === "cancelled") {
-    tip = "本次离职申请已撤回。";
-  } else {
-    tip = `当前进行:${stages[currentIdx]?.label || "—"}`;
-  }
-  return { title: "离职进度", value: rate + "%", rate, tip };
+  return map[staff.value?.displayStatus] || map.pending;
 });
 
+// 流程时间线:已完成 / 进行中 / 待开始
 function stageState(key) {
-  const currentIdx = stages.findIndex((s) => s.key === staff.value.stage);
-  const idx = stages.findIndex((s) => s.key === key);
+  const list = stages.value;
+  const currentIdx = list.findIndex((s) => s.key === staff.value?.stageKey);
+  const idx = list.findIndex((s) => s.key === key);
+  if (currentIdx < 0 || idx < 0) return "pending";
   if (idx < currentIdx) return "done";
-  if (idx === currentIdx) return "current";
+  if (idx === currentIdx) return staff.value?.displayStatus === "completed" ? "done" : "current";
   return "pending";
 }
 
 function moduleClass(status) {
   const s = String(status || "");
   if (
-    s === "已解除" || s === "已停缴" || s === "已交接" || s === "已归还" ||
-    s === "已结算" || s === "已注销" || s === "已开具" || s === "已签订" ||
-    s === "已发放" || s === "已完成"
+    ["已解除", "已停缴", "已交接", "已归还", "已结算", "已注销", "已开具", "已完成"].includes(s)
   ) {
     return "status-approved";
   }
-  if (s === "办理中" || s === "进行中" || s === "交接中" || s === "部分归还" || s === "部分注销") {
+  if (["办理中", "进行中", "交接中", "部分归还", "部分注销"].includes(s)) {
     return "status-processing";
   }
   return "status-pending";
 }
 
-// 工作交接事项(来自 mock)
-const handoverItems = computed(() => staff.value.handover?.items || []);
-
-// 资产列表
-const assetList = computed(() => staff.value.assetReturn?.items || []);
-
-const handoverDone = computed(
-  () => handoverItems.value.filter((x) => effectiveHandoverDone(x)).length,
-);
+// 资产归还等本地演示态
+function refreshLocal(empId) {
+  localState.value = getResignDetail(empId) || {};
+}
 
-// 资产是否已归还(mock + 本地覆盖)
 function effectiveAssetReturned(name) {
   const override = localState.value?.assetReturnMap?.[name];
   if (typeof override === "boolean") return override;
-  const m = staff.value.assetReturn?.items?.find((x) => x.name === name);
-  return m ? !!m.returned : false;
-}
-
-// 工作交接事项是否已完成(mock + 本地)
-function effectiveHandoverDone(name) {
-  const override = localState.value?.handoverDone?.[name];
-  if (typeof override === "boolean") return override;
-  return false;
-}
-
-function onToggleHandover(name) {
-  const next = !effectiveHandoverDone(name);
-  const map = { ...(localState.value?.handoverDone || {}) };
-  map[name] = next;
-  setResignLocalState(staff.value.id, { handoverDone: map });
-  refreshLocal(staff.value.id);
+  const item = staff.value?.assetReturn?.items?.find((x) => x.name === name);
+  return item ? !!item.returned : false;
 }
 
 function onToggleAsset(name) {
-  const next = !effectiveAssetReturned(name);
-  addResignAssetReturn(staff.value.id, name, next);
+  if (!staff.value?.id) return;
+  addResignAssetReturn(staff.value.id, name, !effectiveAssetReturned(name));
   refreshLocal(staff.value.id);
 }
 
-function onRemind() {
-  uni.showToast({ title: "演示版:催办消息未发送", icon: "none" });
+async function loadDetail() {
+  if (!id.value) return;
+  loading.value = true;
+  try {
+    staff.value = await loadResignationDetail(id.value);
+  } catch (e) {
+    staff.value = null;
+    uni.showToast({ title: e.message || "离职单加载失败", icon: "none" });
+  } finally {
+    loading.value = false;
+    refreshLocal(id.value);
+  }
 }
 
-function onReject() {
-  uni.showModal({
-    title: "驳回离职申请",
-    content: `确认驳回「${staff.value.name}」的离职申请?该操作仅修改本机演示数据。`,
-    success: (r) => {
-      if (!r.confirm) return;
-      setResignStatus(staff.value.id, "cancelled");
-      refreshLocal(staff.value.id);
-      uni.showToast({ title: "已驳回(演示版)", icon: "none" });
-    },
+// 可执行动作:主操作 + 一个次要操作(与 PC 端操作可用性判断共用一套规则)
+const actions = computed(() => {
+  const row = staff.value;
+  if (!row) return [];
+  const list = [];
+  if (canArchiveResignation(row)) {
+    list.push({ key: "archive", label: "归档离职单", primary: true });
+  } else if (canGenerateHandover(row)) {
+    list.push({ key: "generateHandover", label: "生成交接单", primary: true });
+  } else if (["approved", "handover", "overdue"].includes(row.displayStatus)) {
+    list.push({ key: "forceComplete", label: "强制办结", primary: true });
+  }
+
+  if (canWithdrawResignation(row)) {
+    list.push({ key: "withdraw", label: "撤回审批", primary: list.length === 0 });
+  } else if (canCancelResignation(row)) {
+    list.push({ key: "cancel", label: "取消离职单", primary: list.length === 0 });
+  }
+  return list.slice(0, 2);
+});
+
+const footerTip = computed(() => {
+  const row = staff.value;
+  if (!row) return "";
+  if (row.source === "demo") {
+    return "演示模式:数据保存在本机,操作不会同步到服务器;接入服务器后同一套流程走 M02 接口。";
+  }
+  return "离职审批由 BPM 流程驱动;交接事项全部完成后可归档离职单。";
+});
+
+function confirmAction(title, content) {
+  return new Promise((resolve) => {
+    uni.showModal({
+      title,
+      content,
+      success: (r) => resolve(!!r.confirm),
+      fail: () => resolve(false),
+    });
   });
 }
 
-function onForceComplete() {
-  uni.showModal({
-    title: "强制归档",
-    content: "演示版:把该离职流程标记为已完成。",
-    success: (r) => {
-      if (!r.confirm) return;
-      setResignStatus(staff.value.id, "done");
-      refreshLocal(staff.value.id);
-      uni.showToast({ title: "已归档", icon: "success" });
-    },
+function promptReason(title, required) {
+  return new Promise((resolve) => {
+    uni.showModal({
+      title,
+      editable: true,
+      placeholderText: required ? "请输入原因(必填)" : "请输入原因(选填)",
+      success: (r) => {
+        if (!r.confirm) return resolve(null);
+        const reason = String(r.content || "").trim();
+        if (required && !reason) {
+          uni.showToast({ title: "请填写原因", icon: "none" });
+          return resolve(null);
+        }
+        resolve(reason);
+      },
+      fail: () => resolve(null),
+    });
   });
 }
 
-function onConfirm() {
-  uni.showModal({
-    title: "确认离职",
-    content: "演示版:将该员工标记为已离职(实际不会修改 mock 数据)。",
-    success: (r) => {
-      if (r.confirm) {
-        setResignStatus(staff.value.id, "done");
-        refreshLocal(staff.value.id);
-        uni.showToast({ title: "操作成功", icon: "success" });
-        setTimeout(() => go("/pages/manage/index?type=resign"), 600);
-      }
-    },
-  });
+async function runAction(key) {
+  const row = staff.value;
+  if (!row) return;
+
+  if (key === "archive") {
+    const ok = await confirmAction("归档离职单", "归档后离职流程结束、劳动关系解除,确认归档?");
+    if (!ok) return;
+    const done = await exec("归档", () => actArchiveResignation(row));
+    if (done) setTimeout(() => back("/pages/manage/index?type=resign"), 700);
+  } else if (key === "generateHandover") {
+    const ok = await confirmAction(
+      "生成离职交接单",
+      "将按工作 / 资产 / 财务 / HR 生成默认交接事项,确认生成?",
+    );
+    if (!ok) return;
+    await exec("生成交接单", () => actGenerateHandover(row));
+  } else if (key === "withdraw") {
+    const reason = await promptReason("撤回离职审批", false);
+    if (reason === null) return;
+    await exec("撤回审批", () => actWithdrawResignation(row, reason));
+  } else if (key === "cancel") {
+    const reason = await promptReason("取消离职单", false);
+    if (reason === null) return;
+    await exec("取消离职单", () => actCancelResignation(row, reason));
+  } else if (key === "forceComplete") {
+    const reason = await promptReason("强制办结离职单", true);
+    if (reason === null) return;
+    const done = await exec("强制办结", () => actForceCompleteResignation(row, reason));
+    if (done) setTimeout(() => back("/pages/manage/index?type=resign"), 700);
+  }
 }
 
+async function exec(title, task) {
+  uni.showLoading({ title: "处理中..." });
+  try {
+    await task();
+    uni.hideLoading();
+    uni.showToast({ title: `${title}成功`, icon: "success" });
+    await loadDetail();
+    return true;
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: e.message || `${title}失败`, icon: "none" });
+    return false;
+  }
+}
+
+async function onToggleHandover(item) {
+  const row = staff.value;
+  if (!row) return;
+  if (["completed", "withdrawn", "cancelled"].includes(row.displayStatus)) {
+    uni.showToast({ title: "当前状态不可操作交接", icon: "none" });
+    return;
+  }
+  if (!item.id) {
+    uni.showToast({ title: "缺少交接事项主键", icon: "none" });
+    return;
+  }
+  if (item.done) {
+    const reason = await promptReason("回退交接事项", false);
+    if (reason === null) return;
+    await exec("回退交接事项", () => actRollbackHandoverItem(row.id, item.id, reason));
+  } else {
+    await exec("完成交接事项", () => actCompleteHandoverItem(row.id, item.id, ""));
+  }
+}
+
+let firstShow = true;
+
 onLoad((options) => {
   id.value = options?.id || "";
   uni.setNavigationBarTitle({ title: "离职详情" });
+  loadDetail();
 });
-</script>
 
-<style scoped>
+onShow(() => {
+  if (firstShow) {
+    firstShow = false;
+    return;
+  }
+  loadDetail();
+});
+</script><style scoped>
 .detail-head {
   padding: 32rpx 28rpx 28rpx;
   color: #fff;
@@ -391,6 +459,7 @@ onLoad((options) => {
 .head-main {
   flex: 1;
   margin-left: 20rpx;
+  min-width: 0;
 }
 .head-name {
   display: block;
@@ -403,6 +472,12 @@ onLoad((options) => {
   font-size: 24rpx;
   opacity: 0.86;
 }
+.head-status {
+  flex-shrink: 0;
+  margin-left: 12rpx;
+  background: rgba(255, 255, 255, 0.24);
+  color: #fff;
+}
 .head-meta {
   display: grid;
   grid-template-columns: 1fr 1fr;
@@ -422,6 +497,12 @@ onLoad((options) => {
   font-weight: 600;
   opacity: 1;
 }
+.loading {
+  padding: 60rpx 28rpx;
+  text-align: center;
+  color: #8b939f;
+  font-size: 26rpx;
+}
 .progress-card {
   padding: 28rpx;
 }
@@ -515,6 +596,7 @@ onLoad((options) => {
 .timeline-main {
   flex: 1;
   margin-left: 18rpx;
+  min-width: 0;
 }
 .timeline-label {
   display: block;
@@ -619,6 +701,11 @@ onLoad((options) => {
 }
 .info-row text:first-child {
   color: #8b939f;
+  flex-shrink: 0;
+  margin-right: 18rpx;
+}
+.info-row text:last-child {
+  text-align: right;
 }
 .info-row.info-items {
   display: flex;
@@ -677,7 +764,4 @@ onLoad((options) => {
   background: #ee6666;
   color: #fff;
 }
-.primary-btn.full {
-  flex: 1;
-}
 </style>

+ 489 - 0
src/pages/manage/resign-handover.vue

@@ -0,0 +1,489 @@
+<template>
+  <view class="page-no-tab">
+    <view class="manage-head">
+      <view>
+        <text class="head-title">离职交接单</text>
+        <text class="head-sub">交接事项进度、归档与办结</text>
+      </view>
+      <view class="head-count"><text>{{ list.length }}</text><text>交接单</text></view>
+    </view>
+
+    <view class="filter-bar">
+      <view><text>⌕</text><input v-model="keyword" placeholder="搜索离职单号" /></view>
+      <text class="press" @click="chooseStatus">{{ statusLabel }}⌄</text>
+    </view>
+
+    <view v-if="loading" class="empty">加载中…</view>
+    <view v-else-if="!filtered.length" class="empty">暂无离职交接单</view>
+
+    <view class="manage-list">
+      <view v-for="h in filtered" :key="h.id" class="card handover-card">
+        <view class="item-top press" @click="toggle(h)">
+          <view class="avatar item-avatar">{{ (h.person || '交').charAt(0) }}</view>
+          <view class="item-main">
+            <text class="item-name">交接单 {{ h.resignationNo || h.id }}</text>
+            <text class="item-sub"
+              >交接人 {{ h.person || '待指派' }} · 交接日期 {{ h.handoverDate || '—' }}</text
+            >
+          </view>
+          <text class="status-tag" :class="statusClass(h.status)">{{ h.statusLabel }}</text>
+        </view>
+
+        <view class="progress-line">
+          <view class="progress-bar">
+            <view class="progress-fill" :style="{ width: h.progress + '%' }"></view>
+          </view>
+          <text class="progress-num">{{ h.progress }}%</text>
+        </view>
+
+        <template v-if="expandedId === h.id">
+          <view class="items">
+            <view
+              v-for="item in h.items"
+              :key="item.id || item.name"
+              class="item-row press"
+              @click="toggleItem(h, item)"
+            >
+              <view class="item-icon" :class="item.done ? 'is-on' : 'is-off'">
+                {{ item.done ? '✓' : '·' }}
+              </view>
+              <view class="item-body">
+                <text class="item-title">{{ item.name }}</text>
+                <text class="item-desc muted">{{ item.detail || item.categoryLabel }}</text>
+              </view>
+              <text class="status-tag" :class="item.done ? 'status-approved' : 'status-pending'">
+                {{ item.done ? '已交接' : '待交接' }}
+              </text>
+            </view>
+            <view v-if="!h.items.length" class="item-empty muted">该交接单暂无事项</view>
+          </view>
+
+          <view class="card-actions">
+            <view class="ghost-btn" @click="onDelete(h)">删除交接单</view>
+            <view v-if="h.status === 'completed'" class="primary-btn" @click="onArchive(h)">
+              归档离职单
+            </view>
+          </view>
+        </template>
+      </view>
+    </view>
+
+    <view class="footer-tip muted"><text>{{ footerTip }}</text></view>
+  </view>
+</template><script setup>
+import { computed, ref } from "vue";
+import { onShow } from "@dcloudio/uni-app";
+import { useAuthGuard } from "@/hooks/useAuthGuard";
+import {
+  actArchiveResignation,
+  actCompleteHandoverItem,
+  actDeleteHandover,
+  actRollbackHandoverItem,
+  canArchiveResignation,
+  loadHandoverDetail,
+  loadHandoverList,
+  loadResignationDetail,
+} from "@/api/resign";
+import { getServerConfig } from "@/utils/auth";
+
+useAuthGuard();
+
+const keyword = ref("");
+const status = ref("all");
+const statusLabel = ref("全部状态");
+const list = ref([]);
+const loading = ref(false);
+const expandedId = ref("");
+
+const statusOptions = [
+  { value: "all", label: "全部状态" },
+  { value: "not_started", label: "待交接" },
+  { value: "processing", label: "交接中" },
+  { value: "completed", label: "已完成" },
+];
+
+const filtered = computed(() =>
+  list.value
+    .filter((h) => (status.value === "all" ? true : h.status === status.value))
+    .filter((h) =>
+      keyword.value ? String(h.resignationNo || "").includes(keyword.value.trim()) : true,
+    ),
+);
+
+const footerTip = computed(() =>
+  getServerConfig().mode === "server"
+    ? "交接事项完成后可归档离职单;归档后流程结束。"
+    : "演示模式:交接数据保存在本机,操作不会同步到服务器。",
+);
+
+function statusClass(value) {
+  if (value === "completed") return "status-approved";
+  if (value === "processing") return "status-processing";
+  return "status-pending";
+}
+
+async function loadList() {
+  loading.value = true;
+  try {
+    const page = await loadHandoverList();
+    list.value = page.list || [];
+  } catch (e) {
+    list.value = [];
+    uni.showToast({ title: e.message || "交接单加载失败", icon: "none" });
+  } finally {
+    loading.value = false;
+  }
+}
+
+function chooseStatus() {
+  uni.showActionSheet({
+    itemList: statusOptions.map((o) => o.label),
+    success: (r) => {
+      const picked = statusOptions[r.tapIndex];
+      if (!picked) return;
+      status.value = picked.value;
+      statusLabel.value = picked.label;
+    },
+  });
+}
+
+async function toggle(handover) {
+  if (expandedId.value === handover.id) {
+    expandedId.value = "";
+    return;
+  }
+  expandedId.value = handover.id;
+  // 列表接口可能不含事项明细,展开时补拉一次详情
+  if (!handover.items?.length && handover.id) {
+    try {
+      const detail = await loadHandoverDetail(handover.id);
+      const idx = list.value.findIndex((x) => String(x.id) === String(handover.id));
+      if (detail && idx > -1) list.value[idx] = { ...list.value[idx], ...detail };
+    } catch (e) {
+      /* 详情拉取失败时保留列表展示 */
+    }
+  }
+}
+
+function promptReason(title, required) {
+  return new Promise((resolve) => {
+    uni.showModal({
+      title,
+      editable: true,
+      placeholderText: required ? "请输入原因(必填)" : "请输入原因(选填)",
+      success: (r) => {
+        if (!r.confirm) return resolve(null);
+        const reason = String(r.content || "").trim();
+        if (required && !reason) {
+          uni.showToast({ title: "请填写原因", icon: "none" });
+          return resolve(null);
+        }
+        resolve(reason);
+      },
+      fail: () => resolve(null),
+    });
+  });
+}
+
+async function exec(title, task) {
+  uni.showLoading({ title: "处理中..." });
+  try {
+    await task();
+    uni.hideLoading();
+    uni.showToast({ title: `${title}成功`, icon: "success" });
+    await loadList();
+    return true;
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: e.message || `${title}失败`, icon: "none" });
+    return false;
+  }
+}
+
+async function toggleItem(handover, item) {
+  if (!handover.resignationId) {
+    uni.showToast({ title: "缺少离职单主键", icon: "none" });
+    return;
+  }
+  if (!item.id) {
+    uni.showToast({ title: "缺少交接事项主键", icon: "none" });
+    return;
+  }
+  if (item.done) {
+    const reason = await promptReason("回退交接事项", false);
+    if (reason === null) return;
+    await exec("回退交接事项", () =>
+      actRollbackHandoverItem(handover.resignationId, item.id, reason),
+    );
+  } else {
+    await exec("完成交接事项", () =>
+      actCompleteHandoverItem(handover.resignationId, item.id, ""),
+    );
+  }
+}
+
+async function onDelete(handover) {
+  const confirmed = await new Promise((resolve) =>
+    uni.showModal({
+      title: "删除交接单",
+      content: "删除后离职单将恢复为未创建交接,确认删除?",
+      success: (r) => resolve(!!r.confirm),
+      fail: () => resolve(false),
+    }),
+  );
+  if (!confirmed) return;
+  await exec("删除交接单", () => actDeleteHandover(handover));
+}
+
+async function onArchive(handover) {
+  if (!handover.resignationId) {
+    uni.showToast({ title: "缺少离职单主键,无法归档", icon: "none" });
+    return;
+  }
+  const confirmed = await new Promise((resolve) =>
+    uni.showModal({
+      title: "归档离职单",
+      content: "归档后离职流程结束、劳动关系解除,确认归档?",
+      success: (r) => resolve(!!r.confirm),
+      fail: () => resolve(false),
+    }),
+  );
+  if (!confirmed) return;
+  uni.showLoading({ title: "处理中..." });
+  try {
+    const row = await loadResignationDetail(handover.resignationId);
+    if (!row || !canArchiveResignation(row)) {
+      uni.hideLoading();
+      uni.showToast({ title: "当前离职单不满足归档条件", icon: "none" });
+      return;
+    }
+    await actArchiveResignation(row);
+    uni.hideLoading();
+    uni.showToast({ title: "归档成功", icon: "success" });
+    await loadList();
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: e.message || "归档失败", icon: "none" });
+  }
+}
+
+onShow(loadList);
+</script><style scoped>
+.manage-head {
+  padding: 32rpx 28rpx;
+  display: flex;
+  align-items: center;
+  color: #fff;
+  background: linear-gradient(145deg, #ee6666, #f08a8a);
+}
+.manage-head > view:first-child {
+  flex: 1;
+}
+.head-title {
+  display: block;
+  font-size: 35rpx;
+  font-weight: 600;
+}
+.head-sub {
+  display: block;
+  margin-top: 10rpx;
+  font-size: 22rpx;
+  opacity: 0.78;
+}
+.head-count {
+  text-align: center;
+}
+.head-count text {
+  display: block;
+  font-size: 42rpx;
+  font-weight: 700;
+}
+.head-count text + text {
+  margin-top: 5rpx;
+  font-size: 20rpx;
+  font-weight: 400;
+}
+.filter-bar {
+  height: 98rpx;
+  padding: 14rpx 24rpx;
+  display: flex;
+  align-items: center;
+  gap: 22rpx;
+  background: #fff;
+}
+.filter-bar view {
+  flex: 1;
+  height: 68rpx;
+  border-radius: 10rpx;
+  background: #f2f3f5;
+  color: #9aa1aa;
+  display: flex;
+  align-items: center;
+  padding: 0 18rpx;
+}
+.filter-bar input {
+  flex: 1;
+  height: 68rpx;
+  margin-left: 10rpx;
+  font-size: 24rpx;
+  color: #1f2329;
+}
+.filter-bar > text {
+  color: #6f7782;
+  font-size: 24rpx;
+  flex-shrink: 0;
+}
+.empty {
+  padding: 90rpx 28rpx;
+  text-align: center;
+  color: #8b939f;
+  font-size: 26rpx;
+}
+.manage-list {
+  padding: 24rpx;
+}
+.handover-card {
+  margin-bottom: 20rpx;
+  padding: 26rpx;
+}
+.item-top {
+  display: flex;
+  align-items: center;
+}
+.item-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  font-size: 27rpx;
+}
+.item-main {
+  flex: 1;
+  margin-left: 18rpx;
+  min-width: 0;
+}
+.item-name {
+  display: block;
+  font-size: 29rpx;
+  font-weight: 600;
+}
+.item-sub {
+  display: block;
+  margin-top: 8rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+}
+.progress-line {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  margin-top: 22rpx;
+}
+.progress-bar {
+  flex: 1;
+  height: 12rpx;
+  border-radius: 6rpx;
+  background: #edf0f2;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  border-radius: 6rpx;
+  background: linear-gradient(90deg, #f08a8a, #ee6666);
+}
+.progress-num {
+  font-size: 22rpx;
+  color: #6f7782;
+  width: 70rpx;
+  text-align: right;
+}
+.items {
+  margin-top: 20rpx;
+  border-top: 1rpx solid #edf0f2;
+}
+.item-row {
+  display: flex;
+  align-items: flex-start;
+  padding: 20rpx 0;
+}
+.item-row + .item-row {
+  border-top: 1rpx solid #f4f5f7;
+}
+.item-icon {
+  width: 48rpx;
+  height: 48rpx;
+  border-radius: 14rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+.item-icon.is-on {
+  background: #eaf8ef;
+  color: #07893f;
+}
+.item-icon.is-off {
+  background: #fff2df;
+  color: #d9780b;
+}
+.item-body {
+  flex: 1;
+  margin-left: 16rpx;
+  min-width: 0;
+}
+.item-title {
+  display: block;
+  font-size: 26rpx;
+  color: #1f2329;
+}
+.item-desc {
+  display: block;
+  margin-top: 6rpx;
+  font-size: 21rpx;
+}
+.item-row > .status-tag {
+  flex-shrink: 0;
+  margin-top: 6rpx;
+  margin-left: 12rpx;
+}
+.item-empty {
+  padding: 24rpx 0;
+  font-size: 22rpx;
+}
+.card-actions {
+  display: flex;
+  gap: 18rpx;
+  margin-top: 22rpx;
+  padding-top: 22rpx;
+  border-top: 1rpx solid #edf0f2;
+}
+.card-actions > view {
+  flex: 1;
+}
+.ghost-btn {
+  height: 80rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 27rpx;
+  background: #fff;
+  color: #4b5563;
+  border: 1rpx solid #d7dce2;
+}
+.primary-btn {
+  height: 80rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 27rpx;
+  background: #ee6666;
+  color: #fff;
+}
+.footer-tip {
+  padding: 12rpx 28rpx 40rpx;
+  font-size: 22rpx;
+  text-align: center;
+}
+</style>

+ 10 - 0
src/utils/router.js

@@ -5,3 +5,13 @@ export function go(url) {
 export function switchMain(url) {
   uni.reLaunch({ url })
 }
+
+// 返回上一页;没有上一页(深链进入)时退回 fallbackUrl
+export function back(fallbackUrl = '') {
+  const pages = getCurrentPages()
+  if (pages.length > 1) {
+    uni.navigateBack({ delta: 1 })
+    return
+  }
+  if (fallbackUrl) uni.reLaunch({ url: fallbackUrl })
+}

+ 91 - 1
src/utils/storage.js

@@ -10,6 +10,9 @@ const ONBOARD_LIST_KEY = 'aimill_hr_onboardings'
 const ONBOARD_INVITATION_KEY = 'aimill_hr_onboarding_invitations'
 const ONBOARD_DRAFT_KEY = 'aimill_hr_onboarding_drafts'
 const RESIGN_KEY = 'aimill_hr_resignations'
+const RESIGN_CREATED_KEY = 'aimill_hr_resign_created'
+const RESIGN_HANDOVER_KEY = 'aimill_hr_resign_handovers'
+const RESIGN_ID_KEY = 'aimill_hr_resign_ids'
 const AUTH_KEY = 'aimill_hr_auth'
 const REMEMBER_KEY = 'aimill_hr_remembered_account'
 const PASSWORD_MEMO_KEY = 'passwordMemo'
@@ -185,7 +188,7 @@ export function getAuthCredentials() {
 }
 
 export function clearDemoStorage() {
-  [APPLY_KEY, DRAFT_KEY, APPROVAL_KEY, TRAINING_KEY, PROFILE_KEY, ONBOARD_MATERIALS_KEY, ONBOARD_LIST_KEY, ONBOARD_INVITATION_KEY, ONBOARD_DRAFT_KEY, RESIGN_KEY].forEach((key) =>
+  [APPLY_KEY, DRAFT_KEY, APPROVAL_KEY, TRAINING_KEY, PROFILE_KEY, ONBOARD_MATERIALS_KEY, ONBOARD_LIST_KEY, ONBOARD_INVITATION_KEY, ONBOARD_DRAFT_KEY, RESIGN_KEY, RESIGN_CREATED_KEY, RESIGN_HANDOVER_KEY, RESIGN_ID_KEY].forEach((key) =>
     uni.removeStorageSync(key),
   )
 }
@@ -525,6 +528,93 @@ export function setResignLocalState(id, patch) {
   return all[id]
 }
 
+// ============ 离职流程(M02)演示态 ============
+// 服务器模式下列表 / 详情 / 操作都走接口,这里只兜底演示模式:
+//   - 申请表单提交后在本地落一条离职单,使列表与详情能串起来
+//   - 「生成交接单」后写本地交接单,供交接事项勾选与回退
+//   - 记住服务端返回过的离职单主键,集合查询不可用时按主键逐个拉详情
+
+export function getCreatedResignations() {
+  const saved = uni.getStorageSync(RESIGN_CREATED_KEY)
+  return Array.isArray(saved) ? saved : []
+}
+
+export function addCreatedResignation(record) {
+  if (!record || !record.id) return getCreatedResignations()
+  const id = String(record.id)
+  const rest = getCreatedResignations().filter((item) => String(item.id) !== id)
+  const list = [{ ...record, createdAt: record.createdAt || formatNow() }, ...rest]
+  uni.setStorageSync(RESIGN_CREATED_KEY, list)
+  return list
+}
+
+export function updateCreatedResignation(id, patch = {}) {
+  if (!id) return getCreatedResignations()
+  const list = getCreatedResignations().map((item) =>
+    String(item.id) === String(id) ? { ...item, ...patch, updatedAt: formatNow() } : item,
+  )
+  uni.setStorageSync(RESIGN_CREATED_KEY, list)
+  return list
+}
+
+export function getResignIds() {
+  const saved = uni.getStorageSync(RESIGN_ID_KEY)
+  return Array.isArray(saved) ? saved.map(String).filter(Boolean) : []
+}
+
+export function rememberResignId(id) {
+  const text = id == null ? '' : String(id).trim()
+  if (!text) return getResignIds()
+  const next = [text, ...getResignIds().filter((item) => item !== text)].slice(0, 200)
+  uni.setStorageSync(RESIGN_ID_KEY, next)
+  return next
+}
+
+export function getHandovers() {
+  const saved = uni.getStorageSync(RESIGN_HANDOVER_KEY)
+  return saved && typeof saved === 'object' ? saved : {}
+}
+
+export function getHandoverById(id) {
+  if (!id) return null
+  const all = getHandovers()
+  return all[id] && typeof all[id] === 'object' ? all[id] : null
+}
+
+export function getHandoverByResignationId(resignationId) {
+  if (!resignationId) return null
+  return (
+    Object.values(getHandovers()).find(
+      (item) => String(item?.resignationId) === String(resignationId),
+    ) || null
+  )
+}
+
+export function saveHandover(handover) {
+  if (!handover || !handover.id) return null
+  const all = getHandovers()
+  all[handover.id] = { ...handover, updatedAt: formatNow() }
+  uni.setStorageSync(RESIGN_HANDOVER_KEY, all)
+  return all[handover.id]
+}
+
+export function removeHandoverById(id) {
+  if (!id) return getHandovers()
+  const all = getHandovers()
+  delete all[id]
+  uni.setStorageSync(RESIGN_HANDOVER_KEY, all)
+  return all
+}
+
+export function updateHandoverItemState(handoverId, itemId, patch = {}) {
+  const handover = getHandoverById(handoverId)
+  if (!handover) return null
+  const items = (handover.items || []).map((item) =>
+    String(item.id) === String(itemId) ? { ...item, ...patch } : item,
+  )
+  return saveHandover({ ...handover, items })
+}
+
 export function formatNow() {
   const d = new Date()
   const pad = (n) => String(n).padStart(2, '0')