feat(miniprogram): show invoice thumbnails in reimburse apply picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-16 21:17:34 +08:00
1 parent 01553ef0f7
commit ffec3ecd94
1 file changed
+172 -75
+172 -75
View File
@@ -139,17 +139,27 @@
</text>
</view>
<button class="btn btn-primary btn-sm" @click="openInvoicePicker">从发票夹选择</button>
<view v-if="selectedInvoicePicks.length" class="invoice-picked-row">
<view v-if="selectedInvoicePicks.length" class="invoice-thumb-grid">
<view
v-for="pick in selectedInvoicePicks"
:key="pick.key"
class="invoice-picked-item"
class="invoice-thumb"
>
<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 class="invoice-thumb__del" @click.stop="removeInvoicePick(pick.key)">×</view>
<view class="invoice-thumb__media" @click="previewInvoicePick(pick)">
<image
v-if="isImageFileType(pick.fileType) && pick.previewUrl"
:src="pick.previewUrl"
mode="aspectFill"
/>
<view v-else class="invoice-thumb__doc">
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(pick.fileName) }}</text>
</view>
</view>
<view class="invoice-thumb__meta">
<text class="invoice-thumb__amt">¥{{ money(pick.amount) }}</text>
<text class="invoice-thumb__date">{{ formatInvoiceDateYmd(pick.createTime) }}</text>
</view>
<view class="invoice-picked-item__remove" @click="removeInvoicePick(pick.key)">×</view>
</view>
</view>
<view class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</view>
@@ -217,24 +227,52 @@
</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>
<scroll-view
scroll-y
class="bx-sheet__body bx-sheet__body--invoice-thumbs"
@scrolltolower="onInvoicePickerLoadMore"
lower-threshold="80"
>
<view v-if="!attachItems.length && !invLoading" class="bx-sheet__empty">
暂无发票附件
</view>
<view v-else class="invoice-pick-grid">
<view
v-for="item in attachItems"
:key="item.key"
class="invoice-pick-card"
:class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
@click="togglePickerKey(item.key)"
>
<view class="invoice-pick-card__check">
<text v-if="pickerDraftKeys.includes(item.key)">✓</text>
</view>
<view class="invoice-pick-card__preview">
<image
v-if="isImageFileType(item.fileType) && item.previewUrl"
:src="item.previewUrl"
mode="aspectFill"
/>
<view v-else class="invoice-pick-card__doc">
<text class="invoice-thumb__doc-ext">{{ fileExtLabel(item.fileName) }}</text>
</view>
</view>
<view class="invoice-pick-card__meta">
<view class="invoice-pick-card__amt">¥{{ money(item.amount) }}</view>
<view class="invoice-pick-card__date">
{{ formatInvoiceDateYmd(item.createTime) }}
</view>
</view>
</view>
<text v-if="pickerDraftKeys.includes(item.key)">✓</text>
</view>
<view v-if="attachItems.length" class="invoice-pick-footer-tip">
<text v-if="invLoading">加载中…</text>
<text v-else-if="invFinished">没有更多了</text>
<text v-else>上拉加载更多</text>
</view>
</scroll-view>
<view class="bx-sheet__footer">
@@ -264,6 +302,13 @@ import {
flattenExpenseTree,
formatExpenseName
} from '@/utils/bxFormat'
import {
isImageFileType,
fileExtLabel,
formatInvoiceDateYmd,
buildAttachItemsFromInvoiceRows,
mergeInvoicePicksByDraftKeys
} from '@/utils/bxInvoiceThumb'
import { getSafeAreaStyle } from '@/utils/safeArea'
import { buildApplyAttachmentDisplayName } from '@/utils/bxAttachmentDisplayName'
@@ -298,7 +343,11 @@ const showExpensePicker = ref(false)
const pickingLineIndex = ref(0)
const walletBalance = ref(0)
const INVOICE_PAGE_SIZE = 10
const attachItems = ref([])
const invPage = ref(1)
const invLoading = ref(false)
const invFinished = ref(false)
const selectedInvoicePicks = ref([])
const pickerDraftKeys = ref([])
const showInvoicePicker = ref(false)
@@ -403,48 +452,6 @@ function confirmExpensePick(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),
@@ -472,22 +479,88 @@ async function refreshTitle() {
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 })
])
const sumRes = await invoiceWalletApi.mineSummary(
owner ? { userId: owner } : undefined
)
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() {
function pagedRows(res) {
const d = res?.data
if (!d) return { rows: [], total: 0 }
if (Array.isArray(d)) return { rows: d, total: d.length }
const rows = d.rows ?? d.items ?? d.list ?? []
const total = typeof d.total === 'number' ? d.total : rows.length
return { rows, total }
}
function kickAttachPreviewLoads(items) {
for (const item of items) {
if (!isImageFileType(item.fileType) || item.previewUrl || item.previewLoading) continue
item.previewLoading = true
attachmentApi
.preview(item.attachmentId)
.then((pRes) => {
item.previewUrl = pRes?.data?.url || ''
})
.catch(() => {})
.finally(() => {
item.previewLoading = false
})
}
}
async function fetchInvoiceAttachPage(isRefresh) {
if (invLoading.value) return
invLoading.value = true
try {
const owner = form.applicantId || undefined
const res = await invoiceApi.list({
page: invPage.value,
pageSize: INVOICE_PAGE_SIZE,
userId: owner
})
const { rows, total } = pagedRows(res)
const chunk = buildAttachItemsFromInvoiceRows(rows)
if (isRefresh) attachItems.value = chunk
else attachItems.value = attachItems.value.concat(chunk)
kickAttachPreviewLoads(chunk)
const invoiceCountSoFar =
(isRefresh ? 0 : (invPage.value - 1) * INVOICE_PAGE_SIZE) + rows.length
if (rows.length < INVOICE_PAGE_SIZE || invoiceCountSoFar >= total) {
invFinished.value = true
}
} catch (e) {
uni.showToast({ title: '发票列表加载失败', icon: 'none' })
if (isRefresh) invFinished.value = true
} finally {
invLoading.value = false
}
}
async function resetAndLoadInvoices() {
invPage.value = 1
invFinished.value = false
attachItems.value = []
await fetchInvoiceAttachPage(true)
invPage.value = 2
}
async function onInvoicePickerLoadMore() {
if (invFinished.value || invLoading.value) return
await fetchInvoiceAttachPage(false)
invPage.value += 1
}
async function openInvoicePicker() {
pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
showInvoicePicker.value = true
if (!attachItems.value.length && !invFinished.value) {
await resetAndLoadInvoices()
}
}
function togglePickerKey(key) {
@@ -497,13 +570,34 @@ function togglePickerKey(key) {
}
function confirmInvoicePicker() {
const set = new Set(pickerDraftKeys.value)
selectedInvoicePicks.value = attachItems.value
.filter((i) => set.has(i.key))
.map((i) => ({ ...i }))
selectedInvoicePicks.value = mergeInvoicePicksByDraftKeys(
pickerDraftKeys.value,
attachItems.value,
selectedInvoicePicks.value
)
showInvoicePicker.value = false
}
async function previewInvoicePick(pick) {
if (!isImageFileType(pick.fileType)) {
uni.showToast({ title: '非图片附件请在管理端查看', icon: 'none' })
return
}
let url = pick.previewUrl
if (!url && pick.attachmentId) {
try {
const pRes = await attachmentApi.preview(pick.attachmentId)
url = pRes?.data?.url || ''
if (url) pick.previewUrl = url
} catch (e) {
uni.showToast({ title: '暂无法预览', icon: 'none' })
return
}
}
if (url) uni.previewImage({ urls: [url], current: url })
else uni.showToast({ title: '暂无法预览', icon: 'none' })
}
function removeInvoicePick(key) {
selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
}
@@ -685,10 +779,13 @@ async function loadEditData(id) {
amount: inv.amount,
createTime,
fileName,
fileType
fileType,
previewUrl: '',
previewLoading: false
})
}
selectedInvoicePicks.value = picks
kickAttachPreviewLoads(selectedInvoicePicks.value)
try {
const attRes = await attachmentApi.listByBiz('reimburse_apply', id)