diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-design.md b/docs/plans/2026-03-04-ctms-enterprise-ui-design.md new file mode 100644 index 00000000..241a54cb --- /dev/null +++ b/docs/plans/2026-03-04-ctms-enterprise-ui-design.md @@ -0,0 +1,152 @@ +# CTMS 企业级 UI 改造设计方案(第一阶段) + +## 1. 目标与边界 + +### 1.1 North Star(1-2 周) +将 CTMS 从“功能可用”升级为“管理层一眼认可的企业级系统”,聚焦: + +1. 视觉统一:10 个核心页面采用同一套视觉语言。 +2. 信息层级清晰:标题、指标、风险、操作区一眼分层。 +3. 专业可信:克制、高密度但不混乱、细节一致。 + +### 1.2 In Scope(第一阶段) + +1. 全局壳层:侧边栏、顶栏、面包屑、页面头、卡片容器。 +2. 设计基础:色彩/字体/间距/圆角/阴影/状态色 token 统一。 +3. 高曝光 10 页: + - `/project/overview` + - `/project/milestones` + - `/subjects` + - `/risk-issues/sae` + - `/risk-issues/pd` + - `/risk-issues/monitoring-visits` + - `/fees/contracts` + - `/fees/special` + - `/file-versions` + - `/admin/projects/:projectId` +4. 轻交互:筛选区、表格工具栏、主次按钮层级、空态/加载态统一。 + +### 1.3 Out of Scope(第一阶段不做) + +1. 业务流程重构和权限逻辑改动。 +2. 新增复杂图表引擎或大规模前端架构迁移。 +3. 全量页面一次性翻新。 + +## 2. 视觉方向与设计系统基线 + +### 2.1 视觉方向 + +**Executive Clinical Minimalism(管理层临床极简)** + +关键词:克制、秩序、可信、少而精。 +避免:花哨渐变、互联网运营感、过度装饰。 + +### 2.2 品牌识别锚点 + +1. 深海军蓝 + 冷灰主色体系,传达管理层报告感。 +2. 高对比标题 + 低饱和数据面,强化信息主次。 +3. 统一壳层卡片语言,确保跨页面一致识别。 + +### 2.3 最小设计系统(MDS) + +1. Tokens + - Color:`brand / text / bg / border / status` + - Type:`Display(标题) + UI(正文)` 双字体体系 + - Space:8pt 网格(`4/8/12/16/24/32`) + - Radius/Shadow:统一 3 档,页面禁止自定义分叉 +2. 组件皮肤(Element Plus 主题化) + - Button(主/次/危险/幽灵) + - Input/Select/DatePicker + - Table(表头、行高、hover、密度) + - Card/Section Header/Tag +3. 交互规则 + - 页面主按钮固定在右上主操作区 + - 筛选条统一(左筛选、右动作) + - 表格区统一“工具栏-表格-分页”三段结构 + +### 2.4 第一阶段质量门槛 + +1. 10 页视觉一致性 >= 90%。 +2. 不出现第三套颜色、第三套圆角、第三套按钮风格。 +3. 1366 桌面与 iPad 宽度保持结构稳定。 + +## 3. 优先级与里程碑 + +### 3.1 优先级 + +#### P0(必须完成) +1. 全局壳层统一:`Layout.vue + main.css + unified-page.css`。 +2. 10 个核心页面统一改造(见 In Scope)。 +3. 统一状态体系:加载态、空态、错误态、禁用态、风险高亮态。 + +#### P1(有余量时) +1. `/workbench` 视觉升级。 +2. 表单页统一(新建/编辑页头、分组、主次按钮)。 +3. 克制型关键动效(入场/hover)。 + +#### P2(后续阶段) +1. 全模块扩展(知识库、启动授权细分页)。 +2. 深度交互优化(跨页筛选记忆、快捷操作)。 +3. 主题扩展(暗色/品牌变体,按需)。 + +### 3.2 两周里程碑 + +#### M1(第 1-3 天):视觉基线锁定 +1. 冻结 tokens(颜色/字体/间距/阴影/圆角)。 +2. 产出 3 类样板页(概览、列表、详情)。 +3. 评审后锁定不可随意改动项。 + +#### M2(第 4-7 天):高曝光页面落地(第一批) +1. 完成 P0 页面中的 6 页。 +2. 统一页面头、筛选条、表格工具栏、分页区。 +3. 首轮一致性巡检与收敛。 + +#### M3(第 8-10 天):P0 收敛(第二批) +1. 完成剩余 4 页。 +2. 统一空态/加载态/错误态。 +3. 窄屏最小可用适配。 + +#### M4(第 11-14 天):验收与打磨 +1. 演示路径串联:概览 -> 风险 -> 进度 -> 费用。 +2. 视觉一致性检查(目标 >= 90%)。 +3. 性能与可访问性快速校验并修复阻断项。 + +## 4. 执行架构与风险控制 + +### 4.1 双轨并行执行 + +1. 轨道 A(20%):Design System 底座 + - 固化 token、组件皮肤、壳层规范。 +2. 轨道 B(80%):核心页面改造 + - 严格按 P0 页面推进,仅做视觉 + 轻交互。 + +### 4.2 工作机制 + +1. 每日节奏:样板页 -> 批量套用 -> 巡检收敛。 +2. 每 2 天一次统一性评审(跨页面对齐)。 +3. 决策优先级:统一性 > 局部美化。 + +### 4.3 主要风险与控制 + +1. 风格分叉 + - 控制:样式入口收敛到全局 token 与统一壳层 class。 +2. 时间被细节吞没 + - 控制:先结构统一,后细节打磨;P1 不挤占 P0。 +3. 观感升级但管理层不买账 + - 控制:围绕高管路径做演示脚本。 +4. 样式改动引入回归 + - 控制:每页改造后执行关键路径 smoke(筛选/跳转/分页/导出)。 + +## 5. 第一阶段验收清单 + +1. 统一性:10 页通过同一套视觉检查项。 +2. 观感:15 分钟演示无明显风格跳变与信息噪声。 +3. 可用性:主操作、筛选、列表关系明确。 +4. 稳定性:桌面与窄屏无结构破坏。 +5. 风险控制:无新增业务逻辑缺陷。 + +## 6. 决策结论 + +1. 采用“Design System 20% + 页面改造 80%”双轨并行。 +2. 第一阶段严格完成 P0;P1 仅在余量下执行。 +3. 下一步进入实施计划拆解(writing-plans)。 diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md new file mode 100644 index 00000000..f811be6c --- /dev/null +++ b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md @@ -0,0 +1,465 @@ +# CTMS Enterprise UI Refresh Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Deliver a management-grade, visually unified CTMS UI across 10 core pages in 1-2 weeks, without changing business logic. + +**Architecture:** Use a dual-track rollout: (A) minimal design-system hardening (tokens + shell + shared states), then (B) apply the same UI contract across prioritized pages. All page work must consume centralized CSS variables and shared shell patterns; page-local visual forks are disallowed. + +**Tech Stack:** Vue 3, TypeScript, Vite, Element Plus, CSS variables + +--- + +Skills to apply during execution: `@test-driven-development`, `@frontend-design`, `@verification-before-completion`, `@requesting-code-review`. + +### Task 1: Add UI Contract Guardrails (Automated Checks) + +**Files:** +- Create: `frontend/scripts/verify-ui-contract.mjs` +- Modify: `frontend/package.json` +- Test: `frontend/scripts/verify-ui-contract.mjs` + +**Step 1: Write the failing test** + +Create `frontend/scripts/verify-ui-contract.mjs`: + +```js +import { readFileSync } from "node:fs"; + +const main = readFileSync("src/styles/main.css", "utf8"); +const unified = readFileSync("src/styles/unified-page.css", "utf8"); + +const requiredMainTokens = [ + "--ctms-primary", + "--ctms-text-main", + "--ctms-bg-base", + "--ctms-radius", + "--ctms-shadow" +]; + +const requiredUnifiedTokens = [ + "--unified-shell-bg", + "--unified-shell-radius", + ".unified-action-bar", + ".unified-section" +]; + +const missing = [ + ...requiredMainTokens.filter((t) => !main.includes(t)), + ...requiredUnifiedTokens.filter((t) => !unified.includes(t)) +]; + +if (missing.length) { + console.error("UI contract check failed. Missing:", missing.join(", ")); + process.exit(1); +} + +console.log("UI contract check passed"); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && node scripts/verify-ui-contract.mjs` +Expected: FAIL (`ENOENT` for script file before creation, then missing-token failures until Task 2/3 done) + +**Step 3: Write minimal implementation** + +Update `frontend/package.json` scripts: + +```json +{ + "scripts": { + "ui:contract": "node scripts/verify-ui-contract.mjs" + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract` +Expected: PASS with `UI contract check passed` after Tasks 2/3 complete. + +**Step 5: Commit** + +```bash +git add frontend/scripts/verify-ui-contract.mjs frontend/package.json +git commit -m "chore(ui): add enterprise ui contract checks" +``` + +### Task 2: Harden Global Design Tokens (Single Source of Truth) + +**Files:** +- Modify: `frontend/src/styles/main.css` +- Test: `frontend/scripts/verify-ui-contract.mjs` + +**Step 1: Write the failing test** + +Add new required tokens into `requiredMainTokens` in `verify-ui-contract.mjs`: + +```js +"--ctms-brand-900", +"--ctms-brand-700", +"--ctms-neutral-100", +"--ctms-neutral-300", +"--ctms-focus-ring" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL listing the newly required tokens as missing. + +**Step 3: Write minimal implementation** + +In `frontend/src/styles/main.css`, define enterprise tokens in `:root`: + +```css +:root { + --ctms-brand-900: #13243a; + --ctms-brand-700: #24496f; + --ctms-neutral-100: #f5f7fa; + --ctms-neutral-300: #d5dce5; + --ctms-focus-ring: 0 0 0 3px rgba(36, 73, 111, 0.2); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract` +Expected: PASS for these tokens. + +**Step 5: Commit** + +```bash +git add frontend/src/styles/main.css frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): add enterprise token baseline" +``` + +### Task 3: Standardize Shell Layout & Page Container Contract + +**Files:** +- Modify: `frontend/src/styles/unified-page.css` +- Modify: `frontend/src/components/Layout.vue` +- Test: `frontend/scripts/verify-ui-contract.mjs` + +**Step 1: Write the failing test** + +Extend `requiredUnifiedTokens` with: + +```js +".ctms-page-shell", +".ctms-page-header-row", +".ctms-page-content-grid" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL showing missing shell classes. + +**Step 3: Write minimal implementation** + +Add shell classes in `unified-page.css`: + +```css +.ctms-page-shell { padding: 16px 20px 20px; } +.ctms-page-header-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.ctms-page-content-grid { display: grid; gap: 12px; } +``` + +In `Layout.vue`, ensure routed views are wrapped by a stable shell root class. + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add frontend/src/styles/unified-page.css frontend/src/components/Layout.vue frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): enforce shared page shell contract" +``` + +### Task 4: Create Shared List-Page Visual Pattern + +**Files:** +- Modify: `frontend/src/views/ia/ProjectMilestones.vue` +- Modify: `frontend/src/views/ia/SubjectManagement.vue` +- Modify: `frontend/src/views/ia/RiskIssueSae.vue` +- Modify: `frontend/src/views/ia/RiskIssuePd.vue` +- Modify: `frontend/src/views/ia/RiskIssueMonitoringVisits.vue` +- Test: `frontend/src/views/ia/*.vue` + +**Step 1: Write the failing test** + +In `verify-ui-contract.mjs`, add per-page checks that each target file includes: + +```js +"ctms-page-shell", +"unified-action-bar", +"ctms-table-card" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL on one or more target views. + +**Step 3: Write minimal implementation** + +Refactor each page template to the same structure: + +```vue +
+
...
+
...
+
+``` + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add frontend/src/views/ia/ProjectMilestones.vue frontend/src/views/ia/SubjectManagement.vue frontend/src/views/ia/RiskIssueSae.vue frontend/src/views/ia/RiskIssuePd.vue frontend/src/views/ia/RiskIssueMonitoringVisits.vue frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): unify list-page enterprise visual pattern" +``` + +### Task 5: Upgrade Executive Dashboard Surfaces + +**Files:** +- Modify: `frontend/src/views/ia/ProjectOverview.vue` +- Modify: `frontend/src/components/KpiCard.vue` +- Modify: `frontend/src/styles/unified-page.css` +- Test: `frontend/src/views/ia/ProjectOverview.vue` + +**Step 1: Write the failing test** + +Add checks requiring overview page contains: + +```js +"ctms-page-shell", +"kpi", +"unified-section" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL until overview/kpi structure is aligned. + +**Step 3: Write minimal implementation** + +- Ensure KPI cards use unified spacing, title scale, value prominence. +- Add deterministic class contract to KPI root: + +```vue +
...
+``` + +- Add style rules in `unified-page.css` for KPI rhythm and hierarchy. + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add frontend/src/views/ia/ProjectOverview.vue frontend/src/components/KpiCard.vue frontend/src/styles/unified-page.css frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): elevate project overview for executive readout" +``` + +### Task 6: Standardize Finance & File Management Pages + +**Files:** +- Modify: `frontend/src/views/fees/ContractFees.vue` +- Modify: `frontend/src/views/fees/SpecialExpenses.vue` +- Modify: `frontend/src/views/ia/FileVersionManagement.vue` +- Test: `frontend/src/views/fees/*.vue`, `frontend/src/views/ia/FileVersionManagement.vue` + +**Step 1: Write the failing test** + +Require common classes in target pages: + +```js +"ctms-page-shell", +"unified-action-bar", +"unified-shell" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL for non-conforming pages. + +**Step 3: Write minimal implementation** + +Refactor templates to shared contract and align action hierarchy: +- Primary action right-aligned. +- Secondary actions grouped. +- Filter and table blocks separated by unified sections. + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add frontend/src/views/fees/ContractFees.vue frontend/src/views/fees/SpecialExpenses.vue frontend/src/views/ia/FileVersionManagement.vue frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): unify finance and file management surfaces" +``` + +### Task 7: Standardize Admin High-Visibility Project Page + +**Files:** +- Modify: `frontend/src/views/admin/ProjectDetail.vue` +- Modify: `frontend/src/styles/unified-page.css` +- Test: `frontend/src/views/admin/ProjectDetail.vue` + +**Step 1: Write the failing test** + +Add contract checks for `ProjectDetail.vue` requiring: + +```js +"ctms-page-shell", +"unified-action-bar", +"unified-section" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL until shell contract is applied. + +**Step 3: Write minimal implementation** + +- Normalize section titles, spacing, card treatment. +- Remove page-specific visual overrides that conflict with token system. +- Keep business fields and logic unchanged. + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add frontend/src/views/admin/ProjectDetail.vue frontend/src/styles/unified-page.css frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): align admin project detail with enterprise system" +``` + +### Task 8: Unify Loading / Empty / Error States + +**Files:** +- Modify: `frontend/src/components/StateLoading.vue` +- Modify: `frontend/src/components/StateEmpty.vue` +- Modify: `frontend/src/components/StateError.vue` +- Modify: `frontend/src/styles/main.css` +- Test: `frontend/src/components/State*.vue` + +**Step 1: Write the failing test** + +Add checks requiring all three state components include: + +```js +"ctms-state", +"ctms-state-title", +"ctms-state-desc" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run ui:contract` +Expected: FAIL for missing class contract. + +**Step 3: Write minimal implementation** + +Refactor each component to common structure: + +```vue +
+

...

+

...

+
+``` + +Add shared state styles in `main.css`. + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add frontend/src/components/StateLoading.vue frontend/src/components/StateEmpty.vue frontend/src/components/StateError.vue frontend/src/styles/main.css frontend/scripts/verify-ui-contract.mjs +git commit -m "feat(ui): unify loading empty error states" +``` + +### Task 9: Verification, Demo Path, and Release Gate + +**Files:** +- Create: `docs/ui/enterprise-ui-acceptance-checklist.md` +- Modify: `docs/release-checklist.md` +- Test: manual smoke + build checks + +**Step 1: Write the failing test** + +Create acceptance checklist with explicit pass/fail items. Mark all as pending initially. + +```md +- [ ] Overview and milestones visual consistency +- [ ] Risk pages visual consistency +- [ ] Finance pages visual consistency +- [ ] File/admin detail visual consistency +- [ ] Desktop and iPad width checks +- [ ] Management demo path continuity +``` + +**Step 2: Run test to verify it fails** + +Run: `cd frontend && npm run type-check && npm run build` +Expected: PASS technically, but acceptance checklist remains FAIL/PENDING. + +**Step 3: Write minimal implementation** + +- Execute manual smoke on target routes. +- Update checklist to PASS with reviewer/date notes. +- Add release gating notes to `docs/release-checklist.md`. + +**Step 4: Run test to verify it passes** + +Run: `cd frontend && npm run ui:contract && npm run type-check && npm run build` +Expected: PASS for all commands and checklist completed. + +**Step 5: Commit** + +```bash +git add docs/ui/enterprise-ui-acceptance-checklist.md docs/release-checklist.md +git commit -m "docs(ui): add enterprise refresh acceptance and release gates" +``` + +## Milestone Mapping + +1. **M1 (Day 1-3):** Tasks 1-3 + sample page alignment. +2. **M2 (Day 4-7):** Tasks 4-5 (first 6 core pages). +3. **M3 (Day 8-10):** Tasks 6-8 (remaining 4 pages + states). +4. **M4 (Day 11-14):** Task 9 + demo rehearsal + release gate. + +## Definition of Done + +1. `npm run ui:contract` passes. +2. `npm run type-check` passes. +3. `npm run build` passes. +4. Acceptance checklist fully marked PASS with reviewer/date. +5. No business logic behavior regressions in smoke path. diff --git a/docs/plans/2026-03-05-rd-monitor-design.md b/docs/plans/2026-03-05-rd-monitor-design.md new file mode 100644 index 00000000..6df62d8c --- /dev/null +++ b/docs/plans/2026-03-05-rd-monitor-design.md @@ -0,0 +1,97 @@ +# 研发项目进展监测系统(rd-monitor)设计文档 + +## 1. 目标与范围 +- 基于现有 CTMS 的界面/组件经验,建设一个完全独立的研发项目进展监测系统。 +- 新系统独立前后端、独立数据库、独立鉴权与权限体系。 +- 首期仅实现 PDF《项目进度概览 v2》中的核心功能:概览展示、进度录入、历史变更、导出、项目隐藏规则。 + +## 2. 系统边界 +- 独立仓库:`rd-monitor` +- 技术栈:Vue3 + Element Plus + FastAPI + SQLAlchemy + Alembic + PostgreSQL +- 与 CTMS 关系:仅复用可迁移的组件/样式实现,不依赖 CTMS API 与 CTMS 数据表。 + +## 3. 数据模型 +### 3.1 users +- `id` +- `username` +- `email` +- `password_hash` +- `role` (`ADMIN`/`PM`/`VIEWER`) +- `is_active` +- `created_at` + +### 3.2 projects +- `id` +- `project_code`(如 HP-001) +- `project_name` +- `regulatory_path` +- `status`(`ACTIVE`/`APPROVED`/`TERMINATED`) +- `created_at` + +### 3.3 stage_templates +- `id` +- `stage_key` +- `stage_name` +- `order_no` +- `regulatory_paths`(JSON 数组;空表示通用) +- `is_active` + +### 3.4 project_stages +- `id` +- `project_id` +- `stage_template_id` +- `order_no` +- `planned_start_date` +- `planned_end_date` +- `actual_finish_date` +- `actual_finish_flag`(`DONE`/`NOT_APPLICABLE`/`PENDING`) +- `adjust_reason` +- `remark` +- `updated_at` + +### 3.5 project_stage_change_logs +- `id` +- `project_stage_id` +- `changed_by` +- `before_json` +- `after_json` +- `comment` +- `created_at` + +## 4. 状态规则(按 PDF) +- `已完成`:`actual_finish_flag = DONE` 且 `actual_finish_date` 有值。 +- `未进行`:`actual_finish_flag = NOT_APPLICABLE`(“此阶段不开展”)。 +- `正进行`:当前阶段未完成/未进行,且上一阶段状态为 `已完成`。 +- `未开始`:其余情况。 +- 概览显示日期:使用 `planned_end_date`。 +- 项目隐藏:`APPROVED` 或 `TERMINATED` 不在概览列表中显示。 + +## 5. 页面与交互 +- 登录页(JWT) +- 概览页:按项目展示阶段节点、计划完成日期、状态图例 +- 录入页:项目阶段表格编辑,支持“此阶段不开展” +- 历史页:阶段变更历史时间线/表格 +- 导出:授权用户导出 CSV + +## 6. 权限模型 +- `ADMIN`:用户/项目/模板管理 + 全部导出 +- `PM`:项目进度录入、历史查看、导出 +- `VIEWER`:只读(概览、历史) + +## 7. API(首期) +- `POST /api/v1/auth/login` +- `GET /api/v1/auth/me` +- `GET /api/v1/projects` +- `POST /api/v1/projects`(ADMIN) +- `GET /api/v1/stage-templates` +- `POST /api/v1/stage-templates`(ADMIN) +- `GET /api/v1/projects/{id}/stages` +- `PATCH /api/v1/projects/{id}/stages/{stageId}`(PM/ADMIN) +- `GET /api/v1/projects/{id}/stages/history` +- `GET /api/v1/projects/stages/export?format=csv`(PM/ADMIN) + +## 8. 验收标准 +- 状态计算规则与 PDF 一致 +- 录入保存会生成历史记录 +- 导出字段与录入字段一致 +- `APPROVED/TERMINATED` 项目不在概览展示 diff --git a/docs/plans/2026-03-05-rd-monitor-implementation.md b/docs/plans/2026-03-05-rd-monitor-implementation.md new file mode 100644 index 00000000..3f4c1189 --- /dev/null +++ b/docs/plans/2026-03-05-rd-monitor-implementation.md @@ -0,0 +1,137 @@ +# rd-monitor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 搭建独立仓库 `rd-monitor`,实现研发项目进展监测 MVP(独立鉴权、项目阶段录入、状态计算、历史留痕、CSV 导出、前端可用界面)。 + +**Architecture:** 后端采用 FastAPI + SQLAlchemy + PostgreSQL,提供鉴权、项目阶段、历史、导出 API;前端采用 Vue3 + Element Plus,提供登录、概览、录入、历史页面。通过 Docker Compose 启动前后端与独立 PostgreSQL。 + +**Tech Stack:** Vue3, Vite, Element Plus, FastAPI, SQLAlchemy, Alembic, PostgreSQL, JWT, Pytest + +--- + +### Task 1: 初始化仓库结构与运行骨架 + +**Files:** +- Create: `/Users/zcc/MyCTMS/rd-monitor/README.md` +- Create: `/Users/zcc/MyCTMS/rd-monitor/docker-compose.yml` +- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/requirements.txt` +- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/.env.example` +- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/package.json` +- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/vite.config.ts` + +**Step 1: Write the failing test** +- 编写后端 smoke 测试,断言 `/healthz` 返回 200(此时接口不存在,应失败)。 + +**Step 2: Run test to verify it fails** +- Run: `cd /Users/zcc/MyCTMS/rd-monitor/backend && pytest -q` +- Expected: FAIL(缺少 app 或路由)。 + +**Step 3: Write minimal implementation** +- 建立 FastAPI app 与 `/healthz` 路由,补全基础依赖与 compose。 + +**Step 4: Run test to verify it passes** +- Run: `cd /Users/zcc/MyCTMS/rd-monitor/backend && pytest -q` +- Expected: PASS。 + +**Step 5: Commit** +- `git add ... && git commit -m "chore: bootstrap rd-monitor skeleton"` + +### Task 2: 实现后端核心领域模型与状态计算 + +**Files:** +- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/app/models/*.py` +- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/app/services/progress.py` +- Test: `/Users/zcc/MyCTMS/rd-monitor/backend/tests/test_progress_status.py` + +**Step 1: Write the failing test** +- 用测试覆盖阶段状态计算(已完成/未进行/正进行/未开始)与项目过滤规则。 + +**Step 2: Run test to verify it fails** +- Run: `pytest tests/test_progress_status.py -q` +- Expected: FAIL。 + +**Step 3: Write minimal implementation** +- 实现状态计算函数与项目过滤函数。 + +**Step 4: Run test to verify it passes** +- Run: `pytest tests/test_progress_status.py -q` +- Expected: PASS。 + +**Step 5: Commit** +- `git commit -m "feat: add progress status domain rules"` + +### Task 3: 实现鉴权、项目阶段 API、历史记录与导出 + +**Files:** +- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/api/*.py` +- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/schemas/*.py` +- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/core/security.py` +- Test: `/Users/zcc/MyCTMS/rd-monitor/backend/tests/test_stage_api.py` + +**Step 1: Write the failing test** +- 测试登录、查询项目阶段、更新阶段后历史新增、导出 CSV、权限拦截。 + +**Step 2: Run test to verify it fails** +- Run: `pytest tests/test_stage_api.py -q` +- Expected: FAIL。 + +**Step 3: Write minimal implementation** +- 完成相关 API 与依赖。 + +**Step 4: Run test to verify it passes** +- Run: `pytest tests/test_stage_api.py -q` +- Expected: PASS。 + +**Step 5: Commit** +- `git commit -m "feat: implement progress APIs with auth history and export"` + +### Task 4: 实现前端登录、概览、录入、历史页面 + +**Files:** +- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/views/*.vue` +- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/router/index.ts` +- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/api/*.ts` +- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/components/*.vue` + +**Step 1: Write the failing test** +- 首期用类型检查/构建作为回归保护,先运行应失败(页面未实现)。 + +**Step 2: Run test to verify it fails** +- Run: `cd /Users/zcc/MyCTMS/rd-monitor/frontend && npm run build` +- Expected: FAIL。 + +**Step 3: Write minimal implementation** +- 实现可登录、可浏览项目阶段、可编辑阶段、可看历史、可导出。 + +**Step 4: Run test to verify it passes** +- Run: `npm run build` +- Expected: PASS。 + +**Step 5: Commit** +- `git commit -m "feat: add frontend pages for monitoring workflow"` + +### Task 5: 联调与最终验证 + +**Files:** +- Modify: `/Users/zcc/MyCTMS/rd-monitor/README.md` + +**Step 1: Write the failing test** +- 先运行端到端 smoke(未配置好前应失败)。 + +**Step 2: Run test to verify it fails** +- Run: `docker compose up --build -d && curl -f http://localhost:8010/healthz` +- Expected: 初次 FAIL 或服务未就绪。 + +**Step 3: Write minimal implementation** +- 修正配置/环境变量/文档。 + +**Step 4: Run test to verify it passes** +- Run: `docker compose up --build -d` +- Run: `curl -f http://localhost:8010/healthz` +- Run: `cd backend && pytest -q` +- Run: `cd frontend && npm run build` +- Expected: 全部 PASS。 + +**Step 5: Commit** +- `git commit -m "docs: finalize rd-monitor setup and verification"` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd5ea96e..876fc3f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,11 +18,27 @@ "devDependencies": { "@types/node": "^20.10.5", "@vitejs/plugin-vue": "^6.0.3", + "jsdom": "^26.1.0", "typescript": "^5.3.3", "vite": "^7.3.1", + "vitest": "^3.2.4", "vue-tsc": "^3.2.2" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "license": "MIT", @@ -61,6 +77,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.6.1", "license": "MIT", @@ -848,6 +979,24 @@ "win32" ] }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -860,7 +1009,6 @@ "node_modules/@types/lodash-es": { "version": "4.17.12", "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -869,7 +1017,6 @@ "version": "20.19.27", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -895,6 +1042,131 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.27", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", @@ -1056,6 +1328,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/alien-signals": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", @@ -1063,6 +1345,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-validator": { "version": "4.2.5", "license": "MIT" @@ -1080,6 +1372,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "license": "MIT", @@ -1091,6 +1393,33 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -1101,10 +1430,38 @@ "node": ">= 0.8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -1119,6 +1476,41 @@ "version": "1.11.19", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "license": "MIT", @@ -1185,6 +1577,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -1254,6 +1653,16 @@ "version": "2.0.2", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1402,15 +1811,121 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lodash": { "version": "4.17.21", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -1421,6 +1936,20 @@ "lodash-es": "*" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "license": "MIT", @@ -1456,6 +1985,13 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -1483,6 +2019,39 @@ "version": "1.2.0", "license": "BSD-3-Clause" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1490,6 +2059,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -1500,7 +2086,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1558,6 +2143,16 @@ "version": "1.1.0", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rollup": { "version": "4.53.5", "dev": true, @@ -1612,6 +2207,40 @@ "darwin" ] }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -1619,6 +2248,54 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1636,11 +2313,86 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1660,7 +2412,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1730,6 +2481,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -1740,7 +2587,6 @@ "node_modules/vue": { "version": "3.5.25", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -1810,6 +2656,123 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 9621bd0c..a6396332 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test:unit": "vitest run --environment jsdom", "type-check": "vue-tsc --noEmit", "ui:contract": "node scripts/verify-ui-contract.mjs" }, @@ -21,8 +22,10 @@ "devDependencies": { "@types/node": "^20.10.5", "@vitejs/plugin-vue": "^6.0.3", + "jsdom": "^26.1.0", "typescript": "^5.3.3", "vite": "^7.3.1", + "vitest": "^3.2.4", "vue-tsc": "^3.2.2" } } diff --git a/frontend/src/components/LockScreenModal.vue b/frontend/src/components/LockScreenModal.vue index ddc0682b..4fdba4f1 100644 --- a/frontend/src/components/LockScreenModal.vue +++ b/frontend/src/components/LockScreenModal.vue @@ -51,7 +51,7 @@ const pad2 = (value: number) => String(value).padStart(2, "0"); const autoLogoutCountdown = computed(() => { const fallbackDeadlineAt = - Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; + Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt) + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; const deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt; const remainMs = Math.max(0, deadlineAt - nowTs.value); const remainSec = Math.floor(remainMs / 1000); diff --git a/frontend/src/session/sessionManager.test.ts b/frontend/src/session/sessionManager.test.ts new file mode 100644 index 00000000..794fd447 --- /dev/null +++ b/frontend/src/session/sessionManager.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +vi.mock("../router", () => ({ + default: { + replace: vi.fn(), + }, +})); + +vi.mock("../api/authClient", () => ({ + extendToken: vi.fn(), + unlockSession: vi.fn(), +})); + +describe("session manager idle recovery", () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + setActivePinia(createPinia()); + const storage = (() => { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, String(value)); + }, + removeItem: (key: string) => { + data.delete(key); + }, + clear: () => { + data.clear(); + }, + }; + })(); + Object.defineProperty(window, "localStorage", { + value: storage, + configurable: true, + }); + Object.defineProperty(window, "sessionStorage", { + value: storage, + configurable: true, + }); + Object.defineProperty(window, "BroadcastChannel", { + value: class { + onmessage: ((event: MessageEvent) => void) | null = null; + postMessage() {} + close() {} + }, + configurable: true, + }); + }); + + it("locks instead of refreshing activity after long inactivity when user activity resumes", async () => { + vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); + const { useSessionStore } = await import("../store/session"); + const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markUserActive } = await import("./sessionManager"); + const session = useSessionStore(); + const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000); + + session.recordUserActivity(oldTs); + session.recordNetworkActivity(oldTs); + + markUserActive(Date.now()); + + expect(session.locked).toBe(true); + expect(session.lockReason).toBe("idle"); + expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000); + }); + + it("locks instead of refreshing activity after long inactivity when network activity resumes", async () => { + vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); + const { useSessionStore } = await import("../store/session"); + const { IDLE_TIMEOUT_MINUTES, AUTO_LOGOUT_TIMEOUT_HOURS, markNetworkActive } = await import("./sessionManager"); + const session = useSessionStore(); + const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000); + + session.recordUserActivity(oldTs); + session.recordNetworkActivity(oldTs); + + markNetworkActive(Date.now()); + + expect(session.locked).toBe(true); + expect(session.lockReason).toBe("idle"); + expect(session.lockAutoLogoutDeadlineAt).toBe(oldTs + AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000); + }); +}); diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts index 67d76963..0157e415 100644 --- a/frontend/src/session/sessionManager.ts +++ b/frontend/src/session/sessionManager.ts @@ -25,6 +25,26 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null let initialized = false; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null; +const getLastActiveAt = (session: ReturnType) => + Math.max(session.lastUserActiveAt, session.lastNetworkActiveAt); + +const reconcileSessionState = (now: number = Date.now()) => { + const session = useSessionStore(); + if (session.locked) return false; + const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; + const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; + const lastActiveAt = getLastActiveAt(session); + if (now - lastActiveAt > autoLogoutMs) { + forceLogout(LOGOUT_REASON_TIMEOUT); + return false; + } + if (now - lastActiveAt > idleTimeoutMs) { + lockSession("idle"); + return false; + } + return true; +}; + const broadcast = (message: BroadcastMessage) => { if (channel) { channel.postMessage(message); @@ -81,9 +101,9 @@ const scheduleIdleCheck = () => { } const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; - const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt); - const idleRemain = baseActiveAt + idleTimeoutMs - now; - const logoutRemain = baseActiveAt + autoLogoutMs - now; + const lastActiveAt = getLastActiveAt(session); + const idleRemain = lastActiveAt + idleTimeoutMs - now; + const logoutRemain = lastActiveAt + autoLogoutMs - now; const nextCheck = Math.min(idleRemain, logoutRemain); if (nextCheck <= 0) { checkIdle(); @@ -103,19 +123,7 @@ const checkIdle = () => { scheduleIdleCheck(); return; } - const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; - const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; - const now = Date.now(); - const idleElapsed = now - session.lastUserActiveAt > idleTimeoutMs && now - session.lastNetworkActiveAt > idleTimeoutMs; - const autoLogoutElapsed = - now - session.lastUserActiveAt > autoLogoutMs && now - session.lastNetworkActiveAt > autoLogoutMs; - if (autoLogoutElapsed) { - forceLogout(LOGOUT_REASON_TIMEOUT); - return; - } - if (idleElapsed) { - lockSession("idle"); - } + if (!reconcileSessionState(Date.now())) return; scheduleIdleCheck(); }; @@ -131,6 +139,14 @@ export const initSessionManager = () => { markUserActive(now); }; events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true })); + window.addEventListener("focus", () => { + checkIdle(); + }); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + checkIdle(); + } + }); if (channel) { channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage); } @@ -150,6 +166,7 @@ export const initSessionManager = () => { export const markUserActive = (at: number = Date.now()) => { const session = useSessionStore(); if (session.locked) return; + if (!reconcileSessionState(at)) return; session.recordUserActivity(at); broadcast({ type: "ACTIVE", at }); scheduleIdleCheck(); @@ -158,6 +175,7 @@ export const markUserActive = (at: number = Date.now()) => { export const markNetworkActive = (at: number = Date.now()) => { const session = useSessionStore(); if (session.locked) return; + if (!reconcileSessionState(at)) return; session.recordNetworkActivity(at); broadcast({ type: "NETWORK", at }); scheduleIdleCheck(); @@ -169,7 +187,7 @@ export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") if (session.locked) return; const email = auth.user?.email || ""; const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000; - const deadlineAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + autoLogoutMs; + const deadlineAt = getLastActiveAt(session) + autoLogoutMs; session.lock(reason, email, deadlineAt); broadcast({ type: "LOCK", reason, email, deadlineAt }); scheduleIdleCheck(); @@ -264,7 +282,7 @@ export const startTokenKeepAlive = () => { if (!expAt) return; const remaining = expAt - Date.now(); const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; - const active = Date.now() - session.lastUserActiveAt < timeoutMs || Date.now() - session.lastNetworkActiveAt < timeoutMs; + const active = Date.now() - getLastActiveAt(session) < timeoutMs; if (active && remaining < EXTEND_EARLY_SECONDS * 1000) { void extendAccessToken("early"); } diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts new file mode 100644 index 00000000..67d5cc1a --- /dev/null +++ b/frontend/src/store/auth.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +type StorageLike = { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; + clear: () => void; +}; + +const createStorage = (): StorageLike => { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, String(value)); + }, + removeItem: (key) => { + data.delete(key); + }, + clear: () => { + data.clear(); + }, + }; +}; + +describe("auth store logout", () => { + beforeEach(() => { + setActivePinia(createPinia()); + Object.defineProperty(window, "localStorage", { + value: createStorage(), + configurable: true, + }); + }); + + it("clears session lock state when logging out", async () => { + const { useAuthStore } = await import("./auth"); + const { useSessionStore } = await import("./session"); + const session = useSessionStore(); + const auth = useAuthStore(); + + session.lock("token_invalid", "demo@example.com", Date.now() + 60_000); + auth.logout(); + + expect(session.locked).toBe(false); + expect(session.lockReason).toBeNull(); + expect(session.lockEmail).toBe(""); + }); +}); diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 5239f633..18a5979e 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -43,11 +43,13 @@ export const useAuthStore = defineStore("auth", () => { const logout = () => { const studyStore = useStudyStore(); + const sessionStore = useSessionStore(); const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || ""; studyStore.rememberCurrentStudyForUser(userKey); token.value = null; user.value = null; forceLogin.value = false; + sessionStore.unlock(); clearToken(); studyStore.clearCurrentStudy(); }; diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 669ffc86..7191cd5b 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,117 +1,101 @@