소스 검색

docs: add collection region staff binding implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 개월 전
부모
커밋
a882bb185e
1개의 변경된 파일과 1219개의 추가작업 그리고 0개의 파일을 삭제
  1. 1219 0
      docs/superpowers/plans/2026-09-18-collection-region-staff-binding.md

+ 1219 - 0
docs/superpowers/plans/2026-09-18-collection-region-staff-binding.md

@@ -0,0 +1,1219 @@
+# Collection Region Staff Binding Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let admins bind tenant users to a collection region, mark zero-or-more as region leaders, and show staff/leader summaries on the region list and view dialog.
+
+**Architecture:** New soft-delete junction table `biz_collection_region_staff` (`region_id` + `user_id` + `is_leader`). Nested CRUD under `/collection-regions/{id}/staff`, mirroring hospitals. Region list batch-loads `staffNames` / `leaderNames`. Web-admin adds a「地区人员配置」dialog next to「地区医院配置」.
+
+**Tech Stack:** MySQL 5.7, FastAPI + SQLAlchemy, Vue 3 + Element Plus (`web-admin`), Fresh Mint UI conventions.
+
+**Spec:** `docs/superpowers/specs/2026-09-18-collection-region-staff-binding-design.md`  
+**Demo:** `docs/superpowers/demos/2026-09-18-collection-region-staff-binding-demo.html`
+
+---
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `docs/sql/migrations/2026-09-18-collection-region-staff.sql` | CREATE TABLE migration |
+| `backend/app/models/models.py` | `BizCollectionRegionStaff` ORM |
+| `backend/app/crud/collection_region.py` | Staff CRUD, list summary, delete cascade |
+| `backend/app/api/basic_info.py` | Staff HTTP routes + region list summary wiring |
+| `backend/tests/test_collection_region_staff_helpers.py` | Pure helper unit tests (display name / summary order) |
+| `web-admin/src/types/api.d.ts` | `staffNames` / `leaderNames` / `StaffItem` |
+| `web-admin/src/api/admin.ts` | Staff API client functions |
+| `web-admin/src/views/basic-info/collection-region/index.vue` | List columns + staff config dialog + view fields |
+
+---
+
+### Task 1: SQL migration
+
+**Files:**
+- Create: `docs/sql/migrations/2026-09-18-collection-region-staff.sql`
+
+- [ ] **Step 1: Write migration SQL**
+
+```sql
+-- 采集地区 — 工作人员绑定(可多名负责人)
+-- Spec: docs/superpowers/specs/2026-09-18-collection-region-staff-binding-design.md
+
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `biz_collection_region_staff` (
+  `id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
+  `region_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采集地区ID',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统用户ID',
+  `is_leader` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否地区负责人 1是0否',
+  `tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
+  `create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_crs_region` (`region_id`, `del_flag`) USING BTREE,
+  KEY `idx_crs_user` (`user_id`, `del_flag`) USING BTREE,
+  KEY `idx_crs_tenant` (`tenant_no`, `del_flag`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采集地区工作人员';
+```
+
+- [ ] **Step 2: Apply on local DB**
+
+Run (adjust credentials to local `.env.local`):
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw/backend
+# Example — use your mysql client / GUI against the DB in .env.local
+mysql -h 127.0.0.1 -u <user> -p <database> < ../docs/sql/migrations/2026-09-18-collection-region-staff.sql
+```
+
+Expected: table `biz_collection_region_staff` exists; `\d` / `SHOW CREATE TABLE` shows columns above.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/sql/migrations/2026-09-18-collection-region-staff.sql
+git commit -m "$(cat <<'EOF'
+docs(sql): add biz_collection_region_staff migration
+
+EOF
+)"
+```
+
+---
+
+### Task 2: ORM model
+
+**Files:**
+- Modify: `backend/app/models/models.py` (immediately after `BizCollectionHospital`, ~line 407)
+
+- [ ] **Step 1: Add `BizCollectionRegionStaff` class**
+
+Insert after `BizCollectionHospital`:
+
+```python
+class BizCollectionRegionStaff(Base):
+    """采集地区工作人员 biz_collection_region_staff"""
+
+    __tablename__ = "biz_collection_region_staff"
+
+    id = Column(String(64), primary_key=True, comment="主键ID")
+    region_id = Column(String(64), nullable=False, comment="采集地区ID")
+    user_id = Column(String(64), nullable=False, comment="系统用户ID")
+    is_leader = Column(SmallInteger, nullable=False, default=0, comment="是否地区负责人 1是0否")
+    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+    order_num = Column(Integer, nullable=True, comment="排序号")
+    remark = Column(String(512), nullable=True, comment="备注")
+    create_by = Column(String(64), nullable=True, comment="创建人id")
+    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
+    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
+    update_by = Column(String(64), nullable=True, comment="更新人id")
+    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
+```
+
+- [ ] **Step 2: Smoke-import**
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate
+python -c "from app.models.models import BizCollectionRegionStaff; print(BizCollectionRegionStaff.__tablename__)"
+```
+
+Expected: `biz_collection_region_staff`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/models/models.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add BizCollectionRegionStaff model
+
+EOF
+)"
+```
+
+---
+
+### Task 3: Helper unit tests (TDD) + CRUD staff layer
+
+**Files:**
+- Create: `backend/tests/test_collection_region_staff_helpers.py`
+- Modify: `backend/app/crud/collection_region.py`
+
+- [ ] **Step 1: Write failing helper tests**
+
+Create `backend/tests/test_collection_region_staff_helpers.py`:
+
+```python
+import unittest
+
+from app.crud.collection_region import staff_display_name, build_staff_leader_name_lists
+
+
+class CollectionRegionStaffHelperTests(unittest.TestCase):
+    def test_staff_display_name_prefers_nick_name(self):
+        self.assertEqual(staff_display_name("张敏", "SQ001"), "张敏")
+
+    def test_staff_display_name_falls_back_to_user_no(self):
+        self.assertEqual(staff_display_name("", "SQ001"), "SQ001")
+        self.assertEqual(staff_display_name(None, "SQ001"), "SQ001")
+
+    def test_build_staff_leader_name_lists_leaders_first_in_staff(self):
+        rows = [
+            {"userId": "u2", "nickName": "李强", "isLeader": False},
+            {"userId": "u1", "nickName": "张敏", "isLeader": True},
+            {"userId": "u3", "nickName": "王芳", "isLeader": True},
+        ]
+        staff_names, leader_names = build_staff_leader_name_lists(rows)
+        self.assertEqual(leader_names, ["张敏", "王芳"])
+        self.assertEqual(staff_names, ["张敏", "王芳", "李强"])
+
+
+if __name__ == "__main__":
+    unittest.main()
+```
+
+- [ ] **Step 2: Run tests — expect FAIL**
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate
+pytest tests/test_collection_region_staff_helpers.py -v
+```
+
+Expected: FAIL — `cannot import name 'staff_display_name'` (or similar).
+
+- [ ] **Step 3: Implement helpers + staff CRUD in `collection_region.py`**
+
+Update imports at top of `backend/app/crud/collection_region.py`:
+
+```python
+from app.models.models import (
+    BizCollectionHospital,
+    BizCollectionRegion,
+    BizCollectionRegionStaff,
+    SysDept,
+    SysUser,
+)
+```
+
+Add helpers and extend `_region_to_dict` / cascade / staff CRUD. Replace `_region_to_dict` and add the following (place helpers near the top after imports; place staff functions after hospital soft-delete):
+
+```python
+def staff_display_name(nick_name: Optional[str], user_no: Optional[str]) -> str:
+    n = (nick_name or "").strip()
+    if n:
+        return n
+    return (user_no or "").strip()
+
+
+def build_staff_leader_name_lists(rows: List[Dict[str, Any]]) -> Tuple[List[str], List[str]]:
+    """rows: dicts with nickName/isLeader (already sorted leaders-first preferred)."""
+    leaders = [staff_display_name(r.get("nickName"), r.get("userNo")) for r in rows if r.get("isLeader")]
+    # Keep leader-first order if caller sorted; otherwise partition
+    non_leaders = [staff_display_name(r.get("nickName"), r.get("userNo")) for r in rows if not r.get("isLeader")]
+    staff_names = leaders + non_leaders
+    return staff_names, leaders
+
+
+def _region_to_dict(
+    r: BizCollectionRegion,
+    hospital_names: Optional[List[str]] = None,
+    staff_names: Optional[List[str]] = None,
+    leader_names: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    return {
+        "id": r.id,
+        "regionName": r.region_name,
+        "hospitalNames": hospital_names or [],
+        "staffNames": staff_names or [],
+        "leaderNames": leader_names or [],
+        "tenantNo": r.tenant_no,
+        "orderNum": r.order_num if r.order_num is not None else 0,
+        "remark": r.remark or "",
+        "createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
+        "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
+    }
+
+
+def _staff_names_by_region_ids(
+    db: Session, region_ids: List[str]
+) -> Dict[str, Tuple[List[str], List[str]]]:
+    """Return region_id -> (staffNames, leaderNames). Leaders first in staffNames."""
+    out: Dict[str, Tuple[List[str], List[str]]] = {rid: ([], []) for rid in region_ids}
+    if not region_ids:
+        return out
+    rows = (
+        db.query(BizCollectionRegionStaff, SysUser)
+        .outerjoin(SysUser, SysUser.id == BizCollectionRegionStaff.user_id)
+        .filter(
+            and_(
+                BizCollectionRegionStaff.region_id.in_(region_ids),
+                BizCollectionRegionStaff.del_flag == 0,
+            )
+        )
+        .order_by(
+            BizCollectionRegionStaff.is_leader.desc(),
+            BizCollectionRegionStaff.order_num.asc(),
+            BizCollectionRegionStaff.create_time.desc(),
+        )
+        .all()
+    )
+    buckets: Dict[str, List[Dict[str, Any]]] = {rid: [] for rid in region_ids}
+    for staff, user in rows:
+        buckets.setdefault(staff.region_id, []).append(
+            {
+                "nickName": (user.nick_name if user else "") or "",
+                "userNo": (user.user_no if user else "") or "",
+                "isLeader": bool(staff.is_leader),
+            }
+        )
+    for rid, items in buckets.items():
+        out[rid] = build_staff_leader_name_lists(items)
+    return out
+
+
+def _staff_to_dict(
+    s: BizCollectionRegionStaff,
+    *,
+    nick_name: str = "",
+    user_no: str = "",
+    dept_name: str = "",
+) -> Dict[str, Any]:
+    return {
+        "id": s.id,
+        "regionId": s.region_id,
+        "userId": s.user_id,
+        "userName": staff_display_name(nick_name, user_no),
+        "nickName": nick_name or "",
+        "userNo": user_no or "",
+        "deptName": dept_name or "",
+        "isLeader": bool(s.is_leader),
+        "tenantNo": s.tenant_no,
+        "orderNum": s.order_num if s.order_num is not None else 0,
+        "remark": s.remark or "",
+        "createTime": s.create_time.strftime("%Y-%m-%d %H:%M:%S") if s.create_time else "",
+        "updateTime": s.update_time.strftime("%Y-%m-%d %H:%M:%S") if s.update_time else "",
+    }
+
+
+def list_staff(
+    db: Session,
+    *,
+    region_id: str,
+    page: int = 1,
+    page_size: int = 10,
+    tenant_no: Optional[str] = None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    region = get_collection_region(db, region_id, tenant_no=tenant_no)
+    if not region:
+        raise ValueError("采集地区不存在")
+
+    def _filtered():
+        q0 = db.query(BizCollectionRegionStaff).filter(
+            and_(BizCollectionRegionStaff.region_id == region_id, BizCollectionRegionStaff.del_flag == 0)
+        )
+        if tenant_no:
+            q0 = q0.filter(BizCollectionRegionStaff.tenant_no == tenant_no)
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizCollectionRegionStaff.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(
+            BizCollectionRegionStaff.is_leader.desc(),
+            BizCollectionRegionStaff.order_num.asc(),
+            BizCollectionRegionStaff.create_time.desc(),
+        )
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    user_ids = [r.user_id for r in rows]
+    user_map: Dict[str, SysUser] = {}
+    dept_map: Dict[str, str] = {}
+    if user_ids:
+        for u in db.query(SysUser).filter(SysUser.id.in_(user_ids)).all():
+            user_map[u.id] = u
+        dept_ids = [u.dept_id for u in user_map.values() if u.dept_id]
+        if dept_ids:
+            for d in db.query(SysDept).filter(SysDept.id.in_(dept_ids), SysDept.del_flag == 0).all():
+                dept_map[d.id] = d.dept_name or ""
+    items: List[Dict[str, Any]] = []
+    for s in rows:
+        u = user_map.get(s.user_id)
+        items.append(
+            _staff_to_dict(
+                s,
+                nick_name=(u.nick_name if u else "") or "",
+                user_no=(u.user_no if u else "") or "",
+                dept_name=dept_map.get((u.dept_id if u else "") or "", ""),
+            )
+        )
+    return items, int(total)
+
+
+def get_staff(db: Session, staff_id: str, tenant_no: Optional[str] = None) -> Optional[BizCollectionRegionStaff]:
+    q = db.query(BizCollectionRegionStaff).filter(
+        and_(BizCollectionRegionStaff.id == staff_id, BizCollectionRegionStaff.del_flag == 0)
+    )
+    if tenant_no:
+        q = q.filter(BizCollectionRegionStaff.tenant_no == tenant_no)
+    return q.first()
+
+
+def create_staff(
+    db: Session,
+    *,
+    region_id: str,
+    user_id: str,
+    is_leader: bool = False,
+    tenant_no: str = "zkxw",
+    order_num: Optional[int] = None,
+    remark: Optional[str] = None,
+    user_id_actor: Optional[str] = None,
+) -> BizCollectionRegionStaff:
+    region = get_collection_region(db, region_id, tenant_no=tenant_no)
+    if not region:
+        raise ValueError("采集地区不存在")
+    uid = (user_id or "").strip()
+    if not uid:
+        raise ValueError("请选择系统人员")
+    user = (
+        db.query(SysUser)
+        .filter(
+            and_(
+                SysUser.id == uid,
+                SysUser.del_flag == 0,
+                SysUser.tenant_no == tenant_no,
+            )
+        )
+        .first()
+    )
+    if not user:
+        raise ValueError("用户不存在")
+    if (user.status or "") != "1":
+        raise ValueError("用户已停用,无法绑定")
+    exists = (
+        db.query(BizCollectionRegionStaff)
+        .filter(
+            and_(
+                BizCollectionRegionStaff.region_id == region_id,
+                BizCollectionRegionStaff.user_id == uid,
+                BizCollectionRegionStaff.del_flag == 0,
+                BizCollectionRegionStaff.tenant_no == tenant_no,
+            )
+        )
+        .first()
+    )
+    if exists:
+        raise ValueError("该人员已绑定到此地区")
+    obj = BizCollectionRegionStaff(
+        id=generate_uuid(),
+        region_id=region_id,
+        user_id=uid,
+        is_leader=1 if is_leader else 0,
+        tenant_no=tenant_no,
+        order_num=order_num if order_num is not None else 0,
+        remark=(remark or "").strip() or None,
+        create_by=user_id_actor,
+        create_dept_id="",
+        create_time=datetime.now(),
+        update_by=user_id_actor,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def update_staff(
+    db: Session,
+    staff_id: str,
+    *,
+    is_leader: Optional[bool] = None,
+    order_num: Optional[int] = None,
+    remark: Optional[str] = None,
+    user_id: Optional[str] = None,
+    tenant_no: Optional[str] = None,
+) -> BizCollectionRegionStaff:
+    obj = get_staff(db, staff_id, tenant_no=tenant_no)
+    if not obj:
+        raise ValueError("绑定记录不存在")
+    if is_leader is not None:
+        obj.is_leader = 1 if is_leader else 0
+    if order_num is not None:
+        obj.order_num = order_num
+    if remark is not None:
+        obj.remark = remark.strip() or None
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def soft_delete_staff(db: Session, staff_id: str, tenant_no: Optional[str] = None) -> None:
+    obj = get_staff(db, staff_id, tenant_no=tenant_no)
+    if not obj:
+        raise ValueError("绑定记录不存在")
+    obj.del_flag = 1
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+```
+
+Also update `list_collection_regions` to attach staff summaries:
+
+```python
+    region_ids = [r.id for r in rows]
+    hospital_map = _hospital_names_by_region_ids(db, region_ids)
+    staff_map = _staff_names_by_region_ids(db, region_ids)
+    return [
+        _region_to_dict(
+            x,
+            hospital_names=hospital_map.get(x.id, []),
+            staff_names=staff_map.get(x.id, ([], []))[0],
+            leader_names=staff_map.get(x.id, ([], []))[1],
+        )
+        for x in rows
+    ], int(total)
+```
+
+And extend `soft_delete_collection_region` to cascade staff (after hospital loop, before `db.commit()`):
+
+```python
+    staff_subs = (
+        db.query(BizCollectionRegionStaff)
+        .filter(
+            and_(
+                BizCollectionRegionStaff.region_id == region_id,
+                BizCollectionRegionStaff.del_flag == 0,
+            )
+        )
+        .all()
+    )
+    for s in staff_subs:
+        s.del_flag = 1
+        s.update_time = now
+        db.add(s)
+```
+
+- [ ] **Step 4: Run helper tests — expect PASS**
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate
+pytest tests/test_collection_region_staff_helpers.py -v
+```
+
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/crud/collection_region.py backend/tests/test_collection_region_staff_helpers.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add collection region staff CRUD and list summaries
+
+EOF
+)"
+```
+
+---
+
+### Task 4: API routes
+
+**Files:**
+- Modify: `backend/app/api/basic_info.py`
+
+- [ ] **Step 1: Wire region list summary**
+
+In `list_collection_regions`, after building `hospital_map`, also load staff map:
+
+```python
+    hospital_map = region_svc._hospital_names_by_region_ids(db, region_ids)
+    staff_map = region_svc._staff_names_by_region_ids(db, region_ids)
+    result_list = [
+        region_svc._region_to_dict(
+            r,
+            hospital_names=hospital_map.get(r.id, []),
+            staff_names=staff_map.get(r.id, ([], []))[0],
+            leader_names=staff_map.get(r.id, ([], []))[1],
+        )
+        for r in items
+    ]
+```
+
+Ensure create/update region responses still work (`_region_to_dict(obj)` defaults empty staff lists).
+
+- [ ] **Step 2: Add Staff body models + routes**
+
+After the hospital delete route block, add:
+
+```python
+# ---------------------------------------------------------------------------
+# 采集地区 — 工作人员
+# ---------------------------------------------------------------------------
+
+
+class RegionStaffCreateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    user_id: str = Field(..., alias="userId")
+    is_leader: Optional[bool] = Field(False, alias="isLeader")
+    order_num: Optional[int] = Field(0, alias="orderNum")
+    remark: Optional[str] = None
+
+
+class RegionStaffUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    is_leader: Optional[bool] = Field(None, alias="isLeader")
+    order_num: Optional[int] = Field(None, alias="orderNum")
+    remark: Optional[str] = None
+
+
+@router.get("/collection-regions/{region_id}/staff")
+async def list_region_staff(
+    region_id: str,
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = region_svc.list_staff(
+            db, region_id=region_id, page=page, page_size=pageSize, tenant_no=tenant_no
+        )
+        tp = region_svc.total_pages(total, pageSize)
+        return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/collection-regions/{region_id}/staff")
+async def create_region_staff(
+    region_id: str,
+    body: RegionStaffCreateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        obj = region_svc.create_staff(
+            db,
+            region_id=region_id,
+            user_id=body.user_id,
+            is_leader=bool(body.is_leader),
+            tenant_no=tenant_no,
+            order_num=body.order_num,
+            remark=body.remark,
+            user_id_actor=user.get("user_id"),
+        )
+        # enrich response with user display fields
+        items, _ = region_svc.list_staff(db, region_id=region_id, page=1, page_size=100, tenant_no=tenant_no)
+        matched = next((x for x in items if x["id"] == obj.id), region_svc._staff_to_dict(obj))
+        return success_response(matched, message="创建成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.put("/collection-regions/{region_id}/staff/{staff_id}")
+async def update_region_staff(
+    region_id: str,
+    staff_id: str,
+    body: RegionStaffUpdateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        s = region_svc.get_staff(db, staff_id, tenant_no=tenant_no)
+        if not s or s.region_id != region_id:
+            return error_response(400, "绑定记录不存在")
+        payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
+        obj = region_svc.update_staff(
+            db,
+            staff_id,
+            is_leader=payload.get("is_leader"),
+            order_num=payload.get("order_num"),
+            remark=payload.get("remark"),
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        items, _ = region_svc.list_staff(db, region_id=region_id, page=1, page_size=100, tenant_no=tenant_no)
+        matched = next((x for x in items if x["id"] == obj.id), region_svc._staff_to_dict(obj))
+        return success_response(matched, message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.delete("/collection-regions/{region_id}/staff/{staff_id}")
+async def delete_region_staff(
+    region_id: str,
+    staff_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        s = region_svc.get_staff(db, staff_id, tenant_no=tenant_no)
+        if not s or s.region_id != region_id:
+            return error_response(400, "绑定记录不存在")
+        region_svc.soft_delete_staff(db, staff_id, tenant_no=tenant_no)
+        return success_response(None, message="删除成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+```
+
+- [ ] **Step 3: Restart backend and smoke-check routes exist**
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate
+python -c "from app.api import basic_info; print('ok', hasattr(basic_info, 'list_region_staff'))"
+```
+
+Expected: `ok True` (or import path that matches package layout).
+
+Manual smoke (with auth token from browser Network tab):
+
+1. `GET /api/web/admin/collection-regions?page=1&pageSize=10` → each item has `staffNames` / `leaderNames` arrays.
+2. `POST .../collection-regions/{id}/staff` with `{ "userId": "<enabled-user-id>", "isLeader": true }` → 成功.
+3. Repeat same `userId` → 400「该人员已绑定到此地区」.
+4. `PUT` toggle `isLeader` → 成功.
+5. `DELETE` staff → 成功; region list summaries update.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/api/basic_info.py
+git commit -m "$(cat <<'EOF'
+feat(api): expose collection region staff nested CRUD
+
+EOF
+)"
+```
+
+---
+
+### Task 5: Frontend types + API client
+
+**Files:**
+- Modify: `web-admin/src/types/api.d.ts`
+- Modify: `web-admin/src/api/admin.ts`
+
+- [ ] **Step 1: Extend TypeScript types**
+
+In `Api.BizCollectionRegion.RegionItem` add:
+
+```typescript
+      staffNames: string[]
+      leaderNames: string[]
+```
+
+Add new namespace after `BizCollectionHospital`:
+
+```typescript
+  namespace BizCollectionRegionStaff {
+    interface StaffItem {
+      id: string
+      regionId: string
+      userId: string
+      userName: string
+      nickName: string
+      userNo: string
+      deptName: string
+      isLeader: boolean
+      tenantNo: string
+      orderNum: number
+      remark: string
+      createTime: string
+      updateTime: string
+    }
+  }
+```
+
+- [ ] **Step 2: Add API functions in `admin.ts` after hospital deletes**
+
+```typescript
+export function fetchRegionStaffList(
+  regionId: string,
+  params?: { page?: number; pageSize?: number }
+) {
+  return request.get<PageResult<Api.BizCollectionRegionStaff.StaffItem>>({
+    url: `/web/admin/collection-regions/${regionId}/staff`,
+    params
+  })
+}
+export function fetchCreateRegionStaff(
+  regionId: string,
+  data: { userId: string; isLeader?: boolean; orderNum?: number; remark?: string }
+) {
+  return request.post({ url: `/web/admin/collection-regions/${regionId}/staff`, params: data })
+}
+export function fetchUpdateRegionStaff(
+  regionId: string,
+  staffId: string,
+  data: Partial<{ isLeader: boolean; orderNum: number; remark: string }>
+) {
+  return request.put({
+    url: `/web/admin/collection-regions/${regionId}/staff/${staffId}`,
+    params: data
+  })
+}
+export function fetchDeleteRegionStaff(regionId: string, staffId: string) {
+  return request.del({ url: `/web/admin/collection-regions/${regionId}/staff/${staffId}` })
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/types/api.d.ts web-admin/src/api/admin.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add collection region staff API types and clients
+
+EOF
+)"
+```
+
+---
+
+### Task 6: Web UI — list columns, view dialog, staff config dialog
+
+**Files:**
+- Modify: `web-admin/src/views/basic-info/collection-region/index.vue`
+
+- [ ] **Step 1: Import APIs and icons**
+
+Extend imports from `@/api/admin`:
+
+```typescript
+    fetchRegionStaffList,
+    fetchCreateRegionStaff,
+    fetchUpdateRegionStaff,
+    fetchDeleteRegionStaff,
+    fetchUserList
+```
+
+Add icons if needed: `User` (optional) — keep `Plus`, `Close`, `Check`, `Delete` already present.
+
+- [ ] **Step 2: Add table columns after 医院信息**
+
+```vue
+          <el-table-column
+            label="工作人员"
+            min-width="160"
+            align="left"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <template v-if="row.staffNames && row.staffNames.length">
+                <div v-for="(name, idx) in row.staffNames" :key="idx" class="hospital-cell-line">
+                  {{ name }}
+                </div>
+              </template>
+              <span v-else class="muted">—</span>
+            </template>
+          </el-table-column>
+          <el-table-column
+            label="负责人"
+            min-width="140"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <template v-if="row.leaderNames && row.leaderNames.length">
+                <el-tag
+                  v-for="(name, idx) in row.leaderNames"
+                  :key="idx"
+                  type="success"
+                  size="small"
+                  style="margin: 2px"
+                  >{{ name }}</el-tag
+                >
+              </template>
+              <el-tag v-else type="info" size="small">未设置</el-tag>
+            </template>
+          </el-table-column>
+```
+
+Widen操作列 `min-width` to `300` and add button:
+
+```vue
+                <el-button class="btn-op-item primary" @click="openStaffDialog(row)">
+                  地区人员配置
+                </el-button>
+```
+
+- [ ] **Step 3: Extend viewSections**
+
+```typescript
+    const staffs =
+      row.staffNames && row.staffNames.length > 0 ? row.staffNames.join('、') : '无'
+    const leaders =
+      row.leaderNames && row.leaderNames.length > 0 ? row.leaderNames.join('、') : '未设置'
+    // fields:
+          { label: '关联医院', value: hospitals, span: true },
+          { label: '工作人员', value: staffs, span: true },
+          {
+            label: '负责人',
+            value: row.leaderNames?.length ? leaders : '未设置',
+            tagType: row.leaderNames?.length ? 'success' : 'info',
+            span: true
+          },
+```
+
+Note: `SystemViewDialog` supports one `tagType` per field — if multiple leaders, prefer plain joined text for leaders OR single tag with joined names. Use joined text with `tagType: 'success'` when any leader exists (matches「Tag 展示」spirit without multi-tag limitation).
+
+- [ ] **Step 4: Add staff dialog state + handlers**
+
+Mirror hospital dialog patterns. Key script pieces:
+
+```typescript
+  const staffDialogVisible = ref(false)
+  const staffDialogTitle = ref('地区人员配置')
+  const staffTable = ref<Api.BizCollectionRegionStaff.StaffItem[]>([])
+  const staffSelected = ref<Api.BizCollectionRegionStaff.StaffItem[]>([])
+  const staffLoading = ref(false)
+  const staffPagination = reactive({ page: 1, pageSize: 10, total: 0 })
+  const staffAddVisible = ref(false)
+  const staffAddLoading = ref(false)
+  const staffAddForm = reactive<{ userId: string; isLeader: boolean }>({
+    userId: '',
+    isLeader: false
+  })
+  const staffUserOptions = ref<Api.User.UserItem[]>([])
+  const staffUserLoading = ref(false)
+
+  function onStaffSelectionChange(rows: Api.BizCollectionRegionStaff.StaffItem[]) {
+    staffSelected.value = rows
+  }
+
+  function openStaffDialog(row: Api.BizCollectionRegion.RegionItem) {
+    currentRegionId.value = row.id
+    currentRegionName.value = row.regionName
+    staffDialogTitle.value = '地区人员配置'
+    staffPagination.page = 1
+    staffDialogVisible.value = true
+    loadStaff()
+  }
+
+  async function loadStaff() {
+    if (!currentRegionId.value) return
+    staffLoading.value = true
+    try {
+      const res = await fetchRegionStaffList(currentRegionId.value, {
+        page: staffPagination.page,
+        pageSize: staffPagination.pageSize
+      })
+      staffTable.value = res.list || []
+      staffPagination.total = res.total ?? 0
+    } finally {
+      staffLoading.value = false
+    }
+  }
+
+  async function remoteSearchUsers(query: string) {
+    staffUserLoading.value = true
+    try {
+      const res = await fetchUserList({
+        page: 1,
+        pageSize: 20,
+        keyword: query || undefined,
+        status: '1'
+      })
+      const bound = new Set(staffTable.value.map((s) => s.userId))
+      staffUserOptions.value = (res.list || []).filter((u) => !bound.has(u.id))
+    } finally {
+      staffUserLoading.value = false
+    }
+  }
+
+  async function submitAddStaff() {
+    if (!staffAddForm.userId) {
+      ElMessage.warning('请选择系统人员')
+      return
+    }
+    staffAddLoading.value = true
+    try {
+      await fetchCreateRegionStaff(currentRegionId.value, {
+        userId: staffAddForm.userId,
+        isLeader: staffAddForm.isLeader
+      })
+      ElMessage.success('添加成功')
+      staffAddVisible.value = false
+      staffAddForm.userId = ''
+      staffAddForm.isLeader = false
+      await loadStaff()
+      await loadData()
+    } catch (e: any) {
+      // request layer usually toasts; keep silent if so
+    } finally {
+      staffAddLoading.value = false
+    }
+  }
+
+  async function toggleStaffLeader(row: Api.BizCollectionRegionStaff.StaffItem) {
+    await fetchUpdateRegionStaff(currentRegionId.value, row.id, { isLeader: !row.isLeader })
+    ElMessage.success('已更新')
+    await loadStaff()
+    await loadData()
+  }
+
+  async function handleDeleteStaff(row: Api.BizCollectionRegionStaff.StaffItem) {
+    await ElMessageBox.confirm(`确定移除「${row.userName}」吗?`, '提示', { type: 'warning' })
+    await fetchDeleteRegionStaff(currentRegionId.value, row.id)
+    ElMessage.success('已移除')
+    await loadStaff()
+    await loadData()
+  }
+
+  async function handleBatchDeleteStaff() {
+    if (!staffSelected.value.length) return
+    await ElMessageBox.confirm(`确定移除选中的 ${staffSelected.value.length} 人吗?`, '提示', {
+      type: 'warning'
+    })
+    await withFullscreenLoading(async () => {
+      for (const row of staffSelected.value) {
+        await fetchDeleteRegionStaff(currentRegionId.value, row.id)
+      }
+    }, fullscreenLoadingText.delete)
+    ElMessage.success('已移除')
+    await loadStaff()
+    await loadData()
+  }
+```
+
+- [ ] **Step 5: Add staff dialog template**
+
+Place after hospital dialog (same structure as hospital config):
+
+```vue
+      <el-dialog
+        v-model="staffDialogVisible"
+        :title="staffDialogTitle"
+        width="860px"
+        class="dialog-form-layout"
+        align-center
+        append-to-body
+        modal-class="protocol-dialog-mask"
+        destroy-on-close
+        :close-on-click-modal="false"
+      >
+        <div class="hospital-dialog-head">
+          <span class="muted">当前地区:{{ currentRegionName }}</span>
+        </div>
+        <div class="list-toolbar hospital-toolbar">
+          <div class="list-toolbar-row">
+            <div class="list-toolbar-selected">
+              已选 <span class="sel-num">{{ staffSelected.length }}</span> 项
+            </div>
+            <el-button class="btn-main" type="primary" @click="staffAddVisible = true; remoteSearchUsers('')">
+              <el-icon><Plus /></el-icon>
+              添加人员
+            </el-button>
+            <el-button
+              class="btn-ghost"
+              type="danger"
+              :disabled="staffSelected.length === 0"
+              @click="handleBatchDeleteStaff"
+            >
+              <el-icon><Delete /></el-icon>
+              批量移除
+            </el-button>
+          </div>
+        </div>
+        <div class="list-body list-wrap hospital-table-wrap">
+          <el-table
+            class="list-table"
+            :data="staffTable"
+            v-loading="staffLoading"
+            stripe
+            row-key="id"
+            max-height="360"
+            @selection-change="onStaffSelectionChange"
+          >
+            <el-table-column type="selection" min-width="55" align="center" header-align="center" />
+            <el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
+            <el-table-column prop="userName" label="姓名" min-width="120" align="center" header-align="center" />
+            <el-table-column prop="userNo" label="工号" min-width="120" align="center" header-align="center" />
+            <el-table-column prop="deptName" label="部门" min-width="140" align="center" header-align="center">
+              <template #default="{ row }">
+                <span v-if="row.deptName">{{ row.deptName }}</span>
+                <span v-else class="muted">—</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="是否负责人" min-width="120" align="center" header-align="center">
+              <template #default="{ row }">
+                <el-tag v-if="row.isLeader" type="success" size="small">负责人</el-tag>
+                <el-tag v-else type="info" size="small">否</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" min-width="260" fixed="right" align="center" header-align="center">
+              <template #default="{ row }">
+                <el-button class="btn-op-item primary" @click="toggleStaffLeader(row)">
+                  {{ row.isLeader ? '取消负责人' : '设为负责人' }}
+                </el-button>
+                <el-button class="btn-op-item" @click="handleDeleteStaff(row)">移除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div class="pagination list-pager-bar">
+            <el-pagination
+              :current-page="staffPagination.page"
+              :page-size="staffPagination.pageSize"
+              :total="staffPagination.total"
+              :page-sizes="[10, 20, 50]"
+              layout="total, sizes, prev, pager, next"
+              @size-change="
+                (val) => (
+                  (staffPagination.page = 1), (staffPagination.pageSize = val), loadStaff()
+                )
+              "
+              @current-change="(val) => ((staffPagination.page = val), loadStaff())"
+            />
+          </div>
+        </div>
+
+        <el-dialog
+          v-model="staffAddVisible"
+          title="添加人员"
+          width="520px"
+          class="dialog-form-layout"
+          align-center
+          append-to-body
+          modal-class="protocol-dialog-mask"
+          destroy-on-close
+          :close-on-click-modal="false"
+        >
+          <el-form label-position="top">
+            <el-form-item label="系统人员" required>
+              <el-select
+                v-model="staffAddForm.userId"
+                filterable
+                remote
+                clearable
+                :remote-method="remoteSearchUsers"
+                :loading="staffUserLoading"
+                placeholder="搜索姓名/账号"
+                style="width: 100%"
+              >
+                <el-option
+                  v-for="u in staffUserOptions"
+                  :key="u.id"
+                  :label="`${u.nickName}(${u.userNo})`"
+                  :value="u.id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="设为负责人">
+              <el-switch v-model="staffAddForm.isLeader" />
+            </el-form-item>
+          </el-form>
+          <template #footer>
+            <el-button class="btn-ghost" @click="staffAddVisible = false">
+              <el-icon><Close /></el-icon>
+              取消
+            </el-button>
+            <el-button class="btn-main" type="primary" :loading="staffAddLoading" @click="submitAddStaff">
+              <el-icon><Check /></el-icon>
+              确定
+            </el-button>
+          </template>
+        </el-dialog>
+      </el-dialog>
+```
+
+- [ ] **Step 6: Manual UI verification**
+
+Open `http://localhost:3006/#/market/market-info/collection-region` (or current port):
+
+1. List shows 工作人员 / 负责人 columns.
+2. Open「地区人员配置」→ add user → appears in table; list summary updates.
+3. Toggle 负责人 → Tag updates on list.
+4. Add same user again → error toast.
+5. Remove / batch remove → confirm then gone.
+6. View dialog shows staff + leader.
+7. Soft-delete region → staff rows soft-deleted (DB check optional).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web-admin/src/views/basic-info/collection-region/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add region staff config dialog and list summaries
+
+EOF
+)"
+```
+
+---
+
+### Task 7: Graphify + final verification
+
+**Files:**
+- Run: `./scripts/update-graph.sh all` (or `backend` then `frontend`)
+
+- [ ] **Step 1: Update knowledge graph**
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 2: Re-run unit tests**
+
+```bash
+cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate
+pytest tests/test_collection_region_staff_helpers.py tests/test_api_crypto.py -v
+```
+
+Expected: all PASS.
+
+- [ ] **Step 3: Commit graphify outputs if changed**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out
+git commit -m "$(cat <<'EOF'
+chore: refresh graphify after region staff binding
+
+EOF
+)"
+```
+
+(Skip commit if no graphify diff.)
+
+---
+
+## Spec coverage checklist
+
+| Spec requirement | Task |
+|---|---|
+| Table `biz_collection_region_staff` + public fields | Task 1–2 |
+| Unique active (tenant, region, user) via app check | Task 3 `create_staff` |
+| Multi leader / zero leader | Task 3–6 |
+| One user, many regions | Task 3 (no cross-region uniqueness) |
+| Nested staff CRUD APIs | Task 4 |
+| List `staffNames` / `leaderNames` | Task 3–4 |
+| Soft-delete region cascades staff | Task 3 |
+| Enabled users only on create | Task 3 |
+| UI dialog symmetric to hospitals | Task 6 |
+| View dialog fields | Task 6 |
+| Out of scope (permissions / protocol defaults) | Not implemented (intentional) |
+
+## Placeholder / consistency self-review
+
+- No TBD steps.
+- Field names: API camelCase `userId` / `isLeader`; DB `user_id` / `is_leader`; TS `isLeader` — consistent across tasks.
+- `_region_to_dict` signature extended with optional staff args so existing hospital-only call sites remain valid.