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