docs: add plan for reimburse apply attachment display names

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-14 11:29:55 +08:00
1 parent 6d0668bf58
commit 5d4e1e14ad
1 file changed
+416
@@ -0,0 +1,416 @@
# 报销申请附件展示名(整单备注)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:** 在 Web / 小程序报销申请的申请附件区,有整单备注时用「备注_序号.扩展名」作展示名;空备注回退原始文件名;不改库表与下载名。
**Architecture:** 纯前端展示层。共享命名纯函数;Web 的 `BxAttachmentUpload` / `BxAttachmentReadonly` 增加可选 `remark` prop,内部按列表下标计算展示名;下载仍走原始 `fileName`。小程序申请/详情当前缩略图网格**不显示文件名**,需补一行 caption 才看得见效果。
**Tech Stack:** Vue 3 + TypeScript(web-admin);uni-app(miniprogram);`node:test` 单测(与现有 `web-admin/src/utils/*.test.ts` 一致)
**Spec:** `docs/superpowers/specs/2026-09-14-reimburse-apply-attachment-display-name-design.md`
---
## File map
| File | Responsibility |
|------|----------------|
| `web-admin/src/utils/bx/attachment-display-name.ts` | 命名纯函数(sanitize / 截断 / 序号) |
| `web-admin/src/utils/bx/attachment-display-name.test.ts` | 单测 |
| `web-admin/src/components/bx/base/BxAttachmentUpload.vue` | 可选 `remark`;UI 用展示名;下载用原始名 |
| `web-admin/src/components/bx/base/BxAttachmentReadonly.vue` | 同上 |
| `web-admin/src/views/bx/reimburse/apply/index.vue` | 上传组件与详情只读传入 `addForm.remark` / `viewData.remark` |
| `web-admin/src/views/bx/reimburse/approve/index.vue` | 详情申请附件只读传入 `viewData.remark`(有则套用) |
| `miniprogram/utils/bxAttachmentDisplayName.js` | 与 Web 对称的纯函数 |
| `miniprogram/pages/bx/reimburse/apply.vue` | 附件网格补展示名 caption |
| `miniprogram/pages/bx/reimburse/detail.vue` | 同上 |
---
### Task 1: Web 命名纯函数 + 单测(TDD)
**Files:**
- Create: `web-admin/src/utils/bx/attachment-display-name.ts`
- Create: `web-admin/src/utils/bx/attachment-display-name.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
sanitizeRemarkBaseName,
buildApplyAttachmentDisplayName,
mapApplyAttachmentDisplayNames
} from './attachment-display-name'
describe('sanitizeRemarkBaseName', () => {
it('trims and replaces illegal chars and spaces', () => {
assert.equal(sanitizeRemarkBaseName(' 差旅/说明 A '), '差旅_说明_A')
})
it('truncates to 40 chars', () => {
const long = '啊'.repeat(50)
assert.equal(sanitizeRemarkBaseName(long).length, 40)
})
it('returns empty for blank', () => {
assert.equal(sanitizeRemarkBaseName(' '), '')
})
})
describe('buildApplyAttachmentDisplayName', () => {
it('falls back to original when remark empty', () => {
assert.equal(
buildApplyAttachmentDisplayName({ remark: '', originalFileName: 'a.pdf', index1: 1 }),
'a.pdf'
)
})
it('uses remark_index + ext', () => {
assert.equal(
buildApplyAttachmentDisplayName({
remark: '差旅说明',
originalFileName: 'photo.JPG',
index1: 2
}),
'差旅说明_2.JPG'
)
})
it('handles missing extension', () => {
assert.equal(
buildApplyAttachmentDisplayName({
remark: '说明',
originalFileName: 'readme',
index1: 1
}),
'说明_1'
)
})
})
describe('mapApplyAttachmentDisplayNames', () => {
it('maps list with 1-based indices', () => {
const out = mapApplyAttachmentDisplayNames('备注', [
{ fileName: 'a.pdf' },
{ fileName: 'b.jpg' }
])
assert.deepEqual(out, ['备注_1.pdf', '备注_2.jpg'])
})
})
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd web-admin && node --import tsx --test src/utils/bx/attachment-display-name.test.ts
```
Expected: FAIL(模块不存在)。若项目惯用其它跑法,对照 `src/utils/pl-slip-product-brand.test.ts` 的执行方式(例如 `pnpm exec tsx --test ...`)。
- [ ] **Step 3: 实现**
`web-admin/src/utils/bx/attachment-display-name.ts`:
```ts
const ILLEGAL = /[/\\:*?"<>|\u0000-\u001f]/g
export function sanitizeRemarkBaseName(remark: string): string {
const cleaned = String(remark || '')
.trim()
.replace(ILLEGAL, '_')
.replace(/\s+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
if (!cleaned) return ''
return cleaned.length > 40 ? cleaned.slice(0, 40) : cleaned
}
function splitExt(fileName: string): { base: string; ext: string } {
const name = String(fileName || '')
const i = name.lastIndexOf('.')
if (i <= 0 || i === name.length - 1) return { base: name, ext: '' }
return { base: name.slice(0, i), ext: name.slice(i) }
}
export function buildApplyAttachmentDisplayName(opts: {
remark: string
originalFileName: string
index1: number
}): string {
const original = String(opts.originalFileName || '').trim() || '申请附件'
const base = sanitizeRemarkBaseName(opts.remark)
if (!base) return original
const { ext } = splitExt(original)
const n = Math.max(1, Math.floor(Number(opts.index1) || 1))
return `${base}_${n}${ext}`
}
export function mapApplyAttachmentDisplayNames(
remark: string,
files: Array<{ fileName?: string | null }>
): string[] {
return (files || []).map((f, i) =>
buildApplyAttachmentDisplayName({
remark,
originalFileName: f.fileName || '',
index1: i + 1
})
)
}
```
- [ ] **Step 4: 跑测确认通过**
同 Step 2 命令。Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/utils/bx/attachment-display-name.ts web-admin/src/utils/bx/attachment-display-name.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add apply attachment display name helper
EOF
)"
```
---
### Task 2: Web 附件组件支持 `remark` 展示名
**Files:**
- Modify: `web-admin/src/components/bx/base/BxAttachmentUpload.vue`
- Modify: `web-admin/src/components/bx/base/BxAttachmentReadonly.vue`
- [ ] **Step 1: Upload 组件**
1. Import `buildApplyAttachmentDisplayName`
2. Props 增加可选 `remark?: string`(默认 `''`)
3. 增加方法:
```ts
function displayNameOf(file: BxAttachmentItem, index: number) {
return buildApplyAttachmentDisplayName({
remark: props.remark || '',
originalFileName: file.fileName || '',
index1: index + 1
})
}
```
4. 模板中(约 L44、L68):
- `:alt` / 名称 span / `:title` 改用 `displayNameOf(file, index)`
- **下载** `handleDownload` / `downloadBxAttachment(file)` 仍传原始 `file`(含原始 `fileName`)
- 预览弹窗标题可用展示名(调用 `previewBxAttachment` 后把 `previewFileName` 设为展示名亦可;勿改下载)
`v-for="(file, index) in files"` 确保有 `index`。
- [ ] **Step 2: Readonly 组件**
同样增加 `remark?: string`,`displayNameOf`,列表文案用展示名;`handleDownload` 仍用原始 `file`。
注意:`displayFiles` 是 watch 出来的副本,v-for 用 index 即可,勿把展示名写回 `file.fileName`。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/components/bx/base/BxAttachmentUpload.vue web-admin/src/components/bx/base/BxAttachmentReadonly.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): show remark-based names on bx attachment cards
EOF
)"
```
---
### Task 3: Web 报销申请 / 审批页传入 `remark`
**Files:**
- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue`
- Modify: `web-admin/src/views/bx/reimburse/approve/index.vue`
- [ ] **Step 1: 申请页**
`BxAttachmentUpload` 增加:
```vue
:remark="addForm.remark"
```
详情「申请附件」:
```vue
<BxAttachmentReadonly :files="viewAttachments" :remark="String(viewData.remark || '')" />
```
**不要**给关联发票 / 发放 / 冲抵的 `BxAttachmentReadonly` 传备注。
- [ ] **Step 2: 审批页**
申请附件只读同样传入 `:remark="String(viewData.remark || '')"`(`viewData` 为当前打开的报销单;若变量名不同,用该页实际的详情对象上的 `remark`)。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/views/bx/reimburse/apply/index.vue web-admin/src/views/bx/reimburse/approve/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): pass remark into reimburse apply attachment UI
EOF
)"
```
---
### Task 4: 小程序对称工具函数
**Files:**
- Create: `miniprogram/utils/bxAttachmentDisplayName.js`
- [ ] **Step 1: 实现**(与 Web 逻辑一致,便于两端对齐)
```js
const ILLEGAL = /[/\\:*?"<>|\u0000-\u001f]/g
export function sanitizeRemarkBaseName(remark) {
const cleaned = String(remark || '')
.trim()
.replace(ILLEGAL, '_')
.replace(/\s+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
if (!cleaned) return ''
return cleaned.length > 40 ? cleaned.slice(0, 40) : cleaned
}
function splitExt(fileName) {
const name = String(fileName || '')
const i = name.lastIndexOf('.')
if (i <= 0 || i === name.length - 1) return { base: name, ext: '' }
return { base: name.slice(0, i), ext: name.slice(i) }
}
export function buildApplyAttachmentDisplayName({ remark, originalFileName, index1 }) {
const original = String(originalFileName || '').trim() || '申请附件'
const base = sanitizeRemarkBaseName(remark)
if (!base) return original
const { ext } = splitExt(original)
const n = Math.max(1, Math.floor(Number(index1) || 1))
return `${base}_${n}${ext}`
}
```
(小程序可不强制单测文件;逻辑与 Web 测试用例对齐即可。)
- [ ] **Step 2: Commit**
```bash
git add miniprogram/utils/bxAttachmentDisplayName.js
git commit -m "$(cat <<'EOF'
feat(miniprogram): add apply attachment display name helper
EOF
)"
```
---
### Task 5: 小程序申请页 / 详情页展示名
**Files:**
- Modify: `miniprogram/pages/bx/reimburse/apply.vue`
- Modify: `miniprogram/pages/bx/reimburse/detail.vue`
**说明:** 当前附件区只有缩略图网格、**没有文件名文案**;必须补 caption,否则功能不可见。
- [ ] **Step 1: 申请页**
Import:
```js
import { buildApplyAttachmentDisplayName } from '@/utils/bxAttachmentDisplayName'
```
Helper:
```js
function attachmentDisplayName(file, index) {
return buildApplyAttachmentDisplayName({
remark: form.remark || '',
originalFileName: file.fileName || '',
index1: index + 1
})
}
```
模板在 `att-grid` 内每个 `att-img` 下增加一行名称(样式用现有 muted 小字即可,例如 `att-img__name`):
```vue
<view v-for="(file, idx) in files" :key="file.id" class="att-img">
<image ... />
<view v-if="!file._pending" class="del" ...>×</view>
<text class="att-img__name">{{ attachmentDisplayName(file, idx) }}</text>
</view>
```
注意:上传成功后 `files` 项可能未带 `fileName`(当前 `uploadOne` 未写入)。在 `uploadOne` 成功回写时补上:
```js
fileName: data.fileName || data.file_name || ''
```
否则有备注时扩展名可能丢失,空备注时只能显示「申请附件」。
加少量 SCSS:名称单行省略、字号约 11–12px、居中或左对齐与网格协调。
- [ ] **Step 2: 详情页**
同样在申请附件网格下显示 `buildApplyAttachmentDisplayName({ remark: detail.value?.remark || '', originalFileName: file.fileName || '', index1: idx + 1 })`。
加载附件列表时确保映射了 `fileName`(若 `listByBiz` 已有则沿用)。
- [ ] **Step 3: Commit**
```bash
git add miniprogram/pages/bx/reimburse/apply.vue miniprogram/pages/bx/reimburse/detail.vue
git commit -m "$(cat <<'EOF'
feat(miniprogram): show remark-based names on reimburse apply attachments
EOF
)"
```
---
### Task 6: 图谱(若改动需更新)与验收核对
- [ ] **Step 1:** `./scripts/update-graph.sh frontend` 或 `all`(仅 web-admin / miniprogram 有代码变更时)
- [ ] **Step 2:** 对照 spec 验收清单代码级勾选:
1. 有备注 → `备注_n.ext`
2. 清空备注 → 原始名
3. 下载仍原始名(组件未改 `downloadBxAttachment` 入参中的 fileName)
4. 发票区未传 remark
5. Web / 小程序规则一致
- [ ] **Step 3:** 有图谱变更则 commit;无则跳过空提交
---
## Spec coverage
| Spec | Task |
|------|------|
| 纯函数命名规则 | Task 1、4 |
| Web 上传/只读展示 | Task 2 |
| 申请页 + 详情传 remark;审批页一并 | Task 3 |
| 小程序申请 + 详情 | Task 5 |
| 不改库 / 不改下载 / 不动发票 | Task 2–5 约束 |
| 空备注回退原始名 | Task 1 测试 |
## 执行说明
- 分支:按用户要求在当前分支继续(勿另开 worktree,除非另有指示)
- 勿提交 `backend/app.pid`