Răsfoiți Sursa

feat(resign): 离职申请与详情模块

新增
- src/api/resign.js:离职类型/原因枚举映射 buildResignationPayload、submitResignationApplication
  (服务器模式 POST /hr/ess/mobile/applications/resignation,演示模式返回时间戳假 id)
- src/pages/manage/resign-detail.vue:离职详情页(顶部信息卡 + 进度条 + 时间线 +
  工作交接清单 + 信息模块:合同解除/社保停缴/资产归还/财务结算/账号注销)
  本地状态(交接勾选、资产归还)写入 storage,状态变更走 setResignStatus

申请表单
- src/pages/apply/form.vue:新增 resign 类型字段(离职类型/原因/预计离职日期/交接人/交接备注/
  待归还资产/补偿方案/开具离职证明开关),表单校验区分 resign 分支,
  提交时调用 buildResignationPayload + submitResignationApplication,
  把服务端返回的主键回写到本地 application 记录

首页快捷入口
- src/pages/home/index.vue:quickActions.more 替换为 resign 后,openQuick 不再单独拦截 more,
  统一走 apply/form?type=resign
xieyong 6 zile în urmă
părinte
comite
7fe3451755
4 a modificat fișierele cu 996 adăugiri și 4 ștergeri
  1. 100 0
      src/api/resign.js
  2. 213 3
      src/pages/apply/form.vue
  3. 0 1
      src/pages/home/index.vue
  4. 683 0
      src/pages/manage/resign-detail.vue

+ 100 - 0
src/api/resign.js

@@ -0,0 +1,100 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+
+// ============ 模式判断 ============
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+function serverPath() {
+  const base = getApiBaseUrl()
+  if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
+  return base
+}
+
+// ============ 枚举 ============
+
+// 前端离职类型(form.vue actionSheet 选项)→ 后端 resignationType 枚举
+// 后端枚举值未定,先用与前端一致的中文 key,由后端后续给出映射表时再调整
+export const RESIGNATION_TYPE_MAP = {
+  '主动离职': 'VOLUNTARY',
+  '协商离职': 'NEGOTIATED',
+  '辞退': 'TERMINATED',
+  '合同到期': 'CONTRACT_EXPIRED',
+  '退休': 'RETIRE',
+}
+
+export const RESIGN_REASON_MAP = {
+  '个人发展': 'CAREER_GROWTH',
+  '家庭原因': 'FAMILY',
+  '薪资待遇': 'COMPENSATION',
+  '职业转型': 'CAREER_CHANGE',
+  '公司调整': 'COMPANY_ADJUSTMENT',
+  '健康原因': 'HEALTH',
+}
+
+/**
+ * 把表单字段映射成 /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,
+ * }}
+ */
+export function buildResignationPayload(form) {
+  if (!form) return null
+  const resignationType = RESIGNATION_TYPE_MAP[form.resignType] || form.resignType || ''
+  const reasonCode = RESIGN_REASON_MAP[form.resignReason] || ''
+  // reason 字段后端要求必填:把中文离职原因与具体事由拼接,避免只传 code 让用户看不到自己的话
+  const reasonParts = []
+  if (form.resignReason) reasonParts.push(form.resignReason)
+  if (reasonCode) reasonParts.push(`(${reasonCode})`)
+  if (form.reason) reasonParts.push(form.reason)
+  const reason = reasonParts.join(' ')
+
+  const userCommentParts = []
+  if (form.handover) userCommentParts.push(`交接人:${form.handover}`)
+  if (form.handoverNote) userCommentParts.push(`交接备注:${form.handoverNote}`)
+  if (form.compensation && form.compensation !== '不适用') {
+    userCommentParts.push(`补偿方案:${form.compensation}`)
+  }
+  if (form.needCertificate === false) {
+    userCommentParts.push('无需开具离职证明')
+  }
+  const userComment = userCommentParts.join(';')
+
+  return {
+    plannedLastWorkDate: form.expectedLeaveDate || '',
+    reason,
+    resignationType,
+    ...(userComment ? { userComment } : {}),
+  }
+}
+
+/**
+ * 提交我的离职申请
+ * 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('申请参数为空')
+
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 300))
+    return Date.now()
+  }
+
+  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
+}

+ 213 - 3
src/pages/apply/form.vue

@@ -48,7 +48,7 @@
         <picker mode="time" :value="form.repairTime" @change="(e) => (form.repairTime = e.detail.value)"><text
             class="value">{{ form.repairTime || "请选择" }}</text></picker><text class="arrow">›</text>
       </view>
-      <template v-if="!['certificate', 'check'].includes(type)">
+      <template v-if="!['certificate', 'check', 'resign'].includes(type)">
         <view class="field"><text class="label required">开始日期</text>
           <picker mode="date" :value="form.startDate" @change="(e) => (form.startDate = e.detail.value)"><text
               class="value">{{ form.startDate }}</text></picker><text class="arrow">›</text>
@@ -70,6 +70,50 @@
         </view>
       </template>
       <view class="divider"></view>
+      <template v-if="type === 'resign'">
+        <view class="field press" @click="chooseResignType"><text
+            class="label required">离职类型</text><text :class="form.resignType ? 'value' : 'placeholder'">{{
+              form.resignType || "请选择"
+            }}</text><text class="arrow">›</text></view>
+        <view class="divider"></view>
+        <view class="field press" @click="chooseResignReason"><text
+            class="label required">离职原因</text><text :class="form.resignReason ? 'value' : 'placeholder'">{{
+              form.resignReason || "请选择"
+            }}</text><text class="arrow">›</text></view>
+        <view class="divider"></view>
+        <view class="field"><text class="label required">预计离职日期</text>
+          <picker mode="date" :start="todayStr" :value="form.expectedLeaveDate"
+            @change="(e) => (form.expectedLeaveDate = e.detail.value)"><text
+              :class="form.expectedLeaveDate ? 'value' : 'placeholder'">{{ form.expectedLeaveDate || "请选择" }}</text></picker><text class="arrow">›</text>
+        </view>
+        <view class="divider"></view>
+        <view class="field"><text class="label">工作交接人</text>
+          <input class="input-flex" v-model="form.handover" placeholder="请输入交接人姓名" maxlength="20" /></view>
+        <view class="divider"></view>
+        <view class="field textarea-field small-textarea"><text class="label">交接备注</text>
+          <textarea v-model="form.handoverNote" maxlength="200" placeholder="交接事项、文档与客户说明(选填)" /></view>
+        <view class="divider"></view>
+        <view class="field check-field"><text class="label">待归还资产</text>
+          <view class="check-list">
+            <label v-for="opt in assetReturnOptions" :key="opt" class="check-row press" @click="toggleAssetReturn(opt)">
+              <view class="checkbox" :class="{ 'is-checked': form.assetReturnList.includes(opt) }">
+                <text v-if="form.assetReturnList.includes(opt)">✓</text>
+              </view>
+              <text>{{ opt }}</text>
+            </label>
+          </view>
+        </view>
+        <view class="divider"></view>
+        <view class="field press" @click="chooseCompensation"><text
+            class="label">补偿方案</text><text :class="form.compensation ? 'value' : 'placeholder'">{{
+              form.compensation || "请选择"
+            }}</text><text class="arrow">›</text></view>
+        <view class="divider"></view>
+        <view class="field switch-field"><text class="label">开具离职证明</text>
+          <switch :checked="form.needCertificate" @change="(e) => (form.needCertificate = e.detail.value)" color="#1677ff" />
+        </view>
+        <view class="divider"></view>
+      </template>
       <view class="field textarea-field"><text class="label"
           :class="{ required: type !== 'certificate' }">申请事由</text><textarea v-model="form.reason" maxlength="200"
           :placeholder="reasonPlaceholder" /><text class="counter">{{ form.reason.length }}/200</text></view>
@@ -111,6 +155,10 @@ import {
   buildAttendancePayload,
   submitAttendanceApplication,
 } from "@/api/attendance";
+import {
+  buildResignationPayload,
+  submitResignationApplication,
+} from "@/api/resign";
 import { uploadAttachment } from "@/api/file";
 import { getServerConfig } from "@/utils/auth";
 const type = ref("leave");
@@ -127,6 +175,14 @@ const form = reactive({
   attachments: [],
   repairType: "",
   repairTime: "",
+  resignType: "",
+  resignReason: "",
+  expectedLeaveDate: "",
+  handover: "",
+  handoverNote: "",
+  assetReturnList: [],
+  compensation: "",
+  needCertificate: true,
 });
 const config = computed(
   () => applicationTypes[type.value] || applicationTypes.leave,
@@ -208,6 +264,14 @@ function resetForm() {
     attachments: [],
     repairType: "",
     repairTime: "",
+    resignType: "",
+    resignReason: "",
+    expectedLeaveDate: "",
+    handover: "",
+    handoverNote: "",
+    assetReturnList: [],
+    compensation: "",
+    needCertificate: true,
   });
 }
 function chooseLeave() {
@@ -231,6 +295,41 @@ function chooseRepairType() {
     success: (r) => (form.repairType = REPAIR_TYPE_OPTIONS[r.tapIndex]),
   });
 }
+
+const todayStr = new Date().toISOString().slice(0, 10);
+const assetReturnOptions = ['工牌', '门禁卡', '办公电脑', '测量仪器', '其他'];
+
+function toggleAssetReturn(opt) {
+  const list = Array.isArray(form.assetReturnList) ? form.assetReturnList : [];
+  const idx = list.indexOf(opt);
+  if (idx >= 0) list.splice(idx, 1);
+  else list.push(opt);
+  form.assetReturnList = [...list];
+}
+
+function chooseResignType() {
+  const items = ['主动离职', '协商离职', '辞退', '合同到期', '退休'];
+  uni.showActionSheet({
+    itemList: items,
+    success: (r) => (form.resignType = items[r.tapIndex]),
+  });
+}
+
+function chooseResignReason() {
+  const items = ['个人发展', '家庭原因', '薪资待遇', '职业转型', '公司调整', '健康原因'];
+  uni.showActionSheet({
+    itemList: items,
+    success: (r) => (form.resignReason = items[r.tapIndex]),
+  });
+}
+
+function chooseCompensation() {
+  const items = ['N+1', 'N', '协商一致', '不适用'];
+  uni.showActionSheet({
+    itemList: items,
+    success: (r) => (form.compensation = items[r.tapIndex]),
+  });
+}
 function chooseImage() {
   uni.chooseImage({
     count: 9,
@@ -270,7 +369,15 @@ function validate() {
     if (!form.repairTime) return "请选择打卡时间";
     if (!form.reason) return "请填写补卡原因";
   }
-  if (!["certificate", "check"].includes(type.value)) {
+  if (type.value === "resign") {
+    if (!form.resignType) return "请选择离职类型";
+    if (!form.resignReason) return "请选择离职原因";
+    if (!form.expectedLeaveDate) return "请选择预计离职日期";
+    if (form.expectedLeaveDate < todayStr) return "预计离职日期不能早于今天";
+    if (!form.reason) return "请填写申请事由";
+    return "";
+  }
+  if (!["certificate", "check", "resign"].includes(type.value)) {
     if (!form.startDate) return "请选择开始日期";
     if (!form.endDate) return "请选择结束日期";
     if (!form.startTime) return "请选择开始时间";
@@ -280,7 +387,7 @@ function validate() {
       return "结束时间不能早于或等于开始时间";
   }
   if (!form.reason && type.value === "certificate") return "";
-  if (!form.reason && !["certificate", "check"].includes(type.value))
+  if (!form.reason && !["certificate", "check", "resign"].includes(type.value))
     return "请填写申请事由";
   return "";
 }
@@ -357,11 +464,46 @@ async function submit() {
     }
   }
 
+  // 服务器模式 + 离职申请
+  //   → POST /hr/ess/mobile/applications/resignation
+  //   → 部门和申请人由服务端当前任职确定,前端只送表单字段
+  //   → 响应 data 是后端新建单据主键,作为本地申请 id 保持两端一致
+  let serverApplicationId = null;
+  if (type.value === "resign" && getServerConfig().mode === "server") {
+    try {
+      uni.showLoading({ title: "提交中..." });
+      const payload = buildResignationPayload(form);
+      if (!payload) throw new Error("申请参数为空");
+      serverApplicationId = await submitResignationApplication(payload);
+      uni.hideLoading();
+    } catch (e) {
+      uni.hideLoading();
+      return uni.showToast({
+        title: e.message || "离职申请提交失败",
+        icon: "none",
+      });
+    }
+  }
+
   addApplication({
+    ...(serverApplicationId ? { id: serverApplicationId } : {}),
     type: config.value.title,
     summary: summary(),
     reason: form.reason || "用于个人材料办理",
     color: config.value.color,
+    metadata: type.value === "resign" ? {
+      employeeId: currentUser.id,
+      resignType: form.resignType,
+      resignReason: form.resignReason,
+      expectedLeaveDate: form.expectedLeaveDate,
+      handover: form.handover,
+      handoverNote: form.handoverNote,
+      assetReturnList: form.assetReturnList,
+      compensation: form.compensation,
+      needCertificate: form.needCertificate,
+      source: serverApplicationId ? "server" : "demo",
+      serverId: serverApplicationId || undefined,
+    } : undefined,
   });
   removeDraft(type.value);
 
@@ -389,6 +531,9 @@ function summary() {
     const typeLabel = form.repairType ? REPAIR_TYPE_LABEL[form.repairType] : "补卡";
     return `${date} · ${typeLabel} · ${form.repairTime || ""}`;
   }
+  if (type.value === "resign") {
+    return `${form.resignType} · ${form.expectedLeaveDate}${form.handover ? " · 交接 " + form.handover : ""}`;
+  }
   return `${form.startDate} 至 ${form.endDate}`;
 }
 </script>
@@ -624,4 +769,69 @@ function summary() {
     transform: translateX(-50%);
   }
 }
+
+.input-flex {
+  flex: 1;
+  text-align: right;
+  font-size: 27rpx;
+  color: #1f2329;
+  padding: 0 6rpx;
+}
+
+.small-textarea {
+  min-height: 180rpx;
+}
+
+.small-textarea textarea {
+  height: 100rpx;
+}
+
+.check-field {
+  display: block;
+  align-items: flex-start;
+}
+
+.check-field .label {
+  display: block;
+  width: auto;
+  margin-bottom: 16rpx;
+}
+
+.check-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 18rpx 24rpx;
+  padding-top: 4rpx;
+}
+
+.check-row {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+  font-size: 26rpx;
+  color: #4b5563;
+}
+
+.checkbox {
+  width: 32rpx;
+  height: 32rpx;
+  border: 1rpx solid #c4c8ce;
+  border-radius: 6rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22rpx;
+  color: #fff;
+  background: #fff;
+  box-sizing: border-box;
+}
+
+.checkbox.is-checked {
+  background: #1677ff;
+  border-color: #1677ff;
+}
+
+.switch-field {
+  justify-content: space-between;
+}
 </style>

+ 0 - 1
src/pages/home/index.vue

@@ -237,7 +237,6 @@ onShow(() => {
 });
 
 function openQuick(item) {
-  if (item.key === "more") return uni.reLaunch({ url: "/pages/work/index" });
   go(`/pages/apply/form?type=${item.key}`);
 }
 

+ 683 - 0
src/pages/manage/resign-detail.vue

@@ -0,0 +1,683 @@
+<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 || '—' }}</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>
+    </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">{{ 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'"
+        >
+          {{ 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>
+        <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>
+
+      <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 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>
+      </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.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.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>
+
+      <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 :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>
+
+    <view class="footer-tip muted">
+      <text>演示版:操作按钮仅作展示,真实数据接入后可对接审批 / 资产交接等接口。</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>
+</template>
+
+<script setup>
+import { computed, ref, watch } from "vue";
+import { onLoad } 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";
+
+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 localState = ref({});
+
+function refreshLocal(empId) {
+  const detail = getResignDetail(empId);
+  localState.value = detail || {};
+}
+
+watch(
+  () => staff.value.id,
+  (next) => refreshLocal(next),
+  { immediate: true },
+);
+
+const statusInfo = computed(() => statusMap[staff.value.status] || statusMap.pending);
+
+const headerGradient = computed(() => {
+  const map = {
+    pending: "linear-gradient(145deg,#f6bd16,#f59a23)",
+    processing: "linear-gradient(145deg,#2479e9,#1363ce)",
+    done: "linear-gradient(145deg,#52c41a,#389e0d)",
+    overdue: "linear-gradient(145deg,#ee4d4d,#cf1322)",
+    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 };
+});
+
+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) {
+  const s = String(status || "");
+  if (
+    s === "已解除" || s === "已停缴" || s === "已交接" || s === "已归还" ||
+    s === "已结算" || s === "已注销" || s === "已开具" || s === "已签订" ||
+    s === "已发放" || s === "已完成"
+  ) {
+    return "status-approved";
+  }
+  if (s === "办理中" || s === "进行中" || s === "交接中" || s === "部分归还" || 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,
+);
+
+// 资产是否已归还(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);
+}
+
+function onToggleAsset(name) {
+  const next = !effectiveAssetReturned(name);
+  addResignAssetReturn(staff.value.id, name, next);
+  refreshLocal(staff.value.id);
+}
+
+function onRemind() {
+  uni.showToast({ title: "演示版:催办消息未发送", icon: "none" });
+}
+
+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" });
+    },
+  });
+}
+
+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 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);
+      }
+    },
+  });
+}
+
+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: #ee6666;
+}
+.progress-bar {
+  margin-top: 18rpx;
+  height: 12rpx;
+  border-radius: 6rpx;
+  background: #edf0f2;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #f08a8a, #ee6666);
+  border-radius: 6rpx;
+}
+.progress-tip {
+  display: block;
+  margin-top: 14rpx;
+  color: #8b939f;
+  font-size: 23rpx;
+}
+.section-title {
+  padding: 28rpx 28rpx 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+.section-more {
+  font-weight: 400;
+  font-size: 22rpx;
+  color: #8b939f;
+}
+.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;
+  flex-shrink: 0;
+}
+.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-name {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #1f2329;
+}
+.material-desc {
+  display: block;
+  margin-top: 6rpx;
+  font-size: 22rpx;
+}
+.material-row > .status-tag {
+  flex-shrink: 0;
+  margin-top: 8rpx;
+  margin-left: 12rpx;
+}
+.empty-tip {
+  padding: 32rpx 28rpx;
+  font-size: 22rpx;
+}
+.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;
+}
+.asset-chip.is-returned {
+  background: #eaf8ef;
+  color: #07893f;
+}
+.asset-chip.is-pending {
+  background: #fff2df;
+  color: #d9780b;
+}
+.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;
+}
+.ghost-btn {
+  height: 88rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+  background: #fff;
+  color: #4b5563;
+  border: 1rpx solid #d7dce2;
+}
+.primary-btn {
+  height: 88rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+  background: #ee6666;
+  color: #fff;
+}
+.primary-btn.full {
+  flex: 1;
+}
+</style>