云
2026-09-28 d7538a8fb830c3de582b25e70566211987c4151f
feat(modules): 新增财务、会员管理和课题管理模块

- 添加财务模块字典定义,包括报销单状态和费用类型
- 添加会员管理模块字典定义,包括入会申请状态、会员类型和等级
- 添加课题管理模块字典定义,包括课题指南类别、状态和申报书状态
- 实现报销单功能的数据结构和表单配置
- 实现课题申报书功能的数据结构和表单配置
- 实现课题指南功能的数据结构和表单配置
- 实现入会申请功能的数据结构和表单配置
- 添加课题申报书附件用于E2E测试
已添加39个文件
已修改1个文件
7079 ■■■■■ 文件已修改
.playwright-mcp/_e2e_attachment.txt 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-09-28T10-11-52-064Z.yml 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-09-28T10-11-57-412Z.yml 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/finance/reimbursement/index.ts 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/application/index.ts 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/fund/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/research/guideline/index.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/application/index.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ums/member/index.ts 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/data.ts 307 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/detail/approve/index.vue 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/index.vue 285 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/modules/form.vue 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/modules/item-form.vue 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/finance/reimbursement/modules/process-select-modal.vue 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/components/member-select-dialog.vue 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/components/member-select.vue 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/data.ts 548 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/detail/approve/index.vue 279 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/index.vue 311 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/form.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/fund-form.vue 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/fund-panel.vue 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/process-select-modal.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/review-form.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/application/modules/review-panel.vue 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/guideline/data.ts 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/guideline/index.vue 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/research/guideline/modules/form.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/data.ts 348 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/detail/approve/index.vue 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/index.vue 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/modules/form.vue 161 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/application/modules/process-select-modal.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/data.ts 432 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/directory.vue 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/index.vue 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ums/member/modules/form.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/_e2e_attachment.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
课题申报书附件(E2E æµ‹è¯•)
课题名称:面向智能工厂的数字孪生关键技术研究
申报人:张伟
本文件用于端到端验证附件上传与绑定流程。
.playwright-mcp/page-2026-09-28T10-11-52-064Z.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
- generic [active] [ref=f34e1]:
  - banner [ref=f34e2]:
    - generic [ref=f34e3]: MOM
    - heading "项目业务全景" [level=1] [ref=f34e5]
    - textbox "搜索业务:销售订单 / å·¥å• / IQC / åº“å­˜ / å›žæ¬¾â€¦" [ref=f34e7]
  - navigation [ref=f34e8]:
    - generic [ref=f34e9]: æ€»è§ˆ
    - generic [ref=f34e10] [cursor=pointer]: é¡¹ç›®æ¦‚览
    - generic [ref=f34e12] [cursor=pointer]: ä¸šåŠ¡å…¨æ™¯å›¾
    - generic [ref=f34e14]: æ ¸å¿ƒä¸šåŠ¡æµç¨‹
    - generic [ref=f34e15] [cursor=pointer]: é”€å”®æµç¨‹
    - generic [ref=f34e17] [cursor=pointer]: é‡‡è´­æµç¨‹
    - generic [ref=f34e19] [cursor=pointer]: ç”Ÿäº§æµç¨‹
    - generic [ref=f34e21] [cursor=pointer]: è´¨é‡æµç¨‹
    - generic [ref=f34e23] [cursor=pointer]: ä»“储流程
    - generic [ref=f34e25] [cursor=pointer]: å”®åŽæµç¨‹
    - generic [ref=f34e27] [cursor=pointer]: è´¢åŠ¡æŠ¥é”€æµç¨‹
    - generic [ref=f34e29] [cursor=pointer]: ä¼šå‘˜å…¥ä¼šæµç¨‹
    - generic [ref=f34e31] [cursor=pointer]: è¯¾é¢˜ç®¡ç†æµç¨‹
    - generic [ref=f34e33]: ç»“构与数据
    - generic [ref=f34e34] [cursor=pointer]: ç³»ç»Ÿå…³ç³»
    - generic [ref=f34e36] [cursor=pointer]: æ•°æ®æµ
    - generic [ref=f34e38] [cursor=pointer]: ä¸šåŠ¡å¯¹è±¡
    - generic [ref=f34e40] [cursor=pointer]: è§’色权限
    - generic [ref=f34e42]: æ‰©å±•
    - generic [ref=f34e43] [cursor=pointer]: AI ä¸šåŠ¡
    - generic [ref=f34e45] [cursor=pointer]: å¾…确认事项
  - main [ref=f34e47]
  - contentinfo [ref=f34e48]
.playwright-mcp/page-2026-09-28T10-11-57-412Z.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,143 @@
- generic [active] [ref=f34e1]:
  - banner [ref=f34e2]:
    - generic [ref=f34e3]: MOM
    - generic [ref=f34e4]:
      - heading "MOM å·¥åŽ‚æ•°å­—åŒ–å¹³å°ï¼ˆmom-pro2) Â· ä¸šåŠ¡å…¨æ™¯" [level=1] [ref=f34e49]
      - generic [ref=f34e50]: æ··åˆç³»ç»Ÿ | MOM åˆ¶é€ è¿è¥ç®¡ç†ç³»ç»Ÿ
    - textbox "搜索业务:销售订单 / å·¥å• / IQC / åº“å­˜ / å›žæ¬¾â€¦" [ref=f34e7]
  - navigation [ref=f34e8]:
    - generic [ref=f34e9]: æ€»è§ˆ
    - generic [ref=f34e10] [cursor=pointer]: é¡¹ç›®æ¦‚览
    - generic [ref=f34e12] [cursor=pointer]: ä¸šåŠ¡å…¨æ™¯å›¾
    - generic [ref=f34e14]: æ ¸å¿ƒä¸šåŠ¡æµç¨‹
    - generic [ref=f34e15] [cursor=pointer]: é”€å”®æµç¨‹
    - generic [ref=f34e17] [cursor=pointer]: é‡‡è´­æµç¨‹
    - generic [ref=f34e19] [cursor=pointer]: ç”Ÿäº§æµç¨‹
    - generic [ref=f34e21] [cursor=pointer]: è´¨é‡æµç¨‹
    - generic [ref=f34e23] [cursor=pointer]: ä»“储流程
    - generic [ref=f34e25] [cursor=pointer]: å”®åŽæµç¨‹
    - generic [ref=f34e27] [cursor=pointer]: è´¢åŠ¡æŠ¥é”€æµç¨‹
    - generic [ref=f34e29] [cursor=pointer]: ä¼šå‘˜å…¥ä¼šæµç¨‹
    - generic [ref=f34e31] [cursor=pointer]: è¯¾é¢˜ç®¡ç†æµç¨‹
    - generic [ref=f34e33]: ç»“构与数据
    - generic [ref=f34e34] [cursor=pointer]: ç³»ç»Ÿå…³ç³»
    - generic [ref=f34e36] [cursor=pointer]: æ•°æ®æµ
    - generic [ref=f34e38] [cursor=pointer]: ä¸šåŠ¡å¯¹è±¡
    - generic [ref=f34e40] [cursor=pointer]: è§’色权限
    - generic [ref=f34e42]: æ‰©å±•
    - generic [ref=f34e43] [cursor=pointer]: AI ä¸šåŠ¡
    - generic [ref=f34e45] [cursor=pointer]: å¾…确认事项
  - main [ref=f34e47]:
    - generic [ref=f34e184]:
      - heading "课题管理流程" [level=2] [ref=f34e185]
      - paragraph [ref=f34e186]: ç‹¬ç«‹ç§‘研模块(yudao-module-research):学会发布课题指南(重点/一般分类)→ ä¼šå‘˜åœ¨çº¿æäº¤è¯¾é¢˜ç”³æŠ¥ä¹¦ â†’ æäº¤ BPM ä¸‰çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ï¼‰ï¼Œä¸“家评分以结构化明细子表记录并由后端自动汇总加权均分、排名与推荐结论 â†’ å®¡æ‰¹é€šè¿‡ç”Ÿæˆç«‹é¡¹ç¼–号并发布系统公告 â†’ æŒ‰æ‰¹æ¬¡ç™»è®°ç»è´¹æ‹¨ä»˜è®°å½•(含凭证附件)。院级二期再补结项验收与成果归档。
      - generic [ref=f34e187]:
        - generic [ref=f34e188]:
          - generic [ref=f34e189]: è¯¾é¢˜ç®¡ç†æµç¨‹ Â· mermaid/14-research-flow.mmd
          - generic [ref=f34e190]:
            - button "-" [ref=f34e191] [cursor=pointer]
            - button "+" [ref=f34e192] [cursor=pointer]
            - button "重置" [ref=f34e193] [cursor=pointer]
            - button "全屏" [ref=f34e194] [cursor=pointer]
        - document [ref=f34e198]:
          - generic [ref=f34e200]:
            - generic [ref=f34e201]:
              - paragraph [ref=f34e208]: ç«‹é¡¹å®¡æ‰¹ BPM research_topic_approve
              - paragraph [ref=f34e215]: åœ¨çº¿ç”³æŠ¥
              - paragraph [ref=f34e222]: è¯¾é¢˜æŒ‡å—发布
            - generic [ref=f34e253]:
              - paragraph [ref=f34e259]: å·²äº§ç”Ÿç”³æŠ¥å•则禁止删除
              - paragraph [ref=f34e265]: publish å‘布
              - paragraph [ref=f34e271]: close æˆªæ­¢
              - paragraph [ref=f34e277]: æŒ‡å—已发布才可作为申报前提
              - paragraph [ref=f34e283]: delete åˆ é™¤Â·ä»…草稿 0 æ”¾è¡ŒÂ·è¿žå¸¦æ¸…理附件
              - paragraph [ref=f34e289]: submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey
              - paragraph [ref=f34e295]: "submit å‰é€æ¡æ ¡éªŒ: â‘  æŒ‡å—必须已发布 â‘¡ å½“前时间必须在申报起止期内"
              - paragraph [ref=f34e301]: ä»…审批中可增删改专家评分
              - paragraph [ref=f34e307]: åŽç«¯é€è¡Œé‡ç®— compositeScore = é€‰é¢˜0.3 + æ–¹æ¡ˆ0.3 + å›¢é˜Ÿ0.2 + æˆæžœ0.2
              - paragraph [ref=f34e313]: "回写主单: reviewAvgScore=全部专家均分 Â· reviewPass=均分达标 ä¸” å¤šæ•°ç»“论为推荐立项"
              - paragraph [ref=f34e319]: ç§˜ä¹¦å¤„汇总申报书与评审结果
              - paragraph [ref=f34e325]: é€šè¿‡
              - paragraph [ref=f34e331]: é€šè¿‡
              - paragraph [ref=f34e337]: BPM å®¡æ‰¹é€šè¿‡
              - paragraph [ref=f34e343]: BPM å®¡æ‰¹é©³å›ž
              - paragraph [ref=f34e349]: BPM å®¡æ‰¹å–消
              - paragraph [ref=f34e355]: é©³å›ž
              - paragraph [ref=f34e361]: ä¿®æ”¹åŽé‡æ–° submit
              - paragraph [ref=f34e367]: publishNotice å‘布立项通知
              - paragraph [ref=f34e373]: ç™»è®°æ‹¨æ¬¾è®°å½• research_fund_record
              - paragraph [ref=f34e379]: "金额校验: å·²ç™»è®°é‡‘额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出申报单申请经费·超出时报出本次 å·²ç™»è®° åˆè®¡ é¢„算四项数值"
              - paragraph [ref=f34e385]: ä»…待拨付 0 å¯æ”¹å¯åˆ 
              - paragraph [ref=f34e391]: çŠ¶æ€æ”¹ä¸ºå·²æ‹¨ä»˜æ—¶å¿…é¡»å·²æœ‰å‡­è¯é™„ä»¶
            - generic [ref=f34e392]:
              - paragraph [ref=f34e399]: æ–°å»ºæŒ‡å—·选类别 1重点课题 / 2一般课题
              - paragraph [ref=f34e406]: å¡«æŒ‡å—名称 ç”³æŠ¥èµ·æ­¢æ—¶é—´ æŒ‡å—正文 é™„ä»¶
              - paragraph [ref=f34e413]: æŒ‡å—编号 GL + yyyyMMdd + 6位流水(Redis·每日重置)
              - paragraph [ref=f34e420]: è‰ç¨¿ 0·仅草稿可改可删
              - paragraph [ref=f34e427]: æç¤ºè¯¥æŒ‡å—下已有 N å¼ ç”³æŠ¥å•·需先处理申报单
              - paragraph [ref=f34e434]: å·²å‘布 20·回写 publishTime
              - paragraph [ref=f34e441]: å·²æˆªæ­¢ 30
              - paragraph [ref=f34e448]: é€‰æŒ‡å—·要求已发布 ä¸”在申报期内
              - paragraph [ref=f34e455]: é€‰ä¼šå‘˜ ums_member è‡ªåŠ¨å¸¦å‡º å§“名 å·¥ä½œå•位 èŒç§° ç”µè¯ é‚®ç®±
              - paragraph [ref=f34e462]: å¡«è¯¾é¢˜åç§° è¯¾é¢˜ç»„成员 ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ ç”³æŠ¥é™„ä»¶
              - paragraph [ref=f34e469]: ç”³æŠ¥ç¼–号 SB + yyyyMMdd + 6位流水·类别冗余自指南不信任前端传值
              - paragraph [ref=f34e476]: è‰ç¨¿ 0
              - paragraph [ref=f34e483]: å®¡æ‰¹ä¸­ 15·回写 processInstanceId
              - paragraph [ref=f34e490]: ä»»ä¸€ä¸æ»¡è¶³å³æ‹’绝提交·提示带指南名与实际状态或起止时间
              - paragraph [ref=f34e497]: ä¸“家评审明细 research_topic_review·一位专家一行
              - paragraph [ref=f34e504]: æŒ‰åŠ æƒåˆ†é™åºé‡æŽ’åæ¬¡ ranking
              - paragraph [ref=f34e511]: ç”³æŠ¥å•评审汇总·删除最后一条明细时清空汇总
              - paragraph [ref=f34e518]: ä¸‰èŠ‚ç‚¹é¡ºåºÂ·å®¡æ‰¹æ–¹æ³•ä¸Žå®¡æ‰¹äººåœ¨æµç¨‹å®šä¹‰å†…é…ç½®
              - paragraph [ref=f34e525]: Activity_secretariat_review ç§˜ä¹¦å¤„形式审查
              - paragraph [ref=f34e532]: Activity_expert_review ä¸“家评审
              - paragraph [ref=f34e539]: Activity_council_approve å­¦ä¼šç«‹é¡¹å®¡æ‰¹
              - paragraph [ref=f34e546]: å·²ç«‹é¡¹ 20·监听器生成 ç«‹é¡¹ç¼–号 LX + yyyyMMdd + 6位流水 ä¸Ž approveTime·已有编号则幂等跳过
              - paragraph [ref=f34e553]: å·²é©³å›ž 35
              - paragraph [ref=f34e560]: system_notice å…¬å‘ŠÂ·æ ‡é¢˜ã€Žå…³äºŽå…¬å¸ƒç«‹é¡¹è¯¾é¢˜çš„通知』·正文含课题名 ç«‹é¡¹ç¼–号 ç±»åˆ« ç”³æŠ¥äºº å•位 èµ„助金额·回写 noticeId·重复发布被拦截
              - paragraph [ref=f34e567]: å¡«æ‰¹æ¬¡åç§° æ‹¨ä»˜é‡‘额 æ‹¨ä»˜æ—¥æœŸ æ”¶æ¬¾å•位 æ‹¨ä»˜çŠ¶æ€ å‡­è¯é™„ä»¶
              - paragraph [ref=f34e574]: å¾…拨付 0
              - paragraph [ref=f34e581]: å·²æ‹¨ä»˜ 20
              - paragraph [ref=f34e588]: é™„ä»¶ recordType = research_guideline
              - paragraph [ref=f34e595]: é™„ä»¶ recordType = research_application
              - paragraph [ref=f34e602]: é™„ä»¶ recordType = research_fund_record
              - paragraph [ref=f34e609]: "审批中心看单: æµç¨‹å®šä¹‰è¡¨å•类型=自定义表单·创建路径 /research/application/index Â· æŸ¥çœ‹è·¯å¾„ /research/application/detail/approve/index.vue·按 businessKey å›žæ˜¾ç”³æŠ¥å•"
              - paragraph [ref=f34e616]: "审批人取值: ä¸‰èŠ‚ç‚¹åœ¨æµç¨‹å®šä¹‰å†…é…å¥½å®¡æ‰¹æ–¹æ³•(依次审批)与审批人策略·部署后由管理员在 BPM æµç¨‹è®¾è®¡å™¨æŒ‰å­¦ä¼šå®žé™…组织调整·提交时前端只让选流程不选审批人"
              - paragraph [ref=f34e623]: "一期边界: ç»è´¹æ‹¨ä»˜åªåœ¨æœ¬æ¨¡å—落拨款记录·与财务模块无数据同步(不做跨模块对接)"
              - paragraph [ref=f34e630]: "二期范围(本图未覆盖): ç»“项验收 ç»“项证书 æˆæžœå½’档知识库 é‡‡çº³æƒ…况与社会效益标注"
              - paragraph [ref=f34e637]: ç‹¬ç«‹æ¨¡å— yudao-module-research·对 ums_member ä¸ºè½¯å…³è”(无外键)·仅复用 BPM å®¡æ‰¹ã€system å…¬å‘Šä¸Žé™„件中心
      - heading "步骤与角色" [level=3] [ref=f34e646]
      - table [ref=f34e647]:
        - rowgroup [ref=f34e648]:
          - row [ref=f34e649]:
            - columnheader "阶段" [ref=f34e650]
            - columnheader "动作(含真实状态流转)" [ref=f34e651]
            - columnheader "角色" [ref=f34e652]
          - row [ref=f34e653]:
            - cell "发布指南" [ref=f34e654]
            - 'cell "新建指南选类别 1重点课题 / 2一般课题,填申报起止时间与指南正文;编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®ï¼‰ï¼›status: 0草稿/20已发布/30已截止。仅草稿可改可删,发布后即冻结;已产生申报单的指南禁止删除(提示该指南下已有 N å¼ ç”³æŠ¥å•)" [ref=f34e655]'
            - cell "学会秘书处、课题管理员" [ref=f34e656]
          - row [ref=f34e657]:
            - cell "在线申报" [ref=f34e658]
            - 'cell "从已发布且在申报期内的指南新建申报书;选会员(ums_member)自动带出姓名/工作单位/职称/联系电话/邮箱,可改;填课题名称、课题组成员、申请经费、申报书正文;编号 SB+yyyyMMdd+6位流水;类别冗余自指南,不信任前端传值;status: 0草稿" [ref=f34e659]'
            - cell "课题申报人(会员)、课题管理员" [ref=f34e660]
          - row [ref=f34e661]:
            - cell "提交审批" [ref=f34e662]
            - cell "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数)→ status=15 å®¡æ‰¹ä¸­å¹¶å›žå†™ processInstanceId。提交前逐条校验:① æŒ‡å—必须已发布 â‘¡ å½“前时间必须在申报起止期内——任一不满足即拒绝,提示带指南名与实际状态或起止时间;仅草稿与已驳回可改可重提,仅草稿可删(连带清附件)" [ref=f34e663]
            - cell "课题申报人" [ref=f34e664]
          - row [ref=f34e665]:
            - cell "专家评审" [ref=f34e666]
            - cell "申报单处于审批中(15)时录入专家评分明细(一位专家一行:姓名/单位/职称 + é€‰é¢˜ä»·å€¼0.3/研究方案0.3/团队基础0.2/预期成果0.2 å››é¡¹åˆ†å€¼ + ç»“论 + æ„è§ï¼‰ï¼›åŽç«¯æ¯æ¬¡å¢žåˆ æ”¹åŽé‡ç®— compositeScore åŠ æƒåˆè®¡ã€æŒ‰åŠ æƒåˆ†é™åºé‡æŽ’ ranking,并回写主单 reviewAvgScore(全部专家均分)与 reviewPass(均分达标且多数结论为推荐立项);明细全部删除时汇总值清空为未汇总;四项分值均在 0~100 ä¹‹å¤–时提示指出具体维度与实际值" [ref=f34e667]
            - cell "评审专家、学会秘书处、课题管理员" [ref=f34e668]
          - row [ref=f34e669]:
            - cell "立项审批" [ref=f34e670]
            - cell "BPM æµç¨‹ research_topic_approve é¡ºåºèµ°ä¸‰èŠ‚ç‚¹ï¼šç§˜ä¹¦å¤„å½¢å¼å®¡æŸ¥ â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ã€‚审批方法与审批人在流程定义内配置,部署后由管理员在 BPM æµç¨‹è®¾è®¡å™¨æŒ‰å­¦ä¼šå®žé™…组织调整;审批中心内的自定义表单只读回显申报单 + è¯„审明细 + æ‹¨ä»˜è®°å½•。通过→20 å·²ç«‹é¡¹ã€é©³å›žâ†’35 å·²é©³å›žã€å–消→退回 0 è‰ç¨¿" [ref=f34e671]
            - cell "学会秘书处(形式审查)、评审专家(评审节点)、学会理事会(立项审批)" [ref=f34e672]
          - row [ref=f34e673]:
            - cell "立项与通知" [ref=f34e674]
            - cell "监听器 ResearchApplicationStatusListener å…ˆå›žå†™å®¡æ‰¹çŠ¶æ€ï¼Œå®¡æ‰¹é€šè¿‡åŽå†è°ƒ approveApplication:生成立项编号 LX+yyyyMMdd+6位流水 ä¸Ž approveTime(已有编号则幂等跳过)。点「发布立项通知」生成 system_notice å…¬å‘Šï¼ˆæ ‡é¢˜ã€Žå…³äºŽå…¬å¸ƒç«‹é¡¹è¯¾é¢˜çš„通知』,正文含课题名/立项编号/类别/申报人/单位/资助金额),写回 noticeId é˜²é‡å¤â€”—重复点击按已发布拦截并告知上次发布时间" [ref=f34e675]
            - cell "课题管理员、学会秘书处" [ref=f34e676]
          - row [ref=f34e677]:
            - cell "经费拨付" [ref=f34e678]
            - 'cell "仅「已立项(20)」的申报单可登记拨款记录(批次名称/拨付金额/拨付日期/收款单位/状态/备注/凭证附件);金额校验:已登记金额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出该单申请经费,超出时提示报出本次、已登记、合计、预算四项数值;status: 0待拨付/20已拨付,凭证明细走 system é™„件中心 recordType=research_fund_record,状态改为已拨付时凭证必填;仅待拨付状态可改可删,编辑时会把本行金额加回余额显示" [ref=f34e679]'
            - cell "学会财务、课题管理员" [ref=f34e680]
  - contentinfo [ref=f34e48]: åŸºäºŽ dev_business åˆ†æ”¯ 87bf4a99 æºç ï¼Œ6 ç»„并行源码走读(MES ç”Ÿäº§è´¨é‡ / MES ä»“储 / ERP / CRM+SRM / å”®åŽ+HRM / AI+BI+MDM+BPM+底座);2026-09-28 å¢žé‡è¡¥å…… yudao-module-finance ç‹¬ç«‹è´¢åŠ¡æŠ¥é”€æ¨¡å—ï¼ˆæŠ¥é”€å• + æ˜Žç»† + å‘票附件 + BPM å®¡æ‰¹ + å½’档)与 yudao-module-ums ç‹¬ç«‹ä¼šå‘˜ç®¡ç†æ¨¡å—(入会申请 + BPM ä¸¤çº§å®¡æ‰¹ + å®¡æ‰¹é€šè¿‡è‡ªåŠ¨å»ºä¼šå‘˜ä¸Žç™»å½•è´¦å· + ä¼šå‘˜åˆ—表/通讯录);2026-09-28 å¢žé‡ï¼šBPM æµç¨‹å®šä¹‰ ums_membership_approve(入会审批)已在 mom-jssm éƒ¨ç½²å¹¶å¹¶å…¥ config_export_all é˜¶æ®µä¸€ï¼ˆåˆ†ç±» ums_membership_approve,秘书处初审 â†’ ç†äº‹ä¼šå®¡è®®ï¼Œä¸¤èŠ‚ç‚¹å®¡æ‰¹äººå‡ä¸ºã€Œè§’è‰²ã€ç­–ç•¥ã€è§’è‰²=超级管理员(1) å…œåº•,表单类型=自定义表单,查看路径 /ums/application/detail/approve/index.vue);同时修复 mom-jssm ç”± CREATE TABLE ... LIKE å…‹éš†å¯¼è‡´çš„ 47 ä¸ªæŒ‡å‘ ruoyi-vue-pro çš„ act_*/flw_* è·¨åº“外键(重建为指向 mom-jssm è‡ªèº«ï¼Œå­¤å„¿è¡Œé¢„检为 0);2026-09-28 å¢žé‡ï¼šæ–°å¢ž yudao-module-research ç‹¬ç«‹ç§‘研课题模块一期(课题指南发布 + åœ¨çº¿ç”³æŠ¥ + ä¸“家评审明细 + BPM ä¸‰çº§ç«‹é¡¹å®¡æ‰¹ + ç«‹é¡¹é€šçŸ¥å…¬å‘Š + ç»è´¹æ‹¨ä»˜è®°å½•,4 å¼  research_* è¡¨ / 5 ä¸ªå­—å…¸ / bpm_category id=8002 / æµç¨‹ research_topic_approve / èœå• 1075510~1075553),并并入 config_export_all_20260928 é˜¶æ®µä¸€ï¼›ç”³æŠ¥äººå– ums_member(软关联)、立项通知复用 system_notice å…¬å‘Šã€æ‹¨æ¬¾å‡­è¯èµ° system é™„件中心 recordType=research_fund_record Â· ç”ŸæˆäºŽ 2026-09-28 Â· ç»´æŠ¤è¯·èµ°æŠ€èƒ½ project-business-analysis
src/api/finance/reimbursement/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace FinanceReimbursementApi {
  /** æŠ¥é”€æ˜Žç»†è¡Œ */
  export interface ReimbursementItem {
    id?: number;
    reimbursementId?: number;
    expenseType: number; // è´¹ç”¨ç±»åž‹ï¼Œå­—å…¸ finance_expense_type
    invoiceNo?: string; // å‘票号码
    invoiceAmount: number; // å‘票金额
    invoiceDate?: string; // å‘票日期 yyyy-MM-dd
    drawer?: string; // å¼€ç¥¨æ–¹
    description?: string; // è´¹ç”¨è¯´æ˜Ž
  }
  /** æŠ¥é”€å•信息 */
  export interface Reimbursement {
    id?: number;
    no?: string; // æŠ¥é”€å•号,后端生成
    title: string; // æŠ¥é”€æ ‡é¢˜
    totalAmount: number; // æŠ¥é”€æ€»é‡‘额
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ finance_reimbursement_status
    applicantId?: number; // ç”³è¯·äºº ID
    applicantName?: string; // ç”³è¯·äººå§“名
    applyDate: string; // ç”³è¯·æ—¥æœŸ yyyy-MM-dd
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    archiveTime?: string; // å½’档时间
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
    createTime?: string; // åˆ›å»ºæ—¶é—´
    updateTime?: string; // æ›´æ–°æ—¶é—´
    items?: ReimbursementItem[]; // æŠ¥é”€æ˜Žç»†
    attachmentList?: SystemStorageApi.StorageBlob[]; // å‘票附件
    hasAttachment?: boolean; // æ˜¯å¦æœ‰å‘票附件
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
}
/** æŸ¥è¯¢æŠ¥é”€å•分页 */
export function getReimbursementPage(params: PageParam) {
  return requestClient.get<PageResult<FinanceReimbursementApi.Reimbursement>>(
    '/finance/reimbursement/page',
    { params },
  );
}
/** æŸ¥è¯¢æŠ¥é”€å•详情 */
export function getReimbursement(id: number) {
  return requestClient.get<FinanceReimbursementApi.Reimbursement>(
    `/finance/reimbursement/get?id=${id}`,
  );
}
/** æ–°å¢žæŠ¥é”€å• */
export function createReimbursement(
  data: FinanceReimbursementApi.Reimbursement,
) {
  return requestClient.post('/finance/reimbursement/create', data);
}
/** ä¿®æ”¹æŠ¥é”€å•(仅草稿、已驳回可改) */
export function updateReimbursement(
  data: FinanceReimbursementApi.Reimbursement,
) {
  return requestClient.put('/finance/reimbursement/update', data);
}
/** åˆ é™¤æŠ¥é”€å•(仅草稿可删) */
export function deleteReimbursement(id: number) {
  return requestClient.delete('/finance/reimbursement/delete', {
    params: { id },
  });
}
/** æäº¤æŠ¥é”€å•审批 */
export function submitReimbursement(id: number, processDefinitionKey: string) {
  return requestClient.post('/finance/reimbursement/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** å½’档报销单(仅已通过可归档) */
export function archiveReimbursement(id: number) {
  return requestClient.post('/finance/reimbursement/archive', null, {
    params: { id },
  });
}
/** èŽ·å–æŠ¥é”€å•å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getReimbursementApproveProcessList() {
  return requestClient.get<FinanceReimbursementApi.ProcessDefinition[]>(
    '/finance/reimbursement/approve-process-list',
  );
}
/** å¯¼å‡ºæŠ¥é”€å• Excel */
export function exportReimbursement(params: any) {
  return requestClient.download('/finance/reimbursement/export-excel', {
    params,
  });
}
src/api/research/application/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace ResearchApplicationApi {
  /** è¯¾é¢˜ç”³æŠ¥ä¹¦ */
  export interface Application {
    id?: number;
    no?: string; // ç”³æŠ¥ç¼–号,后端生成(SB å‰ç¼€ï¼‰
    guidelineId?: number; // æ‰€å±žè¯¾é¢˜æŒ‡å— ID
    guidelineTitle?: string; // æ‰€å±žè¯¾é¢˜æŒ‡å—名称
    title: string; // è¯¾é¢˜åç§°
    category?: number; // è¯¾é¢˜ç±»åˆ«ï¼ˆåŽç«¯è‡ªæŒ‡å—冗余,前端只读)
    memberId?: number; // ç”³æŠ¥ä¼šå‘˜ ID
    applicantName: string; // ç”³æŠ¥äººå§“名/单位名称
    applicantUserId?: number; // ç”³æŠ¥ç™»å½•用户 ID
    workUnit?: string; // å·¥ä½œå•位
    jobTitle?: string; // èŒç§°
    contactPhone?: string; // è”系电话
    contactEmail?: string; // è”系邮箱
    teamMembers?: string; // è¯¾é¢˜ç»„成员(每行一条:姓名|单位|职称)
    budgetAmount?: number; // ç”³è¯·ç»è´¹ï¼ˆå…ƒï¼‰
    planContent?: string; // ç”³æŠ¥ä¹¦æ­£æ–‡
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ research_application_status
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    reviewAvgScore?: number; // ä¸“家评审加权均分(后端汇总)
    reviewPass?: number; // è¯„审结论:0-未通过 1-推荐立项(后端汇总)
    researchNo?: string; // ç«‹é¡¹ç¼–号,审批通过后生成
    approveTime?: string; // ç«‹é¡¹æ—¶é—´
    noticeId?: number; // å·²å‘布的立项通知公告 ID
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
  /** ä¸“家评审明细 */
  export interface TopicReview {
    id?: number;
    applicationId?: number; // è¯¾é¢˜ç”³æŠ¥ä¹¦ ID
    evaluatorName: string; // ä¸“家姓名
    evaluatorUnit?: string; // ä¸“家所在单位
    evaluatorTitle?: string; // ä¸“家职称
    topicScore?: number; // é€‰é¢˜ä»·å€¼ä¸Žæ„ä¹‰å¾—分(权重 0.3)
    planScore?: number; // ç ”究方案可行性得分(权重 0.3)
    teamScore?: number; // å›¢é˜Ÿä¸Žç ”究基础得分(权重 0.2)
    resultScore?: number; // é¢„期成果与效益得分(权重 0.2)
    compositeScore?: number; // åŠ æƒåˆè®¡å¾—åˆ†ï¼ˆåŽç«¯è®¡ç®—ï¼‰
    ranking?: number; // æœ¬ç”³æŠ¥å•内评分排名(后端计算)
    conclusion?: number; // è¯„审结论,字典 research_review_conclusion
    opinion?: string; // è¯„审意见
    reviewTime?: string; // è¯„审时间
    creatorName?: string; // å½•入人姓名
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
}
/** æŸ¥è¯¢è¯¾é¢˜ç”³æŠ¥ä¹¦åˆ†é¡µ */
export function getApplicationPage(params: PageParam) {
  return requestClient.get<PageResult<ResearchApplicationApi.Application>>(
    '/research/application/page',
    { params },
  );
}
/** æŸ¥è¯¢è¯¾é¢˜ç”³æŠ¥ä¹¦è¯¦æƒ… */
export function getApplication(id: number) {
  return requestClient.get<ResearchApplicationApi.Application>(
    `/research/application/get?id=${id}`,
  );
}
/** æ–°å¢žè¯¾é¢˜ç”³æŠ¥ä¹¦ï¼ˆè‰ç¨¿ï¼‰ */
export function createApplication(data: ResearchApplicationApi.Application) {
  return requestClient.post('/research/application/create', data);
}
/** ä¿®æ”¹è¯¾é¢˜ç”³æŠ¥ä¹¦ï¼ˆä»…草稿、已驳回可改) */
export function updateApplication(data: ResearchApplicationApi.Application) {
  return requestClient.put('/research/application/update', data);
}
/** åˆ é™¤è¯¾é¢˜ç”³æŠ¥ä¹¦ï¼ˆä»…草稿可删) */
export function deleteApplication(id: number) {
  return requestClient.delete('/research/application/delete', {
    params: { id },
  });
}
/** æäº¤è¯¾é¢˜ç”³æŠ¥ä¹¦å®¡æ‰¹ */
export function submitApplication(id: number, processDefinitionKey: string) {
  return requestClient.post('/research/application/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–è¯¾é¢˜ç«‹é¡¹å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getApplicationApproveProcessList() {
  return requestClient.get<ResearchApplicationApi.ProcessDefinition[]>(
    '/research/application/approve-process-list',
  );
}
/** å‘布立项通知 */
export function publishApplicationNotice(id: number) {
  return requestClient.post('/research/application/publish-notice', null, {
    params: { id },
  });
}
/** æŸ¥è¯¢æŸç”³æŠ¥ä¹¦çš„专家评审明细列表 */
export function getTopicReviewList(applicationId: number) {
  return requestClient.get<ResearchApplicationApi.TopicReview[]>(
    '/research/topic-review/list',
    { params: { applicationId } },
  );
}
/** å½•入专家评审明细 */
export function createTopicReview(data: ResearchApplicationApi.TopicReview) {
  return requestClient.post('/research/topic-review/create', data);
}
/** ä¿®æ”¹ä¸“家评审明细 */
export function updateTopicReview(data: ResearchApplicationApi.TopicReview) {
  return requestClient.put('/research/topic-review/update', data);
}
/** åˆ é™¤ä¸“家评审明细 */
export function deleteTopicReview(id: number) {
  return requestClient.delete('/research/topic-review/delete', {
    params: { id },
  });
}
src/api/research/fund/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace ResearchFundRecordApi {
  /** ç»è´¹æ‹¨ä»˜è®°å½• */
  export interface FundRecord {
    id?: number;
    applicationId: number; // å…³è”课题申报书 ID
    batchName?: string; // æ‹¨ä»˜æ‰¹æ¬¡ï¼Œå¦‚「第一期」
    amount: number; // æ‹¨ä»˜é‡‘额(元)
    payDate?: string; // æ‹¨ä»˜æ—¥æœŸ
    payee?: string; // æ”¶æ¬¾å•位/账户名
    status?: number; // æ‹¨ä»˜çŠ¶æ€ï¼Œå­—å…¸ research_fund_status
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // å‡­è¯é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰å‡­è¯é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„凭证附件 ID åˆ—表(全量)
  }
}
/** æŸ¥è¯¢æŸè¯¾é¢˜ç”³æŠ¥ä¹¦çš„经费拨付记录列表 */
export function getFundRecordList(applicationId: number) {
  return requestClient.get<ResearchFundRecordApi.FundRecord[]>(
    '/research/fund-record/list',
    { params: { applicationId } },
  );
}
/** æ–°å¢žç»è´¹æ‹¨ä»˜è®°å½• */
export function createFundRecord(data: ResearchFundRecordApi.FundRecord) {
  return requestClient.post('/research/fund-record/create', data);
}
/** ä¿®æ”¹ç»è´¹æ‹¨ä»˜è®°å½•(仅待拨付可改) */
export function updateFundRecord(data: ResearchFundRecordApi.FundRecord) {
  return requestClient.put('/research/fund-record/update', data);
}
/** åˆ é™¤ç»è´¹æ‹¨ä»˜è®°å½•(仅待拨付可删) */
export function deleteFundRecord(id: number) {
  return requestClient.delete('/research/fund-record/delete', {
    params: { id },
  });
}
src/api/research/guideline/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace ResearchGuidelineApi {
  /** è¯¾é¢˜æŒ‡å— */
  export interface Guideline {
    id?: number;
    no?: string; // æŒ‡å—编号,后端生成(GL å‰ç¼€ï¼‰
    title: string; // æŒ‡å—名称
    category: number; // è¯¾é¢˜ç±»åˆ«ï¼Œå­—å…¸ research_guideline_category
    applyStartTime?: string; // ç”³æŠ¥å¼€å§‹æ—¶é—´
    applyEndTime?: string; // ç”³æŠ¥æˆªæ­¢æ—¶é—´
    content?: string; // æŒ‡å—正文
    publishStatus?: number; // å‘布状态,字典 research_guideline_status
    publishTime?: string; // å‘布时间
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
}
/** æŸ¥è¯¢è¯¾é¢˜æŒ‡å—分页 */
export function getGuidelinePage(params: PageParam) {
  return requestClient.get<PageResult<ResearchGuidelineApi.Guideline>>(
    '/research/guideline/page',
    { params },
  );
}
/** æŸ¥è¯¢è¯¾é¢˜æŒ‡å—详情 */
export function getGuideline(id: number) {
  return requestClient.get<ResearchGuidelineApi.Guideline>(
    `/research/guideline/get?id=${id}`,
  );
}
/** æŸ¥è¯¢å·²å‘布的课题指南列表(供申报选指南) */
export function getPublishedGuidelineList() {
  return requestClient.get<ResearchGuidelineApi.Guideline[]>(
    '/research/guideline/published-list',
  );
}
/** æ–°å¢žè¯¾é¢˜æŒ‡å—(草稿) */
export function createGuideline(data: ResearchGuidelineApi.Guideline) {
  return requestClient.post('/research/guideline/create', data);
}
/** ä¿®æ”¹è¯¾é¢˜æŒ‡å—(仅草稿可改) */
export function updateGuideline(data: ResearchGuidelineApi.Guideline) {
  return requestClient.put('/research/guideline/update', data);
}
/** åˆ é™¤è¯¾é¢˜æŒ‡å—(仅草稿且无申报书可删) */
export function deleteGuideline(id: number) {
  return requestClient.delete('/research/guideline/delete', {
    params: { id },
  });
}
/** å‘布课题指南 */
export function publishGuideline(id: number) {
  return requestClient.put('/research/guideline/publish', null, {
    params: { id },
  });
}
/** æˆªæ­¢è¯¾é¢˜æŒ‡å— */
export function closeGuideline(id: number) {
  return requestClient.put('/research/guideline/close', null, {
    params: { id },
  });
}
src/api/ums/application/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace UmsApplicationApi {
  /** å…¥ä¼šç”³è¯· */
  export interface Application {
    id?: number;
    no?: string; // ç”³è¯·ç¼–号,后端生成
    memberType: number; // ä¼šå‘˜ç±»åž‹ï¼Œå­—å…¸ ums_member_type
    applicantName: string; // ç”³è¯·äººå§“名(个人)或单位名称(单位)
    contactPhone?: string; // è”系电话
    contactEmail?: string; // é‚®ç®±
    legalPerson?: string; // æ³•定代表人(单位专有)
    organizationCode?: string; // ç»Ÿä¸€ç¤¾ä¼šä¿¡ç”¨ä»£ç ï¼ˆå•位专有)
    researchDirection?: string; // ç ”究方向
    workUnit?: string; // å·¥ä½œå•位(个人专有)
    title?: string; // èŒç§°ï¼ˆä¸ªäººä¸“有)
    memberGrade?: string; // ç”³è¯·ç­‰çº§ï¼ˆæŒ‰ memberType å–不同字典)
    regionCode?: string; // åœ°åŒºç¼–码
    industry?: string; // è¡Œä¸šï¼Œå­—å…¸ ums_industry
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_application_status
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ ID
    remark?: string; // å¤‡æ³¨
    creator?: string; // åˆ›å»ºäºº ID
    creatorName?: string; // åˆ›å»ºäººå§“名
    createTime?: string; // åˆ›å»ºæ—¶é—´
    updateTime?: string; // æ›´æ–°æ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ProcessDefinition {
    id: string;
    key: string;
    name: string;
  }
}
/** æŸ¥è¯¢å…¥ä¼šç”³è¯·åˆ†é¡µ */
export function getApplicationPage(params: PageParam) {
  return requestClient.get<PageResult<UmsApplicationApi.Application>>(
    '/ums/application/page',
    { params },
  );
}
/** æŸ¥è¯¢å…¥ä¼šç”³è¯·è¯¦æƒ… */
export function getApplication(id: number) {
  return requestClient.get<UmsApplicationApi.Application>(
    `/ums/application/get?id=${id}`,
  );
}
/** æ–°å¢žå…¥ä¼šç”³è¯· */
export function createApplication(data: UmsApplicationApi.Application) {
  return requestClient.post('/ums/application/create', data);
}
/** ä¿®æ”¹å…¥ä¼šç”³è¯·ï¼ˆä»…草稿、已驳回可改) */
export function updateApplication(data: UmsApplicationApi.Application) {
  return requestClient.put('/ums/application/update', data);
}
/** åˆ é™¤å…¥ä¼šç”³è¯·ï¼ˆä»…草稿可删) */
export function deleteApplication(id: number) {
  return requestClient.delete('/ums/application/delete', {
    params: { id },
  });
}
/** æäº¤å…¥ä¼šç”³è¯·å®¡æ‰¹ */
export function submitApplication(id: number, processDefinitionKey: string) {
  return requestClient.post('/ums/application/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–å…¥ä¼šç”³è¯·å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getApplicationApproveProcessList() {
  return requestClient.get<UmsApplicationApi.ProcessDefinition[]>(
    '/ums/application/approve-process-list',
  );
}
src/api/ums/member/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace UmsMemberApi {
  /** ä¼šå‘˜ */
  export interface Member {
    id?: number;
    no?: string; // ä¼šå‘˜ç¼–号,后端生成
    userId?: number; // å…³è”系统用户 ID
    username?: string; // å…³è”用户名(昵称)
    applicationId?: number; // æ¥æºç”³è¯·å• ID
    memberType: number; // ä¼šå‘˜ç±»åž‹ï¼Œå­—å…¸ ums_member_type
    memberGrade: string; // ä¼šå‘˜ç­‰çº§ï¼ˆæŒ‰ memberType å–不同字典)
    name: string; // å§“名(个人)或单位名称(单位)
    contactUser?: string; // è”系人(单位用)
    contactPhone?: string; // è”系电话
    contactEmail?: string; // é‚®ç®±
    legalPerson?: string; // æ³•定代表人(单位专有)
    organizationCode?: string; // ç»Ÿä¸€ç¤¾ä¼šä¿¡ç”¨ä»£ç 
    researchDirection?: string; // ç ”究方向
    workUnit?: string; // å·¥ä½œå•位(个人专有)
    title?: string; // èŒç§°ï¼ˆä¸ªäººä¸“有)
    unitIntro?: string; // å•位简介(单位专有)
    regionCode?: string; // åœ°åŒºç¼–码
    industry?: string; // è¡Œä¸šï¼Œå­—å…¸ ums_industry
    joinDate?: string; // å…¥ä¼šæ—¥æœŸ
    expireDate?: string; // æœ‰æ•ˆæœŸè‡³
    status?: number; // çŠ¶æ€ï¼Œå­—å…¸ ums_member_status
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // æäº¤æ—¶çš„附件 ID åˆ—表(全量)
  }
}
/** æŸ¥è¯¢ä¼šå‘˜é€šè®¯å½•分页 */
export function getMemberPage(params: PageParam) {
  return requestClient.get<PageResult<UmsMemberApi.Member>>('/ums/member/page', {
    params,
  });
}
/** æŸ¥è¯¢ä¼šå‘˜è¯¦æƒ… */
export function getMember(id: number) {
  return requestClient.get<UmsMemberApi.Member>(`/ums/member/get?id=${id}`);
}
/** é€šè¿‡ç”¨æˆ· ID æŸ¥è¯¢ä¼šå‘˜ */
export function getMemberByUserId(userId: number) {
  return requestClient.get<UmsMemberApi.Member>(
    `/ums/member/get-by-user-id?userId=${userId}`,
  );
}
/** æ–°å¢žä¼šå‘˜ */
export function createMember(data: UmsMemberApi.Member) {
  return requestClient.post('/ums/member/create', data);
}
/** ä¿®æ”¹ä¼šå‘˜ */
export function updateMember(data: UmsMemberApi.Member) {
  return requestClient.put('/ums/member/update', data);
}
/** åˆ é™¤ä¼šå‘˜ */
export function deleteMember(id: number) {
  return requestClient.delete('/ums/member/delete', {
    params: { id },
  });
}
src/packages/constants/src/dict-enum.ts
@@ -144,6 +144,31 @@
  ERP_STOCK_RECORD_BIZ_TYPE: 'erp_stock_record_biz_type', // åº“存明细的业务类型
} as const;
/** ========== FINANCE - è´¢åŠ¡æ¨¡å— ========== */
const FINANCE_DICT = {
  FINANCE_REIMBURSEMENT_STATUS: 'finance_reimbursement_status', // æŠ¥é”€å•状态
  FINANCE_EXPENSE_TYPE: 'finance_expense_type', // è´¹ç”¨ç±»åž‹
} as const;
/** ========== UMS - ä¼šå‘˜ç®¡ç†æ¨¡å— ========== */
const UMS_DICT = {
  UMS_APPLICATION_STATUS: 'ums_application_status', // å…¥ä¼šç”³è¯·çŠ¶æ€
  UMS_MEMBER_TYPE: 'ums_member_type', // ä¼šå‘˜ç±»åž‹
  UMS_MEMBER_STATUS: 'ums_member_status', // ä¼šå‘˜çŠ¶æ€
  UMS_PERSONAL_GRADE: 'ums_personal_grade', // ä¸ªäººä¼šå‘˜ç­‰çº§
  UMS_UNIT_GRADE: 'ums_unit_grade', // å•位会员等级
  UMS_INDUSTRY: 'ums_industry', // ä¼šå‘˜è¡Œä¸š
} as const;
/** ========== RESEARCH - è¯¾é¢˜ç®¡ç†æ¨¡å— ========== */
const RESEARCH_DICT = {
  RESEARCH_GUIDELINE_CATEGORY: 'research_guideline_category', // è¯¾é¢˜æŒ‡å—类别(重点/一般)
  RESEARCH_GUIDELINE_STATUS: 'research_guideline_status', // è¯¾é¢˜æŒ‡å—发布状态
  RESEARCH_APPLICATION_STATUS: 'research_application_status', // è¯¾é¢˜ç”³æŠ¥ä¹¦çŠ¶æ€
  RESEARCH_REVIEW_CONCLUSION: 'research_review_conclusion', // ä¸“家评审结论
  RESEARCH_FUND_STATUS: 'research_fund_status', // ç»è´¹æ‹¨ä»˜çŠ¶æ€
} as const;
/** ========== AI - äººå·¥æ™ºèƒ½æ¨¡å— ========== */
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
@@ -351,12 +376,15 @@
  ...BPM_DICT,
  ...CRM_DICT,
  ...ERP_DICT,
  ...FINANCE_DICT,
  ...HRM_DICT,
  ...INFRA_DICT,
  ...IM_DICT,
  ...IOT_DICT,
  ...MES_DICT,
  ...MDM_DICT,
  ...UMS_DICT,
  ...RESEARCH_DICT,
  ...WMS_DICT,
  ...MEMBER_DICT,
  ...MP_DICT,
src/views/finance/reimbursement/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,307 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { erpPriceInputFormatter } from '@vben/utils';
import { getSimpleUserList } from '#/api/system/user';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** æŠ¥é”€å•状态(对应字典 finance_reimbursement_status) */
export const ReimbursementStatus = {
  DRAFT: 0, // è‰ç¨¿
  APPROVING: 15, // å®¡æ‰¹ä¸­
  APPROVED: 20, // å·²é€šè¿‡
  REJECTED: 35, // å·²é©³å›ž
  ARCHIVED: 40, // å·²å½’æ¡£
} as const;
/** è¡¨å•的配置项 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '报销单号',
      component: 'Input',
      componentProps: {
        placeholder: '留空则由系统自动生成',
        maxlength: 36,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'title',
      label: '报销标题',
      component: 'Input',
      componentProps: {
        placeholder: '请输入报销标题',
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'applyDate',
      label: '申请日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择申请日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'totalAmount',
      label: '报销总金额',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        placeholder: '由明细金额汇总,可手动修改',
        precision: 2,
        formatter: erpPriceInputFormatter,
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '发票附件',
      component: 'FileUpload',
      // æ³¨æ„ï¼švalueKey ä¸º id,提交时 formApi ä¼šæŠŠæ–‡ä»¶å¯¹è±¡æå–为 blobId æ•°ç»„
      componentProps: {
        valueKey: 'id',
        maxNumber: 20,
        multiple: true,
        showDescription: formType !== 'detail',
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'items',
      label: '报销明细',
      component: 'Input',
      formItemClass: 'col-span-2',
    },
  ];
}
/** æŠ¥é”€æ˜Žç»†è¡¨æ ¼åˆ—(明细随主表一次性提交,前端可增删行) */
export function useItemColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', width: 55, fixed: 'left' },
    {
      field: 'expenseType',
      title: '费用类型',
      minWidth: 120,
      slots: { default: 'expenseType' },
    },
    {
      field: 'invoiceNo',
      title: '发票号码',
      minWidth: 160,
      slots: { default: 'invoiceNo' },
    },
    {
      field: 'invoiceAmount',
      title: '发票金额',
      minWidth: 130,
      slots: { default: 'invoiceAmount' },
    },
    {
      field: 'invoiceDate',
      title: '发票日期',
      minWidth: 140,
      slots: { default: 'invoiceDate' },
    },
    {
      field: 'drawer',
      title: '开票方',
      minWidth: 180,
      slots: { default: 'drawer' },
    },
    {
      field: 'description',
      title: '费用说明',
      minWidth: 180,
      slots: { default: 'description' },
    },
    {
      title: '操作',
      width: 80,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/**
 * æ˜Žç»†è¡ŒæŠ•影为可提交对象:字段白名单,且**一律不带 id / reimbursementId**。
 * åŽç«¯ä¿å­˜æ˜Žç»†æ˜¯ã€Œå…ˆåˆ åŽå¢žã€å…¨é‡è¦†ç›–(软删旧行),若回传原 id ä¼šä¸Žå·²è½¯åˆ è¡Œä¸»é”®å†²çªã€‚
 * å›žæ˜¾ä¸Žæäº¤ä¸¤æ¡è·¯å¾„都必须经过此函数。
 */
export function toSubmitItems(
  items?: FinanceReimbursementApi.ReimbursementItem[],
): FinanceReimbursementApi.ReimbursementItem[] {
  return (items ?? []).map((item) => ({
    expenseType: item.expenseType,
    invoiceNo: item.invoiceNo,
    invoiceAmount: item.invoiceAmount,
    invoiceDate: item.invoiceDate,
    drawer: item.drawer,
    description: item.description,
  }));
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '报销单号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入报销单号',
        allowClear: true,
      },
    },
    {
      fieldName: 'title',
      label: '报销标题',
      component: 'Input',
      componentProps: {
        placeholder: '请输入报销标题',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.FINANCE_REIMBURSEMENT_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
    {
      fieldName: 'applicantId',
      label: '申请人',
      component: 'ApiSelect',
      componentProps: {
        placeholder: '请选择申请人',
        allowClear: true,
        showSearch: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        valueField: 'id',
      },
    },
    {
      fieldName: 'applyDate',
      label: '申请日期',
      component: 'RangePicker',
      componentProps: {
        // åŽç«¯ LocalDate[] åªæŽ¥å— yyyy-MM-dd,不能带时分秒
        valueFormat: 'YYYY-MM-DD',
        allowClear: true,
        style: { width: '100%' },
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      type: 'checkbox',
      width: 50,
      fixed: 'left',
    },
    {
      field: 'no',
      title: '报销单号',
      width: 175,
      fixed: 'left',
    },
    {
      field: 'title',
      title: '报销标题',
      minWidth: 160,
    },
    {
      field: 'totalAmount',
      title: '报销总金额',
      minWidth: 120,
      formatter: 'formatAmount2',
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 95,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_REIMBURSEMENT_STATUS },
      },
    },
    {
      field: 'applicantName',
      title: '申请人',
      minWidth: 100,
    },
    {
      field: 'applyDate',
      title: '申请日期',
      width: 115,
      formatter: 'formatDate',
    },
    {
      field: 'hasAttachment',
      title: '是否上传发票',
      minWidth: 110,
      slots: { default: 'hasAttachment' },
    },
    {
      field: 'creatorName',
      title: '创建人',
      minWidth: 100,
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 300,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/finance/reimbursement/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,199 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import type { DescriptionItemSchema } from '#/components/description';
import { h, nextTick, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReimbursement } from '#/api/finance/reimbursement';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
defineOptions({ name: 'FinanceReimbursementApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<FinanceReimbursementApi.Reimbursement>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** æŠ¥é”€æ˜Žç»†ï¼ˆå®¡æ‰¹è¯¦æƒ…只读列) */
function useReimbursementItemColumns(): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', width: 55 },
    {
      field: 'expenseType',
      title: '费用类型',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_EXPENSE_TYPE },
      },
    },
    {
      field: 'invoiceNo',
      title: '发票号码',
      minWidth: 160,
      formatter: ({ cellValue }) => textRender(cellValue),
    },
    {
      field: 'invoiceAmount',
      title: '发票金额',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'invoiceDate',
      title: '发票日期',
      minWidth: 120,
      formatter: ({ cellValue }) => textRender(cellValue),
    },
    {
      field: 'drawer',
      title: '开票方',
      minWidth: 160,
      formatter: ({ cellValue }) => textRender(cellValue),
    },
    {
      field: 'description',
      title: '费用说明',
      minWidth: 160,
      formatter: ({ cellValue }) => textRender(cellValue),
    },
  ];
}
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useReimbursementItemColumns(),
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '报销单号', field: 'no', render: textRender },
    { label: '报销标题', field: 'title', render: textRender },
    {
      label: '报销总金额(元)',
      field: 'totalAmount',
      render: (val) => erpPriceInputFormatter(val) || '-',
    },
    {
      label: '状态',
      field: 'status',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.FINANCE_REIMBURSEMENT_STATUS,
          value: val,
        }),
    },
    { label: '申请人', field: 'applicantName', render: textRender },
    {
      label: '申请日期',
      field: 'applyDate',
      render: (val) => textRender(val),
    },
    {
      label: '归档时间',
      field: 'archiveTime',
      render: (val) => (val ? (formatDateTime(val) as string) : '-'),
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => formatDateTime(val) as string,
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getReimbursement(Number(props.id));
    if (formData.value?.items?.length) {
      await nextTick();
      await gridApi.grid.reloadData(formData.value.items);
    }
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- æŠ¥é”€å•基本信息 -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">报销单基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- æŠ¥é”€æ˜Žç»† -->
        <div
          v-if="formData?.items?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">报销明细</span>
          </div>
          <Grid />
        </div>
        <!-- å‘票附件 -->
        <div
          v-if="formData?.attachmentList?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">发票附件</span>
          </div>
          <div class="flex flex-wrap gap-3">
            <a
              v-for="file in formData.attachmentList"
              :key="file.id"
              :href="file.previewURL || file.url"
              target="_blank"
              class="text-blue-500 hover:underline"
            >
              {{ file.name || `发票-${file.id}` }}
            </a>
          </div>
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/finance/reimbursement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,285 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  archiveReimbursement,
  deleteReimbursement,
  exportReimbursement,
  getReimbursementApproveProcessList,
  getReimbursementPage,
} from '#/api/finance/reimbursement';
import { $t } from '#/locales';
import {
  ReimbursementStatus,
  useGridColumns,
  useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
/** è´¢åŠ¡æŠ¥é”€å•åˆ—è¡¨ */
defineOptions({ name: 'FinanceReimbursement' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportReimbursement(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '报销单.xls', source: data });
}
/** æ–°å¢žæŠ¥é”€å• */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑报销单(仅草稿、已驳回可编辑) */
function handleEdit(row: FinanceReimbursementApi.Reimbursement) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹æŠ¥é”€å•详情 */
function handleDetail(row: FinanceReimbursementApi.Reimbursement) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤æŠ¥é”€å•(仅草稿可删) */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteReimbursement(id)));
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ï¼Œæµç¨‹ key ä¸å†™æ­» */
async function handleSubmit(row: FinanceReimbursementApi.Reimbursement) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getReimbursementApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先到 BPM æµç¨‹è®¾è®¡å™¨å‘布报销审批流程');
      return;
    }
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
/** å½’档报销单 */
async function handleArchive(row: FinanceReimbursementApi.Reimbursement) {
  const hideLoading = message.loading({
    content: '归档中...',
    duration: 0,
  });
  try {
    await archiveReimbursement(row.id!);
    message.success('归档成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: FinanceReimbursementApi.Reimbursement) {
  if (!row.processInstanceId) {
    message.warning('该报销单尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: FinanceReimbursementApi.Reimbursement[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getReimbursementPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<FinanceReimbursementApi.Reimbursement>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="报销单列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['报销单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['finance:reimbursement:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:reimbursement:export'],
              onClick: handleExport,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['finance:reimbursement:delete'],
              popConfirm: {
                title: `是否删除所选中数据?(仅草稿状态可删除)`,
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['finance:reimbursement:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['finance:reimbursement:update'],
              ifShow: () =>
                row.status === ReimbursementStatus.DRAFT ||
                row.status === ReimbursementStatus.REJECTED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['finance:reimbursement:update-status'],
              ifShow: () =>
                row.status === ReimbursementStatus.DRAFT ||
                row.status === ReimbursementStatus.REJECTED,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '归档',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['finance:reimbursement:archive'],
              ifShow: () => row.status === ReimbursementStatus.APPROVED,
              popConfirm: {
                title: `确认归档报销单 ${row.no} å—?归档后不可再修改`,
                confirm: handleArchive.bind(null, row),
              },
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['finance:reimbursement:query'],
              ifShow: () => !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:reimbursement:delete'],
              ifShow: () => row.status === ReimbursementStatus.DRAFT,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/finance/reimbursement/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,179 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import {
  createReimbursement,
  getReimbursement,
  updateReimbursement,
} from '#/api/finance/reimbursement';
import { toSubmitItems, useFormSchema } from '../data';
import ItemForm from './item-form.vue';
const emit = defineEmits(['success']);
const formData = ref<FinanceReimbursementApi.Reimbursement>({
  title: '',
  totalAmount: 0,
  applyDate: '',
  remark: '',
  items: [],
});
const formType = ref<FormType>('create'); // è¡¨å•类型:'create' | 'edit' | 'detail'
const itemFormRef = ref<InstanceType<typeof ItemForm>>();
/**
 * æ˜Žç»†è¡¨æ ¼çš„回显数据源:只在打开弹窗时赋值(编辑回显 / æ–°å¢žç½®ç©ºï¼‰ã€‚
 * ç”¨æˆ·åœ¨è¡¨æ ¼é‡Œçš„增删改只通过 emit å•向流到表单值,绝不回写到这里,
 * å¦åˆ™ prop å˜åŒ–会让表格反复重载(且与子组件 watcher å½¢æˆæ­»å¾ªçŽ¯ï¼‰ã€‚
 */
const initialItems = ref<FinanceReimbursementApi.ReimbursementItem[]>([]);
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['报销单']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['报销单']);
  } else {
    return '报销单详情';
  }
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema(formType.value),
  showDefaultActions: false,
});
/** åŒæ­¥æ˜Žç»†è¡Œåˆ°è¡¨å•值,保证随主表一次性提交 */
function handleUpdateItems(items: FinanceReimbursementApi.ReimbursementItem[]) {
  // ç¬¬äºŒä¸ªå‚数为 false:跳过合并算法,否则数组类型的值会被过滤掉
  formApi.setValues({ items }, false);
}
/** æ˜Žç»†é‡‘额变化时回填主表报销总金额(用户仍可手动修改) */
function handleUpdateTotalAmount(totalAmount: number) {
  formApi.setValues({ totalAmount }, false);
}
/** åˆ›å»ºæˆ–更新报销单 */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    const itemFormInstance = Array.isArray(itemFormRef.value)
      ? itemFormRef.value[0]
      : itemFormRef.value;
    try {
      itemFormInstance?.validate();
    } catch (error: any) {
      message.error(error.message || '报销明细验证失败');
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as FinanceReimbursementApi.Reimbursement;
    // å‘票附件为全量替换语义:清空全部发票时必须显式传空数组,
    // ä¼  null åŽç«¯ä¼šè§†ä¸ºã€Œä¸åŠ¨é™„ä»¶ã€ï¼Œå¯¼è‡´åˆ é™¤æ“ä½œä¸ç”Ÿæ•ˆ
    data.blobIds = data.blobIds ?? [];
    try {
      await (formType.value === 'create'
        ? createReimbursement(data)
        : updateReimbursement(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = {} as FinanceReimbursementApi.Reimbursement;
      initialItems.value = [];
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      initialItems.value = [];
      // æ–°å»ºæ—¶é»˜è®¤ç”³è¯·æ—¥æœŸä¸ºä»Šå¤©
      await formApi.setValues(
        { applyDate: dayjs().format('YYYY-MM-DD') },
        false,
      );
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getReimbursement(data.id);
      // æ˜Žç»†è¡¨æ ¼çš„行 key ç”¨æ•°æ®åº“ id,故回显数据源保留原 id
      initialItems.value = formData.value.items ?? [];
      // å·²æœ‰å‘票转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
      const blobIds = (formData.value.attachmentList ?? []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      await formApi.setValues(
        {
          ...formData.value,
          // è¡¨å•里的 items åªç”¨äºŽæäº¤ï¼Œå¿…须剥掉行 id(后端「先删后增」会与软删行主键冲突)
          items: toSubmitItems(formData.value.items),
          blobIds,
        },
        false,
      );
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3">
      <template #items>
        <ItemForm
          ref="itemFormRef"
          :items="initialItems"
          :disabled="formType === 'detail'"
          class="w-full"
          @update:items="handleUpdateItems"
          @update:total-amount="handleUpdateTotalAmount"
        />
      </template>
    </Form>
  </Modal>
</template>
src/views/finance/reimbursement/modules/item-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,223 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import type { DefaultOptionType } from 'ant-design-vue/es/select';
import { nextTick, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { erpPriceInputFormatter } from '@vben/utils';
import { Button, DatePicker, Input, InputNumber, Select } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { $t } from '#/locales';
import { toSubmitItems, useItemColumns } from '../data';
const props = withDefaults(
  defineProps<{
    items?: FinanceReimbursementApi.ReimbursementItem[];
    disabled?: boolean;
  }>(),
  {
    items: () => [],
    disabled: false,
  },
);
const emit = defineEmits(['update:items', 'update:totalAmount']);
const expenseTypeOptions = getDictOptions(
  DICT_TYPE.FINANCE_EXPENSE_TYPE,
  'number',
) as DefaultOptionType[];
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useItemColumns(),
    data: [],
    minHeight: 250,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<FinanceReimbursementApi.ReimbursementItem>,
});
/** è¾“入时不补零,失焦后仍按金额格式保留两位小数 */
const amountFormatter = (
  value: number | string | undefined,
  info: { input: string; userTyping: boolean },
) => {
  return info.userTyping ? info.input : erpPriceInputFormatter(value);
};
/** è¯»å–当前全部明细行(提交形态:不含行 id) */
function collectItems(): FinanceReimbursementApi.ReimbursementItem[] {
  const rows =
    (gridApi.grid.getData() as FinanceReimbursementApi.ReimbursementItem[]) ??
    [];
  const removed =
    (gridApi.grid.getRemoveRecords() as FinanceReimbursementApi.ReimbursementItem[]) ??
    [];
  const inserted =
    (gridApi.grid.getInsertRecords() as FinanceReimbursementApi.ReimbursementItem[]) ??
    [];
  const isRemoved = (row: FinanceReimbursementApi.ReimbursementItem) =>
    removed.some(
      (item) => item === row || (row.id != null && item.id === row.id),
    );
  return toSubmitItems([...rows.filter((row) => !isRemoved(row)), ...inserted]);
}
/** é‡æ–°æ±‡æ€»æ˜Žç»†å¹¶åŒæ­¥ç»™çˆ¶ç»„ä»¶ */
function recalc() {
  const rows = collectItems();
  const totalAmount = rows.reduce(
    (sum, row) => sum + (Number(row.invoiceAmount) || 0),
    0,
  );
  emit('update:items', rows);
  emit('update:totalAmount', Number(totalAmount.toFixed(2)));
}
/** æ·»åŠ æ˜Žç»†è¡Œ */
async function handleAdd() {
  await gridApi.grid.insertAt(
    {} as unknown as FinanceReimbursementApi.ReimbursementItem,
    -1,
  );
  recalc();
}
/** åˆ é™¤æ˜Žç»†è¡Œ */
async function handleDelete(row: FinanceReimbursementApi.ReimbursementItem) {
  await gridApi.grid.remove(row);
  recalc();
}
/** æ ¡éªŒæ˜Žç»†ï¼šè‡³å°‘ 1 è¡Œï¼Œä¸”每行费用类型与发票金额必填 */
function validate() {
  const rows = collectItems();
  if (rows.length === 0) {
    throw new Error('请至少添加一条报销明细');
  }
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i];
    if (row?.expenseType === undefined || row?.expenseType === null) {
      throw new Error(`第 ${i + 1} è¡Œï¼šè¯·é€‰æ‹©è´¹ç”¨ç±»åž‹`);
    }
    if (!row?.invoiceAmount || Number(row.invoiceAmount) <= 0) {
      throw new Error(`第 ${i + 1} è¡Œï¼šå‘票金额必须大于 0`);
    }
  }
}
defineExpose({ validate });
/**
 * å›žæ˜¾æ˜Žç»†ï¼ˆå•向:父 â†’ è¡¨æ ¼ï¼‰ã€‚
 * æ­¤ watcher å†…**禁止**回传 emit('update:items'):父组件会把新数组写回 :items,
 * äºŽæ˜¯æœ¬ watcher è¢«å†æ¬¡è§¦å‘,构成 prop â†” emit æ­»å¾ªçŽ¯ï¼Œæ–°å¢ž/编辑时页面会直接卡死。
 * æ€»é‡‘额同理,回显时不回填,避免覆盖用户手工调整过的报销总金额。
 */
watch(
  () => props.items,
  async (items) => {
    if (!items) {
      return;
    }
    await nextTick();
    await gridApi.grid.loadData([...items]);
  },
  { immediate: true },
);
</script>
<template>
  <Grid class="w-full">
    <template #expenseType="{ row }">
      <Select
        v-model:value="row.expenseType"
        :disabled="disabled"
        :options="expenseTypeOptions"
        placeholder="请选择费用类型"
        allow-clear
        @change="() => recalc()"
      />
    </template>
    <template #invoiceNo="{ row }">
      <Input
        v-model:value="row.invoiceNo"
        :disabled="disabled"
        placeholder="请输入发票号码"
        @change="() => recalc()"
      />
    </template>
    <template #invoiceAmount="{ row }">
      <InputNumber
        v-model:value="row.invoiceAmount"
        :disabled="disabled"
        :precision="2"
        :formatter="amountFormatter"
        class="!w-full"
        placeholder="请输入发票金额"
        @change="() => recalc()"
      />
    </template>
    <template #invoiceDate="{ row }">
      <DatePicker
        v-model:value="row.invoiceDate"
        :disabled="disabled"
        value-format="YYYY-MM-DD"
        placeholder="请选择发票日期"
        class="w-full"
        @change="() => recalc()"
      />
    </template>
    <template #drawer="{ row }">
      <Input
        v-model:value="row.drawer"
        :disabled="disabled"
        placeholder="请输入开票方"
        @change="() => recalc()"
      />
    </template>
    <template #description="{ row }">
      <Input
        v-model:value="row.description"
        :disabled="disabled"
        placeholder="请输入费用说明"
        @change="() => recalc()"
      />
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.delete'),
            danger: true,
            type: 'link',
            icon: ACTION_ICON.DELETE,
            disabled,
            onClick: handleDelete.bind(null, row),
          },
        ]"
      />
    </template>
  </Grid>
  <div v-if="!disabled" class="-mt-4 flex justify-center">
    <Button type="primary" ghost @click="handleAdd"> æ·»åŠ æŠ¥é”€æ˜Žç»† </Button>
  </div>
</template>
src/views/finance/reimbursement/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitReimbursement } from '#/api/finance/reimbursement';
const emit = defineEmits(['success']);
const reimbursementId = ref<number>();
const processList = ref<FinanceReimbursementApi.ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
/** æµç¨‹ key åªèƒ½æ¥è‡ªåŽç«¯å®žæ—¶åˆ—表,禁止前端写死 */
const processOptions = computed(() =>
  processList.value.map((item) => ({ label: item.name, value: item.key })),
);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await submitReimbursement(
        reimbursementId.value!,
        selectedProcessKey.value,
      );
      await modalApi.close();
      emit('success');
      message.success('提交成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      reimbursementId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data =
      modalApi.getData<{
        id: number;
        processList: FinanceReimbursementApi.ProcessDefinition[];
      }>();
    reimbursementId.value = data.id;
    processList.value = data.processList || [];
    // é»˜è®¤é€‰æ‹©ç¬¬ä¸€ä¸ªæµç¨‹
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0]!.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 200px"
        placeholder="请选择审批流程"
      />
    </div>
  </Modal>
</template>
src/views/research/application/components/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
export { default as ResearchMemberSelect } from './member-select.vue';
src/views/research/application/components/member-select-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { UmsMemberApi } from '#/api/ums/member';
import { nextTick, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMemberPage } from '#/api/ums/member';
defineOptions({ name: 'ResearchMemberSelectDialog' });
const emit = defineEmits<{
  selected: [rows: UmsMemberApi.Member[]];
}>();
const open = ref(false);
const selectedRows = ref<UmsMemberApi.Member[]>([]);
const preSelectedId = ref<number>();
function getTableRows() {
  return gridApi.grid.getTableData().fullData as UmsMemberApi.Member[];
}
async function applyPreSelection() {
  if (!preSelectedId.value) {
    return;
  }
  const row = getTableRows().find((item) => item.id === preSelectedId.value);
  if (!row) {
    return;
  }
  await gridApi.grid.setRadioRow(row);
  selectedRows.value = [row];
}
async function handleCellDblclick({ row }: { row: UmsMemberApi.Member }) {
  selectedRows.value = [row];
  await gridApi.grid.setRadioRow(row);
  handleConfirm();
}
const gridColumns = [
  { field: 'no', title: '会员编号', width: 180 },
  {
    field: 'name',
    title: '姓名 / å•位名称',
    minWidth: 180,
  },
  {
    field: 'memberType',
    title: '会员类型',
    width: 110,
    cellRender: {
      name: 'CellDict',
      props: { type: DICT_TYPE.UMS_MEMBER_TYPE },
    },
  },
  { field: 'contactUser', title: '联系人', width: 110 },
  { field: 'contactPhone', title: '联系电话', width: 140 },
  { field: 'workUnit', title: '工作单位', minWidth: 180 },
  { field: 'title', title: '职称', width: 110 },
];
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'name',
        label: '姓名 / å•位名称',
        component: 'Input',
        componentProps: {
          allowClear: true,
          placeholder: '请输入姓名或单位名称',
        },
      },
      {
        fieldName: 'contactPhone',
        label: '联系电话',
        component: 'Input',
        componentProps: {
          allowClear: true,
          placeholder: '请输入联系电话',
        },
      },
    ],
  },
  gridOptions: {
    columns: [{ type: 'radio', width: 50 }, ...gridColumns],
    height: 440,
    keepSource: true,
    radioConfig: {
      highlight: true,
      trigger: 'row',
    },
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getMemberPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<UmsMemberApi.Member>,
  gridEvents: {
    cellDblclick: handleCellDblclick,
    radioChange: ({ row }: { row: UmsMemberApi.Member }) => {
      selectedRows.value = [row];
    },
  },
});
async function resetQueryState() {
  selectedRows.value = [];
  await gridApi.grid.clearRadioRow();
  await gridApi.formApi.resetForm();
}
async function openModal(selectedId?: number) {
  open.value = true;
  preSelectedId.value = selectedId;
  await nextTick();
  await resetQueryState();
  await gridApi.query();
  await nextTick();
  await applyPreSelection();
}
async function closeModal() {
  open.value = false;
  await resetQueryState();
}
function handleConfirm() {
  const row = selectedRows.value[0];
  if (!row) {
    message.warning('请选择一位会员作为申报人');
    return;
  }
  emit('selected', [row]);
  open.value = false;
}
defineExpose({ open: openModal });
</script>
<template>
  <Modal
    v-model:open="open"
    title="选择申报人(会员)"
    width="72%"
    :destroy-on-close="true"
    @ok="handleConfirm"
    @cancel="closeModal"
  >
    <Grid table-title="会员列表" />
  </Modal>
</template>
src/views/research/application/components/member-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,132 @@
<script lang="ts" setup>
import type { UmsMemberApi } from '#/api/ums/member';
import { computed, ref, useAttrs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, Tooltip } from 'ant-design-vue';
import { getMember } from '#/api/ums/member';
import MemberSelectDialog from './member-select-dialog.vue';
defineOptions({ name: 'ResearchMemberSelect', inheritAttrs: false });
const props = withDefaults(
  defineProps<{
    allowClear?: boolean;
    disabled?: boolean;
    modelValue?: number;
    placeholder?: string;
  }>(),
  {
    allowClear: true,
    disabled: false,
    modelValue: undefined,
    placeholder: '请选择申报人(会员)',
  },
);
const emit = defineEmits<{
  change: [item: UmsMemberApi.Member | undefined];
  'update:modelValue': [value: number | undefined];
}>();
const attrs = useAttrs();
const dialogRef = ref<InstanceType<typeof MemberSelectDialog>>();
const hovering = ref(false);
const selectedItem = ref<UmsMemberApi.Member>();
const displayLabel = computed(() => selectedItem.value?.name ?? '');
const showClear = computed(
  () =>
    props.allowClear &&
    !props.disabled &&
    hovering.value &&
    props.modelValue !== null,
);
async function resolveMemberById(id: number | undefined) {
  if (!id) {
    selectedItem.value = undefined;
    return;
  }
  if (selectedItem.value?.id === id) {
    return;
  }
  selectedItem.value = await getMember(id);
}
watch(
  () => props.modelValue,
  (value) => {
    resolveMemberById(value);
  },
  { immediate: true },
);
function clearSelected() {
  selectedItem.value = undefined;
  emit('update:modelValue', undefined);
  emit('change', undefined);
}
function handleClick(event: MouseEvent) {
  if (props.disabled) {
    return;
  }
  const target = event.target as HTMLElement;
  if (showClear.value && target.closest('.ant-input-suffix')) {
    event.stopPropagation();
    clearSelected();
    return;
  }
  dialogRef.value?.open(props.modelValue ?? undefined);
}
function handleSelected(rows: UmsMemberApi.Member[]) {
  const item = rows[0];
  if (!item) {
    return;
  }
  selectedItem.value = item;
  emit('update:modelValue', item.id);
  emit('change', item);
}
</script>
<template>
  <div
    v-bind="attrs"
    class="w-full"
    :class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
    @click="handleClick"
    @mouseenter="hovering = true"
    @mouseleave="hovering = false"
  >
    <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
      <template #title>
        <div v-if="selectedItem" class="leading-6">
          <div>会员编号:{{ selectedItem.no || '-' }}</div>
          <div>联系电话:{{ selectedItem.contactPhone || '-' }}</div>
          <div>工作单位:{{ selectedItem.workUnit || '-' }}</div>
          <div>职称:{{ selectedItem.title || '-' }}</div>
        </div>
      </template>
      <Input
        :disabled="disabled"
        :placeholder="placeholder"
        :value="displayLabel"
        readonly
      >
        <template #suffix>
          <IconifyIcon
            class="size-4"
            :icon="showClear ? 'lucide:circle-x' : 'lucide:search'"
          />
        </template>
      </Input>
    </Tooltip>
  </div>
  <MemberSelectDialog ref="dialogRef" @selected="handleSelected" />
</template>
src/views/research/application/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,548 @@
import type { DefaultOptionType } from 'ant-design-vue/es/select';
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchGuidelineApi } from '#/api/research/guideline';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { erpPriceInputFormatter } from '@vben/utils';
import { markRaw } from 'vue';
import { getRangePickerDefaultProps } from '#/utils';
import { ResearchMemberSelect } from './components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** è¯¾é¢˜ç”³æŠ¥ä¹¦çŠ¶æ€ï¼ˆå¯¹åº”å­—å…¸ research_application_status) */
export const ApplicationStatus = {
  DRAFT: 0, // è‰ç¨¿
  APPROVING: 15, // å®¡æ‰¹ä¸­
  APPROVED: 20, // å·²ç«‹é¡¹
  REJECTED: 35, // å·²é©³å›ž
} as const;
/** ç»è´¹æ‹¨ä»˜çŠ¶æ€ï¼ˆå¯¹åº”å­—å…¸ research_fund_status) */
export const FundStatus = {
  PENDING: 0, // å¾…拨付
  PAID: 20, // å·²æ‹¨ä»˜
} as const;
/** è¯¾é¢˜ç”³æŠ¥ä¹¦è¡¨å•配置 */
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
  guidelines: ResearchGuidelineApi.Guideline[] = [],
): VbenFormSchema[] {
  const guidelineOptions: DefaultOptionType[] = guidelines.map((item) => ({
    label: item.no ? `${item.no} ${item.title}` : item.title,
    value: item.id,
  }));
  const readonly = formType === 'detail';
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '申报编号',
      component: 'Input',
      componentProps: {
        placeholder: '申报编号由系统自动生成',
        disabled: true,
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_APPLICATION_STATUS, 'number'),
        disabled: true,
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
      },
    },
    {
      fieldName: 'guidelineId',
      label: '所属课题指南',
      component: 'Select',
      componentProps: {
        options: guidelineOptions,
        placeholder: '请选择课题指南',
        allowClear: true,
        showSearch: true,
        optionFilterProp: 'label',
        disabled: readonly,
        class: '!w-full',
        onChange: (value: number) => {
          const guideline = guidelines.find((item) => item.id === value);
          formApi?.setFieldValue('category', guideline?.category);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'category',
      label: '课题类别',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY, 'number'),
        disabled: true,
        placeholder: '由所选课题指南自动带入',
      },
      help: '由所选课题指南自动带入,无需填写',
    },
    {
      fieldName: 'title',
      label: '课题名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入课题名称',
        maxlength: 255,
        disabled: readonly,
      },
      rules: 'required',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'memberId',
      label: '申报人(会员)',
      component: markRaw(ResearchMemberSelect),
      componentProps: {
        placeholder: '请选择申报人,选择后自动带入单位、职称与联系方式',
        disabled: readonly,
        onChange: (member: Record<string, unknown> | undefined) => {
          if (!formApi) {
            return;
          }
          formApi.setFieldValue('applicantName', member?.name ?? '');
          formApi.setFieldValue('workUnit', member?.workUnit ?? '');
          formApi.setFieldValue('jobTitle', member?.title ?? '');
          formApi.setFieldValue('contactPhone', member?.contactPhone ?? '');
          formApi.setFieldValue('contactEmail', member?.contactEmail ?? '');
        },
      },
      help: '选择会员后自动带入姓名/单位/职称/联系电话/邮箱,仍可手工调整',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'applicantName',
      label: '申报人姓名/单位名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入申报人姓名或单位名称',
        maxlength: 64,
        disabled: readonly,
      },
      rules: 'required',
    },
    {
      fieldName: 'workUnit',
      label: '工作单位',
      component: 'Input',
      componentProps: {
        placeholder: '请输入工作单位',
        maxlength: 128,
        disabled: readonly,
      },
    },
    {
      fieldName: 'jobTitle',
      label: '职称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入职称',
        maxlength: 64,
        disabled: readonly,
      },
    },
    {
      fieldName: 'contactPhone',
      label: '联系电话',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系电话',
        maxlength: 20,
        disabled: readonly,
      },
    },
    {
      fieldName: 'contactEmail',
      label: '联系邮箱',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系邮箱',
        maxlength: 128,
        disabled: readonly,
      },
    },
    {
      fieldName: 'budgetAmount',
      label: '申请经费(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        placeholder: '请输入申请经费',
        precision: 2,
        min: 0,
        formatter: erpPriceInputFormatter,
        disabled: readonly,
      },
    },
    {
      fieldName: 'teamMembers',
      label: '课题组成员',
      component: 'Textarea',
      componentProps: {
        placeholder: '每行一条,格式:姓名|单位|职称\n例如:李四|XX大学|副教授',
        autoSize: { minRows: 3, maxRows: 6 },
        disabled: readonly,
      },
      help: '每行一条,用竖线分隔:姓名|单位|职称',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'planContent',
      label: '申报书正文',
      component: 'Textarea',
      componentProps: {
        placeholder:
          '请填写研究背景与意义、研究目标、研究方案与技术路线、预期成果与效益等内容',
        autoSize: { minRows: 8, maxRows: 16 },
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '申报书附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: !readonly,
        disabled: readonly,
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '申报编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入申报编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'title',
      label: '课题名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入课题名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'category',
      label: '课题类别',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY, 'number'),
        placeholder: '请选择课题类别',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_APPLICATION_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
    {
      fieldName: 'createTime',
      label: '创建时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列配置 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      type: 'checkbox',
      width: 50,
      fixed: 'left',
    },
    {
      field: 'no',
      title: '申报编号',
      width: 180,
      fixed: 'left',
    },
    {
      field: 'title',
      title: '课题名称',
      minWidth: 220,
    },
    {
      field: 'guidelineTitle',
      title: '所属课题指南',
      minWidth: 180,
    },
    {
      field: 'category',
      title: '课题类别',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY },
      },
    },
    {
      field: 'applicantName',
      title: '申报人',
      minWidth: 120,
    },
    {
      field: 'workUnit',
      title: '工作单位',
      minWidth: 160,
    },
    {
      field: 'budgetAmount',
      title: '申请经费(元)',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'reviewAvgScore',
      title: '评审均分',
      minWidth: 100,
      formatter: ({ cellValue }) =>
        cellValue === null || cellValue === undefined
          ? '-'
          : Number(cellValue).toFixed(2),
    },
    {
      field: 'reviewPass',
      title: '评审结论',
      minWidth: 110,
      slots: { default: 'reviewPass' },
    },
    {
      field: 'researchNo',
      title: '立项编号',
      width: 180,
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 95,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_APPLICATION_STATUS },
      },
    },
    {
      field: 'hasAttachment',
      title: '附件',
      minWidth: 80,
      slots: { default: 'hasAttachment' },
    },
    {
      field: 'creatorName',
      title: '创建人',
      minWidth: 100,
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 330,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
/** æ•°å€¼ç©ºå€¼å ä½ï¼ˆVXE åˆ— formatter) */
function dashIfEmpty({ cellValue }: { cellValue: unknown }) {
  return cellValue === null || cellValue === undefined ? '-' : String(cellValue);
}
/** ä¸“家评审明细列(评审面板 / å®¡æ‰¹è¯¦æƒ…共用) */
export function useReviewColumns(
  showActions = false,
): VxeTableGridOptions['columns'] {
  const columns: VxeTableGridOptions['columns'] = [
    { type: 'seq', title: '序号', width: 55, fixed: 'left' },
    { field: 'evaluatorName', title: '专家姓名', minWidth: 100 },
    { field: 'evaluatorUnit', title: '专家单位', minWidth: 160 },
    { field: 'evaluatorTitle', title: '专家职称', minWidth: 100 },
    {
      field: 'topicScore',
      title: '选题价值(0.3)',
      minWidth: 120,
      formatter: dashIfEmpty,
    },
    {
      field: 'planScore',
      title: '方案可行性(0.3)',
      minWidth: 130,
      formatter: dashIfEmpty,
    },
    {
      field: 'teamScore',
      title: '团队基础(0.2)',
      minWidth: 120,
      formatter: dashIfEmpty,
    },
    {
      field: 'resultScore',
      title: '预期成果(0.2)',
      minWidth: 120,
      formatter: dashIfEmpty,
    },
    {
      field: 'compositeScore',
      title: '加权合计',
      minWidth: 110,
      formatter: dashIfEmpty,
    },
    {
      field: 'ranking',
      title: '排名',
      width: 70,
      formatter: dashIfEmpty,
    },
    {
      field: 'conclusion',
      title: '评审结论',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_REVIEW_CONCLUSION },
      },
    },
    { field: 'opinion', title: '评审意见', minWidth: 200 },
    { field: 'creatorName', title: '录入人', minWidth: 100 },
    {
      field: 'createTime',
      title: '录入时间',
      width: 165,
      formatter: 'formatDateTime',
    },
  ];
  if (showActions) {
    columns.push({
      title: '操作',
      width: 130,
      fixed: 'right',
      slots: { default: 'actions' },
    });
  }
  return columns;
}
/** ç»è´¹æ‹¨ä»˜è®°å½•列(拨付面板 / å®¡æ‰¹è¯¦æƒ…共用) */
export function useFundColumns(
  showActions = false,
): VxeTableGridOptions['columns'] {
  const columns: VxeTableGridOptions['columns'] = [
    { type: 'seq', title: '序号', width: 55 },
    { field: 'batchName', title: '拨付批次', minWidth: 130 },
    {
      field: 'amount',
      title: '拨付金额(元)',
      minWidth: 130,
      formatter: 'formatAmount2',
    },
    {
      field: 'payDate',
      title: '拨付日期',
      width: 120,
      formatter: dashIfEmpty,
    },
    { field: 'payee', title: '收款单位/账户名', minWidth: 180 },
    {
      field: 'status',
      title: '拨付状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_FUND_STATUS },
      },
    },
    {
      field: 'hasAttachment',
      title: '拨付凭证',
      minWidth: 100,
      slots: { default: 'hasAttachment' },
    },
    { field: 'creatorName', title: '登记人', minWidth: 100 },
    {
      field: 'createTime',
      title: '登记时间',
      width: 165,
      formatter: 'formatDateTime',
    },
  ];
  if (showActions) {
    columns.push({
      title: '操作',
      width: 130,
      fixed: 'right',
      slots: { default: 'actions' },
    });
  }
  return columns;
}
src/views/research/application/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,279 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchApplicationApi } from '#/api/research/application';
import type { ResearchFundRecordApi } from '#/api/research/fund';
import type { DescriptionItemSchema } from '#/components/description';
import { h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { Spin, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getApplication, getTopicReviewList } from '#/api/research/application';
import { getFundRecordList } from '#/api/research/fund';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
import {
  FundStatus,
  useFundColumns,
  useReviewColumns,
} from '../../data';
defineOptions({ name: 'ResearchApplicationApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<ResearchApplicationApi.Application>();
const reviews = ref<ResearchApplicationApi.TopicReview[]>([]);
const fundRecords = ref<ResearchFundRecordApi.FundRecord[]>([]);
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** é‡‘额字段空值占位 */
function amountRender(val: string | number | undefined) {
  return val === null || val === undefined || val === ''
    ? '-'
    : (erpPriceInputFormatter(val) as string);
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '申报编号', field: 'no', render: textRender },
    { label: '申报人姓名/单位名称', field: 'applicantName', render: textRender },
    { label: '课题名称', field: 'title', render: textRender },
    { label: '所属课题指南', field: 'guidelineTitle', render: textRender },
    {
      label: '课题类别',
      field: 'category',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY,
          value: val,
        }),
    },
    {
      label: '状态',
      field: 'status',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.RESEARCH_APPLICATION_STATUS,
          value: val,
        }),
    },
    { label: '工作单位', field: 'workUnit', render: textRender },
    { label: '职称', field: 'jobTitle', render: textRender },
    { label: '联系电话', field: 'contactPhone', render: textRender },
    { label: '联系邮箱', field: 'contactEmail', render: textRender },
    {
      label: '申请经费(元)',
      field: 'budgetAmount',
      render: amountRender,
    },
    {
      label: '评审加权均分',
      field: 'reviewAvgScore',
      render: (val) =>
        val === null || val === undefined
          ? '未汇总'
          : Number(val).toFixed(2),
    },
    {
      label: '评审结论',
      field: 'reviewPass',
      render: (val) =>
        val === null || val === undefined
          ? '未汇总'
          : h(
              Tag,
              { color: val === 1 ? 'success' : 'error' },
              () => (val === 1 ? '推荐立项' : '不推荐立项'),
            ),
    },
    { label: '立项编号', field: 'researchNo', render: textRender },
    {
      label: '立项时间',
      field: 'approveTime',
      render: (val) => (val ? (formatDateTime(val) as string) : '-'),
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => (val ? (formatDateTime(val) as string) : '-'),
    },
    { label: '备注', field: 'remark', render: textRender },
  ] as DescriptionItemSchema[],
});
const [ReviewGrid, reviewGridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useReviewColumns(false),
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<ResearchApplicationApi.TopicReview>,
});
const [FundGrid, fundGridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useFundColumns(false),
    data: [],
    minHeight: 200,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<ResearchFundRecordApi.FundRecord>,
});
onMounted(async () => {
  try {
    loading.value = true;
    const applicationId = Number(props.id);
    const [application, reviewList, recordList] = await Promise.all([
      getApplication(applicationId),
      getTopicReviewList(applicationId),
      getFundRecordList(applicationId),
    ]);
    formData.value = application;
    reviews.value = reviewList;
    fundRecords.value = recordList;
    reviewGridApi.setGridOptions({ data: reviewList });
    fundGridApi.setGridOptions({ data: recordList });
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- ç”³æŠ¥ä¹¦åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">课题申报书基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- è¯¾é¢˜ç»„成员 -->
        <div
          v-if="formData?.teamMembers"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">课题组成员</span>
          </div>
          <pre class="whitespace-pre-wrap break-all font-sans">{{
            formData.teamMembers
          }}</pre>
        </div>
        <!-- ç”³æŠ¥ä¹¦æ­£æ–‡ -->
        <div
          v-if="formData?.planContent"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">申报书正文</span>
          </div>
          <pre class="whitespace-pre-wrap break-all font-sans">{{
            formData.planContent
          }}</pre>
        </div>
        <!-- ä¸“家评审明细 -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">专家评审明细</span>
            <span v-if="reviews.length" class="text-gray-500">
              ï¼ˆå…± {{ reviews.length }} ä½ä¸“家)
            </span>
          </div>
          <ReviewGrid v-if="reviews.length" />
          <p v-else class="text-gray-500">暂无专家评审记录</p>
        </div>
        <!-- ç»è´¹æ‹¨ä»˜è®°å½• -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">经费拨付记录</span>
          </div>
          <FundGrid v-if="fundRecords.length">
            <template #hasAttachment="{ row }">
              <Tag :color="row.hasAttachment ? 'success' : 'warning'">
                {{
                  row.hasAttachment
                    ? '已上传'
                    : row.status === FundStatus.PAID
                      ? '缺失'
                      : '不需要'
                }}
              </Tag>
            </template>
          </FundGrid>
          <p v-else class="text-gray-500">暂无经费拨付记录</p>
        </div>
        <!-- ç”³æŠ¥ä¹¦é™„ä»¶ -->
        <div
          v-if="formData?.attachmentList?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">申报书附件</span>
          </div>
          <div class="flex flex-wrap gap-3">
            <a
              v-for="file in formData.attachmentList"
              :key="file.id"
              :href="file.previewURL || file.url"
              target="_blank"
              class="text-blue-500 hover:underline"
            >
              {{ file.name || `附件-${file.id}` }}
            </a>
          </div>
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/research/application/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,311 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchApplicationApi } from '#/api/research/application';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteApplication,
  getApplicationApproveProcessList,
  getApplicationPage,
  publishApplicationNotice,
} from '#/api/research/application';
import { $t } from '#/locales';
import {
  ApplicationStatus,
  useGridColumns,
  useGridFormSchema,
} from './data';
import FundPanel from './modules/fund-panel.vue';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
import ReviewPanel from './modules/review-panel.vue';
/** è¯¾é¢˜ç”³æŠ¥ä¹¦åˆ—表 */
defineOptions({ name: 'ResearchApplication' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
const [ReviewModal, reviewModalApi] = useVbenModal({
  connectedComponent: ReviewPanel,
  destroyOnClose: true,
});
const [FundModal, fundModalApi] = useVbenModal({
  connectedComponent: FundPanel,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žç”³æŠ¥ä¹¦ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑申报书(仅草稿、已驳回可编辑) */
function handleEdit(row: ResearchApplicationApi.Application) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹ç”³æŠ¥ä¹¦è¯¦æƒ… */
function handleDetail(row: ResearchApplicationApi.Application) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤ç”³æŠ¥ä¹¦ï¼ˆä»…草稿可删) */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteApplication(id)));
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ï¼Œæµç¨‹ key ä¸å†™æ­» */
async function handleSubmit(row: ResearchApplicationApi.Application) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getApplicationApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning(
        '暂无可用审批流程,请先到 BPM æµç¨‹è®¾è®¡å™¨å‘布课题立项审批流程',
      );
      return;
    }
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
/** ä¸“家评审面板 */
function handleReview(row: ResearchApplicationApi.Application) {
  reviewModalApi.setData({ id: row.id }).open();
}
/** ç»è´¹æ‹¨ä»˜é¢æ¿ */
function handleFund(row: ResearchApplicationApi.Application) {
  fundModalApi.setData({ id: row.id }).open();
}
/** å‘布立项通知 */
async function handlePublishNotice(row: ResearchApplicationApi.Application) {
  const hideLoading = message.loading({ content: '发布中...', duration: 0 });
  try {
    await publishApplicationNotice(row.id!);
    message.success('立项通知已发布到系统公告,可在公告模块查看');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¿›åº¦ */
function handleProcessDetail(row: ResearchApplicationApi.Application) {
  if (!row.processInstanceId) {
    message.warning('该申报书尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: ResearchApplicationApi.Application[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getApplicationPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ResearchApplicationApi.Application>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <ReviewModal @success="handleRefresh" />
    <FundModal @success="handleRefresh" />
    <Grid table-title="课题申报书列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['课题申报书']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['research:application:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['research:application:delete'],
              popConfirm: {
                title: '是否删除所选中数据?(仅草稿状态可删除)',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #reviewPass="{ row }">
        <Tag v-if="row.reviewPass === 1" color="success">推荐立项</Tag>
        <Tag v-else-if="row.reviewPass === 0" color="error">不推荐立项</Tag>
        <span v-else>-</span>
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:application:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['research:application:update'],
              ifShow: () =>
                row.status === ApplicationStatus.DRAFT ||
                row.status === ApplicationStatus.REJECTED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['research:application:submit'],
              ifShow: () =>
                row.status === ApplicationStatus.DRAFT ||
                row.status === ApplicationStatus.REJECTED,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '专家评审',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:review:query'],
              onClick: handleReview.bind(null, row),
            },
            {
              label: '经费拨付',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:fund:query'],
              onClick: handleFund.bind(null, row),
            },
            {
              label: '发布立项通知',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:application:notice'],
              ifShow: () =>
                row.status === ApplicationStatus.APPROVED && !row.noticeId,
              onClick: handlePublishNotice.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:application:query'],
              ifShow: () => !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['research:application:delete'],
              ifShow: () => row.status === ApplicationStatus.DRAFT,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/research/application/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { ResearchApplicationApi } from '#/api/research/application';
import type { ResearchGuidelineApi } from '#/api/research/guideline';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createApplication,
  getApplication,
  updateApplication,
} from '#/api/research/application';
import {
  getGuideline,
  getPublishedGuidelineList,
} from '#/api/research/guideline';
import { useFormSchema } from '../data';
defineOptions({ name: 'ResearchApplicationForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
/** ä¾›ã€Œæ‰€å±žè¯¾é¢˜æŒ‡å—」下拉与类别带出使用 */
const guidelines = ref<ResearchGuidelineApi.Guideline[]>([]);
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['课题申报书']);
  }
  if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['课题申报书']);
  }
  return '课题申报书详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 130,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [],
  showDefaultActions: false,
});
/** åªæäº¤åŽç«¯ SaveReqVO æŽ¥å—的字段,避免回传 category/reviewAvgScore ç­‰æ´¾ç”Ÿå­—段 */
async function buildSubmitData() {
  const values = (await formApi.getValues()) as ResearchApplicationApi.Application;
  return {
    id: values.id,
    guidelineId: values.guidelineId,
    title: values.title,
    memberId: values.memberId,
    applicantName: values.applicantName,
    workUnit: values.workUnit,
    jobTitle: values.jobTitle,
    contactPhone: values.contactPhone,
    contactEmail: values.contactEmail,
    teamMembers: values.teamMembers,
    budgetAmount: values.budgetAmount,
    planContent: values.planContent,
    remark: values.remark,
    // é™„件为全量替换语义:清空全部附件时必须显式传空数组,
    // ä¼  null åŽç«¯ä¼šè§†ä¸ºã€Œä¸åŠ¨é™„ä»¶ã€ï¼Œå¯¼è‡´åˆ é™¤æ“ä½œä¸ç”Ÿæ•ˆ
    blobIds: values.blobIds ?? [],
  } as ResearchApplicationApi.Application;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = await buildSubmitData();
    try {
      await (formType.value === 'create'
        ? createApplication(data)
        : updateApplication(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      guidelines.value = [];
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    modalApi.lock();
    try {
      if (data?.id) {
        const application = await getApplication(data.id);
        guidelines.value = await resolveGuidelineOptions(application);
        formApi.setState({
          schema: useFormSchema(formType.value, formApi, guidelines.value),
        });
        formApi.setDisabled(formType.value === 'detail');
        // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
        const blobIds = (application.attachmentList ?? []).map((item) => ({
          uid: String(item.id),
          name: item.name || '',
          url: item.url || '',
          status: 'done',
          id: item.id,
        }));
        await formApi.setValues({ ...application, blobIds }, false);
      } else {
        guidelines.value = await getPublishedGuidelineList();
        formApi.setState({
          schema: useFormSchema(formType.value, formApi, guidelines.value),
        });
        formApi.setDisabled(false);
        await formApi.resetForm();
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/**
 * ç¼–辑/详情时指南可能已截止(不在「已发布」列表里),
 * éœ€è¦å•独取回当前所属指南补进选项,否则下拉显示为空。
 */
async function resolveGuidelineOptions(
  application: ResearchApplicationApi.Application,
): Promise<ResearchGuidelineApi.Guideline[]> {
  const published = await getPublishedGuidelineList();
  const currentId = application.guidelineId;
  if (!currentId || published.some((item) => item.id === currentId)) {
    return published;
  }
  const current = await getGuideline(currentId);
  return [...published, current];
}
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/research/application/modules/fund-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,233 @@
<script lang="ts" setup>
import type { ResearchFundRecordApi } from '#/api/research/fund';
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createFundRecord, updateFundRecord } from '#/api/research/fund';
import { FundStatus } from '../data';
defineOptions({ name: 'ResearchFundRecordForm' });
const emit = defineEmits(['success']);
const isEdit = ref(false);
/** ç”³è¯·ç»è´¹å‡åŽ»å·²ç™»è®°æ‹¨ä»˜é‡‘é¢åŽçš„å¯ç”¨ä½™é¢ */
const remainder = ref<number>();
const alertMessage = computed(() => {
  if (remainder.value === undefined) {
    return '';
  }
  return `本课题申请经费可用余额:${remainder.value.toFixed(2)} å…ƒï¼Œæ‹¨æ¬¾é‡‘额累计不得超过申请经费`;
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 130,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'applicationId',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'batchName',
      label: '拨付批次',
      component: 'Input',
      componentProps: {
        placeholder: '如:第一期',
        maxlength: 64,
      },
    },
    {
      fieldName: 'amount',
      label: '拨付金额(元)',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        placeholder: '请输入本次拨付金额',
        precision: 2,
        min: 0,
      },
      rules: 'required',
    },
    {
      fieldName: 'payDate',
      label: '拨付日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择拨付日期',
        // åŽç«¯ä¸º LocalDate,只能传 yyyy-MM-dd
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'payee',
      label: '收款单位/账户名',
      component: 'Input',
      componentProps: {
        placeholder: '请输入收款单位或账户名',
        maxlength: 128,
      },
    },
    {
      fieldName: 'status',
      label: '拨付状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_FUND_STATUS, 'number'),
        placeholder: '请选择拨付状态',
      },
      rules: 'selectRequired',
      help: '选择「已拨付」时必须上传拨付凭证附件',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '拨付凭证',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: true,
      },
      formItemClass: 'col-span-2',
    },
  ],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    const values =
      (await formApi.getValues()) as ResearchFundRecordApi.FundRecord;
    // ã€Œå·²æ‹¨ä»˜ã€å¿…须有凭证,先在前端拦一道,避免用户白等一次请求
    if (
      values.status === FundStatus.PAID &&
      (!values.blobIds || values.blobIds.length === 0)
    ) {
      message.warning('拨付状态为「已拨付」时,请先上传拨付凭证附件');
      return;
    }
    modalApi.lock();
    // é™„件为全量替换语义:清空全部凭证时必须显式传空数组
    const data = {
      id: values.id,
      applicationId: values.applicationId,
      batchName: values.batchName,
      amount: values.amount,
      payDate: values.payDate,
      payee: values.payee,
      status: values.status,
      remark: values.remark,
      blobIds: values.blobIds ?? [],
    } as ResearchFundRecordApi.FundRecord;
    try {
      await (data.id ? updateFundRecord(data) : createFundRecord(data));
      await modalApi.close();
      emit('success');
      message.success('保存成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      isEdit.value = false;
      remainder.value = undefined;
      return;
    }
    const data = modalApi.getData<{
      applicationId: number;
      remainder?: number;
      record?: ResearchFundRecordApi.FundRecord;
    }>();
    isEdit.value = !!data.record;
    remainder.value = data.remainder;
    await formApi.resetForm();
    if (data.record) {
      const blobIds = (data.record.attachmentList ?? []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      await formApi.setValues(
        {
          ...data.record,
          applicationId: data.applicationId,
          blobIds,
        },
        false,
      );
    } else {
      await formApi.setValues(
        {
          applicationId: data.applicationId,
          status: FundStatus.PENDING,
        },
        false,
      );
    }
  },
});
</script>
<template>
  <Modal
    :title="isEdit ? '编辑拨款记录' : '登记拨款记录'"
    class="w-1/2"
    :close-on-click-modal="false"
  >
    <Alert
      v-if="alertMessage"
      class="mx-3 mb-3"
      type="info"
      show-icon
      :message="alertMessage"
    />
    <Form class="mx-3" />
  </Modal>
</template>
src/views/research/application/modules/fund-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,236 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchApplicationApi } from '#/api/research/application';
import type { ResearchFundRecordApi } from '#/api/research/fund';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getApplication } from '#/api/research/application';
import { deleteFundRecord, getFundRecordList } from '#/api/research/fund';
import { ApplicationStatus, FundStatus, useFundColumns } from '../data';
import FundForm from './fund-form.vue';
defineOptions({ name: 'ResearchFundPanel' });
const emit = defineEmits(['success']);
const applicationId = ref<number>();
const application = ref<ResearchApplicationApi.Application>();
const records = ref<ResearchFundRecordApi.FundRecord[]>([]);
/** ä»…「已立项」的申报单可登记拨款 */
const editable = computed(
  () => application.value?.status === ApplicationStatus.APPROVED,
);
/** å·²ç™»è®°æ‹¨ä»˜é‡‘额合计 */
const registeredAmount = computed(() =>
  records.value.reduce((sum, item) => sum + (Number(item.amount) || 0), 0),
);
/** ç”³è¯·ç»è´¹å¯ç”¨ä½™é¢ï¼ˆè´Ÿæ•°è¡¨ç¤ºå·²è¶…额登记) */
const remainder = computed(
  () => (Number(application.value?.budgetAmount) || 0) - registeredAmount.value,
);
const [FundFormModal, fundFormModalApi] = useVbenModal({
  connectedComponent: FundForm,
  destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useFundColumns(true),
    data: [],
    minHeight: 280,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<ResearchFundRecordApi.FundRecord>,
});
async function refresh() {
  if (!applicationId.value) {
    return;
  }
  const [recordList, applicationDetail] = await Promise.all([
    getFundRecordList(applicationId.value),
    getApplication(applicationId.value),
  ]);
  records.value = recordList;
  application.value = applicationDetail;
  gridApi.setGridOptions({ data: recordList });
}
/** æ‹¨æ¬¾è®°å½•增删改后:本面板重载,同时通知列表页刷新 */
async function handleChanged() {
  await refresh();
  emit('success');
}
function handleCreate() {
  fundFormModalApi
    .setData({ applicationId: applicationId.value!, remainder: remainder.value })
    .open();
}
function handleEdit(row: ResearchFundRecordApi.FundRecord) {
  fundFormModalApi
    .setData({
      applicationId: applicationId.value!,
      // ç¼–辑时把当前记录自身金额加回,避免用户看到「余额」偏小产生误解
      remainder: remainder.value + (Number(row.amount) || 0),
      record: row,
    })
    .open();
}
async function handleDelete(row: ResearchFundRecordApi.FundRecord) {
  const hideLoading = message.loading({ content: '删除中...', duration: 0 });
  try {
    await deleteFundRecord(row.id!);
    message.success('删除成功');
    await handleChanged();
  } finally {
    hideLoading();
  }
}
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      applicationId.value = undefined;
      application.value = undefined;
      records.value = [];
      return;
    }
    const data = modalApi.getData<{ id: number }>();
    applicationId.value = data.id;
    modalApi.lock();
    try {
      const [recordList, applicationDetail] = await Promise.all([
        getFundRecordList(data.id),
        getApplication(data.id),
      ]);
      records.value = recordList;
      application.value = applicationDetail;
      gridApi.setGridOptions({ data: recordList });
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal title="经费拨付" class="w-4/5" :show-confirm-button="false">
    <div class="space-y-3">
      <Alert
        v-if="!editable"
        type="info"
        show-icon
        message="仅「已立项」的课题申报书可以登记经费拨付记录"
      />
      <div class="flex flex-wrap items-center gap-6 rounded border border-border p-3">
        <div>
          <span class="text-gray-500">课题名称:</span>
          <span class="font-medium">{{ application?.title || '-' }}</span>
        </div>
        <div>
          <span class="text-gray-500">立项编号:</span>
          <span class="font-medium">{{ application?.researchNo || '未立项' }}</span>
        </div>
        <div>
          <span class="text-gray-500">申请经费:</span>
          <span class="font-medium">
            {{ Number(application?.budgetAmount || 0).toFixed(2) }} å…ƒ
          </span>
        </div>
        <div>
          <span class="text-gray-500">已登记拨付:</span>
          <span class="font-medium">{{ registeredAmount.toFixed(2) }} å…ƒ</span>
        </div>
        <div>
          <span class="text-gray-500">可用余额:</span>
          <span
            class="font-medium"
            :class="remainder < 0 ? 'text-red-500' : ''"
          >
            {{ remainder.toFixed(2) }} å…ƒ
          </span>
        </div>
      </div>
      <div v-if="editable">
        <TableAction
          :actions="[
            {
              label: '登记拨款记录',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['research:fund:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </div>
      <Grid>
        <template #hasAttachment="{ row }">
          <Tag :color="row.hasAttachment ? 'success' : 'warning'">
            {{
              row.hasAttachment
                ? '已上传'
                : row.status === FundStatus.PAID
                  ? '缺失'
                  : '不需要'
            }}
          </Tag>
        </template>
        <template #actions="{ row }">
          <TableAction
            v-if="editable"
            :actions="[
              {
                label: '编辑',
                type: 'link',
                icon: ACTION_ICON.EDIT,
                auth: ['research:fund:update'],
                ifShow: () => row.status === FundStatus.PENDING,
                onClick: handleEdit.bind(null, row),
              },
              {
                label: '删除',
                type: 'link',
                danger: true,
                icon: ACTION_ICON.DELETE,
                auth: ['research:fund:delete'],
                ifShow: () => row.status === FundStatus.PENDING,
                popConfirm: {
                  title: `确认删除「${row.batchName || '本条'}」拨款记录吗?`,
                  confirm: handleDelete.bind(null, row),
                },
              },
            ]"
          />
        </template>
      </Grid>
    </div>
  </Modal>
  <FundFormModal @success="handleChanged" />
</template>
src/views/research/application/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { ResearchApplicationApi } from '#/api/research/application';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitApplication } from '#/api/research/application';
defineOptions({ name: 'ResearchApplicationProcessSelect' });
const emit = defineEmits(['success']);
const applicationId = ref<number>();
const processList = ref<ResearchApplicationApi.ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
/** æµç¨‹ key åªèƒ½æ¥è‡ªåŽç«¯å®žæ—¶åˆ—表,禁止前端写死 */
const processOptions = computed(() =>
  processList.value.map((item) => ({ label: item.name, value: item.key })),
);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await submitApplication(applicationId.value!, selectedProcessKey.value);
      await modalApi.close();
      emit('success');
      message.success('提交成功');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      applicationId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data = modalApi.getData<{
      id: number;
      processList: ResearchApplicationApi.ProcessDefinition[];
    }>();
    applicationId.value = data.id;
    processList.value = data.processList || [];
    // é»˜è®¤é€‰æ‹©ç¬¬ä¸€ä¸ªæµç¨‹
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0]!.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 240px"
        placeholder="请选择审批流程"
      />
    </div>
    <p class="text-sm text-gray-500">
      æäº¤åŽä¾æ¬¡æµè½¬ï¼šç§˜ä¹¦å¤„形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹
    </p>
  </Modal>
</template>
src/views/research/application/modules/review-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { ResearchApplicationApi } from '#/api/research/application';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createTopicReview,
  updateTopicReview,
} from '#/api/research/application';
defineOptions({ name: 'ResearchTopicReviewForm' });
const emit = defineEmits(['success']);
/** å››ä¸ªè¯„分维度与权重(与后端 ResearchTopicReviewServiceImpl ä¿æŒä¸€è‡´ï¼‰ */
const SCORE_ITEMS = [
  { fieldName: 'topicScore', label: '选题价值与意义', weight: '0.3' },
  { fieldName: 'planScore', label: '研究方案可行性', weight: '0.3' },
  { fieldName: 'teamScore', label: '团队与研究基础', weight: '0.2' },
  { fieldName: 'resultScore', label: '预期成果与效益', weight: '0.2' },
] as const;
const schema: VbenFormSchema[] = [
  {
    fieldName: 'id',
    component: 'Input',
    dependencies: {
      triggerFields: [''],
      show: () => false,
    },
  },
  {
    fieldName: 'applicationId',
    component: 'Input',
    dependencies: {
      triggerFields: [''],
      show: () => false,
    },
  },
  {
    fieldName: 'evaluatorName',
    label: '专家姓名',
    component: 'Input',
    componentProps: {
      placeholder: '请输入专家姓名',
      maxlength: 64,
    },
    rules: 'required',
  },
  {
    fieldName: 'evaluatorUnit',
    label: '专家单位',
    component: 'Input',
    componentProps: {
      placeholder: '请输入专家所在单位',
      maxlength: 128,
    },
  },
  {
    fieldName: 'evaluatorTitle',
    label: '专家职称',
    component: 'Input',
    componentProps: {
      placeholder: '请输入专家职称',
      maxlength: 64,
    },
  },
  ...SCORE_ITEMS.map((item) => ({
    fieldName: item.fieldName,
    label: `${item.label}(权重 ${item.weight})`,
    component: 'InputNumber' as const,
    componentProps: {
      class: '!w-full',
      placeholder: '0 ~ 100',
      min: 0,
      max: 100,
      precision: 2,
      addonAfter: '分',
    },
  })),
  {
    fieldName: 'conclusion',
    label: '评审结论',
    component: 'Select',
    componentProps: {
      options: getDictOptions(DICT_TYPE.RESEARCH_REVIEW_CONCLUSION, 'number'),
      placeholder: '请选择评审结论',
      allowClear: true,
    },
    rules: 'selectRequired',
  },
  {
    fieldName: 'opinion',
    label: '评审意见',
    component: 'Textarea',
    componentProps: {
      placeholder: '请输入对该课题的评审意见',
      maxlength: 1000,
      showCount: true,
      autoSize: { minRows: 4, maxRows: 8 },
    },
    formItemClass: 'col-span-2',
  },
];
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 170,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema,
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as ResearchApplicationApi.TopicReview;
    try {
      await (data.id
        ? updateTopicReview(data)
        : createTopicReview(data));
      await modalApi.close();
      emit('success');
      message.success('保存成功,加权合计与排名已自动重算');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{
      applicationId: number;
      review?: ResearchApplicationApi.TopicReview;
    }>();
    await formApi.resetForm();
    if (data.review) {
      await formApi.setValues(
        { ...data.review, applicationId: data.applicationId },
        false,
      );
    } else {
      await formApi.setValues({ applicationId: data.applicationId }, false);
    }
  },
});
</script>
<template>
  <Modal title="专家评分录入" class="w-1/2" :close-on-click-modal="false">
    <Form class="mx-3" />
    <p class="mx-3 text-sm text-gray-500">
      åŠ æƒåˆè®¡ = é€‰é¢˜ä»·å€¼Ã—0.3 + æ–¹æ¡ˆå¯è¡Œæ€§Ã—0.3 + å›¢é˜ŸåŸºç¡€Ã—0.2 + é¢„期成果×0.2,由系统自动计算
    </p>
  </Modal>
</template>
src/views/research/application/modules/review-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,221 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchApplicationApi } from '#/api/research/application';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteTopicReview,
  getApplication,
  getTopicReviewList,
} from '#/api/research/application';
import { ApplicationStatus, useReviewColumns } from '../data';
import ReviewForm from './review-form.vue';
defineOptions({ name: 'ResearchTopicReviewPanel' });
const emit = defineEmits(['success']);
const applicationId = ref<number>();
const application = ref<ResearchApplicationApi.Application>();
const reviews = ref<ResearchApplicationApi.TopicReview[]>([]);
/** ä»…「审批中」的申报单可增删改专家评分 */
const editable = computed(
  () => application.value?.status === ApplicationStatus.APPROVING,
);
const STATUS_LABELS: Record<number, string> = {
  [ApplicationStatus.DRAFT]: '草稿',
  [ApplicationStatus.APPROVING]: '审批中',
  [ApplicationStatus.APPROVED]: '已立项',
  [ApplicationStatus.REJECTED]: '已驳回',
};
const statusLabel = computed(() =>
  application.value?.status === undefined ||
  application.value?.status === null
    ? '未知'
    : (STATUS_LABELS[application.value.status] ?? '未知'),
);
const [ReviewFormModal, reviewFormModalApi] = useVbenModal({
  connectedComponent: ReviewForm,
  destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useReviewColumns(true),
    data: [],
    minHeight: 300,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  } as VxeTableGridOptions<ResearchApplicationApi.TopicReview>,
});
/** æ‹‰å–评审明细并同步主单汇总值(后端在增删改后已重算均分与结论) */
async function refresh() {
  if (!applicationId.value) {
    return;
  }
  const [reviewList, applicationDetail] = await Promise.all([
    getTopicReviewList(applicationId.value),
    getApplication(applicationId.value),
  ]);
  reviews.value = reviewList;
  application.value = applicationDetail;
  gridApi.setGridOptions({ data: reviewList });
}
/** è¯„审明细变化后:本面板重载,同时通知列表页刷新(均分会展示在列表) */
async function handleChanged() {
  await refresh();
  emit('success');
}
function handleCreate() {
  reviewFormModalApi
    .setData({ applicationId: applicationId.value! })
    .open();
}
function handleEdit(row: ResearchApplicationApi.TopicReview) {
  reviewFormModalApi
    .setData({ applicationId: applicationId.value!, review: row })
    .open();
}
async function handleDelete(row: ResearchApplicationApi.TopicReview) {
  const hideLoading = message.loading({ content: '删除中...', duration: 0 });
  try {
    await deleteTopicReview(row.id!);
    message.success('删除成功,均分与排名已重新汇总');
    await handleChanged();
  } finally {
    hideLoading();
  }
}
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      applicationId.value = undefined;
      application.value = undefined;
      reviews.value = [];
      return;
    }
    const data = modalApi.getData<{ id: number }>();
    applicationId.value = data.id;
    modalApi.lock();
    try {
      await refresh();
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal title="专家评审" class="w-4/5" :show-confirm-button="false">
    <div class="space-y-3">
      <Alert
        v-if="!editable"
        type="info"
        show-icon
        :message="`当前申报单状态为「${statusLabel}」,仅「审批中」状态可录入或修改专家评分`"
      />
      <div class="flex flex-wrap items-center gap-6 rounded border border-border p-3">
        <div>
          <span class="text-gray-500">课题名称:</span>
          <span class="font-medium">{{ application?.title || '-' }}</span>
        </div>
        <div>
          <span class="text-gray-500">评审加权均分:</span>
          <span class="font-medium">
            {{
              application?.reviewAvgScore === null ||
              application?.reviewAvgScore === undefined
                ? '未汇总'
                : Number(application.reviewAvgScore).toFixed(2)
            }}
          </span>
        </div>
        <div>
          <span class="text-gray-500">评审结论:</span>
          <Tag
            v-if="application?.reviewPass !== null && application?.reviewPass !== undefined"
            :color="application.reviewPass === 1 ? 'success' : 'error'"
          >
            {{ application.reviewPass === 1 ? '推荐立项' : '不推荐立项' }}
          </Tag>
          <span v-else>未汇总</span>
        </div>
        <div>
          <span class="text-gray-500">已录入专家数:</span>
          <span class="font-medium">{{ reviews.length }}</span>
        </div>
      </div>
      <div v-if="editable">
        <TableAction
          :actions="[
            {
              label: '录入专家评分',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['research:review:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </div>
      <Grid>
        <template #actions="{ row }">
          <TableAction
            v-if="editable"
            :actions="[
              {
                label: '编辑',
                type: 'link',
                icon: ACTION_ICON.EDIT,
                auth: ['research:review:update'],
                onClick: handleEdit.bind(null, row),
              },
              {
                label: '删除',
                type: 'link',
                danger: true,
                icon: ACTION_ICON.DELETE,
                auth: ['research:review:delete'],
                popConfirm: {
                  title: `确认删除专家「${row.evaluatorName}」的评分记录吗?删除后均分与排名将重新汇总`,
                  confirm: handleDelete.bind(null, row),
                },
              },
            ]"
          />
        </template>
      </Grid>
    </div>
  </Modal>
  <ReviewFormModal @success="handleChanged" />
</template>
src/views/research/guideline/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,252 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** è¯¾é¢˜æŒ‡å—发布状态(对应字典 research_guideline_status) */
export const GuidelineStatus = {
  DRAFT: 0, // è‰ç¨¿
  PUBLISHED: 20, // å·²å‘布
  CLOSED: 30, // å·²æˆªæ­¢
} as const;
/** è¡¨å•配置 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '指南编号',
      component: 'Input',
      componentProps: {
        placeholder: '指南编号由系统自动生成',
        disabled: true,
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
      },
    },
    {
      fieldName: 'title',
      label: '指南名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入课题指南名称',
        maxlength: 255,
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'category',
      label: '课题类别',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY, 'number'),
        placeholder: '请选择课题类别',
        allowClear: true,
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'applyStartTime',
      label: '申报开始时间',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择申报开始时间',
        showTime: true,
        // åŽç«¯ä¸º LocalDateTime,必须带时分秒
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        style: { width: '100%' },
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'applyEndTime',
      label: '申报截止时间',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择申报截止时间',
        showTime: true,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
        style: { width: '100%' },
        disabled: formType === 'detail',
      },
      help: '截止时间之后不能再提交课题申报书',
    },
    {
      fieldName: 'content',
      label: '指南正文',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入指南正文(研究方向、申报要求等)',
        autoSize: { minRows: 6, maxRows: 12 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '指南附件',
      component: 'FileUpload',
      // valueKey ä¸º id,提交时 formApi ä¼šæŠŠæ–‡ä»¶å¯¹è±¡æå–为 blobId æ•°ç»„
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: formType !== 'detail',
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '指南编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入指南编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'title',
      label: '指南名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入指南名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'category',
      label: '课题类别',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY, 'number'),
        placeholder: '请选择课题类别',
        allowClear: true,
      },
    },
    {
      fieldName: 'publishStatus',
      label: '发布状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.RESEARCH_GUIDELINE_STATUS, 'number'),
        placeholder: '请选择发布状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列配置 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      type: 'checkbox',
      width: 50,
      fixed: 'left',
    },
    {
      field: 'no',
      title: '指南编号',
      width: 180,
      fixed: 'left',
    },
    {
      field: 'title',
      title: '指南名称',
      minWidth: 220,
    },
    {
      field: 'category',
      title: '课题类别',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_GUIDELINE_CATEGORY },
      },
    },
    {
      field: 'applyStartTime',
      title: '申报开始时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      field: 'applyEndTime',
      title: '申报截止时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      field: 'publishStatus',
      title: '发布状态',
      minWidth: 95,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.RESEARCH_GUIDELINE_STATUS },
      },
    },
    {
      field: 'publishTime',
      title: '发布时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      field: 'hasAttachment',
      title: '附件',
      minWidth: 80,
      slots: { default: 'hasAttachment' },
    },
    {
      field: 'creatorName',
      title: '创建人',
      minWidth: 100,
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 260,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/research/guideline/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,226 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ResearchGuidelineApi } from '#/api/research/guideline';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  closeGuideline,
  deleteGuideline,
  getGuidelinePage,
  publishGuideline,
} from '#/api/research/guideline';
import { $t } from '#/locales';
import { GuidelineStatus, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** è¯¾é¢˜æŒ‡å—列表 */
defineOptions({ name: 'ResearchGuideline' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žæŒ‡å— */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑指南(仅草稿可编辑) */
function handleEdit(row: ResearchGuidelineApi.Guideline) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹æŒ‡å—详情 */
function handleDetail(row: ResearchGuidelineApi.Guideline) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤æŒ‡å—(仅草稿且无申报书可删) */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteGuideline(id)));
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å‘布指南 */
async function handlePublish(row: ResearchGuidelineApi.Guideline) {
  const hideLoading = message.loading({ content: '发布中...', duration: 0 });
  try {
    await publishGuideline(row.id!);
    message.success('发布成功,会员现在可以据此提交课题申报');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æˆªæ­¢æŒ‡å— */
async function handleClose(row: ResearchGuidelineApi.Guideline) {
  const hideLoading = message.loading({ content: '截止中...', duration: 0 });
  try {
    await closeGuideline(row.id!);
    message.success('已截止,该指南不再接受新的课题申报');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: ResearchGuidelineApi.Guideline[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getGuidelinePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ResearchGuidelineApi.Guideline>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="课题指南列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['课题指南']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['research:guideline:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['research:guideline:delete'],
              popConfirm: {
                title: '是否删除所选中数据?(仅草稿状态且无课题申报书的指南可删除)',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:guideline:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['research:guideline:update'],
              ifShow: () => row.publishStatus === GuidelineStatus.DRAFT,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '发布',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['research:guideline:publish'],
              ifShow: () => row.publishStatus === GuidelineStatus.DRAFT,
              popConfirm: {
                title: `确认发布指南 ${row.no} å—?发布后不可再修改,会员可据此提交课题申报`,
                confirm: handlePublish.bind(null, row),
              },
            },
            {
              label: '截止',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['research:guideline:publish'],
              ifShow: () => row.publishStatus === GuidelineStatus.PUBLISHED,
              popConfirm: {
                title: `确认截止指南 ${row.no} å—?截止后不再接受新的课题申报`,
                confirm: handleClose.bind(null, row),
              },
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['research:guideline:delete'],
              ifShow: () => row.publishStatus === GuidelineStatus.DRAFT,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/research/guideline/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { ResearchGuidelineApi } from '#/api/research/guideline';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createGuideline,
  getGuideline,
  updateGuideline,
} from '#/api/research/guideline';
import { useFormSchema } from '../data';
defineOptions({ name: 'ResearchGuidelineForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['课题指南']);
  }
  if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['课题指南']);
  }
  return '课题指南详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema(formType.value),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as ResearchGuidelineApi.Guideline;
    // é™„件为全量替换语义:清空全部附件时必须显式传空数组,
    // ä¼  null åŽç«¯ä¼šè§†ä¸ºã€Œä¸åŠ¨é™„ä»¶ã€ï¼Œå¯¼è‡´åˆ é™¤æ“ä½œä¸ç”Ÿæ•ˆ
    data.blobIds = data.blobIds ?? [];
    try {
      await (formType.value === 'create'
        ? createGuideline(data)
        : updateGuideline(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.setState({ schema: useFormSchema(formType.value) });
    if (!data?.id) {
      await formApi.resetForm();
      return;
    }
    modalApi.lock();
    try {
      const guideline = await getGuideline(data.id);
      // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
      const blobIds = (guideline.attachmentList ?? []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      await formApi.setValues({ ...guideline, blobIds }, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>
src/views/ums/application/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,348 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { markRaw } from 'vue';
import { AreaCascader } from '#/components/area';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** å…¥ä¼šç”³è¯·çŠ¶æ€ï¼ˆå¯¹åº”å­—å…¸ ums_application_status) */
export const ApplicationStatus = {
  DRAFT: 0, // è‰ç¨¿
  APPROVING: 15, // å®¡æ‰¹ä¸­
  APPROVED: 20, // å·²é€šè¿‡
  REJECTED: 35, // å·²é©³å›ž
} as const;
/** ä¼šå‘˜ç±»åž‹ï¼ˆå¯¹åº”å­—å…¸ ums_member_type) */
export const MemberType = {
  PERSONAL: 1, // ä¸ªäººä¼šå‘˜
  UNIT: 2, // å•位会员
} as const;
/** æ ¹æ®ä¼šå‘˜ç±»åž‹èŽ·å–ç­‰çº§å­—å…¸é€‰é¡¹ */
export function getGradeDictOptions(memberType?: number) {
  return getDictOptions(
    memberType === MemberType.UNIT
      ? DICT_TYPE.UMS_UNIT_GRADE
      : DICT_TYPE.UMS_PERSONAL_GRADE,
    'string',
  );
}
/** è¡¨å•配置 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '申请编号',
      component: 'Input',
      componentProps: {
        placeholder: '申请编号由系统自动生成',
        disabled: true,
      },
    },
    {
      fieldName: 'memberType',
      label: '会员类型',
      component: 'Select',
      componentProps: (_values, form) => ({
        options: getDictOptions(DICT_TYPE.UMS_MEMBER_TYPE, 'number'),
        placeholder: '请选择会员类型',
        allowClear: true,
        disabled: formType === 'detail',
        onChange: () => {
          // åˆ‡æ¢ä¼šå‘˜ç±»åž‹åŽæ¸…空已选等级(个人与单位使用不同等级字典)
          form.setFieldValue('memberGrade', undefined);
        },
      }),
      rules: 'required',
    },
    {
      fieldName: 'applicantName',
      label: '申请人/单位名称',
      component: 'Input',
      componentProps: {
        placeholder: '个人填写姓名,单位填写单位名称',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'memberGrade',
      label: '申请等级',
      component: 'Select',
      componentProps: (values) => ({
        options: getGradeDictOptions(values.memberType),
        placeholder: '请选择申请等级',
        allowClear: true,
        disabled: formType === 'detail',
      }),
      rules: 'required',
    },
    {
      fieldName: 'contactPhone',
      label: '联系电话(登录账号)',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系电话,将作为会员系统登录账号',
        maxlength: 20,
        disabled: formType === 'detail',
      },
      help: '审批通过后将自动创建系统登录账号:用户名与初始密码均为联系电话,请务必填写准确',
    },
    {
      fieldName: 'contactEmail',
      label: '邮箱',
      component: 'Input',
      componentProps: {
        placeholder: '请输入邮箱',
        maxlength: 128,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'workUnit',
      label: '工作单位',
      component: 'Input',
      componentProps: {
        placeholder: '请输入工作单位(个人会员填写)',
        maxlength: 128,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.PERSONAL,
      },
    },
    {
      fieldName: 'title',
      label: '职称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入职称(个人会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.PERSONAL,
      },
    },
    {
      fieldName: 'legalPerson',
      label: '法定代表人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入法定代表人(单位会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.UNIT,
      },
    },
    {
      fieldName: 'organizationCode',
      label: '统一社会信用代码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入统一社会信用代码(单位会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.UNIT,
      },
    },
    {
      fieldName: 'researchDirection',
      label: '研究方向',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入研究方向',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'regionCode',
      label: '地区',
      component: markRaw(AreaCascader),
      componentProps: {
        placeholder: '请选择地区',
        allowClear: true,
        class: '!w-full',
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'industry',
      label: '行业',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_INDUSTRY, 'string'),
        placeholder: '请选择行业',
        allowClear: true,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_APPLICATION_STATUS, 'number'),
        disabled: true,
      },
      dependencies: {
        triggerFields: [''],
        show: () => formType !== 'create',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: formType !== 'detail',
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '申请编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入申请编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_APPLICATION_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
    {
      fieldName: 'memberType',
      label: '会员类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_MEMBER_TYPE, 'number'),
        placeholder: '请选择会员类型',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列配置 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      type: 'checkbox',
      width: 50,
      fixed: 'left',
    },
    {
      field: 'no',
      title: '申请编号',
      width: 180,
      fixed: 'left',
    },
    {
      field: 'applicantName',
      title: '申请人/单位',
      minWidth: 140,
    },
    {
      field: 'memberType',
      title: '会员类型',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.UMS_MEMBER_TYPE },
      },
    },
    {
      field: 'memberGrade',
      title: '申请等级',
      minWidth: 100,
      slots: { default: 'memberGrade' },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 95,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.UMS_APPLICATION_STATUS },
      },
    },
    {
      field: 'hasAttachment',
      title: '附件',
      minWidth: 80,
      slots: { default: 'hasAttachment' },
    },
    {
      field: 'creatorName',
      title: '创建人',
      minWidth: 100,
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 165,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 290,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/ums/application/detail/approve/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,196 @@
<script lang="ts" setup>
import type { UmsApplicationApi } from '#/api/ums/application';
import type { DescriptionItemSchema } from '#/components/description';
import { computed, h, onMounted, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { Alert, Spin } from 'ant-design-vue';
import { getApplication } from '#/api/ums/application';
import { DictTag } from '#/components/dict-tag';
import { useDescription } from '#/components/description';
import { ApplicationStatus, MemberType } from '../../data';
defineOptions({ name: 'UmsApplicationApproveDetail' });
const props = defineProps<{ id: string }>();
const loading = ref(false);
const formData = ref<UmsApplicationApi.Application>();
/** æ–‡æœ¬å­—段空值占位 */
function textRender(val: unknown) {
  return val === 0 || val ? String(val) : '-';
}
/** ç”³è¯·ç­‰çº§ï¼šæŒ‰ä¼šå‘˜ç±»åž‹å–个人/单位不同的等级字典 */
function getGradeDictType(memberType?: number) {
  return memberType === MemberType.UNIT
    ? DICT_TYPE.UMS_UNIT_GRADE
    : DICT_TYPE.UMS_PERSONAL_GRADE;
}
const [Descriptions] = useDescription({
  bordered: true,
  column: 2,
  schema: [
    { label: '申请编号', field: 'no', render: textRender },
    {
      label: '会员类型',
      field: 'memberType',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.UMS_MEMBER_TYPE, value: val }),
    },
    { label: '申请人/单位名称', field: 'applicantName', render: textRender },
    {
      label: '申请等级',
      field: 'memberGrade',
      render: (val, data) =>
        h(DictTag, {
          type: getGradeDictType(data?.memberType),
          value: val,
        }),
    },
    {
      label: '状态',
      field: 'status',
      render: (val) =>
        h(DictTag, { type: DICT_TYPE.UMS_APPLICATION_STATUS, value: val }),
    },
    { label: '联系电话', field: 'contactPhone', render: textRender },
    { label: '邮箱', field: 'contactEmail', render: textRender },
    {
      label: '研究方向',
      field: 'researchDirection',
      render: textRender,
      span: 2,
    },
    {
      label: '法定代表人',
      field: 'legalPerson',
      render: textRender,
      show: (data) => data?.memberType === MemberType.UNIT,
    },
    {
      label: '统一社会信用代码',
      field: 'organizationCode',
      render: textRender,
      show: (data) => data?.memberType === MemberType.UNIT,
    },
    {
      label: '工作单位',
      field: 'workUnit',
      render: textRender,
      show: (data) => data?.memberType === MemberType.PERSONAL,
    },
    {
      label: '职称',
      field: 'title',
      render: textRender,
      show: (data) => data?.memberType === MemberType.PERSONAL,
    },
    {
      label: '地区编码',
      field: 'regionCode',
      render: textRender,
    },
    {
      label: '行业',
      field: 'industry',
      render: (val) => h(DictTag, { type: DICT_TYPE.UMS_INDUSTRY, value: val }),
    },
    { label: '创建人', field: 'creatorName', render: textRender },
    {
      label: '创建时间',
      field: 'createTime',
      render: (val) => (val ? (formatDateTime(val) as string) : '-'),
    },
    { label: '备注', field: 'remark', render: textRender, span: 2 },
  ] as DescriptionItemSchema[],
});
/** è´¦å·å‡­æ®æé†’:审批中提示审批人核对联系电话,通过后提示账号已开通及初始密码 */
const credentialTip = computed(() => {
  const status = formData.value?.status;
  const phone = formData.value?.contactPhone;
  if (status === ApplicationStatus.APPROVING) {
    return {
      type: 'warning' as const,
      message: `审批通过后将自动为申请人创建系统登录账号(用户名与初始密码均为联系电话 ${phone || '未填写'}),请先核对联系电话是否准确后再通过。`,
    };
  }
  if (status === ApplicationStatus.APPROVED) {
    if (!phone) {
      return {
        type: 'warning' as const,
        message:
          '未填写联系电话,账号用户名/密码使用了兜底值 unknown,请到会员列表核实并手工修正。',
      };
    }
    return {
      type: 'success' as const,
      message: `系统登录账号已自动开通:用户名 ${phone},初始密码 ${phone}。请通知申请人尽快登录并修改密码。`,
    };
  }
  return null;
});
onMounted(async () => {
  try {
    loading.value = true;
    formData.value = await getApplication(Number(props.id));
  } finally {
    loading.value = false;
  }
});
</script>
<template>
  <ContentWrap class="m-2">
    <Spin :spinning="loading" tip="加载中...">
      <div class="space-y-4">
        <!-- è´¦å·å‡­æ®æé†’ -->
        <Alert
          v-if="credentialTip"
          :type="credentialTip.type"
          :message="credentialTip.message"
          show-icon
        />
        <!-- å…¥ä¼šç”³è¯·åŸºæœ¬ä¿¡æ¯ -->
        <div class="rounded-lg border border-border bg-background p-4">
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">入会申请基本信息</span>
          </div>
          <Descriptions :data="formData" />
        </div>
        <!-- è¯æ˜Žææ–™é™„ä»¶ -->
        <div
          v-if="formData?.attachmentList?.length"
          class="rounded-lg border border-border bg-background p-4"
        >
          <div class="mb-4 flex items-center gap-2">
            <span class="inline-block h-4 w-1 rounded bg-primary" />
            <span class="text-base font-semibold">证明材料附件</span>
          </div>
          <div class="flex flex-wrap gap-3">
            <a
              v-for="file in formData.attachmentList"
              :key="file.id"
              :href="file.previewURL || file.url"
              target="_blank"
              class="text-blue-500 hover:underline"
            >
              {{ file.name || file.originalFilename || `附件-${file.id}` }}
            </a>
          </div>
        </div>
      </div>
    </Spin>
  </ContentWrap>
</template>
src/views/ums/application/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,261 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { UmsApplicationApi } from '#/api/ums/application';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteApplication,
  getApplicationApproveProcessList,
  getApplicationPage,
} from '#/api/ums/application';
import {
  ApplicationStatus,
  MemberType,
  useGridColumns,
  useGridFormSchema,
} from './data';
import Form from './modules/form.vue';
import ProcessSelectModal from './modules/process-select-modal.vue';
/** å…¥ä¼šç”³è¯·åˆ—表 */
defineOptions({ name: 'UmsApplication' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessModal, processModalApi] = useVbenModal({
  connectedComponent: ProcessSelectModal,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žå…¥ä¼šç”³è¯· */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑入会申请(仅草稿、已驳回可编辑) */
function handleEdit(row: UmsApplicationApi.Application) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹å…¥ä¼šç”³è¯·è¯¦æƒ… */
function handleDetail(row: UmsApplicationApi.Application) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤å…¥ä¼šç”³è¯·ï¼ˆä»…草稿可删) */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: '删除中...',
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteApplication(id)));
    message.success('删除成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æäº¤å®¡æ‰¹ - å…ˆèŽ·å–æµç¨‹åˆ—è¡¨ */
async function handleSubmit(row: UmsApplicationApi.Application) {
  const hideLoading = message.loading({
    content: '获取审批流程...',
    duration: 0,
  });
  try {
    const processList = await getApplicationApproveProcessList();
    hideLoading();
    if (!processList || processList.length === 0) {
      message.warning('暂无可用审批流程,请先到 BPM æµç¨‹è®¾è®¡å™¨å‘布入会审批流程');
      return;
    }
    processModalApi.setData({ id: row.id, processList }).open();
  } catch {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail(row: UmsApplicationApi.Application) {
  if (!row.processInstanceId) {
    message.warning('该申请尚未提交审批');
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId },
  });
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: UmsApplicationApi.Application[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
/** ç­‰çº§å­—典映射:根据会员类型显示对应的等级名称 */
const personalGradeOptions = getDictOptions(DICT_TYPE.UMS_PERSONAL_GRADE, 'string');
const unitGradeOptions = getDictOptions(DICT_TYPE.UMS_UNIT_GRADE, 'string');
function getGradeLabel(row: UmsApplicationApi.Application) {
  const options =
    row.memberType === MemberType.UNIT
      ? unitGradeOptions
      : personalGradeOptions;
  return options?.find((o) => o.value === row.memberGrade)?.label || row.memberGrade;
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getApplicationPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<UmsApplicationApi.Application>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="入会申请列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增入会申请',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['ums:application:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['ums:application:delete'],
              popConfirm: {
                title: '是否删除所选中数据?(仅草稿状态可删除)',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #memberGrade="{ row }">
        <span>{{ getGradeLabel(row) }}</span>
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '详情',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['ums:application:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '编辑',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['ums:application:update'],
              ifShow: () =>
                row.status === ApplicationStatus.DRAFT ||
                row.status === ApplicationStatus.REJECTED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['ums:application:submit'],
              ifShow: () =>
                row.status === ApplicationStatus.DRAFT ||
                row.status === ApplicationStatus.REJECTED,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['ums:application:query'],
              ifShow: () => !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['ums:application:delete'],
              ifShow: () => row.status === ApplicationStatus.DRAFT,
              popConfirm: {
                title: `确认删除申请 ${row.no} å—?`,
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/ums/application/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { UmsApplicationApi } from '#/api/ums/application';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createApplication,
  getApplication,
  updateApplication,
} from '#/api/ums/application';
import { ApplicationStatus, useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<UmsApplicationApi.Application>({
  memberType: 1,
  applicantName: '',
  memberGrade: '',
  contactPhone: '',
  contactEmail: '',
  legalPerson: '',
  organizationCode: '',
  researchDirection: '',
  workUnit: '',
  title: '',
  regionCode: '',
  industry: '',
  remark: '',
});
const formType = ref<FormType>('create'); // è¡¨å•类型:'create' | 'edit' | 'detail'
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['入会申请']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['入会申请']);
  } else {
    return '入会申请详情';
  }
});
/** è´¦å·å‡­æ®æé†’:仅在详情态且审批已通过时展示 */
const credentialTip = computed(() => {
  if (
    formType.value !== 'detail' ||
    formData.value.status !== ApplicationStatus.APPROVED
  ) {
    return null;
  }
  const phone = formData.value.contactPhone;
  if (!phone) {
    return {
      type: 'warning' as const,
      message:
        '未填写联系电话,账号用户名/密码使用了兜底值 unknown,请到会员列表核实并手工修正。',
    };
  }
  return {
    type: 'success' as const,
    message: `系统登录账号已自动开通:用户名 ${phone},初始密码 ${phone}。请通知申请人尽快登录并修改密码。`,
  };
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema(formType.value),
  showDefaultActions: false,
});
/** åˆ›å»ºæˆ–更新入会申请 */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as UmsApplicationApi.Application;
    // é™„件为全量替换语义:清空附件时必须显式传空数组
    data.blobIds = data.blobIds ?? [];
    try {
      await (formType.value === 'create'
        ? createApplication(data)
        : updateApplication(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = {} as UmsApplicationApi.Application;
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      // æ–°å»ºæ—¶é»˜è®¤ä¸ªäººä¼šå‘˜ç­‰çº§é€‰é¡¹
      await formApi.setValues({ memberType: 1 }, false);
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getApplication(data.id);
      // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
      const blobIds = (formData.value.attachmentList ?? []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      await formApi.setValues(
        { ...formData.value, blobIds },
        false,
      );
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Alert
      v-if="credentialTip"
      :type="credentialTip.type"
      :message="credentialTip.message"
      show-icon
      class="mx-3 mb-3"
    />
    <Form class="mx-3" />
  </Modal>
</template>
src/views/ums/application/modules/process-select-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { UmsApplicationApi } from '#/api/ums/application';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Select } from 'ant-design-vue';
import { submitApplication } from '#/api/ums/application';
const emit = defineEmits(['success']);
const applicationId = ref<number>();
const processList = ref<UmsApplicationApi.ProcessDefinition[]>([]);
const selectedProcessKey = ref<string>('');
const processOptions = computed(() =>
  processList.value.map((item) => ({ label: item.name, value: item.key })),
);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!selectedProcessKey.value) {
      message.warning('请选择审批流程');
      return;
    }
    modalApi.lock();
    try {
      await submitApplication(applicationId.value!, selectedProcessKey.value);
      await modalApi.close();
      emit('success');
      message.success(
        '提交成功,审批通过后将自动创建登录账号(用户名与初始密码均为联系电话)',
        6,
      );
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      applicationId.value = undefined;
      selectedProcessKey.value = '';
      processList.value = [];
      return;
    }
    const data =
      modalApi.getData<{
        id: number;
        processList: UmsApplicationApi.ProcessDefinition[];
      }>();
    applicationId.value = data.id;
    processList.value = data.processList || [];
    if (processList.value.length > 0) {
      selectedProcessKey.value = processList.value[0]!.key;
    }
  },
});
</script>
<template>
  <Modal title="选择审批流程" class="w-1/3">
    <div class="mb-4">
      <span class="mr-2">审批流程:</span>
      <Select
        v-model:value="selectedProcessKey"
        :options="processOptions"
        style="width: 200px"
        placeholder="请选择审批流程"
      />
    </div>
  </Modal>
</template>
src/views/ums/member/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,432 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { markRaw } from 'vue';
import { AreaCascader } from '#/components/area';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** ä¼šå‘˜ç±»åž‹ï¼ˆå¯¹åº”å­—å…¸ ums_member_type) */
export const MemberType = {
  PERSONAL: 1, // ä¸ªäººä¼šå‘˜
  UNIT: 2, // å•位会员
} as const;
/** ä¼šå‘˜çŠ¶æ€ï¼ˆå¯¹åº”å­—å…¸ ums_member_status) */
export const MemberStatus = {
  NORMAL: 1, // æ­£å¸¸
  SUSPENDED: 0, // æš‚停
} as const;
/** æ ¹æ®ä¼šå‘˜ç±»åž‹èŽ·å–ç­‰çº§å­—å…¸é€‰é¡¹ */
export function getGradeDictOptions(memberType?: number) {
  return getDictOptions(
    memberType === MemberType.UNIT
      ? DICT_TYPE.UMS_UNIT_GRADE
      : DICT_TYPE.UMS_PERSONAL_GRADE,
    'string',
  );
}
/** è¡¨å•配置 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'no',
      label: '会员编号',
      component: 'Input',
      componentProps: {
        placeholder: '会员编号由系统自动生成',
        disabled: true,
      },
    },
    {
      fieldName: 'memberType',
      label: '会员类型',
      component: 'Select',
      componentProps: (_values, form) => ({
        options: getDictOptions(DICT_TYPE.UMS_MEMBER_TYPE, 'number'),
        placeholder: '请选择会员类型',
        allowClear: true,
        disabled: formType === 'detail',
        onChange: () => {
          // åˆ‡æ¢ä¼šå‘˜ç±»åž‹åŽæ¸…空已选等级(个人与单位使用不同等级字典)
          form.setFieldValue('memberGrade', undefined);
        },
      }),
      rules: 'required',
    },
    {
      fieldName: 'name',
      label: '姓名/单位名称',
      component: 'Input',
      componentProps: {
        placeholder: '个人填写姓名,单位填写单位名称',
        maxlength: 128,
        disabled: formType === 'detail',
      },
      rules: 'required',
    },
    {
      fieldName: 'memberGrade',
      label: '会员等级',
      component: 'Select',
      componentProps: (values) => ({
        options: getGradeDictOptions(values.memberType),
        placeholder: '请选择会员等级',
        allowClear: true,
        disabled: formType === 'detail',
      }),
      rules: 'required',
    },
    {
      fieldName: 'contactUser',
      label: '联系人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系人(单位会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.UNIT,
      },
    },
    {
      fieldName: 'contactPhone',
      label: '联系电话',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系电话',
        maxlength: 20,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'contactEmail',
      label: '邮箱',
      component: 'Input',
      componentProps: {
        placeholder: '请输入邮箱',
        maxlength: 128,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'workUnit',
      label: '工作单位',
      component: 'Input',
      componentProps: {
        placeholder: '请输入工作单位(个人会员填写)',
        maxlength: 128,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.PERSONAL,
      },
    },
    {
      fieldName: 'title',
      label: '职称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入职称(个人会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.PERSONAL,
      },
    },
    {
      fieldName: 'legalPerson',
      label: '法定代表人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入法定代表人(单位会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.UNIT,
      },
    },
    {
      fieldName: 'organizationCode',
      label: '统一社会信用代码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入统一社会信用代码(单位会员填写)',
        maxlength: 64,
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.UNIT,
      },
    },
    {
      fieldName: 'researchDirection',
      label: '研究方向',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入研究方向',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'unitIntro',
      label: '单位简介',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入单位简介(单位会员填写)',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      dependencies: {
        triggerFields: ['memberType'],
        show: (values) => values.memberType === MemberType.UNIT,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'regionCode',
      label: '地区',
      component: markRaw(AreaCascader),
      componentProps: {
        placeholder: '请选择地区',
        allowClear: true,
        class: '!w-full',
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'industry',
      label: '行业',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_INDUSTRY, 'string'),
        placeholder: '请选择行业',
        allowClear: true,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'joinDate',
      label: '入会日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择入会日期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'expireDate',
      label: '有效期至',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择有效期',
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_MEMBER_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
        disabled: formType === 'detail',
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        autoSize: { minRows: 2, maxRows: 3 },
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'blobIds',
      label: '附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        showDescription: formType !== 'detail',
        disabled: formType === 'detail',
      },
      formItemClass: 'col-span-2',
    },
  ];
}
/** åˆ—表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'no',
      label: '会员编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入会员编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'name',
      label: '姓名/单位',
      component: 'Input',
      componentProps: {
        placeholder: '请输入姓名或单位名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'memberType',
      label: '会员类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_MEMBER_TYPE, 'number'),
        placeholder: '请选择会员类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'regionCode',
      label: '地区',
      component: markRaw(AreaCascader),
      componentProps: {
        placeholder: '请选择地区',
        allowClear: true,
        class: '!w-full',
      },
    },
    {
      fieldName: 'industry',
      label: '行业',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_INDUSTRY, 'string'),
        placeholder: '请选择行业',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.UMS_MEMBER_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表列配置 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
  return [
    {
      type: 'checkbox',
      width: 50,
      fixed: 'left',
    },
    {
      field: 'no',
      title: '会员编号',
      width: 180,
      fixed: 'left',
    },
    {
      field: 'name',
      title: '姓名/单位',
      minWidth: 150,
    },
    {
      field: 'memberType',
      title: '会员类型',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.UMS_MEMBER_TYPE },
      },
    },
    {
      field: 'memberGrade',
      title: '会员等级',
      minWidth: 100,
      slots: { default: 'memberGrade' },
    },
    {
      field: 'contactPhone',
      title: '联系电话',
      minWidth: 130,
    },
    {
      field: 'industry',
      title: '行业',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.UMS_INDUSTRY },
      },
    },
    {
      field: 'joinDate',
      title: '入会日期',
      width: 115,
      formatter: 'formatDate',
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 85,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.UMS_MEMBER_STATUS },
      },
    },
    {
      field: 'hasAttachment',
      title: '附件',
      minWidth: 80,
      slots: { default: 'hasAttachment' },
    },
    {
      title: '操作',
      width: 180,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/ums/member/directory.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { UmsMemberApi } from '#/api/ums/member';
import { Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMemberPage } from '#/api/ums/member';
import { MemberType, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** ä¼šå‘˜é€šè®¯å½•(按地区/行业检索,只读) */
defineOptions({ name: 'UmsMemberDirectory' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** æŸ¥çœ‹ä¼šå‘˜è¯¦æƒ… */
function handleDetail(row: UmsMemberApi.Member) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** ç­‰çº§å­—典映射:根据会员类型显示对应的等级名称 */
const personalGradeOptions = getDictOptions(DICT_TYPE.UMS_PERSONAL_GRADE, 'string');
const unitGradeOptions = getDictOptions(DICT_TYPE.UMS_UNIT_GRADE, 'string');
function getGradeLabel(row: UmsMemberApi.Member) {
  const options =
    row.memberType === MemberType.UNIT ? unitGradeOptions : personalGradeOptions;
  return options?.find((o) => o.value === row.memberGrade)?.label || row.memberGrade;
}
const [Grid] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: [
      { type: 'seq', title: '序号', width: 55, fixed: 'left' },
      { field: 'no', title: '会员编号', width: 180, fixed: 'left' },
      { field: 'name', title: '姓名/单位', minWidth: 150 },
      {
        field: 'memberType',
        title: '会员类型',
        minWidth: 100,
        cellRender: {
          name: 'CellDict',
          props: { type: DICT_TYPE.UMS_MEMBER_TYPE },
        },
      },
      {
        field: 'memberGrade',
        title: '会员等级',
        minWidth: 100,
        slots: { default: 'memberGrade' },
      },
      { field: 'contactPhone', title: '联系电话', minWidth: 130 },
      { field: 'contactEmail', title: '邮箱', minWidth: 160 },
      { field: 'workUnit', title: '工作单位', minWidth: 160 },
      {
        field: 'industry',
        title: '行业',
        minWidth: 100,
        cellRender: {
          name: 'CellDict',
          props: { type: DICT_TYPE.UMS_INDUSTRY },
        },
      },
      {
        field: 'regionCode',
        title: '地区',
        minWidth: 110,
      },
      {
        field: 'joinDate',
        title: '入会日期',
        width: 115,
        formatter: 'formatDate',
      },
      {
        title: '操作',
        width: 90,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getMemberPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<UmsMemberApi.Member>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal />
    <Grid table-title="会员通讯录">
      <template #memberGrade="{ row }">
        <span>{{ getGradeLabel(row) }}</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '详情',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['ums:member:directory'],
              onClick: handleDetail.bind(null, row),
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/ums/member/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { UmsMemberApi } from '#/api/ums/member';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMember, getMemberPage } from '#/api/ums/member';
import { MemberType, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** ä¼šå‘˜åˆ—表 */
defineOptions({ name: 'UmsMember' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žä¼šå‘˜ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑会员 */
function handleEdit(row: UmsMemberApi.Member) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹ä¼šå‘˜è¯¦æƒ… */
function handleDetail(row: UmsMemberApi.Member) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤ä¼šå‘˜ */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: '删除中...',
    duration: 0,
  });
  try {
    await Promise.all(ids.map((id) => deleteMember(id)));
    message.success('删除成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({ records }: { records: UmsMemberApi.Member[] }) {
  checkedIds.value = records.map((item) => item.id!);
}
/** ç­‰çº§å­—典映射:根据会员类型显示对应的等级名称 */
const personalGradeOptions = getDictOptions(DICT_TYPE.UMS_PERSONAL_GRADE, 'string');
const unitGradeOptions = getDictOptions(DICT_TYPE.UMS_UNIT_GRADE, 'string');
function getGradeLabel(row: UmsMemberApi.Member) {
  const options =
    row.memberType === MemberType.UNIT ? unitGradeOptions : personalGradeOptions;
  return options?.find((o) => o.value === row.memberGrade)?.label || row.memberGrade;
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getMemberPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<UmsMemberApi.Member>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="会员列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '新增会员',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['ums:member:create'],
              onClick: handleCreate,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['ums:member:delete'],
              popConfirm: {
                title: '是否删除所选中会员?',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #memberGrade="{ row }">
        <span>{{ getGradeLabel(row) }}</span>
      </template>
      <template #hasAttachment="{ row }">
        <Tag :color="row.hasAttachment ? 'success' : 'default'">
          {{ row.hasAttachment ? '有' : '无' }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '详情',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['ums:member:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '编辑',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['ums:member:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['ums:member:delete'],
              popConfirm: {
                title: `确认删除会员 ${row.name} å—?`,
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/ums/member/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { UmsMemberApi } from '#/api/ums/member';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createMember, getMember, updateMember } from '#/api/ums/member';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<UmsMemberApi.Member>({
  memberType: 1,
  memberGrade: '',
  name: '',
  contactPhone: '',
  contactEmail: '',
  legalPerson: '',
  organizationCode: '',
  researchDirection: '',
  workUnit: '',
  title: '',
  regionCode: '',
  industry: '',
  remark: '',
});
const formType = ref<FormType>('create'); // è¡¨å•类型:'create' | 'edit' | 'detail'
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['会员']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['会员']);
  } else {
    return '会员详情';
  }
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema(formType.value),
  showDefaultActions: false,
});
/** åˆ›å»ºæˆ–更新会员 */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as UmsMemberApi.Member;
    data.blobIds = data.blobIds ?? [];
    try {
      await (formType.value === 'create'
        ? createMember(data)
        : updateMember(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = {} as UmsMemberApi.Member;
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      await formApi.setValues({ memberType: 1, status: 1 }, false);
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getMember(data.id);
      // å·²æœ‰é™„件转成上传组件可识别的文件对象,提交时按 valueKey=id æå–为 blobId
      const blobIds = (formData.value.attachmentList ?? []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      await formApi.setValues({ ...formData.value, blobIds }, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3" />
  </Modal>
</template>