Kaynağa Gözat

docs: add plan for reimburse apply attachment display names

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 ay önce
ebeveyn
işleme
5d4e1e14ad

+ 416 - 0
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 :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`