2026-09-05-reimburse-apply-attachments.md 16 KB

报销申请「申请附件」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 报销申请与 bx-mobile 申请页增加可选「申请附件」上传/绑定/查看,与发票夹分离,复用现有 reimburse_apply 附件能力。

Architecture: 不改表结构。上传走已有 attachmentApi(biz_type=reimburse_apply);新建先 bizId=0 再 bindBiz;编辑回填 listByBiz。Web 用 BxAttachmentUpload;H5 对齐备用金 useAttachment + van-uploader。详情文案「历史附件」统一为「申请附件」。

Tech Stack: Vue 3、Element Plus、BxAttachmentUpload、bx-mobile Vant、useAttachment、既有 BX attachment API

Spec: docs/superpowers/specs/2026-09-05-reimburse-apply-attachments-design.md


文件结构

操作 文件 职责
Modify web-admin/src/views/bx/reimburse/apply/index.vue 申请弹窗上传区;保存后 bind;编辑回填;详情文案
Modify web-admin/src/views/bx/reimburse/approve/index.vue 「历史附件」→「申请附件」
Modify web-admin/src/views/bx/view/todo/index.vue 同上
Modify web-admin/src/views/bx/view/all/index.vue 同上
Modify web-admin/src/views/bx/view/mine/index.vue 同上
Modify bx-mobile/src/views/reimburse/ReimburseApplyPage.vue 申请附件上传区 + bind/load

不变: 后端 schema、发票夹、miniprogram、付款发放/冲抵附件逻辑。

参考:

  • Web 组件:web-admin/src/components/bx/base/BxAttachmentUpload.vue(hasUploading / getFileIds)
  • Web 用法:web-admin/src/views/bx/payment/index.vue 发放附件
  • H5:bx-mobile/src/views/advance/AdvanceApplyPage.vue + bx-mobile/src/hooks/useAttachment.ts
  • API:attachmentApi 已在 web-admin/src/api/bx/index.ts 与 bx-mobile/src/api/index.ts

Task 1: Web 申请弹窗 — 上传 UI 与状态

Files:

  • Modify: web-admin/src/views/bx/reimburse/apply/index.vue

  • [ ] Step 1: 增加 import 与状态

在 script 中补充:

import { attachmentApi, reimburseApi, invoiceWalletApi } from '@/api/bx'
import BxAttachmentUpload from '@/components/bx/base/BxAttachmentUpload.vue'

(若 reimburseApi / invoiceWalletApi 已从 @/api/bx 导入,合并为一条 import,并加上 attachmentApi。)

在现有 selectedInvoicePicks 附近增加:

const applyAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
const applyAttachments = ref<BxAttachmentItem[]>([])

BxAttachmentItem 已从 @/utils/bx/attachment 导入则复用;否则一并导入。

  • Step 2: 在模板插入上传区块

在「关联发票(可选)」整块(含 selectedInvoicePicks / BxInvoiceSelectDialog 触发区)之后、「备注说明」el-form 之前插入:

      <div class="form-section">
        <h4>申请附件(可选)</h4>
        <BxAttachmentUpload
          ref="applyAttachmentRef"
          v-model="applyAttachments"
          biz-type="reimburse_apply"
          :biz-id="editingId || 0"
          hint="补充行程单、说明截图、合同等;与发票夹无关。支持图片、视频、PDF、Office、压缩包等。"
        />
      </div>
  • Step 3: 打开新增 / 重置时清空附件

在 openAddDialog 与 resetAddForm 中增加:

applyAttachments.value = []
  • Step 4: 打开编辑时回填附件

在 openEditDialog 成功拿到 row.id 并 dialogs.add = true 之后,在现有 Promise.all([...]) 中增加加载,或在其后:

applyAttachments.value = await loadBxAttachments('reimburse_apply', row.id).catch(() => [])

(loadBxAttachments 已在本文件导入。)

  • Step 5: 本地确认弹窗可打开、上传控件可见

Run: cd web-admin && pnpm dev(若已在跑则刷新)
打开 #/bx/reimburse/apply → 新增 → 在发票区与备注之间应看到「申请附件(可选)」。
Expected: 上传卡片可见;未保存前可不实际上传。

  • Step 6: Commit
git add web-admin/src/views/bx/reimburse/apply/index.vue
git commit -m "$(cat <<'EOF'
feat(bx): add apply-attachment upload UI on reimburse apply dialog

EOF
)"

Task 2: Web — 保存/提交时拦截上传中并 bindBiz

Files:

  • Modify: web-admin/src/views/bx/reimburse/apply/index.vue

  • [ ] Step 1: 增加 bind 辅助函数

放在 saveAddForm 附近:

async function bindApplyAttachments(reimburseId: string) {
  const ids = (applyAttachmentRef.value?.getFileIds?.() || applyAttachments.value.map((f) => f.id)).filter(
    (id) => id != null && id !== '' && !String(id).startsWith('upload-')
  )
  if (!ids.length) return
  try {
    await attachmentApi.bindBiz(ids, 'reimburse_apply', reimburseId)
  } catch (e: any) {
    ElMessage.warning(e?.message || '报销单已保存,但申请附件关联失败,请重新编辑后保存')
  }
}

function assertApplyAttachmentsIdle(): boolean {
  if (applyAttachmentRef.value?.hasUploading()) {
    ElMessage.warning('附件上传中,请稍候')
    return false
  }
  return true
}
  • Step 2: 改 submitAdd

在 buildAddPayload() 之后、loadingMap.addSubmit = true 之前:

  if (!assertApplyAttachmentsIdle()) return

在 const id = await saveAddForm(payload) 成功且 id 有值后、ElMessage.success 之前:

    await bindApplyAttachments(id)
  • Step 3: 改 submitAddAndApprove

同样在 payload 校验后加 assertApplyAttachmentsIdle();在 saveAddForm 成功得到 id 后、reimburseApi.submit 之前调用 await bindApplyAttachments(id)。

  • Step 4: 手测绑定
  1. 新增报销:填一条明细 → 上传 1 张图片 → 保存草稿
  2. 打开详情 →「申请附件」区应能看到文件(若文案仍是「历史附件」,Task 3 再改)
  3. 编辑该草稿 → 附件应回显;再传一个文件保存 → 详情应有两个

Expected: 上传中点保存会 toast「附件上传中,请稍候」;无附件可正常保存。

  • Step 5: Commit
git add web-admin/src/views/bx/reimburse/apply/index.vue
git commit -m "$(cat <<'EOF'
feat(bx): bind reimburse apply attachments on save and submit

EOF
)"

Task 3: Web — 「历史附件」文案统一为「申请附件」

Files:

  • Modify: web-admin/src/views/bx/reimburse/apply/index.vue
  • Modify: web-admin/src/views/bx/reimburse/approve/index.vue
  • Modify: web-admin/src/views/bx/view/todo/index.vue
  • Modify: web-admin/src/views/bx/view/all/index.vue
  • Modify: web-admin/src/views/bx/view/mine/index.vue

  • [ ] Step 1: 全局替换标题文案

将上述 5 个文件中用户可见的:

<h4 class="dlg-view-section-title">历史附件</h4>

改为:

<h4 class="dlg-view-section-title">申请附件</h4>

仅改展示文案,不改 loadBxAttachments('reimburse_apply', …) 逻辑。

  • Step 2: 确认无残留

Run:

rg '历史附件' web-admin/src/views/bx --glob '*.vue'

Expected: 无匹配(或仅注释/无关处;业务标题应为 0)。

  • Step 3: Commit
git add web-admin/src/views/bx/reimburse/apply/index.vue \
  web-admin/src/views/bx/reimburse/approve/index.vue \
  web-admin/src/views/bx/view/todo/index.vue \
  web-admin/src/views/bx/view/all/index.vue \
  web-admin/src/views/bx/view/mine/index.vue
git commit -m "$(cat <<'EOF'
fix(bx): rename historical attachments label to apply attachments

EOF
)"

Task 4: bx-mobile 申请页 — 附件上传 UI 与 accept

Files:

  • Modify: bx-mobile/src/views/reimburse/ReimburseApplyPage.vue

  • [ ] Step 1: 增加 import 与常量

在 script 中增加(路径按页面现有 @/ 别名):

import { useAttachment } from '@/hooks/useAttachment'

在其它常量附近:

const MAX_ATTACHMENTS = 10

/** 与 Web BxAttachmentUpload 对齐:图片/视频/文档/压缩包 */
const attachmentAccept = [
  'image/*',
  'video/*',
  'application/pdf',
  'application/msword',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.ms-excel',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.ms-powerpoint',
  'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  'text/plain',
  'text/csv',
  'application/rtf',
  'application/zip',
  'application/x-zip-compressed',
  'application/x-rar-compressed',
  'application/x-7z-compressed',
  '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
  '.txt', '.csv', '.rtf', '.wps', '.et', '.dps',
  '.zip', '.rar', '.7z',
  '.mp4', '.webm', '.mov', '.avi',
].join(',')

const applyAttachment = useAttachment()
  • Step 2: 模板插入「申请附件」卡片

在「关联发票」区块(含 van-popup 发票选择器)结束之后、「报销说明」form-card 之前插入(结构对齐 AdvanceApplyPage):

        <!-- 申请附件(可选) -->
        <div class="form-card">
          <div class="form-header">
            <h3>申请附件(可选)</h3>
          </div>
          <p class="form-tip">补充行程单、说明截图、合同等;与发票夹无关。</p>
          <div class="uploader-slot-btn advance-attachment-upload">
            <van-uploader
              :after-read="onAfterReadApplyAttachment"
              :before-read="beforeReadApplyAttachment"
              :disabled="applyAttachment.uploading.value"
              multiple
              result-type="file"
              :accept="attachmentAccept"
            >
              <button
                type="button"
                class="btn btn-primary btn-sm"
                :class="{ 'btn-loading': applyAttachment.uploading.value }"
                :disabled="applyAttachment.uploading.value"
              >
                {{ applyAttachment.uploading.value ? '上传中…' : '上传附件' }}
              </button>
            </van-uploader>
          </div>
          <div v-if="applyAttachment.files.value.length" class="att-grid advance-att-grid">
            <div
              v-for="(file, idx) in applyAttachment.files.value"
              :key="file.id"
              class="att-img advance-att-tile"
              :class="{ 'is-uploading': file._pending }"
              :title="file.fileName"
              role="button"
              tabindex="0"
              @click="applyAttachment.open(file)"
              @keydown.enter.prevent="applyAttachment.open(file)"
            >
              <img
                v-if="applyAttachment.isImage(file.fileType) && file.previewUrl"
                :src="file.previewUrl"
                :alt="file.fileName"
              />
              <div v-else class="att-file-icon att-doc-thumb">
                <span class="att-doc-thumb__ext">{{ applyAttachment.fileExt(file.fileName) }}</span>
                <span class="att-doc-thumb__name">{{ file.fileName }}</span>
              </div>
              <div v-if="file._pending" class="att-upload-mask" aria-label="上传中">
                <van-loading size="22px" color="#fff" />
                <span class="att-upload-mask__text">上传中</span>
              </div>
              <button
                v-if="!file._pending"
                type="button"
                class="del"
                @click.stop="applyAttachment.remove(idx)"
              >
                ×
              </button>
            </div>
          </div>
        </div>

若页面 style 中尚无 .advance-attachment-upload / .advance-att-grid / .advance-att-tile,从 AdvanceApplyPage.vue 底部 scoped 样式原样拷贝对应规则到本文件(或复用本文件已有同名规则,勿重复冲突)。

  • Step 3: before/after read 处理函数
function beforeReadApplyAttachment(file: File | File[]) {
  const batch = Array.isArray(file) ? file : [file]
  const remain = MAX_ATTACHMENTS - applyAttachment.files.value.length
  if (remain <= 0) {
    showToast(`最多上传 ${MAX_ATTACHMENTS} 个附件`)
    return false
  }
  if (batch.length > remain) {
    showToast(`还可上传 ${remain} 个附件,超出部分已忽略`)
    return Promise.resolve(batch.slice(0, remain))
  }
  return true
}

async function onAfterReadApplyAttachment(uploadFile: any) {
  const files = Array.isArray(uploadFile) ? uploadFile : [uploadFile]
  for (const f of files) {
    if (!f?.file) continue
    await applyAttachment.upload(f.file)
  }
}

(showToast 本页应已导入。)

  • Step 4: Commit
git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
git commit -m "$(cat <<'EOF'
feat(bx-mobile): add optional apply-attachment uploader on reimburse apply

EOF
)"

Task 5: bx-mobile — 保存 bind 与编辑 loadList

Files:

  • Modify: bx-mobile/src/views/reimburse/ReimburseApplyPage.vue

  • [ ] Step 1: 改 onSubmit

在 validateReimburseApplyForm() 通过后、submitting.value = true 之前:

  if (applyAttachment.uploading.value) {
    showToast('附件上传中,请稍候')
    return
  }

在 create / update 成功并得到 id 之后、弹出「提交审批」对话框之前:

    try {
      await applyAttachment.bindNew('reimburse_apply', id)
    } catch (bindErr: any) {
      showToast(bindErr?.message || '报销单已保存,但申请附件关联失败,请重新编辑后保存')
    }
  • Step 2: 编辑加载与清空

在 loadEditData 成功解析单据后(发票 picks 处理前后均可)增加:

    await applyAttachment.loadList('reimburse_apply', editingId.value)

在 loadEditData 开头无 id 分支、以及 watch(route.query.id) 清空编辑态时:

    applyAttachment.clear()

(与 selectedInvoicePicks.value = [] 同级。)

  • Step 3: 手测 H5
  1. 新建报销 → 上传附件 → 保存 → 进详情确认「申请附件」可见
  2. 编辑草稿 → 附件回显 → 删除一个再保存 → 详情同步
  3. 无附件可保存

Expected: 与发票选择互不影响;上传中保存被拦截。

  • Step 4: Commit
git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
git commit -m "$(cat <<'EOF'
feat(bx-mobile): bind and load reimburse apply attachments on save/edit

EOF
)"

Task 6: 联调验收(对照 Spec §6)

Files: 无代码(或仅修联调发现的小问题)

  • Step 1: 按 Spec 验收清单逐项勾选
# 检查项 结果
1 Web 上传/预览/下载/删除;与发票夹独立
2 无附件可保存、可提交审批
3 详情/审批/付款查看「申请附件」
4 bx-mobile 上传与查看
5 仅 draft/rejected 可改
6 类型含图片/视频/文档等
7 Web 无「历史附件」业务标题
  • [ ] Step 2: 若有缺陷,最小修复后追加 commit(勿扩大范围)

  • [ ] Step 3: 更新图谱(若改了代码文件)

./scripts/update-graph.sh frontend
# 若只动了 bx-mobile 且脚本不覆盖,按仓库惯例更新对应 graphify;无脚本则跳过

Spec 覆盖自检

Spec 要求 Task
Web 上传区 + 可选 Task 1
新建 bind / 编辑回填 / 上传中拦截 Task 2
「历史附件」→「申请附件」 Task 3
bx-mobile 上传 UI + accept 含视频 Task 4
H5 bindNew / loadList / clear Task 5
验收标准 Task 6
不做小程序/新表/发票夹改造 全计划未引入

执行说明

本仓库前端几乎无组件单测;各 Task 以手测与 rg 检查代替 TDD 红绿循环。实现时勿改后端除非 bind/list 联调失败再最小修补。