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:
1 parent
9750e1f39d
commit
96dbd3ccbc
12 files changed
+344
-87
No files matched your search
@@ -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 }) =>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import portalClient from './portalClient'
|
||||
|
||||
/** 门户用户下拉(与 web-admin fetchUserOptions 同源) */
|
||||
export const portalAdminApi = {
|
||||
userOptions: () => portalClient.get('/web/admin/users/options'),
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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] })
|
||||
|
||||
Reference in new issue
Block a user