|
|
@@ -0,0 +1,644 @@
|
|
|
+# 值班排班(日历 + 公开链接抢班)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:** 在 web-admin 提供按日月历排班(预置早/晚/值班、名额、指定系统用户、复制/关闭按日公开链接);员工打开 `/#/public/duty/{token}` 免登录填姓名抢班,满员拒绝;支持历史翻月查看。
|
|
|
+
|
|
|
+**Architecture:** 三表 `duty_day` / `duty_slot` / `duty_assignment`;管理 API 挂 `/api/web/admin/duty`;公开 API 挂 `/api/public/duty`(加密排除);公开页复用 `publicPlGet`/`publicPlPost` 与 `isPublicMobile` 静态路由。业务规则集中在 `app/services/duty_schedule.py`。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + SCSS(Fresh Mint 管理端 / 三墙 Token 公开 H5);pytest
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-14-duty-schedule-design.md`
|
|
|
+**UI 原型:** `docs/demos/duty-schedule-v1-demo.html`、`docs/demos/duty-h5-grab-demo.html`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 操作 | 文件 | 职责 |
|
|
|
+|------|------|------|
|
|
|
+| Create | `backend/scripts/migrate_20260814_duty_schedule.sql` | 建表 + 菜单 |
|
|
|
+| Create | `backend/app/models/duty_models.py` | ORM |
|
|
|
+| Create | `backend/app/schemas/duty.py` | 请求/响应 schema |
|
|
|
+| Create | `backend/app/services/duty_schedule.py` | 保存日配置、抢班、关链、序列化 |
|
|
|
+| Create | `backend/app/api/duty.py` | 管理端路由 |
|
|
|
+| Create | `backend/app/api/public_duty.py` | 公开路由 |
|
|
|
+| Create | `backend/tests/test_duty_schedule.py` | 名额/关链/缩容单测 |
|
|
|
+| Modify | `backend/app/main.py` | `include_router` |
|
|
|
+| Modify | `backend/app/core/config.py` | `API_ENCRYPT_EXCLUDE_PATHS` |
|
|
|
+| Create | `web-admin/src/api/duty.ts` | 管理端 API |
|
|
|
+| Create | `web-admin/src/api/public-duty.ts` | 公开 API |
|
|
|
+| Create | `web-admin/src/views/duty/schedule/index.vue` | 月历 + 日配置弹窗 |
|
|
|
+| Create | `web-admin/src/views/public/duty/index.vue` | 公开 H5 |
|
|
|
+| Modify | `web-admin/src/router/routes/staticRoutes.ts` | 公开路由 |
|
|
|
+| Modify | `web-admin/.env.development` / `.env.production`(若有独立排除列表) | 排除公开 path |
|
|
|
+| Sync | `docs/sql/` | 将 migrate 表结构并入基线说明或追加脚本备注 |
|
|
|
+
|
|
|
+**Shift 常量(全栈统一):** `morning` / `evening` / `duty` → 文案 早班 / 晚班 / 值班。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: SQL 迁移(三表 + 菜单)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/scripts/migrate_20260814_duty_schedule.sql`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写迁移 SQL**
|
|
|
+
|
|
|
+```sql
|
|
|
+-- backend/scripts/migrate_20260814_duty_schedule.sql
|
|
|
+-- 值班排班 V1:按日链接 + 班次名额 + 报名
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS `duty_day` (
|
|
|
+ `id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
|
|
+ `duty_date` date NOT NULL COMMENT '值班日期',
|
|
|
+ `access_token` varchar(64) NOT NULL COMMENT '公开访问令牌',
|
|
|
+ `link_status` varchar(16) NOT NULL DEFAULT 'open' COMMENT 'open/closed',
|
|
|
+ `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
|
|
+ `order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
|
|
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
|
|
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
|
|
+ `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
|
|
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
+ `update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
|
|
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
+ `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
+ UNIQUE KEY `uk_token` (`access_token`),
|
|
|
+ KEY `idx_tenant_date` (`tenant_no`, `duty_date`, `del_flag`)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='值班日(一天一条公开链接)';
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS `duty_slot` (
|
|
|
+ `id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
|
|
+ `duty_day_id` varchar(64) NOT NULL COMMENT '值班日ID',
|
|
|
+ `shift_type` varchar(32) NOT NULL COMMENT 'morning/evening/duty',
|
|
|
+ `capacity` int(11) NOT NULL DEFAULT 1 COMMENT '名额',
|
|
|
+ `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
|
|
+ `order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
|
|
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
|
|
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
|
|
+ `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
|
|
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
+ `update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
|
|
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
+ `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
+ KEY `idx_day` (`duty_day_id`, `del_flag`),
|
|
|
+ KEY `idx_tenant` (`tenant_no`)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='值班班次名额';
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS `duty_assignment` (
|
|
|
+ `id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
|
|
+ `duty_slot_id` varchar(64) NOT NULL COMMENT '班次名额ID',
|
|
|
+ `source` varchar(16) NOT NULL COMMENT 'assign/grab',
|
|
|
+ `user_id` varchar(64) DEFAULT NULL COMMENT '系统用户ID(指定时)',
|
|
|
+ `person_name` varchar(64) NOT NULL COMMENT '展示姓名',
|
|
|
+ `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
|
|
+ `order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
|
|
+ `remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
|
|
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
|
|
+ `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
|
|
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
|
|
+ `update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
|
|
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
+ `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
|
|
+ PRIMARY KEY (`id`),
|
|
|
+ KEY `idx_slot` (`duty_slot_id`, `del_flag`),
|
|
|
+ KEY `idx_tenant` (`tenant_no`)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='值班占位(指定或抢班)';
|
|
|
+
|
|
|
+-- 菜单:挂在系统管理或独立一级均可;此处挂 Web 虚拟根下「值班排班」
|
|
|
+-- parent_id 请按环境库 Web 根菜单 id 调整(查询 parent_id=0 且面向 Web 的根)
|
|
|
+INSERT INTO `sys_menu` (
|
|
|
+ `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
|
|
+ `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
|
|
+ `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
|
|
+) VALUES (
|
|
|
+ 'DUTY000000000000000000000000001',
|
|
|
+ '值班排班',
|
|
|
+ '0',
|
|
|
+ 'C',
|
|
|
+ 'duty-schedule',
|
|
|
+ '/duty/schedule/index',
|
|
|
+ 'duty:schedule:list',
|
|
|
+ 1, '1', '1', 'shanxia2.0', 90,
|
|
|
+ '日历排班与公开抢班链接',
|
|
|
+ 'system', '', NOW(), NULL, NOW(), 0
|
|
|
+)
|
|
|
+ON DUPLICATE KEY UPDATE
|
|
|
+ `menu_name`=VALUES(`menu_name`),
|
|
|
+ `component`=VALUES(`component`),
|
|
|
+ `perms`=VALUES(`perms`),
|
|
|
+ `path`=VALUES(`path`);
|
|
|
+```
|
|
|
+
|
|
|
+执行前:查出现网 Web 菜单父级,将 `parent_id='0'` 改为正确父菜单 id(勿做成孤儿叶子;若 `0` 为虚拟根且项目允许顶栏入口,按门户习惯调整)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 在本地库执行迁移**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate
|
|
|
+# 用项目惯用方式执行 SQL,例如 mysql client 指向 .env.local
|
|
|
+mysql ... < scripts/migrate_20260814_duty_schedule.sql
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 三表创建成功;菜单可见(刷新权限后)。
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/scripts/migrate_20260814_duty_schedule.sql
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+chore(duty): add duty_day/slot/assignment tables and menu SQL
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: ORM 模型
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/models/duty_models.py`
|
|
|
+- Modify: 若项目有 `models/__init__.py` 导出习惯则一并导出
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建模型(对齐 bx 公共字段风格)**
|
|
|
+
|
|
|
+```python
|
|
|
+"""值班排班模型 — docs/superpowers/specs/2026-08-14-duty-schedule-design.md"""
|
|
|
+from datetime import datetime
|
|
|
+
|
|
|
+from sqlalchemy import Column, Date, DateTime, Integer, SmallInteger, String
|
|
|
+
|
|
|
+from app.core.database import Base
|
|
|
+
|
|
|
+SHIFT_MORNING = "morning"
|
|
|
+SHIFT_EVENING = "evening"
|
|
|
+SHIFT_DUTY = "duty"
|
|
|
+SHIFT_LABELS = {
|
|
|
+ SHIFT_MORNING: "早班",
|
|
|
+ SHIFT_EVENING: "晚班",
|
|
|
+ SHIFT_DUTY: "值班",
|
|
|
+}
|
|
|
+LINK_OPEN = "open"
|
|
|
+LINK_CLOSED = "closed"
|
|
|
+SOURCE_ASSIGN = "assign"
|
|
|
+SOURCE_GRAB = "grab"
|
|
|
+
|
|
|
+
|
|
|
+class DutyDay(Base):
|
|
|
+ __tablename__ = "duty_day"
|
|
|
+
|
|
|
+ id = Column(String(64), primary_key=True)
|
|
|
+ duty_date = Column(Date, nullable=False)
|
|
|
+ access_token = Column(String(64), nullable=False)
|
|
|
+ link_status = Column(String(16), nullable=False, default=LINK_OPEN)
|
|
|
+ tenant_no = Column(String(64), nullable=False, default="shanxia2.0")
|
|
|
+ order_num = Column(Integer, nullable=True)
|
|
|
+ remark = Column(String(500), nullable=True)
|
|
|
+ create_by = Column(String(64), nullable=True)
|
|
|
+ create_dept_id = Column(String(64), nullable=False, default="")
|
|
|
+ create_time = Column(DateTime, nullable=False, default=datetime.now)
|
|
|
+ update_by = Column(String(64), nullable=True)
|
|
|
+ update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
|
|
+ del_flag = Column(SmallInteger, nullable=False, default=0)
|
|
|
+
|
|
|
+
|
|
|
+class DutySlot(Base):
|
|
|
+ __tablename__ = "duty_slot"
|
|
|
+
|
|
|
+ id = Column(String(64), primary_key=True)
|
|
|
+ duty_day_id = Column(String(64), nullable=False)
|
|
|
+ shift_type = Column(String(32), nullable=False)
|
|
|
+ capacity = Column(Integer, nullable=False, default=1)
|
|
|
+ tenant_no = Column(String(64), nullable=False, default="shanxia2.0")
|
|
|
+ order_num = Column(Integer, nullable=True)
|
|
|
+ remark = Column(String(500), nullable=True)
|
|
|
+ create_by = Column(String(64), nullable=True)
|
|
|
+ create_dept_id = Column(String(64), nullable=False, default="")
|
|
|
+ create_time = Column(DateTime, nullable=False, default=datetime.now)
|
|
|
+ update_by = Column(String(64), nullable=True)
|
|
|
+ update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
|
|
+ del_flag = Column(SmallInteger, nullable=False, default=0)
|
|
|
+
|
|
|
+
|
|
|
+class DutyAssignment(Base):
|
|
|
+ __tablename__ = "duty_assignment"
|
|
|
+
|
|
|
+ id = Column(String(64), primary_key=True)
|
|
|
+ duty_slot_id = Column(String(64), nullable=False)
|
|
|
+ source = Column(String(16), nullable=False)
|
|
|
+ user_id = Column(String(64), nullable=True)
|
|
|
+ person_name = Column(String(64), nullable=False)
|
|
|
+ tenant_no = Column(String(64), nullable=False, default="shanxia2.0")
|
|
|
+ order_num = Column(Integer, nullable=True)
|
|
|
+ remark = Column(String(500), nullable=True)
|
|
|
+ create_by = Column(String(64), nullable=True)
|
|
|
+ create_dept_id = Column(String(64), nullable=False, default="")
|
|
|
+ create_time = Column(DateTime, nullable=False, default=datetime.now)
|
|
|
+ update_by = Column(String(64), nullable=True)
|
|
|
+ update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
|
|
+ del_flag = Column(SmallInteger, nullable=False, default=0)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/models/duty_models.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(duty): add SQLAlchemy models for duty schedule
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Schemas + 领域服务(含 TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/schemas/duty.py`
|
|
|
+- Create: `backend/app/services/duty_schedule.py`
|
|
|
+- Create: `backend/tests/test_duty_schedule.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测(纯函数/可测逻辑优先)**
|
|
|
+
|
|
|
+在 `test_duty_schedule.py` 先测容量校验与姓名规范化(不依赖 DB 的部分),以及用 SQLite/fixture 测 `grab` 满员(若项目测试多用 mock Session,则测 service 内 `_assert_capacity` / `normalize_person_name`)。
|
|
|
+
|
|
|
+最小必测:
|
|
|
+
|
|
|
+```python
|
|
|
+import pytest
|
|
|
+from app.services.duty_schedule import normalize_person_name, SHIFT_LABELS
|
|
|
+from app.models.duty_models import SHIFT_MORNING
|
|
|
+
|
|
|
+
|
|
|
+def test_normalize_person_name_ok():
|
|
|
+ assert normalize_person_name(" 张三 ") == "张三"
|
|
|
+
|
|
|
+
|
|
|
+def test_normalize_person_name_empty():
|
|
|
+ with pytest.raises(ValueError, match="姓名"):
|
|
|
+ normalize_person_name(" ")
|
|
|
+
|
|
|
+
|
|
|
+def test_normalize_person_name_too_long():
|
|
|
+ with pytest.raises(ValueError, match="姓名"):
|
|
|
+ normalize_person_name("啊" * 21)
|
|
|
+
|
|
|
+
|
|
|
+def test_shift_labels():
|
|
|
+ assert SHIFT_LABELS[SHIFT_MORNING] == "早班"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败(函数尚未存在)**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL import / not defined
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 schemas**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/app/schemas/duty.py
|
|
|
+from __future__ import annotations
|
|
|
+from datetime import date
|
|
|
+from typing import List, Literal, Optional
|
|
|
+from pydantic import BaseModel, Field
|
|
|
+
|
|
|
+ShiftType = Literal["morning", "evening", "duty"]
|
|
|
+LinkStatus = Literal["open", "closed"]
|
|
|
+SourceType = Literal["assign", "grab"]
|
|
|
+
|
|
|
+
|
|
|
+class DutyAssignmentIn(BaseModel):
|
|
|
+ source: SourceType = "assign"
|
|
|
+ userId: Optional[str] = None
|
|
|
+ personName: str = ""
|
|
|
+
|
|
|
+
|
|
|
+class DutySlotSaveIn(BaseModel):
|
|
|
+ shiftType: ShiftType
|
|
|
+ capacity: int = Field(ge=1, le=99)
|
|
|
+ assignments: List[DutyAssignmentIn] = []
|
|
|
+
|
|
|
+
|
|
|
+class DutyDaySaveBody(BaseModel):
|
|
|
+ dutyDate: date
|
|
|
+ slots: List[DutySlotSaveIn]
|
|
|
+ linkStatus: Optional[LinkStatus] = None
|
|
|
+
|
|
|
+
|
|
|
+class DutyGrabBody(BaseModel):
|
|
|
+ shiftType: ShiftType
|
|
|
+ personName: str
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 实现 service 核心**
|
|
|
+
|
|
|
+`duty_schedule.py` 必须提供:
|
|
|
+
|
|
|
+- `normalize_person_name(name: str) -> str`
|
|
|
+- `new_access_token() -> str`(`secrets.token_urlsafe(24)`,截断到 ≤64)
|
|
|
+- `build_public_url(token: str) -> str` → `f"{settings.PUBLIC_WEB_ORIGIN.rstrip('/')}/#/public/duty/{token}"`
|
|
|
+- `get_month_summary(db, tenant_no, year, month) -> list`
|
|
|
+- `get_day_detail(db, tenant_no, duty_date|day_id) -> dict`
|
|
|
+- `save_day(db, tenant_no, user, body: DutyDaySaveBody) -> dict`
|
|
|
+ - 校验:每个 slot `len(assignments) <= capacity`;停用已有报名的 shift 须先清空
|
|
|
+ - upsert day(按 tenant+date);token 仅首次生成
|
|
|
+ - soft-delete 去掉的 slots(无报名时)与多余 assignments
|
|
|
+- `set_link_status(db, tenant_no, day_id, status)`
|
|
|
+- `soft_delete_assignment(db, tenant_no, assignment_id)`
|
|
|
+- `get_public_page(db, token) -> dict`(无效 404 语义)
|
|
|
+- `grab(db, token, shift_type, person_name) -> dict`
|
|
|
+ - 关链拒绝;姓名规范化;`with_for_update` 锁 slot 行或 day 行;`count < capacity` 再 insert `source=grab`
|
|
|
+
|
|
|
+序列化字段用 camelCase,与前端一致:`dutyDate`, `linkStatus`, `accessToken`, `publicUrl`, `shiftType`, `shiftLabel`, `capacity`, `taken`, `assignments: [{ id, source, userId, personName }]`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 补充 DB 级单测(可用现有测试 DB fixture;若无 fixture,则 mock Session 测分支)**
|
|
|
+
|
|
|
+至少覆盖:
|
|
|
+
|
|
|
+1. `grab` 在 capacity=1 时第二次失败
|
|
|
+2. `link_status=closed` 时 grab 失败
|
|
|
+3. `save_day` 缩容小于已报名人数抛错
|
|
|
+
|
|
|
+- [ ] **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/schemas/duty.py backend/app/services/duty_schedule.py backend/tests/test_duty_schedule.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(duty): add schemas and duty_schedule service with capacity tests
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 管理端 + 公开 API 路由注册
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/api/duty.py`
|
|
|
+- Create: `backend/app/api/public_duty.py`
|
|
|
+- Modify: `backend/app/main.py`
|
|
|
+- Modify: `backend/app/core/config.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 管理路由** `prefix="/web/admin/duty"`
|
|
|
+
|
|
|
+```python
|
|
|
+# 关键端点(均 Depends 现有 admin 鉴权,如 require_admin_or_super_admin / 菜单守卫)
|
|
|
+GET ""?year=&month= -> month summary
|
|
|
+GET "/day?dutyDate=YYYY-MM-DD" -> day detail(无则返回空骨架 slots=[])
|
|
|
+POST "/day" -> save_day body
|
|
|
+POST "/day/{dayId}/link" -> { linkStatus }
|
|
|
+DELETE "/assignments/{id}" -> soft delete
|
|
|
+```
|
|
|
+
|
|
|
+鉴权用户取 `tenant_no`、`user_id`、`create_dept_id`(与 leave 一致)。指定用户时用现有用户查询解析姓名写入 `person_name`。
|
|
|
+
|
|
|
+- [ ] **Step 2: 公开路由** `prefix="/public/duty"`
|
|
|
+
|
|
|
+```python
|
|
|
+GET "/{token}" -> get_public_page;404 文案「链接无效或已失效」
|
|
|
+POST "/{token}/grab" -> DutyGrabBody;业务错误用 error_response / HTTP 400
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: `main.py` 注册**
|
|
|
+
|
|
|
+```python
|
|
|
+from app.api.duty import router as duty_router
|
|
|
+from app.api.public_duty import router as public_duty_router
|
|
|
+# ...
|
|
|
+app.include_router(duty_router, prefix=settings.API_V1_PREFIX)
|
|
|
+app.include_router(public_duty_router, prefix=settings.API_V1_PREFIX)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 加密排除**
|
|
|
+
|
|
|
+在 `API_ENCRYPT_EXCLUDE_PATHS` 追加:
|
|
|
+
|
|
|
+```
|
|
|
+"/api/public/duty,"
|
|
|
+```
|
|
|
+
|
|
|
+检查 `web-admin/.env.development` 与 `.env.production` 的 `VITE_API_ENCRYPT_EXCLUDE_PATHS`,若存在则同样追加 `/api/public/duty`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 手工用 curl/Swagger 冒烟**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 登录后 POST /api/web/admin/duty/day 创建某日
|
|
|
+# GET /api/public/duty/{token}
|
|
|
+# POST /api/public/duty/{token}/grab {"shiftType":"evening","personName":"测试"}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/duty.py backend/app/api/public_duty.py backend/app/main.py backend/app/core/config.py web-admin/.env.development web-admin/.env.production
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(duty): register admin and public duty APIs with encrypt exclude
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: web-admin 管理端月历页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/api/duty.ts`
|
|
|
+- Create: `web-admin/src/views/duty/schedule/index.vue`
|
|
|
+- 菜单 component 已指向 `/duty/schedule/index`(动态路由按现有菜单加载)
|
|
|
+
|
|
|
+- [ ] **Step 1: API 模块**
|
|
|
+
|
|
|
+```typescript
|
|
|
+// web-admin/src/api/duty.ts
|
|
|
+import request from '@/utils/http'
|
|
|
+
|
|
|
+export type ShiftType = 'morning' | 'evening' | 'duty'
|
|
|
+
|
|
|
+export const fetchDutyMonth = (year: number, month: number) =>
|
|
|
+ request.get({ url: '/web/admin/duty', params: { year, month } })
|
|
|
+
|
|
|
+export const fetchDutyDay = (dutyDate: string) =>
|
|
|
+ request.get({ url: '/web/admin/duty/day', params: { dutyDate } })
|
|
|
+
|
|
|
+export const saveDutyDay = (data: unknown) =>
|
|
|
+ request.post({ url: '/web/admin/duty/day', data })
|
|
|
+
|
|
|
+export const setDutyLinkStatus = (dayId: string, linkStatus: 'open' | 'closed') =>
|
|
|
+ request.post({ url: `/web/admin/duty/day/${dayId}/link`, data: { linkStatus } })
|
|
|
+
|
|
|
+export const deleteDutyAssignment = (id: string) =>
|
|
|
+ request.del({ url: `/web/admin/duty/assignments/${id}` })
|
|
|
+```
|
|
|
+
|
|
|
+(若项目 `request` 方法名是 `delete` 而非 `del`,与邻近 API 文件保持一致。)
|
|
|
+
|
|
|
+- [ ] **Step 2: 月历页**
|
|
|
+
|
|
|
+实现要求(对齐 `docs/demos/duty-schedule-v1-demo.html` + Fresh Mint):
|
|
|
+
|
|
|
+- `panel-top` + `h2.title-bar-heading`「值班排班」
|
|
|
+- 月切换、今天
|
|
|
+- 7 列日历网格(可用纯 CSS/table,不必上 FullCalendar)
|
|
|
+- 日格展示 chip:早班/晚班/值班颜色区分(管理端可用琥珀/蓝/红浅底,勿用小程序主色当 primary 按钮)
|
|
|
+- 点击日期打开 `el-dialog`:`label-position="top"`,`modal-class` 无 blur,`dialog-form-layout`
|
|
|
+- 弹窗内三个可启用班次:`capacity` + 系统用户选择(复用现有用户远程搜索/选择组件,参考 `system/user` 或其它选人控件)+ 已安排 tags(指定/抢班)可删
|
|
|
+- 复制 `publicUrl`;开关链接按钮
|
|
|
+- 保存调用 `saveDutyDay`;缩容错误展示后端 message
|
|
|
+
|
|
|
+按钮均带 `<el-icon>`。
|
|
|
+
|
|
|
+- [ ] **Step 3: 本地 `pnpm dev` 打开菜单验证月历加载与保存**
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/duty.ts web-admin/src/views/duty/schedule/index.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(duty): add web-admin calendar schedule page
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 公开 H5 页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/api/public-duty.ts`
|
|
|
+- Create: `web-admin/src/views/public/duty/index.vue`
|
|
|
+- Modify: `web-admin/src/router/routes/staticRoutes.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: 静态路由**(插在其它 public 路由旁)
|
|
|
+
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ path: '/public/duty/:token',
|
|
|
+ name: 'PublicDutyGrab',
|
|
|
+ component: () => import('@views/public/duty/index.vue'),
|
|
|
+ meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true }
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 公开 API**
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { publicPlGet, publicPlPost } from '@/utils/http/public-pl'
|
|
|
+
|
|
|
+export const publicDutyApi = {
|
|
|
+ getPage: (token: string) => publicPlGet<any>(`/public/duty/${token}`),
|
|
|
+ grab: (token: string, data: { shiftType: string; personName: string }) =>
|
|
|
+ publicPlPost<any>(`/public/duty/${token}/grab`, data)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: H5 页面**
|
|
|
+
|
|
|
+对齐 `docs/demos/duty-h5-grab-demo.html`:
|
|
|
+
|
|
|
+- 三墙色:`#0a9f6d` / `#087f57` / `#e9f9f2` / `#f5f8f7`
|
|
|
+- 状态:加载中 / 无效 / 已关闭 / 可抢列表 / 成功摘要
|
|
|
+- 班次独立卡片;满员禁用;可抢输入姓名(max 20)提交
|
|
|
+- `onMounted` 读 `route.params.token`
|
|
|
+
|
|
|
+参考 `web-admin/src/views/public/pl/transport/index.vue` 的公开页壳与错误处理。
|
|
|
+
|
|
|
+- [ ] **Step 4: 端到端手测**
|
|
|
+
|
|
|
+1. 管理端建日 → 复制链接 → 无痕打开 H5
|
|
|
+2. 抢晚班成功 → 再抢同班次满员失败
|
|
|
+3. 关链 → H5 显示关闭
|
|
|
+4. 管理端删人 → 名额释放可再抢
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/public-duty.ts web-admin/src/views/public/duty/index.vue web-admin/src/router/routes/staticRoutes.ts
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(duty): add public H5 grab page for daily duty links
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 收尾与图谱
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- 按需更新 `docs/sql/` 备注或拷贝 migrate 说明
|
|
|
+- 跑 `./scripts/update-graph.sh backend` 与 `frontend`(或 `graphify update .`)
|
|
|
+
|
|
|
+- [ ] **Step 1: 对照 spec 验收清单勾选**(设计文档「测试要点」7 条)
|
|
|
+
|
|
|
+- [ ] **Step 2: 更新知识图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh all
|
|
|
+# 或
|
|
|
+graphify update .
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 最终 commit(若有 docs/sql 或 graph 变更)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/sql backend/graphify-out web-admin/graphify-out graphify-out
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+chore(duty): sync sql notes and knowledge graph after duty schedule
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec 覆盖自检
|
|
|
+
|
|
|
+| Spec 项 | Task |
|
|
|
+|---------|------|
|
|
|
+| D1 web + 公开 H5 | 5, 6 |
|
|
|
+| D2 一天一链接 | 1–4 save_day token |
|
|
|
+| D3 班次独立罗列 | 6 |
|
|
|
+| D4 只填姓名 | 3 grab / 6 |
|
|
|
+| D5 系统用户指定 | 4–5 |
|
|
|
+| D6/D7 员工不可取消、经理可改 | 4 delete/save/link |
|
|
|
+| D8 无统计页 | 5 仅日历 |
|
|
|
+| D9 预置班次 | models 常量 |
|
|
|
+| D10 按天建 | 5 点日配置 |
|
|
|
+| D11 同日多班次 | grab 不按人跨 slot 限制 |
|
|
|
+| D12 token 不重置 | save_day |
|
|
|
+| D13 缩容校验 | service + test |
|
|
|
+| D14 历史可改 | 无冻结逻辑 |
|
|
|
+| D15 菜单权限 | migrate menu |
|
|
|
+| 加密排除 | Task 4 |
|
|
|
+| 并发满员 | grab + test |
|
|
|
+
|
|
|
+**V1 限流:** 若无现成中间件,可在 `grab` 内不做强限流,验收文档注明「后续可加」;不阻塞主路径(YAGNI)。若已有简易 IP 限流工具则挂上。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 执行方式
|
|
|
+
|
|
|
+Plan 已保存。可选:
|
|
|
+
|
|
|
+1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间审查
|
|
|
+2. **Inline Execution** — 本会话按 executing-plans 连续推进并设检查点
|
|
|
+
|
|
|
+请选择一种,或指出计划中要先改的部分。
|