|
@@ -0,0 +1,487 @@
|
|
|
|
|
+# 值班排班 ·「值班人员」角色筛选 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:** 仅「值班人员」角色下的启用用户可出现在排班配置下拉与公开抢班名单中;新指定/新抢班后端强制校验;历史 assignment 仍展示。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** 迁移插入固定 UUID 角色「值班人员」(无菜单)。在 `duty_schedule.py` 集中实现角色解析与用户过滤;管理端新增 `GET /web/admin/duty/users/options`;公开页 `users` 与 `grab`/`save_day` 复用同一校验。前端配置页改调值班专用 options。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus;pytest;MySQL 迁移脚本
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-17-duty-staff-role-design.md`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件结构
|
|
|
|
|
+
|
|
|
|
|
+| 操作 | 文件 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| Create | `backend/scripts/migrate_20260817_duty_staff_role.sql` | 幂等插入「值班人员」角色 |
|
|
|
|
|
+| Modify | `backend/app/models/duty_models.py` | 增加 `DUTY_STAFF_ROLE_ID` / `DUTY_STAFF_ROLE_NAME` 常量 |
|
|
|
|
|
+| Modify | `backend/app/services/duty_schedule.py` | 角色解析、名单过滤、指定/抢班校验;公开 users 复用 |
|
|
|
|
|
+| Modify | `backend/app/api/duty.py` | 注册 `GET /users/options` |
|
|
|
|
|
+| Modify | `backend/tests/test_duty_schedule.py` | 过滤/拒绝无角色;修补现有 grab mock |
|
|
|
|
|
+| Modify | `web-admin/src/api/duty.ts` | `fetchDutyUserOptions` |
|
|
|
|
|
+| Modify | `web-admin/src/views/duty/schedule/index.vue` | 改用专用 options + 文案提示 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: 迁移脚本插入「值班人员」角色
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `backend/scripts/migrate_20260817_duty_staff_role.sql`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 写迁移 SQL**
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- backend/scripts/migrate_20260817_duty_staff_role.sql
|
|
|
|
|
+-- 值班排班可排班名单角色:仅作标签,不挂菜单
|
|
|
|
|
+
|
|
|
|
|
+INSERT INTO `sys_role` (
|
|
|
|
|
+ `id`, `role_name`, `data_scope`, `status`, `tenant_no`, `order_num`, `remark`,
|
|
|
|
|
+ `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
|
|
|
|
+) VALUES (
|
|
|
|
|
+ 'R65CAAE057914CCCAE2A81BE627A5101',
|
|
|
|
|
+ '值班人员',
|
|
|
|
|
+ '4',
|
|
|
|
|
+ '1',
|
|
|
|
|
+ 'shanxia2.0',
|
|
|
|
|
+ 90,
|
|
|
|
|
+ '值班排班可指定/抢班名单;无菜单权限',
|
|
|
|
|
+ 'system',
|
|
|
|
|
+ '',
|
|
|
|
|
+ NOW(),
|
|
|
|
|
+ NULL,
|
|
|
|
|
+ NOW(),
|
|
|
|
|
+ 0
|
|
|
|
|
+)
|
|
|
|
|
+ON DUPLICATE KEY UPDATE
|
|
|
|
|
+ `role_name` = VALUES(`role_name`),
|
|
|
|
|
+ `status` = VALUES(`status`),
|
|
|
|
|
+ `remark` = VALUES(`remark`),
|
|
|
|
|
+ `del_flag` = 0;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add backend/scripts/migrate_20260817_duty_staff_role.sql
|
|
|
|
|
+git commit -m "chore(db): add duty staff role for schedule eligibility"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: 服务层常量 + 角色解析 / 名单过滤(TDD)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `backend/app/models/duty_models.py`
|
|
|
|
|
+- Modify: `backend/app/services/duty_schedule.py`
|
|
|
|
|
+- Modify: `backend/tests/test_duty_schedule.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 `duty_models.py` 增加常量(文件顶部常量区附近)**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# 可排班名单角色(sys_role 固定 id;无菜单,仅标签)
|
|
|
|
|
+DUTY_STAFF_ROLE_ID = "R65CAAE057914CCCAE2A81BE627A5101"
|
|
|
|
|
+DUTY_STAFF_ROLE_NAME = "值班人员"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 写失败单测(追加到 `test_duty_schedule.py`)**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from app.models.duty_models import DUTY_STAFF_ROLE_ID, DUTY_STAFF_ROLE_NAME
|
|
|
|
|
+from app.services.duty_schedule import (
|
|
|
|
|
+ assert_user_is_duty_staff,
|
|
|
|
|
+ list_duty_staff_users,
|
|
|
|
|
+ resolve_duty_staff_role_id,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_resolve_duty_staff_role_id_by_fixed_id():
|
|
|
|
|
+ db = MagicMock()
|
|
|
|
|
+ role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID, role_name=DUTY_STAFF_ROLE_NAME, del_flag=0)
|
|
|
|
|
+ q = MagicMock()
|
|
|
|
|
+ q.filter.return_value.first.return_value = role
|
|
|
|
|
+ db.query.return_value = q
|
|
|
|
|
+ assert resolve_duty_staff_role_id(db, "shanxia2.0") == DUTY_STAFF_ROLE_ID
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_list_duty_staff_users_empty_when_no_role():
|
|
|
|
|
+ db = MagicMock()
|
|
|
|
|
+ q = MagicMock()
|
|
|
|
|
+ q.filter.return_value.first.return_value = None # resolve role → None twice
|
|
|
|
|
+ db.query.return_value = q
|
|
|
|
|
+ assert list_duty_staff_users(db, "t1") == []
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_assert_user_is_duty_staff_rejects_without_role():
|
|
|
|
|
+ db = MagicMock()
|
|
|
|
|
+ # resolve role id
|
|
|
|
|
+ role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
|
|
|
|
|
+ role_q = MagicMock()
|
|
|
|
|
+ role_q.filter.return_value.first.return_value = role
|
|
|
|
|
+ # user-role link missing
|
|
|
|
|
+ ur_q = MagicMock()
|
|
|
|
|
+ ur_q.filter.return_value.first.return_value = None
|
|
|
|
|
+
|
|
|
|
|
+ def query_side_effect(model):
|
|
|
|
|
+ name = getattr(model, "__name__", "")
|
|
|
|
|
+ if name == "SysRole":
|
|
|
|
|
+ return role_q
|
|
|
|
|
+ if name == "SysUserRole":
|
|
|
|
|
+ return ur_q
|
|
|
|
|
+ return MagicMock()
|
|
|
|
|
+
|
|
|
|
|
+ db.query.side_effect = query_side_effect
|
|
|
|
|
+ with pytest.raises(DutyBizError, match="值班人员"):
|
|
|
|
|
+ assert_user_is_duty_staff(db, "t1", "u1")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_grab_rejects_non_duty_staff():
|
|
|
|
|
+ db = MagicMock()
|
|
|
|
|
+ day = SimpleNamespace(
|
|
|
|
|
+ id="day1",
|
|
|
|
|
+ tenant_no="t1",
|
|
|
|
|
+ link_status=LINK_OPEN,
|
|
|
|
|
+ duty_date=date(2026, 8, 20),
|
|
|
|
|
+ access_token="tok",
|
|
|
|
|
+ del_flag=0,
|
|
|
|
|
+ )
|
|
|
|
|
+ user = SimpleNamespace(
|
|
|
|
|
+ id="u1",
|
|
|
|
|
+ tenant_no="t1",
|
|
|
|
|
+ nick_name="李四",
|
|
|
|
|
+ user_no="lisi",
|
|
|
|
|
+ status="1",
|
|
|
|
|
+ del_flag=0,
|
|
|
|
|
+ )
|
|
|
|
|
+ day_q = MagicMock()
|
|
|
|
|
+ day_q.filter.return_value.with_for_update.return_value.first.return_value = day
|
|
|
|
|
+ user_q = MagicMock()
|
|
|
|
|
+ user_q.filter.return_value.first.return_value = user
|
|
|
|
|
+ role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
|
|
|
|
|
+ role_q = MagicMock()
|
|
|
|
|
+ role_q.filter.return_value.first.return_value = role
|
|
|
|
|
+ ur_q = MagicMock()
|
|
|
|
|
+ ur_q.filter.return_value.first.return_value = None
|
|
|
|
|
+
|
|
|
|
|
+ def query_side_effect(model):
|
|
|
|
|
+ name = getattr(model, "__name__", "")
|
|
|
|
|
+ if name == "DutyDay":
|
|
|
|
|
+ return day_q
|
|
|
|
|
+ if name == "SysUser":
|
|
|
|
|
+ return user_q
|
|
|
|
|
+ if name == "SysRole":
|
|
|
|
|
+ return role_q
|
|
|
|
|
+ if name == "SysUserRole":
|
|
|
|
|
+ return ur_q
|
|
|
|
|
+ return MagicMock()
|
|
|
|
|
+
|
|
|
|
|
+ db.query.side_effect = query_side_effect
|
|
|
|
|
+ with pytest.raises(DutyBizError, match="值班人员"):
|
|
|
|
|
+ grab(db, "tok", SHIFT_EVENING, "u1")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 跑测确认失败**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py::test_resolve_duty_staff_role_id_by_fixed_id tests/test_duty_schedule.py::test_list_duty_staff_users_empty_when_no_role tests/test_duty_schedule.py::test_assert_user_is_duty_staff_rejects_without_role tests/test_duty_schedule.py::test_grab_rejects_non_duty_staff -v
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: FAIL(函数未定义或 grab 未校验角色)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 实现服务函数(`duty_schedule.py`)**
|
|
|
|
|
+
|
|
|
|
|
+在文件顶部增加导入:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from app.models.duty_models import (
|
|
|
|
|
+ # ...existing...
|
|
|
|
|
+ DUTY_STAFF_ROLE_ID,
|
|
|
|
|
+ DUTY_STAFF_ROLE_NAME,
|
|
|
|
|
+)
|
|
|
|
|
+from app.models.models import SysRole, SysUser, SysUserRole
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(若已有 `SysUser` 导入则合并,勿重复。)
|
|
|
|
|
+
|
|
|
|
|
+新增:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def resolve_duty_staff_role_id(db: Session, tenant_no: str) -> Optional[str]:
|
|
|
|
|
+ """按固定 id 查找「值班人员」角色;找不到再按角色名兜底。"""
|
|
|
|
|
+ role = (
|
|
|
|
|
+ db.query(SysRole)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysRole.id == DUTY_STAFF_ROLE_ID,
|
|
|
|
|
+ SysRole.tenant_no == tenant_no,
|
|
|
|
|
+ SysRole.del_flag == 0,
|
|
|
|
|
+ )
|
|
|
|
|
+ .first()
|
|
|
|
|
+ )
|
|
|
|
|
+ if role:
|
|
|
|
|
+ return role.id
|
|
|
|
|
+ role = (
|
|
|
|
|
+ db.query(SysRole)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysRole.role_name == DUTY_STAFF_ROLE_NAME,
|
|
|
|
|
+ SysRole.tenant_no == tenant_no,
|
|
|
|
|
+ SysRole.del_flag == 0,
|
|
|
|
|
+ SysRole.status == "1",
|
|
|
|
|
+ )
|
|
|
|
|
+ .first()
|
|
|
|
|
+ )
|
|
|
|
|
+ return role.id if role else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def list_duty_staff_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
|
|
|
|
|
+ """本租户启用且绑定「值班人员」角色的用户(公开页 users / 管理端 options)。"""
|
|
|
|
|
+ role_id = resolve_duty_staff_role_id(db, tenant_no)
|
|
|
|
|
+ if not role_id:
|
|
|
|
|
+ return []
|
|
|
|
|
+ rows = (
|
|
|
|
|
+ db.query(SysUser)
|
|
|
|
|
+ .join(
|
|
|
|
|
+ SysUserRole,
|
|
|
|
|
+ and_(
|
|
|
|
|
+ SysUserRole.user_id == SysUser.id,
|
|
|
|
|
+ SysUserRole.role_id == role_id,
|
|
|
|
|
+ SysUserRole.del_flag == 0,
|
|
|
|
|
+ SysUserRole.tenant_no == tenant_no,
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysUser.tenant_no == tenant_no,
|
|
|
|
|
+ SysUser.del_flag == 0,
|
|
|
|
|
+ SysUser.status == "1",
|
|
|
|
|
+ )
|
|
|
|
|
+ .order_by(SysUser.nick_name.asc(), SysUser.user_no.asc())
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ out: List[Dict[str, str]] = []
|
|
|
|
|
+ seen = set()
|
|
|
|
|
+ for r in rows:
|
|
|
|
|
+ if r.id in seen:
|
|
|
|
|
+ continue
|
|
|
|
|
+ seen.add(r.id)
|
|
|
|
|
+ label = (r.nick_name or "").strip() or (r.user_no or "").strip()
|
|
|
|
|
+ if not label:
|
|
|
|
|
+ continue
|
|
|
|
|
+ out.append({"id": r.id, "label": label})
|
|
|
|
|
+ return out
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def assert_user_is_duty_staff(db: Session, tenant_no: str, user_id: str) -> None:
|
|
|
|
|
+ role_id = resolve_duty_staff_role_id(db, tenant_no)
|
|
|
|
|
+ if not role_id:
|
|
|
|
|
+ raise DutyBizError("未配置「值班人员」角色,请联系管理员")
|
|
|
|
|
+ link = (
|
|
|
|
|
+ db.query(SysUserRole)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysUserRole.user_id == user_id,
|
|
|
|
|
+ SysUserRole.role_id == role_id,
|
|
|
|
|
+ SysUserRole.tenant_no == tenant_no,
|
|
|
|
|
+ SysUserRole.del_flag == 0,
|
|
|
|
|
+ )
|
|
|
|
|
+ .first()
|
|
|
|
|
+ )
|
|
|
|
|
+ if not link:
|
|
|
|
|
+ raise DutyBizError("仅可选择「值班人员」角色的用户")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+确保 `from sqlalchemy import and_`(若尚未导入)。
|
|
|
|
|
+
|
|
|
|
|
+将 `_list_public_users` 改为:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
|
|
|
|
|
+ return list_duty_staff_users(db, tenant_no)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `_resolve_grab_user` 成功查到 user 后调用:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ assert_user_is_duty_staff(db, tenant_no, user.id)
|
|
|
|
|
+ return user
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `save_day` 写入每个 `SOURCE_ASSIGN` 且有 `uid` 时校验(写入 `db.add(DutyAssignment(...))` 之前):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ if item.source == SOURCE_ASSIGN and uid:
|
|
|
|
|
+ assert_user_is_duty_staff(db, tenant_no, uid)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+更新 `__all__` 导出:`list_duty_staff_users`, `assert_user_is_duty_staff`, `resolve_duty_staff_role_id`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 修补现有会查到用户的 grab 单测**,在 mock 中让 `SysUserRole.filter...first` 返回非空对象(例如 `SimpleNamespace(id="ur1")`),并为 `SysRole` 返回固定角色;否则 `test_grab_rejects_when_full` / `test_grab_rejects_duplicate_same_shift` 会先因无角色失败。
|
|
|
|
|
+
|
|
|
|
|
+辅助写法(两处 `query_side_effect` 内增加):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ if name == "SysRole":
|
|
|
|
|
+ rq = MagicMock()
|
|
|
|
|
+ rq.filter.return_value.first.return_value = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
|
|
|
|
|
+ return rq
|
|
|
|
|
+ if name == "SysUserRole":
|
|
|
|
|
+ urq = MagicMock()
|
|
|
|
|
+ urq.filter.return_value.first.return_value = SimpleNamespace(id="ur1")
|
|
|
|
|
+ return urq
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 跑全文件测试**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: 全部 PASS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add backend/app/models/duty_models.py backend/app/services/duty_schedule.py backend/tests/test_duty_schedule.py
|
|
|
|
|
+git commit -m "feat(duty): restrict assign and grab to duty staff role"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: 管理端 options API
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `backend/app/api/duty.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 增加路由(放在 `month_summary` 之前或之后均可)**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+@router.get("/users/options")
|
|
|
|
|
+def duty_user_options(
|
|
|
|
|
+ _user: dict = Depends(require_admin_or_super_admin),
|
|
|
|
|
+ db: Session = Depends(get_db),
|
|
|
|
|
+):
|
|
|
|
|
+ """值班可安排人员:仅「值班人员」角色下的启用用户。"""
|
|
|
|
|
+ tenant_no = _user.get("tenant_no", "") or ""
|
|
|
|
|
+ rows = duty_svc.list_duty_staff_users(db, tenant_no)
|
|
|
|
|
+ out = [{"label": r["label"], "value": r["id"]} for r in rows]
|
|
|
|
|
+ return success_response(out)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 手动冒烟(可选,需本地库已跑迁移)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# 执行迁移后,用已登录 admin token 请求
|
|
|
|
|
+# GET /api/web/admin/duty/users/options
|
|
|
|
|
+# 期望:未赋角色时 [];赋角色后出现该用户
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add backend/app/api/duty.py
|
|
|
|
|
+git commit -m "feat(duty): add users/options endpoint filtered by duty staff role"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: 前端改用专用 options + 文案
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `web-admin/src/api/duty.ts`
|
|
|
|
|
+- Modify: `web-admin/src/views/duty/schedule/index.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 `duty.ts` 增加**
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+export function fetchDutyUserOptions() {
|
|
|
|
|
+ return request.get<Array<{ label: string; value: string }>>({
|
|
|
|
|
+ url: '/web/admin/duty/users/options'
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 改 `schedule/index.vue`**
|
|
|
|
|
+
|
|
|
|
|
+将:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { fetchUserOptions } from '@/api/admin'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+改为从 `@/api/duty` 引入 `fetchDutyUserOptions`(与其它 duty API 合并 import)。
|
|
|
|
|
+
|
|
|
|
|
+`onMounted` 内:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+const opts = await fetchDutyUserOptions()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+表单项 label / tip:
|
|
|
|
|
+
|
|
|
|
|
+- `label`:`直接安排(值班人员)`
|
|
|
|
|
+- 或保留 label,在 `form-tip` 追加一句:`直接安排与公开链接仅可选择已分配「值班人员」角色的用户。`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 本地确认**
|
|
|
|
|
+
|
|
|
|
|
+1. 用户管理给某用户勾选「值班人员」并保存
|
|
|
|
|
+2. 打开值班配置弹窗:下拉仅见该用户(及同角色用户)
|
|
|
|
|
+3. 公开链接下拉同样仅见该角色用户
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add web-admin/src/api/duty.ts web-admin/src/views/duty/schedule/index.vue
|
|
|
|
|
+git commit -m "feat(web-admin): use duty staff options in schedule dialog"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: 运维核对与收尾
|
|
|
|
|
+
|
|
|
|
|
+**Files:** 无代码,或按需在 `docs/sql/shanxia2.0.sql` 备注/追加角色行(若仓库要求同步全量 SQL;本任务以执行 migrate 脚本为准,**不要**为同步而大改全量 dump,除非团队惯例要求)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在目标库执行迁移**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# 按本地习惯执行,例如:
|
|
|
|
|
+mysql ... < backend/scripts/migrate_20260817_duty_staff_role.sql
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 核对清单**
|
|
|
|
|
+
|
|
|
|
|
+| 检查项 | 期望 |
|
|
|
|
|
+|--------|------|
|
|
|
|
|
+| 角色管理可见「值班人员」 | 是,无菜单 |
|
|
|
|
|
+| 全局 `/users/options` | 仍返回全部用户 |
|
|
|
|
|
+| 值班 `/duty/users/options` | 仅值班人员 |
|
|
|
|
|
+| 公开页 users | 仅值班人员 |
|
|
|
|
|
+| 历史无角色 assignment | 仍显示 |
|
|
|
|
|
+| `pytest tests/test_duty_schedule.py` | 全绿 |
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 若有 graphify 约定,仅在改了代码文件后更新**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+./scripts/update-graph.sh backend
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(前端仅 API/文案小改时可选 `frontend`。)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Spec 覆盖自检
|
|
|
|
|
+
|
|
|
|
|
+| Spec 项 | Task |
|
|
|
|
|
+|---------|------|
|
|
|
|
|
+| D1 新建「值班人员」 | Task 1 |
|
|
|
|
|
+| D2 无菜单、用户管理勾选 | Task 1(无 role_menu) |
|
|
|
|
|
+| D3 管理端+公开页过滤 | Task 2–4 |
|
|
|
|
|
+| D4 历史仍显示 | Task 2(不删 assignment) |
|
|
|
|
|
+| D5 后端强制校验 | Task 2 `assert_user_is_duty_staff` |
|
|
|
|
|
+| D6 专用 options、不改全局 | Task 3–4 |
|
|
|
|
|
+| D7 不自动赋角色 | Task 1 无 user_role 插入 |
|
|
|
|
|
+| 单测 | Task 2 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 执行说明
|
|
|
|
|
+
|
|
|
|
|
+实现时按 Task 顺序;每完成一个 Task 勾选步骤并提交。现有 grab 单测必须在 Task 2 Step 5 同步修补,否则会因新增角色校验而失败。
|