From 5d4e1e14ade2bce5a13e2daf653610caf50b6488 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Mon, 14 Sep 2026 11:29:55 +0800 Subject: [PATCH] docs: add plan for reimburse apply attachment display names Co-authored-by: Cursor --- ...reimburse-apply-attachment-display-name.md | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-14-reimburse-apply-attachment-display-name.md diff --git a/docs/superpowers/plans/2026-09-14-reimburse-apply-attachment-display-name.md b/docs/superpowers/plans/2026-09-14-reimburse-apply-attachment-display-name.md new file mode 100644 index 000000000..9e813a2a7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-reimburse-apply-attachment-display-name.md @@ -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` 传备注。 + +- [ ] **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 + + + × + {{ attachmentDisplayName(file, idx) }} + +``` + +注意:上传成功后 `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`