docs: add reimburse invoice thumbnail implementation plan.
Break scheme-1 into helper, styles, apply picker, and detail tasks with verification steps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
1af6a944fb
commit
ea4d990abb
1 file changed
+919
@@ -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`
|
||||
Reference in new issue
Block a user