Explorar o código

feat(approval): 审批中心接入服务端接口,统一待办角标来源

- 新增 src/api/approval.js:封装审批中心列表/详情/操作接口
- 新增 src/api/bpm.js:BPM 流程相关接口
- 重写 src/pages/approval/index.vue / detail.vue:列表与详情接入服务端
- AppTabBar / 首页 / 工作台 待办角标统一改为 fetchPendingApprovalCount()
xieyong hai 5 días
pai
achega
4fa299192c

+ 317 - 0
src/api/approval.js

@@ -0,0 +1,317 @@
+import { getServerConfig } from '@/utils/auth'
+import { approvals as mockApprovals } from '@/data/mock'
+import {
+  getApprovalResults,
+  setApprovalResult,
+  setResignLocalState,
+  updateCreatedResignation,
+} from '@/utils/storage'
+import {
+  adaptBpmTask,
+  adaptCcProcess,
+  approveTask,
+  getCcProcessPage,
+  getDoneTaskPage,
+  getProcessInstance,
+  getTodoTaskPage,
+  rejectTask,
+} from '@/api/bpm'
+import { loadResignationDetail } from '@/api/resign'
+
+export const APPROVAL_TAB_TODO = '待我审批'
+export const APPROVAL_TAB_DONE = '我已审批'
+export const APPROVAL_TAB_CC = '抄送我的'
+export const APPROVAL_TABS = [APPROVAL_TAB_TODO, APPROVAL_TAB_DONE, APPROVAL_TAB_CC]
+export const RESIGN_BUSINESS_TYPE = '离职申请'
+
+// 缓存最近一次列表结果,详情页按 id 查找时优先命中,避免重复请求
+const listCache = new Map()
+
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+function isResignType(text) {
+  return String(text || '').includes('离职')
+}
+
+// 服务端任务不一定回填 businessType,这里把流程名 / 移动端路由一起作为兜底判断
+function isResignItem(item = {}) {
+  return [item.type, item.businessName, item.processName, item.viewRouter].some(
+    (text) => isResignType(text) || String(text || '').toLowerCase().includes('resign'),
+  )
+}
+
+function withStatus(item) {
+  const status = item.result?.status || '待审批'
+  return {
+    ...item,
+    statusLabel: status,
+    statusClass:
+      status === '已同意'
+        ? 'status-approved'
+        : status === '待审批' || status === '已抄送'
+          ? 'status-pending'
+          : 'status-rejected',
+  }
+}
+
+// ============ 演示模式 ============
+
+async function demoApprovalItems() {
+  const results = getApprovalResults()
+  const base = mockApprovals.map((x) => {
+    const result = results[x.id] || null
+    return {
+      id: x.id,
+      source: 'demo',
+      type: x.type,
+      applicant: x.applicant,
+      avatar: x.avatar,
+      dept: x.dept,
+      position: '',
+      summary: x.summary,
+      reason: x.reason || '',
+      days: x.days || '',
+      color: x.color || '#5b8ff9',
+      time: x.time || '',
+      businessId: x.businessId || '',
+      processInstanceId: '',
+      result: result
+        ? {
+            status: result.status,
+            comment: result.comment || '',
+            handledAt: result.handledAt || '',
+          }
+        : null,
+      raw: x,
+    }
+  })
+  const enriched = await Promise.all(base.map(withResignation))
+  return enriched.map(withStatus)
+}
+
+function filterByTab(list, tab) {
+  if (tab === APPROVAL_TAB_TODO) return list.filter((x) => !x.result)
+  if (tab === APPROVAL_TAB_DONE) return list.filter((x) => x.result)
+  return []
+}
+
+// ============ 服务器模式 ============
+
+// 任务上没带业务主键时,回流程实例里找(PC 端发起流程时写入 businessId / 流程变量)
+async function resolveBusiness(item) {
+  if (item.businessId && item.applicant !== '—') return item
+  if (!item.processInstanceId) return item
+  try {
+    const instance = await getProcessInstance(item.processInstanceId)
+    const variables = instance?.variables || {}
+    const businessId = item.businessId || instance?.businessId || variables.businessId || ''
+    const applicant =
+      item.applicant && item.applicant !== '—'
+        ? item.applicant
+        : instance?.startUserNickname || variables.businessName || '—'
+    return {
+      ...item,
+      businessId,
+      processName: item.processName || instance?.name || variables.businessName || '',
+      businessCode: item.businessCode || instance?.businessCode || variables.businessCode || '',
+      applicant,
+      avatar: String(applicant || '审').charAt(0),
+      dept: item.dept || variables.departmentName || '',
+      position: item.position || variables.positionName || '',
+      type: isResignType(item.type) || !variables.businessType ? item.type : variables.businessType,
+    }
+  } catch (error) {
+    return item
+  }
+}
+
+// 离职类审批:补全离职单信息,便于审批人在手机上直接看单据
+async function withResignation(item) {
+  if (!isResignItem(item) || !item.businessId) return item
+  try {
+    const resignation = await loadResignationDetail(item.businessId)
+    if (!resignation) return item
+    const applicant =
+      item.applicant && item.applicant !== '—' ? item.applicant : resignation.name || '—'
+    return {
+      ...item,
+      resignation,
+      applicant,
+      avatar: String(applicant || '离').charAt(0),
+      dept: item.dept || resignation.department || '',
+      position: item.position || resignation.position || '',
+      businessCode: resignation.resignationNo || item.businessCode || '',
+      processName: item.processName || resignation.type || RESIGN_BUSINESS_TYPE,
+      summary: `${resignation.type || RESIGN_BUSINESS_TYPE} · 预计离职 ${
+        resignation.expectedLeaveDate || '—'
+      }`,
+      reason: resignation.reason || item.reason,
+      days: item.days || `${resignation.handover?.items?.length || 0} 项交接`,
+    }
+  } catch (error) {
+    return item
+  }
+}
+
+async function toServerItem(vo, done) {
+  const task = adaptBpmTask(vo)
+  const base = {
+    id: task.taskId,
+    source: 'server',
+    taskId: task.taskId,
+    processInstanceId: task.processInstanceId,
+    processName: task.processName,
+    businessId: task.businessId,
+    businessCode: task.businessCode,
+    businessName: task.businessName,
+    type: task.businessType || task.processName,
+    applicant: task.applicant || '—',
+    avatar: String(task.applicant || '审').charAt(0),
+    dept: task.department,
+    position: task.position,
+    taskName: task.name,
+    viewRouter: task.viewRouter,
+    handleRouter: task.handleRouter,
+    summary: [task.businessType, task.businessCode || task.businessName]
+      .filter(Boolean)
+      .join(' · '),
+    reason: '',
+    days: '',
+    color: '#5b8ff9',
+    time: task.createTime,
+    result: done
+      ? {
+          status: task.resultLabel || '已同意',
+          comment: task.reason || '',
+          handledAt: task.handledAt,
+        }
+      : null,
+    raw: vo,
+  }
+  return withStatus(await withResignation(await resolveBusiness(base)))
+}
+
+// 抄送我的是只读列表,不带审批动作
+function toCcItem(vo) {
+  const cc = adaptCcProcess(vo)
+  return withStatus({
+    id: `cc-${cc.id}`,
+    source: 'server',
+    ccId: cc.id,
+    processInstanceId: cc.processInstanceId,
+    processName: cc.processName,
+    businessId: '',
+    businessCode: '',
+    type: cc.processName || '流程抄送',
+    applicant: cc.applicant || cc.creatorName || '—',
+    avatar: String(cc.applicant || cc.creatorName || '抄').charAt(0),
+    dept: '',
+    position: '',
+    taskName: cc.taskName,
+    summary: [cc.processName, cc.taskName].filter(Boolean).join(' · '),
+    reason: cc.reason,
+    days: '',
+    color: '#5b8ff9',
+    time: cc.createTime,
+    readonly: true,
+    result: { status: '已抄送', comment: cc.reason || '', handledAt: cc.createTime },
+    raw: vo,
+  })
+}
+
+async function serverApprovalItems(tab) {
+  if (tab === APPROVAL_TAB_CC) {
+    const page = await getCcProcessPage()
+    return page.list.map(toCcItem)
+  }
+  const page = tab === APPROVAL_TAB_TODO ? await getTodoTaskPage() : await getDoneTaskPage()
+  const done = tab !== APPROVAL_TAB_TODO
+  return Promise.all(page.list.map((vo) => toServerItem(vo, done)))
+}
+
+// ============ 对外统一入口 ============
+
+export async function loadApprovalList(tab = APPROVAL_TAB_TODO) {
+  if (!isServerMode()) {
+    const list = filterByTab(await demoApprovalItems(), tab)
+    listCache.set(tab, list)
+    return { list, source: 'demo' }
+  }
+
+
+  const list = await serverApprovalItems(tab)
+  listCache.set(tab, list)
+  return { list, source: 'api' }
+}
+
+export async function loadApprovalDetail(id) {
+  if (!id) return null
+  for (const cached of listCache.values()) {
+    const hit = cached.find((x) => String(x.id) === String(id))
+    if (hit) return hit
+  }
+
+  if (!isServerMode()) {
+    return (await demoApprovalItems()).find((x) => String(x.id) === String(id)) || null
+  }
+
+  // 缓存未命中(例如从消息直达):待办 / 已办各拉一次再匹配
+  const [todo, done] = await Promise.all([
+    serverApprovalItems(APPROVAL_TAB_TODO),
+    serverApprovalItems(APPROVAL_TAB_DONE),
+  ])
+  return [...todo, ...done].find((x) => String(x.id) === String(id)) || null
+}
+
+export async function actApproveApproval(item, comment = '') {
+  if (!item?.id) throw new Error('缺少审批主键')
+  const reason = String(comment || '').trim() || '同意'
+  if (isServerMode()) {
+    await approveTask({ id: item.taskId || item.id, reason })
+    return true
+  }
+  setApprovalResult(item.id, '已同意', reason)
+  await syncDemoResignation(item, true, reason)
+  return true
+}
+
+export async function actRejectApproval(item, comment = '') {
+  if (!item?.id) throw new Error('缺少审批主键')
+  const reason = String(comment || '').trim() || '申请信息不完整'
+  if (isServerMode()) {
+    await rejectTask({ id: item.taskId || item.id, reason })
+    return true
+  }
+  setApprovalResult(item.id, '已驳回', reason)
+  await syncDemoResignation(item, false, reason)
+  return true
+}
+
+// 演示模式:把审批结果回写到离职单,使离职流程能继续往下走(生成交接单 / 归档)
+async function syncDemoResignation(item, approved, comment) {
+  const id = item.businessId
+  if (!id) return
+  // 本地不存在对应离职单时(例如纯 mock 待办)不回写,避免产生孤立数据
+  const existing = await loadResignationDetail(id)
+  if (!existing) return
+  const patch = approved
+    ? { status: 'approved', stage: 'handover', approvalStatus: 'APPROVED', hrComment: comment }
+    : { status: 'rejected', stage: 'submit', approvalStatus: 'REJECTED', hrComment: comment }
+  setResignLocalState(id, patch)
+  updateCreatedResignation(id, patch)
+}
+
+/** 待办数量(首页 / 工作台 / TabBar 角标) */
+export async function fetchPendingApprovalCount() {
+  if (!isServerMode()) {
+    return (await demoApprovalItems()).filter((x) => !x.result).length
+  }
+  try {
+    const page = await getTodoTaskPage({ pageSize: 1 })
+    return page.total
+  } catch (error) {
+    return 0
+  }
+}

+ 185 - 0
src/api/bpm.js

@@ -0,0 +1,185 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+
+// ============ 模式判断 ============
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+export function isBpmServerMode() {
+  return isServerMode()
+}
+
+function serverPath() {
+  const base = getApiBaseUrl()
+  if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
+  return base
+}
+
+// BPM 任务结果枚举(后端 bpm_task.result:1 通过 / 2 不通过 / 3 取消 / 4 退回)
+const TASK_RESULT_LABEL = {
+  1: '已同意',
+  2: '已驳回',
+  3: '已取消',
+  4: '已退回',
+}
+
+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
+  return []
+}
+
+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
+  }, {})
+}
+
+// ============ 接口(与 PC 端 /bpm/* 约定一致) ============
+
+/** 待办任务分页 POST /bpm/task/todo-page(body 见 BpmTaskTodoPageReqVO) */
+export async function getTodoTaskPage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/bpm/task/todo-page`,
+    method: 'POST',
+    data: compact({
+      pageNo: params.pageNo || 1,
+      pageSize: params.pageSize || 20,
+      name: params.name,
+      processType: params.processType,
+    }),
+  })
+  const data = res.data || {}
+  return { list: asList(data), total: Number(data?.count ?? data?.total ?? asList(data).length) }
+}
+
+/** 已办任务分页 POST /bpm/task/done-page(body 见 BpmTaskDonePageReqVO) */
+export async function getDoneTaskPage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/bpm/task/done-page`,
+    method: 'POST',
+    data: compact({
+      pageNo: params.pageNo || 1,
+      pageSize: params.pageSize || 20,
+      name: params.name,
+      result: params.result,
+      keyword: params.keyword,
+      startUserName: params.startUserName,
+    }),
+  })
+  const data = res.data || {}
+  return { list: asList(data), total: Number(data?.count ?? data?.total ?? asList(data).length) }
+}
+
+/** 抄送我的流程分页 GET /bpm/process-instance/cc/my-page */
+export async function getCcProcessPage(params = {}) {
+  const res = await request({
+    url: `${serverPath()}/bpm/process-instance/cc/my-page`,
+    method: 'GET',
+    data: compact({
+      pageNo: params.pageNo || 1,
+      pageSize: params.pageSize || 20,
+      processInstanceName: params.processInstanceName,
+    }),
+  })
+  const data = res.data || {}
+  return { list: asList(data), total: Number(data?.count ?? data?.total ?? asList(data).length) }
+}
+
+/** 流程实例详情 GET /bpm/process-instance/get?id= */
+export async function getProcessInstance(id) {
+  if (!id) return null
+  const res = await request({
+    url: `${serverPath()}/bpm/process-instance/get?id=${encodeURIComponent(id)}`,
+    method: 'GET',
+  })
+  return res.data || null
+}
+
+/** 流程实例下的全部任务(审批记录) GET /bpm/task/list-by-process-instance-id */
+export async function getTaskListByProcessInstanceId(processInstanceId) {
+  if (!processInstanceId) return []
+  const res = await request({
+    url: `${serverPath()}/bpm/task/list-by-process-instance-id?processInstanceId=${encodeURIComponent(
+      processInstanceId,
+    )}`,
+    method: 'GET',
+  })
+  return asList(res.data)
+}
+
+/** 审批通过 PUT /bpm/task/approve */
+export async function approveTask({ id, reason = '' } = {}) {
+  if (!id) throw new Error('缺少任务主键')
+  const res = await request({
+    url: `${serverPath()}/bpm/task/approve`,
+    method: 'PUT',
+    data: compact({ id, reason }),
+  })
+  return res.data
+}
+
+/** 审批驳回 PUT /bpm/task/reject(原因必填) */
+export async function rejectTask({ id, reason } = {}) {
+  if (!id) throw new Error('缺少任务主键')
+  const res = await request({
+    url: `${serverPath()}/bpm/task/reject`,
+    method: 'PUT',
+    data: compact({ id, reason }),
+  })
+  return res.data
+}
+
+// ============ VO → 页面形状 ============
+
+/**
+ * 归一化 BPM 任务,兼容不同字段命名:
+ *   - 申请人 / 部门 / 岗位优先取流程变量(PC 端发起时写入 businessName / departmentName / positionName)
+ *   - 业务主键取 businessId,其次取流程变量里的 businessId
+ *   - done 列表里 result 为 2 视为驳回,其余视为同意
+ */
+export function adaptBpmTask(vo = {}) {
+  const instance = vo.processInstance || {}
+  const resultCode = Number(vo.result)
+
+  return {
+    taskId: vo.id,
+    processInstanceId: instance.id || vo.processInstanceId || '',
+    processName: instance.name || '',
+    startUserId: instance.startUserId,
+    businessId: vo.businessId || '',
+    businessCode: vo.businessCode || '',
+    businessName: vo.businessName || '',
+    businessType: vo.businessType || '',
+    applicant: instance.startUserNickname || '',
+    department: '',
+    position: '',
+    name: vo.name || '',
+    // 后端为每个任务下发了 PC / 移动端路由,移动端优先取 mini*
+    viewRouter: vo.miniViewRouter || vo.pcViewRouter || '',
+    handleRouter: vo.miniHandleRouter || vo.pcHandleRouter || '',
+    reason: vo.reason || '',
+    createTime: vo.createTime || '',
+    handledAt: vo.endTime || vo.updateTime || '',
+    resultLabel: TASK_RESULT_LABEL[resultCode] || '',
+    raw: vo,
+  }
+}
+
+/** 抄送流程 VO → 列表条目(抄送只读,不参与审批操作) */
+export function adaptCcProcess(vo = {}) {
+  return {
+    id: vo.id,
+    processInstanceId: vo.processInstanceId || '',
+    processName: vo.processInstanceName || '',
+    applicant: vo.startUserNickname || '',
+    creatorName: vo.creatorNickname || '',
+    reason: vo.reason || '',
+    taskName: vo.taskName || '',
+    createTime: vo.createTime || '',
+    raw: vo,
+  }
+}

+ 11 - 3
src/components/AppTabBar.vue

@@ -11,10 +11,18 @@
 </template>
 
 <script setup>
-import { ref } from 'vue'
-import { getApprovalResults } from '@/utils/storage'
+import { onMounted, ref } from 'vue'
+import { fetchPendingApprovalCount } from '@/api/approval'
 const props = defineProps({ active: { type: String, default: 'home' } })
-const pendingCount = ref(Math.max(0, 3 - Object.keys(getApprovalResults()).length))
+// 待办角标:与首页 / 工作台同源,统一走审批中心
+const pendingCount = ref(0)
+onMounted(async () => {
+  try {
+    pendingCount.value = await fetchPendingApprovalCount()
+  } catch (error) {
+    pendingCount.value = 0
+  }
+})
 const items = [
   { key: 'home', label: '首页', icon: '⌂', url: '/pages/home/index' },
   { key: 'work', label: '工作台', icon: '▦', url: '/pages/work/index', badge: pendingCount },

+ 198 - 80
src/pages/approval/detail.vue

@@ -1,103 +1,207 @@
 <template>
   <view class="page-no-tab detail-page">
-    <view class="detail-head"
-      ><view class="avatar detail-avatar">{{ item.avatar }}</view
-      ><text class="detail-title">{{ item.applicant }} 的{{ item.type }}</text
-      ><text
-        class="status-tag"
-        :class="
-          result?.status === '已同意'
-            ? 'status-approved'
-            : result?.status === '已驳回'
-              ? 'status-rejected'
-              : 'status-pending'
-        "
-        >{{ result?.status || "待审批" }}</text
-      ></view
+    <view v-if="!item" class="empty"
+      ><text>未找到该审批任务</text></view
     >
-    <view class="info card section"
-      ><view class="info-row"
-        ><text>申请人</text
-        ><text>{{ item.applicant }}({{ item.dept }})</text></view
-      ><view class="info-row"
-        ><text>申请内容</text><text>{{ item.summary }}</text></view
-      ><view v-if="item.days" class="info-row"
-        ><text>时长</text><text>{{ item.days }}</text></view
-      ><view class="info-row reason"
-        ><text>申请事由</text><text>{{ item.reason }}</text></view
-      ></view
-    >
-    <view class="flow-card card section"
-      ><text class="block-title">审批流程</text
-      ><view class="flow-item"
-        ><view class="avatar flow-avatar">{{ item.avatar }}</view
-        ><view class="flow-main"
-          ><text>{{ item.applicant }} · 发起申请</text
-          ><text>{{ item.time }}</text></view
-        ><text class="success">已提交</text></view
-      ><view class="flow-vline"></view
-      ><view class="flow-item"
-        ><view class="avatar flow-avatar manager">宋</view
-        ><view class="flow-main"
-          ><text>宋慧敏 · 直属主管</text
-          ><text>{{ result?.handledAt || "等待处理" }}</text></view
-        ><text
-          :class="
-            result?.status === '已同意'
-              ? 'success'
-              : result?.status === '已驳回'
-                ? 'danger'
-                : 'warning'
-          "
-          >{{ result?.status || "审批中" }}</text
+    <template v-else>
+      <view class="detail-head"
+        ><view class="avatar detail-avatar">{{ item.avatar }}</view
+        ><text class="detail-title">{{ item.applicant }} 的{{ item.type }}</text
+        ><text class="status-tag" :class="item.statusClass">{{
+          item.statusLabel
+        }}</text></view
+      >
+      <view class="info card section"
+        ><view class="info-row"
+          ><text>申请人</text
+          ><text
+            >{{ item.applicant
+            }}<text v-if="item.dept">({{ item.dept }})</text></text
+          ></view
+        ><view class="info-row"
+          ><text>申请内容</text><text>{{ item.summary }}</text></view
+        ><view v-if="item.days" class="info-row"
+          ><text>时长</text><text>{{ item.days }}</text></view
+        ><view v-if="item.reason" class="info-row reason"
+          ><text>申请事由</text><text>{{ item.reason }}</text></view
         ></view
-      ><view v-if="result?.comment" class="approval-comment"
-        >审批意见:{{ result.comment }}</view
-      ></view
-    >
-    <view v-if="!result" class="bottom-action safe-bottom"
-      ><button class="reject" @click="handle(false)">驳回</button
-      ><button class="approve" @click="handle(true)">同意</button></view
-    >
+      >
+      <view v-if="item.resignation" class="resign-card card section"
+        ><text class="block-title">离职单信息</text
+        ><view class="info-row"
+          ><text>离职单号</text
+          ><text>{{
+            item.resignation.resignationNo || item.businessId || "—"
+          }}</text></view
+        ><view class="info-row"
+          ><text>离职类型</text><text>{{ item.resignation.type || "—" }}</text></view
+        ><view class="info-row"
+          ><text>预计离职日</text
+          ><text>{{ item.resignation.expectedLeaveDate || "—" }}</text></view
+        ><view class="info-row"
+          ><text>交接进度</text><text>{{ handoverText }}</text></view
+        ><view class="info-row"
+          ><text>所属部门</text
+          ><text>{{ item.resignation.department || item.dept || "—" }}</text></view
+        ><view class="info-row reason"
+          ><text>离职原因</text
+          ><text>{{ item.resignation.reason || "—" }}</text></view
+        ></view
+      >
+      <view class="flow-card card section"
+        ><text class="block-title">审批流程</text
+        ><view class="flow-item"
+          ><view class="avatar flow-avatar">{{ item.avatar }}</view
+          ><view class="flow-main"
+            ><text>{{ item.applicant }} · 发起申请</text
+            ><text>{{ item.time || "—" }}</text></view
+          ><text class="success">已提交</text></view
+        >
+        <template v-if="flowRecords.length"
+          ><view v-for="r in flowRecords" :key="r.key" class="flow-block"
+            ><view class="flow-vline"></view
+            ><view class="flow-item"
+              ><view class="avatar flow-avatar manager">{{ r.avatar }}</view
+              ><view class="flow-main"
+                ><text>{{ r.title }}</text><text>{{ r.time }}</text></view
+              ><text :class="r.cls">{{ r.label }}</text></view
+            ><view v-if="r.comment" class="approval-comment"
+              >审批意见:{{ r.comment }}</view
+            ></view
+          ></template
+        >
+        <template v-else
+          ><view class="flow-vline"></view
+          ><view class="flow-item"
+            ><view class="avatar flow-avatar manager">{{ managerAvatar }}</view
+            ><view class="flow-main"
+              ><text>直属主管 · 审批</text
+              ><text>{{ item.result?.handledAt || "等待处理" }}</text></view
+            ><text :class="resultClass">{{ item.statusLabel }}</text></view
+          ></template
+        >
+        <view v-if="item.result?.comment" class="approval-comment"
+          >审批意见:{{ item.result.comment }}</view
+        ></view
+      >
+      <view v-if="!item.result" class="bottom-action safe-bottom"
+        ><button class="reject" @click="handle(false)">驳回</button
+        ><button class="approve" @click="handle(true)">同意</button></view
+      >
+    </template>
   </view>
 </template>
 <script setup>
-import { ref } from "vue";
+import { computed, ref } from "vue";
 import { onLoad, onShow } from "@dcloudio/uni-app";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
-import { approvals } from "@/data/mock";
-import { getApprovalResults, setApprovalResult } from "@/utils/storage";
-const item = ref(approvals[0]);
+import { back } from "@/utils/router";
+import { adaptBpmTask, getTaskListByProcessInstanceId, isBpmServerMode } from "@/api/bpm";
+import {
+  actApproveApproval,
+  actRejectApproval,
+  loadApprovalDetail,
+} from "@/api/approval";
+
 useAuthGuard();
-const result = ref(null);
-function sync() {
-  result.value = getApprovalResults()[item.value.id] || null;
-}
-onLoad((o) => {
-  item.value = approvals.find((x) => x.id == o.id) || approvals[0];
-  sync();
+const item = ref(null);
+const records = ref([]);
+const approvalId = ref("");
+
+const managerAvatar = computed(() => {
+  const name = item.value?.manager || "主管";
+  return String(name).charAt(0);
 });
-onShow(sync);
+
+const handoverText = computed(() => {
+  const handover = item.value?.resignation?.handover;
+  if (!handover) return "未生成交接单";
+  const total = handover.items?.length || 0;
+  const done = handover.items?.filter((x) => x.done).length || 0;
+  return total ? `${done}/${total} 项已完成(${handover.progress}%)` : `${handover.progress}%`;
+});
+
+const resultClass = computed(() => {
+  const status = item.value?.result?.status || item.value?.statusLabel;
+  if (status === "已同意") return "success";
+  if (status === "待审批") return "warning";
+  return "danger";
+});
+
+const flowRecords = computed(() =>
+  records.value.map((r, index) => {
+    const vo = r.raw || {};
+    const assignee = vo.assigneeUser?.nickname || vo.assignee || "";
+    return {
+      key: `${r.taskId || index}`,
+      avatar: String(assignee || "审").charAt(0),
+      title: `${assignee || "审批人"} · ${r.name || "审批"}`,
+      time: r.handledAt || r.createTime || "—",
+      label: r.resultLabel || (r.handledAt ? "已处理" : "审批中"),
+      cls:
+        r.resultLabel === "已同意"
+          ? "success"
+          : r.resultLabel
+            ? "danger"
+            : "warning",
+      comment: vo.reason || vo.comment || "",
+    };
+  }),
+);
+
+async function load() {
+  const detail = await loadApprovalDetail(approvalId.value);
+  item.value = detail || null;
+  await loadRecords();
+}
+
+async function loadRecords() {
+  const pid = item.value?.processInstanceId;
+  if (!pid || !isBpmServerMode()) {
+    records.value = [];
+    return;
+  }
+  try {
+    const list = await getTaskListByProcessInstanceId(pid);
+    records.value = list.map((x) => adaptBpmTask(x));
+  } catch (e) {
+    records.value = [];
+  }
+}
+
 function handle(ok) {
   uni.showModal({
     title: ok ? "确认同意" : "确认驳回",
     editable: !ok,
     placeholderText: "请输入审批意见",
-    success: (r) => {
-      if (r.confirm) {
-        setApprovalResult(
-          item.value.id,
-          ok ? "已同意" : "已驳回",
-          r.content || (ok ? "同意" : "申请信息不完整"),
-        );
-        sync();
+    success: async (r) => {
+      if (!r.confirm) return;
+      uni.showLoading({ title: "提交中", mask: true });
+      try {
+        if (ok) {
+          await actApproveApproval(item.value, r.content || "");
+        } else {
+          await actRejectApproval(item.value, r.content || "申请信息不完整");
+        }
+        uni.hideLoading();
         uni.showToast({ title: ok ? "已同意" : "已驳回", icon: "success" });
+        setTimeout(() => back("/pages/approval/index"), 600);
+      } catch (e) {
+        uni.hideLoading();
+        uni.showToast({ title: e?.message || "操作失败", icon: "none" });
       }
     },
   });
 }
-</script>
-<style scoped>
+
+onLoad(async (o) => {
+  approvalId.value = o?.id || "";
+  await load();
+});
+onShow(() => {
+  if (approvalId.value && !item.value) load();
+});
+</script><style scoped>
 .detail-page {
   padding-bottom: 150rpx;
 }
@@ -122,6 +226,15 @@ function handle(ok) {
 .info {
   padding: 12rpx 28rpx;
 }
+.resign-card {
+  padding: 28rpx 28rpx 12rpx;
+}
+.resign-card .block-title {
+  display: block;
+  margin-bottom: 12rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
 .info-row {
   min-height: 92rpx;
   display: flex;
@@ -191,6 +304,11 @@ function handle(ok) {
   color: #6f7782;
   font-size: 23rpx;
 }
+.empty {
+  padding-top: 250rpx;
+  text-align: center;
+  color: #a0a7b0;
+}
 .bottom-action {
   position: fixed;
   left: 0;
@@ -228,4 +346,4 @@ function handle(ok) {
     transform: translateX(-50%);
   }
 }
-</style>
+</style>

+ 99 - 51
src/pages/approval/index.vue

@@ -6,11 +6,9 @@
         :key="t"
         class="tab"
         :class="{ active: tab === t }"
-        @click="tab = t"
+        @click="switchTab(t)"
         >{{ t
-        }}<text v-if="t === '待我审批' && pending.length">{{
-          pending.length
-        }}</text></view
+        }}<text v-if="t === tabTodo && pendingCount">{{ pendingCount }}</text></view
       ></view
     >
     <view v-if="current.length" class="approval-list">
@@ -18,27 +16,20 @@
         v-for="item in current"
         :key="item.id"
         class="approval card press"
-        @click="go('/pages/approval/detail?id=' + item.id)"
+        @click="openDetail(item)"
       >
         <view class="approval-top"
           ><view class="avatar list-avatar">{{ item.avatar }}</view
           ><view class="who"
             ><text
               ><b>{{ item.applicant }}</b> 的{{ item.type }}</text
-            ><text>{{ item.dept }} · {{ item.time }}</text></view
-          ><text
-            class="status-tag"
-            :class="
-              item.result?.status === '已同意'
-                ? 'status-approved'
-                : item.result?.status === '已驳回'
-                  ? 'status-rejected'
-                  : 'status-pending'
-            "
-            >{{ item.result?.status || "待审批" }}</text
-          ></view
+            ><text>{{ item.time ? item.dept + " · " + item.time : item.dept }}</text></view
+          ><text class="status-tag" :class="item.statusClass">{{ item.statusLabel }}</text></view
+        >
+        <view class="summary"
+          ><text>{{ item.summary }}</text
+          ><text v-if="item.days" class="summary-extra">{{ item.days }}</text></view
         >
-        <view class="summary">{{ item.summary }}</view>
         <view v-if="!item.result" class="approval-actions"
           ><button class="reject" @click.stop="action(item, false)">驳回</button
           ><button class="approve" @click.stop="action(item, true)">
@@ -46,14 +37,22 @@
           </button></view
         >
         <view v-else class="handled"
-          >处理时间:{{ item.result.handledAt }}</view
+          >{{ item.readonly ? "抄送时间:" : "处理时间:" }}{{
+            item.result.handledAt || "—"
+          }}</view
         >
       </view>
     </view>
     <view v-else class="empty"
-      ><view>✓</view
+      ><view>{{ error ? "!" : "" }}</view
       ><text>{{
-        tab === "待我审批" ? "所有审批均已处理" : "暂无相关审批记录"
+        error
+          ? error
+          : loading || !loaded
+            ? "加载中…"
+            : tab === tabTodo
+              ? "所有审批均已处理"
+              : "暂无相关审批记录"
       }}</text></view
     >
   </view>
@@ -62,45 +61,86 @@
 import { computed, ref } from "vue";
 import { onShow } from "@dcloudio/uni-app";
 import { useAuthGuard } from "@/hooks/useAuthGuard";
-import { approvals } from "@/data/mock";
 import { go } from "@/utils/router";
-import { getApprovalResults, setApprovalResult } from "@/utils/storage";
-const tabs = ["待我审批", "我已审批", "抄送我的"];
+import {
+  APPROVAL_TAB_TODO,
+  APPROVAL_TABS,
+  actApproveApproval,
+  actRejectApproval,
+  fetchPendingApprovalCount,
+  loadApprovalList,
+} from "@/api/approval";
+
 useAuthGuard();
-const tab = ref("待我审批");
-const results = ref({});
-onShow(() => (results.value = getApprovalResults()));
-const enriched = computed(() =>
-  approvals.map((x) => ({ ...x, result: results.value[x.id] })),
-);
-const pending = computed(() => enriched.value.filter((x) => !x.result));
-const handled = computed(() => enriched.value.filter((x) => x.result));
-const current = computed(() =>
-  tab.value === "待我审批"
-    ? pending.value
-    : tab.value === "我已审批"
-      ? handled.value
-      : [],
-);
+const tabs = APPROVAL_TABS;
+const tabTodo = APPROVAL_TAB_TODO;
+const tab = ref(APPROVAL_TAB_TODO);
+const items = ref([]);
+const pendingCount = ref(0);
+const loading = ref(false);
+const loaded = ref(false);
+const error = ref("");
+const current = computed(() => items.value);
+
+async function load() {
+  loading.value = true;
+  error.value = "";
+  try {
+    const res = await loadApprovalList(tab.value);
+    items.value = res.list || [];
+  } catch (e) {
+    items.value = [];
+    error.value = e?.message || "审批列表加载失败";
+  } finally {
+    loading.value = false;
+    loaded.value = true;
+  }
+  refreshBadge();
+}
+
+async function refreshBadge() {
+  try {
+    pendingCount.value = await fetchPendingApprovalCount();
+  } catch (e) {
+    pendingCount.value = 0;
+  }
+}
+
+function switchTab(next) {
+  if (tab.value === next) return;
+  tab.value = next;
+  items.value = [];
+  loaded.value = false;
+  load();
+}
+
+function openDetail(item) {
+  go("/pages/approval/detail?id=" + item.id);
+}
+
 function action(item, ok) {
   uni.showModal({
     title: ok ? "确认同意" : "确认驳回",
     content: `确认${ok ? "同意" : "驳回"}${item.applicant}的${item.type}?`,
-    success: (r) => {
-      if (r.confirm) {
-        setApprovalResult(
-          item.id,
-          ok ? "已同意" : "已驳回",
-          ok ? "同意" : "信息不完整,请补充",
-        );
-        results.value = getApprovalResults();
+    success: async (r) => {
+      if (!r.confirm) return;
+      uni.showLoading({ title: "提交中", mask: true });
+      try {
+        if (ok) await actApproveApproval(item);
+        else await actRejectApproval(item, "信息不完整,请补充");
+        uni.hideLoading();
         uni.showToast({ title: ok ? "已同意" : "已驳回", icon: "success" });
+        load();
+      } catch (e) {
+        uni.hideLoading();
+        uni.showToast({ title: e?.message || "操作失败", icon: "none" });
       }
     },
   });
 }
-</script>
-<style scoped>
+
+onShow(load);
+</script><style scoped>
 .tabs {
   height: 92rpx;
   display: flex;
@@ -165,7 +205,7 @@ function action(item, ok) {
   display: block;
   font-size: 27rpx;
 }
-.who text + text {
+.who > text + text {
   margin-top: 8rpx;
   color: #a0a7b0;
   font-size: 21rpx;
@@ -178,6 +218,14 @@ function action(item, ok) {
   color: #4b5563;
   font-size: 25rpx;
 }
+.summary text {
+  display: block;
+}
+.summary .summary-extra {
+  margin-top: 10rpx;
+  color: #8b939f;
+  font-size: 22rpx;
+}
 .approval-actions {
   display: flex;
   justify-content: flex-end;
@@ -224,4 +272,4 @@ function action(item, ok) {
   display: block;
   margin-top: 25rpx;
 }
-</style>
+</style>

+ 13 - 6
src/pages/home/index.vue

@@ -110,7 +110,8 @@ import { computed, reactive, ref } from "vue";
 import { onShow } from "@dcloudio/uni-app";
 import { quickActions, notices } from "@/data/mock";
 import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { getApprovalResults, getCurrentUser } from "@/utils/storage";
+import { getCurrentUser } from "@/utils/storage";
+import { fetchPendingApprovalCount } from "@/api/approval";
 import { go } from "@/utils/router";
 import {
   queryAttendanceRule,
@@ -119,7 +120,7 @@ import {
 } from "@/api/attendance";
 import { getLocationWithFallback } from "@/utils/location";
 
-const pendingCount = ref(3);
+const pendingCount = ref(0);
 const currentUser = useCurrentUser();
 // 班次信息:从考勤规则 + 班次详情拉(启动时一次性缓存)
 const shiftInfo = reactive({
@@ -218,13 +219,19 @@ async function loadDaily() {
 }
 
 onShow(() => {
-  pendingCount.value = Math.max(
-    0,
-    3 - Object.keys(getApprovalResults()).length,
-  );
+  loadPending();
   loadDaily();
 });
 
+// 待办角标:与工作台 / 底部导航(AppTabBar)同源,统一走审批中心
+async function loadPending() {
+  try {
+    pendingCount.value = await fetchPendingApprovalCount();
+  } catch (error) {
+    pendingCount.value = 0;
+  }
+}
+
 function openQuick(item) {
   go(`/pages/apply/form?type=${item.key}`);
 }

+ 11 - 9
src/pages/work/index.vue

@@ -58,16 +58,18 @@ import ModuleIcon from "@/components/ModuleIcon.vue";
 import { computed, ref } from "vue";
 import { onShow } from "@dcloudio/uni-app";
 import { workModules } from "@/data/mock";
-import { getApprovalResults } from "@/utils/storage";
 import { go } from "@/utils/router";
-const pendingCount = ref(3);
-onShow(
-  () =>
-    (pendingCount.value = Math.max(
-      0,
-      3 - Object.keys(getApprovalResults()).length,
-    )),
-);
+import { fetchPendingApprovalCount } from "@/api/approval";
+const pendingCount = ref(0);
+// 待办角标:与首页 / 底部导航(AppTabBar)同源,统一走审批中心
+async function loadPending() {
+  try {
+    pendingCount.value = await fetchPendingApprovalCount();
+  } catch (error) {
+    pendingCount.value = 0;
+  }
+}
+onShow(loadPending);
 const keyword = ref("");
 const displayedModules = computed(() =>
   workModules