Files
shanxia2.0/docs/superpowers/plans/2026-08-14-duty-schedule.md
T

645 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 值班排班(日历 + 公开链接抢班)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 连续推进并设检查点
请选择一种,或指出计划中要先改的部分。