Procházet zdrojové kódy

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

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong před 1 měsícem
rodič
revize
ffec3ecd94
1 změnil soubory, kde provedl 172 přidání a 75 odebrání
  1. 172 75
      miniprogram/pages/bx/reimburse/apply.vue

+ 172 - 75
miniprogram/pages/bx/reimburse/apply.vue

@@ -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)