|
|
@@ -0,0 +1,919 @@
|
|
|
+# 小程序报销关联发票缩略图 Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 报销申请选择/已选、报销详情展示关联发票均改为缩略图(金额+日期),选择弹层支持每次 10 条滚动加载。
|
|
|
+
|
|
|
+**Architecture:** 方案 1 页面内改造。抽取纯函数工具 `bxInvoiceThumb.js`(扁平化、合并勾选、日期/图片判断)供 apply/detail 复用;样式补在 `bx-theme.scss`;不改后端。
|
|
|
+
|
|
|
+**Tech Stack:** uni-app Vue 3、`invoiceApi` / `attachmentApi`、现有 `bx-theme.scss` Token。
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-16-bx-reimburse-invoice-thumbnail-design.md`
|
|
|
+**Demo:** `docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| File | Responsibility |
|
|
|
+|------|----------------|
|
|
|
+| Create: `miniprogram/utils/bxInvoiceThumb.js` | 纯函数:图片判断、扩展名、日期 YYYY-MM-DD、发票→附件项、draft 合并 |
|
|
|
+| Create: `miniprogram/scripts/test-bx-invoice-thumb.mjs` | Node 断言脚本验证纯函数(仓库无 miniprogram 单测框架) |
|
|
|
+| Modify: `miniprogram/styles/bx-theme.scss` | `.invoice-thumb-grid` / 选中卡 / 已选缩略图副文案 |
|
|
|
+| Modify: `miniprogram/pages/bx/reimburse/apply.vue` | 弹层缩略图分页 + 已选缩略图预览 |
|
|
|
+| Modify: `miniprogram/pages/bx/reimburse/detail.vue` | 「关联发票」区块(在申请附件上方) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 纯函数工具 + 断言脚本
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `miniprogram/utils/bxInvoiceThumb.js`
|
|
|
+- Create: `miniprogram/scripts/test-bx-invoice-thumb.mjs`
|
|
|
+
|
|
|
+- [ ] **Step 1: 先写失败的断言脚本**
|
|
|
+
|
|
|
+创建 `miniprogram/scripts/test-bx-invoice-thumb.mjs`:
|
|
|
+
|
|
|
+```js
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import {
|
|
|
+ isImageFileType,
|
|
|
+ fileExtLabel,
|
|
|
+ formatInvoiceDateYmd,
|
|
|
+ buildAttachItemsFromInvoiceRows,
|
|
|
+ mergeInvoicePicksByDraftKeys
|
|
|
+} from '../utils/bxInvoiceThumb.js'
|
|
|
+
|
|
|
+assert.equal(isImageFileType('image/jpeg'), true)
|
|
|
+assert.equal(isImageFileType('application/pdf'), false)
|
|
|
+assert.equal(fileExtLabel('a.PDF'), 'PDF')
|
|
|
+assert.equal(formatInvoiceDateYmd('2026-09-16T12:00:00'), '2026-09-16')
|
|
|
+assert.equal(formatInvoiceDateYmd(''), '—')
|
|
|
+
|
|
|
+const rows = [
|
|
|
+ {
|
|
|
+ id: 'inv1',
|
|
|
+ amount: 100,
|
|
|
+ createTime: '2026-09-01T10:00:00',
|
|
|
+ attachments: [
|
|
|
+ { id: 'a1', fileName: 'x.jpg', fileType: 'image/jpeg' },
|
|
|
+ { id: 'a2', fileName: 'y.pdf', fileType: 'application/pdf' }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+]
|
|
|
+const items = buildAttachItemsFromInvoiceRows(rows)
|
|
|
+assert.equal(items.length, 2)
|
|
|
+assert.equal(items[0].key, 'inv1:a1')
|
|
|
+assert.equal(items[1].fileName, 'y.pdf')
|
|
|
+
|
|
|
+const prev = [
|
|
|
+ {
|
|
|
+ key: 'inv9:a9',
|
|
|
+ invoiceId: 'inv9',
|
|
|
+ attachmentId: 'a9',
|
|
|
+ amount: 9,
|
|
|
+ createTime: '2026-08-01',
|
|
|
+ fileName: 'old.jpg',
|
|
|
+ fileType: 'image/jpeg',
|
|
|
+ previewUrl: 'http://old'
|
|
|
+ }
|
|
|
+]
|
|
|
+const merged = mergeInvoicePicksByDraftKeys(
|
|
|
+ ['inv9:a9', 'inv1:a1'],
|
|
|
+ items,
|
|
|
+ prev
|
|
|
+)
|
|
|
+assert.equal(merged.length, 2)
|
|
|
+assert.ok(merged.some((p) => p.key === 'inv9:a9' && p.previewUrl === 'http://old'))
|
|
|
+assert.ok(merged.some((p) => p.key === 'inv1:a1'))
|
|
|
+
|
|
|
+console.log('bxInvoiceThumb ok')
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行确认失败(模块不存在)**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd miniprogram && node scripts/test-bx-invoice-thumb.mjs
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL — `Cannot find module` / ERR_MODULE_NOT_FOUND for `bxInvoiceThumb.js`
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `bxInvoiceThumb.js`**
|
|
|
+
|
|
|
+Create `miniprogram/utils/bxInvoiceThumb.js`:
|
|
|
+
|
|
|
+```js
|
|
|
+export function isImageFileType(fileType) {
|
|
|
+ return !!(fileType && String(fileType).startsWith('image/'))
|
|
|
+}
|
|
|
+
|
|
|
+export function fileExtLabel(name) {
|
|
|
+ const ext = String(name || '').split('.').pop()
|
|
|
+ return ext ? ext.toUpperCase() : 'FILE'
|
|
|
+}
|
|
|
+
|
|
|
+/** 展示用 YYYY-MM-DD;无有效日期返回 — */
|
|
|
+export function formatInvoiceDateYmd(value) {
|
|
|
+ if (value == null || value === '') return '—'
|
|
|
+ const s = String(value).trim()
|
|
|
+ const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
|
|
|
+ if (m) return m[1]
|
|
|
+ const d = new Date(s)
|
|
|
+ if (Number.isNaN(d.getTime())) return '—'
|
|
|
+ const y = d.getFullYear()
|
|
|
+ const mo = String(d.getMonth() + 1).padStart(2, '0')
|
|
|
+ const day = String(d.getDate()).padStart(2, '0')
|
|
|
+ return `${y}-${mo}-${day}`
|
|
|
+}
|
|
|
+
|
|
|
+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 []
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 发票列表行 → 附件粒度选项
|
|
|
+ * @returns {{ key, invoiceId, attachmentId, amount, createTime, fileName, fileType, previewUrl, previewLoading }[]}
|
|
|
+ */
|
|
|
+export function buildAttachItemsFromInvoiceRows(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,
|
|
|
+ previewUrl: '',
|
|
|
+ previewLoading: false
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return items
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 确定勾选:已加载项优先;draft 中仍勾选但不在已加载列表的旧已选项保留(防分页冲掉)
|
|
|
+ */
|
|
|
+export function mergeInvoicePicksByDraftKeys(draftKeys, attachItems, previousPicks) {
|
|
|
+ const set = new Set(draftKeys || [])
|
|
|
+ const fromLoaded = (attachItems || []).filter((i) => set.has(i.key))
|
|
|
+ const loadedKeys = new Set(fromLoaded.map((i) => i.key))
|
|
|
+ const fromPrev = (previousPicks || []).filter(
|
|
|
+ (p) => set.has(p.key) && !loadedKeys.has(p.key)
|
|
|
+ )
|
|
|
+ const byKey = new Map()
|
|
|
+ for (const p of fromPrev) byKey.set(p.key, { ...p })
|
|
|
+ for (const i of fromLoaded) {
|
|
|
+ const prev = byKey.get(i.key)
|
|
|
+ byKey.set(i.key, {
|
|
|
+ ...i,
|
|
|
+ previewUrl: i.previewUrl || prev?.previewUrl || ''
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return Array.from(set)
|
|
|
+ .map((k) => byKey.get(k))
|
|
|
+ .filter(Boolean)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 再跑断言脚本**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```bash
|
|
|
+cd miniprogram && node scripts/test-bx-invoice-thumb.mjs
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 打印 `bxInvoiceThumb ok`,exit 0
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/utils/bxInvoiceThumb.js miniprogram/scripts/test-bx-invoice-thumb.mjs
|
|
|
+git commit -m "feat(miniprogram): add invoice thumb helper utilities."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: bx-theme 缩略图样式
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `miniprogram/styles/bx-theme.scss`(在 `.invoice-picked-*` 段落后追加;可保留旧 class 以免其它页引用,但申请页将改用新 class)
|
|
|
+
|
|
|
+- [ ] **Step 1: 追加样式**
|
|
|
+
|
|
|
+在 `.bx-page .invoice-picked-item__remove { ... }` 块之后插入:
|
|
|
+
|
|
|
+```scss
|
|
|
+/* 关联发票缩略图(申请已选 / 详情 / 选择弹层) */
|
|
|
+.bx-page .invoice-thumb-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb {
|
|
|
+ position: relative;
|
|
|
+ width: 72px;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__media {
|
|
|
+ width: 72px;
|
|
|
+ height: 72px;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid var(--border);
|
|
|
+ background: #f1f5f9;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__media image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__doc {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 2px;
|
|
|
+ padding: 6px;
|
|
|
+ font-size: 10px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__doc-ext {
|
|
|
+ font-weight: 700;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--text-sec);
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__meta {
|
|
|
+ width: 72px;
|
|
|
+ margin-top: 4px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__amt {
|
|
|
+ display: block;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--primary-dark);
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__date {
|
|
|
+ display: block;
|
|
|
+ font-size: 10px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ margin-top: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-thumb__del {
|
|
|
+ position: absolute;
|
|
|
+ top: 2px;
|
|
|
+ right: 2px;
|
|
|
+ z-index: 2;
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ line-height: 20px;
|
|
|
+ text-align: center;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(15, 23, 42, 0.55);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .bx-sheet__body--invoice-thumbs {
|
|
|
+ padding: 12px;
|
|
|
+ max-height: 50vh;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card {
|
|
|
+ position: relative;
|
|
|
+ border: 1.5px solid var(--border);
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: var(--surface);
|
|
|
+ padding: 0;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card.is-selected {
|
|
|
+ border-color: var(--primary);
|
|
|
+ box-shadow: 0 0 0 1px var(--primary);
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__check {
|
|
|
+ position: absolute;
|
|
|
+ top: 6px;
|
|
|
+ right: 6px;
|
|
|
+ z-index: 2;
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 1.5px solid #fff;
|
|
|
+ background: rgba(15, 23, 42, 0.25);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 12px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card.is-selected .invoice-pick-card__check {
|
|
|
+ background: var(--primary);
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__preview {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 1 / 1;
|
|
|
+ background: #f1f5f9;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__preview image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__doc {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 96px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 8px;
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__meta {
|
|
|
+ padding: 6px 8px 8px;
|
|
|
+ border-top: 1px solid var(--border);
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__amt {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: var(--primary-dark);
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-card__date {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 10px;
|
|
|
+ color: var(--text-muted);
|
|
|
+}
|
|
|
+
|
|
|
+.bx-page .invoice-pick-footer-tip {
|
|
|
+ margin-top: 10px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ padding: 4px 0 8px;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 目视确认 Demo 类名语义一致(无需编译)**
|
|
|
+
|
|
|
+对照 `docs/superpowers/demos/2026-09-16-bx-reimburse-invoice-thumb-demo.html`:3 列网格、72 已选格、选中描边。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/styles/bx-theme.scss
|
|
|
+git commit -m "style(miniprogram): add invoice thumbnail grid styles for bx."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 申请页 — 选择弹层缩略图 + 分页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `miniprogram/pages/bx/reimburse/apply.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 替换导入与状态(去掉一次拉 200)**
|
|
|
+
|
|
|
+在 `<script setup>` 中:
|
|
|
+
|
|
|
+1. 增加 import:
|
|
|
+
|
|
|
+```js
|
|
|
+import {
|
|
|
+ isImageFileType,
|
|
|
+ fileExtLabel,
|
|
|
+ formatInvoiceDateYmd,
|
|
|
+ buildAttachItemsFromInvoiceRows,
|
|
|
+ mergeInvoicePicksByDraftKeys
|
|
|
+} from '@/utils/bxInvoiceThumb'
|
|
|
+```
|
|
|
+
|
|
|
+2. 常量与状态:
|
|
|
+
|
|
|
+```js
|
|
|
+const INVOICE_PAGE_SIZE = 10
|
|
|
+const attachItems = ref([])
|
|
|
+const invPage = ref(1)
|
|
|
+const invLoading = ref(false)
|
|
|
+const invFinished = ref(false)
|
|
|
+```
|
|
|
+
|
|
|
+3. 删除本地 `invoiceFileList` / `buildAttachItems`(改用工具函数)。
|
|
|
+
|
|
|
+4. `refreshWallet` 只拉余额:
|
|
|
+
|
|
|
+```js
|
|
|
+async function refreshWallet() {
|
|
|
+ try {
|
|
|
+ const owner = form.applicantId || undefined
|
|
|
+ const sumRes = await invoiceWalletApi.mineSummary(
|
|
|
+ owner ? { userId: owner } : undefined
|
|
|
+ )
|
|
|
+ walletBalance.value = Number(sumRes?.data?.balance ?? 0)
|
|
|
+ } catch (e) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+5. 新增分页加载与 preview:
|
|
|
+
|
|
|
+```js
|
|
|
+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
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+6. 打开 / 确定 / 预览:
|
|
|
+
|
|
|
+```js
|
|
|
+async function openInvoicePicker() {
|
|
|
+ pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
|
|
|
+ showInvoicePicker.value = true
|
|
|
+ if (!attachItems.value.length && !invFinished.value) {
|
|
|
+ await resetAndLoadInvoices()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function confirmInvoicePicker() {
|
|
|
+ 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' })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+7. `loadEditData` 中构建 picks 时补 `previewUrl: ''`,构建完成后:
|
|
|
+
|
|
|
+```js
|
|
|
+kickAttachPreviewLoads(selectedInvoicePicks.value)
|
|
|
+```
|
|
|
+
|
|
|
+(`kickAttachPreviewLoads` 需能改 ref 数组内对象字段;与 mine 页一样原地赋值即可。)
|
|
|
+
|
|
|
+编辑回填若仍依赖 `attachItems` 补 attachmentId,改为仅用详情 `invoices` 字段(已有 attachmentId);去掉对全量 attachItems 的依赖。示例:
|
|
|
+
|
|
|
+```js
|
|
|
+picks.push({
|
|
|
+ key: `${invoiceId}:${attachmentId}`,
|
|
|
+ invoiceId,
|
|
|
+ attachmentId,
|
|
|
+ amount: inv.amount,
|
|
|
+ createTime: createTime,
|
|
|
+ fileName,
|
|
|
+ fileType,
|
|
|
+ previewUrl: '',
|
|
|
+ previewLoading: false
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 替换模板 — 已选区 + 弹层**
|
|
|
+
|
|
|
+已选区(替换原 `invoice-picked-row` 块):
|
|
|
+
|
|
|
+```vue
|
|
|
+<view v-if="selectedInvoicePicks.length" class="invoice-thumb-grid">
|
|
|
+ <view
|
|
|
+ v-for="pick in selectedInvoicePicks"
|
|
|
+ :key="pick.key"
|
|
|
+ class="invoice-thumb"
|
|
|
+ >
|
|
|
+ <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>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+弹层(替换原文字 `bx-sheet__item` 列表):
|
|
|
+
|
|
|
+```vue
|
|
|
+<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 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>
|
|
|
+ </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">
|
|
|
+ <button class="btn btn-primary btn-block" @click="confirmInvoicePicker">
|
|
|
+ 确定(已选 {{ pickerDraftKeys.length }})
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+模板中可直接调用已导入的 `isImageFileType` / `fileExtLabel` / `formatInvoiceDateYmd`(script setup 自动暴露)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 手动冒烟(开发者工具)**
|
|
|
+
|
|
|
+1. 打开报销申请 → 从发票夹选择 → 见 3 列缩略图。
|
|
|
+2. 发票足够多时滚到底加载第 2 页。
|
|
|
+3. 勾选确定 → 已选区为缩略图 + 金额 + 日期;点击可预览。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/pages/bx/reimburse/apply.vue
|
|
|
+git commit -m "feat(miniprogram): show invoice thumbnails in reimburse apply picker."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 详情页 — 关联发票缩略图
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `miniprogram/pages/bx/reimburse/detail.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 增加状态与加载逻辑**
|
|
|
+
|
|
|
+Import:
|
|
|
+
|
|
|
+```js
|
|
|
+import {
|
|
|
+ isImageFileType,
|
|
|
+ fileExtLabel,
|
|
|
+ formatInvoiceDateYmd
|
|
|
+} from '@/utils/bxInvoiceThumb'
|
|
|
+```
|
|
|
+
|
|
|
+状态:
|
|
|
+
|
|
|
+```js
|
|
|
+const linkedInvoices = ref([])
|
|
|
+```
|
|
|
+
|
|
|
+在 `loadDetail` 成功拿到 `detail.value` 后、加载申请附件之前,构建 linkedInvoices:
|
|
|
+
|
|
|
+```js
|
|
|
+const invs = detail.value?.invoices || []
|
|
|
+linkedInvoices.value = invs
|
|
|
+ .map((inv) => {
|
|
|
+ const attachmentId = String(inv.attachmentId || inv.attachment_id || '')
|
|
|
+ if (!attachmentId) return null
|
|
|
+ return {
|
|
|
+ key: `${inv.id || inv.invoiceId}:${attachmentId}`,
|
|
|
+ invoiceId: String(inv.id || inv.invoiceId || ''),
|
|
|
+ attachmentId,
|
|
|
+ amount: inv.amount,
|
|
|
+ createTime: inv.createTime || inv.create_time || '',
|
|
|
+ fileName: inv.fileName || inv.file_name || '发票附件',
|
|
|
+ fileType: inv.fileType || inv.file_type || '',
|
|
|
+ previewUrl: '',
|
|
|
+ previewLoading: false
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter(Boolean)
|
|
|
+
|
|
|
+for (const item of linkedInvoices.value) {
|
|
|
+ if (!isImageFileType(item.fileType)) continue
|
|
|
+ item.previewLoading = true
|
|
|
+ try {
|
|
|
+ const pRes = await attachmentApi.preview(item.attachmentId)
|
|
|
+ item.previewUrl = pRes?.data?.url || ''
|
|
|
+ } catch (e) {
|
|
|
+ // keep empty
|
|
|
+ } finally {
|
|
|
+ item.previewLoading = false
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+预览函数(可与附件共用增强版):
|
|
|
+
|
|
|
+```js
|
|
|
+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' })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 模板插入「关联发票」卡片**
|
|
|
+
|
|
|
+在「申请附件」`detail-info-card` **正上方**插入:
|
|
|
+
|
|
|
+```vue
|
|
|
+<view class="detail-info-card">
|
|
|
+ <view class="detail-info-card__title">关联发票</view>
|
|
|
+ <view v-if="linkedInvoices.length" class="invoice-thumb-grid" style="margin-top: 0">
|
|
|
+ <view
|
|
|
+ v-for="pick in linkedInvoices"
|
|
|
+ :key="pick.key"
|
|
|
+ class="invoice-thumb"
|
|
|
+ @click="previewInvoicePick(pick)"
|
|
|
+ >
|
|
|
+ <view class="invoice-thumb__media">
|
|
|
+ <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">¥{{ formatAmount(pick.amount) }}</text>
|
|
|
+ <text class="invoice-thumb__date">{{ formatInvoiceDateYmd(pick.createTime) }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view v-else class="detail-section-empty">暂无关联发票</view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+确认 `formatAmount` 已从 `@/utils/bxFormat` 导入(详情页已有则复用)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 手动验证**
|
|
|
+
|
|
|
+1. 从「我的申请单」打开已关联发票的报销详情 → 「关联发票」在「申请附件」上方。
|
|
|
+2. 从「全部申请单」同样进入 → 一致。
|
|
|
+3. 无关联发票 → 「暂无关联发票」。
|
|
|
+4. 点击缩略图可预览。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/pages/bx/reimburse/detail.vue
|
|
|
+git commit -m "feat(miniprogram): show linked invoice thumbnails on reimburse detail."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 回归与图谱更新
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- 无新代码文件;按仓库约定更新 graphify
|
|
|
+
|
|
|
+- [ ] **Step 1: 再跑纯函数脚本**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd miniprogram && node scripts/test-bx-invoice-thumb.mjs
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `bxInvoiceThumb ok`
|
|
|
+
|
|
|
+- [ ] **Step 2: 按 Spec §6 清单手测**
|
|
|
+
|
|
|
+对照 `docs/superpowers/specs/2026-09-16-bx-reimburse-invoice-thumbnail-design.md` 第 6 节 6 条。
|
|
|
+
|
|
|
+- [ ] **Step 3: 更新 miniprogram 知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh miniprogram
|
|
|
+```
|
|
|
+
|
|
|
+或在 `miniprogram` 目录:`graphify update .`(以仓库脚本为准)。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit 图谱(若有 diff)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/graphify-out/
|
|
|
+git commit -m "chore(miniprogram): update graphify after invoice thumbnails."
|
|
|
+```
|
|
|
+
|
|
|
+若无变更则跳过 commit。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage self-check
|
|
|
+
|
|
|
+| Spec 要求 | Task |
|
|
|
+|-----------|------|
|
|
|
+| 弹层缩略图网格 + 多选 | Task 3 |
|
|
|
+| pageSize=10 滚动加载 | Task 3 |
|
|
|
+| 已选缩略图 + 预览 + × | Task 3 |
|
|
|
+| 金额+日期 | Task 2/3/4 |
|
|
|
+| 详情关联发票在申请附件上方 | Task 4 |
|
|
|
+| 我的/全部共用详情 | Task 4(同一页) |
|
|
|
+| 分页不冲掉已选 | Task 1 `mergeInvoicePicksByDraftKeys` + Task 3 |
|
|
|
+| 不改后端 / payload | 全任务未改 backend |
|
|
|
+| 非图片占位与 toast | Task 3/4 |
|
|
|
+| 样式 Token | Task 2 |
|
|
|
+
|
|
|
+## Placeholder scan
|
|
|
+
|
|
|
+无 TBD /「类似 Task N」占位;命令与关键代码块已写全。
|
|
|
+
|
|
|
+## Type / API consistency
|
|
|
+
|
|
|
+- `key` = `` `${invoiceId}:${attachmentId}` ``
|
|
|
+- Preview:`attachmentApi.preview(id)` → `data.url`
|
|
|
+- 列表:`invoiceApi.list({ page, pageSize: 10, userId })`
|
|
|
+- 详情:`detail.invoices[].attachmentId|fileType|createTime|amount`
|