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:
wuzilongandCursor committed 2026-09-11 23:25:58 +08:00
1 parent 2ff754d572
commit 006e94ad33
8 files changed
+1861 -9

No files matched your search

+37 -2
View File
@@ -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
}
+2
View File
@@ -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',
+8
View File
@@ -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": "我的申请单" }
+796
View File
@@ -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>
+391
View File
@@ -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>
+321 -2
View File
@@ -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>
+242 -5
View File
@@ -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);
}
+64
View File
@@ -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 || '-'
}