|
|
@@ -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.
|