docs: add plan for bx-mobile reimburse invoice attachment picker
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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`。
|
||||
Reference in new issue
Block a user