feat(miniprogram): migrate reimburse apply list/form/detail from bx-mobile.
Reuse /reimburses APIs with bx envelope; support multi-line details and draft actions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2ff754d572
commit
006e94ad33
8 files changed
+1861
-9
No files matched your search
+37
-2
@@ -11,7 +11,35 @@ export const advanceApi = {
|
||||
delete: (id) => bxRequest({ url: `/advances/${id}`, method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** 附件:Batch2 申请页图片上传所需最小集合 */
|
||||
export const reimburseApi = {
|
||||
generateTitle: (params) =>
|
||||
bxRequest({ url: '/reimburses/generate-title', method: 'GET', params }),
|
||||
my: (params) => bxRequest({ url: '/reimburses/my', method: 'GET', params }),
|
||||
getById: (id) => bxRequest({ url: `/reimburses/${id}`, method: 'GET' }),
|
||||
getApprovals: (id) => bxRequest({ url: `/reimburses/${id}/approvals`, method: 'GET' }),
|
||||
getApprovalHistory: (id) =>
|
||||
bxRequest({ url: `/reimburses/${id}/approval-history`, method: 'GET' }),
|
||||
create: (data) => bxRequest({ url: '/reimburses/', method: 'POST', data }),
|
||||
update: (id, data) => bxRequest({ url: `/reimburses/${id}`, method: 'PUT', data }),
|
||||
submit: (id, data) =>
|
||||
bxRequest({ url: `/reimburses/${id}/submit`, method: 'POST', data }),
|
||||
delete: (id) => bxRequest({ url: `/reimburses/${id}`, method: 'DELETE' })
|
||||
}
|
||||
|
||||
export const expenseItemApi = {
|
||||
tree: () => bxRequest({ url: '/expense-items/tree', method: 'GET' })
|
||||
}
|
||||
|
||||
export const invoiceWalletApi = {
|
||||
mineSummary: (params) =>
|
||||
bxRequest({ url: '/invoice-wallets/mine/summary', method: 'GET', params })
|
||||
}
|
||||
|
||||
export const invoiceApi = {
|
||||
list: (params) => bxRequest({ url: '/invoices', method: 'GET', params })
|
||||
}
|
||||
|
||||
/** 附件:申请页图片上传所需最小集合 */
|
||||
export const attachmentApi = {
|
||||
upload: (filePath, bizType = '', bizId = 0) =>
|
||||
bxUploadFile(filePath, { bizType, bizId }),
|
||||
@@ -35,4 +63,11 @@ export const attachmentApi = {
|
||||
})
|
||||
}
|
||||
|
||||
export default { advanceApi, attachmentApi }
|
||||
export default {
|
||||
advanceApi,
|
||||
reimburseApi,
|
||||
expenseItemApi,
|
||||
invoiceWalletApi,
|
||||
invoiceApi,
|
||||
attachmentApi
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export const PAGE_PATHS = {
|
||||
bxAdvanceApplyForm: '/pages/bx/advance/apply',
|
||||
bxAdvanceDetail: '/pages/bx/advance/detail',
|
||||
bxReimburseApply: '/pages/bx/reimburse/list',
|
||||
bxReimburseApplyForm: '/pages/bx/reimburse/apply',
|
||||
bxReimburseDetail: '/pages/bx/reimburse/detail',
|
||||
bxMine: '/pages/bx/mine/list',
|
||||
bxAll: '/pages/bx/all/list',
|
||||
bxInvoiceMine: '/pages/bx/invoice/mine',
|
||||
|
||||
@@ -96,6 +96,14 @@
|
||||
"path": "pages/bx/reimburse/list",
|
||||
"style": { "navigationStyle": "custom", "navigationBarTitleText": "报销申请" }
|
||||
},
|
||||
{
|
||||
"path": "pages/bx/reimburse/apply",
|
||||
"style": { "navigationStyle": "custom", "navigationBarTitleText": "报销申请" }
|
||||
},
|
||||
{
|
||||
"path": "pages/bx/reimburse/detail",
|
||||
"style": { "navigationStyle": "custom", "navigationBarTitleText": "报销详情" }
|
||||
},
|
||||
{
|
||||
"path": "pages/bx/mine/list",
|
||||
"style": { "navigationStyle": "custom", "navigationBarTitleText": "我的申请单" }
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
<template>
|
||||
<view class="bx-page bx-page--form">
|
||||
<view class="topbar">
|
||||
<view class="topbar-left">
|
||||
<view class="topbar-back" @click="goBack">
|
||||
<text class="topbar-back-icon">‹</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="topbar-title">报销申请</text>
|
||||
<view class="topbar-right" />
|
||||
</view>
|
||||
|
||||
<view class="page-padded">
|
||||
<view class="form-card">
|
||||
<view class="form-header">
|
||||
<text class="form-header-title">报销信息</text>
|
||||
<text class="form-badge">{{ isEdit ? '编辑' : '新建' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">申请人</text>
|
||||
<view class="form-input" style="display: flex; align-items: center">
|
||||
{{ applicantLabel || '当前用户' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">报销日期<text class="req">*</text></text>
|
||||
<picker mode="date" :value="form.reimburseDate" @change="onDateChange">
|
||||
<view class="picker-trigger">
|
||||
<text class="value" :class="{ ph: !form.reimburseDate }">
|
||||
{{ form.reimburseDate || '请选择报销日期' }}
|
||||
</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">报销标题</text>
|
||||
<input
|
||||
class="form-input"
|
||||
:value="form.title"
|
||||
placeholder="不填则自动生成"
|
||||
@input="onTitleInput"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-header">
|
||||
<text class="form-header-title">报销明细</text>
|
||||
<text class="form-badge">{{ form.details.length }}行</text>
|
||||
</view>
|
||||
|
||||
<view v-for="(line, idx) in form.details" :key="idx" class="detail-line">
|
||||
<view v-if="idx > 0" class="detail-divider" />
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">
|
||||
费用项目
|
||||
<text v-if="form.details.length > 1" class="detail-line-no">
|
||||
(第 {{ idx + 1 }} 条)
|
||||
</text>
|
||||
<text class="req">*</text>
|
||||
</text>
|
||||
<view class="picker-trigger" @click="openExpensePicker(idx)">
|
||||
<text
|
||||
class="value"
|
||||
:class="{ ph: !lineExpenseDisplay(line.expenseItemId) }"
|
||||
>
|
||||
{{ lineExpenseDisplay(line.expenseItemId) || '请选择费用项目' }}
|
||||
</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">金额(元)<text class="req">*</text></text>
|
||||
<view class="form-input-prefix">
|
||||
<input
|
||||
class="form-input"
|
||||
type="digit"
|
||||
:value="line.amount"
|
||||
placeholder="0.00"
|
||||
:data-idx="idx"
|
||||
@input="onLineAmount"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">报销事由<text class="req">*</text></text>
|
||||
<input
|
||||
class="form-input"
|
||||
:value="line.remark"
|
||||
placeholder="请输入报销事由"
|
||||
:data-idx="idx"
|
||||
@input="onLineRemark"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">发生时间</text>
|
||||
<picker
|
||||
mode="date"
|
||||
:value="line.occurDate || form.reimburseDate"
|
||||
:data-idx="idx"
|
||||
@change="onOccurDate"
|
||||
>
|
||||
<view class="picker-trigger">
|
||||
<text class="value" :class="{ ph: !line.occurDate }">
|
||||
{{ line.occurDate || '选填' }}
|
||||
</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="form.details.length > 1" class="detail-line-actions">
|
||||
<button class="btn btn-sm btn-danger" @click="removeDetailLine(idx)">
|
||||
删除此行
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="addDetailLine">+ 新增明细行</button>
|
||||
<text class="total-amount">合计:<text style="font-weight: 700">¥{{ detailTotalAmount }}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-header">
|
||||
<text class="form-header-title">关联发票(可选)</text>
|
||||
</view>
|
||||
<view class="invoice-balance-tip">
|
||||
发票余额 ¥{{ money(walletBalance) }} · 本次合计 ¥{{ detailTotalAmount }}
|
||||
<text v-if="balanceInsufficient" class="invoice-balance-tip--warn">
|
||||
(余额不足,提交后余额将为负,请后续补发票)
|
||||
</text>
|
||||
</view>
|
||||
<button class="btn btn-primary btn-sm" @click="openInvoicePicker">从发票夹选择</button>
|
||||
<view v-if="selectedInvoicePicks.length" class="invoice-picked-row">
|
||||
<view
|
||||
v-for="pick in selectedInvoicePicks"
|
||||
:key="pick.key"
|
||||
class="invoice-picked-item"
|
||||
>
|
||||
<view class="invoice-picked-item__meta">
|
||||
<text class="invoice-picked-item__name">{{ pick.fileName }}</text>
|
||||
<text class="invoice-picked-item__amt">整票 ¥{{ money(pick.amount) }}</text>
|
||||
</view>
|
||||
<view class="invoice-picked-item__remove" @click="removeInvoicePick(pick.key)">×</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-header">
|
||||
<text class="form-header-title">申请附件(可选)</text>
|
||||
</view>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="uploading"
|
||||
@click="chooseAttachments"
|
||||
>
|
||||
{{ uploading ? '上传中…' : '上传图片' }}
|
||||
</button>
|
||||
<view v-if="files.length" class="att-grid">
|
||||
<view v-for="(file, idx) in files" :key="file.id" class="att-img">
|
||||
<image :src="file.previewUrl || file.localPath" mode="aspectFill" />
|
||||
<view v-if="!file._pending" class="del" @click.stop="removeFile(idx)">×</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="detail-section-empty" style="margin-top: 8px">暂无附件(可选)</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-header">
|
||||
<text class="form-header-title">报销说明</text>
|
||||
</view>
|
||||
<view class="field">
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
:value="form.remark"
|
||||
placeholder="请输入报销说明"
|
||||
:maxlength="500"
|
||||
@input="onRemarkInput"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-action-bar">
|
||||
<button class="btn btn-primary btn-block" :disabled="submitting" @click="onSubmit">
|
||||
{{ submitting ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 费用项目选择 -->
|
||||
<view v-if="showExpensePicker" class="bx-sheet-mask" @click="showExpensePicker = false">
|
||||
<view class="bx-sheet" @click.stop>
|
||||
<view class="bx-sheet__title">选择费用项目</view>
|
||||
<scroll-view scroll-y class="bx-sheet__body">
|
||||
<view v-if="!expenseOptions.length" class="bx-sheet__empty">暂无可用费用项目</view>
|
||||
<view
|
||||
v-for="opt in expenseOptions"
|
||||
:key="opt.id"
|
||||
class="bx-sheet__item"
|
||||
:class="{ 'is-selected': String(form.details[pickingLineIndex]?.expenseItemId) === opt.id }"
|
||||
@click="confirmExpensePick(opt.id)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发票多选(扁平列表) -->
|
||||
<view v-if="showInvoicePicker" class="bx-sheet-mask" @click="showInvoicePicker = false">
|
||||
<view class="bx-sheet" @click.stop>
|
||||
<view class="bx-sheet__title">选择发票附件</view>
|
||||
<scroll-view scroll-y class="bx-sheet__body">
|
||||
<view v-if="!attachItems.length" class="bx-sheet__empty">暂无发票附件</view>
|
||||
<view
|
||||
v-for="item in attachItems"
|
||||
:key="item.key"
|
||||
class="bx-sheet__item"
|
||||
:class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
|
||||
@click="togglePickerKey(item.key)"
|
||||
>
|
||||
<view>
|
||||
<text>{{ item.fileName }}</text>
|
||||
<view class="bx-sheet__item-sub">整票 ¥{{ money(item.amount) }}</view>
|
||||
</view>
|
||||
<text v-if="pickerDraftKeys.includes(item.key)">✓</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="bx-sheet__footer">
|
||||
<button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
|
||||
确定(已选 {{ pickerDraftKeys.length }})
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
reimburseApi,
|
||||
attachmentApi,
|
||||
expenseItemApi,
|
||||
invoiceWalletApi,
|
||||
invoiceApi
|
||||
} from '@/api/bx'
|
||||
import {
|
||||
normalizeExpenseItem,
|
||||
flattenExpenseTree,
|
||||
formatExpenseName
|
||||
} from '@/utils/bxFormat'
|
||||
|
||||
const MAX_ATTACHMENTS = 10
|
||||
const userStore = useUserStore()
|
||||
|
||||
const editId = ref('')
|
||||
const savedId = ref('')
|
||||
const submitting = ref(false)
|
||||
const uploading = ref(false)
|
||||
const files = ref([])
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
remark: '',
|
||||
applicantId: '',
|
||||
reimburseDate: todayYmd(),
|
||||
details: [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
|
||||
})
|
||||
|
||||
const flatList = ref([])
|
||||
const showExpensePicker = ref(false)
|
||||
const pickingLineIndex = ref(0)
|
||||
|
||||
const walletBalance = ref(0)
|
||||
const attachItems = ref([])
|
||||
const selectedInvoicePicks = ref([])
|
||||
const pickerDraftKeys = ref([])
|
||||
const showInvoicePicker = ref(false)
|
||||
|
||||
const isEdit = computed(() => !!editId.value || !!savedId.value)
|
||||
|
||||
const applicantLabel = computed(() => {
|
||||
const u = userStore.userInfo || {}
|
||||
return u.realName || u.username || u.nickName || u.name || ''
|
||||
})
|
||||
|
||||
const expenseOptions = computed(() => {
|
||||
return (flatList.value || [])
|
||||
.filter((i) => i.status === 1 || i.status === '1')
|
||||
.map((i) => ({
|
||||
id: String(i.id),
|
||||
label: formatExpenseName(i.id, flatList.value)
|
||||
}))
|
||||
})
|
||||
|
||||
const detailTotalNumber = computed(() =>
|
||||
form.details.reduce((sum, d) => sum + Number(d.amount || 0), 0)
|
||||
)
|
||||
|
||||
const detailTotalAmount = computed(() =>
|
||||
detailTotalNumber.value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
)
|
||||
|
||||
const balanceInsufficient = computed(
|
||||
() => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value
|
||||
)
|
||||
|
||||
function money(v) {
|
||||
return Number(v || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
function lineExpenseDisplay(id) {
|
||||
if (id === '' || id == null) return ''
|
||||
const name = formatExpenseName(id, flatList.value)
|
||||
return name === '-' ? '' : name
|
||||
}
|
||||
|
||||
function onTitleInput(e) {
|
||||
form.title = e.detail.value
|
||||
}
|
||||
function onRemarkInput(e) {
|
||||
form.remark = e.detail.value
|
||||
}
|
||||
function onLineAmount(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
if (Number.isNaN(idx) || !form.details[idx]) return
|
||||
form.details[idx].amount = e.detail.value
|
||||
}
|
||||
function onLineRemark(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
if (Number.isNaN(idx) || !form.details[idx]) return
|
||||
form.details[idx].remark = e.detail.value
|
||||
}
|
||||
function onOccurDate(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
if (Number.isNaN(idx) || !form.details[idx]) return
|
||||
form.details[idx].occurDate = e.detail.value
|
||||
}
|
||||
function onDateChange(e) {
|
||||
form.reimburseDate = e.detail.value
|
||||
refreshTitle()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
|
||||
}
|
||||
|
||||
function addDetailLine() {
|
||||
form.details.push({ expenseItemId: '', amount: '', remark: '', occurDate: '' })
|
||||
}
|
||||
|
||||
function removeDetailLine(index) {
|
||||
form.details.splice(index, 1)
|
||||
}
|
||||
|
||||
function openExpensePicker(idx) {
|
||||
pickingLineIndex.value = idx
|
||||
showExpensePicker.value = true
|
||||
}
|
||||
|
||||
function confirmExpensePick(id) {
|
||||
form.details[pickingLineIndex.value].expenseItemId = String(id)
|
||||
showExpensePicker.value = false
|
||||
}
|
||||
|
||||
function invoiceFileList(inv) {
|
||||
const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
|
||||
if (raw.length) {
|
||||
return raw.map((a) => ({
|
||||
id: String(a.id || a.attachmentId),
|
||||
fileName: a.fileName || a.file_name || '发票附件',
|
||||
fileType: a.fileType || a.file_type || ''
|
||||
}))
|
||||
}
|
||||
const aid = inv?.attachmentId || inv?.attachment_id
|
||||
if (aid) {
|
||||
return [
|
||||
{
|
||||
id: String(aid),
|
||||
fileName: inv.fileName || inv.file_name || '发票附件',
|
||||
fileType: inv.fileType || inv.file_type || ''
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function buildAttachItems(rows) {
|
||||
const items = []
|
||||
for (const row of rows || []) {
|
||||
const createTime = row.createTime || row.create_time || ''
|
||||
for (const file of invoiceFileList(row)) {
|
||||
if (!file.id) continue
|
||||
items.push({
|
||||
key: `${row.id}:${file.id}`,
|
||||
invoiceId: String(row.id),
|
||||
attachmentId: file.id,
|
||||
amount: row.amount,
|
||||
createTime,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function buildInvoicePayload() {
|
||||
const invoiceLinks = selectedInvoicePicks.value.map((p) => ({
|
||||
invoiceId: String(p.invoiceId),
|
||||
attachmentId: String(p.attachmentId)
|
||||
}))
|
||||
const invoiceIds = Array.from(new Set(invoiceLinks.map((p) => p.invoiceId)))
|
||||
return { invoiceIds, invoiceLinks }
|
||||
}
|
||||
|
||||
async function refreshTitle() {
|
||||
if (!form.reimburseDate) return
|
||||
try {
|
||||
const params = { reimburseDate: form.reimburseDate }
|
||||
const exclude = editId.value || savedId.value
|
||||
if (exclude) params.excludeApplyId = exclude
|
||||
if (form.applicantId) params.applicantId = form.applicantId
|
||||
const res = await reimburseApi.generateTitle(params)
|
||||
const t = res?.data
|
||||
form.title = typeof t === 'string' ? t : t?.title || form.title
|
||||
} catch (e) {
|
||||
// keep
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWallet() {
|
||||
try {
|
||||
const owner = form.applicantId || undefined
|
||||
const [sumRes, invRes] = await Promise.all([
|
||||
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
|
||||
const rows = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
|
||||
attachItems.value = buildAttachItems(rows)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function openInvoicePicker() {
|
||||
pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
|
||||
showInvoicePicker.value = true
|
||||
}
|
||||
|
||||
function togglePickerKey(key) {
|
||||
const i = pickerDraftKeys.value.indexOf(key)
|
||||
if (i >= 0) pickerDraftKeys.value.splice(i, 1)
|
||||
else pickerDraftKeys.value.push(key)
|
||||
}
|
||||
|
||||
function confirmInvoicePicker() {
|
||||
const set = new Set(pickerDraftKeys.value)
|
||||
selectedInvoicePicks.value = attachItems.value
|
||||
.filter((i) => set.has(i.key))
|
||||
.map((i) => ({ ...i }))
|
||||
showInvoicePicker.value = false
|
||||
}
|
||||
|
||||
function removeInvoicePick(key) {
|
||||
selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
|
||||
}
|
||||
|
||||
function validateReimburseApplyForm() {
|
||||
for (let i = 0; i < form.details.length; i++) {
|
||||
const d = form.details[i]
|
||||
const row = i + 1
|
||||
const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
|
||||
const amountStr = String(d.amount ?? '').trim()
|
||||
const hasAmount = amountStr !== ''
|
||||
const amt = Number(amountStr)
|
||||
const amountOk = hasAmount && !Number.isNaN(amt) && amt > 0
|
||||
const hasRemark = !!d.remark?.trim()
|
||||
|
||||
if (!hasExpense && !hasAmount && !hasRemark) continue
|
||||
|
||||
if (!hasExpense) {
|
||||
uni.showToast({ title: `第 ${row} 行:请选择费用项目`, icon: 'none' })
|
||||
return null
|
||||
}
|
||||
if (!amountOk) {
|
||||
uni.showToast({ title: `第 ${row} 行:请输入大于 0 的金额`, icon: 'none' })
|
||||
return null
|
||||
}
|
||||
if (!d.remark?.trim()) {
|
||||
uni.showToast({ title: `第 ${row} 行:请填写报销事由`, icon: 'none' })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const validDetails = form.details.filter((d) => {
|
||||
const hasExpense = d.expenseItemId !== '' && d.expenseItemId != null
|
||||
const amt = Number(String(d.amount ?? '').trim())
|
||||
return hasExpense && !Number.isNaN(amt) && amt > 0
|
||||
})
|
||||
if (validDetails.length === 0) {
|
||||
uni.showToast({ title: '请至少填写一条完整的报销明细', icon: 'none' })
|
||||
return null
|
||||
}
|
||||
if (!form.reimburseDate) {
|
||||
uni.showToast({ title: '请选择报销日期', icon: 'none' })
|
||||
return null
|
||||
}
|
||||
return validDetails
|
||||
}
|
||||
|
||||
async function chooseAttachments() {
|
||||
const remain = MAX_ATTACHMENTS - files.value.length
|
||||
if (remain <= 0) {
|
||||
uni.showToast({ title: `最多上传 ${MAX_ATTACHMENTS} 个附件`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: remain,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const paths = res.tempFilePaths || []
|
||||
for (const path of paths) {
|
||||
await uploadOne(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadOne(filePath) {
|
||||
const pendingId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
|
||||
files.value.push({
|
||||
id: pendingId,
|
||||
previewUrl: filePath,
|
||||
localPath: filePath,
|
||||
_pending: true,
|
||||
_existing: false
|
||||
})
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await attachmentApi.upload(filePath, '', 0)
|
||||
const data = res?.data || {}
|
||||
const id = String(data.id || '')
|
||||
let previewUrl = filePath
|
||||
if (id) {
|
||||
try {
|
||||
const pRes = await attachmentApi.preview(id)
|
||||
if (pRes?.data?.url) previewUrl = pRes.data.url
|
||||
} catch (e) {
|
||||
// keep local
|
||||
}
|
||||
}
|
||||
const idx = files.value.findIndex((f) => f.id === pendingId)
|
||||
if (idx >= 0) {
|
||||
files.value[idx] = {
|
||||
id: id || pendingId,
|
||||
previewUrl,
|
||||
localPath: filePath,
|
||||
_pending: false,
|
||||
_existing: false
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
files.value = files.value.filter((f) => f.id !== pendingId)
|
||||
} finally {
|
||||
uploading.value = files.value.some((f) => f._pending)
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(idx) {
|
||||
const file = files.value[idx]
|
||||
if (!file) return
|
||||
if (file._existing && file.id && !String(file.id).startsWith('pending_')) {
|
||||
attachmentApi.delete(file.id).catch(() => {})
|
||||
}
|
||||
files.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function bindNewAttachments(bizId) {
|
||||
const newIds = files.value
|
||||
.filter((f) => f.id && !f._existing && !f._pending && !String(f.id).startsWith('pending_'))
|
||||
.map((f) => f.id)
|
||||
if (!newIds.length) return
|
||||
await attachmentApi.bindBiz(newIds, 'reimburse_apply', bizId)
|
||||
files.value.forEach((f) => {
|
||||
if (newIds.includes(f.id)) f._existing = true
|
||||
})
|
||||
}
|
||||
|
||||
async function loadExpenseItems() {
|
||||
try {
|
||||
const res = await expenseItemApi.tree()
|
||||
const tree = (res.data || []).map(normalizeExpenseItem)
|
||||
flatList.value = flattenExpenseTree(tree)
|
||||
} catch (e) {
|
||||
flatList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEditData(id) {
|
||||
try {
|
||||
const res = await reimburseApi.getById(id)
|
||||
const data = res?.data || {}
|
||||
form.title = data.title || ''
|
||||
form.remark = data.remark || ''
|
||||
form.applicantId = String(data.applicantId || userStore.userInfo?.id || '')
|
||||
form.reimburseDate = data.reimburseDate || todayYmd()
|
||||
form.details = (data.details || []).map((d) => ({
|
||||
expenseItemId: d.expenseId ?? d.expenseItemId ?? '',
|
||||
amount: String(d.amount ?? ''),
|
||||
remark: d.remark || '',
|
||||
occurDate: d.occurDate || ''
|
||||
}))
|
||||
if (!form.details.length) {
|
||||
form.details = [{ expenseItemId: '', amount: '', remark: '', occurDate: '' }]
|
||||
}
|
||||
|
||||
await refreshWallet()
|
||||
const linked = data.invoices || []
|
||||
const picks = []
|
||||
for (const inv of linked) {
|
||||
const invoiceId = String(inv.id || inv.invoiceId || '')
|
||||
if (!invoiceId) continue
|
||||
let attachmentId = String(inv.attachmentId || inv.attachment_id || '')
|
||||
let fileName = inv.fileName || inv.file_name || '发票附件'
|
||||
let fileType = inv.fileType || inv.file_type || ''
|
||||
const createTime = inv.createTime || inv.create_time || ''
|
||||
if (!attachmentId) {
|
||||
const src = attachItems.value.find((w) => String(w.invoiceId) === invoiceId)
|
||||
if (src) {
|
||||
attachmentId = src.attachmentId
|
||||
fileName = src.fileName
|
||||
fileType = src.fileType
|
||||
}
|
||||
}
|
||||
if (!attachmentId) continue
|
||||
picks.push({
|
||||
key: `${invoiceId}:${attachmentId}`,
|
||||
invoiceId,
|
||||
attachmentId,
|
||||
amount: inv.amount,
|
||||
createTime,
|
||||
fileName,
|
||||
fileType
|
||||
})
|
||||
}
|
||||
selectedInvoicePicks.value = picks
|
||||
|
||||
try {
|
||||
const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
|
||||
const list = attRes?.data || []
|
||||
const mapped = []
|
||||
for (const a of list) {
|
||||
let previewUrl = ''
|
||||
if ((a.fileType || '').startsWith('image/')) {
|
||||
try {
|
||||
const pRes = await attachmentApi.preview(a.id)
|
||||
previewUrl = pRes?.data?.url || ''
|
||||
} catch (e) {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
mapped.push({
|
||||
id: String(a.id),
|
||||
previewUrl,
|
||||
_pending: false,
|
||||
_existing: true
|
||||
})
|
||||
}
|
||||
files.value = mapped
|
||||
} catch (e) {
|
||||
files.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载数据失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const validDetails = validateReimburseApplyForm()
|
||||
if (!validDetails) return
|
||||
if (uploading.value) {
|
||||
uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const isEditingAction = !!editId.value || !!savedId.value
|
||||
try {
|
||||
const invoicePayload = buildInvoicePayload()
|
||||
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),
|
||||
remark: String(d.remark).trim(),
|
||||
occurDate: d.occurDate ? String(d.occurDate) : undefined
|
||||
})),
|
||||
...invoicePayload
|
||||
}
|
||||
|
||||
let id
|
||||
if (editId.value || savedId.value) {
|
||||
id = savedId.value || editId.value
|
||||
await reimburseApi.update(id, payload)
|
||||
} else {
|
||||
const res = await reimburseApi.create(payload)
|
||||
id = String(res?.data?.id ?? res?.id ?? '')
|
||||
if (!id) throw new Error('保存成功但未返回报销单ID')
|
||||
savedId.value = id
|
||||
editId.value = id
|
||||
}
|
||||
|
||||
try {
|
||||
await bindNewAttachments(id)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '报销单已保存,但附件关联失败', icon: 'none' })
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: isEditingAction ? '保存成功' : '创建成功',
|
||||
content: '报销单已保存为草稿',
|
||||
confirmText: '提交审批',
|
||||
confirmColor: '#00a874',
|
||||
cancelText: '继续编辑',
|
||||
success: async (modalRes) => {
|
||||
if (!modalRes.confirm) return
|
||||
try {
|
||||
await reimburseApi.submit(id, invoicePayload)
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
setTimeout(() => goBack(), 500)
|
||||
} catch (e) {
|
||||
// toasted
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// toasted by bxRequest
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (query) => {
|
||||
const uid = String(userStore.userInfo?.id || userStore.userInfo?.userId || '')
|
||||
form.applicantId = uid
|
||||
await loadExpenseItems()
|
||||
await refreshWallet()
|
||||
const id = query?.id ? String(query.id) : ''
|
||||
if (id) {
|
||||
editId.value = id
|
||||
await loadEditData(id)
|
||||
} else {
|
||||
await refreshTitle()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/bx-theme.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<view class="bx-page bx-page--form">
|
||||
<view class="topbar">
|
||||
<view class="topbar-left">
|
||||
<view class="topbar-back" @click="goBack">
|
||||
<text class="topbar-back-icon">‹</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="topbar-title">报销详情</text>
|
||||
<view class="topbar-right" />
|
||||
</view>
|
||||
|
||||
<template v-if="detail">
|
||||
<view class="advance-ticket-shell">
|
||||
<view class="advance-ticket-stack">
|
||||
<view class="advance-ticket-top">
|
||||
<text class="advance-ticket-code">{{ detail.applyCode || '—' }}</text>
|
||||
<text
|
||||
class="advance-ticket-status"
|
||||
:class="ticketHeaderStatusToneClass(detail.status)"
|
||||
>
|
||||
{{ statusLabel(detail.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="advance-ticket-tear">
|
||||
<view class="advance-ticket-tear__line" />
|
||||
</view>
|
||||
<view class="advance-ticket-bottom">
|
||||
<view class="advance-ticket-reason">{{ detail.title || '—' }}</view>
|
||||
<view class="advance-ticket-amount">{{ formatAmount(detail.amount) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-tabs">
|
||||
<view
|
||||
class="detail-tab"
|
||||
:class="{ active: detailTab === 0 }"
|
||||
@click="detailTab = 0"
|
||||
>
|
||||
基础信息
|
||||
</view>
|
||||
<view
|
||||
class="detail-tab"
|
||||
:class="{ active: detailTab === 1 }"
|
||||
@click="detailTab = 1"
|
||||
>
|
||||
审批信息
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detailTab === 0" class="page-padded advance-detail-stack">
|
||||
<view class="detail-info-card">
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">申请单号</text>
|
||||
<text class="adv-field__value">{{ detail.applyCode || '—' }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">报销标题</text>
|
||||
<text class="adv-field__value">{{ detail.title || '—' }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">申请人</text>
|
||||
<text class="adv-field__value">{{ detail.applicantName || '—' }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">冲抵金额</text>
|
||||
<text class="adv-field__value">
|
||||
{{ moneyOrDash(detail.offsetAmount ?? detail.offset_amount) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">实付金额</text>
|
||||
<text class="adv-field__value">
|
||||
{{
|
||||
moneyOrDash(
|
||||
detail.payAmount ?? detail.actualAmount ?? detail.actual_amount
|
||||
)
|
||||
}}
|
||||
</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">当前节点</text>
|
||||
<text class="adv-field__value">{{ detail.currentNode || '—' }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">申请时间</text>
|
||||
<text class="adv-field__value">{{ formatApplyDateTime(detail) }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">报销说明</text>
|
||||
<text class="adv-field__value adv-field__value--multiline">
|
||||
{{ detail.remark || '—' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="detail.details && detail.details.length"
|
||||
class="detail-info-card"
|
||||
>
|
||||
<view class="detail-info-card__title">费用项信息</view>
|
||||
<view
|
||||
v-for="(d, idx) in detail.details"
|
||||
:key="idx"
|
||||
class="reimburse-detail-group"
|
||||
>
|
||||
<view v-if="detail.details.length > 1" class="reimburse-detail-head">
|
||||
明细 {{ idx + 1 }}
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">费用项目</text>
|
||||
<text class="adv-field__value">{{ resolveExpenseDisplay(d, flatList) }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">金额</text>
|
||||
<text class="adv-field__value adv-field__value--accent">
|
||||
¥ {{ formatAmount(d.amount) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="d.remark" class="adv-field">
|
||||
<text class="adv-field__label">报销事由</text>
|
||||
<text class="adv-field__value adv-field__value--multiline">{{ d.remark }}</text>
|
||||
</view>
|
||||
<view class="adv-field">
|
||||
<text class="adv-field__label">发生时间</text>
|
||||
<text class="adv-field__value">{{ d.occurDate || '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="adv-field reimburse-detail-total-row">
|
||||
<text class="adv-field__label">合计</text>
|
||||
<text class="adv-field__value adv-field__value--accent">
|
||||
¥ {{ totalAmount }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-info-card">
|
||||
<view class="detail-info-card__title">申请附件</view>
|
||||
<view v-if="attachments.length" class="att-grid">
|
||||
<view
|
||||
v-for="file in attachments"
|
||||
:key="file.id"
|
||||
class="att-img"
|
||||
@click="previewAtt(file)"
|
||||
>
|
||||
<image
|
||||
v-if="file.previewUrl"
|
||||
:src="file.previewUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="detail-section-empty" style="border: none; padding: 8px">
|
||||
文件
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="detail-section-empty">暂无申请附件</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="page-padded advance-detail-stack">
|
||||
<view class="detail-info-card" style="padding: 14px 16px">
|
||||
<template v-if="approvals.length">
|
||||
<view v-for="(a, i) in approvals" :key="a.id ?? i" class="flow-step">
|
||||
<view class="flow-avatar" :class="avatarClass(a)">
|
||||
{{ (a.approverName || a.nodeName || a.nodeLabel || '审')[0] }}
|
||||
</view>
|
||||
<view class="flow-info">
|
||||
<view class="flow-name">
|
||||
{{ a.nodeName || a.nodeLabel || a.approverName || '—' }}
|
||||
</view>
|
||||
<view v-if="a.approverName" class="flow-role">{{ a.approverName }}</view>
|
||||
<view v-if="a.approvalTime" class="flow-time">
|
||||
{{ formatDateTime(a.approvalTime) }}
|
||||
</view>
|
||||
<view v-if="a.approvalOpinion" class="adv-flow-opinion">
|
||||
{{ a.approvalOpinion }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<view v-else class="detail-section-empty">暂无审批记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="page-body-loading">加载中...</view>
|
||||
|
||||
<view v-if="showActions" class="bottom-action-bar">
|
||||
<template v-if="detail?.status === 'draft'">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
style="flex: 1"
|
||||
:disabled="actionLoading.submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交审批
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
style="flex: 1"
|
||||
:disabled="actionLoading.delete"
|
||||
@click="handleDelete"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="detail?.status === 'rejected'">
|
||||
<button class="btn btn-primary" style="flex: 1" @click="handleResubmit">
|
||||
重新提交
|
||||
</button>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
import { reimburseApi, attachmentApi, expenseItemApi } from '@/api/bx'
|
||||
import {
|
||||
statusLabel,
|
||||
ticketHeaderStatusToneClass,
|
||||
formatAmount,
|
||||
formatDateTime,
|
||||
formatApplyDateTime,
|
||||
normalizeExpenseItem,
|
||||
flattenExpenseTree,
|
||||
resolveExpenseDisplay
|
||||
} from '@/utils/bxFormat'
|
||||
|
||||
const detailId = ref('')
|
||||
const detail = ref(null)
|
||||
const detailTab = ref(0)
|
||||
const approvals = ref([])
|
||||
const attachments = ref([])
|
||||
const flatList = ref([])
|
||||
const actionLoading = reactive({ submit: false, delete: false })
|
||||
|
||||
const showActions = computed(() => {
|
||||
if (!detail.value) return false
|
||||
const status = detail.value.status
|
||||
return status === 'draft' || status === 'rejected'
|
||||
})
|
||||
|
||||
const totalAmount = computed(() => {
|
||||
const rows = detail.value?.details || []
|
||||
const sum = rows.reduce((s, d) => s + Number(d.amount || 0), 0)
|
||||
return formatAmount(sum)
|
||||
})
|
||||
|
||||
function moneyOrDash(raw) {
|
||||
if (raw == null || raw === '') return '—'
|
||||
const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
|
||||
if (Number.isNaN(n) || n === 0) return '—'
|
||||
return '¥ ' + formatAmount(n)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
|
||||
}
|
||||
|
||||
function avatarClass(a) {
|
||||
const s = a.approvalStatus || a.status
|
||||
if (s === 'approved') return 'av-done'
|
||||
if (s === 'pending') return 'av-current'
|
||||
if (s === 'rejected') return 'av-reject'
|
||||
return 'av-pending'
|
||||
}
|
||||
|
||||
function previewAtt(file) {
|
||||
if (!file.previewUrl) return
|
||||
uni.previewImage({ urls: [file.previewUrl], current: file.previewUrl })
|
||||
}
|
||||
|
||||
async function loadExpenseFlat() {
|
||||
try {
|
||||
const res = await expenseItemApi.tree()
|
||||
flatList.value = flattenExpenseTree((res.data || []).map(normalizeExpenseItem))
|
||||
} catch (e) {
|
||||
flatList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const id = detailId.value
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await reimburseApi.getById(id)
|
||||
detail.value = res.data
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载详情失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const histRes = await reimburseApi.getApprovalHistory(id)
|
||||
const hist = histRes.data || []
|
||||
if (Array.isArray(hist) && hist.length) {
|
||||
approvals.value = hist
|
||||
} else {
|
||||
const apprRes = await reimburseApi.getApprovals(id)
|
||||
const nodes = apprRes.data || []
|
||||
approvals.value = nodes.slice().sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
const apprRes = await reimburseApi.getApprovals(id)
|
||||
const nodes = apprRes.data || []
|
||||
approvals.value = nodes.slice().sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
|
||||
} catch (err) {
|
||||
approvals.value = []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
|
||||
const list = (attRes.data || []).map((a) => ({ ...a, previewUrl: '' }))
|
||||
await Promise.all(
|
||||
list
|
||||
.filter((a) => (a.fileType || '').startsWith('image/'))
|
||||
.map(async (a) => {
|
||||
try {
|
||||
const pRes = await attachmentApi.preview(a.id)
|
||||
a.previewUrl = pRes.data?.url || ''
|
||||
} catch (e) {
|
||||
// skip
|
||||
}
|
||||
})
|
||||
)
|
||||
attachments.value = list
|
||||
} catch (e) {
|
||||
attachments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!detail.value) return
|
||||
actionLoading.submit = true
|
||||
try {
|
||||
await reimburseApi.submit(detail.value.id)
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
await loadDetail()
|
||||
} catch (e) {
|
||||
// toasted
|
||||
} finally {
|
||||
actionLoading.submit = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!detail.value) return
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该报销申请吗?删除后不可恢复。',
|
||||
confirmText: '确定删除',
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
actionLoading.delete = true
|
||||
try {
|
||||
await reimburseApi.delete(detail.value.id)
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
setTimeout(() => goBack(), 500)
|
||||
} catch (e) {
|
||||
// toasted
|
||||
} finally {
|
||||
actionLoading.delete = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleResubmit() {
|
||||
if (!detail.value) return
|
||||
uni.navigateTo({
|
||||
url: `${PAGE_PATHS.bxReimburseApplyForm}?id=${detail.value.id}`
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
detailId.value = query?.id ? String(query.id) : ''
|
||||
loadExpenseFlat()
|
||||
loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/bx-theme.scss';
|
||||
</style>
|
||||
@@ -1,7 +1,326 @@
|
||||
<template>
|
||||
<bx-placeholder-page default-title="报销申请" />
|
||||
<view class="bx-page">
|
||||
<view class="topbar">
|
||||
<view class="topbar-left">
|
||||
<view class="topbar-back" @click="goBack">
|
||||
<text class="topbar-back-icon">‹</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="topbar-title">报销申请</text>
|
||||
<view class="topbar-right" />
|
||||
</view>
|
||||
|
||||
<view class="fab-add" @click="goApply()">+</view>
|
||||
|
||||
<view class="search-wrap">
|
||||
<view class="search-box">
|
||||
<text class="search-box__icon">⌕</text>
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
placeholder="搜索报销单"
|
||||
confirm-type="search"
|
||||
@confirm="onRefresh"
|
||||
/>
|
||||
<view v-if="keyword.trim()" class="search-box__clear" @click="clearKeyword">×</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="search-wrap" style="padding-top: 0">
|
||||
<scroll-view scroll-x class="search-filter search-filter--scroll" :show-scrollbar="false">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value === '' ? '__all' : tab.value"
|
||||
class="filter-tag"
|
||||
:class="{ active: activeTab === tab.value }"
|
||||
@click="onTabChange(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="page-padded list-scroll"
|
||||
scroll-y
|
||||
:style="{ height: listHeight }"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onPullRefresh"
|
||||
@scrolltolower="onLoadMore"
|
||||
>
|
||||
<view v-if="!list.length && !loading" class="empty-state empty-state--fill">
|
||||
<text>暂无报销记录</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="expense-card"
|
||||
@click="goDetail(item.id)"
|
||||
>
|
||||
<view class="ec-top">
|
||||
<view class="ec-type">
|
||||
<text class="reimburse-list-card__title">{{ cardTitle(item) }}</text>
|
||||
</view>
|
||||
<text class="status-tag" :class="workflowListStatusTagClass(item.status)">
|
||||
{{ statusLabel(item.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="ec-amount-label">报销金额</view>
|
||||
<view class="ec-amount">{{ formatAmount(item.amount) }}</view>
|
||||
<view class="reimburse-card-subs">
|
||||
<view class="reimburse-card-sub-row">
|
||||
<text class="k">冲抵金额</text>
|
||||
<text class="v">¥ {{ moneyOrDash(item.offsetAmount ?? item.offset_amount) }}</text>
|
||||
</view>
|
||||
<view class="reimburse-card-sub-row">
|
||||
<text class="k">实付金额</text>
|
||||
<text class="v">
|
||||
¥ {{ moneyOrDash(item.payAmount ?? item.actualAmount ?? item.actual_amount) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list-card-kv">
|
||||
<view class="list-card-kv__row">
|
||||
<text class="list-card-kv__k">申请单号</text>
|
||||
<text class="list-card-kv__v">{{ item.applyCode || '—' }}</text>
|
||||
</view>
|
||||
<view class="list-card-kv__row">
|
||||
<text class="list-card-kv__k">申请人</text>
|
||||
<text class="list-card-kv__v">{{ item.applicantName || '-' }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="resolveExpenseDisplay(item, flatList) !== '-'"
|
||||
class="list-card-kv__row"
|
||||
>
|
||||
<text class="list-card-kv__k">费用项目</text>
|
||||
<text class="list-card-kv__v">{{ resolveExpenseDisplay(item, flatList) }}</text>
|
||||
</view>
|
||||
<view v-if="item.currentNode" class="list-card-kv__row">
|
||||
<text class="list-card-kv__k">当前环节</text>
|
||||
<text class="list-card-kv__v">{{ item.currentNode }}</text>
|
||||
</view>
|
||||
<view class="list-card-kv__row">
|
||||
<text class="list-card-kv__k">发票 / 附件</text>
|
||||
<text class="list-card-kv__v">
|
||||
{{ item.invoiceCount ?? 0 }} 张 · {{ item.attachCount ?? 0 }} 个
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="item.remark" class="list-card-kv__row">
|
||||
<text class="list-card-kv__k">备注</text>
|
||||
<text class="list-card-kv__v">{{ item.remark }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ec-meta">
|
||||
<text>{{ formatApplyDateTime(item) }}</text>
|
||||
</view>
|
||||
<view class="ec-bottom" @click.stop>
|
||||
<view v-if="item.status === 'draft' || item.status === 'rejected'" class="op-actions">
|
||||
<template v-if="item.status === 'draft'">
|
||||
<button class="btn btn-sm btn-danger" @click="handleDelete(item)">删除</button>
|
||||
<button class="btn btn-sm btn-primary" @click="goApply(item.id)">编辑</button>
|
||||
<button class="btn btn-sm btn-primary" @click="handleSubmit(item)">提交</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn btn-sm btn-primary" @click="goDetail(item.id)">查看</button>
|
||||
<button class="btn btn-sm btn-primary" @click="goApply(item.id)">编辑</button>
|
||||
<button class="btn btn-sm btn-primary" @click="handleSubmit(item)">提交</button>
|
||||
</template>
|
||||
</view>
|
||||
<view v-else class="op-actions">
|
||||
<button class="btn btn-sm btn-primary" @click="goDetail(item.id)">查看</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="list.length" class="list-footer-tip">
|
||||
{{ finished ? '没有更多了' : loading ? '加载中...' : '上拉加载更多' }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BxPlaceholderPage from '@/components/bx-ui/bx-placeholder-page.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
import { reimburseApi, expenseItemApi } from '@/api/bx'
|
||||
import {
|
||||
statusLabel,
|
||||
workflowListStatusTagClass,
|
||||
formatAmount,
|
||||
formatApplyDateTime,
|
||||
normalizeExpenseItem,
|
||||
flattenExpenseTree,
|
||||
resolveExpenseDisplay
|
||||
} from '@/utils/bxFormat'
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待提交', value: 'draft' },
|
||||
{ label: '审批中', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
{ label: '已支付', value: 'paid' }
|
||||
]
|
||||
|
||||
const keyword = ref('')
|
||||
const activeTab = ref('')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
const listHeight = ref('60vh')
|
||||
const flatList = ref([])
|
||||
|
||||
function computeListHeight() {
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
const h = (sys.windowHeight || 600) - 210
|
||||
listHeight.value = `${Math.max(h, 320)}px`
|
||||
} catch (e) {
|
||||
listHeight.value = '60vh'
|
||||
}
|
||||
}
|
||||
|
||||
function moneyOrDash(raw) {
|
||||
if (raw == null || raw === '') return '—'
|
||||
const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
|
||||
if (Number.isNaN(n) || n === 0) return '—'
|
||||
return formatAmount(n)
|
||||
}
|
||||
|
||||
function cardTitle(item) {
|
||||
const t = (item.title ?? '').toString().trim()
|
||||
if (t) return t
|
||||
const ex = resolveExpenseDisplay(item, flatList.value)
|
||||
return ex !== '-' ? ex : '-'
|
||||
}
|
||||
|
||||
async function loadExpenseFlat() {
|
||||
try {
|
||||
const res = await expenseItemApi.tree()
|
||||
const tree = (res.data || []).map(normalizeExpenseItem)
|
||||
flatList.value = flattenExpenseTree(tree)
|
||||
} catch (e) {
|
||||
flatList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage() {
|
||||
if (loading.value && !refreshing.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
pageSize
|
||||
}
|
||||
if (activeTab.value) params.status = activeTab.value
|
||||
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
||||
const res = await reimburseApi.my(params)
|
||||
const rows = res.data?.rows || res.data?.items || res.data?.list || []
|
||||
const total = res.data?.total || 0
|
||||
if (currentPage.value === 1) {
|
||||
list.value = rows
|
||||
} else {
|
||||
list.value = list.value.concat(rows)
|
||||
}
|
||||
currentPage.value += 1
|
||||
finished.value = rows.length < pageSize || list.value.length >= total
|
||||
} catch (e) {
|
||||
finished.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
currentPage.value = 1
|
||||
finished.value = false
|
||||
loading.value = false
|
||||
list.value = []
|
||||
fetchPage()
|
||||
}
|
||||
|
||||
function onPullRefresh() {
|
||||
refreshing.value = true
|
||||
currentPage.value = 1
|
||||
finished.value = false
|
||||
fetchPage()
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (finished.value || loading.value) return
|
||||
fetchPage()
|
||||
}
|
||||
|
||||
function onTabChange(value) {
|
||||
activeTab.value = value
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
keyword.value = ''
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack({ fail: () => uni.switchTab({ url: PAGE_PATHS.workbench }) })
|
||||
}
|
||||
|
||||
function goApply(id) {
|
||||
const q = id ? `?id=${id}` : ''
|
||||
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseApplyForm}${q}` })
|
||||
}
|
||||
|
||||
function goDetail(id) {
|
||||
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${id}` })
|
||||
}
|
||||
|
||||
async function handleSubmit(item) {
|
||||
try {
|
||||
await reimburseApi.submit(item.id)
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
onRefresh()
|
||||
} catch (e) {
|
||||
// bxRequest already toasted
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(item) {
|
||||
uni.showModal({
|
||||
title: '删除确认',
|
||||
content: '确定要删除该报销单吗?删除后不可恢复。',
|
||||
confirmText: '确定删除',
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await reimburseApi.delete(item.id)
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
onRefresh()
|
||||
} catch (e) {
|
||||
// toasted
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
computeListHeight()
|
||||
loadExpenseFlat()
|
||||
onRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/bx-theme.scss';
|
||||
|
||||
.list-scroll {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -255,7 +255,8 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bx-page .advance-list-card__title {
|
||||
.bx-page .advance-list-card__title,
|
||||
.bx-page .reimburse-list-card__title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -340,7 +341,8 @@
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.bx-page .advance-card-subs {
|
||||
.bx-page .advance-card-subs,
|
||||
.bx-page .reimburse-card-subs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -349,7 +351,8 @@
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bx-page .advance-card-sub-row {
|
||||
.bx-page .advance-card-sub-row,
|
||||
.bx-page .reimburse-card-sub-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -357,13 +360,15 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bx-page .advance-card-sub-row .k {
|
||||
.bx-page .advance-card-sub-row .k,
|
||||
.bx-page .reimburse-card-sub-row .k {
|
||||
color: var(--text-sec);
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.bx-page .advance-card-sub-row .v {
|
||||
.bx-page .advance-card-sub-row .v,
|
||||
.bx-page .reimburse-card-sub-row .v {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -995,3 +1000,235 @@
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Reimburse apply: detail lines + pickers */
|
||||
.bx-page .detail-line-no {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bx-page .detail-line .detail-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.bx-page .detail-line-actions {
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bx-page .detail-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bx-page .total-amount {
|
||||
font-size: 14px;
|
||||
color: var(--text-sec);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bx-page .total-amount b {
|
||||
color: var(--primary-dark);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bx-page .picker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--rs);
|
||||
background: var(--surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bx-page .picker-trigger:active {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.bx-page .picker-trigger .value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bx-page .picker-trigger .ph {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bx-page .picker-trigger .arrow {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bx-page .form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bx-page .invoice-balance-tip {
|
||||
font-size: 13px;
|
||||
color: var(--text-sec);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bx-page .invoice-balance-tip--warn {
|
||||
color: #dc2626;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.bx-page .invoice-picked-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bx-page .invoice-picked-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--rs);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.bx-page .invoice-picked-item__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bx-page .invoice-picked-item__name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bx-page .invoice-picked-item__amt {
|
||||
font-size: 12px;
|
||||
color: var(--text-sec);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bx-page .invoice-picked-item__remove {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
background: var(--surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__title {
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__item {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__item.is-selected {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__item-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__footer {
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bx-page .bx-sheet__empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bx-page .reimburse-detail-group + .reimburse-detail-group {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.bx-page .reimburse-detail-head {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bx-page .reimburse-detail-total-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -85,3 +85,67 @@ export function formatApplyDateTime(row) {
|
||||
export function formatApplyDate(row) {
|
||||
return formatDate(pickApplyTime(row))
|
||||
}
|
||||
|
||||
/** 费用树扁平化(tree → flatList) */
|
||||
export function flattenExpenseTree(tree) {
|
||||
const result = []
|
||||
for (const node of tree || []) {
|
||||
result.push(node)
|
||||
if (node.children?.length) result.push(...flattenExpenseTree(node.children))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function normalizeExpenseItem(raw) {
|
||||
const children = Array.isArray(raw?.children)
|
||||
? raw.children.map(normalizeExpenseItem)
|
||||
: undefined
|
||||
return {
|
||||
id: String(raw?.id ?? ''),
|
||||
parentId: raw?.parentId == null || raw?.parentId === '' ? '0' : String(raw.parentId),
|
||||
itemName: raw?.itemName ?? '',
|
||||
itemCode: raw?.itemCode ?? '',
|
||||
level: Number(raw?.level ?? 0),
|
||||
sortOrder: Number(raw?.sortOrder ?? 0),
|
||||
status: raw?.status,
|
||||
childCount: Number(raw?.childCount ?? children?.length ?? 0),
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
export function formatExpenseName(id, flatList) {
|
||||
if (id == null || id === '') return '-'
|
||||
const sid = String(id)
|
||||
const parts = []
|
||||
let cur = (flatList || []).find((i) => String(i.id) === sid)
|
||||
const guard = new Set()
|
||||
while (cur && !guard.has(cur.id)) {
|
||||
guard.add(cur.id)
|
||||
parts.unshift(cur.itemName)
|
||||
const pid = cur.parentId
|
||||
cur =
|
||||
pid != null && pid !== '' && pid !== '0'
|
||||
? (flatList || []).find((i) => String(i.id) === pid)
|
||||
: undefined
|
||||
}
|
||||
return parts.length ? parts.join('/') : '-'
|
||||
}
|
||||
|
||||
export function pickExpenseItemId(row) {
|
||||
if (!row || typeof row !== 'object') return null
|
||||
const raw = row.expenseId ?? row.expense_id ?? row.expenseItemId ?? row.expense_item_id
|
||||
if (raw == null || raw === '') return null
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
export function resolveExpenseDisplay(row, flatList) {
|
||||
if (!row) return '-'
|
||||
const id = pickExpenseItemId(row)
|
||||
const nameFb = row.expenseName ?? row.expense_name
|
||||
if (id != null && flatList && flatList.length) {
|
||||
const p = formatExpenseName(id, flatList)
|
||||
if (p && p !== '-') return p
|
||||
}
|
||||
const fb = nameFb != null ? String(nameFb).trim() : ''
|
||||
return fb || '-'
|
||||
}
|
||||
Reference in new issue
Block a user