zlwu пре 4 месеци
родитељ
комит
eec61cb5dd
82 измењених фајлова са 5704 додато и 2036 уклоњено
  1. 1 1
      backend/app.pid
  2. 144 24
      backend/app/api/public.py
  3. 8 2
      backend/app/core/auth.py
  4. 41 0
      backend/app/core/avatar_url.py
  5. 1 0
      backend/app/core/config.py
  6. 32 7
      backend/app/crud/collection_protocol.py
  7. 19 0
      backend/app/crud/user.py
  8. 0 0
      backend/app/graphify-out/cache/ast/051b251e4af1230d48a963f329b19f1e2f5eb6e7d106f60ffb4e44191dea7aed.json
  9. 0 0
      backend/app/graphify-out/cache/ast/07fb0eb89d8e4c6fca95f8905ee0028f782abfa98ed3c43800974c42ac98e818.json
  10. 0 0
      backend/app/graphify-out/cache/ast/0ee754a8d7ec722026239c1a1e97003528481771728ae89e68e29f4efe436c59.json
  11. 0 0
      backend/app/graphify-out/cache/ast/3d0e14af8f4114292852140a285773895a868d06b2a9afb341d0360ea69eb826.json
  12. 0 0
      backend/app/graphify-out/cache/ast/41e89b0e78be680783c961a2f20b2082963729ff9d0fa7d6d7a808f86599418d.json
  13. 0 0
      backend/app/graphify-out/cache/ast/585e42fac98184466b065ea8c7f9f717297ccd2f645454924ab3e253df9b172c.json
  14. 0 0
      backend/app/graphify-out/cache/ast/630a0cd5b4f180b99a4a28bdb561e8ddb87a23c63b3c99e02f19393d1c7913fa.json
  15. 0 0
      backend/app/graphify-out/cache/ast/69f5dd7c6e637bb2ec2bbbca349b9df8b897edbb35da71c9291681bc3c22a4df.json
  16. 0 0
      backend/app/graphify-out/cache/ast/6cc565d6f62deaf3fea9fd20db15f985b8347156c3e2d8755db5d55f34062f07.json
  17. 0 0
      backend/app/graphify-out/cache/ast/71619dde49e29da9fd3725647430be2456699ab50dc3ab99c28e69aa6fb14364.json
  18. 0 0
      backend/app/graphify-out/cache/ast/7cc46efdbd758dca719c74bee9166890264ce071661ac2bcc4764f7e30f8c56d.json
  19. 0 0
      backend/app/graphify-out/cache/ast/94f8c4b7c353bd2bf554b45bd6fd48457815a31c24d83bd469b214405e052ca9.json
  20. 0 0
      backend/app/graphify-out/cache/ast/bb566178ac35853b816b8845ec5e25e10a5a7b9c46c3f730e2ead6531770a388.json
  21. 0 0
      backend/app/graphify-out/cache/ast/c3377cfd7b2471999594b3d58ccddfe69aaee706f9da4e81b59e9f7aadcdcbf0.json
  22. 0 0
      backend/app/graphify-out/cache/ast/d73124ef65ab8f81202b8e836e6ddbfe58077a8130165067fb5ac2c5f896567d.json
  23. 0 0
      backend/app/graphify-out/cache/ast/e7266b0ddc693d2b161890f3150ed36f93215ccf3e81bff1b8e51e895d67704f.json
  24. 1 1
      backend/app/models/models.py
  25. 12 1
      backend/app/schemas/auth.py
  26. 84 0
      backend/app/services/protocol_pay_mode_switch.py
  27. 73 0
      backend/app/services/protocol_payment_installment.py
  28. 828 911
      backend/graphify-out/.graphify_analysis.json
  29. 229 131
      backend/graphify-out/.graphify_extract.json
  30. 171 96
      backend/graphify-out/GRAPH_REPORT.md
  31. 1 1
      backend/graphify-out/graph.html
  32. 163 109
      backend/graphify-out/graph.json
  33. 9 7
      backend/graphify-out/manifest.json
  34. 110 0
      backend/tests/test_protocol_pay_mode_switch.py
  35. 44 0
      backend/tests/test_protocol_payment_installment.py
  36. 5 0
      deploy/docker/README.md
  37. 2 0
      deploy/docker/web-admin/Dockerfile
  38. 499 0
      deploy/docker/部署指导手册.html
  39. 3 0
      docs/sql/migrations/2026-06-04-installment-interval-decimal.sql
  40. 1 1
      docs/sql/zkxw.sql
  41. 227 0
      docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md
  42. 3 0
      scripts/package-docker-deploy.sh
  43. 417 350
      web-admin/graphify-out/.graphify_analysis.json
  44. 822 26
      web-admin/graphify-out/.graphify_extract.json
  45. 89 110
      web-admin/graphify-out/GRAPH_REPORT.md
  46. 1 1
      web-admin/graphify-out/graph.html
  47. 257 104
      web-admin/graphify-out/graph.json
  48. 72 69
      web-admin/graphify-out/manifest.json
  49. 47 0
      web-admin/src/api/auth.ts
  50. 54 9
      web-admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue
  51. 149 0
      web-admin/src/components/core/layouts/art-header-bar/widget/UserPasswordDialog.vue
  52. 326 0
      web-admin/src/components/core/layouts/art-header-bar/widget/UserProfileDialog.vue
  53. 0 0
      web-admin/src/graphify-out/cache/ast/0dc8ff6d5204bdf625aed8dc501b4d0f177a54cbe472781f1401cd5d33ed2577.json
  54. 0 0
      web-admin/src/graphify-out/cache/ast/0fde49475da1c53fea948e3679e306216de02605e343fec6a4c30c1910048597.json
  55. 0 0
      web-admin/src/graphify-out/cache/ast/297b2e78edb9873505234694c624d678086e3979d65ca641a56cae500b42c947.json
  56. 1 0
      web-admin/src/graphify-out/cache/ast/412524c67ae8c587b0ffa7d2da942f3521530a261e8f3dd315fa0266e689a1c6.json
  57. 0 0
      web-admin/src/graphify-out/cache/ast/4d4a2da2625d36e0b112a376498159f99c1db70d74bc15aee51268972e85d8ec.json
  58. 0 0
      web-admin/src/graphify-out/cache/ast/5fb93c975f5070ebdbf4ef07805dca02150ac6592b58dd90785d56145e9c37d9.json
  59. 0 0
      web-admin/src/graphify-out/cache/ast/654b1af618676f59b4226fe4b30b91a8ddc4a618a9202f4b382304f1352b17a4.json
  60. 0 0
      web-admin/src/graphify-out/cache/ast/6feece2286542d38a91f4e2462e04a41bf8335ac92c5f18ebc962fc89d6b3574.json
  61. 1 0
      web-admin/src/graphify-out/cache/ast/7a9c8b8f34e635509ff0c6dd68811f3e407e38ada73c8e5552ab0ae59efa7fca.json
  62. 0 0
      web-admin/src/graphify-out/cache/ast/7c36d82b43d285ffad9a1dfd49654010030ad2b48856f4f14d6cdd7d0ab50ca0.json
  63. 1 0
      web-admin/src/graphify-out/cache/ast/86c3a28c2931c5ba9735deb305e10c18f71cc860c222994e1058af85853c72c3.json
  64. 0 0
      web-admin/src/graphify-out/cache/ast/8ddd482cf0e360f73c560c60c41be1cc9de08ca5b6d89f53e1a7b242386d6789.json
  65. 0 0
      web-admin/src/graphify-out/cache/ast/97ef81f8be2b150d3bbf0ca0682d921cd331a4170c42b41082bd82639d871113.json
  66. 0 0
      web-admin/src/graphify-out/cache/ast/98614108f4d4eb54371e1eb55e723e0fc898eb4ddd242b493de051472b0ee287.json
  67. 0 0
      web-admin/src/graphify-out/cache/ast/9c1115c65c227f52472a3dd83f92b893dba5b78e15aa894b9f9daccd08e288bf.json
  68. 1 0
      web-admin/src/graphify-out/cache/ast/a9337e3ea330a74f4c8b93ab450928f35bf93aed27402fe8c3d860e55e9987aa.json
  69. 0 0
      web-admin/src/graphify-out/cache/ast/aba319e09a5009a843beec72c11aaed026bc831509f67b38efaec028d524847c.json
  70. 0 0
      web-admin/src/graphify-out/cache/ast/bf5e9377cc4b7c76a07eec318b48299e4e73d4778faca18389edb49a0241c4b9.json
  71. 0 0
      web-admin/src/graphify-out/cache/ast/cd8c10e9d510e4e97a4a05d30d273a275dd8cf299c3f9ae5d2dabfe5e1b178d1.json
  72. 0 0
      web-admin/src/graphify-out/cache/ast/e5a5424b76ffa92c795598c33966f64ebe9b292dd627561a85330bff578d8c4a.json
  73. 1 0
      web-admin/src/graphify-out/cache/ast/e92b2927cbfe1c9920ec8f39922252baed1b5b04d9a998e6a46fbfcfbb535a2f.json
  74. 0 0
      web-admin/src/graphify-out/cache/ast/f0b7fd64fc4915744d3d351fe6dc76501e56e2e9fa8d375b22a96b43517ba2ca.json
  75. 0 0
      web-admin/src/graphify-out/cache/ast/fdbd04e569495e6cf7347a6001ab7301c5591ecd702c280f90f914d453c18c43.json
  76. 6 3
      web-admin/src/store/modules/user.ts
  77. 3 1
      web-admin/src/types/api.d.ts
  78. 204 0
      web-admin/src/utils/protocol-fq-params-change.ts
  79. 216 0
      web-admin/src/utils/protocol-pay-mode-switch.ts
  80. 25 2
      web-admin/src/utils/protocol-payment-installment.ts
  81. 80 5
      web-admin/src/utils/user-avatar.ts
  82. 220 64
      web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-91519
+88694

+ 144 - 24
backend/app/api/public.py

@@ -1,7 +1,8 @@
 """公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)"""
+import os
 from typing import Optional
 
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
 from sqlalchemy.exc import OperationalError
 from sqlalchemy.orm import Session
 
@@ -22,16 +23,21 @@ from app.core.auth import (
 )
 from app.core.response import success_response, error_response
 from app.core.config import settings
+from app.core.storage import storage
+from app.core.avatar_url import extract_avatar_storage_key
 from app.models.models import SysUser
 from app.crud.user import user_crud
 from app.core.utils import generate_uuid
 from app.core import login_crypto
 from app.services.web_auth_roles import iter_user_menu_perm_codes, resolve_web_role_for_user
+from app.schemas.auth import ProfileUpdateRequest
+from app.crud.user import update_user, change_password
 
 router = APIRouter(prefix="", tags=["公共接口"])
 
 
 def _avatar_url_field(user: SysUser) -> Optional[str]:
+    """返回库中原始头像路径,由前端按 VITE_MINIO_BASE_URL 拼展示 URL。"""
     raw = getattr(user, "avatar", None)
     if not raw:
         return None
@@ -48,6 +54,8 @@ def _build_user_info_payload(user: SysUser, web_role: str) -> dict:
         "realName": user.nick_name,
         "phone": phone,
         "email": (getattr(user, "email", None) or "").strip() or None,
+        "sex": getattr(user, "sex", None) or "0",
+        "remark": (getattr(user, "remark", None) or "").strip() or None,
         "role": web_role,
         "regionId": user.dept_id,
         "regionName": None,
@@ -55,6 +63,36 @@ def _build_user_info_payload(user: SysUser, web_role: str) -> dict:
     }
 
 
+def _build_current_user_payload(current_user: dict) -> dict:
+    """组装 current-user 接口响应。"""
+    au = current_user.get("avatar_url")
+    avatar_url = str(au).strip() if au else None
+    return {
+        "id": current_user.get("user_id"),
+        "username": current_user.get("username"),
+        "realName": current_user.get("real_name"),
+        "phone": current_user.get("phone"),
+        "email": current_user.get("email"),
+        "sex": current_user.get("sex") or "0",
+        "remark": current_user.get("remark"),
+        "role": current_user.get("role"),
+        "isSuperAdmin": current_user.get("role") == "super_admin",
+        "regionId": current_user.get("region_id"),
+        "regionName": None,
+        "regionIds": [],
+        "isActive": current_user.get("is_active"),
+        "avatarUrl": avatar_url if avatar_url else None,
+    }
+
+
+async def _resolve_current_user_from_request(request: Request, db: Session) -> dict:
+    authorization = request.headers.get("Authorization", "")
+    if not authorization.startswith("Bearer "):
+        raise HTTPException(status_code=401, detail="未授权")
+    token = authorization.replace("Bearer ", "")
+    return await get_current_user_by_token(token=token, db=db)
+
+
 def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
     return {
         "sub": user.id,
@@ -214,35 +252,117 @@ async def logout(request: Request):
 @router.get("/web/auth/current-user", response_model=dict)
 async def get_current_user_profile(request: Request, db: Session = Depends(get_db)):
     """获取当前用户信息(与 sys_user 字段对应)。"""
-    authorization = request.headers.get("Authorization", "")
-    if not authorization.startswith("Bearer "):
-        return error_response(401, "未授权")
-
-    token = authorization.replace("Bearer ", "")
     try:
-        current_user = await get_current_user_by_token(token=token, db=db)
+        current_user = await _resolve_current_user_from_request(request, db)
+        return success_response(_build_current_user_payload(current_user))
+    except HTTPException:
+        return error_response(401, "Token无效或已过期")
 
-        au = current_user.get("avatar_url")
-        avatar_url = str(au).strip() if au else None
-        return success_response(
-            {
-                "id": current_user.get("user_id"),
-                "username": current_user.get("username"),
-                "realName": current_user.get("real_name"),
-                "phone": current_user.get("phone"),
-                "email": current_user.get("email"),
-                "role": current_user.get("role"),
-                "isSuperAdmin": current_user.get("role") == "super_admin",
-                "regionId": current_user.get("region_id"),
-                "regionName": None,
-                "regionIds": [],
-                "isActive": current_user.get("is_active"),
-                "avatarUrl": avatar_url if avatar_url else None,
-            }
+
+@router.put("/web/auth/profile", response_model=dict)
+async def update_current_user_profile(
+    request: Request,
+    body: ProfileUpdateRequest,
+    db: Session = Depends(get_db),
+):
+    """当前用户更新个人信息(手机号、邮箱、性别、备注)。"""
+    try:
+        current_user = await _resolve_current_user_from_request(request, db)
+        user_id = current_user.get("user_id")
+        if not user_id:
+            return error_response(401, "未授权")
+
+        payload = body.model_dump(exclude_unset=True, by_alias=False)
+        update_user(
+            db,
+            user_id,
+            phone_no=payload.get("phone_no"),
+            email=payload.get("email"),
+            sex=payload.get("sex"),
+            remark=payload.get("remark"),
+            avatar=payload.get("avatar"),
+            user_id_updater=user_id,
         )
+
+        user = user_crud.get(db, user_id)
+        if not user:
+            return error_response(404, "用户不存在")
+        web_role = resolve_web_role_for_user(db, user.id)
+        return success_response(_build_user_info_payload(user, web_role))
+    except HTTPException:
+        return error_response(401, "Token无效或已过期")
+    except ValueError as exc:
+        return error_response(400, str(exc))
+
+
+@router.post("/web/auth/change-password", response_model=dict)
+async def change_current_user_password(
+    request: Request,
+    body: dict,
+    db: Session = Depends(get_db),
+):
+    """当前用户修改密码。"""
+    try:
+        current_user = await _resolve_current_user_from_request(request, db)
+        user_id = current_user.get("user_id")
+        if not user_id:
+            return error_response(401, "未授权")
+
+        old_password = (body.get("oldPassword") or body.get("old_password") or "").strip()
+        new_password = (body.get("newPassword") or body.get("new_password") or "").strip()
+        if not old_password:
+            return error_response(400, "请输入原密码")
+        if len(new_password) < 6:
+            return error_response(400, "新密码长度不能少于6位")
+
+        change_password(db, user_id, old_password, new_password)
+        return success_response({"message": "密码修改成功"})
+    except HTTPException:
+        return error_response(401, "Token无效或已过期")
+    except ValueError as exc:
+        return error_response(400, str(exc))
+
+
+_AVATAR_MAX_BYTES = 2 * 1024 * 1024
+_AVATAR_ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
+
+
+@router.post("/web/auth/upload-avatar", response_model=dict)
+async def upload_current_user_avatar(
+    request: Request,
+    file: UploadFile = File(...),
+    db: Session = Depends(get_db),
+):
+    """当前用户上传头像(仅图片,最大 2MB)。"""
+    try:
+        await _resolve_current_user_from_request(request, db)
     except HTTPException:
         return error_response(401, "Token无效或已过期")
 
+    if not file or not file.filename:
+        return error_response(400, "请选择图片")
+
+    ext = os.path.splitext(file.filename)[1].lower()
+    if ext not in _AVATAR_ALLOWED_EXT:
+        return error_response(400, "仅支持 JPG、PNG、GIF、WEBP、BMP 格式")
+
+    content_type = (file.content_type or "").lower()
+    if content_type and not content_type.startswith("image/"):
+        return error_response(400, "仅支持上传图片文件")
+
+    try:
+        file_content = await file.read()
+        if not file_content:
+            return error_response(400, "文件内容为空")
+        if len(file_content) > _AVATAR_MAX_BYTES:
+            return error_response(400, "头像大小不能超过 2MB")
+
+        url = storage.upload_file(file_content, file.filename, "avatar")
+        stored_url = extract_avatar_storage_key(url)
+        return success_response({"url": stored_url})
+    except Exception as exc:
+        return error_response(500, f"上传失败: {str(exc)}")
+
 
 @router.get("/web/auth/menu-codes", response_model=dict)
 async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):

+ 8 - 2
backend/app/core/auth.py

@@ -225,7 +225,11 @@ async def get_current_user_by_token(
 
     phone = (getattr(user, "phone_no", None) or "").strip() or None
     email = (getattr(user, "email", None) or "").strip() or None
-    avatar = getattr(user, "avatar", None)
+    avatar_raw = getattr(user, "avatar", None)
+    avatar_stored = None
+    if avatar_raw is not None:
+        s = str(avatar_raw).strip()
+        avatar_stored = s if s else None
 
     current_user_dept_id.set(user.dept_id or "")
 
@@ -237,7 +241,9 @@ async def get_current_user_by_token(
         "region_id": user.dept_id,
         "phone": phone,
         "email": email,
-        "avatar_url": avatar,
+        "sex": getattr(user, "sex", None) or "0",
+        "remark": (getattr(user, "remark", None) or "").strip() or None,
+        "avatar_url": avatar_stored,
         "is_active": user.status == "1",
     }
 

+ 41 - 0
backend/app/core/avatar_url.py

@@ -0,0 +1,41 @@
+"""用户头像 URL 解析(sys_user.avatar 可能存 MinIO 相对路径或完整 URL)"""
+from typing import Optional
+
+from app.core.config import settings
+from app.core.storage import storage
+
+
+def resolve_avatar_display_url(raw: Optional[str]) -> Optional[str]:
+    """服务端内部拼完整 URL(邮件等);Web API 应返回库中原始路径。"""
+    if raw is None:
+        return None
+    s = str(raw).strip()
+    if not s:
+        return None
+    lower = s.lower()
+    if lower.startswith("http://") or lower.startswith("https://") or s.startswith("/"):
+        return s
+    try:
+        return storage.get_url(s.lstrip("/"))
+    except Exception:
+        base = (settings.MINIO_BASE_URL or settings.LOCAL_STORAGE_URL or "").rstrip("/")
+        if base:
+            return f"{base}/{s.lstrip('/')}"
+        return s
+
+
+def extract_avatar_storage_key(full_url: str) -> str:
+    """从 upload 返回值或历史完整 URL 中提取 avatar/ 相对路径。"""
+    u = (full_url or "").strip()
+    if not u:
+        return ""
+    base_url = (settings.MINIO_BASE_URL or "").rstrip("/")
+    if base_url and u.startswith(base_url + "/"):
+        return u[len(base_url) + 1 :].lstrip("/")
+    marker = "/avatar/"
+    idx = u.lower().find(marker)
+    if idx >= 0:
+        return f"avatar/{u[idx + len(marker) :].lstrip('/')}"
+    if not u.lower().startswith("http") and not u.startswith("/"):
+        return u.lstrip("/")
+    return u

+ 1 - 0
backend/app/core/config.py

@@ -138,6 +138,7 @@ class Settings(BaseSettings):
         "/api/web/admin/products/upload-image,"
         "/api/web/admin/categories/upload-image,"
         "/api/web/admin/users/upload-avatar,"
+        "/api/web/auth/upload-avatar,"
         "/api/invoice/download"
     )
 

+ 32 - 7
backend/app/crud/collection_protocol.py

@@ -17,6 +17,8 @@ from app.crud.protocol_donation_convert import (
     assert_biz_no_available,
     get_partner_sample_codes,
 )
+from app.services.protocol_payment_installment import calc_fq_installment_due_date
+from app.services.protocol_pay_mode_switch import validate_payment_details_for_save
 from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
 from app.models.models import (
     BizAttachment,
@@ -350,7 +352,7 @@ def _payment_to_dict(p: BizProtocolPayment) -> Dict[str, Any]:
         "payMode": p.pay_mode or "",
         "contractAmount": _dec(p.contract_amount),
         "installmentCount": p.installment_count,
-        "installmentIntervalMonths": p.installment_interval_months,
+        "installmentIntervalMonths": _dec(p.installment_interval_months),
         "lumpSumDeadline": (p.lump_sum_deadline or "").strip() if p.lump_sum_deadline else "",
         "installmentStartDate": (p.installment_start_date or "").strip() if p.installment_start_date else "",
         "tenantNo": p.tenant_no,
@@ -2537,7 +2539,8 @@ def align_fq_payment_details_payload(
 
     amounts = _calc_installment_due_amounts(pay.contract_amount, count)
     start = (pay.installment_start_date or "").strip()
-    interval = int(pay.installment_interval_months or 0)
+    interval = pay.installment_interval_months
+    interval_val = float(interval) if interval is not None else 0
     result: List[Dict[str, Any]] = []
     for i in range(count):
         no = i + 1
@@ -2547,11 +2550,13 @@ def align_fq_payment_details_payload(
         if i < len(unnumbered):
             raw = dict(unnumbered[i])
             raw["installment_no"] = no
-            if not raw.get("installment_due_date") and start and interval:
-                raw["installment_due_date"] = _add_months_yyyymmdd(start, i * interval)
+            if not raw.get("installment_due_date") and start and interval_val:
+                raw["installment_due_date"] = calc_fq_installment_due_date(start, i, interval_val)
             result.append(raw)
             continue
-        due_date = _add_months_yyyymmdd(start, i * interval) if start and interval else ""
+        due_date = (
+            calc_fq_installment_due_date(start, i, interval_val) if start and interval_val else ""
+        )
         due = amounts[i] if i < len(amounts) else None
         result.append(
             {
@@ -2601,8 +2606,8 @@ def upsert_payment(
     obj = get_payment_by_protocol_id(db, collection_protocol_id, tenant_no)
     _STR_FIELDS = ["pay_mode"]
     _STR_DATE_FIELDS = ["lump_sum_deadline", "installment_start_date"]
-    _DEC_FIELDS = ["contract_amount"]
-    _INT_FIELDS = ["installment_count", "installment_interval_months"]
+    _DEC_FIELDS = ["contract_amount", "installment_interval_months"]
+    _INT_FIELDS = ["installment_count"]
 
     def _apply(target: BizProtocolPayment) -> None:
         for k in _STR_FIELDS:
@@ -2658,6 +2663,26 @@ def save_payment_details(
     user_id: Optional[str],
     details: list,
 ) -> List[BizProtocolPaymentDetail]:
+    pay = (
+        db.query(BizProtocolPayment)
+        .filter(
+            and_(
+                BizProtocolPayment.id == payment_id,
+                BizProtocolPayment.tenant_no == tenant_no,
+                BizProtocolPayment.del_flag == 0,
+            )
+        )
+        .first()
+    )
+    if pay:
+        normalized = [_normalize_fields(dict(d)) for d in (details or [])]
+        validate_payment_details_for_save(
+            pay.pay_mode or "",
+            pay.contract_amount,
+            pay.installment_count,
+            normalized,
+        )
+
     existing = list_payment_details(db, payment_id, tenant_no)
     for d in existing:
         d.del_flag = 1

+ 19 - 0
backend/app/crud/user.py

@@ -418,6 +418,25 @@ def reset_password(db: Session, user_id: str, new_password: str) -> None:
     db.commit()
 
 
+def change_password(db: Session, user_id: str, old_password: str, new_password: str) -> None:
+    from app.core.security import verify_password
+
+    obj = get_user(db, user_id)
+    if not obj:
+        raise ValueError("用户不存在")
+    pwd_hash = getattr(obj, "password", None) or ""
+    if not verify_password(old_password, pwd_hash):
+        raise ValueError("原密码不正确")
+    new_pw = (new_password or "").strip()
+    if len(new_pw) < 6:
+        raise ValueError("新密码长度不能少于6位")
+    obj.password = _hash_password(new_pw)
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+
+
 def total_pages(total: int, page_size: int) -> int:
     if total <= 0:
         return 0

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/051b251e4af1230d48a963f329b19f1e2f5eb6e7d106f60ffb4e44191dea7aed.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/07fb0eb89d8e4c6fca95f8905ee0028f782abfa98ed3c43800974c42ac98e818.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/0ee754a8d7ec722026239c1a1e97003528481771728ae89e68e29f4efe436c59.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/3d0e14af8f4114292852140a285773895a868d06b2a9afb341d0360ea69eb826.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/41e89b0e78be680783c961a2f20b2082963729ff9d0fa7d6d7a808f86599418d.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/585e42fac98184466b065ea8c7f9f717297ccd2f645454924ab3e253df9b172c.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/630a0cd5b4f180b99a4a28bdb561e8ddb87a23c63b3c99e02f19393d1c7913fa.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/69f5dd7c6e637bb2ec2bbbca349b9df8b897edbb35da71c9291681bc3c22a4df.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/6cc565d6f62deaf3fea9fd20db15f985b8347156c3e2d8755db5d55f34062f07.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/71619dde49e29da9fd3725647430be2456699ab50dc3ab99c28e69aa6fb14364.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/7cc46efdbd758dca719c74bee9166890264ce071661ac2bcc4764f7e30f8c56d.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/94f8c4b7c353bd2bf554b45bd6fd48457815a31c24d83bd469b214405e052ca9.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/bb566178ac35853b816b8845ec5e25e10a5a7b9c46c3f730e2ead6531770a388.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/c3377cfd7b2471999594b3d58ccddfe69aaee706f9da4e81b59e9f7aadcdcbf0.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/d73124ef65ab8f81202b8e836e6ddbfe58077a8130165067fb5ac2c5f896567d.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
backend/app/graphify-out/cache/ast/e7266b0ddc693d2b161890f3150ed36f93215ccf3e81bff1b8e51e895d67704f.json


+ 1 - 1
backend/app/models/models.py

@@ -824,7 +824,7 @@ class BizProtocolPayment(Base):
     pay_mode = Column(String(8), nullable=False, comment="dj趸交 fq分期")
     contract_amount = Column(Numeric(18, 2), nullable=True, comment="合同金额")
     installment_count = Column(Integer, nullable=True, comment="分期期数")
-    installment_interval_months = Column(Integer, nullable=True, comment="每期间隔(月)")
+    installment_interval_months = Column(Numeric(10, 2), nullable=True, comment="每期间隔(月)")
     lump_sum_deadline = Column(CHAR(8), nullable=True, comment="趸交截止时间YYYYMMDD")
     installment_start_date = Column(CHAR(8), nullable=True, comment="分期开始YYYYMMDD")
     tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")

+ 12 - 1
backend/app/schemas/auth.py

@@ -1,5 +1,5 @@
 """Pydantic schemas - 用户认证"""
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, ConfigDict
 from typing import Optional
 from datetime import datetime
 
@@ -51,6 +51,17 @@ class ChangePasswordRequest(BaseModel):
     new_password: str = Field(..., min_length=6)
 
 
+class ProfileUpdateRequest(BaseModel):
+    """当前用户个人信息更新(仅允许自助修改的字段)"""
+    model_config = ConfigDict(populate_by_name=True)
+
+    phone_no: Optional[str] = Field(None, alias="phoneNo", max_length=11)
+    email: Optional[str] = Field(None, max_length=50)
+    sex: Optional[str] = Field(None, pattern=r"^[012]$")
+    remark: Optional[str] = Field(None, max_length=512)
+    avatar: Optional[str] = Field(None, alias="avatarUrl", max_length=512)
+
+
 class ResetPasswordRequest(BaseModel):
     """重置密码请求(运营操作)"""
     user_id: int

+ 84 - 0
backend/app/services/protocol_pay_mode_switch.py

@@ -0,0 +1,84 @@
+"""缴费模式切换校验(与前端 protocol-pay-mode-switch.ts 规则一致)"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Any, Dict, Optional
+
+LOCKED_STATUSES = frozenset({"jfqrz", "yjf", "tfqrz", "ytf"})
+BLOCKING_STATUSES = frozenset({"jfqrz", "tfqrz", "ytf"})
+
+
+def _dec(v: Any) -> float:
+    if v is None:
+        return 0.0
+    return float(Decimal(str(v)))
+
+
+def validate_payment_details_for_save(
+    pay_mode: str,
+    contract_amount: Optional[Any],
+    installment_count: Optional[int],
+    details: Optional[list],
+) -> None:
+    """保存缴费明细前的 P0 校验。"""
+    rows = [dict(d) for d in (details or [])]
+    contract = _dec(contract_amount)
+
+    for r in rows:
+        status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
+        if status in BLOCKING_STATUSES:
+            raise ValueError("存在缴费确认中或退费处理中的明细,无法保存")
+
+    paid_sum = sum(
+        _dec(r.get("due_amount") if r.get("due_amount") is not None else r.get("dueAmount"))
+        for r in rows
+        if (r.get("pay_status") or r.get("payStatus")) == "yjf"
+    )
+    dfj_rows = [
+        r
+        for r in rows
+        if (r.get("pay_status") or r.get("payStatus") or "dfj") == "dfj"
+    ]
+    yjf_rows = [r for r in rows if (r.get("pay_status") or r.get("payStatus")) == "yjf"]
+
+    if pay_mode == "dj":
+        if len(dfj_rows) > 1:
+            raise ValueError("趸交模式仅允许一条待缴费明细")
+        remaining = round(contract - paid_sum, 2)
+        if dfj_rows:
+            due = _dec(
+                dfj_rows[0].get("due_amount")
+                if dfj_rows[0].get("due_amount") is not None
+                else dfj_rows[0].get("dueAmount")
+            )
+            expected = remaining if yjf_rows else contract
+            if abs(due - expected) > 0.015:
+                raise ValueError(
+                    f"趸交待缴金额应为 {expected:.2f},当前为 {due:.2f}"
+                )
+
+    elif pay_mode == "fq":
+        count = int(installment_count or 0)
+        if count < 1:
+            raise ValueError("分期模式须填写分期期数")
+        if len(rows) != count:
+            raise ValueError(f"分期明细条数应等于分期期数({count})")
+        fq_forbidden = frozenset({"jfqrz", "tfqrz", "ytf"})
+        for r in rows:
+            status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
+            if status in fq_forbidden:
+                raise ValueError("分期模式下存在不可保存的缴费状态")
+        total_due = sum(
+            _dec(r.get("due_amount") if r.get("due_amount") is not None else r.get("dueAmount"))
+            for r in rows
+        )
+        if contract and abs(total_due - contract) > 0.015:
+            raise ValueError("分期各期应缴金额之和应等于合同金额")
+        if yjf_rows:
+            max_paid_no = max(
+                int(r.get("installment_no") or r.get("installmentNo") or 0)
+                for r in yjf_rows
+            )
+            if max_paid_no > count:
+                raise ValueError("分期期数不能少于已缴费的最大期次")

+ 73 - 0
backend/app/services/protocol_payment_installment.py

@@ -0,0 +1,73 @@
+"""分期缴费:应缴日计算(整数月按日历递增,小数月按 30 天/月换算)"""
+
+from __future__ import annotations
+
+import calendar
+from decimal import Decimal, ROUND_DOWN
+from typing import Union
+
+Number = Union[int, float, Decimal, str]
+
+DAYS_PER_MONTH = 30
+FQ_ADVANCE_DAYS = 10
+
+
+def add_days_yyyymmdd(date_str: str, days: int) -> str:
+    raw = (date_str or "").strip().replace("-", "")
+    if len(raw) != 8:
+        return ""
+    from datetime import date, timedelta
+
+    y = int(raw[0:4])
+    m = int(raw[4:6])
+    d = int(raw[6:8])
+    dt = date(y, m, d) + timedelta(days=days)
+    return f"{dt.year:04d}{dt.month:02d}{dt.day:02d}"
+
+
+def add_months_yyyymmdd(date_str: str, months: int) -> str:
+    raw = (date_str or "").strip().replace("-", "")
+    if len(raw) != 8:
+        return ""
+    y = int(raw[0:4])
+    m = int(raw[4:6])
+    d = int(raw[6:8])
+    month_index = m - 1 + months
+    ny = y + month_index // 12
+    nm = month_index % 12 + 1
+    last_day = calendar.monthrange(ny, nm)[1]
+    nd = min(d, last_day)
+    return f"{ny:04d}{nm:02d}{nd:02d}"
+
+
+def add_interval_months_yyyymmdd(date_str: str, total_months: Number) -> str:
+    """整数部分按日历加月,小数部分按 30 天/月换算为天数。"""
+    raw = (date_str or "").strip().replace("-", "")
+    if len(raw) != 8:
+        return ""
+    total = Decimal(str(total_months))
+    if total == 0:
+        return raw
+    whole = int(total.to_integral_value(rounding=ROUND_DOWN))
+    frac = total - Decimal(whole)
+    result = raw
+    if whole:
+        result = add_months_yyyymmdd(result, whole)
+    if frac:
+        days = int(round(float(frac) * DAYS_PER_MONTH))
+        if days:
+            result = add_days_yyyymmdd(result, days)
+    return result
+
+
+def calc_fq_installment_due_date(
+    start_date: str,
+    index: int,
+    interval_months: Number,
+) -> str:
+    """第 1 期=开始时间;第 2 期起在间隔递增基础上提前 10 天。"""
+    total_months = Decimal(str(index)) * Decimal(str(interval_months))
+    base = add_interval_months_yyyymmdd(start_date, total_months)
+    if index >= 1:
+        return add_days_yyyymmdd(base, -FQ_ADVANCE_DAYS)
+    return base

Разлика између датотеке није приказан због своје велике величине
+ 828 - 911
backend/graphify-out/.graphify_analysis.json


Разлика између датотеке није приказан због своје велике величине
+ 229 - 131
backend/graphify-out/.graphify_extract.json


+ 171 - 96
backend/graphify-out/GRAPH_REPORT.md

@@ -1,12 +1,12 @@
-# Graph Report - app  (2026-06-03)
+# Graph Report - app  (2026-06-04)
 
 ## Corpus Check
-- 179 files · ~187,032 words
+- 181 files · ~187,864 words
 - Verdict: corpus is large enough that graph structure adds value.
 
 ## Summary
-- 3386 nodes · 15106 edges · 40 communities detected
-- Extraction: 37% EXTRACTED · 63% INFERRED · 0% AMBIGUOUS · INFERRED: 9448 edges (avg confidence: 0.61)
+- 3412 nodes · 15181 edges · 55 communities detected
+- Extraction: 38% EXTRACTED · 62% INFERRED · 0% AMBIGUOUS · INFERRED: 9487 edges (avg confidence: 0.61)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -44,17 +44,32 @@
 - [[_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 44|Community 44]]
+- [[_COMMUNITY_Community 45|Community 45]]
+- [[_COMMUNITY_Community 46|Community 46]]
+- [[_COMMUNITY_Community 47|Community 47]]
+- [[_COMMUNITY_Community 48|Community 48]]
+- [[_COMMUNITY_Community 51|Community 51]]
+- [[_COMMUNITY_Community 52|Community 52]]
+- [[_COMMUNITY_Community 53|Community 53]]
+- [[_COMMUNITY_Community 54|Community 54]]
+- [[_COMMUNITY_Community 55|Community 55]]
+- [[_COMMUNITY_Community 56|Community 56]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `success_response()` - 524 edges
-2. `error_response()` - 424 edges
-3. `SysUser` - 359 edges
+1. `success_response()` - 527 edges
+2. `error_response()` - 427 edges
+3. `SysUser` - 363 edges
 4. `BizQcTaskResult` - 272 edges
 5. `BizQcMaterialUsage` - 264 edges
 6. `BizQcMaterial` - 262 edges
@@ -68,215 +83,275 @@
   core/api_request_log_middleware.py → models/models.py
 - `get_menu_list()` --calls--> `success_response()`  [INFERRED]
   api/menu.py → core/response.py
-- `公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)` --uses--> `SysUser`  [INFERRED]
-  api/public.py → models/models.py
-- `与 web-admin `userInfo` 字段约定一致。` --uses--> `SysUser`  [INFERRED]
-  api/public.py → models/models.py
-- `Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)` --uses--> `SysUser`  [INFERRED]
-  api/public.py → models/models.py
+- `中试服务交付管理 API(交付信息、交付物料、交付方式)` --uses--> `SysUser`  [INFERRED]
+  api/pilot_delivery.py → models/models.py
+- `保留匹配节点及其全部上级,供关键词检索后仍能正确组树。` --uses--> `SysDept`  [INFERRED]
+  crud/dept.py → models/models.py
+- `物品单位 CRUD(biz_item_unit)` --uses--> `BizItemUnit`  [INFERRED]
+  crud/item_unit.py → models/models.py
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (633): attachment_download_url(), AttachmentDownloadUrlBody, create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+625 more)
+Nodes (596): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+588 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.01
-Nodes (463): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+455 more)
+Nodes (523): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+515 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.05
-Nodes (285): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody, Base (+277 more)
+Nodes (387): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonTodoInsuranceBody, ProtocolCreateBody, ProtocolUpdateBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API (+379 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.02
-Nodes (205): generate_uuid(), batch_stock_in(), batch_stock_out(), consumable_code_exists(), create_consumable(), _dec(), get_consumable(), _is_enabled_status() (+197 more)
+Nodes (214): list_collection_regions(), list_protocol_nos(), _ensure_customer_for_mother(), _persist_protocol_sub_resources(), list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id() (+206 more)
 
 ### Community 4 - "Community 4"
-Cohesion: 0.07
-Nodes (223): AuditCreateBody, BatchDetailIdsBody, BatchRefundFinanceSubmitBody, FinanceConfirmBody, FullmoonTodoInsuranceBody, ProtocolCreateBody, ProtocolUpdateBody, 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API (+215 more)
+Cohesion: 0.05
+Nodes (178): generate_uuid(), _deduct_fee_on_delivery(), 创建交付时扣减费用。      流程:     1. schedule → plan → application     2. application.chan, activate_channel_agreement(), _apply_gmcp_order_quotas(), _apply_purchase_txn_holdings(), _apply_unsubscribe_txn_holdings(), _batch_gmcp_current_holding_amounts() (+170 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.02
-Nodes (157): get_designer_data(), delete(), get_multi_with_filters(), create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict() (+149 more)
+Cohesion: 0.03
+Nodes (167): _format_attachment_file_size(), urls_to_attachment_list(), list_production_tasks(), apply_task_result(), complete_task(), _conclusion_label(), create_qc_task_manual(), create_task_for_line() (+159 more)
 
 ### Community 6 - "Community 6"
 Cohesion: 0.03
-Nodes (161): api_create_outbound_qc(), _format_attachment_file_size(), urls_to_attachment_list(), batch_review(), _fmt_dt(), get_schedule_detail(), _get_user_name(), list_reviews_paginated() (+153 more)
+Nodes (103): _avatar_url_field(), _build_current_user_payload(), _build_user_info_payload(), get_current_user_profile(), get_web_auth_menu_codes(), _jwt_user_payload(), logout(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线) (+95 more)
 
 ### Community 7 - "Community 7"
 Cohesion: 0.03
-Nodes (144): api_save_application(), eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), _apply_submit_receipt() (+136 more)
+Nodes (108): get_protocol_nos_used_in_protocols(), create_dict_type(), dict_type_code_exists(), get_dict_type(), list_dict_types(), 字典类型 CRUD(sys_dict_type), _row_to_dict(), soft_delete_dict_type() (+100 more)
 
 ### Community 8 - "Community 8"
 Cohesion: 0.03
-Nodes (128): _persist_protocol_sub_resources(), list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), 数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3, 根据用户角色的数据权限过滤查询。      Args:         query: SQLAlchemy query 对象         model: 查询, 从 SysUser 读取用户的 dept_id。 (+120 more)
+Nodes (63): _error_json(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError, _b64url_decode() (+55 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.03
-Nodes (67): _error_json(), lifespan(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+59 more)
+Cohesion: 0.04
+Nodes (94): api_save_application(), eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), auto_generate_schedules() (+86 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.03
-Nodes (89): generate_id(), camel_to_snake(), CRUDBase, get_model_attr(), HistoryCRUD, get_protocol_nos_used_in_protocols(), create_dict_type(), dict_type_code_exists() (+81 more)
+Cohesion: 0.05
+Nodes (82): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes() (+74 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.06
-Nodes (87): AccessLogCreateBody, AccessLogUpdateBody, batch_create_storage_slots(), CleanRoomCreateBody, CleanRoomUpdateBody, create_access_log(), create_storage_slot(), delete_access_log() (+79 more)
-
-### Community 12 - "Community 12"
 Cohesion: 0.35
 Nodes (61): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+53 more)
 
+### Community 12 - "Community 12"
+Cohesion: 0.07
+Nodes (62): batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation(), _get_current_user_id(), get_research_donation_detail() (+54 more)
+
 ### Community 13 - "Community 13"
-Cohesion: 0.06
-Nodes (57): _avatar_url_field(), _build_user_info_payload(), get_web_auth_menu_codes(), _jwt_user_payload(), logout(), 公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线), 登出:吊销 Redis 会话(若已配置 REDIS_HOST)。, 获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。 (+49 more)
+Cohesion: 0.07
+Nodes (51): get_designer_data(), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody, list_results_by_task() (+43 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.06
-Nodes (60): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes() (+52 more)
+Cohesion: 0.16
+Nodes (32): _create_insurance_company(), _create_insurance_company_invoice(), _create_insurance_project_price(), delete_insurance_company(), delete_insurance_company_invoice(), delete_insurance_project_price(), _fmt_dt(), _generate_uuid() (+24 more)
 
 ### Community 15 - "Community 15"
-Cohesion: 0.08
-Nodes (55): batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation(), _get_current_user_id(), get_research_donation_detail() (+47 more)
+Cohesion: 0.12
+Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.12
-Nodes (30): _apply_qc_status_filter(), _base_query(), _derive_qc_conclusion(), _ensure_notification(), _eq_ci(), _fetch_completed_tasks(), _fmt_dt(), generate_sms_content() (+22 more)
+Cohesion: 0.16
+Nodes (26): add_step(), copy_step(), create_route(), _create_step_consumable(), _create_step_data_collection(), _create_step_output(), _create_step_reagent(), get_route() (+18 more)
 
 ### Community 17 - "Community 17"
-Cohesion: 0.12
-Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
+Cohesion: 0.14
+Nodes (23): batch_stock_in(), batch_stock_out(), consumable_code_exists(), create_consumable(), _dec(), get_consumable(), _is_enabled_status(), list_consumables() (+15 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.21
-Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
+Cohesion: 0.13
+Nodes (18): _access_log_to_dict(), create_access_log(), create_clean_room(), get_access_log(), get_clean_room(), list_access_logs(), list_clean_rooms(), room_code_exists() (+10 more)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.18
-Nodes (18): company_code_exists(), create_company(), get_company(), list_companies(), list_company_options(), _row_to_dict(), soft_delete_company(), update_company() (+10 more)
+Cohesion: 0.16
+Nodes (19): get_by_schedule(), get_method(), _get_tenant_no(), list_by_application(), list_materials(), 中试服务交付管理 API(交付信息、交付物料、交付方式), soft_delete(), create_delivery() (+11 more)
 
 ### Community 20 - "Community 20"
-Cohesion: 0.25
-Nodes (13): code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), list_materials(), _norm_status(), options_list() (+5 more)
+Cohesion: 0.21
+Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
 
 ### Community 21 - "Community 21"
+Cohesion: 0.21
+Nodes (16): batch_stock_in(), batch_stock_out(), _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), list_materials() (+8 more)
+
+### Community 22 - "Community 22"
+Cohesion: 0.25
+Nodes (14): batch_stock_in(), batch_stock_out(), create_reagent(), _dec(), get_reagent(), _is_enabled_status(), list_reagents(), _norm_status() (+6 more)
+
+### Community 23 - "Community 23"
+Cohesion: 0.2
+Nodes (14): deduct_for_task(), _display_remark(), list_usages(), list_usages_by_task(), _parse_receiver_purpose_from_remark(), 查询质检任务关联的物料使用记录(task_complete)。, 撤销任务此前 task_complete 扣减,便于重新填报物料使用。, 兼容旧数据:领用人/用途曾拼接在 remark 中。 (+6 more)
+
+### Community 24 - "Community 24"
 Cohesion: 0.25
 Nodes (13): batch_issue_protocol_nos(), batch_revoke_protocol_nos(), create_protocol_no(), get_protocol_no(), list_protocol_nos(), _parse_issued_time(), protocol_no_exists(), 采集协议号池 CRUD(biz_collection_protocol_no) (+5 more)
 
-### Community 22 - "Community 22"
+### Community 25 - "Community 25"
 Cohesion: 0.24
 Nodes (9): 微信消息通知服务  支持微信小程序模板消息推送  使用方式:     from app.core.wechat_message import Wech, 发送模板消息                  Args:             openid: 用户openid             templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
 
-### Community 23 - "Community 23"
+### Community 26 - "Community 26"
+Cohesion: 0.23
+Nodes (12): Config, ProductBase, ProductCategoryBase, ProductCategoryCreate, ProductCategoryResponse, ProductCreate, ProductResponse, ProductUnitBase (+4 more)
+
+### Community 27 - "Community 27"
+Cohesion: 0.27
+Nodes (11): create_consumable_pack(), get_consumable_pack(), get_pack_detail(), _item_row_to_dict(), list_all_packs(), list_consumable_packs(), pack_code_exists(), 耗材包 CRUD(biz_consumable_pack + biz_consumable_pack_item) (+3 more)
+
+### Community 28 - "Community 28"
 Cohesion: 0.24
-Nodes (9): _ensure_customer_for_mother(), create_customer(), get_customer(), id_card_taken(), list_customers(), 客户 CRUD(biz_customer), _row_to_dict(), soft_delete_customer() (+1 more)
+Nodes (12): appointment_no_exists(), create_exhibition_appointment(), _format_visit_date(), _generate_appointment_no(), get_exhibition_appointment(), list_exhibition_appointments(), 展览预约 CRUD(biz_exhibition_appointment), 生成预约编号:YYYYMMDD + 6位序号(如 20260515000001) (+4 more)
 
-### Community 24 - "Community 24"
+### Community 29 - "Community 29"
+Cohesion: 0.24
+Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
+
+### Community 30 - "Community 30"
+Cohesion: 0.27
+Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
+
+### Community 31 - "Community 31"
+Cohesion: 0.31
+Nodes (8): create_sop_document(), get_sop_document(), list_sop_documents(), SOP文档 CRUD(biz_sop_document), _row_to_dict(), soft_delete_sop_document(), sop_code_exists(), update_sop_document()
+
+### Community 32 - "Community 32"
+Cohesion: 0.31
+Nodes (8): create_express_company(), express_code_exists(), get_express_company(), list_express_companies(), 快递公司 CRUD(biz_express_company), _row_to_dict(), soft_delete_express_company(), update_express_company()
+
+### Community 33 - "Community 33"
 Cohesion: 0.38
 Nodes (9): create_check_item(), get_check_item(), item_code_exists(), list_active_check_items(), list_check_items(), _order_query(), _row_to_dict(), soft_delete_check_item() (+1 more)
 
-### Community 25 - "Community 25"
+### Community 34 - "Community 34"
+Cohesion: 0.27
+Nodes (8): create_dict_data(), get_dict_data(), list_dict_data(), list_dict_data_options(), 字典数据 CRUD(sys_dict_data), _row_to_dict(), soft_delete_dict_data(), update_dict_data()
+
+### Community 35 - "Community 35"
+Cohesion: 0.31
+Nodes (8): category_code_exists(), create_process_category(), get_process_category(), list_process_categories(), 工艺工序分类 CRUD(biz_process_category), _row_to_dict(), soft_delete_process_category(), update_process_category()
+
+### Community 36 - "Community 36"
+Cohesion: 0.31
+Nodes (8): create_item_unit(), get_item_unit(), list_item_units(), 物品单位 CRUD(biz_item_unit), _row_to_dict(), soft_delete_item_unit(), unit_code_exists(), update_item_unit()
+
+### Community 37 - "Community 37"
+Cohesion: 0.31
+Nodes (8): create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict(), soft_delete_cell_sample_type(), type_code_exists(), update_cell_sample_type()
+
+### Community 38 - "Community 38"
+Cohesion: 0.31
+Nodes (8): create_output_type(), get_output_type(), list_output_types(), 工艺产出物类型 CRUD(biz_output_type), _row_to_dict(), soft_delete_output_type(), type_code_exists(), update_output_type()
+
+### Community 39 - "Community 39"
 Cohesion: 0.36
-Nodes (9): deduct_for_task(), _display_remark(), list_usages(), list_usages_by_task(), _parse_receiver_purpose_from_remark(), _resolve_receiver_purpose(), revert_task_material_usages(), _row_to_dict() (+1 more)
+Nodes (7): create_tenant(), get_tenant(), list_tenants(), _row_to_dict(), soft_delete_tenant(), tenant_no_exists(), update_tenant()
 
-### Community 26 - "Community 26"
-Cohesion: 0.39
-Nodes (8): create_lab_item(), get_lab_item(), item_code_exists(), list_active_lab_items(), list_lab_items(), _row_to_dict(), soft_delete_lab_item(), update_lab_item()
+### Community 40 - "Community 40"
+Cohesion: 0.33
+Nodes (7): _cascade_soft_delete(), create_menu(), get_menu(), list_menus(), _row_to_dict(), soft_delete_menu(), update_menu()
 
-### Community 27 - "Community 27"
+### Community 41 - "Community 41"
+Cohesion: 0.36
+Nodes (7): create_site(), get_site(), list_sites(), _row_to_dict(), site_code_exists(), soft_delete_site(), update_site()
+
+### Community 42 - "Community 42"
 Cohesion: 0.29
 Nodes (2): BaseModel, SoftDeleteModel
 
-### Community 28 - "Community 28"
+### Community 43 - "Community 43"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 29 - "Community 29"
+### Community 44 - "Community 44"
 Cohesion: 0.5
 Nodes (3): BaseSettings, Config, Settings
 
-### Community 30 - "Community 30"
+### Community 45 - "Community 45"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 31 - "Community 31"
+### Community 46 - "Community 46"
 Cohesion: 0.67
 Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,13 个一级菜单)
 
-### Community 32 - "Community 32"
+### Community 47 - "Community 47"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 33 - "Community 33"
+### Community 48 - "Community 48"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 36 - "Community 36"
+### Community 51 - "Community 51"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 37 - "Community 37"
+### Community 52 - "Community 52"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 38 - "Community 38"
+### Community 53 - "Community 53"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 39 - "Community 39"
+### Community 54 - "Community 54"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 40 - "Community 40"
+### Community 55 - "Community 55"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 41 - "Community 41"
+### Community 56 - "Community 56"
 Cohesion: 1.0
 Nodes (1): 发送催单通知给配送员                  Args:             deliver_openid: 配送员openid
 
 ## Knowledge Gaps
-- **99 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `Base64 密文 -> JSON 对象(须含 username、password、ts 毫秒时间戳)。` (+94 more)
+- **106 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `Base64 密文 -> JSON 对象(须含 username、password、ts 毫秒时间戳)。` (+101 more)
   These have ≤1 connection - possible missing edges or undocumented components.
-- **Thin community `Community 27`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+- **Thin community `Community 42`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 30`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 45`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 31`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,13 个一级菜单)`
+- **Thin community `Community 46`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,13 个一级菜单)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 32`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 47`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 33`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 48`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 36`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 51`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 37`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 52`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 38`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 53`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 54`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 40`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 55`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 41`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 56`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
   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 `SysUser` connect `Community 1` to `Community 0`, `Community 2`, `Community 4`, `Community 7`, `Community 8`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 15`, `Community 16`, `Community 21`?**
-  _High betweenness centrality (0.113) - this node is a cross-community bridge._
-- **Why does `generate_uuid()` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 18`, `Community 19`, `Community 20`, `Community 21`, `Community 23`, `Community 24`, `Community 25`, `Community 26`?**
-  _High betweenness centrality (0.073) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 31`?**
-  _High betweenness centrality (0.067) - this node is a cross-community bridge._
-- **Are the 523 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
-  _`success_response()` has 523 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 514 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `extract_login_credentials()`) actually correct?**
-  _`str` has 514 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 423 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
-  _`error_response()` has 423 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 357 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
-  _`SysUser` has 357 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `SysUser` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 4`, `Community 6`, `Community 7`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 17`, `Community 19`, `Community 23`, `Community 24`, `Community 29`?**
+  _High betweenness centrality (0.117) - this node is a cross-community bridge._
+- **Why does `generate_uuid()` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 12`, `Community 13`, `Community 16`, `Community 17`, `Community 18`, `Community 20`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 34`, `Community 35`, `Community 36`, `Community 37`, `Community 38`, `Community 39`, `Community 40`, `Community 41`?**
+  _High betweenness centrality (0.069) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 3`, `Community 6`, `Community 9`, `Community 10`, `Community 12`, `Community 13`, `Community 46`, `Community 14`, `Community 19`?**
+  _High betweenness centrality (0.068) - this node is a cross-community bridge._
+- **Are the 526 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
+  _`success_response()` has 526 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 521 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
+  _`str` has 521 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 426 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
+  _`error_response()` has 426 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 361 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
+  _`SysUser` has 361 INFERRED edges - model-reasoned connections that need verification._

Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
backend/graphify-out/graph.html


Разлика између датотеке није приказан због своје велике величине
+ 163 - 109
backend/graphify-out/graph.json


+ 9 - 7
backend/graphify-out/manifest.json

@@ -2,12 +2,13 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/__init__.py": 1778578257.989357,
   "/Users/wuzilong/work/00code/zkxw/backend/app/main.py": 1780457411.2283201,
   "/Users/wuzilong/work/00code/zkxw/backend/app/tasks/__init__.py": 1778579841.437791,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/core/auth.py": 1779245791.6811426,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/core/auth.py": 1780540737.3359916,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/api_crypto.py": 1778578258.039314,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/login_crypto.py": 1778578258.041774,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/core/config.py": 1779313263.9269438,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/core/config.py": 1780539434.4590578,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_pay.py": 1778578258.0461762,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/api_request_log_middleware.py": 1779882001.753267,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/core/avatar_url.py": 1780540753.5481932,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/database.py": 1779242347.136184,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/security.py": 1778578258.044949,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/request_payload.py": 1778578258.04292,
@@ -26,7 +27,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_message.py": 1778578258.0458734,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1779283385.245847,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1779022399.0175831,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1780462850.084297,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1780545639.1395557,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1779418058.876012,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/research_donation.py": 1780458012.8329656,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_material.py": 1779928228.734251,
@@ -35,7 +36,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/storage_release_review.py": 1779491680.5729434,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/qc_task.py": 1779597960.4337523,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/process_route.py": 1779367496.1718137,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/auth.py": 1778578258.0939043,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/auth.py": 1780539434.444314,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/delivery.py": 1778578258.0949655,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/outbound_release_review.py": 1779968230.0408957,
   "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/price_list.py": 1778578258.096609,
@@ -76,7 +77,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/outbound_release_review.py": 1779968239.1429183,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1779265648.38155,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1778775532.8870275,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1780539127.1805215,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1780479191.5054278,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/material.py": 1779283386.3879013,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_type.py": 1779265621.5212462,
@@ -89,7 +90,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task.py": 1779927441.9434412,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_third_party_item.py": 1779265661.482352,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779512669.9847593,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1780479210.0456562,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1780545654.7291799,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1779332036.2049248,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1779882385.4674172,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779828818.7646794,
@@ -146,7 +147,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/clinical_payment.py": 1779969576.9773977,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_release_review.py": 1779526635.1455016,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/outbound_release_review.py": 1779968232.5759368,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1779313409.0685387,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1780540754.892583,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1779526648.041865,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1779965447.565143,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1779882422.004045,
@@ -177,5 +178,6 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1778727966.7277644,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_engine.py": 1778634664.6293883,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/web_auth_roles.py": 1778775532.8878627,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_payment_installment.py": 1780545628.43657,
   "/Users/wuzilong/work/00code/zkxw/backend/app/ws/__init__.py": 1778578258.105995
 }

+ 110 - 0
backend/tests/test_protocol_pay_mode_switch.py

@@ -0,0 +1,110 @@
+"""缴费模式切换校验"""
+
+import unittest
+
+from app.services.protocol_pay_mode_switch import validate_payment_details_for_save
+
+
+class TestValidatePaymentDetailsForSave(unittest.TestCase):
+    def test_dj_single_dfj_full_contract(self):
+        validate_payment_details_for_save(
+            "dj",
+            10000,
+            None,
+            [{"payStatus": "dfj", "dueAmount": 10000}],
+        )
+
+    def test_dj_rejects_multiple_dfj(self):
+        with self.assertRaises(ValueError) as ctx:
+            validate_payment_details_for_save(
+                "dj",
+                10000,
+                None,
+                [
+                    {"payStatus": "dfj", "dueAmount": 5000},
+                    {"payStatus": "dfj", "dueAmount": 5000},
+                ],
+            )
+        self.assertIn("仅允许一条待缴费", str(ctx.exception))
+
+    def test_dj_partial_paid_remaining(self):
+        validate_payment_details_for_save(
+            "dj",
+            10000,
+            None,
+            [
+                {"payStatus": "yjf", "dueAmount": 2000},
+                {"payStatus": "dfj", "dueAmount": 8000},
+            ],
+        )
+
+    def test_dj_partial_paid_wrong_remaining(self):
+        with self.assertRaises(ValueError):
+            validate_payment_details_for_save(
+                "dj",
+                10000,
+                None,
+                [
+                    {"payStatus": "yjf", "dueAmount": 2000},
+                    {"payStatus": "dfj", "dueAmount": 10000},
+                ],
+            )
+
+    def test_fq_count_mismatch(self):
+        with self.assertRaises(ValueError):
+            validate_payment_details_for_save(
+                "fq",
+                6000,
+                6,
+                [{"payStatus": "dfj", "dueAmount": 6000}],
+            )
+
+    def test_fq_partial_paid_valid(self):
+        validate_payment_details_for_save(
+            "fq",
+            10000,
+            4,
+            [
+                {"payStatus": "yjf", "installmentNo": 1, "dueAmount": 2500},
+                {"payStatus": "yjf", "installmentNo": 2, "dueAmount": 2500},
+                {"payStatus": "dfj", "installmentNo": 3, "dueAmount": 2500},
+                {"payStatus": "dfj", "installmentNo": 4, "dueAmount": 2500},
+            ],
+        )
+
+    def test_fq_jfqrz_rejected(self):
+        with self.assertRaises(ValueError):
+            validate_payment_details_for_save(
+                "fq",
+                6000,
+                2,
+                [
+                    {"payStatus": "jfqrz", "dueAmount": 3000},
+                    {"payStatus": "dfj", "dueAmount": 3000},
+                ],
+            )
+
+    def test_fq_ytf_rejected(self):
+        with self.assertRaises(ValueError):
+            validate_payment_details_for_save(
+                "fq",
+                6000,
+                2,
+                [
+                    {"payStatus": "ytf", "dueAmount": 3000},
+                    {"payStatus": "dfj", "dueAmount": 3000},
+                ],
+            )
+
+    def test_blocking_status_rejected(self):
+        with self.assertRaises(ValueError):
+            validate_payment_details_for_save(
+                "dj",
+                10000,
+                None,
+                [{"payStatus": "jfqrz", "dueAmount": 10000}],
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 44 - 0
backend/tests/test_protocol_payment_installment.py

@@ -0,0 +1,44 @@
+"""分期应缴日:小数月间隔(30 天/月)与提前 10 天规则"""
+
+import unittest
+
+from app.services.protocol_payment_installment import calc_fq_installment_due_date
+
+
+class TestCalcFqInstallmentDueDate(unittest.TestCase):
+    def test_first_period_equals_start_date(self):
+        self.assertEqual(calc_fq_installment_due_date("20260604", 0, 0.5), "20260604")
+
+    def test_fractional_interval_half_month_six_periods(self):
+        start = "20260604"
+        interval = 0.5
+        expected = [
+            "20260604",
+            "20260609",
+            "20260624",
+            "20260709",
+            "20260725",
+            "20260809",
+        ]
+        for i, exp in enumerate(expected):
+            self.assertEqual(
+                calc_fq_installment_due_date(start, i, interval),
+                exp,
+                msg=f"period {i + 1}",
+            )
+
+    def test_fractional_interval_0_1_advance_10_days(self):
+        # +3 天 = 20260607,再提前 10 天
+        self.assertEqual(calc_fq_installment_due_date("20260604", 1, 0.1), "20260528")
+
+    def test_integer_interval_six_months(self):
+        # +6 日历月 = 20261204,再提前 10 天
+        self.assertEqual(calc_fq_installment_due_date("20260604", 1, 6), "20261124")
+
+    def test_fractional_interval_0_3(self):
+        # index=2 → 0.6 月 → 18 天 → 20260622,再提前 10 天
+        self.assertEqual(calc_fq_installment_due_date("20260604", 2, 0.3), "20260612")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 5 - 0
deploy/docker/README.md

@@ -144,6 +144,7 @@ zkxw-docker-deploy-<版本>/
 │   └── mysql/init/
 ├── docs/sql/zkxw.sql          # 首次部署自动建库用
 ├── 部署说明.md
+├── 部署指导手册.html          # 客户安装手册(浏览器打开,推荐交付)
 └── VERSION.txt
 ```
 
@@ -412,6 +413,10 @@ docker compose up -d
 ### 发版方打包时 pip / pnpm 很慢或超时
 
 - 已默认 **阿里云 PyPI** + **npmmirror**,并启用 BuildKit 缓存。
+- 若前端构建在 `corepack prepare pnpm@...` 报错(请求 `registry.npmjs.org` 失败),请更新到含 `COREPACK_NPM_REGISTRY` 的 Dockerfile,或手动指定:
+  ```bash
+  NPM_REGISTRY=https://registry.npmmirror.com ./scripts/package-docker-deploy.sh v1.0.0
+  ```
 - **在 Mac(M 芯片)上交叉构建 `linux/amd64` 很慢**(后端 pip 常见 15~40 分钟),日志会长时间不动,**不要误按 Ctrl+C**。
 - **推荐**:把仓库放到 **Linux x86 云主机**(与甲方同架构),在该机器执行 `./scripts/package-docker-deploy.sh`,通常 5~15 分钟完成。
 

+ 2 - 0
deploy/docker/web-admin/Dockerfile

@@ -5,6 +5,8 @@ ARG NGINX_IMAGE=docker.m.daocloud.io/library/nginx:1.27-alpine
 FROM ${NODE_IMAGE} AS builder
 
 ARG NPM_REGISTRY=https://registry.npmmirror.com
+# corepack 默认走 registry.npmjs.org,国内/受限网络会失败;须显式指定镜像
+ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY}
 
 RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
 

+ 499 - 0
deploy/docker/部署指导手册.html

@@ -0,0 +1,499 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>中科兴皖 Docker 部署指导手册</title>
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #0f172a;
+      --text-muted: #64748b;
+      --border: #e2e8f0;
+      --warn-bg: #fffbeb;
+      --warn-border: #f59e0b;
+      --danger-bg: #fef2f2;
+      --danger-border: #ef4444;
+      --code-bg: #f8fafc;
+      --radius: 10px;
+      --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
+        "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+    }
+    * { box-sizing: border-box; }
+    html { scroll-behavior: smooth; }
+    body {
+      margin: 0;
+      font-family: var(--font);
+      font-size: 15px;
+      line-height: 1.65;
+      color: var(--text);
+      background: #f1f5f9;
+    }
+    .layout {
+      display: grid;
+      grid-template-columns: 260px 1fr;
+      min-height: 100vh;
+      max-width: 1280px;
+      margin: 0 auto;
+      background: #fff;
+      box-shadow: 0 0 40px rgba(15, 23, 42, 0.06);
+    }
+    @media (max-width: 900px) {
+      .layout { grid-template-columns: 1fr; }
+      .sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
+    }
+    .sidebar {
+      position: sticky;
+      top: 0;
+      height: 100vh;
+      overflow-y: auto;
+      padding: 24px 20px;
+      background: #fafbfc;
+      border-right: 1px solid var(--border);
+    }
+    .sidebar h1 {
+      font-size: 17px;
+      margin: 0 0 4px;
+      color: var(--primary-dark);
+    }
+    .sidebar .sub {
+      font-size: 12px;
+      color: var(--text-muted);
+      margin-bottom: 20px;
+    }
+    .sidebar nav a {
+      display: block;
+      padding: 6px 10px;
+      margin: 2px 0;
+      font-size: 13px;
+      color: var(--text);
+      text-decoration: none;
+      border-radius: 6px;
+    }
+    .sidebar nav a:hover { background: var(--primary-bg); color: var(--primary-dark); }
+    main { padding: 32px 40px 64px; min-width: 0; }
+    @media (max-width: 600px) { main { padding: 20px 16px 48px; } }
+    h2 {
+      font-size: 22px;
+      margin: 40px 0 16px;
+      padding-bottom: 8px;
+      border-bottom: 2px solid var(--primary-bg);
+      color: var(--primary-dark);
+    }
+    h2:first-of-type { margin-top: 0; }
+    h3 { font-size: 17px; margin: 24px 0 12px; }
+    p { margin: 0 0 12px; }
+    ul, ol { margin: 0 0 16px; padding-left: 1.4em; }
+    li { margin-bottom: 6px; }
+    table {
+      width: 100%;
+      border-collapse: collapse;
+      margin: 12px 0 20px;
+      font-size: 14px;
+    }
+    th, td {
+      border: 1px solid var(--border);
+      padding: 10px 12px;
+      text-align: left;
+      vertical-align: top;
+    }
+    th { background: var(--primary-bg); font-weight: 600; }
+    pre, code {
+      font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+      font-size: 13px;
+    }
+    pre {
+      background: var(--code-bg);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 14px 16px;
+      overflow-x: auto;
+      margin: 12px 0 20px;
+      line-height: 1.5;
+    }
+    code { background: var(--code-bg); padding: 2px 6px; border-radius: 4px; }
+    pre code { background: none; padding: 0; }
+    .hero {
+      background: linear-gradient(135deg, var(--primary-bg) 0%, #fff 60%);
+      border: 1px solid #a7f3d0;
+      border-radius: var(--radius);
+      padding: 24px 28px;
+      margin-bottom: 28px;
+    }
+    .hero h2 { border: none; margin: 0 0 8px; padding: 0; font-size: 24px; }
+    .hero p { margin: 0; color: var(--text-muted); }
+    .badge {
+      display: inline-block;
+      background: var(--primary);
+      color: #fff;
+      font-size: 12px;
+      font-weight: 600;
+      padding: 3px 10px;
+      border-radius: 999px;
+      margin-right: 8px;
+      vertical-align: middle;
+    }
+    .steps { counter-reset: step; list-style: none; padding: 0; }
+    .steps > li {
+      counter-increment: step;
+      position: relative;
+      padding: 16px 16px 16px 52px;
+      margin-bottom: 12px;
+      background: #fafbfc;
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+    }
+    .steps > li::before {
+      content: counter(step);
+      position: absolute;
+      left: 16px;
+      top: 16px;
+      width: 28px;
+      height: 28px;
+      line-height: 28px;
+      text-align: center;
+      background: var(--primary);
+      color: #fff;
+      font-weight: 700;
+      font-size: 14px;
+      border-radius: 50%;
+    }
+    .steps h3 { margin: 0 0 8px; font-size: 16px; }
+    .box {
+      border-radius: 8px;
+      padding: 14px 16px;
+      margin: 16px 0;
+      font-size: 14px;
+    }
+    .box-warn { background: var(--warn-bg); border-left: 4px solid var(--warn-border); }
+    .box-danger { background: var(--danger-bg); border-left: 4px solid var(--danger-border); }
+    .box-info { background: var(--primary-bg); border-left: 4px solid var(--primary); }
+    .checklist { list-style: none; padding: 0; }
+    .checklist li {
+      padding: 8px 0 8px 28px;
+      position: relative;
+      border-bottom: 1px dashed var(--border);
+    }
+    .checklist li::before {
+      content: "☐";
+      position: absolute;
+      left: 0;
+      color: var(--primary);
+      font-size: 18px;
+    }
+    .arch {
+      background: var(--code-bg);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 16px;
+      font-family: ui-monospace, monospace;
+      font-size: 13px;
+      line-height: 1.45;
+      white-space: pre;
+      overflow-x: auto;
+    }
+    .tree {
+      font-family: ui-monospace, monospace;
+      font-size: 13px;
+      background: var(--code-bg);
+      padding: 16px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+    }
+    footer {
+      margin-top: 48px;
+      padding-top: 20px;
+      border-top: 1px solid var(--border);
+      font-size: 13px;
+      color: var(--text-muted);
+    }
+  </style>
+</head>
+<body>
+  <div class="layout">
+    <aside class="sidebar">
+      <h1>中科兴皖</h1>
+      <p class="sub">Docker 部署指导手册<br />适用:甲方运维人员</p>
+      <nav>
+        <a href="#overview">概述</a>
+        <a href="#requirements">环境要求</a>
+        <a href="#package">交付包说明</a>
+        <a href="#mirror">镜像加速(国内)</a>
+        <a href="#install">安装步骤</a>
+        <a href="#config">配置说明</a>
+        <a href="#verify">验收检查</a>
+        <a href="#architecture">服务架构</a>
+        <a href="#profiles">部署模式</a>
+        <a href="#ops">日常运维</a>
+        <a href="#backup">备份与升级</a>
+        <a href="#faq">常见问题</a>
+      </nav>
+    </aside>
+
+    <main>
+      <div class="hero">
+        <h2><span class="badge">Docker</span>中科兴皖系统部署指导</h2>
+        <p>
+          本手册帮助您在 Linux 服务器上完成<strong>预构建镜像</strong>方式的安装。
+          交付包不含前后端源码,首次启动需联网拉取 MySQL / Redis / MinIO 等基础镜像。
+          对外访问端口默认为 <strong>8112</strong>。
+        </p>
+      </div>
+
+      <section id="overview">
+        <h2>1. 概述</h2>
+        <table>
+          <tr><th>项目</th><th>说明</th></tr>
+          <tr><td>部署方式</td><td><code>docker load</code> 导入应用镜像 + <code>docker compose up -d</code></td></tr>
+          <tr><td>应用镜像</td><td><code>zkxw-backend</code>、<code>zkxw-web</code>(已预构建,无需编译)</td></tr>
+          <tr><td>CPU 架构</td><td><code>linux/amd64</code>(常见 x86 云主机;ARM 服务器需联系交付方重新打包)</td></tr>
+          <tr><td>对外端口</td><td><strong>8112</strong>(Web 管理端 + API 反代)</td></tr>
+          <tr><td>默认账号</td><td><code>admin</code> / <code>111111</code>(首次登录后请立即修改)</td></tr>
+        </table>
+        <div class="box box-info">
+          <strong>说明:</strong>MySQL、Redis、MinIO 仅在 Docker 内部网络通信,<strong>不占用</strong>宿主机 3306 / 6379 / 9000 端口,一般不会与贵方已有中间件冲突。
+        </div>
+      </section>
+
+      <section id="requirements">
+        <h2>2. 环境要求</h2>
+        <table>
+          <tr><th>项</th><th>要求</th></tr>
+          <tr><td>操作系统</td><td>Linux x86_64(推荐 Ubuntu 22.04、CentOS 7+)</td></tr>
+          <tr><td>Docker</td><td>≥ 24.0</td></tr>
+          <tr><td>Docker Compose</td><td>v2(使用 <code>docker compose</code> 子命令)</td></tr>
+          <tr><td>网络</td><td>可访问 Docker Hub,或已配置国内 Registry 镜像加速</td></tr>
+          <tr><td>硬件建议</td><td>4 核 CPU、8 GB 内存、磁盘 ≥ 50 GB</td></tr>
+          <tr><td>端口</td><td>宿主机 <strong>8112</strong> 未被占用;防火墙 / 安全组已放行 TCP 8112</td></tr>
+        </table>
+        <p>安装前请执行以下命令确认环境:</p>
+        <pre><code>docker --version
+docker compose version
+docker info | grep -A2 "Registry Mirrors"   # 国内环境建议已配置加速</code></pre>
+      </section>
+
+      <section id="package">
+        <h2>3. 交付包目录说明</h2>
+        <p>解压 <code>zkxw-docker-deploy-&lt;版本&gt;.tar.gz</code> 后,目录结构如下:</p>
+        <div class="tree">zkxw-docker-deploy-&lt;版本&gt;/
+├── images/
+│   └── zkxw-app-images.tar      # 预构建前后端镜像(必做 docker load)
+├── deploy/docker/
+│   ├── docker-compose.yml
+│   ├── .env.example             # 复制为 .env 后修改密码
+│   ├── mysql/init/
+│   └── 部署指导手册.html        # 本手册
+├── docs/sql/zkxw.sql            # 首次部署自动建库(非业务源码)
+├── 部署说明.md                  # Markdown 版说明(可选阅读)
+└── VERSION.txt                  # 版本与镜像 tag 信息</div>
+        <div class="box box-warn">
+          <strong>不包含:</strong>后端 Python 源码、前端 Vue 源码、Dockerfile。数据库脚本仅为表结构与基线数据。
+        </div>
+      </section>
+
+      <section id="mirror">
+        <h2>4. Docker 镜像加速(国内强烈建议)</h2>
+        <p>
+          应用镜像通过 <code>docker load</code> 本地导入;但首次 <code>docker compose up</code> 仍需从网络拉取
+          <code>mysql:5.7.36</code>、<code>redis:7-alpine</code>、<code>minio/minio</code> 等基础镜像。
+          国内服务器建议配置 Registry 镜像加速。
+        </p>
+        <p>编辑 <code>/etc/docker/daemon.json</code>(示例,任选可用加速地址):</p>
+        <pre><code>{
+  "registry-mirrors": [
+    "https://docker.1ms.run",
+    "https://docker.m.daocloud.io"
+  ]
+}</code></pre>
+        <pre><code>sudo systemctl daemon-reload
+sudo systemctl restart docker</code></pre>
+        <p>若贵司已有阿里云 ACR 等专属加速地址,请优先使用官方提供的 URL。</p>
+      </section>
+
+      <section id="install">
+        <h2>5. 安装步骤</h2>
+        <p>以下命令假设交付包已上传至 <code>/opt/zkxw</code>,请按实际路径调整。</p>
+        <ol class="steps">
+          <li>
+            <h3>解压交付包</h3>
+            <pre><code>mkdir -p /opt/zkxw && cd /opt/zkxw
+tar -xzf zkxw-docker-deploy-*.tar.gz
+cd zkxw-docker-deploy-*</code></pre>
+          </li>
+          <li>
+            <h3>导入应用镜像</h3>
+            <pre><code>docker load -i images/zkxw-app-images.tar
+docker images | grep zkxw-</code></pre>
+            <p>请记下镜像 tag(如 <code>zkxw-backend:v1.0.0</code>),下一步配置须一致。</p>
+          </li>
+          <li>
+            <h3>配置环境变量</h3>
+            <pre><code>cd deploy/docker
+cp .env.example .env
+vi .env</code></pre>
+            <p>至少修改:<code>MYSQL_ROOT_PASSWORD</code>、<code>DATABASE_URL</code> 中的密码、<code>SECRET_KEY</code>、<code>MINIO_SECRET_KEY</code>。</p>
+            <p>确认 <code>ZKXW_BACKEND_IMAGE</code>、<code>ZKXW_WEB_IMAGE</code> 与 <code>docker images</code> 显示的 tag 一致(可参考包内 <code>VERSION.txt</code>)。</p>
+          </li>
+          <li>
+            <h3>启动全部服务</h3>
+            <pre><code>docker compose up -d</code></pre>
+            <p>首次启动会自动拉取基础镜像,MySQL 导入 <code>zkxw.sql</code> 约需 <strong>1~3 分钟</strong>,请耐心等待。</p>
+          </li>
+          <li>
+            <h3>(可选)查看启动进度</h3>
+            <pre><code>docker compose ps
+docker compose logs -f mysql    # 可选,Ctrl+C 仅退出日志,不停止服务
+docker compose logs -f backend</code></pre>
+          </li>
+        </ol>
+      </section>
+
+      <section id="config">
+        <h2>6. 配置说明(.env 要点)</h2>
+        <p>默认模式 <code>COMPOSE_PROFILES=full</code>:自带 MySQL、Redis、MinIO。</p>
+        <table>
+          <tr><th>变量</th><th>说明</th></tr>
+          <tr><td><code>HTTP_PORT</code></td><td>对外 HTTP 端口,默认 <strong>8112</strong></td></tr>
+          <tr><td><code>ZKXW_BACKEND_IMAGE</code></td><td>后端镜像名:tag,须与 docker load 一致</td></tr>
+          <tr><td><code>ZKXW_WEB_IMAGE</code></td><td>前端镜像名:tag,须与 docker load 一致</td></tr>
+          <tr><td><code>MYSQL_ROOT_PASSWORD</code></td><td>MySQL root 密码(full 模式)</td></tr>
+          <tr><td><code>DATABASE_URL</code></td><td>数据库连接串,密码须与上面一致</td></tr>
+          <tr><td><code>SECRET_KEY</code></td><td>JWT 密钥,生产环境务必改为长随机串</td></tr>
+          <tr><td><code>REDIS_HOST</code></td><td>full 模式填 <code>redis</code></td></tr>
+          <tr><td><code>MINIO_ENDPOINT</code></td><td>full 模式填 <code>minio:9000</code></td></tr>
+          <tr><td><code>MINIO_BASE_URL</code></td><td>保持 <code>/minio/zkxw</code>(由 Web 镜像内 Nginx 反代)</td></tr>
+          <tr><td><code>API_ENCRYPT_ENABLED</code></td><td>默认 <code>false</code>,Docker 交付为明文 API</td></tr>
+        </table>
+        <div class="box box-danger">
+          <strong>切勿将 .env 提交到版本库。</strong>修改密码后若服务已启动,执行 <code>docker compose up -d</code> 并 <code>docker compose restart backend</code>。
+        </div>
+      </section>
+
+      <section id="verify">
+        <h2>7. 验收检查</h2>
+        <ul class="checklist">
+          <li><code>docker compose ps</code> 中 mysql、redis、minio、backend、web 均为 running(backend/web 可为 healthy)</li>
+          <li>服务器本机:<code>curl -s http://127.0.0.1:8112/health</code> 返回正常</li>
+          <li>浏览器访问 <code>http://&lt;服务器IP&gt;:8112/</code> 可打开登录页</li>
+          <li>使用 <code>admin</code> / <code>111111</code> 登录成功(登录后请改密)</li>
+          <li>防火墙 / 安全组已放行 TCP 8112</li>
+        </ul>
+        <table>
+          <tr><th>地址</th><th>说明</th></tr>
+          <tr><td><code>http://&lt;IP&gt;:8112/</code></td><td>Web 管理端</td></tr>
+          <tr><td><code>http://&lt;IP&gt;:8112/health</code></td><td>健康检查</td></tr>
+          <tr><td><code>http://&lt;IP&gt;:8112/docs</code></td><td>Swagger API 文档</td></tr>
+        </table>
+      </section>
+
+      <section id="architecture">
+        <h2>8. 服务架构</h2>
+        <div class="arch">浏览器 :8112
+    └── web(Nginx + 前端静态)
+            ├── /api/*       → backend:8111(FastAPI)
+            └── /minio/zkxw/* → minio:9000(附件,容器内网络)
+
+backend → mysql:3306(业务库 zkxw)
+        → redis:6379(会话)
+        → minio:9000(对象存储)</div>
+      </section>
+
+      <section id="profiles">
+        <h2>9. 部署模式(高级)</h2>
+        <p>通过 <code>.env</code> 中 <code>COMPOSE_PROFILES</code> 切换,默认无需修改。</p>
+        <h3>9.1 full(默认,全栈)</h3>
+        <pre><code>COMPOSE_PROFILES=full</code></pre>
+        <p>启动 MySQL + Redis + MinIO + 后端 + 前端,适合独立部署一台服务器。</p>
+        <h3>9.2 app(仅应用,连接已有中间件)</h3>
+        <p>若贵方已有 MySQL、Redis、MinIO,可设为 <code>COMPOSE_PROFILES=app</code>,并将 <code>DATABASE_URL</code>、<code>REDIS_HOST</code>、<code>MINIO_*</code> 改为实际地址。具体示例见 <code>deploy/docker/.env.example</code> 注释。</p>
+        <h3>9.3 app,storage(外部库 + 自带 MinIO)</h3>
+        <p>已有 MySQL/Redis,仅 MinIO 由本 compose 提供时使用。</p>
+        <div class="box box-warn">
+          使用外部 MinIO 且不在 Docker 网络内时,可能需要交付方重新构建 Web 镜像。一般建议先按默认 <code>full</code> 模式完成安装。
+        </div>
+      </section>
+
+      <section id="ops">
+        <h2>10. 日常运维命令</h2>
+        <p>以下命令均在 <code>deploy/docker</code> 目录执行:</p>
+        <pre><code># 查看状态
+docker compose ps
+
+# 查看日志
+docker compose logs -f backend
+docker compose logs -f web
+
+# 重启
+docker compose restart
+
+# 停止(不删数据卷)
+docker compose down
+
+# 进入后端容器
+docker compose exec backend bash</code></pre>
+        <div class="box box-danger">
+          <strong>警告:</strong><code>docker compose down -v</code> 会删除所有数据卷(MySQL、MinIO 等),导致数据丢失。仅在首次部署失败且可接受清空数据时使用。
+        </div>
+      </section>
+
+      <section id="backup">
+        <h2>11. 备份与升级</h2>
+        <h3>11.1 数据卷</h3>
+        <table>
+          <tr><th>卷名(前缀 zkxw_)</th><th>内容</th></tr>
+          <tr><td>mysql_data</td><td>MySQL 业务数据</td></tr>
+          <tr><td>redis_data</td><td>Redis 持久化</td></tr>
+          <tr><td>minio_data</td><td>附件对象</td></tr>
+          <tr><td>backend_uploads</td><td>本地上传目录(若使用本地存储)</td></tr>
+        </table>
+        <h3>11.2 MySQL 备份示例</h3>
+        <pre><code>docker compose exec mysql mysqldump -uroot -p zkxw > zkxw_backup.sql</code></pre>
+        <h3>11.3 版本升级</h3>
+        <ol>
+          <li>备份数据库与 MinIO 数据</li>
+          <li>解压新版本交付包,<code>docker load -i images/zkxw-app-images.tar</code></li>
+          <li>更新 <code>.env</code> 中 <code>ZKXW_BACKEND_IMAGE</code>、<code>ZKXW_WEB_IMAGE</code></li>
+          <li>在 <code>deploy/docker</code> 执行 <code>docker compose up -d</code></li>
+          <li><code>curl -s http://127.0.0.1:8112/health</code> 验收</li>
+        </ol>
+        <p>若仅有数据库结构变更,请使用交付方提供的增量 SQL,<strong>不要</strong>删除已有 mysql_data 卷。</p>
+      </section>
+
+      <section id="faq">
+        <h2>12. 常见问题</h2>
+        <h3>无法访问 8112</h3>
+        <ul>
+          <li>检查云主机安全组 / 防火墙是否放行 TCP 8112</li>
+          <li><code>docker compose ps</code> 确认 web 为 running</li>
+          <li><code>docker compose logs web</code> 查看 Nginx 日志</li>
+        </ul>
+        <h3>/api 返回 502</h3>
+        <ul>
+          <li>后端未就绪:<code>docker compose logs backend</code></li>
+          <li>首次部署 MySQL 仍在导入:等待 1~3 分钟后重试</li>
+        </ul>
+        <h3>拉取 mysql/redis/minio 镜像失败或很慢</h3>
+        <p>请按第 4 节配置 Docker Registry 镜像加速后,重新执行 <code>docker compose up -d</code>。</p>
+        <h3>附件无法显示</h3>
+        <ul>
+          <li>确认 <code>STORAGE_TYPE=minio</code></li>
+          <li><code>docker compose logs minio-init</code> 应显示 bucket 创建成功</li>
+          <li>浏览器中附件 URL 应为 <code>/minio/zkxw/...</code></li>
+        </ul>
+        <h3>首次部署数据库初始化失败(可清空重来)</h3>
+        <pre><code>cd deploy/docker
+docker compose down -v
+docker compose up -d</code></pre>
+        <p>此操作会清空所有数据,仅适用于尚未正式上线的环境。</p>
+      </section>
+
+      <footer>
+        <p>中科兴皖 · Docker 部署指导手册 · 与交付包内 <code>deploy/docker/README.md</code> 保持一致</p>
+        <p>技术支持请联系项目交付方。安装完成后请修改默认管理员密码。</p>
+      </footer>
+    </main>
+  </div>
+</body>
+</html>

+ 3 - 0
docs/sql/migrations/2026-06-04-installment-interval-decimal.sql

@@ -0,0 +1,3 @@
+-- 每期间隔(月)支持小数,如 0.1、0.5
+ALTER TABLE `biz_protocol_payment`
+  MODIFY COLUMN `installment_interval_months` decimal(10, 2) DEFAULT NULL COMMENT '每期间隔(月)';

+ 1 - 1
docs/sql/zkxw.sql

@@ -3046,7 +3046,7 @@ CREATE TABLE `biz_protocol_payment` (
   `pay_mode` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'dj趸交 fq分期',
   `contract_amount` decimal(18,2) DEFAULT NULL COMMENT '合同金额',
   `installment_count` int(11) DEFAULT NULL COMMENT '分期期数',
-  `installment_interval_months` int(11) DEFAULT NULL COMMENT '每期间隔(月)',
+  `installment_interval_months` decimal(10,2) DEFAULT NULL COMMENT '每期间隔(月)',
   `lump_sum_deadline` char(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '趸交截止时间YYYYMMDD',
   `installment_start_date` char(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '分期开始时间YYYYMMDD',
   `tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',

+ 227 - 0
docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md

@@ -0,0 +1,227 @@
+# 采集协议单 · 缴费模式切换设计
+
+**日期**:2026-06-04  
+**范围**:`web-admin` 采集协议单新增/编辑弹窗(`ProtocolFormDialog.vue`)及后端缴费主表/明细保存  
+**状态**:已实现(P0)
+
+---
+
+## 1. 背景
+
+采集协议单缴费主表 `pay_mode` 有两种取值:
+
+| 值 | 含义 | 明细约定 |
+|----|------|----------|
+| `dj` | 趸交(一次性) | **1 条**有效明细 |
+| `fq` | 分期 | **N 条**明细,N = `installment_count` |
+
+用户在**编辑已保存协议**时可能切换缴费模式。当前实现仅做字段联动(金额同步、日期重算),**不区分明细缴费状态**,会导致:
+
+- 分期改趸交后仍保留多条明细且每条金额 = 合同全额;
+- 已缴明细被重算应缴日/期次;
+- 趸交已缴后仍可改分期,账实不符。
+
+本设计定义切换规则与实现边界。
+
+---
+
+## 2. 决策记录
+
+| 议题 | 决定 |
+|------|------|
+| 小数月间隔换算 | 30 天/月(已实现,见 `protocol-payment-installment.ts`) |
+| 第 2 期起提前 10 天 | 保留 |
+| 分期部分已缴后改趸交,未缴期次 | **A:作废**(软删/不再展示,不进分期预警) |
+| 已缴/确认中明细 | **保留**,只读,不参与重算 |
+| 趸交已缴后改分期 | **禁止** |
+| 趸交确认中改分期 | **禁止**(提示先完成或撤回财务确认) |
+
+---
+
+## 3. 明细状态与「锁定」定义
+
+| pay_status | 含义 | 切换时 |
+|------------|------|--------|
+| `dfj` | 待缴费 | 可作废、可重建 |
+| `jfqrz` | 缴费确认中 | **锁定**(视为已占用,禁止模式切换) |
+| `yjf` | 已缴费 | **锁定**(保留,只读) |
+| `tfqrz` | 退费确认中 | **锁定**(禁止切换) |
+| `ytf` | 已退费 | **锁定**(禁止切换) |
+
+**剩余应缴金额**:
+
+```
+remaining = contractAmount − Σ(dueAmount WHERE pay_status IN ('yjf'))
+```
+
+`jfqrz` 阶段切换一律阻断;若业务后续允许「确认中算占用」,再单独开变更。
+
+---
+
+## 4. 场景规则
+
+### 4.1 趸交 → 分期(dj → fq)
+
+| 前置条件 | 处理 |
+|----------|------|
+| 无明细或全部 `dfj` | **允许**。作废全部现有明细;按分期参数生成 N 条新 `dfj` 明细;主表 `pay_mode=fq`。 |
+| 存在 `jfqrz` / `yjf` / `tfqrz` / `ytf` | **禁止**。提示原因。 |
+
+切换后:第 i 期 `installmentNo=i`,应缴日按 `calcFqInstallmentDueDate`,应缴金额按合同均分(末期待尾差)。
+
+### 4.2 分期 → 趸交(fq → dj),一期未缴
+
+| 前置条件 | 处理 |
+|----------|------|
+| 全部 `dfj` | **允许**。作废全部 N 条分期明细;**新建 1 条**趸交明细:`dueAmount=contractAmount`,`installmentNo=null`,应缴日=`lumpSumDeadline`。 |
+| 存在任何非 `dfj` | 走 4.3 |
+
+### 4.3 分期 → 趸交(fq → dj),部分已缴
+
+| 前置条件 | 处理 |
+|----------|------|
+| 有 `yjf`,无 `jfqrz`/`tfqrz`/`ytf` | **允许**。 |
+| 存在 `jfqrz`/`tfqrz`/`ytf` | **禁止**。 |
+
+**允许时步骤**:
+
+1. **保留**所有 `yjf` 明细(只读展示,保留原 `installmentNo`、转账信息)。
+2. **作废**所有 `dfj` 分期明细(不进入表单列表;保存时 `del_flag=1`)。
+3. **新建 1 条**趸交明细:`dueAmount=remaining`,`payStatus=dfj`,`installmentNo=null`,应缴日=`lumpSumDeadline`。
+4. 主表 `pay_mode=dj`;分期参数字段可保留只读快照(便于查看历史计划)。
+
+若 `remaining ≤ 0`:禁止切换,提示「已缴清,无需改趸交」。
+
+### 4.4 趸交 → 分期,趸交已缴
+
+**禁止**。提示「趸交已结清,无法改为分期」。
+
+---
+
+## 5. 交互设计
+
+### 5.1 切换前校验
+
+用户点击「分期 / 趸交」radio 时:
+
+1. 若目标模式与当前相同 → 无操作。
+2. 调用 `evaluatePayModeSwitch(currentMode, targetMode, details, contractAmount)`。
+3. 返回 `blocked` → `ElMessageBox.alert` 说明原因,**回滚 radio**。
+4. 返回 `allowed` → `ElMessageBox.confirm` 展示影响摘要,确认后执行变换。
+
+**确认文案示例(fq→dj,部分已缴)**:
+
+> 将作废 4 期未缴计划,保留 2 期已缴记录,并新增 1 条趸交明细(剩余 ¥X,XXX.XX)。是否继续?
+
+### 5.2 编辑态保护
+
+- 锁定明细:禁用删除、应缴金额、缴费状态、期次、应缴日编辑。
+- 趸交模式下隐藏「添加明细」(或禁用,保证仅 1 条有效未缴行 + 已缴历史行)。
+- 切到分期时**不**强制 `resetCountInterval`;优先保留主表已保存的期数/间隔/开始时间。
+
+### 5.3 表单数据结构(过渡期)
+
+```typescript
+interface PaymentDetailRow {
+  // ...existing fields
+  _payModeLocked?: boolean  // yjf 等锁定行
+  _voided?: boolean         // 前端待作废,保存时不提交或提交 void 标记
+}
+```
+
+保存 payload:仅提交**未作废**明细 + 锁定保留行;作废行由后端按 id 软删(P1)或前端不提交、后端 diff(P2)。
+
+---
+
+## 6. 后端改造(分阶段)
+
+### P0(与前端同步上线)
+
+在 `save_payment_details` / 协议 bundle 保存前增加校验:
+
+- `pay_mode=dj`:有效明细中「未锁定 + 未作废」的**待缴**行 ≤ 1;`Σ dueAmount(dfj 行)` = `remaining`(无已缴时 = 合同金额)。
+- `pay_mode=fq`:有效 `dfj` 行数 = `installment_count`(无已缴切换场景);或允许「已缴 k 期 + 剩余 N−k 期 dfj」(dj→fq 不适用,仅 fq 内改参数时需另议)。
+- 禁止客户端把 `yjf` 行 `pay_status` 改回 `dfj`。
+
+### P1
+
+- 作废明细:`del_flag=1`,分期预警自然排除。
+- 保存时带 `detail.id` upsert,避免每次编辑换 UUID。
+
+### P2
+
+- 专用 API:`POST .../payment/change-mode`
+- 操作日志:记录模式变更、作废条数、剩余金额
+
+---
+
+## 7. 核心算法(前端纯函数)
+
+建议新增 `web-admin/src/utils/protocol-pay-mode-switch.ts`:
+
+```typescript
+type SwitchResult =
+  | { ok: true; details: DetailLike[]; summary: string }
+  | { ok: false; reason: string }
+
+function evaluatePayModeSwitch(...): SwitchResult | 'need_confirm'
+function applyPayModeSwitch(...): SwitchResult
+```
+
+**禁止**在 `onPayModeChange` 中直接 `forEach` 重写全部明细;改为 `applyPayModeSwitch` 返回新数组。
+
+`tryGenerateInstallmentDetails` / `syncPaymentDueAmounts`:
+
+- 跳过 `_payModeLocked` 或 `payStatus !== 'dfj'` 的行;
+- 仅对未锁定 `dfj` 行重算金额/日期。
+
+---
+
+## 8. 测试用例
+
+| # | 场景 | 期望 |
+|---|------|------|
+| T1 | dj dfj×1 → fq 6期 | 1 条作废,6 条新 dfj,金额均分 |
+| T2 | fq 6×dfj → dj | 6 条作废,1 条 dj dfj 全额 |
+| T3 | fq 2×yjf + 4×dfj → dj | 2 条 yjf 保留,4 条作废,1 条 dj remaining |
+| T4 | dj yjf×1 → fq | blocked |
+| T5 | fq 含 jfqrz → dj | blocked |
+| T6 | fq 全部 yjf → dj | blocked(remaining=0) |
+| T7 | 切换取消 | radio 回滚,明细不变 |
+
+---
+
+## 9. 不在本次范围
+
+- 退费后再改模式的专流程 UI
+- 小程序端缴费模式切换
+
+---
+
+## 10. 实现顺序建议
+
+1. `protocol-pay-mode-switch.ts` + 单元测试(Node/vitest 或 backend pytest 镜像逻辑)
+2. 改造 `onPayModeChange` + 确认弹窗
+3. 改造 `tryGenerateInstallmentDetails` / `syncPaymentDueAmounts` 跳过锁定行
+4. 后端 P0 校验
+5. 联调四个场景 + 分期预警回归
+
+---
+
+## 11. 分期参数变更(非模式切换)
+
+编辑已保存的**分期**协议时,用户可能修改「分期期数 / 每期间隔 / 分期开始时间」,规则如下:
+
+| 情况 | 处理 |
+|------|------|
+| 全部 `dfj` | 确认后按新参数**整表重建** N 条明细 |
+| 部分 `yjf` | 保留已缴行;**作废**全部 `dfj`;按 **剩余金额** 均分生成 `(新期数 − 已缴期数)` 条未缴明细;应缴日按已缴期数续算 index |
+| 期数小于已缴最大期次 | **禁止** |
+| 含 `jfqrz`/`tfqrz`/`ytf` | **禁止** |
+| 仅改间隔/开始时间(期数不变) | 确认后保留 yjf,重建未缴行(金额+日期) |
+
+实现:`web-admin/src/utils/protocol-fq-params-change.ts`,表单 `@change` → `handleFqParamsChange()`。
+
+---
+
+**审阅**:请确认本 spec 无遗漏后,进入 `writing-plans` 生成实现任务清单。

+ 3 - 0
scripts/package-docker-deploy.sh

@@ -39,6 +39,8 @@ cp -R "${ROOT}/deploy/docker/mysql" "${STAGING}/deploy/docker/"
 cp "${ROOT}/docs/sql/zkxw.sql" "${STAGING}/docs/sql/zkxw.sql"
 
 cp "${ROOT}/deploy/docker/README.md" "${STAGING}/部署说明.md"
+cp "${ROOT}/deploy/docker/部署指导手册.html" "${STAGING}/部署指导手册.html"
+cp "${ROOT}/deploy/docker/部署指导手册.html" "${STAGING}/deploy/docker/部署指导手册.html"
 
 cat > "${STAGING}/VERSION.txt" <<EOF
 package=${PKG_NAME}
@@ -64,6 +66,7 @@ echo "包内不含前后端源码,包含:"
 echo "  - images/zkxw-app-images.tar"
 echo "  - deploy/docker/ (compose + .env.example + mysql init)"
 echo "  - docs/sql/zkxw.sql"
+echo "  - 部署指导手册.html(客户安装手册,浏览器打开)"
 echo ""
 echo "甲方服务器执行:"
 echo "  tar -xzf ${PKG_NAME}.tar.gz"

Разлика између датотеке није приказан због своје велике величине
+ 417 - 350
web-admin/graphify-out/.graphify_analysis.json


Разлика између датотеке није приказан због своје велике величине
+ 822 - 26
web-admin/graphify-out/.graphify_extract.json


+ 89 - 110
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
-# Graph Report - src  (2026-06-03)
+# Graph Report - src  (2026-06-04)
 
 ## Corpus Check
-- Large corpus: 680 files · ~424,746 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 683 files · ~427,683 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 1957 nodes · 2519 edges · 51 communities detected
-- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 557 edges (avg confidence: 0.8)
+- 2000 nodes · 2611 edges · 48 communities detected
+- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 579 edges (avg confidence: 0.8)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -43,7 +43,7 @@
 - [[_COMMUNITY_Community 32|Community 32]]
 - [[_COMMUNITY_Community 33|Community 33]]
 - [[_COMMUNITY_Community 34|Community 34]]
-- [[_COMMUNITY_Community 36|Community 36]]
+- [[_COMMUNITY_Community 35|Community 35]]
 - [[_COMMUNITY_Community 37|Community 37]]
 - [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
@@ -51,19 +51,16 @@
 - [[_COMMUNITY_Community 41|Community 41]]
 - [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 44|Community 44]]
-- [[_COMMUNITY_Community 45|Community 45]]
 - [[_COMMUNITY_Community 46|Community 46]]
 - [[_COMMUNITY_Community 48|Community 48]]
-- [[_COMMUNITY_Community 49|Community 49]]
+- [[_COMMUNITY_Community 50|Community 50]]
 - [[_COMMUNITY_Community 51|Community 51]]
 - [[_COMMUNITY_Community 52|Community 52]]
-- [[_COMMUNITY_Community 53|Community 53]]
-- [[_COMMUNITY_Community 55|Community 55]]
-- [[_COMMUNITY_Community 57|Community 57]]
+- [[_COMMUNITY_Community 54|Community 54]]
 
 ## God Nodes (most connected - your core abstractions)
 1. `get()` - 206 edges
-2. `push()` - 36 edges
+2. `push()` - 39 edges
 3. `WebSocketClient` - 26 edges
 4. `emit()` - 16 edges
 5. `MenuProcessor` - 16 edges
@@ -75,53 +72,53 @@
 
 ## Surprising Connections (you probably didn't know these)
 - `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
-  views/basic-info/customer/index.vue → utils/fullscreen-loading.ts
+  views/production/consumable/ConsumablePackTab.vue → utils/fullscreen-loading.ts
+- `handleDownloadAttachment()` --calls--> `downloadAttachmentFile()`  [INFERRED]
+  views/pilot-service/infusion-schedule-management/InfusionScheduleHandoverDialog.vue → utils/download-attachment.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
-- `fetchLeaveList()` --calls--> `get()`  [INFERRED]
-  api/leave.ts → views/production/process/ProcessRouteTab.vue
-- `fetchLeaveDetail()` --calls--> `get()`  [INFERRED]
-  api/leave.ts → views/production/process/ProcessRouteTab.vue
+- `fetchConsumableList()` --calls--> `get()`  [INFERRED]
+  api/production.ts → views/production/process/ProcessRouteTab.vue
+- `fetchReagentList()` --calls--> `get()`  [INFERRED]
+  api/production.ts → views/production/process/ProcessRouteTab.vue
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (103): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList() (+95 more)
+Nodes (111): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchCellSampleTypeList() (+103 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.03
-Nodes (91): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchCheckupInstitutionProjects(), fetchPilotCheckupActualItems(), fetchPilotCheckupDetail() (+83 more)
+Nodes (93): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchCheckupInstitutionProjects(), fetchPilotCheckupDetail(), markPilotCheckupChecked() (+85 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.03
-Nodes (40): fetchGetMenuList(), fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), clickTab(), handleTouchEnd(), setTransition() (+32 more)
+Nodes (38): clickTab(), handleTouchEnd(), setTransition(), useSettingsHandlers(), useSettingsPanel(), useSettingsState(), IframeRouteManager, RoutePermissionValidator (+30 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.03
-Nodes (36): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents(), fetchAllReagentStockLogs() (+28 more)
+Nodes (37): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents(), fetchAllReagentStockLogs() (+29 more)
 
 ### Community 4 - "Community 4"
 Cohesion: 0.03
-Nodes (35): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchHospitalList(), fetchUpdateCollectionRegion(), fetchUpdateHospital() (+27 more)
+Nodes (41): fetchAttachmentDownloadUrl(), fetchClinicalPaymentDetail(), fetchClinicalPayments(), fetchClinicalReceiptConfirmed(), fetchClinicalReceiptPending(), fetchClinicalRefundConfirmed(), fetchClinicalRefundPending(), fetchOrderAdjusts() (+33 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.04
-Nodes (28): updateInfusionScheduleStatus(), submitOutboundReleaseReview(), fetchStorageReleaseReviewDetail(), fetchStorageReleaseReviewList(), submitStorageReleaseReview(), disableDevTools(), handleLock(), handleUnlock() (+20 more)
+Cohesion: 0.03
+Nodes (35): fetchCollectionRegionList(), fetchCreateCollectionRegion(), fetchCreateHospital(), fetchDeleteCollectionRegion(), fetchDeleteHospital(), fetchHospitalList(), fetchUpdateCollectionRegion(), fetchUpdateHospital() (+27 more)
 
 ### Community 6 - "Community 6"
 Cohesion: 0.04
-Nodes (32): handleBlur(), handleFocus(), handleInput(), fetchClinicalPaymentDetail(), fetchClinicalPayments(), fetchClinicalReceiptConfirmed(), fetchClinicalReceiptPending(), fetchClinicalRefundConfirmed() (+24 more)
+Nodes (35): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), submitOutboundReleaseReview(), fetchStorageReleaseReviewDetail(), fetchStorageReleaseReviewList(), submitStorageReleaseReview() (+27 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.06
-Nodes (34): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+26 more)
+Cohesion: 0.04
+Nodes (35): handleBlur(), handleFocus(), handleInput(), fetchEthnicList(), formatAmount(), fmtMoney(), auditorDisplay(), dash() (+27 more)
 
 ### Community 8 - "Community 8"
 Cohesion: 0.06
-Nodes (29): for(), set(), walk(), annotateForkEdges(), chainApiToElements(), walkAncestorBranch(), walkAncestorParents(), if() (+21 more)
+Nodes (11): fetchGetLatestUpgradeLog(), getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager (+3 more)
 
 ### Community 9 - "Community 9"
 Cohesion: 0.06
@@ -132,24 +129,24 @@ Cohesion: 0.1
 Nodes (2): WebSocketClient, TableCache
 
 ### Community 11 - "Community 11"
-Cohesion: 0.07
-Nodes (21): auditorDisplay(), dash(), expressName(), formatYm(), formatYmd(), sampleTypeName(), siteName(), unitName() (+13 more)
+Cohesion: 0.08
+Nodes (33): fetchRefreshToken(), toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url() (+25 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.06
-Nodes (24): fetchAttachmentDownloadUrl(), handleDownloadAttachment(), fullFileUrl(), handleDownloadAttachment(), handlePreviewAttachment(), isVideoFile(), handleReceptionAttachmentDownload(), handleDownloadAttachment() (+16 more)
+Cohesion: 0.07
+Nodes (20): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), collectIds(), postorder(), preorder(), applyTreeExpand() (+12 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.09
-Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
+Cohesion: 0.07
+Nodes (26): fetchCreateSite(), fetchDeleteSite(), fetchSiteList(), fetchUpdateSite(), fetchUserList(), formatHandoverUserLabel(), formatNowDatetime(), fullFileUrl() (+18 more)
 
 ### Community 14 - "Community 14"
 Cohesion: 0.07
-Nodes (25): fetchCreateSite(), fetchDeleteSite(), fetchSiteList(), fetchUpdateSite(), fetchUserList(), formatHandoverUserLabel(), formatNowDatetime(), fullFileUrl() (+17 more)
+Nodes (22): for(), set(), walk(), annotateForkEdges(), chainApiToElements(), walkAncestorBranch(), walkAncestorParents(), if() (+14 more)
 
 ### Community 15 - "Community 15"
-Cohesion: 0.08
-Nodes (20): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), collectIds(), postorder(), preorder(), applyTreeExpand() (+12 more)
+Cohesion: 0.13
+Nodes (26): applyFqParamsChange(), countByStatus(), evaluateFqParamsChange(), formatMoneySummary(), generateInitialFqDetails(), getPaidInstallmentRows(), maxPaidInstallmentNo(), activeDetails() (+18 more)
 
 ### Community 16 - "Community 16"
 Cohesion: 0.08
@@ -157,56 +154,56 @@ Nodes (11): maskIdCard(), buildOutboundPilotContextSections(), applicationStatus
 
 ### Community 17 - "Community 17"
 Cohesion: 0.12
-Nodes (22): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), handleBatchComplete(), handleBatchStart(), handleBatchVoid() (+14 more)
+Nodes (19): fetchGetUserInfo(), fetchLogin(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchUserInfo(), buildWebEncryptedLoginBody(), defaultAvatarPathByRole(), hasCustomUserAvatar() (+11 more)
 
 ### Community 18 - "Community 18"
 Cohesion: 0.12
-Nodes (20): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd(), handleBatchDelete() (+12 more)
+Nodes (22): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), handleBatchComplete(), handleBatchStart(), handleBatchVoid() (+14 more)
 
 ### Community 19 - "Community 19"
 Cohesion: 0.12
-Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
+Nodes (20): fetchCreateUser(), fetchDeleteUser(), fetchResetPassword(), fetchUpdateUser(), fetchUserDetail(), formatUserDeptDisplay(), handleAdd(), handleBatchDelete() (+12 more)
 
 ### Community 20 - "Community 20"
+Cohesion: 0.12
+Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
+
+### Community 21 - "Community 21"
+Cohesion: 0.13
+Nodes (5): fetchGetMenuList(), fetchGetMenuCodes(), MenuProcessor, useAppMode(), useAuth()
+
+### Community 22 - "Community 22"
 Cohesion: 0.1
 Nodes (6): fetchProductionTaskDetail(), fetchProductionTaskList(), fetchUpdateProductionTask(), handleClose(), handleSubmit(), loadTaskDetail()
 
-### Community 21 - "Community 21"
+### Community 23 - "Community 23"
 Cohesion: 0.15
 Nodes (16): fetchPilotCheckupList(), canEditActualCheckupInfo(), canEditReserve(), canFillCheckupInfo(), canMarkChecked(), handleDropdownCommand(), handleEditActual(), handleEditReserve() (+8 more)
 
-### Community 22 - "Community 22"
+### Community 24 - "Community 24"
 Cohesion: 0.12
 Nodes (12): fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances(), fetchPendingTasks(), fetchProcessDefList() (+4 more)
 
-### Community 23 - "Community 23"
+### Community 25 - "Community 25"
 Cohesion: 0.13
 Nodes (8): fetchOutboundReleaseMaterial(), fetchOutboundReleaseReviewDetail(), fetchOutboundReleaseReviewList(), fetchReleaseReviewList(), handleReset(), handleSearch(), loadData(), onReviewSubmitted()
 
-### Community 24 - "Community 24"
-Cohesion: 0.16
-Nodes (14): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), handleAdd(), handleBatchDelete(), handleDelete(), handleDropdownCommand() (+6 more)
-
-### Community 25 - "Community 25"
+### Community 26 - "Community 26"
 Cohesion: 0.16
 Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
 
-### Community 26 - "Community 26"
+### Community 27 - "Community 27"
 Cohesion: 0.2
 Nodes (2): ComponentLoader, RouteTransformer
 
-### Community 27 - "Community 27"
+### Community 28 - "Community 28"
 Cohesion: 0.27
 Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
 
-### Community 28 - "Community 28"
+### Community 29 - "Community 29"
 Cohesion: 0.18
 Nodes (9): addSample(), applyDonationBundleToForm(), confirmBatchTransportApply(), createEmptyTransport(), ensureEthnicOptionInList(), handleOpenForAdd(), loadEthnicOptions(), normalizeSampleRow() (+1 more)
 
-### Community 29 - "Community 29"
-Cohesion: 0.14
-Nodes (8): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), useCeremony(), useTheme(), themeAnimation(), toggleTheme(), toggleTransition()
-
 ### Community 30 - "Community 30"
 Cohesion: 0.18
 Nodes (5): fetchServiceOrderTxnList(), handleReset(), handleSearch(), handleTabChange(), loadData()
@@ -223,124 +220,106 @@ Nodes (3): fetchMaterialList(), fetchMaterialOptions(), fetchMaterialStockLogLis
 Cohesion: 0.2
 Nodes (2): fetchQcMaterialList(), fetchQcMaterialOptions()
 
-### Community 36 - "Community 36"
+### Community 35 - "Community 35"
+Cohesion: 0.28
+Nodes (1): FireworkSystem
+
+### Community 37 - "Community 37"
 Cohesion: 0.39
 Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
 
-### Community 37 - "Community 37"
+### Community 38 - "Community 38"
 Cohesion: 0.36
 Nodes (1): TableHeightCalculator
 
-### Community 38 - "Community 38"
+### Community 39 - "Community 39"
 Cohesion: 0.25
 Nodes (2): fetchQcTaskDetail(), fetchQcTaskList()
 
-### Community 39 - "Community 39"
+### Community 40 - "Community 40"
 Cohesion: 0.25
 Nodes (3): fetchOutboundQcDetail(), fetchQcWorkOrderDetail(), fetchQcWorkOrderList()
 
-### Community 40 - "Community 40"
+### Community 41 - "Community 41"
 Cohesion: 0.33
 Nodes (2): logApiCryptoBootInfo(), maskSecret()
 
-### Community 41 - "Community 41"
-Cohesion: 0.29
-Nodes (2): fetchLeaveDetail(), fetchLeaveList()
-
 ### Community 42 - "Community 42"
 Cohesion: 0.47
 Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
 
 ### Community 44 - "Community 44"
 Cohesion: 0.33
-Nodes (2): fetchSampleReceptionCheckItemList(), fetchSampleReceptionCheckItemsActive()
-
-### Community 45 - "Community 45"
-Cohesion: 0.33
-Nodes (2): fetchQcThirdPartyItemList(), fetchQcThirdPartyItemsActive()
-
-### Community 46 - "Community 46"
-Cohesion: 0.33
 Nodes (2): fetchQcThirdPartyCompanyList(), fetchQcThirdPartyCompanyOptions()
 
-### Community 48 - "Community 48"
+### Community 46 - "Community 46"
 Cohesion: 0.5
 Nodes (2): focusInput(), handleKeydown()
 
-### Community 49 - "Community 49"
+### Community 48 - "Community 48"
 Cohesion: 0.67
 Nodes (2): if(), logSidebarMenu()
 
-### Community 51 - "Community 51"
+### Community 50 - "Community 50"
 Cohesion: 0.5
 Nodes (1): fetchStorageEntrySmsList()
 
-### Community 52 - "Community 52"
+### Community 51 - "Community 51"
 Cohesion: 0.5
 Nodes (1): fetchSampleSmsNotificationList()
 
-### Community 53 - "Community 53"
+### Community 52 - "Community 52"
 Cohesion: 0.5
 Nodes (1): fetchQcSmsList()
 
-### Community 55 - "Community 55"
+### Community 54 - "Community 54"
 Cohesion: 1.0
 Nodes (2): getSettingDefaults(), resetToDefaults()
 
-### Community 57 - "Community 57"
-Cohesion: 1.0
-Nodes (2): defaultAvatarPathByRole(), resolveUserAvatarUrl()
-
 ## Knowledge Gaps
 - **Thin community `Community 10`** (42 nodes): `WebSocketClient`, `.calculateReconnectDelay()`, `.clearAllTimers()`, `.clearConnectionTimers()`, `.clearTimer()`, `.close()`, `.closeCurrentSocketForReconnect()`, `.connect()`, `.connectionStatusText()`, `.constructor()`, `.destroyInstance()`, `.flushMessageQueue()`, `.getInstance()`, `.getReadyStateText()`, `.handleClose()`, `.handleConnectionTimeout()`, `.handleError()`, `.handleMessage()`, `.handleOpen()`, `.init()`, `.isWebSocketConnected()`, `.reconnect()`, `.resetHeartbeat()`, `.send()`, `.startHeartbeat()`, `.startPing()`, `TableCache`, `.cleanupExpired()`, `.clear()`, `.clearByTags()`, `.clearCurrentSearch()`, `.clearPagination()`, `.constructor()`, `.evictLRU()`, `.generateKey()`, `.generateTags()`, `.get()`, `.getStats()`, `.log()`, `.set()`, `index.ts`, `tableCache.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 26`** (17 nodes): `ComponentLoader`, `.constructor()`, `.createEmptyComponent()`, `.createErrorComponent()`, `.load()`, `.loadIframe()`, `.loadLayout()`, `.load()`, `RouteTransformer`, `.extractFirstSegment()`, `.handleFirstLevelRoute()`, `.handleIframeRoute()`, `.handleNormalRoute()`, `.isFirstLevelRoute()`, `.transform()`, `ComponentLoader.ts`, `RouteTransformer.ts`
+- **Thin community `Community 27`** (17 nodes): `ComponentLoader`, `.constructor()`, `.createEmptyComponent()`, `.createErrorComponent()`, `.load()`, `.loadIframe()`, `.loadLayout()`, `.load()`, `RouteTransformer`, `.extractFirstSegment()`, `.handleFirstLevelRoute()`, `.handleIframeRoute()`, `.handleNormalRoute()`, `.isFirstLevelRoute()`, `.transform()`, `ComponentLoader.ts`, `RouteTransformer.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 - **Thin community `Community 34`** (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 37`** (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 38`** (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 40`** (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 41`** (7 nodes): `fetchCreateLeave()`, `fetchDeleteLeave()`, `fetchLeaveDetail()`, `fetchLeaveList()`, `fetchSubmitLeave()`, `fetchUpdateLeave()`, `leave.ts`
+- **Thin community `Community 35`** (9 nodes): `FireworkSystem`, `.constructor()`, `.createFirework()`, `.createParticle()`, `.getAvailableParticle()`, `.initializePool()`, `.preloadAllImages()`, `.preloadImage()`, `index.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 44`** (6 nodes): `fetchCreateSampleReceptionCheckItem()`, `fetchDeleteSampleReceptionCheckItem()`, `fetchSampleReceptionCheckItemList()`, `fetchSampleReceptionCheckItemsActive()`, `fetchUpdateSampleReceptionCheckItem()`, `sample-reception-check-item.ts`
+- **Thin community `Community 38`** (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 45`** (6 nodes): `fetchCreateQcThirdPartyItem()`, `fetchDeleteQcThirdPartyItem()`, `fetchQcThirdPartyItemList()`, `fetchQcThirdPartyItemsActive()`, `fetchUpdateQcThirdPartyItem()`, `qc-third-party-item.ts`
+- **Thin community `Community 39`** (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 46`** (6 nodes): `fetchCreateQcThirdPartyCompany()`, `fetchDeleteQcThirdPartyCompany()`, `fetchQcThirdPartyCompanyList()`, `fetchQcThirdPartyCompanyOptions()`, `fetchUpdateQcThirdPartyCompany()`, `qc-third-party-company.ts`
+- **Thin community `Community 41`** (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 48`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
+- **Thin community `Community 44`** (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 49`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
+- **Thin community `Community 46`** (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 51`** (4 nodes): `fetchGenerateStorageEntrySms()`, `fetchStorageEntrySmsList()`, `fetchToggleStorageEntrySmsNotified()`, `storage-entry-sms-notification.ts`
+- **Thin community `Community 48`** (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 52`** (4 nodes): `fetchGenerateSms()`, `fetchSampleSmsNotificationList()`, `fetchToggleSmsNotified()`, `sample-sms-notification.ts`
+- **Thin community `Community 50`** (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 53`** (4 nodes): `fetchGenerateQcSms()`, `fetchQcSmsList()`, `fetchToggleQcSmsNotified()`, `qc-sms-notification.ts`
+- **Thin community `Community 51`** (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 55`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
+- **Thin community `Community 52`** (4 nodes): `fetchGenerateQcSms()`, `fetchQcSmsList()`, `fetchToggleQcSmsNotified()`, `qc-sms-notification.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 57`** (3 nodes): `defaultAvatarPathByRole()`, `resolveUserAvatarUrl()`, `user-avatar.ts`
+- **Thin community `Community 54`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.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 8`, `Community 9`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 20`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 30`, `Community 32`, `Community 33`, `Community 34`, `Community 38`, `Community 39`, `Community 41`, `Community 44`, `Community 45`, `Community 46`, `Community 51`, `Community 52`, `Community 53`?**
-  _High betweenness centrality (0.280) - this node is a cross-community bridge._
-- **Why does `push()` connect `Community 1` to `Community 0`, `Community 3`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 12`, `Community 14`, `Community 16`, `Community 28`?**
-  _High betweenness centrality (0.131) - this node is a cross-community bridge._
+- **Why does `get()` connect `Community 0` to `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 30`, `Community 32`, `Community 33`, `Community 34`, `Community 39`, `Community 40`, `Community 44`, `Community 50`, `Community 51`, `Community 52`?**
+  _High betweenness centrality (0.276) - this node is a cross-community bridge._
+- **Why does `push()` connect `Community 1` to `Community 0`, `Community 35`, `Community 3`, `Community 4`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 15`, `Community 16`, `Community 29`?**
+  _High betweenness centrality (0.144) - this node is a cross-community bridge._
+- **Why does `handleDynamicRoutes()` connect `Community 2` to `Community 17`, `Community 21`?**
+  _High betweenness centrality (0.033) - this node is a cross-community bridge._
 - **Are the 205 inferred relationships involving `get()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `buildTreePathLabelFromFlat()`) actually correct?**
   _`get()` has 205 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 35 inferred relationships involving `push()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `fetchAllPagedRecords()`) actually correct?**
-  _`push()` has 35 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 38 inferred relationships involving `push()` (e.g. with `collectLeafFacilityNodesInTreeOrder()` and `fetchAllPagedRecords()`) actually correct?**
+  _`push()` has 38 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 15 inferred relationships involving `emit()` (e.g. with `handleSubmit()` and `handleBlur()`) actually correct?**
   _`emit()` has 15 INFERRED edges - model-reasoned connections that need verification._
 - **Should `Community 0` be split into smaller, more focused modules?**
-  _Cohesion score 0.01 - nodes in this community are weakly interconnected._
-- **Should `Community 1` be split into smaller, more focused modules?**
-  _Cohesion score 0.03 - nodes in this community are weakly interconnected._
+  _Cohesion score 0.01 - nodes in this community are weakly interconnected._

Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
web-admin/graphify-out/graph.html


Разлика између датотеке није приказан због своје велике величине
+ 257 - 104
web-admin/graphify-out/graph.json


+ 72 - 69
web-admin/graphify-out/manifest.json

@@ -1,6 +1,6 @@
 {
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/App.vue": 1779790323.6203642,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/main.ts": 1779258114.8468678,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/App.vue": 1780480414.9445062,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/main.ts": 1780480414.9551268,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/env.d.ts": 1778582067.998984,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/shims-loose.d.ts": 1778582068.0215242,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/directives/index.ts": 1778582067.9976711,
@@ -8,26 +8,26 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/directives/core/auth.ts": 1778582067.9971414,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/directives/business/ripple.ts": 1778582067.996821,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/directives/business/highlight.ts": 1778582067.9966211,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api.d.ts": 1779155643.5621417,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api.d.ts": 1780539154.013632,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/index.ts": 1778582068.02854,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/config/index.ts": 1778582068.0266159,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/component/chart.ts": 1778582068.026058,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/component/index.ts": 1778582068.0262403,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/response.ts": 1778582068.025649,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/response.ts": 1780480414.9570467,
   "/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": 1780477949.7778664,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1780477857.5482304,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1780546861.7453368,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1780546851.8236628,
   "/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/types/router/index.ts": 1780480414.9574106,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/locales/index.ts": 1778582068.004279,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/setting.ts": 1778582067.9954739,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts": 1779344841.362722,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/index.ts": 1780480414.9542737,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/component.ts": 1778582067.9943824,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/festival.ts": 1778582067.9949014,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/headerBar.ts": 1778582067.9952137,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/fastEnter.ts": 1779232181.1371717,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/modules/fastEnter.ts": 1780480414.9544525,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/assets/images.ts": 1778582067.993645,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/plugins/echarts.ts": 1778582068.010295,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/plugins/index.ts": 1778582068.010531,
@@ -40,9 +40,9 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/enums/appEnum.ts": 1778582067.9982593,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/patch-el-select-filterable.ts": 1779790341.162103,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/storage-slot-options.ts": 1779790341.466299,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/user-avatar.ts": 1779790341.943788,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/user-avatar.ts": 1780540742.1887667,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/fullscreen-loading.ts": 1779117454.818312,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/router.ts": 1779938225.7809103,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/router.ts": 1780480414.9585993,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-labels.ts": 1779199125.4894068,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/status-tag-registry.ts": 1779826490.6921933,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/fetch-paged-list.ts": 1778775532.895782,
@@ -55,8 +55,10 @@
   "/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": 1779790340.9231393,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/qc-labels.ts": 1779199108.173778,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-fq-params-change.ts": 1780547381.0547066,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/loginRsa.ts": 1778582068.0373487,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-installment.ts": 1780310646.0954366,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-pay-mode-switch.ts": 1780546639.7181845,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-installment.ts": 1780545638.298603,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/download-attachment.ts": 1779159591.1376455,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/colors.ts": 1778582068.046773,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/emojo.ts": 1778582068.0469406,
@@ -71,43 +73,43 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/form/validator.ts": 1778582068.0335238,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/constants/links.ts": 1778582068.031627,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/constants/index.ts": 1778582068.0312057,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/worktab.ts": 1779240471.2587266,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/jump.ts": 1778775532.8961763,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/route.ts": 1779790341.1550379,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/worktab.ts": 1780480414.958444,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/jump.ts": 1780480414.9581196,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/route.ts": 1780480414.9582925,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/navigation/index.ts": 1778582068.0376182,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/storage/storage.ts": 1778582068.0412395,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/storage/storage-config.ts": 1778582068.040829,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/storage/index.ts": 1778582068.040637,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/storage/storage-key-manager.ts": 1778582068.0410247,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sys/mittBus.ts": 1778582068.0423212,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sys/upgrade.ts": 1779790341.6936553,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sys/upgrade.ts": 1780480414.9587884,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sys/error-handle.ts": 1778582068.0419197,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sys/index.ts": 1778582068.0421178,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/sys/console.ts": 1778582068.0417144,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/table/tableConfig.ts": 1778582068.0443969,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/table/tableCache.ts": 1778582068.044193,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/table/tableUtils.ts": 1778582068.0454955,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/status.ts": 1778582068.036632,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/status.ts": 1780480414.957961,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/crypto.ts": 1778582068.0353796,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/index.ts": 1779463885.9788811,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/error.ts": 1778582068.0356607,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/index.ts": 1780480414.9578006,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/http/error.ts": 1780480414.9575992,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/socket/index.ts": 1778582068.0397933,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/router/sidebar-menu-filter.ts": 1778582068.0391986,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/tables/art-table/index.vue": 1779525781.573567,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/tables/art-table-header/index.vue": 1779790330.385015,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-search-bar/index.vue": 1779525781.574576,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-form/index.vue": 1779525781.575067,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-button-more/index.vue": 1779525781.5755153,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-excel-import/index.vue": 1779525781.575924,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-excel-export/index.vue": 1779525781.576321,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-drag-verify/index.vue": 1779525781.576787,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-wang-editor/index.vue": 1779790328.3615272,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-button-table/index.vue": 1779525781.5775588,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/tables/art-table-header/index.vue": 1780480414.9533372,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-search-bar/index.vue": 1780480414.9481184,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-form/index.vue": 1780480414.9478915,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-button-more/index.vue": 1780480414.9465926,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-excel-import/index.vue": 1780480414.9476178,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-excel-export/index.vue": 1780480414.9474266,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-drag-verify/index.vue": 1780480414.9471714,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-wang-editor/index.vue": 1780480414.9483848,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/forms/art-button-table/index.vue": 1780480414.9469068,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/widget/art-icon-button/index.vue": 1779525781.578175,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-bar-chart-card/index.vue": 1779525781.5786,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-data-list-card/index.vue": 1779525781.5789726,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-timeline-list-card/index.vue": 1779525781.5793364,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-stats-card/index.vue": 1779525781.5797477,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-stats-card/index.vue": 1780480414.9464056,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-image-card/index.vue": 1779525781.5800624,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-donut-chart-card/index.vue": 1779525781.5803144,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/cards/art-progress-card/index.vue": 1779525781.5805767,
@@ -121,7 +123,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/charts/art-radar-chart/index.vue": 1779525781.5834749,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/charts/art-h-bar-chart/index.vue": 1779525781.5837238,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/others/art-watermark/index.vue": 1779525781.5842316,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/others/art-menu-right/index.vue": 1779790330.3317213,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/others/art-menu-right/index.vue": 1780480414.9530756,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/banners/art-card-banner/index.vue": 1779525781.5850756,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/banners/art-basic-banner/index.vue": 1779525781.5857205,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-global-component/index.vue": 1779525781.5863822,
@@ -131,45 +133,46 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts": 1778582067.9773157,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts": 1778582067.9766955,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue": 1779525781.5871665,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue": 1779525781.587754,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue": 1779525781.5882142,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue": 1780480414.9523888,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue": 1780480414.9525797,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue": 1779525781.5885482,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue": 1779525781.588895,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue": 1779525781.5892572,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue": 1779525781.5898669,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue": 1779525781.5901814,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue": 1780480414.952198,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue": 1779525781.5907402,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue": 1779525781.5913193,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue": 1779525781.59174,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue": 1779525781.5920076,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-work-tab/index.vue": 1779525781.592554,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-chat-window/index.vue": 1779525781.59301,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-page-content/index.vue": 1779942912.5296464,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-work-tab/index.vue": 1780480414.9528105,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-chat-window/index.vue": 1780480414.949035,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-page-content/index.vue": 1780480414.9517472,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue": 1779525781.5938804,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue": 1779525781.5942907,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue": 1779525781.5947194,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue": 1779790328.9606564,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue": 1779525781.59551,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue": 1780480414.950054,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue": 1780480414.9512155,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-fireworks-effect/index.vue": 1779525781.5963395,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-global-search/index.vue": 1779525781.5967863,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-notification/index.vue": 1779525781.597402,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-global-search/index.vue": 1780480414.949282,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-notification/index.vue": 1780480414.9515367,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-fast-enter/index.vue": 1779525781.5979466,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-screen-lock/index.vue": 1779525781.598362,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-screen-lock/index.vue": 1780480414.9519973,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-breadcrumb/index.vue": 1779525781.5987902,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-header-bar/index.vue": 1779525781.5991871,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue": 1779790328.7361135,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-header-bar/index.vue": 1780480414.949582,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue": 1780540514.104913,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/layouts/art-header-bar/widget/UserProfileDialog.vue": 1780540511.8256087,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/theme/theme-svg/index.vue": 1779525781.6001027,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/result/ArtResultPage.vue": 1779525781.6004124,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/exception/ExceptionIllustration.vue": 1779790330.5225084,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/exception/ArtException.vue": 1779790330.512786,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/login/AuthTopBar.vue": 1779790330.5399241,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/exception/ArtException.vue": 1780480414.9538875,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/login/AuthTopBar.vue": 1780480414.9540887,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/views/login/LoginLeftView.vue": 1779525781.6012564,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/text-effect/art-text-scroll/index.vue": 1779525781.6015408,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/text-effect/art-text-scroll/index.vue": 1780480414.953665,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/text-effect/art-count-to/index.vue": 1779525781.6018777,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/text-effect/art-festival-text-scroll/index.vue": 1779525781.6024969,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/base/art-svg-icon/index.vue": 1779525781.603243,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/base/art-back-to-top/index.vue": 1779525781.6038082,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/base/art-logo/index.vue": 1779790326.538098,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/base/art-svg-icon/index.vue": 1780480414.9462214,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/base/art-back-to-top/index.vue": 1780480414.9458482,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/base/art-logo/index.vue": 1780480414.9460301,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/media/art-video-player/index.vue": 1779525781.6045322,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/media/art-cutter-img/index.vue": 1779525781.6051006,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue": 1779790325.7480125,
@@ -236,15 +239,15 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/system.ts": 1779790323.5636237,
   "/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": 1780462889.9246686,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/auth.ts": 1778582067.8610063,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/auth.ts": 1780539439.6854532,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-work-order.ts": 1780478257.9926786,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/auth/login/index.vue": 1779938231.136978,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/auth/login/index.vue": 1780480414.9591243,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/refund-confirm/index.vue": 1779866933.9349332,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/index.vue": 1779859546.0906804,
   "/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": 1780462937.905501,
   "/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": 1780458686.0600615,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue": 1780547422.9811244,
   "/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-info/index.vue": 1780457198.009363,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/sample-reception-info/index.vue": 1780325524.917126,
@@ -279,17 +282,17 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationViewDialog.vue": 1780478943.7240853,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationFormDialog.vue": 1780458686.0616076,
   "/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": 1779857063.389745,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/dashboard/console/index.vue": 1780480414.9592953,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/tenant/index.vue": 1779858296.9440098,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/order/index.vue": 1779856873.200306,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/unit/index.vue": 1779858300.774436,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/role/index.vue": 1779858382.6288393,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/role/index.vue": 1780480414.9603326,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/post/index.vue": 1779858367.1503367,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/category/index.vue": 1779859746.1898513,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/express/index.vue": 1779858275.4881697,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/index.vue": 1779856885.5607417,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/index.vue": 1780480414.960575,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/detail.vue": 1779790371.4357243,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/modules/user-dialog.vue": 1779790371.5651114,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/user/modules/user-dialog.vue": 1780480414.960775,
   "/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": 1779859799.4894958,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/workflow/my-applications/index.vue": 1779856891.19533,
@@ -299,7 +302,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/workflow/components/ApprovalDialog.vue": 1779790371.6886575,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/workflow/designer/FlowDesigner.vue": 1779790371.8114932,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/workflow/process/index.vue": 1779859839.5932422,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/menu/index.vue": 1779858346.0258229,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/menu/index.vue": 1780480414.960126,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/site/index.vue": 1779858286.7791865,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/dept/index.vue": 1779858319.5899463,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/dict/index.vue": 1779857764.622668,
@@ -352,9 +355,9 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseViewDialog.vue": 1780478944.9533937,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-usage/index.vue": 1779864358.234948,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/third-party-company/index.vue": 1779864494.1133437,
-  "/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/exception/500/index.vue": 1780480414.959776,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/403/index.vue": 1780480414.95946,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/exception/404/index.vue": 1780480414.9596128,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-type/index.vue": 1779871660.7926793,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-type/CellProductPriceTab.vue": 1779871667.0159404,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/pilot-service/cell-product-type/ProjectPriceSystemTab.vue": 1779864896.060257,
@@ -410,27 +413,27 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/basic-info/collection-region/CollectionProtocolNoTab.vue": 1779865978.3539536,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/assets/svg/loading.ts": 1778582067.932299,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/index.ts": 1778582068.0218356,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/worktab.ts": 1779790340.7550395,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/worktab.ts": 1780480414.9568844,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/table.ts": 1779257771.098434,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/menu.ts": 1779790340.600478,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/menu.ts": 1780480414.9564605,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/setting.ts": 1778582068.0225098,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/user.ts": 1779938228.2554607,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/store/modules/user.ts": 1780540187.1384017,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routesAlias.ts": 1778582068.0211735,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/index.ts": 1779232037.727516,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/index.ts": 1780480414.9557922,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RouteTransformer.ts": 1778582068.0132053,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RouteValidator.ts": 1778582068.013567,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/ComponentLoader.ts": 1778582068.0119128,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/MenuProcessor.ts": 1779882681.9204168,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/MenuProcessor.ts": 1780480414.955383,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RouteRegistry.ts": 1778582068.0129957,
   "/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": 1780457411.241232,
-  "/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,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/index.ts": 1780480414.9561174,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/exception.ts": 1780480414.9559598,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routes/staticRoutes.ts": 1780480414.9562852,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routes/asyncRoutes.ts": 1778582068.0206802,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/guards/beforeEach.ts": 1779938234.2979362,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/guards/beforeEach.ts": 1780480414.9556284,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/guards/afterEach.ts": 1778582068.0146143,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/assets/svg/my-copy.svg": 1778582067.9349968,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/assets/svg/search.svg": 1778582067.9393654,

+ 47 - 0
web-admin/src/api/auth.ts

@@ -38,6 +38,9 @@ export function fetchGetUserInfo() {
     username: string
     realName: string
     phone: string
+    email?: string
+    sex?: string
+    remark?: string
     role: string
     regionId: string
     regionName: string
@@ -50,6 +53,50 @@ export function fetchGetUserInfo() {
   })
 }
 
+/** 当前用户更新个人信息 */
+export function fetchUpdateProfile(data: {
+  phoneNo?: string
+  email?: string
+  sex?: string
+  remark?: string
+  avatarUrl?: string
+}) {
+  return request.put<{
+    id: string
+    username: string
+    realName: string
+    phone: string
+    email?: string
+    sex?: string
+    remark?: string
+    role: string
+    regionId: string
+    regionName: string
+    avatarUrl?: string | null
+  }>({
+    url: '/web/auth/profile',
+    data,
+    showSuccessMessage: true
+  })
+}
+
+/** 当前用户上传头像 */
+export function fetchUploadProfileAvatar(data: FormData) {
+  return request.post<{ url: string }>({
+    url: '/web/auth/upload-avatar',
+    data
+  })
+}
+
+/** 当前用户修改密码 */
+export function fetchChangePassword(data: { oldPassword: string; newPassword: string }) {
+  return request.post({
+    url: '/web/auth/change-password',
+    data,
+    showSuccessMessage: true
+  })
+}
+
 /**
  * 获取当前用户菜单 code 权限
  */

+ 54 - 9
web-admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue

@@ -1,6 +1,7 @@
 <!-- 用户菜单 -->
 <template>
-  <ElPopover
+  <div class="user-menu-root">
+    <ElPopover
     ref="userMenuPopover"
     placement="bottom-end"
     :width="240"
@@ -13,16 +14,21 @@
   >
     <template #reference>
       <div class="user-avatar-wrapper">
-        <el-avatar :size="34" :src="avatarDisplayUrl">
-          {{ userInfo.realName?.charAt(0) || userInfo.username?.charAt(0) || '管' }}
+        <el-avatar :size="34" :src="avatarDisplayUrl || undefined" :key="avatarDisplayUrl">
+          <span v-if="showAvatarInitial">{{ avatarInitialText }}</span>
         </el-avatar>
       </div>
     </template>
     <template #default>
       <div class="pt-3">
         <div class="flex-c pb-1 px-0">
-          <el-avatar :size="40" :src="avatarDisplayUrl" style="margin-right: 12px">
-            {{ userInfo.realName?.charAt(0) || userInfo.username?.charAt(0) || '管' }}
+          <el-avatar
+            :size="40"
+            :src="avatarDisplayUrl || undefined"
+            :key="avatarDisplayUrl"
+            style="margin-right: 12px"
+          >
+            <span v-if="showAvatarInitial">{{ avatarInitialText }}</span>
           </el-avatar>
           <div class="w-[calc(100%-60px)] h-full">
             <span class="block text-sm font-medium text-g-800 truncate">
@@ -43,6 +49,15 @@
             <span>{{ userInfo.regionName }}</span>
           </li>
           <div class="w-full h-px my-2 bg-g-300/80"></div>
+          <li class="btn-item" @click="openProfileDialog">
+            <ArtSvgIcon icon="edit" />
+            <span>个人信息</span>
+          </li>
+          <li class="btn-item" @click="openPasswordDialog">
+            <ArtSvgIcon icon="ri:lock-password-line" />
+            <span>修改密码</span>
+          </li>
+          <div class="w-full h-px my-2 bg-g-300/80"></div>
           <div class="log-out c-p" @click="loginOut">
             {{ $t('topBar.user.logout') }}
           </div>
@@ -50,13 +65,23 @@
       </div>
     </template>
   </ElPopover>
+
+  <UserProfileDialog v-model:visible="profileDialogVisible" />
+  <UserPasswordDialog v-model:visible="passwordDialogVisible" />
+  </div>
 </template>
 
 <script setup lang="ts">
-  import { computed } from 'vue'
+  import { computed, ref } from 'vue'
   import { ElMessageBox } from 'element-plus'
   import { useUserStore } from '@/store/modules/user'
-  import { resolveUserAvatarUrl } from '@/utils/user-avatar'
+  import {
+    resolveUserAvatarFromInfo,
+    pickUserAvatarRaw,
+    hasCustomUserAvatar
+  } from '@/utils/user-avatar'
+  import UserProfileDialog from './UserProfileDialog.vue'
+  import UserPasswordDialog from './UserPasswordDialog.vue'
 
   defineOptions({ name: 'ArtUserMenu' })
 
@@ -64,11 +89,31 @@
 
   const { getUserInfo: userInfo } = storeToRefs(userStore)
 
-  const avatarDisplayUrl = computed(() =>
-    resolveUserAvatarUrl(userInfo.value.avatarUrl, userInfo.value.role)
+  const avatarDisplayUrl = computed(() => resolveUserAvatarFromInfo(userInfo.value))
+
+  const showAvatarInitial = computed(() => !hasCustomUserAvatar(pickUserAvatarRaw(userInfo.value)))
+
+  const avatarInitialText = computed(
+    () => userInfo.value.realName?.charAt(0) || userInfo.value.username?.charAt(0) || '管'
   )
 
   const userMenuPopover = ref()
+  const profileDialogVisible = ref(false)
+  const passwordDialogVisible = ref(false)
+
+  function openProfileDialog() {
+    closeUserMenu()
+    setTimeout(() => {
+      profileDialogVisible.value = true
+    }, 150)
+  }
+
+  function openPasswordDialog() {
+    closeUserMenu()
+    setTimeout(() => {
+      passwordDialogVisible.value = true
+    }, 150)
+  }
 
   /**
    * 获取角色名称

+ 149 - 0
web-admin/src/components/core/layouts/art-header-bar/widget/UserPasswordDialog.vue

@@ -0,0 +1,149 @@
+<!-- 当前用户修改密码 -->
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    title="修改密码"
+    width="480px"
+    class="dialog-form-layout"
+    align-center
+    destroy-on-close
+    :close-on-click-modal="false"
+    @close="handleClose"
+  >
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-position="top"
+      scroll-to-error
+      :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+    >
+      <div class="form-grid-2">
+        <el-form-item label="原密码" prop="oldPassword" class="form-col-span-2">
+          <el-input
+            v-model="formData.oldPassword"
+            type="password"
+            show-password
+            placeholder="请输入原密码"
+            autocomplete="current-password"
+          />
+        </el-form-item>
+        <el-form-item label="新密码" prop="newPassword" class="form-col-span-2">
+          <el-input
+            v-model="formData.newPassword"
+            type="password"
+            show-password
+            placeholder="至少 6 位"
+            autocomplete="new-password"
+          />
+        </el-form-item>
+        <el-form-item label="确认新密码" prop="confirmPassword" class="form-col-span-2">
+          <el-input
+            v-model="formData.confirmPassword"
+            type="password"
+            show-password
+            placeholder="请再次输入新密码"
+            autocomplete="new-password"
+          />
+        </el-form-item>
+      </div>
+    </el-form>
+    <template #footer>
+      <el-button class="btn-ghost" @click="handleClose">
+        <el-icon><Close /></el-icon>
+        取消
+      </el-button>
+      <el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
+        <el-icon><Check /></el-icon>
+        确定
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, computed, watch } from 'vue'
+  import type { FormInstance, FormRules } from 'element-plus'
+  import { Close, Check } from '@element-plus/icons-vue'
+  import { fetchChangePassword } from '@/api/auth'
+
+  const props = defineProps<{
+    visible: boolean
+  }>()
+
+  const emit = defineEmits<{
+    (e: 'update:visible', val: boolean): void
+  }>()
+
+  const formRef = ref<FormInstance>()
+  const submitting = ref(false)
+
+  const formData = reactive({
+    oldPassword: '',
+    newPassword: '',
+    confirmPassword: ''
+  })
+
+  const formRules: FormRules = {
+    oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
+    newPassword: [
+      { required: true, message: '请输入新密码', trigger: 'blur' },
+      { min: 6, message: '新密码长度不能少于 6 位', trigger: 'blur' }
+    ],
+    confirmPassword: [
+      { required: true, message: '请再次输入新密码', trigger: 'blur' },
+      {
+        validator: (_rule, value, callback) => {
+          if (value !== formData.newPassword) {
+            callback(new Error('两次输入的新密码不一致'))
+            return
+          }
+          callback()
+        },
+        trigger: 'blur'
+      }
+    ]
+  }
+
+  const dialogVisible = computed({
+    get: () => props.visible,
+    set: (val) => emit('update:visible', val)
+  })
+
+  function resetForm() {
+    formData.oldPassword = ''
+    formData.newPassword = ''
+    formData.confirmPassword = ''
+    formRef.value?.clearValidate?.()
+  }
+
+  function handleClose() {
+    dialogVisible.value = false
+  }
+
+  async function handleSubmit() {
+    if (!formRef.value) return
+    try {
+      await formRef.value.validate()
+      submitting.value = true
+      await fetchChangePassword({
+        oldPassword: formData.oldPassword,
+        newPassword: formData.newPassword
+      })
+      dialogVisible.value = false
+    } catch {
+      /* 校验或接口错误已由全局处理 */
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  watch(
+    () => props.visible,
+    (val) => {
+      if (val) {
+        resetForm()
+      }
+    }
+  )
+</script>

+ 326 - 0
web-admin/src/components/core/layouts/art-header-bar/widget/UserProfileDialog.vue

@@ -0,0 +1,326 @@
+<!-- 当前用户个人信息编辑 -->
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    title="个人信息"
+    width="560px"
+    class="dialog-form-layout"
+    align-center
+    destroy-on-close
+    :close-on-click-modal="false"
+    @close="handleClose"
+  >
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-position="top"
+      scroll-to-error
+      :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+    >
+      <div class="form-grid-2">
+        <el-form-item label="头像" class="form-col-span-2">
+          <div class="profile-avatar-row">
+            <el-avatar
+              :size="72"
+              :src="avatarPreviewUrl || undefined"
+              :key="avatarPreviewKey"
+            >
+              <span v-if="showAvatarInitial">{{ avatarInitial }}</span>
+            </el-avatar>
+            <div class="profile-avatar-actions">
+              <div class="profile-avatar-btns">
+                <el-upload
+                  ref="avatarUploadRef"
+                  accept="image/*"
+                  :show-file-list="false"
+                  :auto-upload="false"
+                  :disabled="avatarUploading"
+                  @change="handleAvatarChange"
+                >
+                  <el-button class="btn-ghost" :loading="avatarUploading">
+                    <el-icon><Upload /></el-icon>
+                    上传头像
+                  </el-button>
+                </el-upload>
+                <el-button
+                  v-if="formData.avatarUrl"
+                  class="btn-ghost"
+                  :disabled="avatarUploading"
+                  @click="handleClearAvatar"
+                >
+                  <el-icon><Delete /></el-icon>
+                  恢复默认
+                </el-button>
+              </div>
+              <p class="profile-avatar-tip">支持 JPG、PNG 等图片,大小不超过 2MB</p>
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item label="手机号" prop="phoneNo">
+          <el-input v-model="formData.phoneNo" placeholder="选填" maxlength="11" clearable />
+        </el-form-item>
+        <el-form-item label="邮箱" prop="email">
+          <el-input v-model="formData.email" placeholder="选填" maxlength="50" clearable />
+        </el-form-item>
+        <el-form-item label="性别" prop="sex">
+          <el-select v-model="formData.sex" placeholder="请选择" style="width: 100%">
+            <el-option label="男" value="0" />
+            <el-option label="女" value="1" />
+            <el-option label="未知" value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注" class="form-col-span-2">
+          <el-input
+            v-model="formData.remark"
+            type="textarea"
+            :rows="3"
+            placeholder="选填"
+            maxlength="512"
+            show-word-limit
+          />
+        </el-form-item>
+      </div>
+    </el-form>
+    <template #footer>
+      <el-button class="btn-ghost" @click="handleClose">
+        <el-icon><Close /></el-icon>
+        取消
+      </el-button>
+      <el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
+        <el-icon><Check /></el-icon>
+        保存
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
+  import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
+  import { Close, Check, Upload, Delete } from '@element-plus/icons-vue'
+  import { fetchGetUserInfo, fetchUpdateProfile, fetchUploadProfileAvatar } from '@/api/auth'
+  import { useUserStore } from '@/store/modules/user'
+  import {
+    pickUserAvatarRaw,
+    pickUploadAvatarUrl,
+    hasCustomUserAvatar,
+    resolveUserAvatarUrl
+  } from '@/utils/user-avatar'
+
+  const props = defineProps<{
+    visible: boolean
+  }>()
+
+  const emit = defineEmits<{
+    (e: 'update:visible', val: boolean): void
+  }>()
+
+  const userStore = useUserStore()
+  const formRef = ref<FormInstance>()
+  const avatarUploadRef = ref<UploadInstance>()
+  const submitting = ref(false)
+  const avatarUploading = ref(false)
+  const avatarLocalPreview = ref('')
+  const displayRole = ref('')
+
+  const formData = reactive({
+    phoneNo: '',
+    email: '',
+    sex: '0',
+    remark: '',
+    avatarUrl: ''
+  })
+
+  const formRules: FormRules = {
+    phoneNo: [
+      {
+        validator: (_rule, value, callback) => {
+          const v = (value || '').trim()
+          if (v && !/^1\d{10}$/.test(v)) {
+            callback(new Error('请输入正确的手机号'))
+            return
+          }
+          callback()
+        },
+        trigger: 'blur'
+      }
+    ],
+    email: [
+      {
+        validator: (_rule, value, callback) => {
+          const v = (value || '').trim()
+          if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
+            callback(new Error('请输入正确的邮箱'))
+            return
+          }
+          callback()
+        },
+        trigger: 'blur'
+      }
+    ]
+  }
+
+  const dialogVisible = computed({
+    get: () => props.visible,
+    set: (val) => emit('update:visible', val)
+  })
+
+  const avatarPreviewUrl = computed(() => {
+    if (avatarLocalPreview.value) return avatarLocalPreview.value
+    return resolveUserAvatarUrl(formData.avatarUrl, displayRole.value)
+  })
+
+  const avatarPreviewKey = computed(
+    () => avatarLocalPreview.value || formData.avatarUrl || 'default-avatar'
+  )
+
+  const showAvatarInitial = computed(
+    () => !avatarLocalPreview.value && !hasCustomUserAvatar(formData.avatarUrl)
+  )
+
+  const avatarInitial = computed(() => {
+    const info = userStore.getUserInfo
+    return info.realName?.charAt(0) || info.username?.charAt(0) || '管'
+  })
+
+  function revokeAvatarLocalPreview() {
+    if (avatarLocalPreview.value) {
+      URL.revokeObjectURL(avatarLocalPreview.value)
+      avatarLocalPreview.value = ''
+    }
+  }
+
+  function handleClose() {
+    dialogVisible.value = false
+  }
+
+  function handleClearAvatar() {
+    formData.avatarUrl = ''
+    revokeAvatarLocalPreview()
+    avatarUploadRef.value?.clearFiles()
+  }
+
+  async function loadProfile() {
+    const data = await fetchGetUserInfo()
+    formData.phoneNo = data.phone || ''
+    formData.email = data.email || ''
+    formData.sex = data.sex || '0'
+    formData.remark = data.remark || ''
+    formData.avatarUrl = pickUserAvatarRaw(data) || pickUserAvatarRaw(userStore.getUserInfo) || ''
+    displayRole.value = data.role || userStore.getUserInfo.role || ''
+    revokeAvatarLocalPreview()
+    avatarUploadRef.value?.clearFiles()
+  }
+
+  async function handleAvatarChange(uploadFile: UploadFile) {
+    const file = uploadFile.raw
+    if (!file) return
+    if (uploadFile.status && uploadFile.status !== 'ready') return
+
+    if (!file.type.startsWith('image/')) {
+      ElMessage.error('仅支持上传图片文件')
+      avatarUploadRef.value?.clearFiles()
+      return
+    }
+    if (file.size > 2 * 1024 * 1024) {
+      ElMessage.error('头像大小不能超过 2MB')
+      avatarUploadRef.value?.clearFiles()
+      return
+    }
+
+    avatarUploading.value = true
+    revokeAvatarLocalPreview()
+    avatarLocalPreview.value = URL.createObjectURL(file)
+    try {
+      const fd = new FormData()
+      fd.append('file', file)
+      const res = await fetchUploadProfileAvatar(fd)
+      const stored = pickUploadAvatarUrl(res)
+      if (!stored) {
+        ElMessage.error('上传成功但未返回图片地址')
+        handleClearAvatar()
+        return
+      }
+      formData.avatarUrl = stored
+      ElMessage.success('头像上传成功')
+    } catch {
+      handleClearAvatar()
+    } finally {
+      avatarUploading.value = false
+      avatarUploadRef.value?.clearFiles()
+    }
+  }
+
+  async function handleSubmit() {
+    if (!formRef.value) return
+    try {
+      await formRef.value.validate()
+      submitting.value = true
+      const updated = await fetchUpdateProfile({
+        phoneNo: formData.phoneNo.trim() || undefined,
+        email: formData.email.trim() || undefined,
+        sex: formData.sex,
+        remark: formData.remark.trim() || undefined,
+        avatarUrl: formData.avatarUrl || ''
+      })
+      userStore.setUserInfo({
+        phone: updated.phone,
+        email: updated.email,
+        sex: updated.sex,
+        remark: updated.remark,
+        avatarUrl: pickUserAvatarRaw(updated)
+      })
+      dialogVisible.value = false
+    } catch {
+      /* 校验或接口错误已由全局处理 */
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  watch(
+    () => props.visible,
+    (val) => {
+      if (val) {
+        loadProfile().catch(() => {})
+        formRef.value?.clearValidate?.()
+      } else {
+        revokeAvatarLocalPreview()
+      }
+    }
+  )
+
+  onBeforeUnmount(() => {
+    revokeAvatarLocalPreview()
+  })
+</script>
+
+<style scoped lang="scss">
+  .profile-avatar-row {
+    display: flex;
+    gap: 16px;
+    align-items: center;
+  }
+
+  .profile-avatar-actions {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  .profile-avatar-btns {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .profile-avatar-tip {
+    margin: 0;
+    font-size: 12px;
+    color: var(--mint-text-muted, #64748b);
+    line-height: 1.5;
+  }
+</style>

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/0dc8ff6d5204bdf625aed8dc501b4d0f177a54cbe472781f1401cd5d33ed2577.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/0fde49475da1c53fea948e3679e306216de02605e343fec6a4c30c1910048597.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/297b2e78edb9873505234694c624d678086e3979d65ca641a56cae500b42c947.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/412524c67ae8c587b0ffa7d2da942f3521530a261e8f3dd315fa0266e689a1c6.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": []}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/4d4a2da2625d36e0b112a376498159f99c1db70d74bc15aee51268972e85d8ec.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/5fb93c975f5070ebdbf4ef07805dca02150ac6592b58dd90785d56145e9c37d9.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/654b1af618676f59b4226fe4b30b91a8ddc4a618a9202f4b382304f1352b17a4.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/6feece2286542d38a91f4e2462e04a41bf8335ac92c5f18ebc962fc89d6b3574.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/7a9c8b8f34e635509ff0c6dd68811f3e407e38ada73c8e5552ab0ae59efa7fca.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": []}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/7c36d82b43d285ffad9a1dfd49654010030ad2b48856f4f14d6cdd7d0ab50ca0.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/86c3a28c2931c5ba9735deb305e10c18f71cc860c222994e1058af85853c72c3.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": []}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/8ddd482cf0e360f73c560c60c41be1cc9de08ca5b6d89f53e1a7b242386d6789.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/97ef81f8be2b150d3bbf0ca0682d921cd331a4170c42b41082bd82639d871113.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/98614108f4d4eb54371e1eb55e723e0fc898eb4ddd242b493de051472b0ee287.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/9c1115c65c227f52472a3dd83f92b893dba5b78e15aa894b9f9daccd08e288bf.json


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

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_api_d_ts", "label": "api.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api.d.ts", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_types_api_d_ts", "target": "vue", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api.d.ts", "source_location": "L793", "weight": 1.0}], "raw_calls": []}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/aba319e09a5009a843beec72c11aaed026bc831509f67b38efaec028d524847c.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/bf5e9377cc4b7c76a07eec318b48299e4e73d4778faca18389edb49a0241c4b9.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/cd8c10e9d510e4e97a4a05d30d273a275dd8cf299c3f9ae5d2dabfe5e1b178d1.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/e5a5424b76ffa92c795598c33966f64ebe9b292dd627561a85330bff578d8c4a.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/e92b2927cbfe1c9920ec8f39922252baed1b5b04d9a998e6a46fbfcfbb535a2f.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": []}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/f0b7fd64fc4915744d3d351fe6dc76501e56e2e9fa8d375b22a96b43517ba2ca.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
web-admin/src/graphify-out/cache/ast/fdbd04e569495e6cf7347a6001ab7301c5591ecd702c280f90f914d453c18c43.json


+ 6 - 3
web-admin/src/store/modules/user.ts

@@ -24,6 +24,7 @@ import { StorageConfig } from '@/utils/storage/storage-config'
 import { fetchLogout } from '@/api/auth'
 import { RoutesAlias } from '@/router/routesAlias'
 import { resolveSafeRedirectTarget } from '@/utils/router'
+import { pickUserAvatarRaw } from '@/utils/user-avatar'
 
 /**
  * 用户状态管理
@@ -57,11 +58,13 @@ export const useUserStore = defineStore(
      * @param newInfo 新的用户信息
      */
     const setUserInfo = (newInfo: Partial<Api.Auth.UserInfo> & Record<string, any>) => {
+      const merged = { ...(info.value || {}), ...(newInfo || {}) }
+      const avatarRaw = pickUserAvatarRaw(newInfo) || pickUserAvatarRaw(merged)
       info.value = {
-        ...(info.value || {}),
-        ...(newInfo || {}),
+        ...merged,
         buttons: newInfo?.buttons ?? info.value?.buttons ?? [],
-        userName: newInfo?.userName ?? newInfo?.username ?? info.value?.userName ?? ''
+        userName: newInfo?.userName ?? newInfo?.username ?? info.value?.userName ?? '',
+        avatarUrl: avatarRaw || undefined
       } as Api.Auth.UserInfo
     }
 

+ 3 - 1
web-admin/src/types/api.d.ts

@@ -9,10 +9,12 @@ declare namespace Api {
       username: string
       realName?: string
       phone?: string
+      email?: string
+      sex?: string
+      remark?: string
       role: string
       regionId?: string
       regionName?: string
-      email?: string
       roles?: string[]
       /** 自定义头像 URL,空则前端使用 `/avatart.jpg` */
       avatarUrl?: string | null

+ 204 - 0
web-admin/src/utils/protocol-fq-params-change.ts

@@ -0,0 +1,204 @@
+/**
+ * 分期参数变更(期数 / 间隔 / 开始时间),非缴费模式切换
+ * 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md §分期参数变更
+ */
+
+import {
+  calcFqInstallmentDueDate,
+  calcInstallmentDueAmounts,
+  sortFqPaymentDetails
+} from '@/utils/protocol-payment-installment'
+import {
+  calcRemainingAmount,
+  createPaymentDetailRow,
+  type DetailLike,
+  type EvaluateResult
+} from '@/utils/protocol-pay-mode-switch'
+
+export interface FqParams {
+  installmentCount: number | null
+  installmentIntervalMonths: number | null
+  installmentStartDate: string
+}
+
+const BLOCKING_STATUSES = new Set(['jfqrz', 'tfqrz', 'ytf'])
+
+function formatMoneySummary(v: number): string {
+  return `¥${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
+}
+
+function countByStatus(details: DetailLike[], status: string): number {
+  return details.filter((d) => String(d.payStatus || 'dfj') === status).length
+}
+
+export function fqParamsSnapshot(params: FqParams): FqParams {
+  return {
+    installmentCount: params.installmentCount,
+    installmentIntervalMonths: params.installmentIntervalMonths,
+    installmentStartDate: params.installmentStartDate || ''
+  }
+}
+
+export function isSameFqParams(a: FqParams, b: FqParams): boolean {
+  return (
+    Number(a.installmentCount) === Number(b.installmentCount) &&
+    Number(a.installmentIntervalMonths) === Number(b.installmentIntervalMonths) &&
+    String(a.installmentStartDate || '') === String(b.installmentStartDate || '')
+  )
+}
+
+export function getPaidInstallmentRows(details: DetailLike[]): DetailLike[] {
+  return [...details]
+    .filter((d) => String(d.payStatus) === 'yjf')
+    .sort((a, b) => Number(a.installmentNo || 0) - Number(b.installmentNo || 0))
+}
+
+function maxPaidInstallmentNo(details: DetailLike[]): number {
+  return getPaidInstallmentRows(details).reduce(
+    (max, d) => Math.max(max, Number(d.installmentNo || 0)),
+    0
+  )
+}
+
+export function evaluateFqParamsChange(
+  _oldParams: FqParams,
+  newParams: FqParams,
+  details: DetailLike[],
+  contractAmount: number | null
+): EvaluateResult {
+  const rows = details.filter((d) => !d._voided)
+  const newCount = Number(newParams.installmentCount)
+  const interval = Number(newParams.installmentIntervalMonths)
+  const startDate = String(newParams.installmentStartDate || '')
+
+  if (!newCount || newCount < 1) {
+    return { action: 'blocked', reason: '请填写有效的分期期数。' }
+  }
+  if (!interval || interval <= 0) {
+    return { action: 'blocked', reason: '请填写有效的每期间隔(月)。' }
+  }
+  if (!startDate) {
+    return { action: 'blocked', reason: '请填写分期开始时间。' }
+  }
+
+  if (rows.some((d) => BLOCKING_STATUSES.has(String(d.payStatus || '')))) {
+    return {
+      action: 'blocked',
+      reason: '存在缴费确认中或退费处理中的明细,请先完成相关流程后再修改分期参数。'
+    }
+  }
+
+  const paidRows = getPaidInstallmentRows(rows)
+  const maxPaidNo = maxPaidInstallmentNo(rows)
+
+  if (maxPaidNo > 0 && newCount < maxPaidNo) {
+    return {
+      action: 'blocked',
+      reason: `已有第 ${maxPaidNo} 期缴费记录,分期期数不能少于 ${maxPaidNo}。`
+    }
+  }
+
+  const dfjCount = countByStatus(rows, 'dfj')
+
+  if (paidRows.length === 0) {
+    if (rows.length === 0) {
+      return { action: 'noop' }
+    }
+    return {
+      action: 'confirm',
+      summary: `将按新参数(${newCount} 期)重新生成缴费明细,原有 ${rows.length} 条待缴计划将被替换。是否继续?`
+    }
+  }
+
+  const remaining = calcRemainingAmount(contractAmount, rows)
+  const newUnpaidCount = newCount - paidRows.length
+
+  if (newUnpaidCount < 0) {
+    return {
+      action: 'blocked',
+      reason: `已有 ${paidRows.length} 期缴费记录,分期期数不能少于 ${paidRows.length}。`
+    }
+  }
+
+  if (remaining <= 0 && dfjCount > 0) {
+    return { action: 'blocked', reason: '已缴清,无需调整分期计划。' }
+  }
+
+  const countChanged = Number(_oldParams.installmentCount) !== newCount
+
+  if (countChanged) {
+    return {
+      action: 'confirm',
+      summary: `将保留 ${paidRows.length} 期已缴记录,作废 ${dfjCount} 期未缴计划,并重新生成 ${newUnpaidCount} 期未缴明细(剩余 ${formatMoneySummary(remaining)})。是否继续?`
+    }
+  }
+
+  return {
+    action: 'confirm',
+    summary: `将保留已缴记录,并更新 ${dfjCount} 期未缴计划的应缴时间与金额(剩余 ${formatMoneySummary(remaining)})。是否继续?`
+  }
+}
+
+/** 按新分期参数重建明细:保留 yjf,作废原 dfj,生成剩余期次 */
+export function applyFqParamsChange(
+  params: FqParams,
+  details: DetailLike[],
+  contractAmount: number | null
+): DetailLike[] {
+  const count = Number(params.installmentCount) || 0
+  const interval = Number(params.installmentIntervalMonths) || 0
+  const startDate = params.installmentStartDate || ''
+  if (!count || !interval || !startDate) return details
+
+  const paidRows = getPaidInstallmentRows(details).map((d) => ({
+    ...d,
+    _payModeLocked: true
+  }))
+  const remainingCount = count - paidRows.length
+  const remaining = calcRemainingAmount(contractAmount, paidRows)
+  const amounts =
+    remainingCount > 0
+      ? calcInstallmentDueAmounts(remaining, remainingCount)
+      : []
+
+  const result: DetailLike[] = [...paidRows]
+
+  for (let i = 0; i < remainingCount; i++) {
+    const periodIndex = paidRows.length + i
+    result.push(
+      createPaymentDetailRow({
+        dueAmount: amounts[i] ?? null,
+        installmentNo: periodIndex + 1,
+        installmentDueDate: calcFqInstallmentDueDate(startDate, periodIndex, interval),
+        expanded: false
+      })
+    )
+  }
+
+  return sortFqPaymentDetails(result)
+}
+
+/** 首次填充分期参数时生成明细(无需确认) */
+export function generateInitialFqDetails(
+  params: FqParams,
+  contractAmount: number | null
+): DetailLike[] {
+  const count = Number(params.installmentCount) || 0
+  const interval = Number(params.installmentIntervalMonths) || 0
+  const startDate = params.installmentStartDate || ''
+  if (!count || !interval || !startDate) return []
+
+  const amounts = calcInstallmentDueAmounts(contractAmount, count)
+  const result: DetailLike[] = []
+  for (let i = 0; i < count; i++) {
+    result.push(
+      createPaymentDetailRow({
+        dueAmount: amounts[i] ?? null,
+        installmentNo: i + 1,
+        installmentDueDate: calcFqInstallmentDueDate(startDate, i, interval),
+        expanded: i === 0
+      })
+    )
+  }
+  return result
+}

+ 216 - 0
web-admin/src/utils/protocol-pay-mode-switch.ts

@@ -0,0 +1,216 @@
+/**
+ * 采集协议单 · 缴费模式切换(dj ↔ fq)
+ * 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md
+ */
+
+import {
+  calcFqInstallmentDueDate,
+  calcInstallmentDueAmounts
+} from '@/utils/protocol-payment-installment'
+
+export type PayMode = 'dj' | 'fq'
+export type DetailLike = Record<string, any>
+
+const LOCKED_STATUSES = new Set(['jfqrz', 'yjf', 'tfqrz', 'ytf'])
+const BLOCKING_STATUSES = new Set(['jfqrz', 'tfqrz', 'ytf'])
+
+export interface PayModeSwitchContext {
+  contractAmount: number | null
+  lumpSumDeadline: string
+  installmentCount: number | null
+  installmentIntervalMonths: number | null
+  installmentStartDate: string
+}
+
+export type EvaluateResult =
+  | { action: 'noop' }
+  | { action: 'blocked'; reason: string }
+  | { action: 'confirm'; summary: string }
+
+export function isPayDetailLocked(detail: DetailLike): boolean {
+  if (detail._payModeLocked) return true
+  return LOCKED_STATUSES.has(String(detail.payStatus || ''))
+}
+
+export function markPayDetailLocks(details: DetailLike[]): void {
+  for (const d of details) {
+    d._payModeLocked = isPayDetailLocked(d)
+  }
+}
+
+export function sumPaidAmount(details: DetailLike[]): number {
+  return details
+    .filter((d) => String(d.payStatus) === 'yjf')
+    .reduce((sum, d) => sum + (Number(d.dueAmount) || 0), 0)
+}
+
+export function calcRemainingAmount(
+  contractAmount: number | null,
+  details: DetailLike[]
+): number {
+  if (contractAmount == null) return 0
+  const remaining = Number(contractAmount) - sumPaidAmount(details)
+  return Math.round(remaining * 100) / 100
+}
+
+function formatMoneySummary(v: number): string {
+  return `¥${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
+}
+
+function activeDetails(details: DetailLike[]): DetailLike[] {
+  return details.filter((d) => !d._voided)
+}
+
+function hasBlockingStatus(details: DetailLike[]): boolean {
+  return details.some((d) => BLOCKING_STATUSES.has(String(d.payStatus || '')))
+}
+
+function countByStatus(details: DetailLike[], status: string): number {
+  return details.filter((d) => String(d.payStatus || 'dfj') === status).length
+}
+
+export function createPaymentDetailRow(options: {
+  dueAmount: number | null
+  installmentDueDate?: string
+  installmentNo?: number | null
+  payStatus?: string
+  expanded?: boolean
+}): DetailLike {
+  const now = new Date()
+  const pad = (n: number) => String(n).padStart(2, '0')
+  const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
+  return {
+    transferType: '',
+    dueAmount: options.dueAmount,
+    transferAmount: null,
+    transferTime: defaultTime,
+    payStatus: options.payStatus ?? 'dfj',
+    installmentNo: options.installmentNo ?? null,
+    installmentDueDate: options.installmentDueDate ?? '',
+    _expanded: options.expanded ?? false,
+    _payModeLocked: false,
+    attachments: []
+  }
+}
+
+export function evaluatePayModeSwitch(
+  fromMode: PayMode,
+  toMode: PayMode,
+  details: DetailLike[],
+  ctx: PayModeSwitchContext
+): EvaluateResult {
+  if (fromMode === toMode) return { action: 'noop' }
+
+  const rows = activeDetails(details)
+
+  if (hasBlockingStatus(rows)) {
+    return {
+      action: 'blocked',
+      reason: '存在缴费确认中或退费处理中的明细,请先完成相关流程后再切换缴费模式。'
+    }
+  }
+
+  if (fromMode === 'dj' && toMode === 'fq') {
+    if (rows.some((d) => isPayDetailLocked(d))) {
+      return {
+        action: 'blocked',
+        reason: '趸交已有缴费记录,无法改为分期。'
+      }
+    }
+    const count = ctx.installmentCount || 6
+    return {
+      action: 'confirm',
+      summary: `将清除当前趸交计划,按 ${count} 期分期重新生成缴费明细。是否继续?`
+    }
+  }
+
+  if (fromMode === 'fq' && toMode === 'dj') {
+    const dfjCount = countByStatus(rows, 'dfj')
+    const yjfCount = countByStatus(rows, 'yjf')
+
+    if (yjfCount > 0 && dfjCount === 0) {
+      return {
+        action: 'blocked',
+        reason: '分期已全部缴清,无需改为趸交。'
+      }
+    }
+
+    const remaining = calcRemainingAmount(ctx.contractAmount, rows)
+    if (yjfCount > 0 && remaining <= 0) {
+      return {
+        action: 'blocked',
+        reason: '已缴清,无需改为趸交。'
+      }
+    }
+
+    if (yjfCount > 0) {
+      return {
+        action: 'confirm',
+        summary: `将作废 ${dfjCount} 期未缴计划,保留 ${yjfCount} 期已缴记录,并新增 1 条趸交明细(剩余 ${formatMoneySummary(remaining)})。是否继续?`
+      }
+    }
+
+    return {
+      action: 'confirm',
+      summary: `将作废 ${dfjCount || rows.length} 期分期计划,并合并为 1 条趸交明细。是否继续?`
+    }
+  }
+
+  return { action: 'noop' }
+}
+
+export function applyPayModeSwitch(
+  fromMode: PayMode,
+  toMode: PayMode,
+  details: DetailLike[],
+  ctx: PayModeSwitchContext
+): DetailLike[] {
+  const rows = activeDetails(details)
+
+  if (fromMode === 'dj' && toMode === 'fq') {
+    const count = Number(ctx.installmentCount) || 6
+    const interval = Number(ctx.installmentIntervalMonths) || 6
+    const startDate = ctx.installmentStartDate || ''
+    const amounts = calcInstallmentDueAmounts(ctx.contractAmount, count)
+    const result: DetailLike[] = []
+    for (let i = 0; i < count; i++) {
+      result.push(
+        createPaymentDetailRow({
+          dueAmount: amounts[i] ?? null,
+          installmentNo: i + 1,
+          installmentDueDate:
+            startDate && interval ? calcFqInstallmentDueDate(startDate, i, interval) : '',
+          expanded: i === 0
+        })
+      )
+    }
+    return result
+  }
+
+  if (fromMode === 'fq' && toMode === 'dj') {
+    const yjfRows = rows
+      .filter((d) => String(d.payStatus) === 'yjf')
+      .map((d) => ({ ...d, _payModeLocked: true }))
+    const remaining = calcRemainingAmount(ctx.contractAmount, yjfRows)
+    const deadline = ctx.lumpSumDeadline || ''
+    const dueAmount = yjfRows.length ? remaining : ctx.contractAmount
+    const newDjRow = createPaymentDetailRow({
+      dueAmount,
+      installmentNo: null,
+      installmentDueDate: deadline,
+      expanded: yjfRows.length === 0
+    })
+    return [...yjfRows, newDjRow]
+  }
+
+  return rows
+}
+
+/** 趸交模式下是否还可添加待缴明细 */
+export function canAddPaymentDetail(payMode: string, details: DetailLike[]): boolean {
+  if (payMode === 'fq') return true
+  const dfjCount = details.filter(
+    (d) => !isPayDetailLocked(d) && String(d.payStatus || 'dfj') === 'dfj'
+  ).length
+  return dfjCount < 1
+}

+ 25 - 2
web-admin/src/utils/protocol-payment-installment.ts

@@ -1,5 +1,7 @@
 /** 分期缴费:期数对齐、应缴日/金额计算(与 ProtocolFormDialog 逻辑一致) */
 
+const DAYS_PER_MONTH = 30
+
 export function addMonthsYmd(dateStr: string, months: number): string {
   const compact = String(dateStr || '').replace(/-/g, '')
   if (compact.length !== 8) return ''
@@ -28,13 +30,34 @@ export function addDaysYmd(dateStr: string, days: number): string {
   return `${yy}${mm}${dd}`
 }
 
-/** 分期应缴费时间:第 1 期=开始时间,第 2 期起在按月递增基础上提前 10 天 */
+/** 整数部分按日历加月,小数部分按 30 天/月换算为天数 */
+export function addIntervalMonthsYmd(dateStr: string, totalMonths: number): string {
+  const compact = String(dateStr || '').replace(/-/g, '')
+  if (compact.length !== 8 || !Number.isFinite(totalMonths) || totalMonths === 0) {
+    return compact.length === 8 ? compact : ''
+  }
+  const wholeMonths = Math.trunc(totalMonths)
+  const fracMonths = totalMonths - wholeMonths
+  let result = compact
+  if (wholeMonths !== 0) {
+    result = addMonthsYmd(result, wholeMonths)
+  }
+  if (fracMonths !== 0) {
+    const days = Math.round(fracMonths * DAYS_PER_MONTH)
+    if (days !== 0) {
+      result = addDaysYmd(result, days)
+    }
+  }
+  return result
+}
+
+/** 分期应缴费时间:第 1 期=开始时间,第 2 期起在间隔递增基础上提前 10 天 */
 export function calcFqInstallmentDueDate(
   startDate: string,
   index: number,
   intervalMonths: number
 ): string {
-  const base = addMonthsYmd(startDate, index * intervalMonths)
+  const base = addIntervalMonthsYmd(startDate, index * intervalMonths)
   if (index >= 1) {
     return addDaysYmd(base, -10)
   }

+ 80 - 5
web-admin/src/utils/user-avatar.ts

@@ -1,17 +1,92 @@
-/** 未设置头像时使用 `public/avatart.jpg`(根路径 `/avatart.jpg`) */
-export const DEFAULT_USER_AVATAR_URL = '/avatart.jpg'
+/** 未设置头像时使用 `public/avatart.jpg` */
+function joinPublicAsset(path: string): string {
+  const base = import.meta.env.BASE_URL || '/'
+  const prefix = base.endsWith('/') ? base : `${base}/`
+  return `${prefix}${path.replace(/^\//, '')}`
+}
+
+export const DEFAULT_USER_AVATAR_URL = joinPublicAsset('avatart.jpg')
+
+/** 本地/部署环境由 .env 配置;docker 默认走 Nginx `/minio/zkxw` 反代 */
+const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '/minio/zkxw').replace(/\/$/, '')
 
 /** @deprecated 各角色默认已统一为默认头像图,保留参数仅为兼容旧调用 */
 export function defaultAvatarPathByRole(_role: string | undefined): string {
   return DEFAULT_USER_AVATAR_URL
 }
 
-/** 展示用头像:`avatarUrl` 非空则用其值,否则用默认图 */
+/**
+ * 将库中头像值规范为存储相对路径(如 avatar/202606/04/xxx.jpeg)。
+ * 兼容历史数据里写入的完整 URL(如 https://www.chengyantek.cn/zkxw/avatar/...)。
+ */
+export function normalizeAvatarStorageKey(raw: string | null | undefined): string {
+  const u = raw != null ? String(raw).trim() : ''
+  if (!u) return ''
+  if (!/^https?:\/\//i.test(u) && !u.startsWith('/')) return u
+
+  const marker = '/avatar/'
+  const lower = u.toLowerCase()
+  const idx = lower.indexOf(marker)
+  if (idx >= 0) {
+    return `avatar/${u.slice(idx + marker.length)}`
+  }
+
+  if (u.startsWith('/')) {
+    const parts = u.split('/').filter(Boolean)
+    const avatarIdx = parts.findIndex((p) => p === 'avatar')
+    if (avatarIdx >= 0) return parts.slice(avatarIdx).join('/')
+  }
+
+  return u
+}
+
+/** 从用户信息对象读取原始头像字段(兼容 avatar / avatarUrl) */
+export function pickUserAvatarRaw(
+  source: { avatarUrl?: string | null; avatar?: string | null } | null | undefined
+): string {
+  if (!source) return ''
+  const raw = source.avatarUrl ?? source.avatar
+  return normalizeAvatarStorageKey(raw != null ? String(raw).trim() : '')
+}
+
+/** 是否已设置自定义头像(非空即视为已设置) */
+export function hasCustomUserAvatar(avatarUrl: string | null | undefined): boolean {
+  return Boolean(normalizeAvatarStorageKey(avatarUrl))
+}
+
+/** 解析上传接口返回的图片地址 */
+export function pickUploadAvatarUrl(res: unknown): string {
+  if (res == null) return ''
+  if (typeof res === 'string') return normalizeAvatarStorageKey(res)
+  const record = res as Record<string, unknown>
+  const nested = record.data as Record<string, unknown> | undefined
+  const raw =
+    record.url ?? record.fileUrl ?? record.file_url ?? nested?.url ?? nested?.fileUrl
+  return typeof raw === 'string' ? normalizeAvatarStorageKey(raw) : ''
+}
+
+/** MinIO 相对路径或完整 URL → 浏览器可访问地址 */
+export function resolveStorageFileUrl(url: string | null | undefined): string {
+  const key = normalizeAvatarStorageKey(url)
+  if (!key) return ''
+  if (/^https?:\/\//i.test(key)) return key
+  if (key.startsWith('/')) return key
+  return `${minioBaseUrl}/${key.replace(/^\//, '')}`
+}
+
+/** 展示用头像:优先用户表头像,否则默认图 */
 export function resolveUserAvatarUrl(
   avatarUrl: string | null | undefined,
   role: string | undefined
 ): string {
-  const u = avatarUrl != null ? String(avatarUrl).trim() : ''
-  if (u) return u
+  const key = normalizeAvatarStorageKey(avatarUrl)
+  if (key) return resolveStorageFileUrl(key)
   return defaultAvatarPathByRole(role)
 }
+
+/** 从用户对象直接得到展示用头像 URL */
+export function resolveUserAvatarFromInfo(
+  source: { avatarUrl?: string | null; avatar?: string | null; role?: string } | null | undefined
+): string {
+  return resolveUserAvatarUrl(pickUserAvatarRaw(source), source?.role)
+}

+ 220 - 64
web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

@@ -758,7 +758,7 @@
                       step="any"
                       :min="1"
                       style="width: 100%"
-                      @change="tryGenerateInstallmentDetails"
+                      @change="handleFqParamsChange"
                     />
                   </el-form-item>
                   <el-form-item label="每期间隔(月)">
@@ -766,9 +766,9 @@
                       v-model="formData.installmentIntervalMonths"
                       type="number"
                       step="any"
-                      :min="1"
+                      :min="0.1"
                       style="width: 100%"
-                      @change="tryGenerateInstallmentDetails"
+                      @change="handleFqParamsChange"
                     />
                   </el-form-item>
                   <el-form-item label="分期开始时间">
@@ -780,7 +780,7 @@
                       placeholder="选择开始时间"
                       clearable
                       style="width: 100%"
-                      @change="tryGenerateInstallmentDetails"
+                      @change="handleFqParamsChange"
                     />
                   </el-form-item>
                 </template>
@@ -790,7 +790,11 @@
             <!-- 缴费明细 -->
             <div class="section-subtitle" style="margin-top: 20px">缴费明细</div>
             <div style="margin-bottom: 14px">
-              <el-button type="primary" @click="addPaymentDetail">
+              <el-button
+                type="primary"
+                :disabled="!canAddPaymentDetail(formData.payMode, formData.paymentDetails)"
+                @click="addPaymentDetail"
+              >
                 <el-icon><Plus /></el-icon>
                 添加明细
               </el-button>
@@ -834,6 +838,7 @@
                     {{ paymentDetailSummary(detail) }}
                   </span>
                   <el-button
+                    v-if="!isPayDetailLocked(detail)"
                     link
                     type="danger"
                     size="small"
@@ -872,6 +877,7 @@
                         <AmountInput
                           v-model="detail.transferAmount"
                           :min="0"
+                          :disabled="isPayDetailLocked(detail)"
                           placeholder="请输入转账金额"
                         />
                       </el-form-item>
@@ -882,6 +888,7 @@
                           value-format="YYYY-MM-DD HH:mm:ss"
                           placeholder="选择时间"
                           clearable
+                          :disabled="isPayDetailLocked(detail)"
                           style="width: 100%"
                         />
                       </el-form-item>
@@ -889,6 +896,7 @@
                         <el-select
                           v-model="detail.payStatus"
                           placeholder="状态"
+                          :disabled="isPayDetailLocked(detail)"
                           style="width: 100%"
                         >
                           <el-option label="待缴费" value="dfj" />
@@ -906,12 +914,13 @@
                           format="YYYY-MM-DD"
                           placeholder="选择应缴费时间"
                           clearable
+                          :disabled="isPayDetailLocked(detail)"
                           style="width: 100%"
                         />
                       </el-form-item>
                     </div>
                     <!-- 每条明细的附件上传区 -->
-                    <div class="payment-detail-card__attachments">
+                    <div v-if="!isPayDetailLocked(detail)" class="payment-detail-card__attachments">
                       <div class="attachment-upload">
                         <div class="attachment-list">
                           <el-upload
@@ -1175,10 +1184,24 @@
   } from '@/api/admin'
   import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
   import { useUserStore } from '@/store/modules/user'
+  import { syncFqPaymentDetailRowCount } from '@/utils/protocol-payment-installment'
   import {
-    calcFqInstallmentDueDate,
-    syncFqPaymentDetailRowCount
-  } from '@/utils/protocol-payment-installment'
+    applyPayModeSwitch,
+    canAddPaymentDetail,
+    evaluatePayModeSwitch,
+    isPayDetailLocked,
+    markPayDetailLocks,
+    sumPaidAmount,
+    type PayMode
+  } from '@/utils/protocol-pay-mode-switch'
+  import {
+    applyFqParamsChange,
+    evaluateFqParamsChange,
+    fqParamsSnapshot,
+    generateInitialFqDetails,
+    isSameFqParams,
+    type FqParams
+  } from '@/utils/protocol-fq-params-change'
   import AmountInput from '@/components/common/amount-input/index.vue'
   import { formatMoneyDisplay } from '@/utils/money-format'
   import {
@@ -1199,6 +1222,16 @@
 
   const userStore = useUserStore()
 
+  /** 上次确认生效的缴费模式(用于切换取消/拦截时回滚) */
+  const lastConfirmedPayMode = ref<PayMode>('dj')
+
+  /** 上次确认生效的分期参数(用于编辑时修改期数/间隔/开始时间回滚) */
+  const lastConfirmedFqParams = ref<FqParams>({
+    installmentCount: null,
+    installmentIntervalMonths: null,
+    installmentStartDate: ''
+  })
+
   const customerId = ref<string | null>(null)
   const customerOptions = ref<
     Array<{ id: string; customerName: string; phoneNo: string; idCardNo: string }>
@@ -1544,6 +1577,12 @@
     batchTransportVisible.value = false
     Object.assign(batchTransport, createEmptyTransport())
     activeFormTab.value = 'basic'
+    lastConfirmedPayMode.value = 'dj'
+    lastConfirmedFqParams.value = {
+      installmentCount: null,
+      installmentIntervalMonths: null,
+      installmentStartDate: ''
+    }
   }
 
   function scrollToSection(key: string) {
@@ -1627,6 +1666,7 @@
   }
 
   function addPaymentDetail() {
+    if (!canAddPaymentDetail(formData.payMode, formData.paymentDetails)) return
     const now = new Date()
     const pad = (n: number) => String(n).padStart(2, '0')
     const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
@@ -1702,77 +1742,180 @@
     return Number.isFinite(n) ? Math.trunc(n) : undefined
   }
 
+  function toFloatOrUndefined(val: unknown): number | undefined {
+    if (val == null || val === '') return undefined
+    const n = Number(val)
+    return Number.isFinite(n) ? n : undefined
+  }
+
   function fqPaymentContext() {
     return {
       payMode: formData.payMode,
       installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
       contractAmount: formData.contractAmount,
       installmentStartDate: formData.installmentStartDate,
-      installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths) ?? null
+      installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null
     }
   }
 
-  /** 分期模式:三个参数都填好后生成/补齐缴费明细行(已有明细时仅补齐期次,不整表覆盖) */
-  function tryGenerateInstallmentDetails() {
+  function payModeSwitchContext() {
+    return {
+      contractAmount: formData.contractAmount,
+      lumpSumDeadline: formData.lumpSumDeadline,
+      installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
+      installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
+      installmentStartDate: formData.installmentStartDate
+    }
+  }
+
+  function getFqParamsFromForm(): FqParams {
+    return {
+      installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
+      installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
+      installmentStartDate: formData.installmentStartDate || ''
+    }
+  }
+
+  function restoreFqParams(params: FqParams) {
+    formData.installmentCount = params.installmentCount
+    formData.installmentIntervalMonths = params.installmentIntervalMonths
+    formData.installmentStartDate = params.installmentStartDate || ''
+  }
+
+  function syncLastConfirmedFqParams() {
+    lastConfirmedFqParams.value = fqParamsSnapshot(getFqParamsFromForm())
+  }
+
+  /**
+   * 分期参数变更(期数 / 间隔 / 开始时间):校验 → 确认 → 重建未缴期次
+   * 与缴费模式切换独立;保留 yjf 已缴行,作废原 dfj 后按剩余金额重建。
+   */
+  async function handleFqParamsChange() {
     if (formData.payMode !== 'fq') return
-    const count = formData.installmentCount
-    const interval = formData.installmentIntervalMonths
-    const startDate = formData.installmentStartDate
-    if (!count || !interval || !startDate) return
 
-    if (formData.paymentDetails.length) {
-      syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
-      syncPaymentDueAmounts()
-      formData.paymentDetails.forEach((d, i) => {
-        d.installmentNo = i + 1
-        d.installmentDueDate = calcFqInstallmentDueDate(startDate, i, interval)
-      })
+    const newParams = getFqParamsFromForm()
+    const oldParams = lastConfirmedFqParams.value
+
+    if (isSameFqParams(oldParams, newParams)) return
+
+    if (
+      !newParams.installmentCount ||
+      !newParams.installmentIntervalMonths ||
+      !newParams.installmentStartDate
+    ) {
       return
     }
 
-    const dueAmounts = calcInstallmentDueAmounts(formData.contractAmount, count)
-    const details = []
-    for (let i = 0; i < count; i++) {
-      details.push({
-        transferType: '',
-        dueAmount: dueAmounts[i] ?? null,
-        transferAmount: null,
-        transferTime: '',
-        payStatus: 'dfj',
-        installmentNo: i + 1,
-        installmentDueDate: calcFqInstallmentDueDate(startDate, i, interval),
-        attachments: [] as Array<{
-          id?: string
-          fileName: string
-          fileUrl: string
-          fileSize: number
-          fileSizeDisplay?: string
-          fileType?: string
-          _uploading?: boolean
-        }>
-      })
+    if (formData.paymentDetails.length === 0) {
+      formData.paymentDetails = generateInitialFqDetails(newParams, formData.contractAmount)
+      initPaymentDetailExpandFlags(formData.paymentDetails)
+      markPayDetailLocks(formData.paymentDetails)
+      syncLastConfirmedFqParams()
+      return
     }
-    initPaymentDetailExpandFlags(details)
-    formData.paymentDetails = details
-  }
 
-  /** 缴费模式切换回调 */
-  function onPayModeChange(val: string) {
-    if (val === 'fq') {
-      applyFqPaymentDefaults({ resetCountInterval: true })
-      tryGenerateInstallmentDetails()
-    } else {
-      // 趸交模式:将趸交截止时间同步到所有明细的应缴费时间
-      syncPaymentDueAmounts()
+    const evaluation = evaluateFqParamsChange(
+      oldParams,
+      newParams,
+      formData.paymentDetails,
+      formData.contractAmount
+    )
+
+    if (evaluation.action === 'noop') {
+      syncLastConfirmedFqParams()
+      return
+    }
+
+    if (evaluation.action === 'blocked') {
+      restoreFqParams(oldParams)
+      await ElMessageBox.alert(evaluation.reason, '无法修改分期参数', { type: 'warning' })
+      return
+    }
+
+    if (evaluation.action === 'confirm') {
+      try {
+        await ElMessageBox.confirm(evaluation.summary, '修改分期参数', {
+          confirmButtonText: '确认修改',
+          cancelButtonText: '取消',
+          type: 'warning'
+        })
+      } catch {
+        restoreFqParams(oldParams)
+        return
+      }
+    }
+
+    formData.paymentDetails = applyFqParamsChange(
+      newParams,
+      formData.paymentDetails,
+      formData.contractAmount
+    )
+    initPaymentDetailExpandFlags(formData.paymentDetails)
+    markPayDetailLocks(formData.paymentDetails)
+    syncLastConfirmedFqParams()
+  }
+
+  /** 缴费模式切换:校验 → 确认 → 变换明细 */
+  async function onPayModeChange(val: string) {
+    const toMode = val as PayMode
+    const fromMode = lastConfirmedPayMode.value
+    if (toMode === fromMode) return
+
+    const evaluation = evaluatePayModeSwitch(
+      fromMode,
+      toMode,
+      formData.paymentDetails,
+      payModeSwitchContext()
+    )
+
+    if (evaluation.action === 'blocked') {
+      formData.payMode = fromMode
+      await ElMessageBox.alert(evaluation.reason, '无法切换缴费模式', { type: 'warning' })
+      return
+    }
+
+    if (evaluation.action === 'confirm') {
+      try {
+        await ElMessageBox.confirm(evaluation.summary, '切换缴费模式', {
+          confirmButtonText: '确认切换',
+          cancelButtonText: '取消',
+          type: 'warning'
+        })
+      } catch {
+        formData.payMode = fromMode
+        return
+      }
+    }
+
+    if (toMode === 'fq') {
+      applyFqPaymentDefaults({ resetCountInterval: false })
+    }
+
+    formData.paymentDetails = applyPayModeSwitch(
+      fromMode,
+      toMode,
+      formData.paymentDetails,
+      payModeSwitchContext()
+    )
+    initPaymentDetailExpandFlags(formData.paymentDetails)
+    markPayDetailLocks(formData.paymentDetails)
+
+    if (toMode === 'dj') {
       syncLumpSumDueDate()
     }
+
+    lastConfirmedPayMode.value = toMode
+    if (toMode === 'fq') {
+      syncLastConfirmedFqParams()
+    }
   }
 
-  /** 趸交模式:将趸交截止时间同步到所有缴费明细的应缴费时间 */
+  /** 趸交模式:将趸交截止时间同步到待缴明细的应缴费时间 */
   function syncLumpSumDueDate() {
     if (formData.payMode !== 'dj') return
     const deadline = formData.lumpSumDeadline || ''
     formData.paymentDetails.forEach((d) => {
+      if (isPayDetailLocked(d)) return
       d.installmentDueDate = deadline
     })
   }
@@ -1885,6 +2028,7 @@
       formData.installmentStartDate = String(pay.installmentStartDate || '')
       if (formData.payMode === 'fq') {
         applyFqPaymentDefaults()
+        syncLastConfirmedFqParams()
       }
     }
 
@@ -1896,8 +2040,10 @@
       }))
       initPaymentDetailExpandFlags(details)
       formData.paymentDetails = details
+      markPayDetailLocks(formData.paymentDetails)
     }
     syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
+    lastConfirmedPayMode.value = (formData.payMode === 'fq' ? 'fq' : 'dj') as PayMode
 
     const pa =
       (row as any).protocolAttachments || (bundle.protocol as any)?.protocolAttachments || ''
@@ -2057,11 +2203,13 @@
           contractAmount: formData.contractAmount ?? undefined,
           lumpSumDeadline: formData.lumpSumDeadline || undefined,
           installmentCount: toIntOrUndefined(formData.installmentCount),
-          installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths),
+          installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths),
           installmentStartDate: formData.installmentStartDate || undefined
         },
-        paymentDetails: formData.paymentDetails.map(({ _expanded, ...d }) => ({
-          transferType: d.transferType,
+        paymentDetails: formData.paymentDetails.map(
+          ({ _expanded, _payModeLocked, _voided, ...d }) => ({
+            id: d.id,
+            transferType: d.transferType,
           dueAmount: d.dueAmount,
           transferAmount: d.transferAmount,
           transferTime: d.transferTime,
@@ -2087,7 +2235,7 @@
             fileSizeDisplay: a.fileSizeDisplay,
             fileType: a.fileType
           }))
-        }))
+          }))
       }
 
       if (isEdit.value && editingId.value) {
@@ -2527,21 +2675,29 @@
     return amounts
   }
 
-  /** 按缴费模式同步各明细应缴费金额 */
+  /** 按缴费模式同步各明细应缴费金额(跳过已锁定明细) */
   function syncPaymentDueAmounts() {
     const contract = formData.contractAmount
     if (contract == null) return
     if (formData.payMode === 'dj') {
-      formData.paymentDetails.forEach((d) => {
-        d.dueAmount = contract
+      const paidSum = sumPaidAmount(formData.paymentDetails)
+      const remaining = Math.round((Number(contract) - paidSum) * 100) / 100
+      const dfjRows = formData.paymentDetails.filter(
+        (d) => !isPayDetailLocked(d) && String(d.payStatus || 'dfj') === 'dfj'
+      )
+      const targetAmount = paidSum > 0 ? remaining : contract
+      dfjRows.forEach((d, i) => {
+        if (i === 0) d.dueAmount = targetAmount
       })
       return
     }
     if (formData.payMode === 'fq') {
+      if (formData.paymentDetails.some((d) => isPayDetailLocked(d))) return
       const count = formData.installmentCount || formData.paymentDetails.length
       if (!count) return
-      const amounts = calcInstallmentDueAmounts(Number(contract), count)
+      const amounts = calcInstallmentDueAmounts(Number(contract), Number(count))
       formData.paymentDetails.forEach((d, i) => {
+        if (isPayDetailLocked(d)) return
         if (amounts[i] !== undefined) d.dueAmount = amounts[i]
       })
     }

Неке датотеке нису приказане због велике количине промена