refactor(bx-mobile): use attachment-level invoice picks in reimburse apply logic

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-09 19:44:37 +08:00
1 parent 38383b35d9
commit 9f92012e43
1 file changed
+199 -49
@@ -161,42 +161,40 @@
<button type="button" class="btn btn-primary btn-sm" @click="openInvoicePicker">
从发票夹选择
</button>
<div v-if="selectedInvoiceIds.length" class="invoice-picked">
<div v-if="selectedInvoicePicksSorted.length" class="invoice-picked">
<div
v-for="inv in selectedInvoiceRows"
:key="inv.id"
v-for="pick in selectedInvoicePicksSorted"
:key="pick.key"
class="invoice-picked__item"
>
<span>¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}</span>
<button type="button" class="del" @click="removeInvoice(inv.id)">×</button>
<span>¥{{ money(pick.amount) }} · {{ pick.fileName }}</span>
<button type="button" class="del" @click="removeInvoicePick(pick.key)">×</button>
</div>
</div>
<p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</p>
<p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。选择对象为附件文件。</p>
</div>
<van-popup v-model:show="showInvoicePicker" position="bottom" round :style="{ height: '70%' }">
<div class="invoice-picker">
<div class="invoice-picker__title">选择发票</div>
<van-checkbox-group v-model="pickerCheckedIds">
<van-cell-group>
<van-cell
v-for="inv in walletInvoices"
:key="inv.id"
clickable
@click="togglePickerId(inv.id)"
>
<template #title>
¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}
</template>
<template #right-icon>
<van-checkbox :name="String(inv.id)" @click.stop />
</template>
</van-cell>
</van-cell-group>
</van-checkbox-group>
<div class="invoice-picker__title">选择发票附件</div>
<van-cell-group>
<van-cell
v-for="item in attachItems"
:key="item.key"
clickable
@click="togglePickerKey(item.key)"
>
<template #title>
¥{{ money(item.amount) }} · {{ item.fileName }}
</template>
<template #right-icon>
<span v-if="pickerDraftKeys.includes(item.key)">✓</span>
</template>
</van-cell>
</van-cell-group>
<div class="invoice-picker__footer">
<button type="button" class="btn btn-primary btn-block" @click="confirmInvoicePicker">
确定(已选 {{ pickerCheckedIds.length }})
确定(已选 {{ pickerDraftKeys.length }})
</button>
</div>
</div>
@@ -296,7 +294,7 @@
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showDialog } from 'vant'
import { invoiceApi, invoiceWalletApi, reimburseApi } from '@/api'
import { attachmentApi, invoiceApi, invoiceWalletApi, reimburseApi } from '@/api'
import { BizError } from '@/api/client'
import {
useExpenseItemTree,
@@ -361,6 +359,7 @@ function onApplicantConfirm({ selectedOptions }: any) {
const opt = selectedOptions?.[0]
if (opt?.value) {
form.applicantId = String(opt.value)
selectedInvoicePicks.value = []
refreshTitle()
refreshWallet()
}
@@ -382,11 +381,23 @@ async function refreshTitle() {
}
}
type InvoiceAttachPick = {
key: string
invoiceId: string
attachmentId: string
amount: number | string
createTime: string
fileName: string
fileType: string
previewUrl: string
}
const walletBalance = ref(0)
const walletInvoices = ref<any[]>([])
const selectedInvoiceIds = ref<string[]>([])
const selectedInvoicePicks = ref<InvoiceAttachPick[]>([])
const pickerDraftKeys = ref<string[]>([])
const attachItems = ref<InvoiceAttachPick[]>([])
const showInvoicePicker = ref(false)
const pickerCheckedIds = ref<string[]>([])
const detailTotalNumber = computed(() =>
form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0),
@@ -403,8 +414,37 @@ const balanceInsufficient = computed(
() => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value,
)
const selectedInvoiceRows = computed(() =>
walletInvoices.value.filter((inv) => selectedInvoiceIds.value.includes(String(inv.id))),
const pickerMonthGroups = computed(() => {
const map = new Map<string, InvoiceAttachPick[]>()
for (const item of attachItems.value) {
const k = monthKey(item.createTime)
const list = map.get(k) || []
list.push(item)
map.set(k, list)
}
return Array.from(map.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([key, items]) => {
const sorted = items
.slice()
.sort((a, b) => String(b.createTime).localeCompare(String(a.createTime)))
return {
monthKey: key,
monthLabel: monthLabel(key),
items: sorted.map((item, idx) => ({ ...item, serial: idx + 1 })),
}
})
})
const selectedInvoicePicksSorted = computed(() =>
selectedInvoicePicks.value
.slice()
.sort((a, b) => {
const ta = String(a.createTime || '')
const tb = String(b.createTime || '')
if (ta !== tb) return tb.localeCompare(ta)
return String(b.attachmentId).localeCompare(String(a.attachmentId))
}),
)
function money(v: unknown) {
@@ -414,6 +454,89 @@ function money(v: unknown) {
})
}
function isImageFileType(ft: string) {
return String(ft || '').startsWith('image/')
}
function monthKey(raw: string) {
const text = String(raw || '').trim()
const m = text.match(/^(\d{4})[-/]?(\d{2})/)
if (m) return `${m[1]}-${m[2]}`
return 'unknown'
}
function monthLabel(key: string) {
if (key === 'unknown') return '未知月份'
const [y, m] = key.split('-')
return `${y}年${Number(m)}月`
}
function invoiceFileList(inv: any): Array<{ id: string; fileName: string; fileType: string }> {
const raw = Array.isArray(inv?.attachments) ? inv.attachments : []
if (raw.length) {
return raw.map((a: any) => ({
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: any[]): InvoiceAttachPick[] {
const items: InvoiceAttachPick[] = []
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,
previewUrl: '',
})
}
}
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 }
}
function kickAttachPreviews(items: InvoiceAttachPick[]) {
for (const item of items) {
if (!isImageFileType(item.fileType) || item.previewUrl) continue
attachmentApi
.preview(item.attachmentId)
.then((pRes: any) => {
item.previewUrl = pRes?.data?.url || ''
})
.catch(() => {
/* optional */
})
}
}
async function refreshWallet() {
try {
const owner = form.applicantId || undefined
@@ -424,37 +547,34 @@ async function refreshWallet() {
walletBalance.value = Number(sumRes?.data?.balance ?? 0)
const d = invRes?.data
walletInvoices.value = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
attachItems.value = buildAttachItems(walletInvoices.value)
kickAttachPreviews(attachItems.value)
} catch {
/* ignore */
}
}
function openInvoicePicker() {
pickerCheckedIds.value = [...selectedInvoiceIds.value]
pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
showInvoicePicker.value = true
}
function togglePickerId(id: string | number) {
const sid = String(id)
const i = pickerCheckedIds.value.indexOf(sid)
if (i >= 0) pickerCheckedIds.value.splice(i, 1)
else pickerCheckedIds.value.push(sid)
function togglePickerKey(key: string) {
const i = pickerDraftKeys.value.indexOf(key)
if (i >= 0) pickerDraftKeys.value.splice(i, 1)
else pickerDraftKeys.value.push(key)
}
function confirmInvoicePicker() {
selectedInvoiceIds.value = [...pickerCheckedIds.value]
const set = new Set(pickerDraftKeys.value)
selectedInvoicePicks.value = attachItems.value
.filter((i) => set.has(i.key))
.map((i) => ({ ...i }))
showInvoicePicker.value = false
}
function removeInvoice(id: string | number) {
const sid = String(id)
selectedInvoiceIds.value = selectedInvoiceIds.value.filter((x) => x !== sid)
}
function buildInvoicePayload() {
const invoiceIds = [...selectedInvoiceIds.value]
const invoiceLinks = invoiceIds.map((invoiceId) => ({ invoiceId }))
return { invoiceIds, invoiceLinks }
function removeInvoicePick(key: string) {
selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
}
function lineExpenseDisplay(id: number | string): string {
@@ -664,7 +784,7 @@ async function loadEditData() {
const id = route.query.id
if (!id) {
editingId.value = null
selectedInvoiceIds.value = []
selectedInvoicePicks.value = []
return
}
editingId.value = String(id)
@@ -685,8 +805,38 @@ async function loadEditData() {
form.details = [{ expenseItemId: '', amount: '', remark: '' }]
}
const linked = data.invoices || []
selectedInvoiceIds.value = linked.map((x: any) => String(x.id || x.invoiceId)).filter(Boolean)
await refreshWallet()
const picks: InvoiceAttachPick[] = []
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 = walletInvoices.value.find((w) => String(w.id) === invoiceId)
const first = src ? invoiceFileList(src)[0] : undefined
if (first) {
attachmentId = first.id
fileName = first.fileName
fileType = first.fileType
}
}
if (!attachmentId) continue
picks.push({
key: `${invoiceId}:${attachmentId}`,
invoiceId,
attachmentId,
amount: inv.amount,
createTime,
fileName,
fileType,
previewUrl: '',
})
}
selectedInvoicePicks.value = picks
kickAttachPreviews(selectedInvoicePicks.value)
} catch (e: any) {
showToast(e?.message || '加载数据失败')
}
@@ -697,7 +847,7 @@ watch(
async (id, prev) => {
if (!id) {
editingId.value = null
selectedInvoiceIds.value = []
selectedInvoicePicks.value = []
return
}
if (String(id) === String(prev ?? '')) return