From 35e09fa09b73f24e5d6b8d82f861d6fc7c572ada Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 14 Aug 2026 17:14:05 +0800 Subject: [PATCH] feat(duty): add web-admin calendar page and public H5 grab UI Co-authored-by: Cursor --- docs/demos/duty-h5-grab-demo.html | 519 ++++++++++++++ docs/demos/duty-schedule-v1-demo.html | 548 ++++++++++++++ .../plans/2026-08-14-duty-schedule.md | 644 +++++++++++++++++ .../specs/2026-08-14-duty-schedule-design.md | 205 ++++++ web-admin/src/api/duty.ts | 71 ++ web-admin/src/api/public-duty.ts | 32 + web-admin/src/router/routes/staticRoutes.ts | 6 + web-admin/src/views/duty/schedule/index.vue | 673 ++++++++++++++++++ web-admin/src/views/public/duty/index.vue | 454 ++++++++++++ 9 files changed, 3152 insertions(+) create mode 100644 docs/demos/duty-h5-grab-demo.html create mode 100644 docs/demos/duty-schedule-v1-demo.html create mode 100644 docs/superpowers/plans/2026-08-14-duty-schedule.md create mode 100644 docs/superpowers/specs/2026-08-14-duty-schedule-design.md create mode 100644 web-admin/src/api/duty.ts create mode 100644 web-admin/src/api/public-duty.ts create mode 100644 web-admin/src/views/duty/schedule/index.vue create mode 100644 web-admin/src/views/public/duty/index.vue diff --git a/docs/demos/duty-h5-grab-demo.html b/docs/demos/duty-h5-grab-demo.html new file mode 100644 index 000000000..7983baa50 --- /dev/null +++ b/docs/demos/duty-h5-grab-demo.html @@ -0,0 +1,519 @@ + + + + + + 值班抢班 · 公开 H5 Demo + + + +
+ 公开 H5 · 抢班页 + + + + +
一天一个链接 · 班次独立罗列 · 免登录只填姓名 · 满员不可抢 · 对齐三墙 Token
+
+ +
+
+
375px 手机预览 · 点击上方切换状态
+
+
+ + +
+
+
山峡化工 · 值班抢班
+

8月20日 值班

+

无需登录,填写姓名后选择班次提交。名额满了就不能再抢。

+
+ 周三 + 链接有效 + 剩余 2 个名额 +
+
+
+
提示:同名可能重复,请填写真实姓名;提交后如需改动请联系管理员。
+ + +
+
+
早班
+ 已满 +
+
+ 名额 + 2 / 2 +
+
+
+ 张伟 + 陈静 +
+ +
+ + +
+
+
晚班
+ 可抢 +
+
+ 名额 + 1 / 2 +
+
+
+ 李娜 + 空缺 1 +
+
+ + +
+ +
+ + +
+
+
值班
+ 可抢 +
+
+ 名额 + 0 / 1 +
+
+
+ 暂无人报名 +
+
+ + +
+ +
+
+ +
+ + +
+
+
山峡化工 · 值班抢班
+

报名成功

+

请按时到岗。如需调整请联系管理员。

+
+
+
✓
+

已抢到晚班

+

2026年8月20日(周三)

+
+
日期2026-08-20
+
班次晚班
+
姓名王小明
+
状态已报名
+
+ +
+
+ + +
+
+
⊘
+

报名已关闭

+

管理员已关闭本链接,暂时无法继续抢班。
如需值班请联系管理员另行安排。

+
+
日期2026-08-20
+
状态已关闭
+
+
+
+ + +
+
+
?
+

链接无效或已失效

+

请确认是否复制完整,或向管理员重新索取当日值班链接。

+
+
+ +
+
+
+
+ + + + diff --git a/docs/demos/duty-schedule-v1-demo.html b/docs/demos/duty-schedule-v1-demo.html new file mode 100644 index 000000000..ee726923d --- /dev/null +++ b/docs/demos/duty-schedule-v1-demo.html @@ -0,0 +1,548 @@ + + + + + + 值班排班 · 方案1 Demo + + + + +
+ 值班 · 方案1 Demo + + + + Fresh Mint 管理端 · 三墙绿 H5 · 仅示意交互 +
+ + +
+
+
山峡化工 · 值班排班
+
+
+
+

值班排班

+
+ + 2026年 8月 + + +
+
+ 早班 + 晚班 + 值班 + 点击日期可配置 / 复制链接 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
日一二三四五六
26
27
28
29
30
31
1
+ 早班 张伟 + 晚班 李娜 +
2
+ 早班 王磊 + 值班 刘洋 + +2 +
3
+ 早班 陈静 + 晚班 赵鑫 +
4
+ 未排班 · 点击配置 +
5
+ 早班 张伟 + 晚班 李娜 + 值班 王磊 +
6
+ 早班 刘洋 · 陈静 + 晚班 1/2 + 可抢 · 有链接 +
7
+ 未排班 · 点击配置 +
8
+ 未排班 · 点击配置 +
9
未排班
10
未排班
11
+ 早班 张伟 + 晚班 满 +
12
未排班
13
未排班
14
+ 值班 赵鑫 +
15
未排班
16
未排班
17
未排班
18
未排班
19
未排班
20
+ 早班 2/2 + 晚班 1/2 + 值班 0/1 +
21
未排班
22
未排班
23
未排班
24
未排班
25
未排班
26
未排班
27
未排班
28
未排班
29
未排班
30
未排班
31
未排班
1
2
3
4
5
+
+
+
+
+ + +
+
+
配置值班日 · 2026-08-20
+
+
+
+

2026年8月20日

+ +
+ +
+

早班 · 名额 2

+
+
+
+ +
+
+
+ 张伟(指定) + 陈静(抢班) +
+
+ +
+

晚班 · 名额 2

+
+
+
+ +
+
+
+ 李娜(抢班) + 空缺 1 +
+
+ +
+

值班 · 名额 1

+
+
+
+ +
+
+
+ 空缺 1 · 可抢 +
+
+ + + +
+ + +
+
+
+
+
+ + +
+
+
+
值班抢班
+
+

8月20日 值班

+

免登录 · 填写姓名即可抢班 · 满员不可再抢

+
+
+
+
+ 早班 + 已满 2/2 +
+
+
已报名:张伟、陈静
+ +
+ +
+
+ 晚班 + 可抢 1/2 +
+
+
已报名:李娜
+ + +
+ +
+
+ 值班 + 可抢 0/1 +
+
+
暂无人报名
+ + +
+
+
+
+
+ + +
+
+
+ 配置 · 2026-08-06 + +
+
+

为当天配置班次名额,保存后生成公开链接。可直接从系统用户安排,也可留给员工抢班。

+
+

早班

+
+
+
+
+
+
+

晚班

+
+
+
+
+
+
+

值班

+
+
+
+
+
+ +
+
+ + +
+
+
+ +
+ + + + diff --git a/docs/superpowers/plans/2026-08-14-duty-schedule.md b/docs/superpowers/plans/2026-08-14-duty-schedule.md new file mode 100644 index 000000000..1dab88f61 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-duty-schedule.md @@ -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 + +按钮均带 ``。 + +- [ ] **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(`/public/duty/${token}`), + grab: (token: string, data: { shiftType: string; personName: string }) => + publicPlPost(`/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 连续推进并设检查点 + +请选择一种,或指出计划中要先改的部分。 diff --git a/docs/superpowers/specs/2026-08-14-duty-schedule-design.md b/docs/superpowers/specs/2026-08-14-duty-schedule-design.md new file mode 100644 index 000000000..c3478a93d --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-duty-schedule-design.md @@ -0,0 +1,205 @@ +# 值班排班(日历 + 公开链接抢班)— 设计文档 + +> **日期**: 2026-08-14 +> **状态**: 待实现 +> **仓库**: shanxia2.0(`backend/` + `web-admin/`) +> **方案**: 方案 1 —「日排班 + 班次名额 + 公开 Token 链接」 +> **UI 原型**: `docs/demos/duty-schedule-v1-demo.html`(管理端)、`docs/demos/duty-h5-grab-demo.html`(公开 H5) +> **权威对照**: Fresh Mint(`docs/web-admin-ui-spec-fresh-mint.md`)、公开页可参考运输计划司机免登录(`/#/public/pl/transport/{token}`) + +## 背景 + +总经理需要按日历安排值班(早班 / 晚班 / 值班等),可设定各班次人数,并可直接指定系统用户;同时生成**按日**的公开链接,员工免登录填写姓名抢班,满员不可再抢。总经理在日历中可查看历史排班。 + +参考截图(仅视觉参考):`值班系统截图/`。第一版**不做**统计页。 + +## 已确认决策 + +| # | 决策 | +|---|------| +| D1 | 管理端在 **web-admin** 排班;员工通过 **公开 H5 链接**抢班 | +| D2 | **一天一个链接**(如「8 月 20 日值班」),每次配置某日即对应一条 token 链接 | +| D3 | H5 上将当日多个班次**独立罗列**,不做筛选,方便直接抢 | +| D4 | 抢班只填**姓名**;同名不拦截,靠总经理事后核对 | +| D5 | 总经理「直接安排」从**系统用户**选择 | +| D6 | 员工不能自己取消;总经理可删人、改名额/班次、关链接 | +| D7 | 有人报名后总经理仍可改(不整日锁定) | +| D8 | V1 只要**日历 + 历史排班**;统计页以后再说 | +| D9 | 班次类型**系统预置**(早班 / 晚班 / 值班),暂不可自定义 | +| D10 | 只能**按天**建:点日历某日 → 配置 → 生成该日链接(不做批量日期范围) | +| D11 | 同一天同一人**可以**占多个班次(早班 + 晚班互不影响) | +| D12 | 关链后重开仍用**原 token**(V1 不做「重置链接」) | +| D13 | 将 `capacity` 调到小于当前报名人数时**禁止保存**,须先删人 | +| D14 | 过往日期仍允许总经理修改(不做历史冻结) | +| D15 | 权限按**菜单权限**控制,不单独硬编码「总经理」角色名 | + +## 方案选择 + +**采用方案 1(已确认)**:`duty_day` + `duty_slot` + `duty_assignment`,公开 token 对齐现有免登录链接模式。 + +未采用:扁平日历事件(名额与关链难控);纯列表无月历(不符合「按日历排班/看历史」)。 + +## 架构与边界 + +| 端 | 职责 | +|----|------| +| **web-admin** | 月历、按日配置、指定用户、复制/关闭链接、删人改班、历史翻月 | +| **公开页(web-admin `#/public/...`)** | token 打开当日抢班页;免登录;只填姓名提交 | +| **backend** | 租户隔离管理 API + 公开抢班 API;名额并发控制;路径加入加密排除列表 | + +公开页落地建议:与司机免登录一致,挂在 web-admin hash 路由,例如 `/#/public/duty/{token}`;链接 Origin 使用现有 `PUBLIC_WEB_ORIGIN`(或等价配置)拼接。 + +``` +duty_day (一天 = 一个 access_token) + └── duty_slot × N(morning / evening / duty,各有 capacity) + └── duty_assignment × M(source = assign | grab) +``` + +## 数据模型 + +表名前缀建议 `duty_`;均含门户公共字段(`id`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)。字段顺序:`id` → 业务字段 → 公共字段。软删。 + +### `duty_day`(值班日) + +| 字段 | 说明 | +|------|------| +| `duty_date` | 值班日期;**同一 `tenant_no` 下唯一**(未删除) | +| `access_token` | 公开访问令牌;足够长、不可猜;租户内唯一 | +| `link_status` | `open` / `closed` | + +### `duty_slot`(班次名额) + +| 字段 | 说明 | +|------|------| +| `duty_day_id` | 所属值班日 | +| `shift_type` | 预置:`morning`(早班)/ `evening`(晚班)/ `duty`(值班) | +| `capacity` | 名额,≥ 1 | + +约束:同一 `duty_day_id` + `shift_type` 唯一(未删除)。 + +### `duty_assignment`(占位) + +| 字段 | 说明 | +|------|------| +| `duty_slot_id` | 所属班次 | +| `source` | `assign`(管理端指定)/ `grab`(H5 抢班) | +| `user_id` | 指定时必填;抢班为空 | +| `person_name` | 展示名(用户姓名或手填姓名) | + +满员规则:该 slot 下未删除 assignment 数 `< capacity` 才可新增。 + +SQL 变更须同步维护 `docs/sql/`(与 `database-conventions` 一致)。 + +## Web 管理端 + +### 入口与权限 + +- 新菜单「值班排班」(具体挂载位置实现时定),按钮级权限可拆:查看 / 编辑 / 关链(最小也可合并为菜单权限,能进即可改)。 +- UI:Fresh Mint;主色 `#10b981`;`label-position="top"`;弹窗无背景模糊。 +- 模式:月历主视图;**不做统计页**(D8)。 + +### 月历 + +- 月切换 +「今天」。 +- 日格摘要:班次 chip(类型 + 人名或 `已占/名额`);未配置显示未排班。 +- 点击日期 → 配置弹窗(与 Demo 一致)。 + +### 配置某日 + +- 配置预置班次及 `capacity`(可只启用部分班次)。 +- 从系统用户选择直接安排 → `source=assign`,写入 `user_id` + `person_name`;指定人数不得超过该班次剩余名额。 +- 列表展示已报名,区分指定 / 抢班;支持删除 assignment。 +- **复制链接**:`{PUBLIC_WEB_ORIGIN}/#/public/duty/{access_token}`。 +- **关闭 / 打开链接**:改 `link_status`;关链后 H5 展示关闭态;日历仍可见数据。 +- 缩容校验(D13):`capacity` 不得小于当前有效报名数。 +- 去掉某班次(不再启用):若该 slot 仍有有效报名,**禁止删除/停用**,须先清空报名。 +- 首次保存某日:创建 `duty_day` + slots,生成 `access_token`;再次保存更新 slots/安排,token 不变(D12)。 + +### 历史 + +- 翻月查看过往排班;过往日仍可编辑(D14)。 + +## 公开 H5 + +对齐原型 `docs/demos/duty-h5-grab-demo.html`;视觉用三墙 Token(`#0a9f6d`),与管理端色值不混用。 + +### 状态 + +| 状态 | 表现 | +|------|------| +| 可抢 | 班次卡片独立罗列;可填姓名提交;满员卡片禁用 | +| 提交成功 | 展示日期 / 班次 / 姓名摘要 | +| 链接关闭 | 「报名已关闭」 | +| token 无效 | 「链接无效或已失效」 | + +### 规则 + +- 免登录;只填姓名(D4)。 +- 不可自助取消(D6)。 +- 同天可抢多个班次(D11)。 +- 展示已报名姓名列表(便于员工感知剩余名额;同名风险已知晓)。 + +## API + +### 管理端(登录 + 租户 + 数据权限按现有约定) + +| 能力 | 说明 | +|------|------| +| 月历查询 | 按年月返回每日摘要(slots + assignments 精简) | +| 日详情 | 含链接状态、token/完整 URL、slots、assignments | +| 保存日配置 | 创建或更新 day/slots;校验缩容;可选写入 assign | +| 删除 assignment | 释放名额 | +| 开关链接 | `open` / `closed` | + +用户选择器复用现有系统用户列表接口。 + +### 公开(免登录) + +| 接口 | 说明 | +|------|------| +| `GET /api/public/duty/{token}` | 当日只读:日期、状态、各 slot 与已报名姓名 | +| `POST /api/public/duty/{token}/grab` | body: `{ shiftType, personName }`;`personName` 去首尾空格后非空且长度受限(建议 ≤20);校验开链与名额 | + +须: + +- 注册路由于 `app/api/__init__.py` 与 `app/main.py`。 +- 公开路径加入 `API_ENCRYPT_EXCLUDE_PATHS`(及前端等价排除,若有)。 +- 抢班在事务内检查人数后再插入,防止超卖。 +- 错误:满员 / 已关闭 / token 无效 — 明确文案。 +- V1:按 IP 或 token 做基础限流(实现时可用现有中间件或轻量计数)。 + +公开响应**不**返回其他日期或其他租户数据。 + +## 错误处理 + +| 场景 | 行为 | +|------|------| +| 抢班时刚好满员 | 返回失败,H5 提示名额已满并刷新人数 | +| 关链后提交 | 拒绝,展示关闭态 | +| 管理端缩容过大 | 保存失败,提示先删除多余人员 | +| 删除不存在的 assignment | 幂等或明确 404 | + +## 测试要点(验收) + +1. 按日配置早/晚/值班名额并复制链接,H5 可打开且班次独立展示。 +2. 指定系统用户占名额;H5 抢班占名额;满员后不可再抢。 +3. 关链后 H5 不可抢;打开后可继续抢(同 token)。 +4. 管理端删人后名额释放;缩容小于当前人数被拒。 +5. 同姓名可重复报名;同人同日可抢多个班次。 +6. 翻月可看历史排班。 +7. 租户隔离:A 租户链接不可看到 B 租户数据。 + +## 明确不做(V1) + +- 统计页 / 按人汇总卡片 +- 批量按日期范围建排班 +- 自定义班次类型 +- H5 登录、手机号、验证码 +- 员工自助取消 +- 重置 token +- 历史日期自动冻结 + +## 原型索引 + +- 管理端月历 + 配置:`docs/demos/duty-schedule-v1-demo.html` +- 公开 H5 多状态:`docs/demos/duty-h5-grab-demo.html` diff --git a/web-admin/src/api/duty.ts b/web-admin/src/api/duty.ts new file mode 100644 index 000000000..304e84ebb --- /dev/null +++ b/web-admin/src/api/duty.ts @@ -0,0 +1,71 @@ +import request from '@/utils/http' + +export type ShiftType = 'morning' | 'evening' | 'duty' +export type LinkStatus = 'open' | 'closed' + +export interface DutyAssignmentItem { + id?: string + source: 'assign' | 'grab' + userId?: string | null + personName: string +} + +export interface DutySlotItem { + id?: string + shiftType: ShiftType + shiftLabel?: string + capacity: number + taken?: number + assignments: DutyAssignmentItem[] +} + +export interface DutyDayItem { + id?: string | null + dutyDate: string + linkStatus: LinkStatus + accessToken?: string | null + publicUrl?: string | null + slots: DutySlotItem[] +} + +export function fetchDutyMonth(year: number, month: number) { + return request.get({ + url: '/web/admin/duty', + params: { year, month } + }) +} + +export function fetchDutyDay(dutyDate: string) { + return request.get({ + url: '/web/admin/duty/day', + params: { dutyDate } + }) +} + +export function saveDutyDay(data: { + dutyDate: string + slots: Array<{ + shiftType: ShiftType + capacity: number + assignments: DutyAssignmentItem[] + }> + linkStatus?: LinkStatus +}) { + return request.post({ + url: '/web/admin/duty/day', + data + }) +} + +export function setDutyLinkStatus(dayId: string, linkStatus: LinkStatus) { + return request.post({ + url: `/web/admin/duty/day/${dayId}/link`, + data: { linkStatus } + }) +} + +export function deleteDutyAssignment(id: string) { + return request.delete({ + url: `/web/admin/duty/assignments/${id}` + }) +} diff --git a/web-admin/src/api/public-duty.ts b/web-admin/src/api/public-duty.ts new file mode 100644 index 000000000..128343346 --- /dev/null +++ b/web-admin/src/api/public-duty.ts @@ -0,0 +1,32 @@ +import { publicPlGet, publicPlPost } from '@/utils/http/public-pl' +import type { ShiftType } from '@/api/duty' + +export interface PublicDutySlot { + id?: string + shiftType: ShiftType + shiftLabel?: string + capacity: number + taken: number + assignments: Array<{ id?: string; source: string; personName: string }> +} + +export interface PublicDutyPage { + dutyDate: string + linkStatus: 'open' | 'closed' + closed?: boolean + slots: PublicDutySlot[] +} + +export interface PublicDutyGrabResult { + dutyDate: string + shiftType: ShiftType + shiftLabel: string + personName: string + status: string +} + +export const publicDutyApi = { + getPage: (token: string) => publicPlGet(`/public/duty/${token}`), + grab: (token: string, data: { shiftType: string; personName: string }) => + publicPlPost(`/public/duty/${token}/grab`, data) +} diff --git a/web-admin/src/router/routes/staticRoutes.ts b/web-admin/src/router/routes/staticRoutes.ts index 8c3c97204..9afa20480 100644 --- a/web-admin/src/router/routes/staticRoutes.ts +++ b/web-admin/src/router/routes/staticRoutes.ts @@ -87,6 +87,12 @@ export const staticRoutes: AppRouteRecordRaw[] = [ component: () => import('@views/public/db/inventory/index.vue'), meta: { title: '出入库单', isHideTab: true, isPublicMobile: true } }, + { + path: '/public/duty/:token', + name: 'PublicDutyGrab', + component: () => import('@views/public/duty/index.vue'), + meta: { title: '值班抢班', isHideTab: true, isPublicMobile: true } + }, { path: '/:pathMatch(.*)*', name: 'Exception404CatchAll', diff --git a/web-admin/src/views/duty/schedule/index.vue b/web-admin/src/views/duty/schedule/index.vue new file mode 100644 index 000000000..1e5749483 --- /dev/null +++ b/web-admin/src/views/duty/schedule/index.vue @@ -0,0 +1,673 @@ + + + + + diff --git a/web-admin/src/views/public/duty/index.vue b/web-admin/src/views/public/duty/index.vue new file mode 100644 index 000000000..e9c0acabf --- /dev/null +++ b/web-admin/src/views/public/duty/index.vue @@ -0,0 +1,454 @@ + + + + +