diff --git a/docs/superpowers/plans/2026-09-18-collection-region-staff-binding.md b/docs/superpowers/plans/2026-09-18-collection-region-staff-binding.md new file mode 100644 index 00000000..ff6d04a7 --- /dev/null +++ b/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 -p < ../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": "", "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>({ + 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 + + + + + + +``` + +Widen操作列 `min-width` to `300` and add button: + +```vue + + 地区人员配置 + +``` + +- [ ] **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([]) + const staffSelected = ref([]) + 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([]) + 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 + +
+ 当前地区:{{ currentRegionName }} +
+
+
+
+ 已选 {{ staffSelected.length }} 项 +
+ + + 添加人员 + + + + 批量移除 + +
+
+
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
+``` + +- [ ] **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.