docs: add plan for bx-mobile reimburse invoice attachment picker

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-09 19:42:03 +08:00
1 parent 2f87223236
commit 47ef575524
1 file changed
+651
@@ -0,0 +1,651 @@
# bx-mobile 报销申请发票附件选择 — 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:** 将 `bx-mobile` 报销申请页的「从发票夹选择」从发票级改为附件级,UI 与提交 payload 对齐 web-admin。
**Architecture:** 仅改造 `ReimburseApplyPage.vue`:用 `invoiceApi.list` 返回的 `attachments` flatten 为可选附件项;底部 sheet 按月 3 列缩略图多选;状态为 `selectedInvoicePicks`;提交 `invoiceLinks: [{ invoiceId, attachmentId }]`。预览用现有 `attachmentApi.preview`。后端契约已支持,不改 API。
**Tech Stack:** Vue 3 + Vant 4 + TypeScript;bx-mobile Fresh Mint tokens;对照 Demo `mobile-ui/shanxia-mp-bx-reimburse-invoice-picker-demo.html`。
**Spec:** `docs/superpowers/specs/2026-08-09-bx-mobile-reimburse-invoice-attach-picker-design.md`
---
## 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| Modify | `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue` | 附件级状态、flatten、picker UI、payload、编辑回显、样式 |
| Reference | `mobile-ui/shanxia-mp-bx-reimburse-invoice-picker-demo.html` | 视觉/交互对照(已提交,勿改除非 Demo 纠错) |
| Reference | `web-admin/src/components/bx/base/BxInvoiceSelectDialog.vue` | flatten / key / 月份分组语义对照 |
| Reference | `bx-mobile/src/views/invoice/InvoiceWalletMinePage.vue` | `normalizeInvoiceFiles` / preview 模式可抄 |
**不新建文件**(YAGNI:无 vitest、不抽公共组件)。
**验证:** `pnpm`/`npm` 在 `bx-mobile` 下 `vue-tsc --noEmit`(或 `npm run build`);手工打开 `/reimburse/apply` 验收。
---
### Task 1: 替换状态模型与纯函数(script)
**Files:**
- Modify: `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue`(`<script setup>`)
- [ ] **Step 1: 增加 import**
在现有 import 中加入 `attachmentApi`:
```ts
import { attachmentApi, invoiceApi, invoiceWalletApi, reimburseApi } from '@/api'
```
- [ ] **Step 2: 定义类型与状态,删除发票级状态**
删除:
```ts
const selectedInvoiceIds = ref<string[]>([])
const pickerCheckedIds = ref<string[]>([])
const selectedInvoiceRows = computed(...)
```
新增:
```ts
type InvoiceAttachPick = {
key: string
invoiceId: string
attachmentId: string
amount: number | string
createTime: string
fileName: string
fileType: string
previewUrl: string
}
const selectedInvoicePicks = ref<InvoiceAttachPick[]>([])
const pickerDraftKeys = ref<string[]>([])
const attachItems = ref<InvoiceAttachPick[]>([])
```
保留 `walletInvoices`、`showInvoicePicker`、`walletBalance`。
- [ ] **Step 3: 实现 flatten / 月份 / payload / 图片判断**
紧挨 `money()` 附近加入(名称与语义对齐 Web):
```ts
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 }
}
```
- [ ] **Step 4: 月份分组 computed + 选中列表排序**
```ts
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))
}),
)
```
- [ ] **Step 5: 更新 refreshWallet,构建 attachItems 并 kick 预览**
```ts
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
const [sumRes, invRes]: any[] = await Promise.all([
invoiceWalletApi.mineSummary(owner ? { userId: owner } : undefined),
invoiceApi.list({ page: 1, pageSize: 200, userId: owner }),
])
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 */
}
}
```
- [ ] **Step 6: 重写 picker / remove / 换人清空**
```ts
function openInvoicePicker() {
pickerDraftKeys.value = selectedInvoicePicks.value.map((p) => p.key)
showInvoicePicker.value = true
}
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() {
const set = new Set(pickerDraftKeys.value)
selectedInvoicePicks.value = attachItems.value
.filter((i) => set.has(i.key))
.map((i) => ({ ...i }))
showInvoicePicker.value = false
}
function removeInvoicePick(key: string) {
selectedInvoicePicks.value = selectedInvoicePicks.value.filter((p) => p.key !== key)
}
```
在 `onApplicantConfirm` 中,确认申请人后增加清空:
```ts
selectedInvoicePicks.value = []
```
(放在 `refreshWallet()` 之前或同时。)
- [ ] **Step 7: 编辑回显改为附件级**
将 `loadEditData` 中:
```ts
selectedInvoiceIds.value = linked.map(...)
```
替换为:
```ts
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)
```
注意:原 `loadEditData` 末尾已有 `await refreshWallet()` —— 合并为先 `refreshWallet` 再组 picks,避免重复调用。
同步替换所有 `selectedInvoiceIds.value = []` 为 `selectedInvoicePicks.value = []`(新建清空、`watch` query 清空等)。
- [ ] **Step 8: 确认 buildInvoicePayload 已被 submit 使用**
`onSubmit` 已调用 `buildInvoicePayload()` —— 确认新实现带 `attachmentId`,无需改调用点。
- [ ] **Step 9: Commit**
```bash
git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
git commit -m "refactor(bx-mobile): use attachment-level invoice picks in reimburse apply logic"
```
---
### Task 2: 模板 — 已选区 + 附件网格 Sheet
**Files:**
- Modify: `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue`(`<template>` 关联发票区块)
- [ ] **Step 1: 替换「关联发票」卡片内已选列表与按钮后结构**
将 `selectedInvoiceIds` / `selectedInvoiceRows` / 文本行列表,改为横向卡片(对齐 Demo):
```vue
<button type="button" class="btn btn-primary btn-sm" @click="openInvoicePicker">
从发票夹选择
</button>
<div v-if="selectedInvoicePicksSorted.length" class="invoice-picked-row">
<div
v-for="pick in selectedInvoicePicksSorted"
:key="pick.key"
class="invoice-picked-card"
>
<button
type="button"
class="invoice-picked-card__remove"
aria-label="移除"
@click="removeInvoicePick(pick.key)"
>
×
</button>
<div class="invoice-picked-card__preview">
<img
v-if="isImageFileType(pick.fileType) && pick.previewUrl"
:src="pick.previewUrl"
:alt="pick.fileName"
/>
<div v-else class="invoice-picked-card__icon">
{{ isImageFileType(pick.fileType) ? '图片' : 'PDF' }}
</div>
</div>
<div class="invoice-picked-card__meta">
<span class="invoice-picked-card__name">{{ pick.fileName }}</span>
<div class="invoice-picked-card__amount">¥{{ money(pick.amount) }}</div>
</div>
</div>
</div>
<p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。选择对象为附件文件。</p>
```
- [ ] **Step 2: 替换 van-popup 选择器内容为月份网格**
```vue
<van-popup v-model:show="showInvoicePicker" position="bottom" round :style="{ height: '72%' }">
<div class="invoice-picker">
<div class="invoice-picker__title">选择发票附件</div>
<div class="invoice-picker__body">
<p v-if="!pickerMonthGroups.length" class="invoice-picker__empty">暂无发票附件</p>
<section
v-for="group in pickerMonthGroups"
:key="group.monthKey"
class="invoice-month"
>
<h4 class="invoice-month__title">{{ group.monthLabel }}</h4>
<div class="invoice-attach-grid">
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="invoice-attach-card"
:class="{ 'is-selected': pickerDraftKeys.includes(item.key) }"
@click="togglePickerKey(item.key)"
>
<span class="invoice-attach-card__index">{{ item.serial }}</span>
<span
v-if="pickerDraftKeys.includes(item.key)"
class="invoice-attach-card__check"
>✓</span>
<div class="invoice-attach-card__preview">
<img
v-if="isImageFileType(item.fileType) && item.previewUrl"
:src="item.previewUrl"
:alt="item.fileName"
/>
<div v-else class="invoice-attach-card__doc">
{{ isImageFileType(item.fileType) ? '图片' : 'PDF' }}
</div>
</div>
<div class="invoice-attach-card__meta">
<span class="invoice-attach-card__name">{{ item.fileName }}</span>
<div class="invoice-attach-card__amount">¥{{ money(item.amount) }}</div>
</div>
</button>
</div>
</section>
</div>
<div class="invoice-picker__footer">
<button type="button" class="btn btn-primary btn-block" @click="confirmInvoicePicker">
确定(已选 {{ pickerDraftKeys.length }})
</button>
</div>
</div>
</van-popup>
```
删除旧的 `van-checkbox-group` / `van-cell` 列表。
- [ ] **Step 3: Commit**
```bash
git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
git commit -m "feat(bx-mobile): show attachment grid picker on reimburse apply"
```
---
### Task 3: 样式对齐 Demo + 类型检查
**Files:**
- Modify: `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue`(`<style>`)
- [ ] **Step 1: 替换/扩展发票相关样式**
删除仅服务于旧 `.invoice-picked__item` 的样式;加入(可按 Demo 微调,颜色用页面已有 CSS 变量):
```css
.invoice-picked-row {
margin-top: 12px;
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.invoice-picked-card {
position: relative;
flex: 0 0 120px;
width: 120px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.invoice-picked-card__remove {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(15, 23, 42, 0.55);
color: #fff;
font-size: 14px;
line-height: 1;
}
.invoice-picked-card__preview {
height: 80px;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.invoice-picked-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.invoice-picked-card__icon {
font-size: 12px;
color: var(--text-muted);
}
.invoice-picked-card__meta { padding: 8px; }
.invoice-picked-card__name {
display: block;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invoice-picked-card__amount {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--primary-dark);
}
.invoice-picker__body {
flex: 1;
overflow-y: auto;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.invoice-picker__empty {
text-align: center;
color: var(--text-muted);
padding: 40px 0;
font-size: 13px;
}
.invoice-month { margin-bottom: 18px; }
.invoice-month__title {
margin: 0 0 10px 4px;
font-size: 13px;
font-weight: 600;
}
.invoice-attach-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.invoice-attach-card {
position: relative;
padding: 0;
border: 1.5px solid var(--border);
border-radius: 8px;
background: #fff;
text-align: left;
overflow: hidden;
}
.invoice-attach-card.is-selected {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.invoice-attach-card__index {
position: absolute;
top: 4px;
left: 4px;
z-index: 1;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: rgba(15, 23, 42, 0.5);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.invoice-attach-card__check {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.invoice-attach-card__preview {
height: 72px;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.invoice-attach-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.invoice-attach-card__doc {
font-size: 11px;
color: var(--text-muted);
}
.invoice-attach-card__meta { padding: 6px 8px 8px; }
.invoice-attach-card__name {
display: block;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.invoice-attach-card__amount {
margin-top: 2px;
font-size: 12px;
font-weight: 600;
color: var(--primary-dark);
}
```
确保 `.invoice-picker` 仍为 `display:flex; flex-direction:column; height:100%`,以便 body 滚动、footer 置底。
- [ ] **Step 2: 类型检查**
```bash
cd bx-mobile && npx vue-tsc --noEmit
```
Expected: 无 error(允许项目原有无关告警;本文件改动不得引入新错误)。
- [ ] **Step 3: 手工验收清单**
1. `/reimburse/apply` 打开选择器 → 3 列附件网格 + 月份标题。
2. 勾选同一发票的部分附件 → 确定后横向卡片展示。
3. 保存后在 Network 中确认 `invoiceLinks` 含 `attachmentId`。
4. 再编辑该单 → 回显对应附件。
5. 换申请人 → 已选清空。
6. 余额不足仍不可提交。
- [ ] **Step 4: Commit**
```bash
git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
git commit -m "style(bx-mobile): polish reimburse invoice attachment picker layout"
```
若 Task 2/3 希望合并为一个 commit,允许一次提交 message:
```text
feat(bx-mobile): align reimburse invoice picker with web attachment selection
```
---
## Spec 覆盖自检
| Spec 要求 | Task |
|-----------|------|
| 选择器展示附件、3 列缩略图 | Task 2 |
| 提交带 attachmentId | Task 1 `buildInvoicePayload` |
| 编辑回显首附件回退 | Task 1 Step 7 |
| 换申请人清空 | Task 1 Step 6 |
| 无附件不出现 | Task 1 `buildAttachItems` 跳过空文件 |
| 余额逻辑不变 | 未改 `balanceInsufficient` / submit 校验 |
| Demo 对照 | Task 2/3 样式与结构 |
| 不改后端 / Web | 文件表仅申请页 |
**占位符扫描:** 无 TBD/TODO。
**类型一致性:** 全程使用 `InvoiceAttachPick` / `key` / `pickerDraftKeys`。