Przeglądaj źródła

feat(bx-mobile): complete P3 withdraw, proxy apply, history, and config pages

Align H5 with portal APIs for reimburse withdraw, approval history, applicant/date/title, and UUID-based expense/workflow config using portal user options.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 miesięcy temu
rodzic
commit
96dbd3ccbc

+ 14 - 1
bx-mobile/src/api/index.ts

@@ -31,6 +31,7 @@ export const advanceApi = {
     bxClient.get('/advances/approved-by-me', { params }),
   getById: (id: BxId) => bxClient.get(`/advances/${id}`),
   getApprovals: (id: BxId) => bxClient.get(`/advances/${id}/approvals`),
+  getApprovalHistory: (id: BxId) => bxClient.get(`/advances/${id}/approval-history`),
   create: (data: Record<string, any>) => bxClient.post('/advances/', data),
   update: (id: BxId, data: Record<string, any>) => bxClient.put(`/advances/${id}`, data),
   submit: (id: BxId) => bxClient.post(`/advances/${id}/submit`),
@@ -42,7 +43,17 @@ export const advanceApi = {
 }
 
 export const reimburseApi = {
-  generateTitle: () => bxClient.get('/reimburses/generate-title'),
+  generateTitle: (
+    reimburseDate?: string,
+    excludeApplyId?: BxId,
+    applicantId?: BxId,
+  ) => {
+    const params: Record<string, string> = {}
+    if (reimburseDate) params.reimburseDate = reimburseDate
+    if (excludeApplyId) params.excludeApplyId = String(excludeApplyId)
+    if (applicantId) params.applicantId = String(applicantId)
+    return bxClient.get('/reimburses/generate-title', { params })
+  },
   list: (params?: Record<string, any>) => bxClient.get('/reimburses/', { params }),
   my: (params?: Record<string, any>) => bxClient.get('/reimburses/my', { params }),
   myStats: () => bxClient.get('/reimburses/my/stats'),
@@ -52,10 +63,12 @@ export const reimburseApi = {
     bxClient.get('/reimburses/approved-by-me', { params }),
   getById: (id: BxId) => bxClient.get(`/reimburses/${id}`),
   getApprovals: (id: BxId) => bxClient.get(`/reimburses/${id}/approvals`),
+  getApprovalHistory: (id: BxId) => bxClient.get(`/reimburses/${id}/approval-history`),
   create: (data: Record<string, any>) => bxClient.post('/reimburses/', data),
   update: (id: BxId, data: Record<string, any>) => bxClient.put(`/reimburses/${id}`, data),
   submit: (id: BxId, data?: Record<string, any>) =>
     bxClient.post(`/reimburses/${id}/submit`, data),
+  withdraw: (id: BxId) => bxClient.post(`/reimburses/${id}/withdraw`),
   approve: (id: BxId, data: { approvalOpinion?: string }) =>
     bxClient.post(`/reimburses/${id}/approve`, data),
   reject: (id: BxId, data: { approvalOpinion?: string }) =>

+ 6 - 0
bx-mobile/src/api/portalAdmin.ts

@@ -0,0 +1,6 @@
+import portalClient from './portalClient'
+
+/** 门户用户下拉(与 web-admin fetchUserOptions 同源) */
+export const portalAdminApi = {
+  userOptions: () => portalClient.get('/web/admin/users/options'),
+}

+ 1 - 1
bx-mobile/src/constants/bxMenuCatalog.ts

@@ -141,4 +141,4 @@ export const BX_MENU_CATALOG: BxMenuEntry[] = [
 ]
 
 /** 当前已开放阶段:发版时改为 1 → 2 → 3 */
-export const BX_MENU_CURRENT_PHASE: BxMenuPhase = 2
+export const BX_MENU_CURRENT_PHASE: BxMenuPhase = 3

+ 21 - 0
bx-mobile/src/hooks/useApplicantOptions.ts

@@ -0,0 +1,21 @@
+import { ref } from 'vue'
+import { portalAdminApi } from '@/api/portalAdmin'
+
+export function useApplicantOptions() {
+  const applicantOptions = ref<{ label: string; value: string }[]>([])
+
+  async function loadApplicantOptions() {
+    try {
+      const res: any = await portalAdminApi.userOptions()
+      const data = res?.data ?? res ?? []
+      applicantOptions.value = (Array.isArray(data) ? data : []).map((u: any) => ({
+        value: String(u.value ?? u.id ?? ''),
+        label: String(u.label ?? u.nickName ?? u.realName ?? u.username ?? ''),
+      }))
+    } catch {
+      applicantOptions.value = []
+    }
+  }
+
+  return { applicantOptions, loadApplicantOptions }
+}

+ 19 - 1
bx-mobile/src/router/index.ts

@@ -129,7 +129,25 @@ const routes = [
         component: () => import('@/views/invoice/InvoiceWalletAdminPage.vue'),
         meta: { menuCodes: ['bx:invoiceWallet:admin'] },
       },
-      /* P3 再注册 admin 配置;IAM(users/roles/menus)已移除 */
+      {
+        path: 'admin/expenses',
+        name: 'ExpenseProject',
+        component: () => import('@/views/admin/ExpenseProjectPage.vue'),
+        meta: { menuCodes: ['bx:expenseItem:list'] },
+      },
+      {
+        path: 'admin/wf-advance',
+        name: 'WfAdvance',
+        component: () => import('@/views/admin/WfAdvancePage.vue'),
+        meta: { menuCodes: ['bx:workflowAdvance:list'] },
+      },
+      {
+        path: 'admin/wf-reimburse',
+        name: 'WfReimburse',
+        component: () => import('@/views/admin/WfReimbursePage.vue'),
+        meta: { menuCodes: ['bx:workflowReimburse:list'] },
+      },
+      /* IAM(users/roles/menus)已移除 */
       { path: 'profile', name: 'Profile', component: () => import('@/views/profile/ProfilePage.vue') },
     ],
   },

+ 31 - 24
bx-mobile/src/views/admin/ExpenseProjectPage.vue

@@ -27,9 +27,9 @@
             <!-- Level 1 Header -->
             <div class="tree-head" @click="toggleExpand(item.id)">
               <div style="display: flex; align-items: center; gap: 8px;">
-                <span :class="['tree-node', '', expandedIds.includes(item.id) ? 'expanded' : '']" style="padding: 0;">
+                <span :class="['tree-node', '', expandedIds.includes(String(item.id)) ? 'expanded' : '']" style="padding: 0;">
                   <span class="twisty" style="background: rgba(16, 185, 129, .10); width: 22px; height: 22px; border-radius: 7px; display: flex; align-items: center; justify-content: center;">
-                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--primary-dark)" stroke-width="2" :style="{ transform: expandedIds.includes(item.id) ? 'rotate(90deg)' : '', transition: '.15s' }"><path d="m9 18 6-6-6-6"/></svg>
+                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--primary-dark)" stroke-width="2" :style="{ transform: expandedIds.includes(String(item.id)) ? 'rotate(90deg)' : '', transition: '.15s' }"><path d="m9 18 6-6-6-6"/></svg>
                   </span>
                 </span>
                 <span class="title">{{ item.itemName }}</span>
@@ -41,7 +41,7 @@
             </div>
 
             <!-- Level 1 Details -->
-            <div v-show="expandedIds.includes(item.id)">
+            <div v-show="expandedIds.includes(String(item.id))">
               <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 8px 14px; background: var(--bg);">
                 <div>
                   <div style="font-size: 11px; color: var(--text-muted);">项目编码</div>
@@ -191,7 +191,7 @@ const router = useRouter()
 const expenseTree = ref<any[]>([])
 const loading = ref(false)
 const refreshing = ref(false)
-const expandedIds = ref<number[]>([])
+const expandedIds = ref<string[]>([])
 
 const showForm = ref(false)
 const showParentPicker = ref(false)
@@ -200,29 +200,29 @@ const loadingMap = reactive<Record<string, boolean>>({})
 const dialogMode = ref<'add' | 'addChild' | 'edit'>('add')
 
 const form = reactive({
-  id: 0,
+  id: '' as string,
   itemName: '',
   itemCode: '',
   level: 1,
-  parentId: 0,
+  parentId: '0' as string,
   sortOrder: '1',
   status: 1,
   remark: '',
 })
 
 const statusSwitch = computed({
-  get: () => form.status === 1,
+  get: () => form.status === 1 || form.status === ('1' as any),
   set: (v: boolean) => { form.status = v ? 1 : 0 },
 })
 
-const parentOptions = computed(() => expenseTree.value.filter(r => r.level === 1))
+const parentOptions = computed(() => expenseTree.value.filter((r: any) => Number(r.level) === 1))
 const parentColumns = computed(() =>
-  parentOptions.value.map(p => ({ text: p.itemName, value: p.id }))
+  parentOptions.value.map((p: any) => ({ text: p.itemName, value: String(p.id) })),
 )
 
 const parentDisplay = computed(() => {
-  if (form.parentId === 0) return ''
-  return parentOptions.value.find(p => p.id === form.parentId)?.itemName ?? ''
+  if (!form.parentId || form.parentId === '0') return ''
+  return parentOptions.value.find((p: any) => String(p.id) === form.parentId)?.itemName ?? ''
 })
 
 const dialogTitle = computed(() => {
@@ -231,10 +231,11 @@ const dialogTitle = computed(() => {
   return '编辑项目'
 })
 
-function toggleExpand(id: number) {
-  const idx = expandedIds.value.indexOf(id)
+function toggleExpand(id: string | number) {
+  const sid = String(id)
+  const idx = expandedIds.value.indexOf(sid)
   if (idx === -1) {
-    expandedIds.value.push(id)
+    expandedIds.value.push(sid)
   } else {
     expandedIds.value.splice(idx, 1)
   }
@@ -242,8 +243,14 @@ function toggleExpand(id: number) {
 
 function resetForm() {
   Object.assign(form, {
-    id: 0, itemName: '', itemCode: '', level: 1,
-    parentId: 0, sortOrder: '1', status: 1, remark: '',
+    id: '',
+    itemName: '',
+    itemCode: '',
+    level: 1,
+    parentId: '0',
+    sortOrder: '1',
+    status: 1,
+    remark: '',
   })
 }
 
@@ -252,7 +259,7 @@ function openAddDialog() {
   isEdit.value = false
   resetForm()
   form.level = 1
-  form.parentId = 0
+  form.parentId = '0'
   showForm.value = true
 }
 
@@ -261,7 +268,7 @@ function openAddChildDialog(parent: any) {
   isEdit.value = false
   resetForm()
   form.level = 2
-  form.parentId = parent.id
+  form.parentId = String(parent.id)
   showForm.value = true
 }
 
@@ -269,11 +276,11 @@ function openEditDialog(item: any) {
   dialogMode.value = 'edit'
   isEdit.value = true
   Object.assign(form, {
-    id: item.id,
+    id: String(item.id),
     itemName: item.itemName ?? '',
     itemCode: item.itemCode ?? '',
     level: item.level ?? 1,
-    parentId: item.parentId ?? 0,
+    parentId: item.parentId == null || item.parentId === 0 ? '0' : String(item.parentId),
     sortOrder: String(item.sortOrder ?? 1),
     status: item.status ?? 1,
     remark: item.remark ?? '',
@@ -283,7 +290,7 @@ function openEditDialog(item: any) {
 
 function onParentConfirm({ selectedOptions }: any) {
   if (selectedOptions.length > 0) {
-    form.parentId = selectedOptions[0].value
+    form.parentId = String(selectedOptions[0].value)
   }
   showParentPicker.value = false
 }
@@ -293,7 +300,7 @@ async function loadTree() {
   try {
     const res: any = await expenseItemApi.tree()
     expenseTree.value = res.data ?? []
-    expandedIds.value = expenseTree.value.map(m => m.id)
+    expandedIds.value = expenseTree.value.map((m: any) => String(m.id))
   } catch (e: any) {
     showToast(e?.message || '加载数据失败')
   } finally {
@@ -311,7 +318,7 @@ function validateExpenseItemForm(): boolean {
     showToast('请输入项目名称')
     return false
   }
-  if (form.level === 2 && (!form.parentId || form.parentId === 0)) {
+  if (form.level === 2 && (!form.parentId || form.parentId === '0')) {
     showToast('请选择上级项目')
     return false
   }
@@ -329,7 +336,7 @@ async function handleSubmit() {
       itemName: form.itemName,
       itemCode: form.itemCode,
       level: form.level,
-      parentId: form.parentId,
+      parentId: form.parentId === '0' ? '0' : form.parentId,
       sortOrder,
       status: form.status,
       remark: form.remark,

+ 33 - 13
bx-mobile/src/views/admin/WfAdvancePage.vue

@@ -129,6 +129,7 @@ import { useRouter } from 'vue-router'
 import { showToast, showDialog } from 'vant'
 import { DELETE_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
 import { workflowApi } from '@/api'
+import { portalAdminApi } from '@/api/portalAdmin'
 import { statusLabel, statusTagClass } from '@/utils/format'
 import StatusTag from '@/components/mobile/StatusTag.vue'
 
@@ -143,21 +144,26 @@ const showApproverPicker = ref(false)
 const isEdit = ref(false)
 const loadingMap = reactive<Record<string, boolean>>({})
 
-const userOptions = ref<{ id: number; realName: string; displayName: string }[]>([])
+const userOptions = ref<
+  { id: string; realName: string; displayName: string }[]
+>([])
 const userColumns = computed(() =>
-  userOptions.value.map(u => ({ text: u.displayName, value: u.id }))
+  userOptions.value.map((u) => ({ text: u.displayName, value: u.id })),
 )
 
 const formData = reactive({
-  editingNodeId: null as number | null,
+  editingNodeId: null as string | null,
   nodeName: '',
-  approverId: null as number | null,
+  approverId: null as string | null,
   approverName: '',
 })
 
 const approverDisplay = computed(() => {
-  if (formData.approverId == null) return ''
-  return userOptions.value.find(u => u.id === formData.approverId)?.displayName ?? formData.approverName
+  if (formData.approverId == null || formData.approverId === '') return ''
+  return (
+    userOptions.value.find((u) => u.id === String(formData.approverId))?.displayName ??
+    formData.approverName
+  )
 })
 
 function nodeKey(node: Record<string, any>): string {
@@ -165,8 +171,21 @@ function nodeKey(node: Record<string, any>): string {
 }
 
 async function loadUsers() {
-  // 旧 bx userApi 已移除;P3 再接门户用户下拉
-  userOptions.value = []
+  try {
+    const res: any = await portalAdminApi.userOptions()
+    const data = res?.data ?? res ?? []
+    userOptions.value = (Array.isArray(data) ? data : []).map((u: any) => {
+      const name = u.label || u.nickName || u.realName || ''
+      const account = u.userNo || u.username || ''
+      return {
+        id: String(u.value ?? u.id ?? ''),
+        realName: name,
+        displayName: account ? `${name}(${account})` : name,
+      }
+    })
+  } catch {
+    userOptions.value = []
+  }
 }
 
 async function loadWfDef() {
@@ -203,10 +222,11 @@ function openAddDialog() {
 
 function openEditDialog(node: Record<string, any>) {
   isEdit.value = true
+  const aid = node.approverId ?? node.approver_id ?? null
   Object.assign(formData, {
-    editingNodeId: node.id ?? null,
+    editingNodeId: node.id != null ? String(node.id) : null,
     nodeName: node.nodeName ?? '',
-    approverId: node.approverId ?? node.approver_id ?? null,
+    approverId: aid != null && aid !== '' ? String(aid) : null,
     approverName: node.approverName ?? node.approver_name ?? '',
   })
   showForm.value = true
@@ -214,8 +234,8 @@ function openEditDialog(node: Record<string, any>) {
 
 function onApproverConfirm({ selectedOptions }: any) {
   if (selectedOptions.length > 0) {
-    formData.approverId = selectedOptions[0].value
-    formData.approverName = selectedOptions[0].text.split('(')[0]
+    formData.approverId = String(selectedOptions[0].value)
+    formData.approverName = String(selectedOptions[0].text || '').split('(')[0]
   }
   showApproverPicker.value = false
 }
@@ -235,7 +255,7 @@ async function handleSubmit() {
   try {
     if (isEdit.value && formData.editingNodeId != null) {
       const newNodes = nodes.value.map((n: Record<string, any>) =>
-        n.id === formData.editingNodeId
+        String(n.id) === formData.editingNodeId
           ? { ...n, nodeName: formData.nodeName, approverId: formData.approverId, approverName: formData.approverName }
           : n
       )

+ 33 - 13
bx-mobile/src/views/admin/WfReimbursePage.vue

@@ -129,6 +129,7 @@ import { useRouter } from 'vue-router'
 import { showToast, showDialog } from 'vant'
 import { DELETE_CONFIRM_BUTTON_COLOR } from '@/constants/dialog'
 import { workflowApi } from '@/api'
+import { portalAdminApi } from '@/api/portalAdmin'
 import { statusLabel, statusTagClass } from '@/utils/format'
 import StatusTag from '@/components/mobile/StatusTag.vue'
 
@@ -143,21 +144,26 @@ const showApproverPicker = ref(false)
 const isEdit = ref(false)
 const loadingMap = reactive<Record<string, boolean>>({})
 
-const userOptions = ref<{ id: number; realName: string; displayName: string }[]>([])
+const userOptions = ref<
+  { id: string; realName: string; displayName: string }[]
+>([])
 const userColumns = computed(() =>
-  userOptions.value.map(u => ({ text: u.displayName, value: u.id }))
+  userOptions.value.map((u) => ({ text: u.displayName, value: u.id })),
 )
 
 const formData = reactive({
-  editingNodeId: null as number | null,
+  editingNodeId: null as string | null,
   nodeName: '',
-  approverId: null as number | null,
+  approverId: null as string | null,
   approverName: '',
 })
 
 const approverDisplay = computed(() => {
-  if (formData.approverId == null) return ''
-  return userOptions.value.find(u => u.id === formData.approverId)?.displayName ?? formData.approverName
+  if (formData.approverId == null || formData.approverId === '') return ''
+  return (
+    userOptions.value.find((u) => u.id === String(formData.approverId))?.displayName ??
+    formData.approverName
+  )
 })
 
 function nodeKey(node: Record<string, any>): string {
@@ -165,8 +171,21 @@ function nodeKey(node: Record<string, any>): string {
 }
 
 async function loadUsers() {
-  // 旧 bx userApi 已移除;P3 再接门户用户下拉
-  userOptions.value = []
+  try {
+    const res: any = await portalAdminApi.userOptions()
+    const data = res?.data ?? res ?? []
+    userOptions.value = (Array.isArray(data) ? data : []).map((u: any) => {
+      const name = u.label || u.nickName || u.realName || ''
+      const account = u.userNo || u.username || ''
+      return {
+        id: String(u.value ?? u.id ?? ''),
+        realName: name,
+        displayName: account ? `${name}(${account})` : name,
+      }
+    })
+  } catch {
+    userOptions.value = []
+  }
 }
 
 async function loadWfDef() {
@@ -203,10 +222,11 @@ function openAddDialog() {
 
 function openEditDialog(node: Record<string, any>) {
   isEdit.value = true
+  const aid = node.approverId ?? node.approver_id ?? null
   Object.assign(formData, {
-    editingNodeId: node.id ?? null,
+    editingNodeId: node.id != null ? String(node.id) : null,
     nodeName: node.nodeName ?? '',
-    approverId: node.approverId ?? node.approver_id ?? null,
+    approverId: aid != null && aid !== '' ? String(aid) : null,
     approverName: node.approverName ?? node.approver_name ?? '',
   })
   showForm.value = true
@@ -214,8 +234,8 @@ function openEditDialog(node: Record<string, any>) {
 
 function onApproverConfirm({ selectedOptions }: any) {
   if (selectedOptions.length > 0) {
-    formData.approverId = selectedOptions[0].value
-    formData.approverName = selectedOptions[0].text.split('(')[0]
+    formData.approverId = String(selectedOptions[0].value)
+    formData.approverName = String(selectedOptions[0].text || '').split('(')[0]
   }
   showApproverPicker.value = false
 }
@@ -235,7 +255,7 @@ async function handleSubmit() {
   try {
     if (isEdit.value && formData.editingNodeId != null) {
       const newNodes = nodes.value.map((n: Record<string, any>) =>
-        n.id === formData.editingNodeId
+        String(n.id) === formData.editingNodeId
           ? { ...n, nodeName: formData.nodeName, approverId: formData.approverId, approverName: formData.approverName }
           : n
       )

+ 21 - 7
bx-mobile/src/views/advance/AdvanceDetailPage.vue

@@ -384,15 +384,29 @@ async function loadDetail() {
     return
   }
 
-  // 加载审批流程
+  // 审批历史优先;失败则回退当前审批节点
   try {
-    const apprRes: any = await advanceApi.getApprovals(id)
-    const nodes = apprRes.data || []
-    approvals.value = nodes.sort(
-      (a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0)
-    )
+    const histRes: any = await advanceApi.getApprovalHistory(id)
+    const hist = histRes.data || []
+    if (Array.isArray(hist) && hist.length) {
+      approvals.value = hist
+    } else {
+      const apprRes: any = await advanceApi.getApprovals(id)
+      const nodes = apprRes.data || []
+      approvals.value = nodes.sort(
+        (a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0),
+      )
+    }
   } catch {
-    // 审批流程加载失败不影响详情
+    try {
+      const apprRes: any = await advanceApi.getApprovals(id)
+      const nodes = apprRes.data || []
+      approvals.value = nodes.sort(
+        (a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0),
+      )
+    } catch {
+      // 审批流程加载失败不影响详情
+    }
   }
 
   // 加载附件

+ 23 - 7
bx-mobile/src/views/approval/ApprovalDetailPage.vue

@@ -427,13 +427,29 @@ async function loadDetail() {
       detail.value = res.data || res
     }
 
-    // Load approval flow
-    const apprRes: any = await api.getApprovals(id)
-    if (apprRes?.code === 200 || apprRes?.data) {
-      const nodes = apprRes.data || apprRes
-      approvals.value = Array.isArray(nodes)
-        ? nodes.sort((a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
-        : []
+    // 审批历史优先;失败则回退当前审批节点
+    try {
+      const histRes: any = await api.getApprovalHistory(id)
+      const hist = histRes?.data ?? histRes
+      if (Array.isArray(hist) && hist.length) {
+        approvals.value = hist
+      } else {
+        const apprRes: any = await api.getApprovals(id)
+        const nodes = apprRes?.data ?? apprRes
+        approvals.value = Array.isArray(nodes)
+          ? nodes.sort((a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
+          : []
+      }
+    } catch {
+      try {
+        const apprRes: any = await api.getApprovals(id)
+        const nodes = apprRes?.data ?? apprRes
+        approvals.value = Array.isArray(nodes)
+          ? nodes.sort((a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
+          : []
+      } catch {
+        approvals.value = []
+      }
     }
 
     // Load attachments(申请)

+ 91 - 15
bx-mobile/src/views/reimburse/ReimburseApplyPage.vue

@@ -18,6 +18,26 @@
             <h3>报销信息</h3>
             <span class="form-badge">{{ editingId ? '编辑' : '新建' }}</span>
           </div>
+          <div class="field">
+            <label class="field-label">申请人</label>
+            <div class="picker-trigger" @click="showApplicantPicker = true">
+              <div class="left">
+                <div class="value" :class="{ ph: !applicantLabel }">
+                  {{ applicantLabel || '请选择申请人' }}
+                </div>
+              </div>
+              <van-icon name="arrow" class="arrow" />
+            </div>
+          </div>
+          <div class="field">
+            <label class="field-label">报销日期<span class="req">*</span></label>
+            <input
+              class="form-input"
+              type="date"
+              v-model="form.reimburseDate"
+              @change="refreshTitle"
+            />
+          </div>
           <div class="field">
             <label class="field-label">报销标题</label>
             <input
@@ -29,6 +49,15 @@
           </div>
         </div>
 
+        <van-popup v-model:show="showApplicantPicker" position="bottom" round :style="{ height: '60%' }">
+          <van-picker
+            title="选择申请人"
+            :columns="applicantPickerColumns"
+            @confirm="onApplicantConfirm"
+            @cancel="showApplicantPicker = false"
+          />
+        </van-popup>
+
         <!-- 报销明细 -->
         <div class="form-card">
           <div class="form-header">
@@ -248,11 +277,21 @@ import {
   formatExpenseName,
   type ExpenseItem,
 } from '@/hooks/useExpenseItemTree'
+import { useApplicantOptions } from '@/hooks/useApplicantOptions'
+import { useAuthStore } from '@/stores/auth'
 import { SUBMIT_APPROVE_BUTTON_COLOR } from '@/constants/dialog'
 import BottomActionBar from '@/components/mobile/BottomActionBar.vue'
 
 const router = useRouter()
 const route = useRoute()
+const auth = useAuthStore()
+
+function todayYmd() {
+  const d = new Date()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${d.getFullYear()}-${m}-${day}`
+}
 
 interface DetailLine {
   expenseItemId: number | string
@@ -263,6 +302,8 @@ interface DetailLine {
 const form = reactive({
   title: '',
   remark: '',
+  applicantId: '' as string,
+  reimburseDate: todayYmd(),
   details: [{ expenseItemId: '' as string | number, amount: '', remark: '' }] as DetailLine[],
 })
 
@@ -271,8 +312,44 @@ const editingId = ref<string | null>(null)
 const showExpensePicker = ref(false)
 const pickingLineIndex = ref(0)
 const selectedLeftId = ref<string | null>(null)
+const showApplicantPicker = ref(false)
 
 const { expenseTree, flatList, load: loadExpenseItems } = useExpenseItemTree()
+const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
+
+const applicantLabel = computed(() => {
+  const opt = applicantOptions.value.find((o) => o.value === form.applicantId)
+  return opt?.label || ''
+})
+
+const applicantPickerColumns = computed(() =>
+  applicantOptions.value.map((o) => ({ text: o.label, value: o.value })),
+)
+
+function onApplicantConfirm({ selectedOptions }: any) {
+  const opt = selectedOptions?.[0]
+  if (opt?.value) {
+    form.applicantId = String(opt.value)
+    refreshTitle()
+    refreshWallet()
+  }
+  showApplicantPicker.value = false
+}
+
+async function refreshTitle() {
+  if (!form.reimburseDate) return
+  try {
+    const res: any = await reimburseApi.generateTitle(
+      form.reimburseDate,
+      editingId.value || undefined,
+      form.applicantId || undefined,
+    )
+    const t = res?.data
+    form.title = typeof t === 'string' ? t : t?.title || form.title
+  } catch {
+    /* keep */
+  }
+}
 
 const walletBalance = ref(0)
 const walletInvoices = ref<any[]>([])
@@ -308,9 +385,10 @@ function money(v: unknown) {
 
 async function refreshWallet() {
   try {
+    const owner = form.applicantId || undefined
     const [sumRes, invRes]: any[] = await Promise.all([
-      invoiceWalletApi.mineSummary(),
-      invoiceApi.list({ page: 1, pageSize: 200 }),
+      invoiceWalletApi.mineSummary(owner ? { userId: owner } : undefined),
+      invoiceApi.list({ page: 1, pageSize: 200, userId: owner }),
     ])
     walletBalance.value = Number(sumRes?.data?.balance ?? 0)
     const d = invRes?.data
@@ -485,6 +563,8 @@ async function onSubmit() {
     const payload = {
       title: form.title || undefined,
       remark: form.remark || undefined,
+      applicantId: form.applicantId || undefined,
+      reimburseDate: form.reimburseDate || undefined,
       details: validDetails.map((d) => ({
         expenseItemId: String(d.expenseItemId),
         amount: Number(d.amount),
@@ -563,6 +643,8 @@ async function loadEditData() {
     const data = res.data || res
     form.title = data.title || ''
     form.remark = data.remark || ''
+    form.applicantId = String(data.applicantId || auth.user?.id || '')
+    form.reimburseDate = data.reimburseDate || todayYmd()
     form.details = (data.details || []).map((d: any) => ({
       expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
       amount: String(d.amount ?? ''),
@@ -573,9 +655,7 @@ async function loadEditData() {
     }
     const linked = data.invoices || []
     selectedInvoiceIds.value = linked.map((x: any) => String(x.id || x.invoiceId)).filter(Boolean)
-    if (data.status && data.status !== 'draft' && data.status !== 'rejected') {
-      showToast('旧草稿若仍含本地发票附件,请改走发票夹关联')
-    }
+    await refreshWallet()
   } catch (e: any) {
     showToast(e?.message || '加载数据失败')
   }
@@ -596,17 +676,13 @@ watch(
 
 onMounted(async () => {
   sessionStorage.removeItem('bx_edit_resubmit')
-  await Promise.all([loadExpenseItems(), refreshWallet()])
+  if (!form.applicantId) form.applicantId = String(auth.user?.id || '')
+  await Promise.all([loadExpenseItems(), loadApplicantOptions(), refreshWallet()])
   await loadEditData()
-  if (!editingId.value && !form.title) {
-    try {
-      const res: any = await reimburseApi.generateTitle()
-      if (res.data) {
-        form.title = typeof res.data === 'string' ? res.data : res.data.title || ''
-      }
-    } catch {
-      /* skip */
-    }
+  if (!editingId.value) {
+    if (!form.applicantId) form.applicantId = String(auth.user?.id || '')
+    if (!form.reimburseDate) form.reimburseDate = todayYmd()
+    await refreshTitle()
   }
 })
 </script>

+ 51 - 5
bx-mobile/src/views/reimburse/ReimburseDetailPage.vue

@@ -456,6 +456,15 @@
         :disabled="deleteLoading"
         @click="handleDelete"
       >删除</button>
+      <button
+        v-if="canWithdraw"
+        type="button"
+        class="btn btn-ghost"
+        style="flex:1"
+        :class="{ 'btn-loading': withdrawLoading }"
+        :disabled="withdrawLoading"
+        @click="handleWithdraw"
+      >撤回</button>
       <button
         v-if="detail.status === 'rejected'"
         type="button"
@@ -494,6 +503,7 @@ const offsetAttachments = ref<any[]>([])
 const offsetRecords = ref<any[]>([])
 const submitLoading = ref(false)
 const deleteLoading = ref(false)
+const withdrawLoading = ref(false)
 
 function isImageFile(fileType: string): boolean {
   return fileType?.startsWith('image/')
@@ -519,10 +529,13 @@ const offsetTotalDisplay = computed(() => {
   return formatAmount(Number(raw))
 })
 
+const canWithdraw = computed(() => detail.value?.status === 'pending')
+
 const actionButtons = computed(() => {
   if (!detail.value) return []
   const s = detail.value.status
   if (s === 'draft' || s === 'rejected') return ['submit', 'delete']
+  if (canWithdraw.value) return ['withdraw']
   return []
 })
 
@@ -603,12 +616,26 @@ async function loadDetail() {
     const res: any = await reimburseApi.getById(id)
     detail.value = res.data || res
 
-    // Load approvals
+    // 审批历史优先;失败则回退当前审批节点
     try {
-      const apprRes: any = await reimburseApi.getApprovals(id)
-      const nodes = apprRes.data || []
-      approvals.value = nodes.sort((a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
-    } catch { /* ignore */ }
+      const histRes: any = await reimburseApi.getApprovalHistory(id)
+      const hist = histRes.data || []
+      if (Array.isArray(hist) && hist.length) {
+        approvals.value = hist
+      } else {
+        const apprRes: any = await reimburseApi.getApprovals(id)
+        const nodes = apprRes.data || []
+        approvals.value = nodes.sort((a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
+      }
+    } catch {
+      try {
+        const apprRes: any = await reimburseApi.getApprovals(id)
+        const nodes = apprRes.data || []
+        approvals.value = nodes.sort((a: any, b: any) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
+      } catch {
+        /* ignore */
+      }
+    }
 
     // Load all attachment categories in parallel + 冲抵明细(备用金订单)
     const [apply, pay, offset, offRes] = await Promise.all([
@@ -669,6 +696,25 @@ function handleEdit() {
   router.push({ path: '/reimburse/apply', query: { id: String(detail.value.id) } })
 }
 
+async function handleWithdraw() {
+  if (!detail.value) return
+  try {
+    await showConfirmDialog({
+      title: '撤回确认',
+      message: '确定撤回该报销单?撤回后将退回发票额度。',
+      confirmButtonColor: DELETE_CONFIRM_BUTTON_COLOR,
+    })
+    withdrawLoading.value = true
+    await reimburseApi.withdraw(detail.value.id)
+    showToast('已撤回')
+    await loadDetail()
+  } catch (e: any) {
+    if (e !== 'cancel') showToast(e?.message || '撤回失败')
+  } finally {
+    withdrawLoading.value = false
+  }
+}
+
 async function onPreviewFile(file: any) {
   if (isImageFile(file.fileType) && file.previewUrl) {
     showImagePreview({ images: [file.previewUrl] })