zlwu 5 månader sedan
förälder
incheckning
ee4abd361e
100 ändrade filer med 3895 tillägg och 1479 borttagningar
  1. 1 0
      AGENTS.md
  2. 1 1
      backend/app.pid
  3. 42 13
      backend/app/api/pilot_service.py
  4. 226 25
      backend/app/crud/pilot_service.py
  5. 41 3
      backend/app/main.py
  6. 3 0
      backend/app/models/models.py
  7. 15 0
      docs/sql/2026-05-25-channel-service-order-txn-void-fields.sql
  8. 3 0
      docs/sql/zkxw.sql
  9. 176 0
      docs/superpowers/plans/2026-05-25-money-input-thousand-separator.md
  10. 189 0
      docs/superpowers/specs/2026-05-25-money-input-thousand-separator-design.md
  11. 47 1
      openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md
  12. 439 474
      web-admin/graphify-out/.graphify_analysis.json
  13. 191 114
      web-admin/graphify-out/.graphify_extract.json
  14. 138 86
      web-admin/graphify-out/GRAPH_REPORT.md
  15. 1 1
      web-admin/graphify-out/graph.html
  16. 112 112
      web-admin/graphify-out/graph.json
  17. 36 32
      web-admin/graphify-out/manifest.json
  18. 5 0
      web-admin/src/api/admin.ts
  19. 1 6
      web-admin/src/components/business/protocol-finance-confirm-fields/index.vue
  20. 80 0
      web-admin/src/components/common/amount-input/index.vue
  21. 1 0
      web-admin/src/graphify-out/cache/ast/04fcc62b4c4b8783a1475c4c4764f1477a84860fe6c4ea57e240c8050b65941b.json
  22. 1 0
      web-admin/src/graphify-out/cache/ast/07fdcf8910ddfeab44458b988dbb76dca081af494ef59272fa9b5300a5a3a5b1.json
  23. 1 0
      web-admin/src/graphify-out/cache/ast/1100878045fb0a0b93f01c4b0709e70ed1b8adf40be7e3771016571ae9207567.json
  24. 1 0
      web-admin/src/graphify-out/cache/ast/12f8f0007a5c134ff6dd26868422def5c29fa3096d08d6980cee7b33388479d8.json
  25. 1 0
      web-admin/src/graphify-out/cache/ast/2282127492a9d5a20fcdee0d7f950a63482edcb2f89536ac20f7d0ff6d9e41bb.json
  26. 1 0
      web-admin/src/graphify-out/cache/ast/2c36856fcdd7696e04486ead04d59273fa7d5293deea06d57a220d2884cf672e.json
  27. 0 0
      web-admin/src/graphify-out/cache/ast/2ddd0f811a4a66d0bdaa637e11cfa6950558d076171890704ab3369618540ef9.json
  28. 1 0
      web-admin/src/graphify-out/cache/ast/31704f241ad0e687813532a767f8e5c2e5b03a387416bb8df232d24f4348c3e5.json
  29. 1 0
      web-admin/src/graphify-out/cache/ast/3f1cbc015877fbba5647327af5a3df51751d3a6bdcbd9af4f66769f7c38b9b8c.json
  30. 0 0
      web-admin/src/graphify-out/cache/ast/471e48d7f02c3da5878dddb721cad06e6ded76a0170c3d41f47be527138b3a93.json
  31. 0 0
      web-admin/src/graphify-out/cache/ast/5afbd7a2c75e2e65eae8d19a47e9ef0c7f4be122a2de13949a8c4c7f583eacf3.json
  32. 1 0
      web-admin/src/graphify-out/cache/ast/5c541e9a2285e785e00ef9da675dabba0e54e98e735cb1e5cafc738bda50964e.json
  33. 1 0
      web-admin/src/graphify-out/cache/ast/5e0ae62a6666a84f12df5a766ee8c5caabdbefea986eb21b435abf7e35fdb86d.json
  34. 0 0
      web-admin/src/graphify-out/cache/ast/66aab76aa12d80e9e6de0d6fc7dc59c030b67fcfdc96ba75878ca989fc7833bd.json
  35. 1 0
      web-admin/src/graphify-out/cache/ast/6aaece741cccb10d803e3de42056c1fae04ed057fb6e0e2f1cd4934d49d7f59e.json
  36. 0 0
      web-admin/src/graphify-out/cache/ast/6de4ed7618f2e63cfaeb20cf4ea2d880d26486654bf6d17ed72856a860550917.json
  37. 1 0
      web-admin/src/graphify-out/cache/ast/75ac17ad572905845c16d14da5525953743d65fdad6086ad897c86488f6cef71.json
  38. 1 0
      web-admin/src/graphify-out/cache/ast/7c03d990abc09f28d32e51df65e2e54f2be07ba273d4a6304d543fe246a932cc.json
  39. 0 0
      web-admin/src/graphify-out/cache/ast/7c9ee65f53ea9f9818b99a553a223bf537c587ad3ae4282f200486dbf1a69da0.json
  40. 1 0
      web-admin/src/graphify-out/cache/ast/8ef8260410ab120e8d991f344b463196e706e50599ac422bc36e10f3ddf2020c.json
  41. 1 0
      web-admin/src/graphify-out/cache/ast/9108d40b4fe7a9958caa19169315bdd6d9f56fb712a4c483be63f38678721c17.json
  42. 1 0
      web-admin/src/graphify-out/cache/ast/958c3c8398394b7cee6f86d8cb7d569079065e05cef5545597e4dc7b1741c37e.json
  43. 1 0
      web-admin/src/graphify-out/cache/ast/9b79994230ba835942a7694ec722c282cb9dbea82858f2a2201f545dc4d9b379.json
  44. 0 0
      web-admin/src/graphify-out/cache/ast/9e80aa2567338a651d6573d9736787ad59fa78e2ef33cee3f074ac8ffc5ebf6b.json
  45. 0 0
      web-admin/src/graphify-out/cache/ast/a16e268b9b086f855f6f6f1b6a92b302b00d8dc215aa95f98a8d4519fde229ae.json
  46. 0 0
      web-admin/src/graphify-out/cache/ast/a38d7413d41bd7ed78c7df08cd61b43e4f181285a0188410461f8c52a1f8c04c.json
  47. 0 0
      web-admin/src/graphify-out/cache/ast/a49e3ea67e0c38bc000247ad2e61c9854ed41b3e26a06bcc58128aa3df15d886.json
  48. 0 0
      web-admin/src/graphify-out/cache/ast/a97a5a72d199987571747098b37806fff50745bfe24ebcd6a01609cf71d4744c.json
  49. 1 0
      web-admin/src/graphify-out/cache/ast/aa7d824370d3bbcabc3e138c22d38701c7c0e19abcca5f5a253832881be17eee.json
  50. 0 0
      web-admin/src/graphify-out/cache/ast/ace3bc9db1dedf638cd9a50b758f25b0e6fb502710127063e5aaecbfe4ecebbc.json
  51. 0 0
      web-admin/src/graphify-out/cache/ast/ae4a92a8cab26a44a1f57684abe842cdae415ccb4a9550154e53aeb853deee3e.json
  52. 1 0
      web-admin/src/graphify-out/cache/ast/ae9c4065d6bd05a38d4ceffe36fac15f0b0f1db5790a4257efc9e9d2f6c10df0.json
  53. 1 0
      web-admin/src/graphify-out/cache/ast/b48cf81fa31d6d7352e6b487c2933ecb3e45353f20f448dc1ca73c6aa040764b.json
  54. 0 0
      web-admin/src/graphify-out/cache/ast/b67aea5bc8148641abb6fa1cf86814c25532ad61be0f43ea6443e3a9ab23b250.json
  55. 0 0
      web-admin/src/graphify-out/cache/ast/b825185b75e6c8179519ed68684aeefb1d72d1f4be43e0169f5822360108e7de.json
  56. 0 0
      web-admin/src/graphify-out/cache/ast/c6c955501448fd41e79ac5bacf657c707e701d77d6a45c377566704d3ec1e905.json
  57. 1 0
      web-admin/src/graphify-out/cache/ast/c7c4e6a1d55abacdef2f2ecba7d5edfe0573e734fd932d069749f2bb5c1a01e1.json
  58. 1 0
      web-admin/src/graphify-out/cache/ast/cf3f8a1921a52b4fee1c5bf7f2c5f9e04ad5c876d109ced2a8d307e4b33c664d.json
  59. 0 0
      web-admin/src/graphify-out/cache/ast/d02551ad5d611df49b964c47ce129b48820bd55f6cc2dc958eb23200a23174a1.json
  60. 0 0
      web-admin/src/graphify-out/cache/ast/d3b1f975b505e86709f8b456275dcf9c683bacd368209d4f2041abca9a5bf683.json
  61. 0 0
      web-admin/src/graphify-out/cache/ast/db117095b590d096f4da3c27fc36905c9675203b236b221c1b3bb9cdbc112e0d.json
  62. 1 0
      web-admin/src/graphify-out/cache/ast/e2fbdb069c4481bfaf0ae799b27ff557536bf3720c5021a9f17506c35a5a39fd.json
  63. 1 0
      web-admin/src/graphify-out/cache/ast/e332bff3e7a0452518a66cd960d035411dcf69839054f5d9689a34c431c4a6c2.json
  64. 1 0
      web-admin/src/graphify-out/cache/ast/e43a5865c29af34d873fe5ff8edb39bdd14e33f6d0017226c2e80cb3ee719130.json
  65. 0 0
      web-admin/src/graphify-out/cache/ast/e7297ed7b56b389095320c7925ead0810c5fff9eadadfc769c05e08533de321f.json
  66. 1 0
      web-admin/src/graphify-out/cache/ast/eb627b0782478cb51b42ae6724fde73135a82d15876b718b18c76412055b92a7.json
  67. 0 0
      web-admin/src/graphify-out/cache/ast/ecc04d7ad5442f72a2e121b7fca9b703cb926138b70973354b611bf3c8f2ef70.json
  68. 1 0
      web-admin/src/graphify-out/cache/ast/f0597e1ea48ee6cb63905f7807daff2eb5f9a049141e647864f5370c7b6223c1.json
  69. 1 0
      web-admin/src/graphify-out/cache/ast/f131dee6c12771a4ddee16965866d82cd543ad390d7cd442b0f4c174077f0674.json
  70. 0 0
      web-admin/src/graphify-out/cache/ast/fcb2d52ca1b0728687f15f39728a0b12acee8e403638ed19258d4870d6982155.json
  71. 73 0
      web-admin/src/utils/money-format.ts
  72. 4 2
      web-admin/src/views/basic-info/insurance-company/index.vue
  73. 4 11
      web-admin/src/views/cell-storage/fullmoon-todo/index.vue
  74. 6 1
      web-admin/src/views/cell-storage/payment-confirm/index.vue
  75. 6 13
      web-admin/src/views/cell-storage/payment-info/index.vue
  76. 15 23
      web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue
  77. 12 12
      web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue
  78. 6 1
      web-admin/src/views/cell-storage/refund-confirm/index.vue
  79. 1 8
      web-admin/src/views/cell-storage/storage-cert/index.vue
  80. 5 6
      web-admin/src/views/pilot-service/cell-product-price/index.vue
  81. 401 293
      web-admin/src/views/pilot-service/channel-service-order-finance/index.vue
  82. 1200 0
      web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue
  83. 94 0
      web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue
  84. 238 140
      web-admin/src/views/pilot-service/channel-service-order/index.vue
  85. 2 6
      web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue
  86. 10 21
      web-admin/src/views/pilot-service/channel/index.vue
  87. 5 6
      web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue
  88. 4 2
      web-admin/src/views/pilot-service/checkup-institution/index.vue
  89. 7 9
      web-admin/src/views/pilot-service/project-price-system/index.vue
  90. 2 7
      web-admin/src/views/production/cell-product/index.vue
  91. 2 7
      web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue
  92. 2 7
      web-admin/src/views/production/sop/index.vue
  93. 2 1
      web-admin/src/views/quality-inspection/qc-task/index.vue
  94. 3 2
      web-admin/src/views/quality-inspection/qc-work-order/index.vue
  95. 2 6
      web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue
  96. 4 2
      web-admin/src/views/quality-inspection/third-party-company/index.vue
  97. 2 14
      web-admin/src/views/research-donation/list/DonationFormDialog.vue
  98. 2 1
      web-admin/src/views/research-donation/list/DonationViewDialog.vue
  99. 1 6
      web-admin/src/views/system/category/index.vue
  100. 5 4
      web-admin/src/views/system/order/index.vue

+ 1 - 0
AGENTS.md

@@ -29,6 +29,7 @@ Full specs: `openspec/changes/zkxw-engineering-standards/specs/*/spec.md`. Below
 - **Tab bar (WorkTab) icons**: Tab bar tabs SHALL NOT show icons — only text titles. `meta.icon` is for sidebar only.
 - **Form field annotations**: Field hints (e.g. "数值越小越靠前") SHALL appear as small text after the label via `<template #label>字段名<span class="form-label-tip">注释</span></template>`, NOT beside the input control. Style class: `.form-label-tip` (12px, `--mint-text-muted`).
 - **Attachment upload card layout**: Upload trigger SHALL be a card (140×96px, dashed border `#a7f3d0`) inline as the first item in the same flex row as uploaded attachment cards; select file → immediate upload via `fetchUploadAttachment`; preview/download/delete on cards; hint below the row. Authority: `ProtocolFormDialog.vue` «协议附件». See `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` §「附件上传卡片化布局」for full Vue template + SCSS + interaction rules.
+- **Button icons**: All `web-admin` `el-button` with visible text labels SHALL include a leading `<el-icon>` from `@element-plus/icons-vue` (explicit import). See `openspec/.../ui-conventions/spec.md` §「按钮须携带图标」.
 - **Sort fields (sort_order/orderNum)**: web-admin 列表、新增弹窗、编辑弹窗中默认不展示排序字段。后续根据功能需要自行添加。
 - **Status/conclusion/type fields as Tags**: All status, conclusion, and type fields in table columns and view dialogs SHALL render as `<el-tag>` with semantic colors. Tables: use `<el-tag :type="..." size="small">` in column templates. SystemViewDialog: use `tagType` property on `ViewField`. Semantic colors: `success`=正常/启用/已完成/通过, `warning`=进行中/待确认/已过期, `danger`=异常/已作废/拒绝/不通过, `info`=待执行/待生效/停用/默认, `primary`=特殊分类/来源类型. SHALL NOT use plain text or custom `<span class="tag">`. See `openspec/.../ui-conventions/spec.md` "状态、结论与类型字段使用 Tag 标签展示".
 - **Template pages** — new pages SHALL match these exactly:

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-89592
+21868

+ 42 - 13
backend/app/api/pilot_service.py

@@ -1619,6 +1619,7 @@ class TxnRechargeBody(BaseModel):
 
     amount: float = Field(..., gt=0, alias="amount")
     remark: Optional[str] = Field(None, alias="remark")
+    finance_submit_attachments: Optional[str] = Field(None, alias="financeSubmitAttachments")
 
 
 class TxnRefundBody(BaseModel):
@@ -1654,6 +1655,14 @@ class TxnUnsubscribeBody(BaseModel):
     remark: Optional[str] = Field(None, alias="remark")
 
 
+class TxnUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    amount: Optional[float] = Field(None, gt=0, alias="amount")
+    remark: Optional[str] = Field(None, alias="remark")
+    finance_submit_attachments: Optional[str] = Field(None, alias="financeSubmitAttachments")
+
+
 class TxnFinanceSubmitBody(BaseModel):
     model_config = ConfigDict(populate_by_name=True)
 
@@ -1933,6 +1942,7 @@ async def list_service_order_txns(
     keyword: Optional[str] = None,
     createTimeStart: Optional[str] = Query(None, alias="createTimeStart"),
     createTimeEnd: Optional[str] = Query(None, alias="createTimeEnd"),
+    financeDoneOnly: Optional[bool] = Query(False, alias="financeDoneOnly"),
     user: dict = Depends(require_admin_or_super_admin),
     db: Session = Depends(get_db),
 ):
@@ -1945,6 +1955,7 @@ async def list_service_order_txns(
         keyword=keyword,
         create_time_start=createTimeStart,
         create_time_end=createTimeEnd,
+        finance_done_only=bool(financeDoneOnly),
         page=page,
         page_size=pageSize,
         tenant_no=tenant_no,
@@ -1966,17 +1977,7 @@ async def get_service_order_txn_detail(
         txn = ps_svc.get_service_order_txn(db, txn_id, tenant_no=tenant_no)
         if not txn:
             return error_response(404, "流水不存在")
-        txn_dict = ps_svc._row_to_dict_service_order_txn(txn, db, tenant_no)
-        order = ps_svc.get_service_order(db, txn.service_order_id, tenant_no=tenant_no)
-        if order:
-            txn_dict["channelName"] = order.channel_name or ""
-            txn_dict["orderType"] = order.order_type or ""
-        user_ids = [x for x in (txn.create_by, txn.finance_submit_by) if x]
-        name_map = ps_svc._user_nick_name_map(db, user_ids)
-        txn_dict["createByName"] = name_map.get(txn.create_by or "", "") or (txn.create_by or "")
-        txn_dict["financeSubmitByName"] = name_map.get(txn.finance_submit_by or "", "") or (
-            txn.finance_submit_by or ""
-        )
+        txn_dict = ps_svc._service_order_txn_to_response_dict(db, txn, tenant_no)
         return success_response(txn_dict)
     except ValueError as e:
         return error_response(400, str(e))
@@ -1996,6 +1997,7 @@ async def create_recharge_txn(
             order_id,
             amount=body.amount,
             remark=body.remark,
+            finance_submit_attachments=body.finance_submit_attachments,
             tenant_no=tenant_no,
             user_id=user.get("user_id"),
         )
@@ -2080,6 +2082,31 @@ async def create_unsubscribe_txn(
         return error_response(400, str(e))
 
 
+@router.put("/channel-service-order-txns/{txn_id}")
+async def update_service_order_txn(
+    txn_id: str,
+    body: TxnUpdateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        txn = ps_svc.update_service_order_txn(
+            db,
+            txn_id,
+            amount=body.amount,
+            remark=body.remark,
+            finance_submit_attachments=body.finance_submit_attachments,
+            tenant_no=tenant_no,
+            user_id=user.get("user_id"),
+        )
+        return success_response(
+            ps_svc._row_to_dict_service_order_txn(txn, db, tenant_no), message="更新成功"
+        )
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
 @router.post("/channel-service-order-txns/{txn_id}/submit-finance")
 async def submit_txn_finance(
     txn_id: str,
@@ -2122,7 +2149,8 @@ async def confirm_txn(
             user_id=user.get("user_id"),
         )
         return success_response(
-            ps_svc._row_to_dict_service_order_txn(txn, db, tenant_no), message="财务确认成功"
+            ps_svc._service_order_txn_to_response_dict(db, txn, tenant_no),
+            message="财务确认成功",
         )
     except ValueError as e:
         return error_response(400, str(e))
@@ -2143,7 +2171,8 @@ async def void_txn(
             user_id=user.get("user_id"),
         )
         return success_response(
-            ps_svc._row_to_dict_service_order_txn(txn, db, tenant_no), message="作废成功"
+            ps_svc._service_order_txn_to_response_dict(db, txn, tenant_no),
+            message="作废成功",
         )
     except ValueError as e:
         return error_response(400, str(e))

+ 226 - 25
backend/app/crud/pilot_service.py

@@ -2598,9 +2598,12 @@ def _row_to_dict_service_order_txn(row, db=None, tenant_no=None) -> Dict[str, An
         "financeSubmitAttachments": row.finance_submit_attachments or "",
         "financeSubmitAttachmentFiles": urls_to_attachment_list(row.finance_submit_attachments, db, tn) if db and row.finance_submit_attachments else [],
         "financeConfirmTime": row.finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.finance_confirm_time else "",
+        "financeConfirmBy": row.finance_confirm_by or "",
         "financeConfirmAttachments": row.finance_confirm_attachments or "",
         "financeConfirmAttachmentFiles": urls_to_attachment_list(row.finance_confirm_attachments, db, tn) if db and row.finance_confirm_attachments else [],
         "financeRemark": row.finance_remark or "",
+        "voidBy": row.void_by or "",
+        "voidTime": row.void_time.strftime("%Y-%m-%d %H:%M:%S") if row.void_time else "",
         "balanceBefore": float(row.balance_before) if row.balance_before else 0,
         "balanceAfter": float(row.balance_after) if row.balance_after else 0,
         "tenantNo": row.tenant_no or "",
@@ -2618,6 +2621,71 @@ def _row_to_dict_service_order_txn(row, db=None, tenant_no=None) -> Dict[str, An
     return d
 
 
+def _service_order_txn_to_response_dict(
+    db,
+    txn,
+    tenant_no=None,
+    *,
+    order=None,
+    name_map=None,
+) -> Dict[str, Any]:
+    """流水响应字典:含订单渠道信息与提交人/创建人昵称。"""
+    d = _row_to_dict_service_order_txn(txn, db, tenant_no)
+    if order is None and txn.service_order_id:
+        order = get_service_order(db, txn.service_order_id, tenant_no)
+    if order:
+        d["channelName"] = order.channel_name or ""
+        d["deptName"] = order.dept_name or ""
+        d["orderType"] = order.order_type or ""
+    confirm_uid = txn.finance_confirm_by or (
+        txn.update_by
+        if (txn.txn_status or "") == "yqr" and txn.finance_confirm_time and not txn.finance_confirm_by
+        else None
+    )
+    void_uid = txn.void_by or (
+        txn.update_by
+        if (txn.txn_status or "") == "yzf"
+        and txn.finance_submit_time
+        and not txn.void_by
+        else None
+    )
+    if name_map is None:
+        user_ids = [x for x in (txn.create_by, txn.finance_submit_by, confirm_uid, void_uid) if x]
+        name_map = _user_nick_name_map(db, user_ids) if db and user_ids else {}
+    d["createByName"] = name_map.get(txn.create_by or "", "") or (txn.create_by or "")
+    d["financeSubmitByName"] = name_map.get(txn.finance_submit_by or "", "") or (
+        txn.finance_submit_by or ""
+    )
+    d["financeConfirmByName"] = name_map.get(confirm_uid or "", "") or (confirm_uid or "")
+    if not d.get("voidTime") and (txn.txn_status or "") == "yzf" and txn.update_time:
+        d["voidTime"] = txn.update_time.strftime("%Y-%m-%d %H:%M:%S")
+    d["voidByName"] = name_map.get(void_uid or "", "") or (void_uid or "")
+    if not (d.get("financeSubmitRemark") or "").strip() and (d.get("remark") or "").strip():
+        d["financeSubmitRemark"] = (d.get("remark") or "").strip()
+    _enrich_finance_operate_fields(d, txn)
+    return d
+
+
+def _enrich_finance_operate_fields(d: Dict[str, Any], txn) -> None:
+    """财务页「已处理」展示:操作类型、操作人、操作时间。"""
+    status = txn.txn_status or ""
+    if status == "yqr" and txn.finance_confirm_time:
+        d["financeOperateType"] = "confirm"
+        d["financeOperateTypeLabel"] = "财务确认"
+        d["financeOperateByName"] = d.get("financeConfirmByName") or d.get("financeConfirmBy") or ""
+        d["financeOperateTime"] = d.get("financeConfirmTime") or ""
+    elif status == "yzf" and txn.finance_submit_time:
+        d["financeOperateType"] = "void"
+        d["financeOperateTypeLabel"] = "财务作废"
+        d["financeOperateByName"] = d.get("voidByName") or d.get("voidBy") or ""
+        d["financeOperateTime"] = d.get("voidTime") or ""
+    else:
+        d["financeOperateType"] = ""
+        d["financeOperateTypeLabel"] = ""
+        d["financeOperateByName"] = ""
+        d["financeOperateTime"] = ""
+
+
 def list_channel_service_orders(
     db,
     *,
@@ -2879,11 +2947,10 @@ def create_channel_service_order(
     order_status = "dsx"
 
     if order_type == "yfk":
-        # 预存款订单:创建后为待生效,市场发起财务确认后进入待确认
-        if not prepay_amount or float(prepay_amount) <= 0:
-            raise ValueError("预存金额必须大于0")
+        # 预存款订单:创建后为待生效,充值金额通过「发起充值」流水录入
         _get_valid_channel_agreement(db, channel_agreement_id, channel_id, tenant_no)
         order_status = "dsx"
+        prepay_amount = None
 
     elif order_type == "gmcp":
         # 购买产品订单:创建后为待生效,走财务确认后生效
@@ -2903,7 +2970,7 @@ def create_channel_service_order(
         channel_name=channel_name or channel.channel_name or "",
         dept_name=dept_name or "",
         order_type=order_type,
-        prepay_amount=prepay_amount if order_type == "yfk" else None,
+        prepay_amount=None,
         purchase_total_amount=purchase_total_amount,
         order_status=order_status,
         channel_agreement_id=channel_agreement_id,
@@ -2932,7 +2999,7 @@ def create_channel_service_order(
 
 
 def update_channel_service_order(db, order_id, *, tenant_no=None, user_id=None, products=None, **kwargs):
-    """待生效状态下可更新;预存款可改协议与充值金额,购买订单可改协议与产品明细"""
+    """待生效状态下可更新;预存款可改协议,购买订单可改协议与产品明细"""
     order = get_service_order(db, order_id, tenant_no)
     if not order:
         raise ValueError("订单不存在")
@@ -2943,11 +3010,6 @@ def update_channel_service_order(db, order_id, *, tenant_no=None, user_id=None,
     if "remark" in kwargs and kwargs["remark"] is not None:
         order.remark = kwargs["remark"]
     if order.order_type == "yfk":
-        if "prepay_amount" in kwargs and kwargs["prepay_amount"] is not None:
-            amt = float(kwargs["prepay_amount"])
-            if amt <= 0:
-                raise ValueError("预存金额必须大于0")
-            order.prepay_amount = amt
         if "channel_agreement_id" in kwargs and kwargs["channel_agreement_id"] is not None:
             _get_valid_channel_agreement(
                 db, kwargs["channel_agreement_id"], order.channel_id, tenant_no
@@ -3185,21 +3247,33 @@ def list_service_order_txns(
     keyword=None,
     create_time_start=None,
     create_time_end=None,
+    finance_done_only=False,
     page=1,
     page_size=20,
     tenant_no=None,
 ):
-    """查询操作流水列表"""
+    """查询操作流水列表。finance_done_only:财务已处理(已确认或财务侧作废)。"""
     q = db.query(BizChannelServiceOrderTxn).filter(BizChannelServiceOrderTxn.del_flag == 0)
     if tenant_no:
         q = q.filter(BizChannelServiceOrderTxn.tenant_no == tenant_no)
     if service_order_id:
         q = q.filter(BizChannelServiceOrderTxn.service_order_id == service_order_id)
-    ss = (txn_status or "").strip()
-    if ss:
-        statuses = [s.strip() for s in ss.split(",") if s.strip()]
-        if statuses:
-            q = q.filter(BizChannelServiceOrderTxn.txn_status.in_(statuses))
+    if finance_done_only:
+        q = q.filter(
+            or_(
+                BizChannelServiceOrderTxn.txn_status == "yqr",
+                and_(
+                    BizChannelServiceOrderTxn.txn_status == "yzf",
+                    BizChannelServiceOrderTxn.finance_submit_time.isnot(None),
+                ),
+            )
+        )
+    else:
+        ss = (txn_status or "").strip()
+        if ss:
+            statuses = [s.strip() for s in ss.split(",") if s.strip()]
+            if statuses:
+                q = q.filter(BizChannelServiceOrderTxn.txn_status.in_(statuses))
     tt = (txn_type or "").strip()
     if tt:
         types = [t.strip() for t in tt.split(",") if t.strip()]
@@ -3234,8 +3308,66 @@ def list_service_order_txns(
     total = q.order_by(None).with_entities(func.count(BizChannelServiceOrderTxn.id)).scalar() or 0
     page = max(1, page)
     page_size = min(max(1, page_size), 100)
-    rows = q.order_by(BizChannelServiceOrderTxn.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
-    return [_row_to_dict_service_order_txn(r, db, tenant_no) for r in rows], int(total)
+    if finance_done_only:
+        operate_time = func.coalesce(
+            BizChannelServiceOrderTxn.finance_confirm_time,
+            BizChannelServiceOrderTxn.void_time,
+        )
+        rows = (
+            q.order_by(operate_time.desc(), BizChannelServiceOrderTxn.create_time.desc())
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+    else:
+        rows = (
+            q.order_by(BizChannelServiceOrderTxn.create_time.desc())
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+    if not rows:
+        return [], int(total)
+
+    order_ids = list({r.service_order_id for r in rows if r.service_order_id})
+    orders: Dict[str, Any] = {}
+    if order_ids:
+        oq = db.query(BizChannelServiceOrder).filter(
+            BizChannelServiceOrder.id.in_(order_ids),
+            BizChannelServiceOrder.del_flag == 0,
+        )
+        if tenant_no:
+            oq = oq.filter(BizChannelServiceOrder.tenant_no == tenant_no)
+        for o in oq.all():
+            orders[o.id] = o
+
+    user_ids: List[str] = []
+    for r in rows:
+        if r.create_by:
+            user_ids.append(r.create_by)
+        if r.finance_submit_by:
+            user_ids.append(r.finance_submit_by)
+        if r.finance_confirm_by:
+            user_ids.append(r.finance_confirm_by)
+        elif (r.txn_status or "") == "yqr" and r.update_by:
+            user_ids.append(r.update_by)
+        if r.void_by:
+            user_ids.append(r.void_by)
+        elif (r.txn_status or "") == "yzf" and r.finance_submit_time and r.update_by:
+            user_ids.append(r.update_by)
+    name_map = _user_nick_name_map(db, user_ids) if db and user_ids else {}
+
+    items = [
+        _service_order_txn_to_response_dict(
+            db,
+            r,
+            tenant_no,
+            order=orders.get(r.service_order_id),
+            name_map=name_map,
+        )
+        for r in rows
+    ]
+    return items, int(total)
 
 
 def get_service_order_txn(db, txn_id, *, tenant_no=None):
@@ -3248,8 +3380,17 @@ def get_service_order_txn(db, txn_id, *, tenant_no=None):
     return q.first()
 
 
-def create_recharge_txn(db, order_id, *, amount, remark=None, tenant_no=None, user_id=None):
-    """创建充值流水(yfk订单),状态 dsx"""
+def create_recharge_txn(
+    db,
+    order_id,
+    *,
+    amount,
+    remark=None,
+    finance_submit_attachments=None,
+    tenant_no=None,
+    user_id=None,
+):
+    """创建充值流水(yfk订单),状态 dsx;附件预存于 finance_submit_attachments 供后续财务确认"""
     order = get_service_order(db, order_id, tenant_no)
     if not order:
         raise ValueError("订单不存在")
@@ -3270,6 +3411,7 @@ def create_recharge_txn(db, order_id, *, amount, remark=None, tenant_no=None, us
         amount=amount,
         txn_status="dsx",
         remark=remark or "",
+        finance_submit_attachments=(finance_submit_attachments or "").strip() or None,
         tenant_no=tenant_no or order.tenant_no,
         order_num=0,
         create_by=user_id,
@@ -3497,6 +3639,56 @@ def create_unsubscribe_txn(db, order_id, *, products, remark=None, tenant_no=Non
     return txn
 
 
+def update_service_order_txn(
+    db,
+    txn_id,
+    *,
+    amount=None,
+    remark=None,
+    finance_submit_attachments=None,
+    tenant_no=None,
+    user_id=None,
+):
+    """更新待生效流水:仅 dsx;充值/退款可改金额,购买/退订仅可改备注与凭证"""
+    txn = get_service_order_txn(db, txn_id, tenant_no=tenant_no)
+    if not txn:
+        raise ValueError("流水不存在")
+    if txn.txn_status != "dsx":
+        raise ValueError("仅待生效流水可编辑")
+
+    now = datetime.now()
+    txn_type = (txn.txn_type or "").strip()
+
+    if amount is not None:
+        amt = float(amount)
+        if amt <= 0:
+            raise ValueError("金额必须大于0")
+        if txn_type in ("recharge", "refund"):
+            if txn_type == "refund":
+                order = get_service_order(db, txn.service_order_id, tenant_no)
+                if not order:
+                    raise ValueError("关联订单不存在")
+                current_balance = float(order.current_balance) if order.current_balance else 0
+                if amt > current_balance:
+                    raise ValueError(f"退款金额不能大于当前余额 {current_balance}")
+            txn.amount = amt
+        else:
+            raise ValueError("购买/退订流水不可修改金额,请作废后重新发起")
+
+    if remark is not None:
+        txn.remark = remark or ""
+
+    if finance_submit_attachments is not None:
+        txn.finance_submit_attachments = (finance_submit_attachments or "").strip() or None
+
+    txn.update_by = user_id
+    txn.update_time = now
+    db.add(txn)
+    db.commit()
+    db.refresh(txn)
+    return txn
+
+
 def submit_txn_finance(db, txn_id, *, finance_submit_remark=None, finance_submit_attachments=None, tenant_no=None, user_id=None):
     """发起流水财务确认:dsx → dqr"""
     txn = get_service_order_txn(db, txn_id, tenant_no=tenant_no)
@@ -3509,8 +3701,14 @@ def submit_txn_finance(db, txn_id, *, finance_submit_remark=None, finance_submit
     txn.txn_status = "dqr"
     txn.finance_submit_by = user_id
     txn.finance_submit_time = now
-    txn.finance_submit_remark = finance_submit_remark or ""
-    txn.finance_submit_attachments = finance_submit_attachments or ""
+    submit_remark = (finance_submit_remark or "").strip()
+    if not submit_remark:
+        submit_remark = (txn.remark or "").strip()
+    txn.finance_submit_remark = submit_remark
+    if finance_submit_attachments:
+        txn.finance_submit_attachments = finance_submit_attachments
+    elif not (txn.finance_submit_attachments or "").strip():
+        txn.finance_submit_attachments = ""
     txn.update_by = user_id
     txn.update_time = now
     db.add(txn)
@@ -3615,6 +3813,7 @@ def confirm_txn(db, txn_id, *, finance_remark=None, finance_confirm_attachments=
     # Update txn status
     txn.txn_status = "yqr"
     txn.finance_confirm_time = now
+    txn.finance_confirm_by = user_id
     txn.finance_confirm_attachments = finance_confirm_attachments or ""
     txn.finance_remark = finance_remark or ""
     txn.update_by = user_id
@@ -3634,12 +3833,12 @@ def confirm_txn(db, txn_id, *, finance_remark=None, finance_confirm_attachments=
 
 
 def void_txn(db, txn_id, *, tenant_no=None, user_id=None):
-    """作废流水:dsx → yzf。如果是 purchase/unsubscribe 还需要回滚产品持仓"""
+    """作废流水:dsx/dqr → yzf。如果是 purchase/unsubscribe 还需要回滚产品持仓"""
     txn = get_service_order_txn(db, txn_id, tenant_no=tenant_no)
     if not txn:
         raise ValueError("流水不存在")
-    if txn.txn_status != "dsx":
-        raise ValueError("仅待生效流水可作废")
+    if txn.txn_status not in ("dsx", "dqr"):
+        raise ValueError("仅待生效或待财务确认流水可作废")
 
     now = datetime.now()
     order = get_service_order(db, txn.service_order_id, tenant_no)
@@ -3692,6 +3891,8 @@ def void_txn(db, txn_id, *, tenant_no=None, user_id=None):
                 db.add(existing)
 
     txn.txn_status = "yzf"
+    txn.void_by = user_id
+    txn.void_time = now
     txn.update_by = user_id
     txn.update_time = now
     db.add(txn)

+ 41 - 3
backend/app/main.py

@@ -1,9 +1,12 @@
 """FastAPI 应用入口(骨架工程)"""
 from contextlib import asynccontextmanager
 import logging
+from datetime import datetime
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from sqlalchemy.exc import SQLAlchemyError
 
 from app.core.config import settings
 from app.core.database import SessionLocal
@@ -58,6 +61,43 @@ app.add_middleware(CORSMiddleware, **_cors_kw)
 app.add_middleware(ApiRequestLogMiddleware)
 app.add_middleware(ApiCryptoMiddleware)
 
+logger = logging.getLogger("sqxp")
+
+
+def _error_json(status_code: int, code: int, message: str) -> JSONResponse:
+    return JSONResponse(
+        status_code=status_code,
+        content={
+            "code": code,
+            "message": message,
+            "data": None,
+            "timestamp": int(datetime.now().timestamp() * 1000),
+        },
+    )
+
+
+@app.exception_handler(SQLAlchemyError)
+async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
+    """数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误"""
+    logger.exception("Database error on %s %s", request.method, request.url.path)
+    if settings.DEBUG:
+        message = f"数据库不可用:{exc.orig if getattr(exc, 'orig', None) else exc}"
+    else:
+        message = "数据库不可用,请确认 MySQL 已启动且连接配置正确"
+    return _error_json(503, 503, message)
+
+
+@app.exception_handler(Exception)
+async def unhandled_exception_handler(request: Request, exc: Exception):
+    """未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin"""
+    logger.exception("Unhandled error on %s %s", request.method, request.url.path)
+    if settings.DEBUG:
+        message = str(exc) or "服务器内部错误"
+    else:
+        message = "服务器内部错误,请稍后重试"
+    return _error_json(500, 500, message)
+
+
 app.include_router(public_router, prefix=settings.API_V1_PREFIX)
 app.include_router(menu_router, prefix=settings.API_V1_PREFIX)
 app.include_router(admin_router, prefix=settings.API_V1_PREFIX)
@@ -83,8 +123,6 @@ app.include_router(pilot_delivery_router, prefix=settings.API_V1_PREFIX)
 app.include_router(pilot_infusion_confirm_router, prefix=settings.API_V1_PREFIX)
 app.include_router(outbound_release_review_router, prefix=settings.API_V1_PREFIX)
 
-logger = logging.getLogger("sqxp")
-
 
 def _mask_secret(value: str) -> str:
     if not value:

+ 3 - 0
backend/app/models/models.py

@@ -1850,8 +1850,11 @@ class BizChannelServiceOrderTxn(Base):
     finance_submit_remark = Column(String(512), nullable=True, comment="发起确认备注")
     finance_submit_attachments = Column(Text, nullable=True, comment="发起确认附件")
     finance_confirm_time = Column(DateTime, nullable=True, comment="财务确认时间")
+    finance_confirm_by = Column(String(64), nullable=True, comment="财务确认人ID")
     finance_confirm_attachments = Column(Text, nullable=True, comment="财务确认附件")
     finance_remark = Column(String(512), nullable=True, comment="财务确认备注")
+    void_by = Column(String(64), nullable=True, comment="作废人ID")
+    void_time = Column(DateTime, nullable=True, comment="作废时间")
     balance_before = Column(Numeric(18, 2), nullable=True, comment="操作前余额")
     balance_after = Column(Numeric(18, 2), nullable=True, comment="操作后余额")
     tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")

+ 15 - 0
docs/sql/2026-05-25-channel-service-order-txn-void-fields.sql

@@ -0,0 +1,15 @@
+-- 渠道服务订单操作流水:财务确认人、作废人、作废时间
+ALTER TABLE `biz_channel_service_order_txn`
+  ADD COLUMN `finance_confirm_by` varchar(64) DEFAULT NULL COMMENT '财务确认人ID' AFTER `finance_confirm_time`,
+  ADD COLUMN `void_by` varchar(64) DEFAULT NULL COMMENT '作废人ID' AFTER `finance_remark`,
+  ADD COLUMN `void_time` datetime DEFAULT NULL COMMENT '作废时间' AFTER `void_by`;
+
+-- 历史已确认流水:用 update_by 回填确认人
+UPDATE `biz_channel_service_order_txn`
+SET `finance_confirm_by` = `update_by`
+WHERE `txn_status` = 'yqr' AND `finance_confirm_time` IS NOT NULL AND `finance_confirm_by` IS NULL;
+
+-- 历史已作废流水:用 update_by / update_time 回填(仅曾提交财务的流水视为财务作废)
+UPDATE `biz_channel_service_order_txn`
+SET `void_by` = `update_by`, `void_time` = `update_time`
+WHERE `txn_status` = 'yzf' AND `finance_submit_time` IS NOT NULL AND `void_by` IS NULL;

+ 3 - 0
docs/sql/zkxw.sql

@@ -1035,8 +1035,11 @@ CREATE TABLE `biz_channel_service_order_txn` (
   `finance_submit_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '发起确认备注',
   `finance_submit_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '发起确认附件',
   `finance_confirm_time` datetime DEFAULT NULL COMMENT '财务确认时间',
+  `finance_confirm_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '财务确认人ID',
   `finance_confirm_attachments` text COLLATE utf8mb4_unicode_ci COMMENT '财务确认附件',
   `finance_remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '财务确认备注',
+  `void_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '作废人ID',
+  `void_time` datetime DEFAULT NULL COMMENT '作废时间',
   `balance_before` decimal(18,2) DEFAULT NULL COMMENT '操作前余额(yfk类型)',
   `balance_after` decimal(18,2) DEFAULT NULL COMMENT '操作后余额(yfk类型)',
   `tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',

+ 176 - 0
docs/superpowers/plans/2026-05-25-money-input-thousand-separator.md

@@ -0,0 +1,176 @@
+# 金额/单价千分位输入与展示 — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在 web-admin 统一金额/单价的「聚焦纯数字、失焦千分位、提交数值」交互,并统一只读列表/详情的千分位展示。
+
+**Architecture:** 抽取 `money-format.ts` 纯函数 + `AmountInput` 封装 `el-input`;业务页替换金额类 `el-input-number` 与局部重复逻辑;只读处使用 `formatMoneyDisplay`。
+
+**Tech Stack:** Vue 3、Element Plus、TypeScript、现有 Prettier/ESLint 规范。
+
+**Design spec:** `docs/superpowers/specs/2026-05-25-money-input-thousand-separator-design.md`
+
+---
+
+## File map
+
+| 操作 | 路径 |
+|------|------|
+| Create | `web-admin/src/utils/money-format.ts` |
+| Create | `web-admin/src/components/common/amount-input/index.vue` |
+| Modify | `web-admin/src/views/pilot-service/channel-service-order/index.vue` |
+| Modify | `web-admin/src/views/pilot-service/channel-service-order-finance/index.vue` |
+| Modify | `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` |
+| Later P1+ | `channel/index.vue`, `cell-product-price/index.vue`, `project-price-system/index.vue`, `ProtocolFormDialog.vue`, `payment-info/index.vue`, … |
+
+---
+
+### Task 1: 金额格式化工具
+
+**Files:**
+- Create: `web-admin/src/utils/money-format.ts`
+
+- [ ] **Step 1: 实现三个导出函数**
+
+```typescript
+export function addThousandSeparators(intStr: string): string { /* 字符串分段,无前瞻正则 */ }
+
+export function sanitizeMoneyInput(value: string, precision = 2): string { /* 去逗号、单小数点、截断小数 */ }
+
+export function formatMoneyDisplay(value: string | number | null | undefined, precision = 2): string
+
+export function parseMoneyToNumber(value: string, precision = 2): number | null
+```
+
+- [ ] **Step 2: 本地快速验证**
+
+在 Node 或浏览器控制台执行:
+
+```javascript
+formatMoneyDisplay(1234567.89) // '1,234,567.89'
+parseMoneyToNumber('1,234.56') // 1234.56
+```
+
+---
+
+### Task 2: AmountInput 组件
+
+**Files:**
+- Create: `web-admin/src/components/common/amount-input/index.vue`
+
+- [ ] **Step 1: 实现组件**
+
+- `defineModel<number | null>()` 或 `modelValue` + `emit('update:modelValue')`
+- 内部 `displayText`,`watch(modelValue)` 在非聚焦时同步为 `formatMoneyDisplay`
+- `@focus` → sanitize 展示
+- `@input` → `sanitizeMoneyInput` 写回 `displayText`
+- `@blur` → `parseMoneyToNumber` → emit;`displayText = formatMoneyDisplay`
+- Props: `min`, `max`, `precision`, `placeholder`, `disabled`, `clearable`
+
+- [ ] **Step 2: 在渠道服务订单页面临时引用验证**
+
+仅 import 一个弹窗字段 smoke test(Task 3 会全量替换)。
+
+---
+
+### Task 3: 渠道服务订单页(P0)
+
+**Files:**
+- Modify: `web-admin/src/views/pilot-service/channel-service-order/index.vue`
+
+- [ ] **Step 1: 删除页面内重复函数**
+
+移除 `addThousandSeparators`、`formatRechargeAmountDisplay`、`sanitizeRechargeAmountInput`、`parseRechargeAmountInput`、`parseAmountInputToNumber`(若仅用于充值)、`rechargeAmountInput` 及相关 handler。
+
+- [ ] **Step 2: 替换可编辑金额**
+
+| 位置 | 替换为 |
+|------|--------|
+| 发起充值 | `<AmountInput v-model="rechargeForm.amount" :min="0.01" />` |
+| 申请退款 | `<AmountInput v-model="refundTxnForm.amount" :min="0.01" :max="currentBalance" />` |
+| 新增/编辑 产品明细单价 | `<AmountInput v-model="row.unitPrice" :min="0" @change="calcItemAmount(row)" />` |
+
+- [ ] **Step 3: 只读展示**
+
+将列表/查看/合计中的 `formatAmount`(金额类)改为 `formatMoneyDisplay`,或:
+
+```typescript
+const formatAmount = (v: any) => formatMoneyDisplay(v, 2)
+```
+
+仅当该文件内 `formatAmount` 全部用于金额时方可整文件替换。
+
+- [ ] **Step 4: 手工验收**
+
+按 spec 第 6 节 5 条检查;提交充值/退款 payload 为 number。
+
+---
+
+### Task 4: 财务确认页只读列(P1 片段)
+
+**Files:**
+- Modify: `web-admin/src/views/pilot-service/channel-service-order-finance/index.vue`
+
+- [ ] **Step 1: `formatAmount` → `formatMoneyDisplay`**
+
+列表金额、详情单价/金额、确认弹窗中的金额文案。
+
+---
+
+### Task 5: UI 规范 OpenSpec
+
+**Files:**
+- Modify: `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md`
+
+- [ ] **Step 1: 新增 Requirement「金额与单价千分位」**
+
+SHALL 行为表 + `AmountInput` + `formatMoneyDisplay` 路径说明。
+
+---
+
+### Task 6: P1 中试服务其余页(可并行子任务)
+
+**Files:**
+- Modify: `web-admin/src/views/pilot-service/channel/index.vue`(协议价行内)
+- Modify: `web-admin/src/views/pilot-service/cell-product-price/index.vue`
+- Modify: `web-admin/src/views/pilot-service/project-price-system/index.vue`
+- Modify: `web-admin/src/views/pilot-service/checkup-institution/index.vue`、`CheckupPackagePanel.vue`
+
+每文件重复 Task 3 的替换模式:金额类 `el-input-number` → `AmountInput`;只读 → `formatMoneyDisplay`。
+
+---
+
+### Task 7: P2 细胞存储(后续迭代)
+
+**Files:**
+- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`
+- Modify: `web-admin/src/views/cell-storage/payment-info/index.vue`
+- 只读:`ProtocolViewDialog.vue`、payment/refund confirm 列表
+
+---
+
+### Task 8: 图谱更新(Agent 规则)
+
+- [ ] **Step 1:** `./scripts/update-graph.sh frontend`(完成 P0 后)
+
+---
+
+## Self-review(plan ↔ spec)
+
+| Spec 要求 | Task |
+|-----------|------|
+| 聚焦/失焦/提交行为 | Task 1–2 |
+| 只读千分位 | Task 3–4, 6–7 |
+| 禁止页面内复制正则 | Task 3 删除重复 |
+| 数量字段排除 | 各 Task 仅改单价/金额 |
+| OpenSpec | Task 5 |
+| 分期 P0–P3 | Task 3–7 |
+
+## 执行选项
+
+Plan 已保存至 `docs/superpowers/plans/2026-05-25-money-input-thousand-separator.md`。
+
+1. **Subagent-Driven** — 按 Task 派发子代理,逐 Task 评审  
+2. **Inline Execution** — 当前会话按 Task 1→3 连续实现(建议先 P0)
+
+请告知:是否批准设计 spec,以及希望先执行 **P0(工具 + 组件 + 渠道服务订单)** 还是全量分期推进。

+ 189 - 0
docs/superpowers/specs/2026-05-25-money-input-thousand-separator-design.md

@@ -0,0 +1,189 @@
+# Web 管理端金额/单价千分位输入与展示 — 设计说明
+
+## 背景
+
+业务人员在录入或核对大额「单价」「金额」时,需要千分位逗号辅助阅读,但输入过程中插入逗号会干扰光标与连续键入。渠道服务订单「发起充值」已在 `channel-service-order/index.vue` 用页面内局部函数实现「聚焦纯数字、失焦千分位」,尚未复用到其他页面。
+
+本设计将交互固化为**统一工具 + 统一组件**,并明确**可编辑输入**与**只读展示**两类场景,避免再次在 `.vue` 中复制正则或误用前瞻表达式导致编译错误。
+
+## 目标行为(SHALL)
+
+| 时机 | 可编辑输入框 | 列表/查看等只读展示 |
+|------|----------------|---------------------|
+| 聚焦输入中 | 纯数字,如 `1234567.89`(无逗号) | — |
+| 失焦后 | 千分位,如 `1,234,567.89` | 始终千分位(同上格式) |
+| 再次聚焦 | 去掉逗号,恢复纯数字 | — |
+| 提交/API | `number` 或约定精度的小数,**不受逗号影响** | — |
+
+**不在范围:** 数量、份数、排序号、百分比系数等非金额字段(仍用 `el-input-number` 整数或原控件)。
+
+## 范围
+
+### 纳入(单价 / 金额类)
+
+**中试服务(pilot-service)**
+
+| 页面 | 路由 | 可编辑 | 只读展示 |
+|------|------|--------|----------|
+| 渠道服务购买订单 | `/#/pilot-service/channel-service-order` | 充值金额、退款金额、产品明细单价;发起充值已改 | 列表余额/购买总额、查看流水与持仓、合计 |
+| 渠道服务订单财务确认 | `/#/pilot-service/channel-service-order-finance` | — | 列表/详情金额、单价 |
+| 渠道信息管理 | `/#/pilot-service/channel` | 协议价(元)行内编辑 | 价格选择器单价列 |
+| 细胞产品价格体系 | `/#/pilot-service/cell-product-price` | 表单单价 | 列表单价 |
+| 细胞产品项目价格 | `/#/pilot-service/project-price-system` | 表格单价 | 查看单价 |
+| 体检机构及项目 | `/#/pilot-service/checkup-institution` | 项目费用等 | 套餐合计费用 |
+
+**细胞存储(cell-storage)**
+
+| 页面 | 路由 | 可编辑 | 只读展示 |
+|------|------|--------|----------|
+| 采集协议单录入 | `/#/cell-storage/protocol` | 合同金额、应缴费、转账金额、采集金额等 | 查看弹窗同字段 |
+| 缴费信息 | `/#/cell-storage/payment-info` | 退费金额 | 应缴/实缴列 |
+| 缴费/退费确认(财务) | `/#/cell-storage/payment-confirm`、`refund-confirm` | — | 金额列 |
+
+**其他(第二批)**
+
+| 页面 | 说明 |
+|------|------|
+| 订单 Demo `system/order/index.vue` | 供货价/售价及金额(若仍为可编辑控件则纳入) |
+| 研究捐赠 `DonationFormDialog` | 采集金额等 |
+| 第三方检测公司等 | `feeAmount` 等 `:precision="2"` 的金额 |
+
+### 明确排除
+
+- 小程序端 `miniprogram/`
+- 后端 API(仍接收 JSON number)
+- 非金额 `el-input-number`(数量、排序、`priceCoefficient` 百分比等)
+
+## 方案对比
+
+### 方案 A:每页复制 focus/blur 函数(现状扩展)
+
+- 优点:无新依赖,单页改动快  
+- 缺点:已出现正则笔误导致整页无法加载;难维护;`formatAmount` 与输入逻辑不一致  
+
+### 方案 B:工具函数 + `AmountInput` 组件(推荐)
+
+- `@/utils/money-format.ts`:`sanitizeMoneyInput`、`formatMoneyDisplay`、`parseMoneyToNumber`  
+- `@/components/common/amount-input/index.vue`:`v-model` 绑定 `number | null`,封装 focus/blur/input  
+- 只读:`formatMoneyDisplay(n)` 或增强现有列表 `formatAmount` 的展示路径  
+
+- 优点:单测友好、符合 Fresh Mint 组件化、表格/弹窗同一套  
+- 缺点:需逐页替换 `el-input-number`(金额类)  
+
+### 方案 C:全局指令 `v-money-input`
+
+- 优点:模板改动小  
+- 缺点:难支持 `min`/`max`/`precision`、表格行绑定不清晰、与 Element Plus 集成弱  
+
+**结论:采用方案 B。**
+
+## 技术设计
+
+### 1. 工具模块 `web-admin/src/utils/money-format.ts`
+
+```typescript
+// 展示:整数千分位 + 最多 precision 位小数(默认 2)
+export function formatMoneyDisplay(value: string | number | null | undefined, precision?: number): string
+
+// 输入清洗:去逗号、仅保留数字与一个点
+export function sanitizeMoneyInput(value: string, precision?: number): string
+
+// 提交:number | null;空串 / 仅 '.' → null
+export function parseMoneyToNumber(value: string, precision?: number): number | null
+```
+
+实现要点:
+
+- 千分位用**字符串分段拼接**(`addThousandSeparators`),**禁止**在 `.vue` 内联复杂前瞻正则。  
+- 小数位截断与 `el-input-number` 的 `:precision="2"` 行为对齐。
+
+### 2. 组件 `AmountInput`
+
+路径:`web-admin/src/components/common/amount-input/index.vue`
+
+| Prop | 类型 | 默认 | 说明 |
+|------|------|------|------|
+| `modelValue` | `number \| null` | — | 双向绑定数值 |
+| `min` | `number` | — | 可选下限 |
+| `max` | `number` | — | 可选上限 |
+| `precision` | `number` | `2` | 小数位数;单价/金额均为 2 |
+| `placeholder` | `string` | — | |
+| `disabled` | `boolean` | `false` | |
+| `clearable` | `boolean` | `true` | |
+
+事件:`update:modelValue`、`change`(失焦且数值变化时,便于触发行内 `calcAmount`)
+
+内部状态:
+
+- `displayText: ref<string>` — 绑定 `el-input`  
+- `@focus` → `displayText = sanitize(modelValue)`  
+- `@input` → 清洗 + 临时更新 `displayText`;可选同步 `modelValue` 为中间值  
+- `@blur` → `emit(update:modelValue, parseMoneyToNumber(...))`;`displayText = formatMoneyDisplay(...)`  
+
+校验:`min`/`max` 在 blur 或 submit 时由父级 `el-form` rules 负责;组件可在 blur 时 `ElMessage.warning` 可选(默认不弹,避免表格内刷屏)。
+
+样式:宽度 `100%`;与现有 `el-input` 高度 36px 规范一致。
+
+### 3. 只读展示
+
+- 列表列、查看弹窗、合计文案:统一 `formatMoneyDisplay(v)`,前缀 `¥` 仍在模板侧拼接(与现网 `¥{{ formatAmount(...) }}` 一致)。  
+- **迁移策略:** 在 `money-format.ts` 导出 `formatMoneyDisplay`;各页逐步将只读 `formatAmount` 替换为 `formatMoneyDisplay`(或全局别名 `formatAmount = formatMoneyDisplay` 仅用于金额列——需逐文件审查,避免误改非金额 toFixed)。
+
+建议新增 `formatMoneyAmount(v)` 专用于金额展示,保留原 `formatAmount` 为两位小数无逗号直至迁移完成,**P0 页面直接使用 `formatMoneyDisplay`**。
+
+### 4. 与 OpenSpec / UI 规范
+
+在 `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md`(或归档后 `openspec/specs/ui-conventions/spec.md`)增补一节:
+
+- 金额/单价**可编辑**字段 SHALL 使用 `AmountInput`(或经评审的等价封装),SHALL 满足聚焦/失焦/提交行为表。  
+- 金额/单价**只读**展示 SHALL 使用 `formatMoneyDisplay`,整数部分千分位、小数位默认 2 位。  
+- SHALL NOT 在业务 `.vue` 中复制千分位正则实现。
+
+### 5. 渠道服务订单页迁移(含已改充值)
+
+- 删除页面内 `addThousandSeparators`、`formatRechargeAmountDisplay` 等重复函数,改为 import 工具 + `AmountInput`。  
+- 「发起充值」「申请退款」弹窗金额 → `AmountInput`。  
+- 新增/编辑订单产品明细「单价」列 → `AmountInput`(`@change` 仍调 `calcItemAmount`)。
+
+### 6. 测试与验收
+
+**单元(建议)** `web-admin/src/utils/money-format.spec.ts` 或 vitest:
+
+- `formatMoneyDisplay(1234567.89)` → `1,234,567.89`  
+- `sanitizeMoneyInput('1,234.5')` → `1234.5`  
+- `parseMoneyToNumber('1,234.56')` → `1234.56`  
+
+**手工验收(每批至少 1 页)**
+
+1. 聚焦输入 `1000000.5`,框内无逗号  
+2. 失焦显示 `1,000,000.5`  
+3. 再聚焦变回 `1000000.5`  
+4. 保存后网络请求 payload 为数字 `1000000.5`  
+5. 列表只读列为千分位  
+
+## 分期交付
+
+| 阶段 | 内容 | 产出 |
+|------|------|------|
+| P0 | 工具 + 组件 + 规范 + 渠道服务订单全页 | 可复用基座 |
+| P1 | 渠道信息、细胞价格、项目价格、财务确认页只读列 | 中试服务闭环 |
+| P2 | 细胞存储协议/缴费/退费 | 存储线金额 |
+| P3 | Demo 订单、捐赠、第三方费用等 | 余量 |
+
+## 风险与约束
+
+- **表格行内多个 `AmountInput`**:须 `row-key` 稳定,避免 Vue 复用 DOM 导致显示串行。  
+- **`el-form` 校验**:rules 仍针对 `number` 字段,blur 后须已 `emit` 最新值。  
+- **min/max**:退款金额等保留 `max=currentBalance`,在 `AmountInput` 上透传并在 blur 校验。  
+- **不要**对 `quantity` 等整数字段使用本组件。
+
+## 非目标(YAGNI)
+
+- 货币符号内置于组件(仍由模板加 `¥`)  
+- 多币种、汇率  
+- 输入法全角数字自动转换(可后续加)
+
+---
+
+**状态:** 待评审  
+**关联实现:** `docs/superpowers/plans/2026-05-25-money-input-thousand-separator.md`

+ 47 - 1
openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md

@@ -544,7 +544,7 @@ watchDebounced(
 - **THEN** SHALL 拒绝 `picture-card`、`show-file-list` 默认列表、与规范不一致的尺寸/色值
 ### Requirement: 按钮须携带图标
 
-`web-admin` 中所有带文字标签的 `el-button`(或 `ElButton`)SHALL 在文字前方包含 `<el-icon>` 子元素,使用 `@element-plus/icons-vue` 提供的图标组件;SHALL NOT 出现仅有文字而无图标的按钮。图标的引入方式为在文件 `<script setup>` 中显式 `import` 所需图标组件(`@element-plus/icons-vue` 不属于 auto-import 范围)。
+`web-admin` 中所有带文字标签的 `el-button`(或 `ElButton`)SHALL 在文字**前方**包含 `<el-icon>` 子元素,使用 `@element-plus/icons-vue` 提供的图标组件;SHALL NOT 出现仅有文字而无图标的按钮。该规则适用于列表工具栏、表格操作列、`el-dialog` / 抽屉的 `#footer` 底栏、表单内 `link` / `plain` / `size="small"` 等**凡渲染可见文案的按钮**,不限于 `type="primary"`。图标的引入方式为在文件 `<script setup>` 中显式 `import` 所需图标组件(`@element-plus/icons-vue` 不属于 auto-import 范围)。图标与文字间距由全局样式保证(与 Fresh Mint 弹窗底栏约定一致,约 6px)。
 
 #### 常用按钮图标映射
 
@@ -562,6 +562,12 @@ watchDebounced(
 | 配置 / 设置 | `Setting` 或 `SetUp` | 操作列「配置」按钮 |
 | 导入 / 上传 | `Upload` | 工具栏「导入」、表单「上传」按钮 |
 | 折叠 / 展开 | `ArrowDown` / `ArrowUp` | 工具栏「展开/收起」按钮 |
+| 充值 / 发起充值 | `Wallet` | 渠道服务订单等预付款充值操作 |
+| 退款 / 申请退款 | `Sell` | 退款、退费类操作 |
+| 购买产品 | `ShoppingCart` | 购买、下单类操作 |
+| 退订 | `Remove` | 退订、减少持有类操作 |
+| 作废 | `Delete` | 作废流水/订单(与删除同图标,可用 `op-warn` 样式区分) |
+| 操作流水 / 记录列表 | `List` | 「操作流水」等子列表入口 |
 
 #### Scenario: 新增列表页工具栏按钮
 
@@ -588,6 +594,12 @@ watchDebounced(
 - **WHEN** PR 包含新增或修改的 `el-button`
 - **THEN** 审查者 SHALL 确认该按钮包含 `<el-icon>` 子元素,且图标组件已从 `@element-plus/icons-vue` 显式导入
 
+#### Scenario: 渠道服务订单与财务确认页
+
+- **WHEN** 实现或修改 `web-admin/src/views/pilot-service/channel-service-order/**` 或 `channel-service-order-finance/**` 中的按钮(含弹窗底栏、表格操作列、行内 link 删除)
+- **THEN** 每个带文案的 `el-button` SHALL 按上表携带图标(如查看 `View`、确认 `Check`、取消 `Close`、作废 `Delete`、发起充值 `Wallet` 等)
+- **THEN** 弹窗底栏取消/确定 SHALL 使用 `btn-ghost` / `btn-main` 类名并与 Demo 页图标约定一致
+
 #### 标准写法示例
 
 ```html
@@ -944,3 +956,37 @@ import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-r
 - **WHEN** 新增 P28 或更高级别模式,或全站页面映射变更
 - **THEN** 维护者 SHALL 同步更新 `docs/web-admin-ui-pattern-index.md` 与本 Requirement 决策表
 
+### Requirement: 金额与单价千分位输入与展示
+
+金额、单价等可编辑输入字段 SHALL 使用 `AmountInput` 组件(路径 `@/components/common/amount-input/index.vue`),满足以下行为:
+
+| 时机 | 行为 |
+|------|------|
+| 聚焦输入中 | 纯数字展示,无千分位逗号,如 `1234567.89` |
+| 失焦后 | 千分位展示,如 `1,234,567.89` |
+| 再次聚焦 | 自动去掉逗号,恢复纯数字便于修改 |
+| 提交/API | `number` 类型,不受逗号影响 |
+
+金额、单价等只读展示(列表列、查看弹窗、合计文案)SHALL 使用 `formatMoneyDisplay` 工具函数(路径 `@/utils/money-format.ts`),整数部分千分位、小数位默认 2 位。
+
+业务 `.vue` 文件 SHALL NOT 复制千分位正则或 `addThousandSeparators` 等函数实现;SHALL 统一引用 `@/utils/money-format.ts`。
+
+不在范围内:数量、份数、排序号、百分比系数等非金额字段(仍用 `el-input-number`)。
+
+#### Scenario: 新增金额输入字段
+
+- **WHEN** 新增可编辑的金额或单价字段
+- **THEN** SHALL 使用 `<AmountInput v-model="..." />` 组件
+- **THEN** SHALL NOT 使用 `<el-input-number>` 或页面内联 focus/blur 千分位逻辑
+
+#### Scenario: 新增金额只读展示
+
+- **WHEN** 在列表列、查看弹窗、合计文案中展示金额
+- **THEN** SHALL 使用 `formatMoneyDisplay(v, 2)` 格式化
+- **THEN** 货币符号 `¥` 仍在模板侧拼接,不内置于函数
+
+#### Scenario: Code review 金额展示
+
+- **WHEN** PR 涉及金额/单价字段展示
+- **THEN** 审查者 SHALL 确认使用 `AmountInput`(可编辑)或 `formatMoneyDisplay`(只读),拒绝页面内复制千分位实现
+

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 439 - 474
web-admin/graphify-out/.graphify_analysis.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 191 - 114
web-admin/graphify-out/.graphify_extract.json


+ 138 - 86
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
 # Graph Report - src  (2026-05-25)
 
 ## Corpus Check
-- Large corpus: 612 files · ~351,591 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 616 files · ~355,459 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 1689 nodes · 2148 edges · 38 communities detected
-- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 476 edges (avg confidence: 0.8)
+- 1717 nodes · 2195 edges · 46 communities detected
+- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 490 edges (avg confidence: 0.8)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -28,29 +28,37 @@
 - [[_COMMUNITY_Community 16|Community 16]]
 - [[_COMMUNITY_Community 17|Community 17]]
 - [[_COMMUNITY_Community 18|Community 18]]
+- [[_COMMUNITY_Community 19|Community 19]]
 - [[_COMMUNITY_Community 20|Community 20]]
 - [[_COMMUNITY_Community 21|Community 21]]
-- [[_COMMUNITY_Community 22|Community 22]]
+- [[_COMMUNITY_Community 23|Community 23]]
 - [[_COMMUNITY_Community 24|Community 24]]
 - [[_COMMUNITY_Community 25|Community 25]]
-- [[_COMMUNITY_Community 26|Community 26]]
 - [[_COMMUNITY_Community 27|Community 27]]
+- [[_COMMUNITY_Community 28|Community 28]]
 - [[_COMMUNITY_Community 29|Community 29]]
-- [[_COMMUNITY_Community 30|Community 30]]
 - [[_COMMUNITY_Community 31|Community 31]]
 - [[_COMMUNITY_Community 32|Community 32]]
 - [[_COMMUNITY_Community 33|Community 33]]
 - [[_COMMUNITY_Community 34|Community 34]]
+- [[_COMMUNITY_Community 35|Community 35]]
+- [[_COMMUNITY_Community 36|Community 36]]
 - [[_COMMUNITY_Community 37|Community 37]]
 - [[_COMMUNITY_Community 38|Community 38]]
-- [[_COMMUNITY_Community 39|Community 39]]
 - [[_COMMUNITY_Community 40|Community 40]]
 - [[_COMMUNITY_Community 41|Community 41]]
+- [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 43|Community 43]]
+- [[_COMMUNITY_Community 45|Community 45]]
+- [[_COMMUNITY_Community 46|Community 46]]
+- [[_COMMUNITY_Community 47|Community 47]]
+- [[_COMMUNITY_Community 48|Community 48]]
+- [[_COMMUNITY_Community 49|Community 49]]
+- [[_COMMUNITY_Community 51|Community 51]]
 
 ## God Nodes (most connected - your core abstractions)
 1. `get()` - 182 edges
-2. `push()` - 27 edges
+2. `push()` - 28 edges
 3. `WebSocketClient` - 26 edges
 4. `request()` - 23 edges
 5. `withFullscreenLoading()` - 17 edges
@@ -61,208 +69,252 @@
 10. `StorageCompatibilityManager` - 12 edges
 
 ## Surprising Connections (you probably didn't know these)
+- `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
+  views/basic-info/collection-protocol-no/index.vue → utils/fullscreen-loading.ts
+- `handleBatchRevoke()` --calls--> `withFullscreenLoading()`  [INFERRED]
+  views/basic-info/collection-protocol-no/index.vue → utils/fullscreen-loading.ts
 - `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
   views/basic-info/customer/index.vue → utils/fullscreen-loading.ts
 - `subscribeTokenRefresh()` --calls--> `push()`  [INFERRED]
   utils/http/index.ts → views/operations/storage-facility/index.vue
 - `handleUnauthorizedResponse()` --calls--> `fetchRefreshToken()`  [INFERRED]
   utils/http/index.ts → api/auth.ts
-- `fetchStorageReleaseReviewList()` --calls--> `get()`  [INFERRED]
-  api/storage-release-review.ts → views/production/process-route/index.vue
-- `fetchStorageReleaseReviewDetail()` --calls--> `get()`  [INFERRED]
-  api/storage-release-review.ts → views/production/process-route/index.vue
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (138): fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList(), fetchChannelAccount() (+130 more)
+Nodes (86): fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList(), fetchChannelAccount() (+78 more)
 
 ### Community 1 - "Community 1"
-Cohesion: 0.01
-Nodes (47): autoGenerateSchedules(), completePilotApplication(), createPilotApplication(), createPilotInfusionPlan(), deletePilotApplication(), deletePilotInfusionPlan(), deletePilotInfusionSchedule(), fetchChannelList() (+39 more)
+Cohesion: 0.03
+Nodes (60): fetchBatchGenerateSampleCodes(), fetchBatchIssueSampleCodes(), fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchCreateSampleCode(), fetchDeleteCollectionRegion(), fetchDeleteHospital() (+52 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.03
-Nodes (62): fetchBatchGenerateProtocolNos(), fetchBatchGenerateSampleCodes(), fetchBatchIssueProtocolNos(), fetchBatchIssueSampleCodes(), fetchCollectionProtocolNoList(), fetchCreateCollectionProtocolNo(), fetchCreateSampleCode(), fetchDeleteCollectionProtocolNo() (+54 more)
+Nodes (39): fetchGetMenuList(), fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), clickTab(), handleTouchEnd(), setTransition() (+31 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.03
-Nodes (39): fetchGetMenuList(), fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), clickTab(), handleTouchEnd(), setTransition() (+31 more)
+Nodes (45): fetchDeptTree(), fetchPostBoundUserIds(), fetchUploadAttachment(), FireworkSystem, reload(), handleConfirmAttachmentChange(), loadDeptData(), onPersonSelectChange() (+37 more)
 
 ### Community 4 - "Community 4"
-Cohesion: 0.04
-Nodes (43): fetchDeptTree(), fetchPostBoundUserIds(), fetchUploadAttachment(), reload(), loadDeptData(), onPersonSelectChange(), handleAttachmentChange(), handleMenuJump() (+35 more)
+Cohesion: 0.03
+Nodes (22): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents(), fetchAllReagentStockLogs() (+14 more)
 
 ### Community 5 - "Community 5"
+Cohesion: 0.05
+Nodes (48): autoGenerateSchedules(), completePilotApplication(), createPilotApplication(), createPilotInfusionPlan(), deletePilotApplication(), deletePilotInfusionPlan(), deletePilotInfusionSchedule(), fetchChannelList() (+40 more)
+
+### Community 6 - "Community 6"
 Cohesion: 0.06
 Nodes (11): fetchGetLatestUpgradeLog(), getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager (+3 more)
 
-### Community 6 - "Community 6"
+### Community 7 - "Community 7"
 Cohesion: 0.05
-Nodes (43): fetchAccessLogList(), fetchCleanRoomList(), fetchCreateAccessLog(), fetchCreateCleanRoom(), fetchCreateSite(), fetchDeleteAccessLog(), fetchDeleteCleanRoom(), fetchDeleteSite() (+35 more)
+Nodes (41): fetchAccessLogList(), fetchCleanRoomList(), fetchCreateAccessLog(), fetchCreateCleanRoom(), fetchCreateSite(), fetchDeleteAccessLog(), fetchDeleteCleanRoom(), fetchDeleteSite() (+33 more)
 
-### Community 7 - "Community 7"
+### Community 8 - "Community 8"
 Cohesion: 0.08
 Nodes (6): handleReset(), handleSearch(), WebSocketClient, TableCache, emit(), handleSubmit()
 
-### Community 8 - "Community 8"
-Cohesion: 0.06
-Nodes (12): collectIds(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), ComponentLoader, RoutePermissionValidator, RouteTransformer, useCeremony() (+4 more)
-
 ### Community 9 - "Community 9"
-Cohesion: 0.07
-Nodes (21): auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName(), siteName(), unitName() (+13 more)
+Cohesion: 0.06
+Nodes (28): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchPaymentDetailList(), fetchReceiptConfirmedList(), fetchReceiptConfirmList() (+20 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.08
-Nodes (31): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+23 more)
+Cohesion: 0.07
+Nodes (11): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), ComponentLoader, RoutePermissionValidator, RouteTransformer, useCeremony(), useTheme() (+3 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.09
-Nodes (16): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), postorder(), preorder(), applyTreeExpand(), handleAdd() (+8 more)
+Cohesion: 0.07
+Nodes (21): auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName(), siteName(), unitName() (+13 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.1
-Nodes (13): fetchEthnicList(), addSample(), applyDonationBundleToForm(), confirmBatchTransportApply(), createEmptyTransport(), ensureDonationProtocolNoInOptions(), ensureEthnicOptionInList(), handleOpenForAdd() (+5 more)
+Cohesion: 0.07
+Nodes (22): handleBlur(), handleFocus(), handleInput(), fetchEthnicList(), addSample(), applyDonationBundleToForm(), confirmBatchTransportApply(), createEmptyTransport() (+14 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.11
-Nodes (21): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolDetail(), buildInsuranceAttachmentsValue(), buildInsurancePayload(), formatYmdFromAny(), fullFileUrl(), handleBatchEdit() (+13 more)
+Cohesion: 0.06
+Nodes (20): fetchAttachmentDownloadUrl(), fetchOrderAdjusts(), fetchOrderDetail(), fetchOrderItems(), fetchOrderList(), fetchPriceVersionDetail(), fetchSystemConfig(), handleDownloadAttachment() (+12 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.09
-Nodes (13): fetchAttachmentDownloadUrl(), handleDownloadAttachment(), handleReceptionAttachmentDownload(), attachmentFullUrl(), handleAttachmentDownload(), handleAttachmentPreview(), attachmentFullUrl(), handleAttachmentDownload() (+5 more)
+Cohesion: 0.07
+Nodes (17): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), collectIds(), postorder(), preorder(), applyTreeExpand() (+9 more)
 
 ### Community 15 - "Community 15"
-Cohesion: 0.1
-Nodes (18): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchHospitalList(), fetchUpdateCollectionRegion(), fetchUpdateHospital() (+10 more)
+Cohesion: 0.11
+Nodes (26): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+18 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.12
-Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
+Cohesion: 0.11
+Nodes (20): fetchBatchGenerateProtocolNos(), fetchBatchIssueProtocolNos(), fetchCollectionProtocolNoList(), fetchCreateCollectionProtocolNo(), fetchDeleteCollectionProtocolNo(), fetchUpdateCollectionProtocolNo(), handleAdd(), handleBatchDelete() (+12 more)
 
 ### Community 17 - "Community 17"
 Cohesion: 0.12
-Nodes (19): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchRoleOptions(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd() (+11 more)
+Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
 
 ### Community 18 - "Community 18"
 Cohesion: 0.1
 Nodes (6): fetchProductionTaskDetail(), fetchProductionTaskList(), fetchUpdateProductionTask(), handleClose(), handleSubmit(), loadTaskDetail()
 
+### Community 19 - "Community 19"
+Cohesion: 0.14
+Nodes (17): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd(), handleDelete() (+9 more)
+
 ### Community 20 - "Community 20"
-Cohesion: 0.15
-Nodes (12): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleBatchDelete(), handleDelete(), handleReset() (+4 more)
+Cohesion: 0.14
+Nodes (14): fetchConfirmTxn(), fetchServiceOrderTxnList(), fetchVoidTxn(), executeSubmitConfirm(), fullFileUrl(), handlePreviewAttachment(), handleReset(), handleSearch() (+6 more)
 
 ### Community 21 - "Community 21"
+Cohesion: 0.12
+Nodes (12): fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances(), fetchPendingTasks(), fetchProcessDefList() (+4 more)
+
+### Community 23 - "Community 23"
 Cohesion: 0.16
 Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
 
-### Community 22 - "Community 22"
+### Community 24 - "Community 24"
+Cohesion: 0.15
+Nodes (12): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleBatchDelete(), handleDelete(), handleReset() (+4 more)
+
+### Community 25 - "Community 25"
 Cohesion: 0.27
 Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
 
-### Community 24 - "Community 24"
+### Community 27 - "Community 27"
 Cohesion: 0.17
 Nodes (3): fetchResearchDonationAuditList(), fetchResearchDonationDetail(), fetchResearchDonationList()
 
-### Community 25 - "Community 25"
+### Community 28 - "Community 28"
 Cohesion: 0.18
 Nodes (3): fetchMaterialList(), fetchMaterialOptions(), fetchMaterialStockLogList()
 
-### Community 26 - "Community 26"
+### Community 29 - "Community 29"
 Cohesion: 0.2
 Nodes (2): fetchQcMaterialList(), fetchQcMaterialOptions()
 
-### Community 27 - "Community 27"
-Cohesion: 0.28
-Nodes (1): FireworkSystem
-
-### Community 29 - "Community 29"
+### Community 31 - "Community 31"
 Cohesion: 0.39
 Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
 
-### Community 30 - "Community 30"
+### Community 32 - "Community 32"
 Cohesion: 0.36
 Nodes (1): TableHeightCalculator
 
-### Community 31 - "Community 31"
+### Community 33 - "Community 33"
 Cohesion: 0.25
 Nodes (2): fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList()
 
-### Community 32 - "Community 32"
+### Community 34 - "Community 34"
 Cohesion: 0.25
 Nodes (2): fetchQcTaskDetail(), fetchQcTaskList()
 
-### Community 33 - "Community 33"
+### Community 35 - "Community 35"
 Cohesion: 0.33
 Nodes (2): logApiCryptoBootInfo(), maskSecret()
 
-### Community 34 - "Community 34"
-Cohesion: 0.47
-Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
+### Community 36 - "Community 36"
+Cohesion: 0.29
+Nodes (2): fetchLeaveDetail(), fetchLeaveList()
 
 ### Community 37 - "Community 37"
-Cohesion: 0.53
-Nodes (4): isEtaTodayOrEarlier(), parseEtaLocalDate(), sampleReceptionRowClassName(), startOfLocalDay()
+Cohesion: 0.29
+Nodes (2): fetchQcWorkOrderDetail(), fetchQcWorkOrderList()
 
 ### Community 38 - "Community 38"
+Cohesion: 0.47
+Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
+
+### Community 40 - "Community 40"
+Cohesion: 0.33
+Nodes (2): fetchSampleReceptionCheckItemList(), fetchSampleReceptionCheckItemsActive()
+
+### Community 41 - "Community 41"
+Cohesion: 0.33
+Nodes (2): fetchQcThirdPartyItemList(), fetchQcThirdPartyItemsActive()
+
+### Community 42 - "Community 42"
+Cohesion: 0.33
+Nodes (2): fetchQcThirdPartyCompanyList(), fetchQcThirdPartyCompanyOptions()
+
+### Community 43 - "Community 43"
+Cohesion: 0.33
+Nodes (2): fetchQcLabTestItemList(), fetchQcLabTestItemsActive()
+
+### Community 45 - "Community 45"
 Cohesion: 0.5
 Nodes (2): focusInput(), handleKeydown()
 
-### Community 39 - "Community 39"
+### Community 46 - "Community 46"
 Cohesion: 0.67
 Nodes (2): if(), logSidebarMenu()
 
-### Community 40 - "Community 40"
+### Community 47 - "Community 47"
 Cohesion: 0.5
 Nodes (1): fetchStorageEntrySmsList()
 
-### Community 41 - "Community 41"
+### Community 48 - "Community 48"
+Cohesion: 0.5
+Nodes (1): fetchSampleSmsNotificationList()
+
+### Community 49 - "Community 49"
 Cohesion: 1.0
 Nodes (2): getSettingDefaults(), resetToDefaults()
 
-### Community 43 - "Community 43"
+### Community 51 - "Community 51"
 Cohesion: 1.0
 Nodes (2): defaultAvatarPathByRole(), resolveUserAvatarUrl()
 
 ## Knowledge Gaps
-- **Thin community `Community 26`** (10 nodes): `fetchBatchStockInQcMaterial()`, `fetchBatchStockOutQcMaterial()`, `fetchCreateQcMaterial()`, `fetchDeleteQcMaterial()`, `fetchQcMaterialList()`, `fetchQcMaterialOptions()`, `fetchStockInQcMaterial()`, `fetchStockOutQcMaterial()`, `fetchUpdateQcMaterial()`, `qc-material.ts`
+- **Thin community `Community 29`** (10 nodes): `fetchBatchStockInQcMaterial()`, `fetchBatchStockOutQcMaterial()`, `fetchCreateQcMaterial()`, `fetchDeleteQcMaterial()`, `fetchQcMaterialList()`, `fetchQcMaterialOptions()`, `fetchStockInQcMaterial()`, `fetchStockOutQcMaterial()`, `fetchUpdateQcMaterial()`, `qc-material.ts`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 32`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 33`** (8 nodes): `fetchCompleteProductionWorkOrder()`, `fetchConfigureProductionWorkOrder()`, `fetchCreateProductionWorkOrder()`, `fetchDeleteProductionWorkOrder()`, `fetchProductionWorkOrderDetail()`, `fetchProductionWorkOrderList()`, `fetchUpdateProductionWorkOrder()`, `production-work-order.ts`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 34`** (8 nodes): `fetchCompleteQcTask()`, `fetchCreateQcTask()`, `fetchDeleteQcTask()`, `fetchQcTaskDetail()`, `fetchQcTaskList()`, `fetchStartQcTask()`, `fetchUpdateQcTaskAttachments()`, `qc-task.ts`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 35`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 36`** (7 nodes): `fetchCreateLeave()`, `fetchDeleteLeave()`, `fetchLeaveDetail()`, `fetchLeaveList()`, `fetchSubmitLeave()`, `fetchUpdateLeave()`, `leave.ts`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 37`** (7 nodes): `fetchConfigureQcWorkOrder()`, `fetchCreateQcWorkOrder()`, `fetchDeleteQcWorkOrder()`, `fetchQcWorkOrderDetail()`, `fetchQcWorkOrderList()`, `fetchUpdateQcWorkOrder()`, `qc-work-order.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 27`** (9 nodes): `FireworkSystem`, `.constructor()`, `.createFirework()`, `.createParticle()`, `.getAvailableParticle()`, `.initializePool()`, `.preloadAllImages()`, `.preloadImage()`, `index.vue`
+- **Thin community `Community 40`** (6 nodes): `fetchCreateSampleReceptionCheckItem()`, `fetchDeleteSampleReceptionCheckItem()`, `fetchSampleReceptionCheckItemList()`, `fetchSampleReceptionCheckItemsActive()`, `fetchUpdateSampleReceptionCheckItem()`, `sample-reception-check-item.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 30`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
+- **Thin community `Community 41`** (6 nodes): `fetchCreateQcThirdPartyItem()`, `fetchDeleteQcThirdPartyItem()`, `fetchQcThirdPartyItemList()`, `fetchQcThirdPartyItemsActive()`, `fetchUpdateQcThirdPartyItem()`, `qc-third-party-item.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 31`** (8 nodes): `fetchCompleteProductionWorkOrder()`, `fetchConfigureProductionWorkOrder()`, `fetchCreateProductionWorkOrder()`, `fetchDeleteProductionWorkOrder()`, `fetchProductionWorkOrderDetail()`, `fetchProductionWorkOrderList()`, `fetchUpdateProductionWorkOrder()`, `production-work-order.ts`
+- **Thin community `Community 42`** (6 nodes): `fetchCreateQcThirdPartyCompany()`, `fetchDeleteQcThirdPartyCompany()`, `fetchQcThirdPartyCompanyList()`, `fetchQcThirdPartyCompanyOptions()`, `fetchUpdateQcThirdPartyCompany()`, `qc-third-party-company.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 32`** (8 nodes): `fetchCompleteQcTask()`, `fetchCreateQcTask()`, `fetchDeleteQcTask()`, `fetchQcTaskDetail()`, `fetchQcTaskList()`, `fetchStartQcTask()`, `fetchUpdateQcTaskAttachments()`, `qc-task.ts`
+- **Thin community `Community 43`** (6 nodes): `fetchCreateQcLabTestItem()`, `fetchDeleteQcLabTestItem()`, `fetchQcLabTestItemList()`, `fetchQcLabTestItemsActive()`, `fetchUpdateQcLabTestItem()`, `qc-lab-test-item.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 33`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
+- **Thin community `Community 45`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 38`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
+- **Thin community `Community 46`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
+- **Thin community `Community 47`** (4 nodes): `fetchGenerateStorageEntrySms()`, `fetchStorageEntrySmsList()`, `fetchToggleStorageEntrySmsNotified()`, `storage-entry-sms-notification.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 40`** (4 nodes): `fetchGenerateStorageEntrySms()`, `fetchStorageEntrySmsList()`, `fetchToggleStorageEntrySmsNotified()`, `storage-entry-sms-notification.ts`
+- **Thin community `Community 48`** (4 nodes): `fetchGenerateSms()`, `fetchSampleSmsNotificationList()`, `fetchToggleSmsNotified()`, `sample-sms-notification.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 41`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
+- **Thin community `Community 49`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 43`** (3 nodes): `defaultAvatarPathByRole()`, `resolveUserAvatarUrl()`, `user-avatar.ts`
+- **Thin community `Community 51`** (3 nodes): `defaultAvatarPathByRole()`, `resolveUserAvatarUrl()`, `user-avatar.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 11`, `Community 12`, `Community 13`, `Community 15`, `Community 17`, `Community 18`, `Community 20`, `Community 24`, `Community 25`, `Community 26`, `Community 31`, `Community 32`, `Community 40`?**
-  _High betweenness centrality (0.270) - this node is a cross-community bridge._
-- **Why does `push()` connect `Community 4` to `Community 1`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 10`, `Community 12`, `Community 13`, `Community 27`?**
-  _High betweenness centrality (0.127) - this node is a cross-community bridge._
-- **Why does `handleDynamicRoutes()` connect `Community 3` to `Community 8`?**
-  _High betweenness centrality (0.040) - this node is a cross-community bridge._
+- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 12`, `Community 13`, `Community 14`, `Community 16`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 24`, `Community 27`, `Community 28`, `Community 29`, `Community 33`, `Community 34`, `Community 36`, `Community 37`, `Community 40`, `Community 41`, `Community 42`, `Community 43`, `Community 47`, `Community 48`?**
+  _High betweenness centrality (0.256) - this node is a cross-community bridge._
+- **Why does `push()` connect `Community 3` to `Community 0`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 12`, `Community 15`?**
+  _High betweenness centrality (0.141) - this node is a cross-community bridge._
+- **Why does `handleDynamicRoutes()` connect `Community 2` to `Community 10`?**
+  _High betweenness centrality (0.054) - this node is a cross-community bridge._
 - **Are the 181 inferred relationships involving `get()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `buildTreePathLabelFromFlat()`) actually correct?**
   _`get()` has 181 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 26 inferred relationships involving `push()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `fetchAllPagedRecords()`) actually correct?**
-  _`push()` has 26 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 27 inferred relationships involving `push()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `fetchAllPagedRecords()`) actually correct?**
+  _`push()` has 27 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 21 inferred relationships involving `request()` (e.g. with `showSuccess()` and `showError()`) actually correct?**
   _`request()` has 21 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 16 inferred relationships involving `withFullscreenLoading()` (e.g. with `.close()` and `handleSubmit()`) actually correct?**

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 1
web-admin/graphify-out/graph.html


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 112 - 112
web-admin/graphify-out/graph.json


+ 36 - 32
web-admin/graphify-out/manifest.json

@@ -17,8 +17,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1778582068.0254738,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1778582068.026978,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1778582068.0246184,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1779685868.3519082,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1779685098.1460314,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1779700098.614053,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1779700080.6929684,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1778582068.0293276,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1778775532.895603,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/locales/index.ts": 1778582068.004279,
@@ -51,6 +51,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/mask-sensitive.ts": 1778984825.4182227,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/production-labels.ts": 1779615806.1798687,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/index.ts": 1778582068.0369413,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/money-format.ts": 1779695444.9027524,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/category-path-display.ts": 1778582068.0304127,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/qc-labels.ts": 1779199108.173778,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/loginRsa.ts": 1778582068.0373487,
@@ -184,6 +185,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/system-view-dialog/index.vue": 1779586311.488459,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/production-task-view-dialog/index.vue": 1779615853.6423426,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/order-operate-dialog-bridge/index.vue": 1779525781.611354,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/common/amount-input/index.vue": 1779695502.3228035,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/hooks/index.ts": 1778582068.0037498,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/hooks/core/useFastEnter.ts": 1778582068.0014625,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/hooks/core/useTableColumns.ts": 1778582068.0029352,
@@ -203,14 +205,14 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-third-party-item.ts": 1779017836.101254,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/storage-release-review.ts": 1779615813.3431153,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/workflow.ts": 1778729873.096252,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-work-order.ts": 1779615080.6717544,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-work-order.ts": 1779692786.7954206,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-task-result.ts": 1779332042.258692,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-third-party-company.ts": 1779021425.3773527,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/collection-protocol.ts": 1779691498.8437068,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/chinaRegion.ts": 1778582067.8620915,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/storage-entry-sms-notification.ts": 1779691739.8547683,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/business.ts": 1779159587.6033742,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/admin.ts": 1779692218.2173133,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/admin.ts": 1779696010.1232035,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/ethnic.ts": 1778929297.3919344,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-lab-test-item.ts": 1779017838.8231559,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/cell-product.ts": 1779594447.3049195,
@@ -218,30 +220,30 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/research-donation.ts": 1779692338.7516685,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-task.ts": 1779612098.519313,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-item-param.ts": 1779022632.5658252,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-task.ts": 1779586811.7854652,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-task.ts": 1779692910.5154753,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-material-usage.ts": 1779284726.00147,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-material.ts": 1779283280.0705447,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/system.ts": 1778582067.862994,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/material.ts": 1779283387.4196396,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/sample-reception.ts": 1779255909.8553667,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/sample-reception.ts": 1779692503.163169,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/auth.ts": 1778582067.8610063,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-work-order.ts": 1779611630.4600656,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-work-order.ts": 1779692694.5895822,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/auth/login/index.vue": 1779525781.6116724,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/refund-confirm/index.vue": 1779691522.6943383,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/refund-confirm/index.vue": 1779699061.836399,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/index.vue": 1779686149.7501445,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue": 1779525781.613681,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue": 1779583778.6780727,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue": 1779699075.4044075,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue": 1779525781.6162472,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue": 1779525781.6179993,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue": 1779699092.0957584,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolAuditDialog.vue": 1779525781.6193507,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/sample-reception-info/index.vue": 1779691690.1009152,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-info/index.vue": 1779686339.3853126,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-info/index.vue": 1779699056.0146186,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/storage-cert/index.vue": 1779692218.217227,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol-audit/index.vue": 1779686205.7531993,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/index.vue": 1779543540.7084875,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/fullmoon-todo/index.vue": 1779692218.2173543,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/fullmoon-todo/index.vue": 1779699073.6049047,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/storage-entry-info/index.vue": 1779691749.1963794,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/index.vue": 1779691305.390457,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/index.vue": 1779699057.959591,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material-stock-log/index.vue": 1779543540.7038007,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/outside/Iframe.vue": 1779525781.6271372,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/operations/demo/index.vue": 1779525781.6276834,
@@ -251,19 +253,19 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/audit/index.vue": 1779692379.194848,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/index.vue": 1779692356.4217088,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationAuditDialog.vue": 1779525781.6319423,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationViewDialog.vue": 1779591471.046118,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationViewDialog.vue": 1779699979.6871445,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationFormDialog.vue": 1779525781.635114,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue": 1779525781.6357327,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/dashboard/console/index.vue": 1779525781.636181,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/tenant/index.vue": 1779543443.6733122,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/order/index.vue": 1779543540.74551,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/order/index.vue": 1779699966.1783187,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/unit/index.vue": 1779543540.7397332,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/role/index.vue": 1779543540.7380679,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/post/index.vue": 1779543540.7481663,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/category/index.vue": 1779543540.749298,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/express/index.vue": 1779543540.7485101,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/index.vue": 1779543540.7475703,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/detail.vue": 1779525781.6442838,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/detail.vue": 1779699977.6617017,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/modules/user-dialog.vue": 1779525781.6434622,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/modules/user-search.vue": 1779525781.643876,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/product/index.vue": 1779543540.7469623,
@@ -291,9 +293,9 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/cell-product/source-chain/chainToFlow.ts": 1779601450.3906622,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process-route/index.vue": 1779543540.7289982,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/sop/index.vue": 1779543540.726837,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-task/index.vue": 1779615840.3873165,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-task/index.vue": 1779692921.2198172,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/reagent/index.vue": 1779589143.1296964,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue": 1779615827.927988,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue": 1779692806.8630154,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/useTaskStepEditor.ts": 1779615478.2475564,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/StepDetailPanel.vue": 1779525781.678735,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/CompleteProductionTaskDialog.vue": 1779615467.9727454,
@@ -308,35 +310,37 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process/index.vue": 1779543540.717012,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process-category/index.vue": 1779543540.7154598,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/index/index.vue": 1779525781.6949234,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/index.vue": 1779615108.02306,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/index.vue": 1779699975.3058991,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception-check-item/index.vue": 1779543540.7019148,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/lab-test-item/index.vue": 1779543540.7013087,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/index.vue": 1779543540.7000535,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/index.vue": 1779543540.698455,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-task/index.vue": 1779612120.724959,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/index.vue": 1779692594.0406384,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-task/index.vue": 1779699971.8908935,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/index.vue": 1779543540.695075,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue": 1779525781.7374997,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseViewDialog.vue": 1779613667.0297782,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-usage/index.vue": 1779543540.693297,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/third-party-company/index.vue": 1779543540.692346,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/third-party-company/index.vue": 1779699924.4409633,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/500/index.vue": 1779546148.0553653,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/403/index.vue": 1779546144.3909206,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/404/index.vue": 1779546146.242008,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-type/index.vue": 1779543540.6913223,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/index.vue": 1779543540.6905549,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue": 1779525781.7596755,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/index.vue": 1779633189.469777,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/index.vue": 1779543540.6894271,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue": 1779619832.8572278,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/index.vue": 1779697615.6754658,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue": 1779697623.5824215,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/index.vue": 1779699826.455601,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue": 1779699863.6833217,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue": 1779699868.898797,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/index.vue": 1779697613.264022,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue": 1779697640.6669152,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/index.vue": 1779637937.8548203,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/pilot-application/ApplicationDetail.vue": 1779657466.23388,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-price/index.vue": 1779543540.6862803,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order-finance/index.vue": 1779632871.1355302,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/project-price-system/index.vue": 1779543540.6854389,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-price/index.vue": 1779697593.9746604,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order-finance/index.vue": 1779699868.3981209,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/project-price-system/index.vue": 1779697597.2297914,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-spec/index.vue": 1779543540.6845775,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/sample-code/index.vue": 1779543540.6835444,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/cell-sample-type/index.vue": 1779543540.6821191,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/insurance-company/index.vue": 1779543540.6812408,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/insurance-company/index.vue": 1779699898.7911212,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/collection-protocol-no/index.vue": 1779543540.6802733,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/customer/index.vue": 1779686149.7501822,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/collection-region/index.vue": 1779543540.6782162,
@@ -357,7 +361,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RoutePermissionValidator.ts": 1778582068.0127926,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/index.ts": 1778582068.0138035,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/IframeRouteManager.ts": 1778582068.0121944,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1779637692.9707448,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1779693179.2014906,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/index.ts": 1778598510.6043763,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/exception.ts": 1779546186.993006,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routes/staticRoutes.ts": 1779546226.1088154,

+ 5 - 0
web-admin/src/api/admin.ts

@@ -2007,6 +2007,11 @@ export function fetchCreateUnsubscribeTxn(orderId: string, data: Record<string,
   return request.post({ url: `/web/admin/channel-service-orders/${orderId}/unsubscribe`, params: data })
 }
 
+/** 更新待生效流水 */
+export function fetchUpdateServiceOrderTxn(txnId: string, data: Record<string, any>) {
+  return request.put({ url: `/web/admin/channel-service-order-txns/${txnId}`, params: data })
+}
+
 /** 流水发起财务确认 */
 export function fetchSubmitTxnFinance(txnId: string, data: Record<string, any>) {
   return request.post({ url: `/web/admin/channel-service-order-txns/${txnId}/submit-finance`, params: data })

+ 1 - 6
web-admin/src/components/business/protocol-finance-confirm-fields/index.vue

@@ -70,7 +70,7 @@
             </div>
           </div>
         </div>
-        <p class="upload-hint">支持图片、视频、文档等,单文件不超过 20MB。</p>
+        <p class="upload-hint">支持图片、视频、文档等。</p>
       </div>
     </el-form-item>
   </div>
@@ -158,11 +158,6 @@
   async function handleAttachmentChange(uploadFile: { raw?: File }) {
     const file = uploadFile.raw
     if (!file) return
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      return
-    }
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     const uploading: FinanceAttachmentItem = {
       id: tempId,

+ 80 - 0
web-admin/src/components/common/amount-input/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <el-input
+    v-model="displayText"
+    :placeholder="placeholder"
+    :disabled="disabled"
+    :clearable="clearable"
+    style="width: 100%"
+    @focus="handleFocus"
+    @blur="handleBlur"
+    @input="handleInput"
+  />
+</template>
+
+<script setup lang="ts">
+import {
+  formatMoneyDisplay,
+  parseMoneyToNumber,
+  sanitizeMoneyInput,
+} from '@/utils/money-format'
+
+defineOptions({ name: 'AmountInput' })
+
+interface Props {
+  modelValue: number | null
+  min?: number
+  max?: number
+  precision?: number
+  placeholder?: string
+  disabled?: boolean
+  clearable?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  min: undefined,
+  max: undefined,
+  precision: 2,
+  placeholder: '',
+  disabled: false,
+  clearable: true,
+})
+
+const emit = defineEmits<{
+  'update:modelValue': [value: number | null]
+  change: [value: number | null]
+}>()
+
+const displayText = ref('')
+const isFocused = ref(false)
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (!isFocused.value) {
+      displayText.value = formatMoneyDisplay(val, props.precision)
+    }
+  },
+  { immediate: true }
+)
+
+function handleFocus() {
+  isFocused.value = true
+  displayText.value = sanitizeMoneyInput(displayText.value, props.precision)
+}
+
+function handleInput(val: string) {
+  displayText.value = sanitizeMoneyInput(val, props.precision)
+}
+
+function handleBlur() {
+  isFocused.value = false
+  let num = parseMoneyToNumber(displayText.value, props.precision)
+  if (num !== null) {
+    if (props.min !== undefined && num < props.min) num = props.min
+    if (props.max !== undefined && num > props.max) num = props.max
+  }
+  emit('update:modelValue', num)
+  emit('change', num)
+  displayText.value = formatMoneyDisplay(num, props.precision)
+}
+</script>

+ 1 - 0
web-admin/src/graphify-out/cache/ast/04fcc62b4c4b8783a1475c4c4764f1477a84860fe6c4ea57e240c8050b65941b.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_cell_product_price_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-price/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/07fdcf8910ddfeab44458b988dbb76dca081af494ef59272fa9b5300a5a3a5b1.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/1100878045fb0a0b93f01c4b0709e70ed1b8adf40be7e3771016571ae9207567.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_basic_info_insurance_company_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/insurance-company/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/12f8f0007a5c134ff6dd26868422def5c29fa3096d08d6980cee7b33388479d8.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/2282127492a9d5a20fcdee0d7f950a63482edcb2f89536ac20f7d0ff6d9e41bb.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_finance_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order-finance/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/2c36856fcdd7696e04486ead04d59273fa7d5293deea06d57a220d2884cf672e.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_sample_reception_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/2ddd0f811a4a66d0bdaa637e11cfa6950558d076171890704ab3369618540ef9.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/31704f241ad0e687813532a767f8e5c2e5b03a387416bb8df232d24f4348c3e5.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_project_price_system_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/project-price-system/index.vue", "source_location": "L1"}, {"id": "project_price_system_index_if", "label": "if()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/project-price-system/index.vue", "source_location": "L497"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_project_price_system_index_vue", "target": "project_price_system_index_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/project-price-system/index.vue", "source_location": "L497", "weight": 1.0}], "raw_calls": [{"caller_nid": "project_price_system_index_if", "callee": "fetchDeleteProjectSystemItem", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/project-price-system/index.vue", "source_location": "L498"}]}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/3f1cbc015877fbba5647327af5a3df51751d3a6bdcbd9af4f66769f7c38b9b8c.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_payment_info_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-info/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/471e48d7f02c3da5878dddb721cad06e6ded76a0170c3d41f47be527138b3a93.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/5afbd7a2c75e2e65eae8d19a47e9ef0c7f4be122a2de13949a8c4c7f583eacf3.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/5c541e9a2285e785e00ef9da675dabba0e54e98e735cb1e5cafc738bda50964e.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_checkup_institution_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/5e0ae62a6666a84f12df5a766ee8c5caabdbefea986eb21b435abf7e35fdb86d.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_work_order_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-work-order/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/66aab76aa12d80e9e6de0d6fc7dc59c030b67fcfdc96ba75878ca989fc7833bd.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/6aaece741cccb10d803e3de42056c1fae04ed057fb6e0e2f1cd4934d49d7f59e.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/6de4ed7618f2e63cfaeb20cf4ea2d880d26486654bf6d17ed72856a860550917.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/75ac17ad572905845c16d14da5525953743d65fdad6086ad897c86488f6cef71.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_refund_confirm_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/refund-confirm/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/7c03d990abc09f28d32e51df65e2e54f2be07ba273d4a6304d543fe246a932cc.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxntable_vue", "label": "ServiceOrderTxnTable.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/7c9ee65f53ea9f9818b99a553a223bf537c587ad3ae4282f200486dbf1a69da0.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/8ef8260410ab120e8d991f344b463196e706e50599ac422bc36e10f3ddf2020c.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_finance_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order-finance/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/9108d40b4fe7a9958caa19169315bdd6d9f56fb712a4c483be63f38678721c17.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "label": "ServiceOrderTxnDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L1"}, {"id": "channel_service_order_serviceordertxndialog_if", "label": "if()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L494"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L494", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L501", "weight": 1.0}], "raw_calls": [{"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L495"}, {"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L502"}]}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/958c3c8398394b7cee6f86d8cb7d569079065e05cef5545597e4dc7b1741c37e.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "label": "ServiceOrderTxnDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L1"}, {"id": "channel_service_order_serviceordertxndialog_if", "label": "if()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L519"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L519", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L526", "weight": 1.0}], "raw_calls": [{"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L520"}, {"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L527"}]}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/9b79994230ba835942a7694ec722c282cb9dbea82858f2a2201f545dc4d9b379.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_system_user_detail_vue", "label": "detail.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/detail.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/9e80aa2567338a651d6573d9736787ad59fa78e2ef33cee3f074ac8ffc5ebf6b.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/a16e268b9b086f855f6f6f1b6a92b302b00d8dc215aa95f98a8d4519fde229ae.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/a38d7413d41bd7ed78c7df08cd61b43e4f181285a0188410461f8c52a1f8c04c.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/a49e3ea67e0c38bc000247ad2e61c9854ed41b3e26a06bcc58128aa3df15d886.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/a97a5a72d199987571747098b37806fff50745bfe24ebcd6a01609cf71d4744c.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/aa7d824370d3bbcabc3e138c22d38701c7c0e19abcca5f5a253832881be17eee.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "label": "ServiceOrderTxnDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L1"}, {"id": "channel_service_order_serviceordertxndialog_if", "label": "if()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L519"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L519", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L526", "weight": 1.0}], "raw_calls": [{"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L520"}, {"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L527"}]}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/ace3bc9db1dedf638cd9a50b758f25b0e6fb502710127063e5aaecbfe4ecebbc.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/ae4a92a8cab26a44a1f57684abe842cdae415ccb4a9550154e53aeb853deee3e.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/ae9c4065d6bd05a38d4ceffe36fac15f0b0f1db5790a4257efc9e9d2f6c10df0.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "label": "ServiceOrderTxnDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L1"}, {"id": "channel_service_order_serviceordertxndialog_if", "label": "if()", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L492"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L492", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxndialog_vue", "target": "channel_service_order_serviceordertxndialog_if", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L499", "weight": 1.0}], "raw_calls": [{"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L493"}, {"caller_nid": "channel_service_order_serviceordertxndialog_if", "callee": "warning", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue", "source_location": "L500"}]}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/b48cf81fa31d6d7352e6b487c2933ecb3e45353f20f448dc1ca73c6aa040764b.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_payment_confirm_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/b67aea5bc8148641abb6fa1cf86814c25532ad61be0f43ea6443e3a9ab23b250.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/b825185b75e6c8179519ed68684aeefb1d72d1f4be43e0169f5822360108e7de.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/c6c955501448fd41e79ac5bacf657c707e701d77d6a45c377566704d3ec1e905.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/c7c4e6a1d55abacdef2f2ecba7d5edfe0573e734fd932d069749f2bb5c1a01e1.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_channelviewdialog_vue", "label": "ChannelViewDialog.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/cf3f8a1921a52b4fee1c5bf7f2c5f9e04ad5c876d109ced2a8d307e4b33c664d.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_channel_service_order_serviceordertxntable_vue", "label": "ServiceOrderTxnTable.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/d02551ad5d611df49b964c47ce129b48820bd55f6cc2dc958eb23200a23174a1.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/d3b1f975b505e86709f8b456275dcf9c683bacd368209d4f2041abca9a5bf683.json


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/db117095b590d096f4da3c27fc36905c9675203b236b221c1b3bb9cdbc112e0d.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/e2fbdb069c4481bfaf0ae799b27ff557536bf3720c5021a9f17506c35a5a39fd.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/e332bff3e7a0452518a66cd960d035411dcf69839054f5d9689a34c431c4a6c2.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_router_modules_sqxp_ts", "label": "sqxp.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_router_modules_sqxp_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_types_router", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/e43a5865c29af34d873fe5ff8edb39bdd14e33f6d0017226c2e80cb3ee719130.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_task_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-task/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/e7297ed7b56b389095320c7925ead0810c5fff9eadadfc769c05e08533de321f.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/eb627b0782478cb51b42ae6724fde73135a82d15876b718b18c76412055b92a7.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_third_party_company_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/third-party-company/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/ecc04d7ad5442f72a2e121b7fca9b703cb926138b70973354b611bf3c8f2ef70.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/f0597e1ea48ee6cb63905f7807daff2eb5f9a049141e647864f5370c7b6223c1.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_pilot_service_checkup_institution_checkuppackagepanel_vue", "label": "CheckupPackagePanel.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/f131dee6c12771a4ddee16965866d82cd543ad390d7cd442b0f4c174077f0674.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_system_order_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/order/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
web-admin/src/graphify-out/cache/ast/fcb2d52ca1b0728687f15f39728a0b12acee8e403638ed19258d4870d6982155.json


+ 73 - 0
web-admin/src/utils/money-format.ts

@@ -0,0 +1,73 @@
+/**
+ * 金额格式化工具模块
+ * 提供千分位分隔、输入清洗、展示格式化、数值解析等功能
+ */
+
+/** 字符串分段拼接千分位,禁止使用前瞻正则 */
+export function addThousandSeparators(intStr: string): string {
+  if (!intStr) return ''
+  const isNegative = intStr.startsWith('-')
+  const absStr = isNegative ? intStr.slice(1) : intStr
+  const parts: string[] = []
+  const len = absStr.length
+  for (let i = len; i > 0; i -= 3) {
+    const start = Math.max(0, i - 3)
+    parts.unshift(absStr.slice(start, i))
+  }
+  const result = parts.join(',')
+  return isNegative ? `-${result}` : result
+}
+
+/** 输入清洗:去逗号、仅保留数字与一个点、截断小数位 */
+export function sanitizeMoneyInput(value: string, precision = 2): string {
+  if (!value) return ''
+  // 去逗号
+  let cleaned = value.replace(/,/g, '')
+  // 去非数字非点字符
+  cleaned = cleaned.replace(/[^0-9.]/g, '')
+  // 只保留第一个小数点
+  const dotIndex = cleaned.indexOf('.')
+  if (dotIndex !== -1) {
+    const intPart = cleaned.slice(0, dotIndex + 1)
+    const decPart = cleaned.slice(dotIndex + 1).replace(/\./g, '')
+    cleaned = intPart + decPart
+  }
+  // 截断小数到 precision 位
+  if (dotIndex !== -1) {
+    const dotIdx = cleaned.indexOf('.')
+    const intPart = cleaned.slice(0, dotIdx + 1)
+    let decPart = cleaned.slice(dotIdx + 1)
+    if (decPart.length > precision) {
+      decPart = decPart.slice(0, precision)
+    }
+    cleaned = intPart + decPart
+  }
+  return cleaned
+}
+
+/** 展示:整数部分千分位 + 最多 precision 位小数 */
+export function formatMoneyDisplay(
+  value: string | number | null | undefined,
+  precision = 2
+): string {
+  if (value === null || value === undefined || value === '') return '0.00'
+  const num = typeof value === 'string' ? parseFloat(value) : value
+  if (isNaN(num)) return '0.00'
+  const isNegative = num < 0
+  const absNum = Math.abs(num)
+  const fixed = absNum.toFixed(precision)
+  const [intStr, decStr] = fixed.split('.')
+  const formatted = addThousandSeparators(intStr)
+  const result = `${formatted}.${decStr}`
+  return isNegative ? `-${result}` : result
+}
+
+/** 提交:number | null;空串 / 仅 '.' → null */
+export function parseMoneyToNumber(value: string, precision = 2): number | null {
+  const sanitized = sanitizeMoneyInput(value, precision)
+  if (!sanitized || sanitized === '.') return null
+  const num = Number(sanitized)
+  if (isNaN(num)) return null
+  const factor = Math.pow(10, precision)
+  return Math.round(num * factor) / factor
+}

+ 4 - 2
web-admin/src/views/basic-info/insurance-company/index.vue

@@ -200,7 +200,7 @@
             <el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
             <el-table-column prop="projectName" label="险种名称" min-width="160" align="center" header-align="center" />
             <el-table-column prop="projectFee" label="险种费用(元)" min-width="120" align="center" header-align="center">
-              <template #default="{ row }">{{ row.projectFee != null ? `¥${row.projectFee}` : '—' }}</template>
+              <template #default="{ row }">{{ row.projectFee != null ? '¥' + formatMoneyDisplay(row.projectFee) : '—' }}</template>
             </el-table-column>
             <el-table-column prop="status" label="状态" min-width="90" align="center" header-align="center">
               <template #default="{ row }">
@@ -245,7 +245,7 @@
                 <el-input v-model="itemForm.projectName" placeholder="请输入险种名称" />
               </el-form-item>
               <el-form-item label="险种费用(元)" prop="projectFee">
-                <el-input-number v-model="itemForm.projectFee" :min="0" :precision="2" style="width: 100%" />
+                <AmountInput v-model="itemForm.projectFee" :min="0" placeholder="请输入项目费用" />
               </el-form-item>
               <el-form-item label="状态" prop="status">
                 <el-radio-group v-model="itemForm.status">
@@ -305,6 +305,8 @@
     fetchDeleteInsuranceProjectPrice
   } from '@/api/admin'
   import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
 
   defineOptions({ name: 'BasicInfoInsuranceCompany' })
 

+ 4 - 11
web-admin/src/views/cell-storage/fullmoon-todo/index.vue

@@ -297,11 +297,10 @@
               <el-input v-model="formData.insurancePolicyNo" placeholder="请输入保险单号" />
             </el-form-item>
             <el-form-item label="保单费用(元)">
-              <el-input-number
+              <AmountInput
                 v-model="formData.insuranceFee"
-                :precision="2"
                 :min="0"
-                style="width: 100%"
+                placeholder="请输入保单费用"
               />
             </el-form-item>
             <el-form-item label="保险单生效时间">
@@ -415,7 +414,7 @@
                   </div>
                 </div>
                 <span class="upload-hint"
-                  >支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                  >支持图片、视频、PDF、Word、Excel等文件</span
                 >
               </div>
             </el-form-item>
@@ -474,6 +473,7 @@
     Delete
   } from '@element-plus/icons-vue'
   import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
+  import AmountInput from '@/components/common/amount-input/index.vue'
   import {
     fetchCollectionProtocolDetail,
     type CollectionProtocolRow
@@ -1062,13 +1062,6 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      attachmentUploadRef.value?.clearFiles()
-      return
-    }
-
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     formData.attachments.push({
       id: tempId,

+ 6 - 1
web-admin/src/views/cell-storage/payment-confirm/index.vue

@@ -285,16 +285,21 @@
   import { maskIdCard } from '@/utils/mask-sensitive'
   import { downloadAttachmentFile } from '@/utils/download-attachment'
   import {
-    formatPaymentAmount,
     formatPaymentDueDate,
     payModeLabel,
     protocolStatusLabel,
     protocolStatusTagType,
     transferTypeLabel
   } from '@/utils/protocol-payment-labels'
+  import { formatMoneyDisplay } from '@/utils/money-format'
 
   defineOptions({ name: 'CellStoragePaymentConfirm' })
 
+  function formatPaymentAmount(v: unknown): string {
+    if (v == null || v === '') return '-'
+    return '¥' + formatMoneyDisplay(v)
+  }
+
   function formatLocalDate(d: Date): string {
     const y = d.getFullYear()
     const m = String(d.getMonth() + 1).padStart(2, '0')

+ 6 - 13
web-admin/src/views/cell-storage/payment-info/index.vue

@@ -332,13 +332,11 @@
         >
           <div class="form-grid-2">
             <el-form-item label="退费金额(元)" prop="refundAmount" class="form-col-span-2">
-              <el-input-number
+              <AmountInput
                 v-model="refundForm.refundAmount"
                 :min="0.01"
-                :precision="2"
                 :step="100"
-                controls-position="right"
-                style="width: 100%"
+                placeholder="请输入退费金额"
               />
             </el-form-item>
             <el-form-item label="退费备注" prop="refundRemark" class="form-col-span-2">
@@ -447,6 +445,8 @@
   import { fetchUploadAttachment } from '@/api/business'
   import { maskIdCard } from '@/utils/mask-sensitive'
   import { isPaymentDueWithinWarnWindow } from '@/utils/protocol-payment-labels'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import {
     protocolStatusLabel,
     protocolStatusTagType,
@@ -544,11 +544,9 @@
     return isPaymentDueWithinWarnWindow(row.installmentDueDate) ? 'row-due-soon' : ''
   }
 
-  function formatAmount(v: unknown) {
+  function formatAmount(v: unknown): string {
     if (v == null || v === '') return '-'
-    const n = Number(v)
-    if (Number.isNaN(n)) return String(v)
-    return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+    return '¥' + formatMoneyDisplay(v)
   }
 
   async function loadData() {
@@ -668,11 +666,6 @@
   async function handleRefundAttachmentChange(uploadFile: { raw?: File }) {
     const file = uploadFile.raw
     if (!file) return
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      return
-    }
     const tempId = `temp_${Date.now()}`
     refundForm.attachments.push({
       id: tempId,

+ 15 - 23
web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

@@ -188,7 +188,7 @@
                         </div>
                       </div>
                       <span class="upload-hint"
-                        >支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                        >支持图片、视频、PDF、Word、Excel等文件</span
                       >
                     </div>
                   </el-form-item>
@@ -585,12 +585,10 @@
                       </el-select>
                     </el-form-item>
                     <el-form-item label="价格(元)">
-                      <el-input
+                      <AmountInput
                         v-model="row.price"
-                        type="number"
-                        step="any"
                         :min="0"
-                        style="width: 100%"
+                        placeholder="请输入价格"
                         @change="onSamplePriceChange"
                       />
                     </el-form-item>
@@ -844,7 +842,7 @@
                         class="detail-amount"
                         :class="detail.payStatus === 'yjf' ? 'green' : 'amber'"
                       >
-                        ¥{{ detail.dueAmount }}
+                        {{ fmtMoney(detail.dueAmount) }}
                       </span>
                       <span
                         v-if="detail.payStatus"
@@ -896,12 +894,10 @@
                             </el-select>
                           </el-form-item>
                           <el-form-item label="转账金额(元)">
-                            <el-input
+                            <AmountInput
                               v-model="detail.transferAmount"
-                              type="number"
-                              step="any"
                               :min="0"
-                              style="width: 100%"
+                              placeholder="请输入转账金额"
                             />
                           </el-form-item>
                           <el-form-item label="转账时间">
@@ -1010,7 +1006,7 @@
                               </div>
                             </div>
                             <span class="upload-hint"
-                              >支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                              >支持图片、视频、PDF、Word、Excel等文件</span
                             >
                           </div>
                         </div>
@@ -1215,6 +1211,8 @@
   import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
   import { useUserStore } from '@/store/modules/user'
   import { syncFqPaymentDetailRowCount } from '@/utils/protocol-payment-installment'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import {
     payModeLabel,
     newbornSexLabel,
@@ -1225,6 +1223,12 @@
     payStatusLabel
   } from '@/utils/status-tag-registry'
 
+  /** 金额只读展示:千分位 + ¥ 前缀 */
+  function fmtMoney(v: unknown): string {
+    if (v == null || v === '') return '—'
+    return '¥' + formatMoneyDisplay(v)
+  }
+
   const userStore = useUserStore()
 
   const customerId = ref<string | null>(null)
@@ -2738,13 +2742,6 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      attachmentUploadRef.value?.clearFiles()
-      return
-    }
-
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     formData.attachments.push({
       id: tempId,
@@ -2789,11 +2786,6 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      return
-    }
 
     const detail = formData.paymentDetails[detailIndex]
     if (!detail) return

+ 12 - 12
web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue

@@ -454,7 +454,7 @@
               <div class="data-item"
                 ><div class="data-label">保单费用(元)</div
                 ><div class="data-value">{{
-                  formatMoney(fullmoonInsuranceInfo?.insuranceFee)
+                  fmtMoney(fullmoonInsuranceInfo?.insuranceFee)
                 }}</div></div
               >
               <div class="data-item"
@@ -722,13 +722,13 @@
               <div class="payment-metric"
                 ><div class="pm-label">合同金额(元)</div
                 ><div class="pm-value pm-value--amount">{{
-                  payment?.contractAmount ? `¥${payment.contractAmount}` : '—'
+                  fmtMoney(payment?.contractAmount)
                 }}</div></div
               >
               <div class="payment-metric"
                 ><div class="pm-label">已收金额(元)</div
                 ><div class="pm-value pm-value--amount pm-value--received">{{
-                  receivedAmount ? `¥${receivedAmount}` : '—'
+                  fmtMoney(receivedAmount)
                 }}</div></div
               >
               <template v-if="payment?.payMode === 'dj'">
@@ -775,7 +775,7 @@
                       class="pdc-header-amount"
                       :class="payDetailColorClass(String(detail.payStatus || ''))"
                     >
-                      {{ detail.dueAmount ? `¥${detail.dueAmount}` : '—' }}
+                      {{ fmtMoney(detail.dueAmount) }}
                     </span>
                   </div>
                   <el-tag :type="payStatusTagType(String(detail.payStatus || ''))" size="small">
@@ -788,7 +788,7 @@
                   <div class="pdc-info-item">
                     <span class="info-label">应缴费金额(元)</span>
                     <span class="info-value">{{
-                      detail.dueAmount ? `¥${detail.dueAmount}` : '—'
+                      fmtMoney(detail.dueAmount)
                     }}</span>
                   </div>
                   <div class="pdc-info-item">
@@ -800,7 +800,7 @@
                   <div class="pdc-info-item">
                     <span class="info-label">转账金额(元)</span>
                     <span class="info-value">{{
-                      detail.transferAmount ? `¥${detail.transferAmount}` : '—'
+                      fmtMoney(detail.transferAmount)
                     }}</span>
                   </div>
                   <div class="pdc-info-item">
@@ -951,7 +951,7 @@
                                 <span
                                   class="flow-detail-card__value"
                                   :class="payDetailColorClass(String(detail.payStatus || ''))"
-                                  >{{ detail.refundAmount ? `¥${detail.refundAmount}` : '—' }}</span
+                                  >{{ fmtMoney(detail.refundAmount) }}</span
                                 >
                               </div>
                               <div
@@ -1172,6 +1172,7 @@
     fetchUserList
   } from '@/api/admin'
   import { downloadAttachmentFile } from '@/utils/download-attachment'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import {
     protocolStatusLabel,
     protocolStatusTagType,
@@ -1358,11 +1359,10 @@
     return val === '1' ? 'success' : 'info'
   }
 
-  function formatMoney(val: unknown) {
-    if (val === null || val === undefined || val === '') return '—'
-    const n = Number(val)
-    if (Number.isNaN(n)) return String(val)
-    return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+  /** 金额只读展示:千分位 + ¥ 前缀,null 显示 — */
+  function fmtMoney(v: unknown): string {
+    if (v == null || v === '') return '—'
+    return '¥' + formatMoneyDisplay(v)
   }
 
   function storageCertMakeStatusLabel(makeStatus: string) {

+ 6 - 1
web-admin/src/views/cell-storage/refund-confirm/index.vue

@@ -302,16 +302,21 @@
   import { maskIdCard } from '@/utils/mask-sensitive'
   import { downloadAttachmentFile } from '@/utils/download-attachment'
   import {
-    formatPaymentAmount,
     formatPaymentDueDate,
     payModeLabel,
     protocolStatusLabel,
     protocolStatusTagType,
     transferTypeLabel
   } from '@/utils/protocol-payment-labels'
+  import { formatMoneyDisplay } from '@/utils/money-format'
 
   defineOptions({ name: 'CellStorageRefundConfirm' })
 
+  function formatPaymentAmount(v: unknown): string {
+    if (v == null || v === '') return '-'
+    return '¥' + formatMoneyDisplay(v)
+  }
+
   function formatLocalDate(d: Date): string {
     const y = d.getFullYear()
     const m = String(d.getMonth() + 1).padStart(2, '0')

+ 1 - 8
web-admin/src/views/cell-storage/storage-cert/index.vue

@@ -221,7 +221,7 @@
                   </div>
                 </div>
               </div>
-              <span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span>
+              <span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件</span>
             </div>
           </el-form-item>
           <div class="form-grid-2">
@@ -504,13 +504,6 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      certAttachmentUploadRef.value?.clearFiles()
-      return
-    }
-
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     certAttachments.value.push({
       id: tempId,

+ 5 - 6
web-admin/src/views/pilot-service/cell-product-price/index.vue

@@ -74,7 +74,7 @@
           <el-table-column prop="specText" label="规格" min-width="120" align="center" header-align="center" />
           <el-table-column prop="unitPrice" label="单价" min-width="100" align="center" header-align="center">
             <template #default="{ row }">
-              {{ row.unitPrice != null ? `${row.unitPrice} 元` : '-' }}
+              {{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
             </template>
           </el-table-column>
           <el-table-column prop="remark" label="备注" min-width="150" align="center" header-align="center" />
@@ -164,13 +164,10 @@
               </el-select>
             </el-form-item>
             <el-form-item label="单价(元)" prop="unitPrice">
-              <el-input-number
+              <AmountInput
                 v-model="formData.unitPrice"
-                :precision="2"
                 :min="0"
-                :controls="false"
                 placeholder="请输入单价"
-                style="width: 100%"
               />
             </el-form-item>
             <el-form-item label="备注">
@@ -207,6 +204,8 @@
   import { ElMessage, ElMessageBox } from 'element-plus'
   import { Plus, Close, Check, View, Edit, Delete, ScaleToOriginal, RefreshLeft, Search } from '@element-plus/icons-vue'
   import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
   import {
     fetchCellProductPriceList,
@@ -279,7 +278,7 @@
           { label: '项目编码', value: row.projectCode },
           { label: '细胞类型名称', value: row.cellTypeName },
           { label: '规格', value: row.specText },
-          { label: '单价(元)', value: row.unitPrice != null ? row.unitPrice : '-' },
+          { label: '单价(元)', value: row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' },
           { label: '备注', value: row.remark, span: true }
         ]
       }

+ 401 - 293
web-admin/src/views/pilot-service/channel-service-order-finance/index.vue

@@ -19,7 +19,11 @@
       <div class="filter-block">
         <el-form :model="searchForm" class="filter-form" label-position="top" @submit.prevent>
           <p class="filter-hint">
-            {{ activeTab === 'pending' ? '展示待确认的流水记录。' : '展示已确认的流水记录。' }}
+            {{
+              activeTab === 'pending'
+                ? '展示待确认的流水记录。'
+                : '展示财务已确认或财务作废的流水记录。'
+            }}
           </p>
           <div class="filter-grid">
             <el-form-item label="渠道名称">
@@ -99,21 +103,61 @@
             </template>
           </el-table-column>
           <el-table-column prop="channelName" label="渠道名称" min-width="160" show-overflow-tooltip />
-          <el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
+          <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
           <el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
-          <el-table-column label="操作" min-width="200" fixed="right" align="center" header-align="center">
+          <template v-if="activeTab === 'done'">
+            <el-table-column label="财务操作" min-width="110" align="center">
+              <template #default="{ row }">
+                <el-tag
+                  v-if="row.financeOperateTypeLabel"
+                  :type="row.financeOperateType === 'void' ? 'info' : 'success'"
+                  size="small"
+                >
+                  {{ row.financeOperateTypeLabel }}
+                </el-tag>
+                <span v-else class="text-muted">—</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作人" min-width="120" show-overflow-tooltip>
+              <template #default="{ row }">
+                {{ row.financeOperateByName || '—' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="操作时间" min-width="170" align="center">
+              <template #default="{ row }">
+                {{ row.financeOperateTime || '—' }}
+              </template>
+            </el-table-column>
+          </template>
+          <el-table-column label="操作" min-width="300" fixed="right" align="center" header-align="center">
             <template #default="{ row }">
               <el-button class="btn-op-item" @click="handleView(row)">
                 <el-icon><View /></el-icon>查看
               </el-button>
-              <el-button
-                v-if="row.txnStatus === 'dqr'"
-                class="btn-op-item primary"
-                type="primary"
-                @click="openConfirmDialog(row)"
-              >
-                确认
-              </el-button>
+              <template v-if="row.txnStatus === 'dqr'">
+                <el-button
+                  class="btn-op-item primary"
+                  type="primary"
+                  @click="openConfirmDialog(row)"
+                >
+                  <el-icon><Check /></el-icon>确认
+                </el-button>
+                <el-popconfirm
+                  title="确定作废该流水吗?作废后不可恢复,业务需重新发起。"
+                  confirm-button-text="确定"
+                  cancel-button-text="取消"
+                  @confirm="handleVoidTxn(row)"
+                >
+                  <template #reference>
+                    <el-button
+                      class="btn-op-item op-warn"
+                      :loading="voidTxnId === row.id"
+                    >
+                      <el-icon><Delete /></el-icon>作废
+                    </el-button>
+                  </template>
+                </el-popconfirm>
+              </template>
             </template>
           </el-table-column>
         </el-table>
@@ -215,7 +259,7 @@
             </el-tag>
           </div>
           <div class="view-field-row">
-            <span class="view-field-label">金额:</span>
+            <span class="view-field-label">{{ txnAmountLabel(currentRow.txnType) }}:</span>
             <span class="amount-text">¥{{ formatAmount(currentRow.amount) }}</span>
           </div>
           <div class="view-field-row" v-if="currentRow.financeSubmitByName || currentRow.financeSubmitBy">
@@ -226,9 +270,9 @@
             <span class="view-field-label">提交时间:</span>
             <span class="view-field-text">{{ currentRow.financeSubmitTime }}</span>
           </div>
-          <div class="view-field-row" v-if="currentRow.financeSubmitRemark">
+          <div class="view-field-row" v-if="financeSubmitRemarkDisplay(currentRow)">
             <span class="view-field-label">备注:</span>
-            <span class="view-field-text">{{ currentRow.financeSubmitRemark }}</span>
+            <span class="view-field-text">{{ financeSubmitRemarkDisplay(currentRow) }}</span>
           </div>
         </div>
 
@@ -240,61 +284,124 @@
           <el-form-item label="财务备注">
             <el-input v-model="confirmForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
           </el-form-item>
-          <el-form-item label="确认凭证">
+          <el-form-item label="确认凭证" class="form-col-span-2">
             <div class="attachment-upload">
-              <div class="upload-cards-row">
-                <div
-                  v-for="(file, index) in confirmForm.attachments"
-                  :key="file.id"
-                  class="upload-card uploaded"
-                >
-                  <img
-                    v-if="isImageFile(file) && file.fileUrl"
-                    :src="fullFileUrl(file.fileUrl)"
-                    class="upload-card-preview"
-                    @click="handlePreviewAttachment(file)"
-                  />
-                  <div v-else class="upload-card-file">
-                    <el-icon size="24"><Document /></el-icon>
-                    <span class="upload-card-filename">{{ file.fileName }}</span>
-                  </div>
-                  <div class="upload-card-actions">
-                    <el-button size="small" link @click="handlePreviewAttachment(file)">
-                      <el-icon><View /></el-icon>
-                    </el-button>
-                    <el-button size="small" link @click="handleDownloadAttachment(file)">
-                      <el-icon><Download /></el-icon>
-                    </el-button>
-                    <el-button
-                      size="small"
-                      link
-                      type="danger"
-                      @click="confirmForm.attachments.splice(index, 1)"
-                    >
-                      <el-icon><Delete /></el-icon>
-                    </el-button>
-                  </div>
-                </div>
+              <div class="attachment-list">
                 <el-upload
                   ref="confirmUploadRef"
                   :auto-upload="false"
                   :show-file-list="false"
-                  accept="image/*,.pdf,.doc,.docx"
+                  :multiple="true"
+                  accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
                   @change="handleConfirmAttachmentChange"
                 >
-                  <div class="upload-card trigger">
-                    <el-icon size="24" color="#10b981"><Plus /></el-icon>
-                    <span>上传凭证</span>
+                  <div class="upload-card">
+                    <el-icon :size="28" color="#10b981"><Plus /></el-icon>
+                    <span class="upload-card-text">上传附件</span>
                   </div>
                 </el-upload>
+                <div
+                  v-for="(file, index) in confirmForm.attachments"
+                  :key="file.id || index"
+                  class="attachment-item"
+                  :class="{ 'is-uploading': file._uploading }"
+                >
+                  <div
+                    class="attachment-preview"
+                    @click="
+                      isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
+                    "
+                  >
+                    <template v-if="isImageFile(file) && file.fileUrl">
+                      <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
+                    </template>
+                    <template v-else-if="isImageFile(file) && !file.fileUrl">
+                      <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
+                    </template>
+                    <template v-else-if="isVideoFile(file)">
+                      <div class="attachment-preview__icon attachment-preview__icon--video">
+                        <el-icon :size="28"><VideoPlay /></el-icon>
+                      </div>
+                    </template>
+                    <template v-else>
+                      <div class="attachment-preview__icon attachment-preview__icon--doc">
+                        <el-icon :size="28"><Document /></el-icon>
+                      </div>
+                    </template>
+                  </div>
+                  <div class="attachment-footer">
+                    <span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
+                    <div class="attachment-actions">
+                      <el-icon
+                        v-if="isImageFile(file) || isVideoFile(file)"
+                        class="action-btn"
+                        title="预览"
+                        @click="handlePreviewAttachment(file)"
+                        ><View
+                      /></el-icon>
+                      <el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)"
+                        ><Download
+                      /></el-icon>
+                      <el-icon
+                        class="action-btn action-btn--danger"
+                        title="删除"
+                        @click="confirmForm.attachments.splice(index, 1)"
+                        ><Delete
+                      /></el-icon>
+                    </div>
+                  </div>
+                </div>
               </div>
+              <span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件</span>
             </div>
           </el-form-item>
         </el-form>
       </div>
       <template #footer>
-        <el-button @click="confirmVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitLoading" @click="submitConfirm">确认</el-button>
+        <el-button class="btn-ghost" @click="confirmVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-popconfirm
+          v-if="currentRow?.txnStatus === 'dqr'"
+          title="确定作废该流水吗?作废后不可恢复,业务需重新发起。"
+          confirm-button-text="确定"
+          cancel-button-text="取消"
+          @confirm="handleVoidTxn(currentRow)"
+        >
+          <template #reference>
+            <el-button class="btn-op-item op-warn" :loading="voidTxnId === currentRow?.id">
+              <el-icon><Delete /></el-icon>作废
+            </el-button>
+          </template>
+        </el-popconfirm>
+        <el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitConfirm">
+          <el-icon><Check /></el-icon>确认
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 提交前金额二次确认 -->
+    <el-dialog
+      v-model="amountConfirmVisible"
+      title="确认操作"
+      width="480px"
+      destroy-on-close
+      append-to-body
+      align-center
+      :close-on-click-modal="false"
+      class="finance-amount-confirm-dialog"
+      style="backdrop-filter: none"
+    >
+      <p v-if="currentRow" class="amount-confirm-text">
+        确认该笔操作 ¥{{ formatAmount(currentRow.amount) }}?
+      </p>
+      <template #footer>
+        <el-button class="btn-ghost" @click="amountConfirmVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="submitLoading" @click="executeSubmitConfirm">
+          <el-icon><Check /></el-icon>确认
+        </el-button>
       </template>
     </el-dialog>
 
@@ -320,7 +427,7 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, onMounted } from 'vue'
-  import { ElMessage, ElMessageBox } from 'element-plus'
+  import { ElMessage } from 'element-plus'
   import {
     Plus,
     Search,
@@ -328,7 +435,11 @@
     RefreshLeft,
     Download,
     Delete,
-    Document
+    Document,
+    Loading,
+    VideoPlay,
+    Close,
+    Check
   } from '@element-plus/icons-vue'
   import SystemViewDialog, {
     type ViewField,
@@ -338,10 +449,12 @@
   import {
     fetchServiceOrderTxnList,
     fetchServiceOrderTxnDetail,
-    fetchConfirmTxn
+    fetchConfirmTxn,
+    fetchVoidTxn
   } from '@/api/admin'
   import { fetchUploadAttachment } from '@/api/business'
   import { downloadAttachmentFile } from '@/utils/download-attachment'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import {
     txnTypeLabel,
     txnTypeTagType,
@@ -356,11 +469,18 @@
     return val ?? '-'
   }
 
-  function formatAmount(val: any): string {
-    if (val == null || val === '') return '0.00'
-    const num = Number(val)
-    if (isNaN(num)) return '0.00'
-    return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+  const formatAmount = (v: any): string => formatMoneyDisplay(v, 2)
+
+  function txnAmountLabel(txnType?: string) {
+    if (txnType === 'recharge') return '充值金额(元)'
+    if (txnType === 'refund') return '退款金额(元)'
+    if (txnType === 'purchase') return '购买金额(元)'
+    if (txnType === 'unsubscribe') return '退订金额(元)'
+    return '金额(元)'
+  }
+
+  function financeSubmitRemarkDisplay(d: Record<string, any>) {
+    return (d.financeSubmitRemark || d.remark || '').trim()
   }
 
   const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
@@ -396,10 +516,10 @@
   // 搜索与列表
   // ---------------------------------------------------------------------------
 
-  /** Tab 对应的流水状态列表 */
+  /** Tab 对应的流水状态列表(已处理 Tab 走后端 financeDoneOnly) */
   const TAB_STATUS_MAP: Record<string, string[]> = {
     pending: ['dqr'],
-    done: ['yqr']
+    done: []
   }
 
   function formatLocalDate(d: Date): string {
@@ -426,6 +546,7 @@
 
   const tableData = ref<any[]>([])
   const loading = ref(false)
+  const voidTxnId = ref('')
   const pagination = reactive({
     page: 1,
     pageSize: 20,
@@ -440,7 +561,9 @@
         pageSize: pagination.pageSize
       }
       if (searchForm.txnType) params.txnType = searchForm.txnType
-      if (searchForm.txnStatus.length > 0) {
+      if (activeTab.value === 'done') {
+        params.financeDoneOnly = true
+      } else if (searchForm.txnStatus.length > 0) {
         params.txnStatus = searchForm.txnStatus.join(',')
       }
       if (searchForm.keyword.trim()) params.keyword = searchForm.keyword.trim()
@@ -514,8 +637,12 @@
       }
     ]
 
-    // 余额变动
-    if (d.balanceBefore != null || d.balanceAfter != null) {
+    // 余额变动(待处理不展示;已处理中已作废的充值不展示)
+    const showBalanceSection =
+      activeTab.value !== 'pending' &&
+      !(d.txnStatus === 'yzf' && d.txnType === 'recharge') &&
+      (d.balanceBefore != null || d.balanceAfter != null)
+    if (showBalanceSection) {
       sections.push({
         title: '余额变动',
         fields: [
@@ -539,9 +666,10 @@
       const submitSection: ViewSection = {
         title: '发起确认',
         fields: [
+          { label: txnAmountLabel(d.txnType), value: `¥${formatAmount(d.amount)}` },
           { label: '提交人', value: d.financeSubmitByName || d.financeSubmitBy },
           { label: '提交时间', value: d.financeSubmitTime },
-          { label: '备注', value: d.financeSubmitRemark, span: true }
+          { label: '备注', value: financeSubmitRemarkDisplay(d), span: true }
         ]
       }
       if (d.financeSubmitAttachmentFiles?.length) {
@@ -552,11 +680,31 @@
       sections.push(submitSection)
     }
 
+    // 财务作废信息
+    if (d.txnStatus === 'yzf' && (d.voidTime || d.voidByName)) {
+      sections.push({
+        title: '财务作废',
+        fields: [
+          {
+            label: '作废人',
+            value: d.voidByName || d.voidBy,
+            tagType: 'info'
+          },
+          { label: '作废时间', value: d.voidTime }
+        ]
+      })
+    }
+
     // 确认信息
     if (d.financeConfirmTime) {
       const confirmSection: ViewSection = {
-        title: '确认信息',
+        title: '财务确认',
         fields: [
+          {
+            label: '确认人',
+            value: d.financeConfirmByName || d.financeConfirmBy,
+            tagType: 'success'
+          },
           { label: '确认时间', value: d.financeConfirmTime },
           { label: '财务备注', value: d.financeRemark, span: true }
         ]
@@ -594,6 +742,7 @@
   // 统一确认弹窗
   // ---------------------------------------------------------------------------
   const confirmVisible = ref(false)
+  const amountConfirmVisible = ref(false)
   const confirmUploadRef = ref()
   const confirmForm = reactive({
     remark: '',
@@ -607,22 +756,35 @@
     confirmVisible.value = true
   }
 
-  async function submitConfirm() {
+  async function handleVoidTxn(row: any) {
+    voidTxnId.value = row.id
+    try {
+      await fetchVoidTxn(row.id)
+      ElMessage.success('流水已作废')
+      if (currentRow.value?.id === row.id) {
+        confirmVisible.value = false
+        amountConfirmVisible.value = false
+        currentRow.value = null
+      }
+      await loadData()
+    } catch (e: any) {
+      ElMessage.error(e?.message || '作废失败')
+    } finally {
+      voidTxnId.value = ''
+    }
+  }
+
+  function submitConfirm() {
     if (!currentRow.value) return
     if (hasUploadingAttachments(confirmForm.attachments)) {
       ElMessage.warning('附件上传中,请稍候')
       return
     }
-    try {
-      await ElMessageBox.confirm(
-        `确认该笔操作 ¥${formatAmount(currentRow.value.amount)}?`,
-        '确认操作',
-        { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
-      )
-    } catch {
-      return
-    }
+    amountConfirmVisible.value = true
+  }
 
+  async function executeSubmitConfirm() {
+    if (!currentRow.value) return
     submitLoading.value = true
     try {
       const data: Record<string, any> = {
@@ -634,6 +796,7 @@
       }
       await fetchConfirmTxn(currentRow.value.id, data)
       ElMessage.success('确认成功')
+      amountConfirmVisible.value = false
       confirmVisible.value = false
       loadData()
     } catch {
@@ -647,12 +810,6 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      return
-    }
-
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     confirmForm.attachments.push({
       id: tempId,
@@ -746,6 +903,13 @@
     }
   }
 
+  .amount-confirm-text {
+    margin: 0;
+    font-size: 14px;
+    line-height: 1.5;
+    color: var(--mint-text, #334155);
+  }
+
   .form-section-divider {
     height: 1px;
     margin: 20px 0;
@@ -800,236 +964,180 @@
     min-width: 80px;
   }
 
-  /* 附件上传卡片化布局 */
-
-.attachment-upload {
-  width: 100%;
-}
-
-.upload-cards-row {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
-}
-
-.upload-card {
-  width: 140px;
-  height: 96px;
-  border: 1px dashed #a7f3d0;
-  border-radius: 8px;
-  background: #f0fdf4;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 6px;
-  cursor: pointer;
-  transition:
-    border-color 0.15s,
-    background 0.15s;
-
-  &:hover {
-    border-color: #10b981;
-    border-style: solid;
-    background: #ecfdf5;
+  /* 附件上传卡片化布局(对齐 ProtocolFormDialog / OpenSpec) */
+  .attachment-upload {
+    width: 100%;
   }
 
-  &.uploaded {
-    border-style: solid;
-    border-color: #e2e8f0;
+  .attachment-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 12px;
+  }
+
+  .upload-card {
+    width: 140px;
+    height: 96px;
+    border: 1px dashed #a7f3d0;
+    border-radius: 8px;
+    background: #f0fdf4;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    cursor: pointer;
+    transition:
+      border-color 0.15s,
+      background 0.15s;
+
+    &:hover {
+      border-color: #10b981;
+      border-style: solid;
+      background: #ecfdf5;
+    }
+  }
+
+  .upload-card-text {
+    font-size: 12px;
+    color: #10b981;
+    font-weight: 500;
+    line-height: 1;
+  }
+
+  .upload-hint {
+    display: block;
+    font-size: 11px;
+    color: #94a3b8;
+    line-height: 1;
+    margin-top: 8px;
+  }
+
+  .attachment-item {
+    width: 140px;
+    display: flex;
+    flex-direction: column;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
     background: #fff;
-    position: relative;
+    overflow: hidden;
+    transition:
+      border-color 0.15s,
+      box-shadow 0.15s;
 
     &:hover {
       border-color: #10b981;
       box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
     }
-  }
 
-  &.trigger {
-    span {
-      font-size: 12px;
-      color: #10b981;
-      font-weight: 500;
-      line-height: 1;
+    &.is-uploading {
+      opacity: 0.6;
+      pointer-events: none;
     }
   }
-}
-
-.upload-card-preview {
-  width: 100%;
-  height: 60px;
-  object-fit: cover;
-  border-radius: 6px 6px 0 0;
-}
-
-.upload-card-file {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  height: 60px;
-  gap: 4px;
-  color: #64748b;
-}
-
-.upload-card-filename {
-  font-size: 11px;
-  color: #334155;
-  max-width: 120px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.upload-card-actions {
-  display: flex;
-  justify-content: center;
-  gap: 4px;
-  height: 28px;
-  align-items: center;
-}
-
-.upload-card-text {
-  font-size: 12px;
-  color: #10b981;
-  font-weight: 500;
-  line-height: 1;
-}
-
-.upload-hint {
-  display: block;
-  font-size: 11px;
-  color: #94a3b8;
-  line-height: 1;
-  margin-top: 8px;
-}
-
-.attachment-list {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
-}
-
-.attachment-item {
-  width: 140px;
-  display: flex;
-  flex-direction: column;
-  border: 1px solid #e2e8f0;
-  border-radius: 8px;
-  background: #fff;
-  overflow: hidden;
-  transition:
-    border-color 0.15s,
-    box-shadow 0.15s;
-
-  &:hover {
-    border-color: #10b981;
-    box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
+
+  .attachment-preview {
+    width: 100%;
+    height: 96px;
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #f8fafc;
+    overflow: hidden;
+    cursor: pointer;
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
   }
 
-  &.is-uploading {
-    opacity: 0.6;
-    pointer-events: none;
+  .attachment-preview__icon {
+    width: 48px;
+    height: 48px;
+    border-radius: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &--video {
+      background: #ede9fe;
+      color: #7c3aed;
+    }
+
+    &--doc {
+      background: #e0f2fe;
+      color: #0284c7;
+    }
   }
-}
-
-.attachment-preview {
-  width: 100%;
-  height: 96px;
-  flex-shrink: 0;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #f8fafc;
-  overflow: hidden;
-  cursor: pointer;
-
-  img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-    display: block;
+
+  .attachment-footer {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    padding: 6px 8px;
   }
-}
-
-.attachment-preview__icon {
-  width: 48px;
-  height: 48px;
-  border-radius: 10px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  &--video {
-    background: #ede9fe;
-    color: #7c3aed;
+
+  .attachment-name {
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    font-size: 11px;
+    color: #334155;
+    line-height: 1.4;
+    word-break: break-all;
+    min-height: calc(11px * 1.4 * 2);
   }
 
-  &--doc {
-    background: #e0f2fe;
-    color: #0284c7;
+  .attachment-actions {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    gap: 8px;
+    margin-top: 4px;
+    flex-shrink: 0;
   }
-}
-
-.attachment-footer {
-  display: flex;
-  flex-direction: column;
-  flex: 1;
-  padding: 6px 8px;
-}
-
-.attachment-name {
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  font-size: 11px;
-  color: #334155;
-  line-height: 1.4;
-  word-break: break-all;
-  min-height: calc(11px * 1.4 * 2);
-}
-
-.attachment-actions {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  gap: 8px;
-  margin-top: 4px;
-  flex-shrink: 0;
-}
-
-.action-btn {
-  width: 22px;
-  height: 22px;
-  border-radius: 4px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 12px;
-  color: #64748b;
-  cursor: pointer;
-  background: #f1f5f9;
-  transition:
-    background 0.15s,
-    color 0.15s;
-
-  &:hover {
-    background: #10b981;
-    color: #fff;
+
+  .action-btn {
+    width: 22px;
+    height: 22px;
+    border-radius: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 12px;
+    color: #64748b;
+    cursor: pointer;
+    background: #f1f5f9;
+    transition:
+      background 0.15s,
+      color 0.15s;
+
+    &:hover {
+      background: #10b981;
+      color: #fff;
+    }
+
+    &--danger:hover {
+      background: #ef4444;
+      color: #fff;
+    }
   }
 
-  &--danger:hover {
-    background: #ef4444;
-    color: #fff;
+  .preview-container {
+    text-align: center;
   }
-}
 
-.preview-container {
-  text-align: center;
-}
+  .text-muted {
+    color: var(--mint-text-muted, #94a3b8);
+    font-size: 13px;
+  }
 
   /* 查看详情键值对 */
   .view-section {

+ 1200 - 0
web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnDialog.vue

@@ -0,0 +1,1200 @@
+<!-- 渠道服务订单 — 操作流水卡片弹窗(可折叠、编辑、发起财务确认、作废) -->
+<template>
+  <el-dialog
+    v-model="innerVisible"
+    :title="dialogTitle"
+    width="880px"
+    destroy-on-close
+    align-center
+    :close-on-click-modal="false"
+    class="service-order-txn-dialog"
+    style="backdrop-filter: none"
+  >
+    <div v-loading="loading" class="txn-dialog-body">
+      <p v-if="orderSummary" class="txn-dialog-summary">{{ orderSummary }}</p>
+      <el-empty v-if="!loading && !txnList.length" description="暂无操作流水" :image-size="64" />
+      <div v-else class="txn-card-list">
+        <article
+          v-for="txn in txnList"
+          :key="txn.id"
+          class="txn-card"
+          :class="[`txn-card--${txn.txnStatus || 'default'}`, { 'is-expanded': isExpanded(txn.id) }]"
+        >
+          <header class="txn-card__head" @click="toggleExpand(txn.id)">
+            <el-icon class="txn-card__chevron" :class="{ 'is-expanded': isExpanded(txn.id) }">
+              <ArrowRight />
+            </el-icon>
+            <div class="txn-card__line">
+              <div class="txn-card__tags">
+                <el-tag :type="txnTypeTagType(txn.txnType)" size="small">
+                  {{ txnTypeLabel(txn.txnType) }}
+                </el-tag>
+                <el-tag :type="txnStatusTagType(txn.txnStatus)" size="small">
+                  {{ txnStatusLabel(txn.txnStatus) }}
+                </el-tag>
+              </div>
+              <span class="txn-card__amount">¥{{ formatAmount(txn.amount) }}</span>
+              <span class="txn-card__sep" aria-hidden="true">|</span>
+              <span class="txn-card__meta-item">{{ txn.createTime || '—' }}</span>
+              <span v-if="remarkPreview(txn.remark)" class="txn-card__meta-item txn-card__meta-remark">
+                {{ remarkPreview(txn.remark) }}
+              </span>
+              <span
+                v-if="txn.financeSubmitAttachmentFiles?.length"
+                class="txn-card__meta-item txn-card__meta-attach"
+              >
+                附件 {{ txn.financeSubmitAttachmentFiles.length }} 个
+              </span>
+              <span v-if="txn.txnStatus === 'yzf'" class="txn-card__meta-item txn-card__meta-void">
+                作废 {{ txn.voidByName || '—' }} · {{ txn.voidTime || '—' }}
+              </span>
+            </div>
+          </header>
+
+          <div v-show="isExpanded(txn.id)" class="txn-card__body">
+            <div class="txn-card__sections">
+              <!-- 订单信息 -->
+              <section class="txn-card__section">
+                <h4 class="txn-card__section-title">订单信息</h4>
+                <dl class="txn-card__kv-grid">
+                  <div class="txn-card__kv">
+                    <dt>流水类型</dt>
+                    <dd>
+                      <el-tag :type="txnTypeTagType(txn.txnType)" size="small">
+                        {{ txnTypeLabel(txn.txnType) }}
+                      </el-tag>
+                    </dd>
+                  </div>
+                  <div class="txn-card__kv">
+                    <dt>金额(元)</dt>
+                    <dd class="txn-card__kv-amount">¥{{ formatAmount(txn.amount) }}</dd>
+                  </div>
+                  <div class="txn-card__kv">
+                    <dt>状态</dt>
+                    <dd>
+                      <el-tag :type="txnStatusTagType(txn.txnStatus)" size="small">
+                        {{ txnStatusLabel(txn.txnStatus) }}
+                      </el-tag>
+                    </dd>
+                  </div>
+                  <div v-if="txn.channelName" class="txn-card__kv">
+                    <dt>渠道名称</dt>
+                    <dd>{{ txn.channelName }}</dd>
+                  </div>
+                  <div v-if="txn.deptName" class="txn-card__kv">
+                    <dt>部门</dt>
+                    <dd>{{ txn.deptName }}</dd>
+                  </div>
+                  <div class="txn-card__kv">
+                    <dt>创建人</dt>
+                    <dd>{{ txn.createByName || txn.createBy || '—' }}</dd>
+                  </div>
+                  <div class="txn-card__kv">
+                    <dt>创建时间</dt>
+                    <dd>{{ txn.createTime || '—' }}</dd>
+                  </div>
+                  <div class="txn-card__kv txn-card__kv--full">
+                    <dt>备注</dt>
+                    <dd>{{ txn.remark || '—' }}</dd>
+                  </div>
+                  <template v-if="showBusinessVoid(txn)">
+                    <div class="txn-card__kv">
+                      <dt>作废人</dt>
+                      <dd>{{ txn.voidByName || txn.voidBy || '—' }}</dd>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>作废时间</dt>
+                      <dd>{{ txn.voidTime || '—' }}</dd>
+                    </div>
+                  </template>
+                  <template v-if="showBalanceSnapshot(txn)">
+                    <div class="txn-card__kv">
+                      <dt>变动前余额</dt>
+                      <dd>¥{{ formatAmount(txn.balanceBefore) }}</dd>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>变动后余额</dt>
+                      <dd>¥{{ formatAmount(txn.balanceAfter) }}</dd>
+                    </div>
+                  </template>
+                </dl>
+                <div v-if="txn.products?.length" class="txn-card__products">
+                  <span class="txn-card__products-label">产品明细</span>
+                  <el-table
+                    :data="txn.products"
+                    stripe
+                    size="small"
+                    class="list-table inner-table"
+                  >
+                    <el-table-column
+                      prop="projectName"
+                      label="项目"
+                      min-width="120"
+                      show-overflow-tooltip
+                    />
+                    <el-table-column prop="specText" label="规格" min-width="100" show-overflow-tooltip />
+                    <el-table-column prop="qty" label="数量" min-width="72" align="center" />
+                    <el-table-column label="金额(元)" min-width="100" align="right">
+                      <template #default="{ row }">¥{{ formatAmount(row.amount) }}</template>
+                    </el-table-column>
+                  </el-table>
+                </div>
+              </section>
+
+              <!-- 财务处理信息 -->
+              <section class="txn-card__section">
+                <h4 class="txn-card__section-title">财务处理信息</h4>
+                <template v-if="hasFinanceActivity(txn)">
+                  <dl v-if="txn.financeSubmitTime" class="txn-card__kv-grid txn-card__kv-grid--sub">
+                    <div class="txn-card__kv txn-card__kv--full txn-card__subhead">
+                      <dt>发起财务确认</dt>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>提交人</dt>
+                      <dd>{{ txn.financeSubmitByName || txn.financeSubmitBy || '—' }}</dd>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>提交时间</dt>
+                      <dd>{{ txn.financeSubmitTime }}</dd>
+                    </div>
+                    <div
+                      v-if="financeSubmitRemarkDisplay(txn)"
+                      class="txn-card__kv txn-card__kv--full"
+                    >
+                      <dt>提交备注</dt>
+                      <dd>{{ financeSubmitRemarkDisplay(txn) }}</dd>
+                    </div>
+                  </dl>
+                  <div
+                    v-if="txn.financeSubmitTime && txn.financeSubmitAttachmentFiles?.length"
+                    class="txn-card__attachments"
+                  >
+                    <span class="txn-card__attachments-label">提交凭证</span>
+                    <ProtocolAttachmentReadonly
+                      :files="txn.financeSubmitAttachmentFiles"
+                      @preview="handlePreviewAttachment"
+                      @download="handleDownloadAttachment"
+                    />
+                  </div>
+
+                  <dl v-if="txn.financeConfirmTime" class="txn-card__kv-grid txn-card__kv-grid--sub">
+                    <div class="txn-card__kv txn-card__kv--full txn-card__subhead">
+                      <dt>财务确认</dt>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>确认人</dt>
+                      <dd>{{ txn.financeConfirmByName || txn.financeConfirmBy || '—' }}</dd>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>确认时间</dt>
+                      <dd>{{ txn.financeConfirmTime }}</dd>
+                    </div>
+                    <div v-if="txn.financeRemark" class="txn-card__kv txn-card__kv--full">
+                      <dt>财务备注</dt>
+                      <dd>{{ txn.financeRemark }}</dd>
+                    </div>
+                  </dl>
+                  <div
+                    v-if="txn.financeConfirmAttachmentFiles?.length"
+                    class="txn-card__attachments"
+                  >
+                    <span class="txn-card__attachments-label">确认凭证</span>
+                    <ProtocolAttachmentReadonly
+                      :files="txn.financeConfirmAttachmentFiles"
+                      @preview="handlePreviewAttachment"
+                      @download="handleDownloadAttachment"
+                    />
+                  </div>
+
+                  <dl
+                    v-if="txn.txnStatus === 'yzf' && txn.financeSubmitTime"
+                    class="txn-card__kv-grid txn-card__kv-grid--sub"
+                  >
+                    <div class="txn-card__kv txn-card__kv--full txn-card__subhead">
+                      <dt>作废</dt>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>作废人</dt>
+                      <dd>{{ txn.voidByName || txn.voidBy || '—' }}</dd>
+                    </div>
+                    <div class="txn-card__kv">
+                      <dt>作废时间</dt>
+                      <dd>{{ txn.voidTime || '—' }}</dd>
+                    </div>
+                  </dl>
+                </template>
+                <p v-else class="txn-card__section-empty">暂无</p>
+              </section>
+            </div>
+
+            <footer v-if="txn.txnStatus === 'dsx'" class="txn-card__actions" @click.stop>
+              <el-button
+                class="btn-op-item"
+                @click="openEditDialog(txn)"
+              >
+                <el-icon><Edit /></el-icon>编辑
+              </el-button>
+              <el-button
+                class="btn-op-item primary"
+                type="primary"
+                :loading="actionTxnId === txn.id && actionType === 'submit'"
+                @click="handleSubmitFinance(txn)"
+              >
+                <el-icon><Check /></el-icon>发起财务确认
+              </el-button>
+              <el-popconfirm
+                title="确定作废该流水吗?作废后不可恢复。"
+                confirm-button-text="确定"
+                cancel-button-text="取消"
+                @confirm="handleVoidTxn(txn)"
+              >
+                <template #reference>
+                  <el-button
+                    class="btn-op-item op-warn"
+                    :loading="actionTxnId === txn.id && actionType === 'void'"
+                  >
+                    <el-icon><Delete /></el-icon>作废
+                  </el-button>
+                </template>
+              </el-popconfirm>
+            </footer>
+            <p v-else-if="txn.txnStatus === 'dqr'" class="txn-card__hint">
+              已提交财务确认,请至「渠道服务购买订单(财务确认)」页处理。
+            </p>
+          </div>
+        </article>
+      </div>
+    </div>
+
+    <template #footer>
+      <el-button class="btn-ghost" @click="innerVisible = false">
+        <el-icon><Close /></el-icon>关闭
+      </el-button>
+    </template>
+
+    <!-- 编辑流水 -->
+    <el-dialog
+      v-model="editVisible"
+      :title="editDialogTitle"
+      width="560px"
+      destroy-on-close
+      append-to-body
+      :close-on-click-modal="false"
+      class="service-order-txn-edit-dialog"
+      style="backdrop-filter: none"
+    >
+      <el-form label-position="top" class="dialog-form">
+        <el-form-item v-if="canEditAmount(editTxn)" label="金额(元)">
+          <AmountInput
+            v-model="editForm.amount"
+            :min="0.01"
+            :max="editAmountMax"
+            placeholder="请输入金额"
+          />
+        </el-form-item>
+        <el-form-item v-else-if="editTxn" label="金额(元)">
+          <span class="amount-text">¥{{ formatAmount(editTxn.amount) }}</span>
+          <p v-if="editTxn.txnType === 'purchase' || editTxn.txnType === 'unsubscribe'" class="form-label-tip">
+            购买/退订流水不可修改金额,请作废后重新发起
+          </p>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="editForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
+        </el-form-item>
+        <el-form-item v-if="canEditAttachments(editTxn)" label="附件">
+          <div class="attachment-upload">
+            <div class="attachment-list">
+              <el-upload
+                ref="editUploadRef"
+                :auto-upload="false"
+                :show-file-list="false"
+                :multiple="true"
+                accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
+                @change="handleEditAttachmentChange"
+              >
+                <div class="upload-card">
+                  <el-icon :size="28" color="#10b981"><Plus /></el-icon>
+                  <span class="upload-card-text">上传附件</span>
+                </div>
+              </el-upload>
+              <div
+                v-for="(file, index) in editForm.attachments"
+                :key="file.id || index"
+                class="attachment-item"
+                :class="{ 'is-uploading': file._uploading }"
+              >
+                <div
+                  class="attachment-preview"
+                  @click="
+                    isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
+                  "
+                >
+                  <template v-if="isImageFile(file) && file.fileUrl">
+                    <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
+                  </template>
+                  <template v-else-if="isImageFile(file) && !file.fileUrl">
+                    <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
+                  </template>
+                  <template v-else>
+                    <div class="attachment-preview__icon attachment-preview__icon--doc">
+                      <el-icon :size="28"><Document /></el-icon>
+                    </div>
+                  </template>
+                </div>
+                <div class="attachment-footer">
+                  <span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
+                  <div class="attachment-actions">
+                    <el-icon class="action-btn" title="预览" @click="handlePreviewAttachment(file)"
+                      ><View
+                    /></el-icon>
+                    <el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)"
+                      ><Download
+                    /></el-icon>
+                    <el-icon
+                      class="action-btn action-btn--danger"
+                      title="删除"
+                      @click="editForm.attachments.splice(index, 1)"
+                      ><Delete
+                    /></el-icon>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <span class="upload-hint">支持图片、视频、PDF 等</span>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button class="btn-ghost" @click="editVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="editLoading" @click="submitEdit">
+          <el-icon><Check /></el-icon>保存
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 发起财务确认二次确认 -->
+    <el-dialog
+      v-model="submitFinanceVisible"
+      title="提示"
+      width="480px"
+      destroy-on-close
+      append-to-body
+      :close-on-click-modal="false"
+      class="service-order-txn-submit-finance-dialog"
+      style="backdrop-filter: none"
+    >
+      <p class="submit-finance-confirm-text">确定发起财务确认?提交后财务可在确认页处理。</p>
+      <template #footer>
+        <el-button class="btn-ghost" @click="closeSubmitFinanceDialog">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="submitFinanceLoading" @click="confirmSubmitFinance">
+          <el-icon><Check /></el-icon>发起确认
+        </el-button>
+      </template>
+    </el-dialog>
+
+    <el-dialog v-model="previewVisible" title="附件预览" width="640px" append-to-body>
+      <div v-if="previewType === 'image'" class="preview-container">
+        <img
+          :src="previewUrl"
+          alt="预览"
+          style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
+        />
+      </div>
+      <div v-else class="preview-container">
+        <video
+          :src="previewUrl"
+          controls
+          style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
+        />
+      </div>
+    </el-dialog>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, reactive, ref, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import {
+  ArrowRight,
+  Check,
+  Close,
+  Delete,
+  Document,
+  Download,
+  Edit,
+  Loading,
+  Plus,
+  View
+} from '@element-plus/icons-vue'
+import AmountInput from '@/components/common/amount-input/index.vue'
+import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
+import {
+  fetchServiceOrderTxnList,
+  fetchSubmitTxnFinance,
+  fetchUpdateServiceOrderTxn,
+  fetchVoidTxn
+} from '@/api/admin'
+import { fetchUploadAttachment } from '@/api/business'
+import { downloadAttachmentFile } from '@/utils/download-attachment'
+import {
+  txnTypeLabel,
+  txnTypeTagType,
+  txnStatusLabel,
+  txnStatusTagType,
+  channelServiceOrderTypeLabel
+} from '@/utils/status-tag-registry'
+
+defineOptions({ name: 'ServiceOrderTxnDialog' })
+
+const props = defineProps<{
+  visible: boolean
+  order: Record<string, any> | null
+}>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  refreshed: []
+}>()
+
+const innerVisible = computed({
+  get: () => props.visible,
+  set: (v: boolean) => emit('update:visible', v)
+})
+
+const dialogTitle = computed(() => {
+  const name = props.order?.channelName || '渠道服务订单'
+  return `${name} · 操作流水`
+})
+
+const orderSummary = computed(() => {
+  const o = props.order
+  if (!o) return ''
+  const typeLabel = channelServiceOrderTypeLabel(o.orderType)
+  const dept = o.deptName ? ` / ${o.deptName}` : ''
+  return `${typeLabel}${dept}`
+})
+
+const loading = ref(false)
+const txnList = ref<Record<string, any>[]>([])
+const expandedTxnIds = ref<Set<string>>(new Set())
+const actionTxnId = ref('')
+const actionType = ref<'submit' | 'void' | ''>('')
+
+const editVisible = ref(false)
+const editLoading = ref(false)
+const submitFinanceVisible = ref(false)
+const submitFinanceLoading = ref(false)
+const pendingSubmitTxn = ref<Record<string, any> | null>(null)
+const editTxn = ref<Record<string, any> | null>(null)
+const editUploadRef = ref<{ clearFiles?: () => void }>()
+const editForm = reactive({
+  amount: null as number | null,
+  remark: '',
+  attachments: [] as any[]
+})
+
+const editDialogTitle = computed(() => {
+  if (!editTxn.value) return '编辑流水'
+  return `编辑${txnTypeLabel(editTxn.value.txnType)}流水`
+})
+
+const editAmountMax = computed(() => {
+  if (editTxn.value?.txnType === 'refund') {
+    return Number(props.order?.currentBalance || 0) || undefined
+  }
+  return undefined
+})
+
+const previewVisible = ref(false)
+const previewUrl = ref('')
+const previewType = ref<'image' | 'video'>('image')
+
+const formatAmount = (v: unknown): string => {
+  if (v == null || v === '') return '0.00'
+  const n = Number(v)
+  if (Number.isNaN(n)) return '0.00'
+  return n.toFixed(2)
+}
+
+const remarkPreview = (remark?: string) => {
+  const text = (remark || '').trim()
+  if (!text) return ''
+  return text.length > 36 ? `${text.slice(0, 36)}…` : text
+}
+
+const isExpanded = (id: string) => expandedTxnIds.value.has(id)
+
+const toggleExpand = (id: string) => {
+  const next = new Set(expandedTxnIds.value)
+  if (next.has(id)) next.delete(id)
+  else next.add(id)
+  expandedTxnIds.value = next
+}
+
+const initExpandedState = () => {
+  const firstId = txnList.value[0]?.id
+  expandedTxnIds.value = firstId ? new Set([firstId]) : new Set()
+}
+
+const financeSubmitRemarkDisplay = (txn: Record<string, any>) =>
+  (txn.financeSubmitRemark || txn.remark || '').trim()
+
+const showBalanceSnapshot = (txn: Record<string, any>) => {
+  const t = txn.txnType
+  if (t !== 'recharge' && t !== 'refund') return false
+  if (txn.txnStatus !== 'yqr') return false
+  return txn.balanceBefore != null || txn.balanceAfter != null
+}
+
+/** 仅业务已提交财务或财务已确认/作废后展示财务区块 */
+const hasFinanceActivity = (txn: Record<string, any>) =>
+  !!(txn.financeSubmitTime || txn.financeConfirmTime)
+
+/** 待生效阶段业务侧作废,作废信息归订单信息 */
+const showBusinessVoid = (txn: Record<string, any>) =>
+  txn.txnStatus === 'yzf' && !txn.financeSubmitTime
+
+const canEditAmount = (txn: Record<string, any> | null) =>
+  txn?.txnStatus === 'dsx' && (txn.txnType === 'recharge' || txn.txnType === 'refund')
+
+const canEditAttachments = (txn: Record<string, any> | null) =>
+  txn?.txnStatus === 'dsx' && txn.txnType === 'recharge'
+
+const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
+
+function fullFileUrl(suffix: string) {
+  if (!suffix) return ''
+  if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
+    return suffix
+  return `${minioBaseUrl}/${suffix}`
+}
+
+function isImageFile(file: { fileUrl?: string; fileName?: string; fileType?: string }) {
+  const url = (file.fileUrl || '').toLowerCase()
+  const name = (file.fileName || '').toLowerCase()
+  return (
+    /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
+    /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
+    (file.fileType || '').startsWith('image/')
+  )
+}
+
+function isVideoFile(file: { fileName?: string }) {
+  const name = (file.fileName || '').toLowerCase()
+  return ['.mp4', '.webm', '.mov', '.avi'].some((ext) => name.endsWith(ext))
+}
+
+function handlePreviewAttachment(file: Record<string, any>) {
+  if (!file?.fileUrl) return
+  previewUrl.value = fullFileUrl(file.fileUrl)
+  previewType.value = isVideoFile(file) ? 'video' : 'image'
+  previewVisible.value = true
+}
+
+function handleDownloadAttachment(file: Record<string, any>) {
+  downloadAttachmentFile(file, { fullFileUrl })
+}
+
+function cloneAttachmentFiles(files: any[] = []) {
+  return files.map((f) => ({ ...f, _uploading: false }))
+}
+
+const openEditDialog = (txn: Record<string, any>) => {
+  editTxn.value = txn
+  editForm.amount = Number(txn.amount) || null
+  editForm.remark = txn.remark || ''
+  editForm.attachments = cloneAttachmentFiles(txn.financeSubmitAttachmentFiles || [])
+  editVisible.value = true
+}
+
+const handleEditAttachmentChange = (uploadFile: any) => {
+  const file = uploadFile.raw || uploadFile
+  if (!file) return
+  uploadAttachmentItem(file, editForm.attachments, editUploadRef, 'channel-service-order')
+}
+
+async function uploadAttachmentItem(
+  file: File,
+  list: any[],
+  uploadRef: { value?: { clearFiles?: () => void } },
+  folder: string
+) {
+  const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
+  list.push({
+    id: tempId,
+    fileName: file.name,
+    fileUrl: '',
+    fileSize: file.size,
+    fileType: file.type || '',
+    _uploading: true
+  })
+  const idx = list.length - 1
+
+  try {
+    const fd = new FormData()
+    fd.append('folder', folder)
+    fd.append('file', file)
+    const res: any = await fetchUploadAttachment(fd)
+    list[idx] = {
+      ...list[idx],
+      id: res?.id || res?.data?.id || tempId,
+      fileName: file.name || res?.fileName || res?.data?.fileName,
+      fileUrl: res?.fileUrl ?? res?.data?.fileUrl ?? res?.data?.url ?? res?.url ?? '',
+      fileSize: res?.fileSize ?? res?.data?.fileSize ?? file.size,
+      fileType: res?.fileType ?? res?.data?.fileType ?? file.type ?? '',
+      _uploading: false
+    }
+  } catch {
+    ElMessage.error('文件上传失败')
+    list.splice(idx, 1)
+  } finally {
+    nextTick(() => uploadRef.value?.clearFiles?.())
+  }
+}
+
+function hasUploadingAttachments(list: any[]) {
+  return list.some((item) => item._uploading)
+}
+
+function joinAttachmentUrls(list: any[]) {
+  return list
+    .filter((item) => item.fileUrl && !item._uploading)
+    .map((item) => item.fileUrl)
+    .join(',')
+}
+
+const submitEdit = async () => {
+  if (!editTxn.value) return
+  const txn = editTxn.value
+  const payload: Record<string, any> = {
+    remark: editForm.remark
+  }
+  if (canEditAmount(txn)) {
+    if (!editForm.amount || editForm.amount <= 0) {
+      ElMessage.warning('请输入有效金额')
+      return
+    }
+    payload.amount = editForm.amount
+  }
+  if (canEditAttachments(txn)) {
+    if (hasUploadingAttachments(editForm.attachments)) {
+      ElMessage.warning('附件正在上传,请稍候')
+      return
+    }
+    payload.financeSubmitAttachments = joinAttachmentUrls(editForm.attachments)
+  }
+  editLoading.value = true
+  try {
+    await fetchUpdateServiceOrderTxn(txn.id, payload)
+    ElMessage.success('保存成功')
+    editVisible.value = false
+    await loadTxnList()
+    emit('refreshed')
+  } catch (e: any) {
+    ElMessage.error(e?.message || '保存失败')
+  } finally {
+    editLoading.value = false
+  }
+}
+
+async function loadTxnList() {
+  const orderId = props.order?.id
+  if (!orderId) {
+    txnList.value = []
+    expandedTxnIds.value = new Set()
+    return
+  }
+  loading.value = true
+  try {
+    const res: any = await fetchServiceOrderTxnList({
+      serviceOrderId: orderId,
+      page: 1,
+      pageSize: 100
+    })
+    txnList.value = res?.data?.list || res?.list || []
+    initExpandedState()
+  } catch {
+    ElMessage.error('加载操作流水失败')
+    txnList.value = []
+    expandedTxnIds.value = new Set()
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSubmitFinance(txn: Record<string, any>) {
+  pendingSubmitTxn.value = txn
+  submitFinanceVisible.value = true
+}
+
+function closeSubmitFinanceDialog() {
+  submitFinanceVisible.value = false
+  pendingSubmitTxn.value = null
+}
+
+async function confirmSubmitFinance() {
+  const txn = pendingSubmitTxn.value
+  if (!txn) return
+  submitFinanceLoading.value = true
+  actionTxnId.value = txn.id
+  actionType.value = 'submit'
+  try {
+    const payload: Record<string, string> = {}
+    if (txn.financeSubmitAttachments) {
+      payload.financeSubmitAttachments = txn.financeSubmitAttachments
+    }
+    if (txn.financeSubmitRemark) {
+      payload.financeSubmitRemark = txn.financeSubmitRemark
+    }
+    await fetchSubmitTxnFinance(txn.id, payload)
+    ElMessage.success('已发起财务确认')
+    closeSubmitFinanceDialog()
+    await loadTxnList()
+    emit('refreshed')
+  } catch (e: any) {
+    ElMessage.error(e?.message || '操作失败')
+  } finally {
+    submitFinanceLoading.value = false
+    actionTxnId.value = ''
+    actionType.value = ''
+  }
+}
+
+async function handleVoidTxn(txn: Record<string, any>) {
+  actionTxnId.value = txn.id
+  actionType.value = 'void'
+  try {
+    await fetchVoidTxn(txn.id)
+    ElMessage.success('流水已作废')
+    await loadTxnList()
+    emit('refreshed')
+  } catch (e: any) {
+    ElMessage.error(e?.message || '作废失败')
+  } finally {
+    actionTxnId.value = ''
+    actionType.value = ''
+  }
+}
+
+watch(
+  () => [props.visible, props.order?.id] as const,
+  ([visible, orderId]) => {
+    if (visible && orderId) loadTxnList()
+  }
+)
+
+defineExpose({ reload: loadTxnList })
+</script>
+
+<style lang="scss" scoped>
+.txn-dialog-body {
+  min-height: 120px;
+  max-height: calc(100vh - 220px);
+  overflow-y: auto;
+  padding-right: 4px;
+}
+
+.txn-dialog-summary {
+  margin: 0 0 16px;
+  font-size: 13px;
+  color: var(--mint-text-muted, #64748b);
+}
+
+.txn-card-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.txn-card {
+  border: 1px solid var(--mint-border, #d9e8e2);
+  border-radius: 10px;
+  background: #fff;
+  overflow: hidden;
+  transition: border-color 0.15s ease;
+
+  &--dsx {
+    border-color: #a7f3d0;
+  }
+
+  &--dqr {
+    border-color: #fde68a;
+  }
+
+  &--yqr {
+    border-color: #bbf7d0;
+  }
+
+  &--yzf {
+    border-color: #e2e8f0;
+    opacity: 0.92;
+  }
+
+  &.is-expanded {
+    &--dsx {
+      background: #f8fffc;
+    }
+
+    &--dqr {
+      background: #fffbeb;
+    }
+
+    &--yqr {
+      background: #f0fdf4;
+    }
+  }
+}
+
+.txn-card__head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  cursor: pointer;
+  user-select: none;
+
+  &:hover {
+    background: rgba(16, 185, 129, 0.04);
+  }
+}
+
+.txn-card__chevron {
+  flex-shrink: 0;
+  font-size: 16px;
+  color: var(--mint-text-muted, #64748b);
+  transition: transform 0.2s ease;
+
+  &.is-expanded {
+    transform: rotate(90deg);
+    color: var(--mint-primary, #10b981);
+  }
+}
+
+.txn-card__line {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  flex-wrap: nowrap;
+  gap: 10px;
+  font-size: 13px;
+  line-height: 1.4;
+  overflow: hidden;
+}
+
+.txn-card__tags {
+  display: flex;
+  flex-shrink: 0;
+  gap: 6px;
+}
+
+.txn-card__amount {
+  flex-shrink: 0;
+  font-size: 15px;
+  font-weight: 700;
+  color: var(--mint-primary-dark, #059669);
+  white-space: nowrap;
+}
+
+.txn-card__sep {
+  flex-shrink: 0;
+  color: #cbd5e1;
+  font-size: 12px;
+}
+
+.txn-card__meta-item {
+  flex-shrink: 1;
+  min-width: 0;
+  color: var(--mint-text-muted, #64748b);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.txn-card__meta-remark {
+  color: var(--mint-text, #475569);
+  max-width: 200px;
+}
+
+.txn-card__meta-attach {
+  flex-shrink: 0;
+  color: var(--mint-primary-dark, #059669);
+}
+
+.txn-card__meta-void {
+  flex-shrink: 0;
+  color: #94a3b8;
+}
+
+.txn-card__body {
+  padding: 0 14px 14px 40px;
+}
+
+.txn-card__sections {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px 16px;
+}
+
+@media (max-width: 720px) {
+  .txn-card__sections {
+    grid-template-columns: 1fr;
+  }
+}
+
+.txn-card__section {
+  padding: 12px;
+  border: 1px solid var(--mint-border, #e8f0ec);
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.7);
+}
+
+.txn-card__section-title {
+  margin: 0 0 10px;
+  padding-bottom: 8px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--mint-text, #334155);
+  border-bottom: 1px solid var(--mint-border, #e8f0ec);
+}
+
+.txn-card__section-empty {
+  margin: 0;
+  font-size: 13px;
+  color: var(--mint-text-muted, #94a3b8);
+  line-height: 1.5;
+}
+
+.txn-card__kv-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px 12px;
+  margin: 0;
+
+  &--sub {
+    margin-top: 4px;
+
+    & + & {
+      margin-top: 12px;
+      padding-top: 12px;
+      border-top: 1px dashed var(--mint-border, #e8f0ec);
+    }
+  }
+}
+
+.txn-card__kv {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+
+  dt {
+    margin: 0;
+    font-size: 12px;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  dd {
+    margin: 0;
+    font-size: 13px;
+    color: var(--mint-text, #334155);
+    word-break: break-word;
+  }
+
+  &--full {
+    grid-column: 1 / -1;
+  }
+}
+
+.txn-card__kv-amount {
+  font-weight: 600;
+  color: var(--mint-primary-dark, #059669);
+}
+
+.txn-card__subhead dt {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--mint-primary-dark, #059669);
+}
+
+.txn-card__products {
+  margin-top: 12px;
+
+  &-label {
+    display: block;
+    font-size: 12px;
+    color: var(--mint-text-muted, #64748b);
+    margin-bottom: 8px;
+  }
+}
+
+.txn-card__attachments {
+  margin-top: 10px;
+
+  & + .txn-card__kv-grid--sub {
+    margin-top: 12px;
+    padding-top: 12px;
+    border-top: 1px dashed var(--mint-border, #e8f0ec);
+  }
+
+  &-label {
+    display: block;
+    font-size: 12px;
+    color: var(--mint-text-muted, #64748b);
+    margin-bottom: 8px;
+  }
+}
+
+.txn-card__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 14px;
+  padding-top: 12px;
+  border-top: 1px solid var(--mint-border, #e8f0ec);
+}
+
+.txn-card__hint {
+  margin: 12px 0 0;
+  padding: 8px 10px;
+  font-size: 13px;
+  line-height: 1.5;
+  color: #b45309;
+  background: #fffbeb;
+  border-radius: 8px;
+}
+
+.amount-text {
+  font-weight: 600;
+  color: var(--mint-primary, #10b981);
+}
+
+.form-label-tip {
+  margin: 6px 0 0;
+  font-size: 12px;
+  color: var(--mint-text-muted, #64748b);
+}
+
+.attachment-upload {
+  width: 100%;
+}
+
+.attachment-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.upload-card {
+  width: 140px;
+  height: 96px;
+  border: 1px dashed #a7f3d0;
+  border-radius: 8px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  cursor: pointer;
+  background: #f8fffc;
+
+  &:hover {
+    border-color: #10b981;
+  }
+}
+
+.upload-card-text {
+  font-size: 12px;
+  color: #64748b;
+}
+
+.upload-hint {
+  display: block;
+  margin-top: 8px;
+  font-size: 12px;
+  color: var(--mint-text-muted, #64748b);
+}
+
+.attachment-item {
+  width: 140px;
+  border: 1px solid var(--mint-border, #e2e8f0);
+  border-radius: 8px;
+  overflow: hidden;
+  background: #fff;
+
+  &.is-uploading {
+    opacity: 0.7;
+  }
+}
+
+.attachment-preview {
+  height: 72px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f8fafc;
+  cursor: pointer;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+}
+
+.attachment-preview__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.attachment-footer {
+  padding: 6px 8px;
+}
+
+.attachment-name {
+  display: block;
+  font-size: 11px;
+  color: #334155;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.attachment-actions {
+  display: flex;
+  justify-content: center;
+  gap: 6px;
+  margin-top: 4px;
+}
+
+.action-btn {
+  width: 22px;
+  height: 22px;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  color: #64748b;
+  cursor: pointer;
+  background: #f1f5f9;
+
+  &:hover {
+    background: #10b981;
+    color: #fff;
+  }
+
+  &--danger:hover {
+    background: #ef4444;
+  }
+}
+
+.preview-container {
+  text-align: center;
+}
+</style>

+ 94 - 0
web-admin/src/views/pilot-service/channel-service-order/ServiceOrderTxnTable.vue

@@ -0,0 +1,94 @@
+<!-- 渠道服务订单 — 操作流水标准表格(查看弹窗等只读场景) -->
+<template>
+  <div class="list-body list-wrap service-order-txn-table-wrap">
+    <el-table
+      v-if="rows.length"
+      :data="rows"
+      stripe
+      class="list-table inner-table"
+      :max-height="maxHeight"
+    >
+      <el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
+      <el-table-column label="类型" min-width="120" align="center" header-align="center">
+        <template #default="{ row }">
+          <el-tag :type="txnTypeTagType(row.txnType)" size="small">
+            {{ txnTypeLabel(row.txnType) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="金额(元)" min-width="130" align="right" header-align="center">
+        <template #default="{ row }">
+          <span class="amount-text">¥{{ formatAmount(row.amount) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" min-width="120" align="center" header-align="center">
+        <template #default="{ row }">
+          <el-tag :type="txnStatusTagType(row.txnStatus)" size="small">
+            {{ txnStatusLabel(row.txnStatus) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
+      <el-table-column prop="createTime" label="创建时间" min-width="170" align="center" header-align="center" />
+      <el-table-column label="作废信息" min-width="200" show-overflow-tooltip>
+        <template #default="{ row }">
+          <span v-if="row.txnStatus === 'yzf'" class="void-info">
+            {{ row.voidByName || '—' }} · {{ row.voidTime || '—' }}
+          </span>
+          <span v-else class="void-info muted">—</span>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-empty v-else description="暂无操作流水" :image-size="48" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  txnTypeLabel,
+  txnTypeTagType,
+  txnStatusLabel,
+  txnStatusTagType
+} from '@/utils/status-tag-registry'
+import { formatMoneyDisplay } from '@/utils/money-format'
+
+defineOptions({ name: 'ServiceOrderTxnTable' })
+
+withDefaults(
+  defineProps<{
+    rows: Record<string, any>[]
+    maxHeight?: number | string
+  }>(),
+  {
+    rows: () => [],
+    maxHeight: 360
+  }
+)
+
+const formatAmount = (v: unknown): string => {
+  if (v == null || v === '') return '0.00'
+  return formatMoneyDisplay(v)
+}
+</script>
+
+<style lang="scss" scoped>
+.service-order-txn-table-wrap {
+  margin-top: 0;
+  padding: 0;
+  border: none;
+}
+
+.amount-text {
+  font-weight: 600;
+  color: var(--mint-primary, #10b981);
+}
+
+.void-info {
+  font-size: 13px;
+  color: var(--mint-text-muted, #64748b);
+
+  &.muted {
+    color: #cbd5e1;
+  }
+}
+</style>

+ 238 - 140
web-admin/src/views/pilot-service/channel-service-order/index.vue

@@ -139,15 +139,24 @@
             </template>
           </el-table-column>
           <el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
-          <el-table-column label="操作" min-width="440" fixed="right" align="center" header-align="center">
+          <el-table-column label="操作" min-width="520" fixed="right" align="center" header-align="center">
             <template #default="{ row }">
-              <el-button class="btn-op-item primary" @click="handleView(row)">查看</el-button>
+              <el-button class="btn-op-item primary" @click="handleView(row)">
+                <el-icon><View /></el-icon>查看
+              </el-button>
+              <el-button
+                v-if="row.orderType === 'yfk' || row.orderType === 'gmcp'"
+                class="btn-op-item"
+                @click="openTxnDialog(row)"
+              >
+                <el-icon><List /></el-icon>操作流水
+              </el-button>
               <el-button
                 class="btn-op-item"
                 v-if="(row.orderType === 'yfk' || row.orderType === 'gmcp') && row.orderStatus === 'dsx'"
                 @click="handleEdit(row)"
               >
-                编辑
+                <el-icon><Edit /></el-icon>编辑
               </el-button>
               <el-popconfirm
                 v-if="(row.orderType === 'yfk' || row.orderType === 'gmcp') && row.orderStatus === 'dsx'"
@@ -157,7 +166,9 @@
                 @confirm="handleDelete(row)"
               >
                 <template #reference>
-                  <el-button class="btn-op-item op-warn">删除</el-button>
+                  <el-button class="btn-op-item op-warn">
+                    <el-icon><Delete /></el-icon>删除
+                  </el-button>
                 </template>
               </el-popconfirm>
               <!-- yfk 充值型:发起充值 -->
@@ -167,7 +178,7 @@
                 type="primary"
                 @click="openRechargeDialog(row)"
               >
-                发起充值
+                <el-icon><Wallet /></el-icon>发起充值
               </el-button>
               <!-- yfk 充值型:申请退款 -->
               <el-button
@@ -175,7 +186,7 @@
                 class="btn-op-item"
                 @click="openRefundDialog(row)"
               >
-                申请退款
+                <el-icon><Sell /></el-icon>申请退款
               </el-button>
               <!-- gmcp 购买型:购买产品 -->
               <el-button
@@ -184,7 +195,7 @@
                 type="primary"
                 @click="openPurchaseDialog(row)"
               >
-                购买产品
+                <el-icon><ShoppingCart /></el-icon>购买产品
               </el-button>
               <!-- gmcp 购买型:退订产品 -->
               <el-button
@@ -192,7 +203,7 @@
                 class="btn-op-item"
                 @click="openUnsubscribeDialog(row)"
               >
-                退订产品
+                <el-icon><Remove /></el-icon>退订产品
               </el-button>
               <!-- 作废 -->
               <el-button
@@ -201,7 +212,7 @@
                 type="danger"
                 @click="handleVoid(row)"
               >
-                作废
+                <el-icon><Delete /></el-icon>作废
               </el-button>
             </template>
           </el-table-column>
@@ -235,58 +246,46 @@
     >
       <!-- Product holdings for gmcp -->
       <template v-if="viewData.orderType === 'gmcp'" #order-products>
-        <el-table
-          v-if="viewData.products?.length"
-          :data="viewData.products"
-          stripe
-          class="list-table inner-table"
-          size="small"
-        >
-          <el-table-column type="index" label="序号" min-width="60" align="center" />
-          <el-table-column prop="projectName" label="项目名称" min-width="150" show-overflow-tooltip />
-          <el-table-column prop="cellType" label="细胞类型" min-width="120" show-overflow-tooltip />
-          <el-table-column prop="specText" label="规格文本" min-width="150" show-overflow-tooltip />
-          <el-table-column label="协议单价(元)" min-width="120" align="right">
-            <template #default="{ row }">¥{{ formatAmount(row.agreementUnitPrice) }}</template>
-          </el-table-column>
-          <el-table-column prop="currentQty" label="当前持有" min-width="100" align="center" />
-          <el-table-column prop="totalBuyQty" label="累计购买" min-width="100" align="center" />
-          <el-table-column prop="totalUnsubscribeQty" label="累计退订" min-width="100" align="center" />
-        </el-table>
-        <el-empty v-else description="暂无产品持仓" :image-size="48" />
+        <div class="list-body list-wrap view-inner-table-wrap">
+          <el-table
+            v-if="viewData.products?.length"
+            :data="viewData.products"
+            stripe
+            class="list-table inner-table"
+            max-height="360"
+          >
+            <el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
+            <el-table-column prop="projectName" label="项目名称" min-width="150" show-overflow-tooltip />
+            <el-table-column prop="cellType" label="细胞类型" min-width="120" show-overflow-tooltip />
+            <el-table-column prop="specText" label="规格文本" min-width="150" show-overflow-tooltip />
+            <el-table-column label="协议单价(元)" min-width="130" align="right" header-align="center">
+              <template #default="{ row }">¥{{ formatAmount(row.agreementUnitPrice) }}</template>
+            </el-table-column>
+            <el-table-column prop="currentQty" label="当前持有" min-width="100" align="center" header-align="center" />
+            <el-table-column prop="totalBuyQty" label="累计购买" min-width="100" align="center" header-align="center" />
+            <el-table-column
+              prop="totalUnsubscribeQty"
+              label="累计退订"
+              min-width="100"
+              align="center"
+              header-align="center"
+            />
+          </el-table>
+          <el-empty v-else description="暂无产品持仓" :image-size="48" />
+        </div>
       </template>
-      <!-- Transaction timeline -->
-      <template v-if="viewData.transactions?.length" #txn-timeline>
-        <el-table
-          :data="viewData.transactions"
-          stripe
-          class="list-table inner-table"
-          size="small"
-        >
-          <el-table-column type="index" label="序号" min-width="60" align="center" />
-          <el-table-column label="类型" min-width="100" align="center">
-            <template #default="{ row }">
-              <el-tag :type="txnTypeTagType(row.txnType)" size="small">
-                {{ txnTypeLabel(row.txnType) }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column label="金额(元)" min-width="120" align="right">
-            <template #default="{ row }">¥{{ formatAmount(row.amount) }}</template>
-          </el-table-column>
-          <el-table-column label="状态" min-width="100" align="center">
-            <template #default="{ row }">
-              <el-tag :type="txnStatusTagType(row.txnStatus)" size="small">
-                {{ txnStatusLabel(row.txnStatus) }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
-          <el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
-        </el-table>
+      <template #txn-timeline>
+        <ServiceOrderTxnTable :rows="viewData.transactions || []" />
       </template>
     </SystemViewDialog>
 
+    <ServiceOrderTxnDialog
+      ref="txnDialogRef"
+      v-model:visible="txnDialogVisible"
+      :order="txnDialogOrder"
+      @refreshed="onTxnDialogRefreshed"
+    />
+
     <!-- ═══════════════════════════════════════════════════════════════════ -->
     <!-- 新增/编辑弹窗 -->
     <!-- ═══════════════════════════════════════════════════════════════════ -->
@@ -338,17 +337,6 @@
             <el-form-item label="部门名称" prop="deptName">
               <el-input v-model="formData.deptName" placeholder="请输入部门名称" />
             </el-form-item>
-            <!-- 预付款充值 - 充值金额 -->
-            <el-form-item v-if="formData.orderType === 'yfk'" label="充值金额(元)" prop="prepayAmount">
-              <el-input-number
-                v-model="formData.prepayAmount"
-                :min="0.01"
-                :precision="2"
-                :step="100"
-                controls-position="right"
-                style="width: 100%"
-              />
-            </el-form-item>
             <!-- 渠道协议(预付款充值 / 购买产品均必填,后续按协议价指导中试服务单) -->
             <el-form-item label="渠道协议" prop="channelAgreementId">
               <el-select
@@ -415,13 +403,10 @@
                   </el-table-column>
                   <el-table-column label="单价(元)" min-width="120" align="right">
                     <template #default="{ row }">
-                      <el-input-number
+                      <AmountInput
                         v-model="row.unitPrice"
-                        class="product-detail-cell-control"
                         :min="0"
-                        :precision="2"
-                        :step="1"
-                        controls-position="right"
+                        class="product-detail-cell-control"
                         @change="calcItemAmount(row)"
                       />
                     </template>
@@ -445,7 +430,9 @@
                   </el-table-column>
                   <el-table-column label="操作" min-width="70" align="center">
                     <template #default="{ $index }">
-                      <el-button link type="danger" @click="removeFormItem($index)">删除</el-button>
+                      <el-button link type="danger" @click="removeFormItem($index)">
+                        <el-icon><Delete /></el-icon>删除
+                      </el-button>
                     </template>
                   </el-table-column>
                 </el-table>
@@ -491,18 +478,93 @@
     </el-dialog>
 
     <!-- 发起充值弹窗 -->
-    <el-dialog v-model="rechargeVisible" title="发起充值" width="480px" destroy-on-close :close-on-click-modal="false">
+    <el-dialog v-model="rechargeVisible" title="发起充值" width="560px" destroy-on-close :close-on-click-modal="false">
       <el-form label-position="top">
         <el-form-item label="充值金额(元)">
-          <el-input-number v-model="rechargeForm.amount" :min="0.01" :precision="2" :step="100" controls-position="right" style="width: 100%" />
+          <AmountInput
+            v-model="rechargeForm.amount"
+            :min="0.01"
+            placeholder="请输入充值金额"
+          />
         </el-form-item>
         <el-form-item label="备注">
           <el-input v-model="rechargeForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
         </el-form-item>
+        <el-form-item label="附件">
+          <div class="attachment-upload">
+            <div class="attachment-list">
+              <el-upload
+                ref="rechargeUploadRef"
+                :auto-upload="false"
+                :show-file-list="false"
+                :multiple="true"
+                accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
+                @change="handleRechargeAttachmentChange"
+              >
+                <div class="upload-card">
+                  <el-icon :size="28" color="#10b981"><Plus /></el-icon>
+                  <span class="upload-card-text">上传附件</span>
+                </div>
+              </el-upload>
+              <div
+                v-for="(file, index) in rechargeForm.attachments"
+                :key="file.id || index"
+                class="attachment-item"
+                :class="{ 'is-uploading': file._uploading }"
+              >
+                <div
+                  class="attachment-preview"
+                  @click="
+                    isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
+                  "
+                >
+                  <template v-if="isImageFile(file) && file.fileUrl">
+                    <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
+                  </template>
+                  <template v-else-if="isImageFile(file) && !file.fileUrl">
+                    <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
+                  </template>
+                  <template v-else-if="isVideoFile(file)">
+                    <div class="attachment-preview__icon attachment-preview__icon--video">
+                      <el-icon :size="28"><VideoPlay /></el-icon>
+                    </div>
+                  </template>
+                  <template v-else>
+                    <div class="attachment-preview__icon attachment-preview__icon--doc">
+                      <el-icon :size="28"><Document /></el-icon>
+                    </div>
+                  </template>
+                </div>
+                <div class="attachment-footer">
+                  <span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
+                  <div class="attachment-actions">
+                    <el-icon class="action-btn" title="预览" @click="handlePreviewAttachment(file)"
+                      ><View
+                    /></el-icon>
+                    <el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)"
+                      ><Download
+                    /></el-icon>
+                    <el-icon
+                      class="action-btn action-btn--danger"
+                      title="删除"
+                      @click="removeRechargeAttachment(index)"
+                      ><Delete
+                    /></el-icon>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <span class="upload-hint">支持图片、视频、PDF、Word、Excel 等,可上传多个</span>
+          </div>
+        </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="rechargeVisible = false">取消</el-button>
-        <el-button type="primary" :loading="rechargeLoading" @click="submitRecharge">确认</el-button>
+        <el-button class="btn-ghost" @click="rechargeVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="rechargeLoading" @click="submitRecharge">
+          <el-icon><Check /></el-icon>确认
+        </el-button>
       </template>
     </el-dialog>
 
@@ -511,15 +573,24 @@
       <el-form label-position="top">
         <el-form-item>
           <template #label>退款金额(元)<span class="form-label-tip">当前余额:¥{{ formatAmount(currentOrderForTxn?.currentBalance) }}</span></template>
-          <el-input-number v-model="refundTxnForm.amount" :min="0.01" :max="Number(currentOrderForTxn?.currentBalance || 0)" :precision="2" :step="100" controls-position="right" style="width: 100%" />
+          <AmountInput
+            v-model="refundTxnForm.amount"
+            :min="0.01"
+            :max="Number(currentOrderForTxn?.currentBalance || 0)"
+            placeholder="请输入退款金额"
+          />
         </el-form-item>
         <el-form-item label="备注">
           <el-input v-model="refundTxnForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="refundTxnVisible = false">取消</el-button>
-        <el-button type="primary" :loading="refundTxnLoading" @click="submitRefundTxn">确认</el-button>
+        <el-button class="btn-ghost" @click="refundTxnVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="refundTxnLoading" @click="submitRefundTxn">
+          <el-icon><Check /></el-icon>确认
+        </el-button>
       </template>
     </el-dialog>
 
@@ -549,7 +620,9 @@
             </el-table-column>
             <el-table-column label="操作" min-width="60" align="center">
               <template #default="{ $index }">
-                <el-button type="danger" link size="small" @click="removePurchaseItem($index)">删除</el-button>
+                <el-button type="danger" link size="small" @click="removePurchaseItem($index)">
+                  <el-icon><Delete /></el-icon>删除
+                </el-button>
               </template>
             </el-table-column>
           </el-table>
@@ -563,8 +636,12 @@
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="purchaseVisible = false">取消</el-button>
-        <el-button type="primary" :loading="purchaseLoading" @click="submitPurchase">确认</el-button>
+        <el-button class="btn-ghost" @click="purchaseVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="purchaseLoading" @click="submitPurchase">
+          <el-icon><Check /></el-icon>确认
+        </el-button>
       </template>
     </el-dialog>
 
@@ -597,8 +674,12 @@
         </el-form-item>
       </el-form>
       <template #footer>
-        <el-button @click="unsubscribeVisible = false">取消</el-button>
-        <el-button type="primary" :loading="unsubscribeLoading" @click="submitUnsubscribe">确认退订</el-button>
+        <el-button class="btn-ghost" @click="unsubscribeVisible = false">
+          <el-icon><Close /></el-icon>取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="unsubscribeLoading" @click="submitUnsubscribe">
+          <el-icon><Check /></el-icon>确认退订
+        </el-button>
       </template>
     </el-dialog>
 
@@ -641,9 +722,18 @@ import {
   Loading,
   Download,
   Delete,
-  VideoPlay
+  VideoPlay,
+  View,
+  List,
+  Edit,
+  Wallet,
+  Sell,
+  ShoppingCart,
+  Remove
 } from '@element-plus/icons-vue'
 import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
+import ServiceOrderTxnTable from './ServiceOrderTxnTable.vue'
+import ServiceOrderTxnDialog from './ServiceOrderTxnDialog.vue'
 import {
   fetchChannelServiceOrderList,
   fetchChannelServiceOrderDetail,
@@ -662,27 +752,20 @@ import {
 } from '@/api/admin'
 import { fetchUploadAttachment } from '@/api/business'
 import { downloadAttachmentFile } from '@/utils/download-attachment'
+import AmountInput from '@/components/common/amount-input/index.vue'
+import { formatMoneyDisplay } from '@/utils/money-format'
 import {
   channelServiceOrderTypeLabel,
   channelServiceOrderTypeTagType,
   channelServiceOrderStatusLabel,
   channelServiceOrderStatusTagType,
-  agreementTypeLabel,
-  txnTypeLabel,
-  txnTypeTagType,
-  txnStatusLabel,
-  txnStatusTagType
+  agreementTypeLabel
 } from '@/utils/status-tag-registry'
 
 defineOptions({ name: 'ChannelServiceOrderManagement' })
 
 // ── 工具函数 ──────────────────────────────────────────────────────────
-const formatAmount = (v: any): string => {
-  if (v == null || v === '') return '0.00'
-  const n = Number(v)
-  if (Number.isNaN(n)) return '0.00'
-  return n.toFixed(2)
-}
+const formatAmount = (v: any): string => formatMoneyDisplay(v, 2)
 
 const displayValue = (v: any): string => {
   if (v == null || v === '' || v === undefined) return '-'
@@ -881,14 +964,11 @@ const viewSections = computed((): ViewSection[] => {
     })
   }
 
-  // Transaction timeline
-  if (d.transactions?.length) {
-    sections.push({
-      title: '操作记录',
-      fields: [],
-      slot: 'txn-timeline'
-    })
-  }
+  sections.push({
+    title: '操作记录',
+    fields: [],
+    slot: 'txn-timeline'
+  })
 
   return sections
 })
@@ -939,7 +1019,6 @@ const initFormData = () => ({
   orderType: 'yfk',
   channelId: '',
   deptName: '',
-  prepayAmount: null as number | null,
   channelAgreementId: '',
   items: [] as any[],
   remark: ''
@@ -950,22 +1029,6 @@ const formData = reactive(initFormData())
 const formRules: FormRules = {
   orderType: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
   channelId: [{ required: true, message: '请选择渠道', trigger: 'change' }],
-  prepayAmount: [
-    {
-      validator: (_rule, value, callback) => {
-        if (formData.orderType !== 'yfk') {
-          callback()
-          return
-        }
-        if (value == null || Number(value) <= 0) {
-          callback(new Error('请输入充值金额'))
-          return
-        }
-        callback()
-      },
-      trigger: 'blur'
-    }
-  ],
   channelAgreementId: [
     {
       validator: (_rule, value, callback) => {
@@ -1158,7 +1221,6 @@ const formTotalAmount = computed(() =>
 )
 
 const handleOrderTypeChange = () => {
-  formData.prepayAmount = null
   formData.channelAgreementId = ''
   formData.items = []
   agreementOptions.value = []
@@ -1189,7 +1251,6 @@ const handleEdit = async (row: any) => {
     formData.orderType = detail.orderType || 'yfk'
     formData.channelId = detail.channelId || ''
     formData.deptName = detail.deptName || ''
-    formData.prepayAmount = detail.prepayAmount ?? null
     formData.channelAgreementId = detail.channelAgreementId || ''
     formData.remark = detail.remark || ''
 
@@ -1267,7 +1328,6 @@ const handleFormSubmit = async () => {
     }
 
     if (formData.orderType === 'yfk') {
-      payload.prepayAmount = formData.prepayAmount
       payload.channelAgreementId = formData.channelAgreementId
     } else {
       payload.channelAgreementId = formData.channelAgreementId
@@ -1322,32 +1382,67 @@ const handleDelete = async (row: any) => {
 
 // ── 流水操作 ──────────────────────────────────────────────────────────
 const currentOrderForTxn = ref<any>(null)
+const txnDialogVisible = ref(false)
+const txnDialogOrder = ref<Record<string, any> | null>(null)
+const txnDialogRef = ref<InstanceType<typeof ServiceOrderTxnDialog> | null>(null)
+
+const openTxnDialog = (row: any) => {
+  txnDialogOrder.value = row
+  txnDialogVisible.value = true
+}
+
+const onTxnDialogRefreshed = () => {
+  loadData()
+  if (viewVisible.value && viewData.value?.id === txnDialogOrder.value?.id) {
+    handleView(txnDialogOrder.value)
+  }
+}
 
 // ── 发起充值 ──────────────────────────────────────────────────────────
 const rechargeVisible = ref(false)
 const rechargeLoading = ref(false)
+const rechargeUploadRef = ref<{ clearFiles?: () => void }>()
 const rechargeForm = reactive({
   amount: null as number | null,
-  remark: ''
+  remark: '',
+  attachments: [] as any[]
 })
 
 const openRechargeDialog = (row: any) => {
   currentOrderForTxn.value = row
   rechargeForm.amount = null
   rechargeForm.remark = ''
+  rechargeForm.attachments = []
   rechargeVisible.value = true
 }
 
+const handleRechargeAttachmentChange = (uploadFile: any) => {
+  const file = uploadFile.raw || uploadFile
+  if (!file) return
+  uploadAttachmentItem(file, rechargeForm.attachments, rechargeUploadRef, 'channel-service-order')
+}
+
+const removeRechargeAttachment = (index: number) => {
+  rechargeForm.attachments.splice(index, 1)
+}
+
 const submitRecharge = async () => {
   if (!rechargeForm.amount || rechargeForm.amount <= 0) {
     ElMessage.warning('请输入充值金额')
     return
   }
+  if (hasUploadingAttachments(rechargeForm.attachments)) {
+    ElMessage.warning('附件正在上传,请稍候')
+    return
+  }
+  const attachmentUrls = joinAttachmentUrls(rechargeForm.attachments)
+  const attachmentPayload = attachmentUrls ? { financeSubmitAttachments: attachmentUrls } : {}
   rechargeLoading.value = true
   try {
     const res: any = await fetchCreateRechargeTxn(currentOrderForTxn.value.id, {
       amount: rechargeForm.amount,
-      remark: rechargeForm.remark
+      remark: rechargeForm.remark,
+      ...attachmentPayload
     })
     const txn = res?.data ?? res
     rechargeVisible.value = false
@@ -1358,12 +1453,13 @@ const submitRecharge = async () => {
         cancelButtonText: '稍后再说',
         type: 'info'
       })
-      await fetchSubmitTxnFinance(txn.id, {})
+      await fetchSubmitTxnFinance(txn.id, attachmentPayload)
       ElMessage.success('充值成功,已发起财务确认')
     } catch {
       ElMessage.success('充值流水已创建')
     }
-    loadData()
+    onTxnDialogRefreshed()
+    txnDialogRef.value?.reload()
   } catch (e: any) {
     ElMessage.error(e?.message || '操作失败')
   } finally {
@@ -1410,7 +1506,8 @@ const submitRefundTxn = async () => {
     } catch {
       ElMessage.success('退款流水已创建')
     }
-    loadData()
+    onTxnDialogRefreshed()
+    txnDialogRef.value?.reload()
   } catch (e: any) {
     ElMessage.error(e?.message || '操作失败')
   } finally {
@@ -1533,7 +1630,8 @@ const submitPurchase = async () => {
     } catch {
       ElMessage.success('购买流水已创建')
     }
-    loadData()
+    onTxnDialogRefreshed()
+    txnDialogRef.value?.reload()
   } catch (e: any) {
     ElMessage.error(e?.message || '操作失败')
   } finally {
@@ -1619,7 +1717,8 @@ const submitUnsubscribe = async () => {
     } catch {
       ElMessage.success('退订流水已创建')
     }
-    loadData()
+    onTxnDialogRefreshed()
+    txnDialogRef.value?.reload()
   } catch (e: any) {
     ElMessage.error(e?.message || '操作失败')
   } finally {
@@ -1693,13 +1792,6 @@ async function uploadAttachmentItem(
   uploadRef: { value?: { clearFiles?: () => void } },
   folder: string
 ) {
-  const maxSizeMb = 20
-  if (file.size / 1024 / 1024 > maxSizeMb) {
-    ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-    uploadRef.value?.clearFiles?.()
-    return
-  }
-
   const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
   list.push({
     id: tempId,
@@ -1812,6 +1904,12 @@ onMounted(() => {
   }
 }
 
+.view-inner-table-wrap {
+  margin-top: 0;
+  padding: 0;
+  border: none;
+}
+
 .product-detail-table-wrap {
   width: 100%;
 

+ 2 - 6
web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue

@@ -414,6 +414,7 @@
   import { Calendar, Close, CollectionTag, OfficeBuilding, User } from '@element-plus/icons-vue'
   import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
   import { downloadAttachmentFile } from '@/utils/download-attachment'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import {
     channelTypeLabel,
     channelTypeTagType,
@@ -542,12 +543,7 @@
     downloadAttachmentFile(file, { fullFileUrl })
   }
 
-  function formatAmount(val: unknown) {
-    if (val === null || val === undefined || val === '') return '0.00'
-    const num = Number(val)
-    if (isNaN(num)) return '0.00'
-    return num.toFixed(2)
-  }
+  const formatAmount = (v: unknown): string => formatMoneyDisplay(v as any, 2)
 
   async function loadAccountData() {
     if (!props.channelId || accountLoaded.value) return

+ 10 - 21
web-admin/src/views/pilot-service/channel/index.vue

@@ -283,7 +283,7 @@
                         </div>
                       </div>
                       <span class="upload-hint"
-                        >支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                        >支持图片、视频、PDF、Word、Excel等文件</span
                       >
                     </div>
                   </el-form-item>
@@ -632,7 +632,7 @@
                 </div>
                 <div v-if="agr.creditLimit != null" class="sub-card-field">
                   <span class="field-label">授信额度(元)</span>
-                  <span class="field-value">{{ agr.creditLimit }}</span>
+                  <span class="field-value">{{ agr.creditLimit != null ? formatMoneyDisplay(agr.creditLimit) : '-' }}</span>
                 </div>
                 <div v-if="agr.effectiveDate" class="sub-card-field">
                   <span class="field-label">生效日期</span>
@@ -728,7 +728,7 @@
                         header-align="center"
                       >
                         <template #default="{ row: priceRow }">
-                          {{ priceRow.originalPrice != null ? `${priceRow.originalPrice}` : '-' }}
+                          {{ priceRow.originalPrice != null ? formatMoneyDisplay(priceRow.originalPrice) : '-' }}
                         </template>
                       </el-table-column>
                       <el-table-column
@@ -738,15 +738,12 @@
                         header-align="center"
                       >
                         <template #default="{ row: priceRow }">
-                          <el-input-number
+                          <AmountInput
                             v-model="priceRow.agreementPrice"
-                            class="protocol-price-input"
-                            :precision="2"
                             :min="0"
-                            :controls="false"
-                            style="width: 100%"
+                            class="protocol-price-input"
                             placeholder="请输入"
-                            @blur="handleUpdatePrice(priceRow)"
+                            @change="handleUpdatePrice(priceRow)"
                           />
                         </template>
                       </el-table-column>
@@ -852,12 +849,9 @@
                 </el-select>
               </el-form-item>
               <el-form-item label="授信额度(元)" prop="creditLimit">
-                <el-input-number
+                <AmountInput
                   v-model="agreementFormData.creditLimit"
-                  :precision="2"
                   :min="0"
-                  :controls="false"
-                  style="width: 100%"
                   placeholder="请输入授信额度(元)"
                 />
               </el-form-item>
@@ -965,7 +959,7 @@
             <el-table-column prop="specText" label="规格" min-width="100" align="center" header-align="center" />
             <el-table-column label="单价(元)" min-width="90" align="center" header-align="center">
               <template #default="{ row }">
-                {{ row.unitPrice != null ? `${row.unitPrice}` : '-' }}
+                {{ row.unitPrice != null ? formatMoneyDisplay(row.unitPrice) : '-' }}
               </template>
             </el-table-column>
             <el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" show-overflow-tooltip />
@@ -1034,6 +1028,8 @@
   import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
   import { fetchUploadAttachment } from '@/api/business'
   import { downloadAttachmentFile } from '@/utils/download-attachment'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import {
     channelType as channelTypeRegistry,
     channelTypeLabel,
@@ -2239,13 +2235,6 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      attachmentUploadRef.value?.clearFiles()
-      return
-    }
-
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     formData.attachments.push({
       id: tempId,

+ 5 - 6
web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue

@@ -29,7 +29,7 @@
         <el-table-column prop="itemCount" label="项目数" min-width="80" align="center" header-align="center" />
         <el-table-column label="合计费用(元)" min-width="120" align="center" header-align="center">
           <template #default="{ row }">{{
-            row.totalFee != null ? `¥${row.totalFee}` : '—'
+            row.totalFee != null ? `¥${formatMoneyDisplay(row.totalFee)}` : '—'
           }}</template>
         </el-table-column>
         <el-table-column prop="status" label="状态" min-width="90" align="center">
@@ -129,12 +129,9 @@
             </el-table-column>
             <el-table-column label="项目费用(元)" min-width="120" align="center" header-align="center">
               <template #default="{ row }">
-                <el-input-number
+                <AmountInput
                   v-model="row.projectFee"
-                  :precision="2"
                   :min="0"
-                  :controls="false"
-                  style="width: 100%"
                 />
               </template>
             </el-table-column>
@@ -216,7 +213,7 @@
           />
           <el-table-column label="项目费用(元)" min-width="120" align="center" header-align="center">
             <template #default="{ row }">{{
-              row.projectFee != null ? `¥${row.projectFee}` : '—'
+              row.projectFee != null ? `¥${formatMoneyDisplay(row.projectFee)}` : '—'
             }}</template>
           </el-table-column>
           <el-table-column prop="status" label="状态" min-width="90" align="center" header-align="center">
@@ -266,6 +263,8 @@
     fetchCheckupInstitutionProjects
   } from '@/api/admin'
   import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
 
   const props = defineProps<{
     institutionId: string

+ 4 - 2
web-admin/src/views/pilot-service/checkup-institution/index.vue

@@ -201,7 +201,7 @@
             <el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
             <el-table-column prop="projectName" label="项目名称" min-width="160" align="center" header-align="center" />
             <el-table-column prop="projectFee" label="项目费用(元)" min-width="120" align="center" header-align="center">
-              <template #default="{ row }">{{ row.projectFee != null ? `¥${row.projectFee}` : '—' }}</template>
+              <template #default="{ row }">{{ row.projectFee != null ? `¥${formatMoneyDisplay(row.projectFee)}` : '—' }}</template>
             </el-table-column>
             <el-table-column prop="status" label="状态" min-width="90" align="center" header-align="center">
               <template #default="{ row }">
@@ -255,7 +255,7 @@
                 <el-input v-model="itemForm.projectName" />
               </el-form-item>
               <el-form-item label="项目费用(元)" prop="projectFee">
-                <el-input-number v-model="itemForm.projectFee" :min="0" :precision="2" style="width: 100%" />
+                <AmountInput v-model="itemForm.projectFee" :min="0" placeholder="请输入项目费用" />
               </el-form-item>
               <el-form-item label="状态" prop="status">
                 <el-radio-group v-model="itemForm.status">
@@ -313,6 +313,8 @@
   } from '@element-plus/icons-vue'
   import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
   import CheckupPackagePanel from './CheckupPackagePanel.vue'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
   import {
     fetchCheckupInstitutionList,

+ 7 - 9
web-admin/src/views/pilot-service/project-price-system/index.vue

@@ -179,13 +179,9 @@
             <el-table-column prop="specText" label="规格" min-width="100" align="center" header-align="center" />
             <el-table-column label="单价(元)" min-width="100" align="center" header-align="center">
               <template #default="{ row }">
-                <el-input-number
+                <AmountInput
                   v-model="row.unitPrice"
-                  :precision="2"
                   :min="0"
-                  :controls="false"
-                  size="default"
-                  style="width: 100%"
                   placeholder="请输入单价"
                 />
               </template>
@@ -249,7 +245,7 @@
           <el-table-column prop="specText" label="规格" min-width="100" align="center" header-align="center" />
           <el-table-column label="单价" min-width="80" align="center" header-align="center">
             <template #default="{ row }">
-              {{ row.unitPrice != null ? `${row.unitPrice} 元` : '-' }}
+              {{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
             </template>
           </el-table-column>
           <el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" />
@@ -283,8 +279,10 @@
   import { ref, reactive, onMounted, computed, nextTick } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
   import { Plus, Close, Check, View, Edit, Delete, ScaleToOriginal, RefreshLeft, Search } from '@element-plus/icons-vue'
-  import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
-  import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
+import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
+import AmountInput from '@/components/common/amount-input/index.vue'
+import { formatMoneyDisplay } from '@/utils/money-format'
+import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
   import {
     fetchProjectSystemList,
     fetchCreateProjectSystem,
@@ -394,7 +392,7 @@
         title: '价格明细',
         fields: items.map((item: any, idx: number) => ({
           label: `明细 ${idx + 1}`,
-          value: `${item.projectName || '-'} / ${item.cellTypeName || '-'} / ${item.specText || '-'} / ${item.unitPrice != null ? item.unitPrice + ' 元' : '-'} / ${item.remark || '-'}`,
+          value: `${item.projectName || '-'} / ${item.cellTypeName || '-'} / ${item.specText || '-'} / ${item.unitPrice != null ? formatMoneyDisplay(item.unitPrice) + ' 元' : '-'} / ${item.remark || '-'}`,
           span: true
         }))
       }

+ 2 - 7
web-admin/src/views/production/cell-product/index.vue

@@ -533,7 +533,7 @@
                     </div>
                   </div>
                 </div>
-                <span class="upload-hint">支持图片上传,单文件不超过 10MB</span>
+                <span class="upload-hint">支持图片上传</span>
               </div>
             </el-form-item>
             <el-form-item label="备注" class="form-col-span-2">
@@ -1105,12 +1105,7 @@
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 10
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`图片大小不能超过 ${maxSizeMb}MB`)
-      productImageUploadRef.value?.clearFiles()
-      return
-    }
+
 
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     productImageFiles.value.push({

+ 2 - 7
web-admin/src/views/production/production-work-order/TaskOutputProductDialog.vue

@@ -244,7 +244,7 @@
                 </div>
               </div>
             </div>
-            <span class="upload-hint">支持图片上传,单文件不超过 10MB</span>
+            <span class="upload-hint">支持图片上传</span>
           </div>
         </el-form-item>
         <!-- 备注 (full width) -->
@@ -625,12 +625,7 @@
   async function handleProductImageChange(uploadFile: any) {
     const file = uploadFile.raw || uploadFile
     if (!file) return
-    const maxSizeMb = 10
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`图片大小不能超过 ${maxSizeMb}MB`)
-      productImageUploadRef.value?.clearFiles()
-      return
-    }
+
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     productImageFiles.value.push({
       id: tempId,

+ 2 - 7
web-admin/src/views/production/sop/index.vue

@@ -196,7 +196,7 @@
                   </div>
                 </div>
                 <span class="upload-hint"
-                  >支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                  >支持图片、视频、PDF、Word、Excel等文件</span
                 >
               </div>
             </el-form-item>
@@ -460,12 +460,7 @@
     const file = uploadFile?.raw || uploadFile
     if (!file) return
 
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      attachmentUploadRef.value?.clearFiles()
-      return
-    }
+
 
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     formData.attachments.push({

+ 2 - 1
web-admin/src/views/quality-inspection/qc-task/index.vue

@@ -382,6 +382,7 @@
   import { ref, reactive, computed, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
   import { Close, Check, Search, RefreshLeft, VideoPlay, View, Tickets, EditPen, Plus } from '@element-plus/icons-vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
   import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
   import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
@@ -884,7 +885,7 @@
           { label: '检测项目', value: d.itemName },
           { label: '项目编码', value: d.itemCode || '—' },
           { label: '第三方公司', value: d.companyName || '—' },
-          { label: '检测费用', value: d.feeAmount != null ? `¥${d.feeAmount}` : '—' },
+          { label: '检测费用', value: d.feeAmount != null ? '¥' + formatMoneyDisplay(d.feeAmount) : '—' },
           { label: '任务状态', value: taskStatusLabel(d.taskStatus), tagType: d.taskStatus === 'ywc' ? 'success' : d.taskStatus === 'jxz' ? 'warning' : 'info' },
           {
             label: '检测结论',

+ 3 - 2
web-admin/src/views/quality-inspection/qc-work-order/index.vue

@@ -214,7 +214,7 @@
                 <div class="qc-company-group-title">{{ group.companyName }}</div>
                 <div class="qc-company-group-items">
                   <el-checkbox v-for="item in group.items" :key="item.id" :value="item.id">
-                    {{ item.itemName }}({{ item.itemCode }})¥{{ item.feeAmount }}
+                    {{ item.itemName }}({{ item.itemCode }})¥{{ formatMoneyDisplay(item.feeAmount) }}
                   </el-checkbox>
                 </div>
               </div>
@@ -348,6 +348,7 @@
 </template>
 
 <script setup lang="ts">
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import { ref, reactive, computed, onMounted } from 'vue'
   import { ElMessage, ElMessageBox } from 'element-plus'
   import { Close, Check, Search, RefreshLeft, View, Setting, Document, Plus, EditPen } from '@element-plus/icons-vue'
@@ -718,7 +719,7 @@
   function formatConfiguredThirdItems(d: QcWorkOrderDetail) {
     return (
       d.thirdPartyItems
-        ?.map((li) => `${li.companyName || ''} — ${li.itemName || ''}(¥${li.feeAmount ?? '—'})`)
+        ?.map((li) => `${li.companyName || ''} — ${li.itemName || ''}(${li.feeAmount != null ? '¥' + formatMoneyDisplay(li.feeAmount) : '—'})`)
         .join('、') || '无'
     )
   }

+ 2 - 6
web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue

@@ -97,7 +97,7 @@
               </div>
             </div>
           </div>
-          <p class="upload-hint">支持图片、文档等,单文件不超过 20MB。</p>
+          <p class="upload-hint">支持图片、文档等。</p>
         </div>
       </el-form-item>
     </el-form>
@@ -196,11 +196,7 @@
   async function handleAttachmentChange(uploadFile: { raw?: File }) {
     const file = uploadFile.raw
     if (!file) return
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      return
-    }
+
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     const uploading: AttachmentItem = {
       id: tempId,

+ 4 - 2
web-admin/src/views/quality-inspection/third-party-company/index.vue

@@ -204,7 +204,7 @@
             <el-table-column prop="itemCode" label="项目编码" min-width="120" align="center" />
             <el-table-column prop="itemName" label="项目名称" min-width="160" align="center" />
             <el-table-column prop="feeAmount" label="费用(元)" min-width="110" align="center">
-              <template #default="{ row }">{{ row.feeAmount != null ? `¥${row.feeAmount}` : '—' }}</template>
+              <template #default="{ row }">{{ row.feeAmount != null ? '¥' + formatMoneyDisplay(row.feeAmount) : '—' }}</template>
             </el-table-column>
             <el-table-column prop="status" label="状态" min-width="90" align="center">
               <template #default="{ row }">
@@ -263,7 +263,7 @@
                   <el-input v-model="itemForm.itemName" />
                 </el-form-item>
                 <el-form-item label="费用(元)" prop="feeAmount">
-                  <el-input-number v-model="itemForm.feeAmount" :min="0" :precision="2" style="width: 100%" />
+                  <AmountInput v-model="itemForm.feeAmount" :min="0" placeholder="请输入检测费用" />
                 </el-form-item>
                 <el-form-item label="状态" prop="status">
                   <el-radio-group v-model="itemForm.status">
@@ -409,6 +409,8 @@
   } from '@/api/qc-third-party-item'
   import { fetchQcItemParams, fetchSaveQcItemParams, type QcItemParam } from '@/api/qc-item-param'
   import { fetchItemUnitList } from '@/api/admin'
+  import AmountInput from '@/components/common/amount-input/index.vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
 
   defineOptions({ name: 'QualityInspectionThirdPartyCompany' })
 

+ 2 - 14
web-admin/src/views/research-donation/list/DonationFormDialog.vue

@@ -711,7 +711,7 @@
                   </div>
                 </div>
               </div>
-              <span class="upload-hint">支持图片和 PDF 格式,可稍后补充,单文件不超过 20MB</span>
+               <span class="upload-hint">支持图片和 PDF 格式,可稍后补充</span>
             </div>
 
             <!-- 其他附件 -->
@@ -783,7 +783,7 @@
                   </div>
                 </div>
               </div>
-              <span class="upload-hint">支持图片、PDF、Word 格式,单文件不超过 20MB</span>
+               <span class="upload-hint">支持图片、PDF、Word 格式</span>
             </div>
           </div>
         </div>
@@ -2147,12 +2147,6 @@
   async function handleConsentAttachmentChange(uploadFile: any) {
     const file = uploadFile.raw || uploadFile
     if (!file) return
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      consentUploadRef.value?.clearFiles()
-      return
-    }
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     formData.consentAttachments.push({
       id: tempId,
@@ -2197,12 +2191,6 @@
   async function handleOtherAttachmentChange(uploadFile: any) {
     const file = uploadFile.raw || uploadFile
     if (!file) return
-    const maxSizeMb = 20
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
-      otherUploadRef.value?.clearFiles()
-      return
-    }
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
     formData.otherAttachments.push({
       id: tempId,

+ 2 - 1
web-admin/src/views/research-donation/list/DonationViewDialog.vue

@@ -581,6 +581,7 @@
 
 <script setup lang="ts">
   import { computed, nextTick, ref, watch } from 'vue'
+  import { formatMoneyDisplay } from '@/utils/money-format'
   import { Calendar, CircleCheck, Close, Document, User } from '@element-plus/icons-vue'
   import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
   import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
@@ -830,7 +831,7 @@
       if (ec !== '—') parts.push(ec)
       if (transport.waybillNo) parts.push(String(transport.waybillNo))
       if (transport.expressFee != null && transport.expressFee !== '') {
-        parts.push(`¥${transport.expressFee}`)
+        parts.push('¥' + formatMoneyDisplay(transport.expressFee))
       }
       return parts.length ? parts.join(' · ') : '—'
     }

+ 1 - 6
web-admin/src/views/system/category/index.vue

@@ -802,12 +802,7 @@
   }
 
   const uploadCategoryImageFile = async (file: File) => {
-    const maxSizeMb = 10
-    if (file.size / 1024 / 1024 > maxSizeMb) {
-      ElMessage.error(`图片大小不能超过 ${maxSizeMb}MB`)
-      categoryImageUploadRef.value?.clearFiles()
-      return
-    }
+
 
     try {
       ElMessage.info('图片正在上传...')

+ 5 - 4
web-admin/src/views/system/order/index.vue

@@ -1474,7 +1474,7 @@
           <el-table-column label="供货价" min-width="110" align="center">
             <template #default="{ row }">
               <span v-if="!orderPriceVersionDetail?.quotationId">-</span>
-              <span v-else>¥{{ (Number(row.supplierPrice || 0) || 0).toFixed(2) }}</span>
+              <span v-else>¥{{ formatAmount(row.supplierPrice || 0) }}</span>
             </template>
           </el-table-column>
           <el-table-column label="供货金额" min-width="110" align="center">
@@ -1482,7 +1482,7 @@
           </el-table-column>
           <el-table-column label="商品售价" min-width="110" align="center">
             <template #default="{ row }"
-              >¥{{ (Number(row.finalPrice ?? row.price) || 0).toFixed(2) }}</template
+              >¥{{ formatAmount(row.finalPrice ?? row.price) }}</template
             >
           </el-table-column>
           <el-table-column label="商品金额" min-width="110" align="center">
@@ -1548,6 +1548,7 @@
   import { useUserStore } from '@/store/modules/user'
   import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
   import { ensureCategoryPathDataLoaded, formatCategoryPathLabel } from '@/utils/category-path-display'
+  import { formatMoneyDisplay } from '@/utils/money-format'
 
   defineOptions({ name: 'OrderManagement' })
 
@@ -2061,8 +2062,8 @@
   }
 
   const formatAmount = (amount: number | string) => {
-    const num = typeof amount === 'string' ? parseFloat(amount) : amount
-    return isNaN(num) ? '0.00' : num.toFixed(2)
+    if (amount == null || amount === '') return '0.00'
+    return formatMoneyDisplay(amount)
   }
 
   const displayValue = (value: any) => {

Vissa filer visades inte eftersom för många filer har ändrats