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>
This commit is contained in:
zlwuandCursor committed 2026-08-09 16:19:51 +08:00
1 parent 9750e1f39d
commit 96dbd3ccbc
12 files changed
+344 -87

No files matched your search

+14 -1
View File
@@ -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
View File
@@ -0,0 +1,6 @@
import portalClient from './portalClient'
/** 门户用户下拉(与 web-admin fetchUserOptions 同源) */
export const portalAdminApi = {
userOptions: () => portalClient.get('/web/admin/users/options'),
}
+1 -1
View File
@@ -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
@@ -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
View File
@@ -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') },
],
},
@@ -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
View File
@@ -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
View File
@@ -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
)
@@ -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 {
// 审批流程加载失败不影响详情
}
}
// 加载附件
@@ -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(申请)
@@ -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>
@@ -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] })