登录页UI及长时间未操作锁定逻辑优化
This commit is contained in:
@@ -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)。
|
||||||
@@ -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
|
||||||
|
<div class="ctms-page-shell">
|
||||||
|
<section class="unified-action-bar">...</section>
|
||||||
|
<section class="unified-shell ctms-table-card">...</section>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
<article class="kpi-card kpi-enterprise">...</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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
|
||||||
|
<section class="ctms-state">
|
||||||
|
<h3 class="ctms-state-title">...</h3>
|
||||||
|
<p class="ctms-state-desc">...</p>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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` 项目不在概览展示
|
||||||
@@ -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"`
|
||||||
Generated
+973
-10
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest run --environment jsdom",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||||
},
|
},
|
||||||
@@ -21,8 +22,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
"vue-tsc": "^3.2.2"
|
"vue-tsc": "^3.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const pad2 = (value: number) => String(value).padStart(2, "0");
|
|||||||
|
|
||||||
const autoLogoutCountdown = computed(() => {
|
const autoLogoutCountdown = computed(() => {
|
||||||
const fallbackDeadlineAt =
|
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 deadlineAt = session.lockAutoLogoutDeadlineAt || fallbackDeadlineAt;
|
||||||
const remainMs = Math.max(0, deadlineAt - nowTs.value);
|
const remainMs = Math.max(0, deadlineAt - nowTs.value);
|
||||||
const remainSec = Math.floor(remainMs / 1000);
|
const remainSec = Math.floor(remainMs / 1000);
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,26 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
|
|||||||
let initialized = false;
|
let initialized = false;
|
||||||
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
|
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
|
||||||
|
|
||||||
|
const getLastActiveAt = (session: ReturnType<typeof useSessionStore>) =>
|
||||||
|
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) => {
|
const broadcast = (message: BroadcastMessage) => {
|
||||||
if (channel) {
|
if (channel) {
|
||||||
channel.postMessage(message);
|
channel.postMessage(message);
|
||||||
@@ -81,9 +101,9 @@ const scheduleIdleCheck = () => {
|
|||||||
}
|
}
|
||||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
||||||
const baseActiveAt = Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt);
|
const lastActiveAt = getLastActiveAt(session);
|
||||||
const idleRemain = baseActiveAt + idleTimeoutMs - now;
|
const idleRemain = lastActiveAt + idleTimeoutMs - now;
|
||||||
const logoutRemain = baseActiveAt + autoLogoutMs - now;
|
const logoutRemain = lastActiveAt + autoLogoutMs - now;
|
||||||
const nextCheck = Math.min(idleRemain, logoutRemain);
|
const nextCheck = Math.min(idleRemain, logoutRemain);
|
||||||
if (nextCheck <= 0) {
|
if (nextCheck <= 0) {
|
||||||
checkIdle();
|
checkIdle();
|
||||||
@@ -103,19 +123,7 @@ const checkIdle = () => {
|
|||||||
scheduleIdleCheck();
|
scheduleIdleCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idleTimeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
if (!reconcileSessionState(Date.now())) return;
|
||||||
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");
|
|
||||||
}
|
|
||||||
scheduleIdleCheck();
|
scheduleIdleCheck();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +139,14 @@ export const initSessionManager = () => {
|
|||||||
markUserActive(now);
|
markUserActive(now);
|
||||||
};
|
};
|
||||||
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
|
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
|
||||||
|
window.addEventListener("focus", () => {
|
||||||
|
checkIdle();
|
||||||
|
});
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
checkIdle();
|
||||||
|
}
|
||||||
|
});
|
||||||
if (channel) {
|
if (channel) {
|
||||||
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
|
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
|
||||||
}
|
}
|
||||||
@@ -150,6 +166,7 @@ export const initSessionManager = () => {
|
|||||||
export const markUserActive = (at: number = Date.now()) => {
|
export const markUserActive = (at: number = Date.now()) => {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
if (session.locked) return;
|
if (session.locked) return;
|
||||||
|
if (!reconcileSessionState(at)) return;
|
||||||
session.recordUserActivity(at);
|
session.recordUserActivity(at);
|
||||||
broadcast({ type: "ACTIVE", at });
|
broadcast({ type: "ACTIVE", at });
|
||||||
scheduleIdleCheck();
|
scheduleIdleCheck();
|
||||||
@@ -158,6 +175,7 @@ export const markUserActive = (at: number = Date.now()) => {
|
|||||||
export const markNetworkActive = (at: number = Date.now()) => {
|
export const markNetworkActive = (at: number = Date.now()) => {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
if (session.locked) return;
|
if (session.locked) return;
|
||||||
|
if (!reconcileSessionState(at)) return;
|
||||||
session.recordNetworkActivity(at);
|
session.recordNetworkActivity(at);
|
||||||
broadcast({ type: "NETWORK", at });
|
broadcast({ type: "NETWORK", at });
|
||||||
scheduleIdleCheck();
|
scheduleIdleCheck();
|
||||||
@@ -169,7 +187,7 @@ export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid")
|
|||||||
if (session.locked) return;
|
if (session.locked) return;
|
||||||
const email = auth.user?.email || "";
|
const email = auth.user?.email || "";
|
||||||
const autoLogoutMs = AUTO_LOGOUT_TIMEOUT_HOURS * 60 * 60 * 1000;
|
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);
|
session.lock(reason, email, deadlineAt);
|
||||||
broadcast({ type: "LOCK", reason, email, deadlineAt });
|
broadcast({ type: "LOCK", reason, email, deadlineAt });
|
||||||
scheduleIdleCheck();
|
scheduleIdleCheck();
|
||||||
@@ -264,7 +282,7 @@ export const startTokenKeepAlive = () => {
|
|||||||
if (!expAt) return;
|
if (!expAt) return;
|
||||||
const remaining = expAt - Date.now();
|
const remaining = expAt - Date.now();
|
||||||
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
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) {
|
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
|
||||||
void extendAccessToken("early");
|
void extendAccessToken("early");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,11 +43,13 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
|
const sessionStore = useSessionStore();
|
||||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||||
studyStore.rememberCurrentStudyForUser(userKey);
|
studyStore.rememberCurrentStudyForUser(userKey);
|
||||||
token.value = null;
|
token.value = null;
|
||||||
user.value = null;
|
user.value = null;
|
||||||
forceLogin.value = false;
|
forceLogin.value = false;
|
||||||
|
sessionStore.unlock();
|
||||||
clearToken();
|
clearToken();
|
||||||
studyStore.clearCurrentStudy();
|
studyStore.clearCurrentStudy();
|
||||||
};
|
};
|
||||||
|
|||||||
+224
-441
@@ -1,117 +1,101 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<div class="login-wrapper">
|
||||||
<!-- Vanta.js 网络背景 -->
|
<div class="login-bg-shape shape-1"></div>
|
||||||
<div ref="vantaCanvas" id="vanta-canvas" class="vanta-background"></div>
|
<div class="login-bg-shape shape-2"></div>
|
||||||
|
|
||||||
<!-- 登录卡片 -->
|
<div class="login-container">
|
||||||
<div class="login-wrapper">
|
<div class="login-card">
|
||||||
<div class="max-w-md w-full card-container">
|
<div class="login-header">
|
||||||
<div class="card-content">
|
<div class="logo-box">
|
||||||
<!-- 顶部装饰区域 -->
|
<svg
|
||||||
<div class="login-header" id="vanta-header">
|
width="28"
|
||||||
<!-- 左上角:安全访问 -->
|
height="28"
|
||||||
<div class="header-left">
|
viewBox="0 0 24 24"
|
||||||
<span class="secure-badge">安全访问</span>
|
fill="none"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
<!-- 中间:CTMS -->
|
stroke-linecap="round"
|
||||||
<div class="header-center">
|
stroke-linejoin="round"
|
||||||
<div class="ctms-logo">CTMS</div>
|
class="logo-icon"
|
||||||
<div class="header-divider"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 登录表单内容 -->
|
|
||||||
<div class="login-form-section">
|
|
||||||
<div class="form-label-section">
|
|
||||||
<span class="auth-badge">身份验证</span>
|
|
||||||
<h3 class="form-title">账户登录</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form
|
|
||||||
ref="formRef"
|
|
||||||
:model="form"
|
|
||||||
:rules="rules"
|
|
||||||
@keyup.enter="onSubmit"
|
|
||||||
class="login-form"
|
|
||||||
>
|
>
|
||||||
<!-- 邮箱字段 -->
|
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||||
<div class="form-group">
|
<path d="M2 17l10 5 10-5"></path>
|
||||||
<label for="email" class="input-label">邮箱</label>
|
<path d="M2 12l10 5 10-5"></path>
|
||||||
<el-form-item prop="email">
|
</svg>
|
||||||
<el-input
|
|
||||||
id="email"
|
|
||||||
v-model="form.email"
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
autocomplete="email"
|
|
||||||
class="custom-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 密码字段 -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="password-label-row">
|
|
||||||
<label for="password" class="input-label">密码</label>
|
|
||||||
<a href="#" class="forgot-link">忘记密码?</a>
|
|
||||||
</div>
|
|
||||||
<el-form-item prop="password">
|
|
||||||
<el-input
|
|
||||||
id="password"
|
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
autocomplete="current-password"
|
|
||||||
show-password
|
|
||||||
class="custom-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 记住设备 -->
|
|
||||||
<div class="remember-row">
|
|
||||||
<el-checkbox id="remember" v-model="form.remember" class="custom-checkbox">
|
|
||||||
<span class="remember-text">记住此设备</span>
|
|
||||||
</el-checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 登录按钮 -->
|
|
||||||
<div class="button-group">
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:loading="loading"
|
|
||||||
@click="onSubmit"
|
|
||||||
class="login-btn"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="btn-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
|
||||||
</svg>
|
|
||||||
登录
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<!-- 底部信息 -->
|
|
||||||
<div class="footer-section">
|
|
||||||
<div class="footer-divider"></div>
|
|
||||||
<p class="footer-text">
|
|
||||||
还没有账户? <RouterLink to="/register" class="footer-link">立即注册</RouterLink>
|
|
||||||
</p>
|
|
||||||
<div class="status-row">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
<span class="status-text">系统状态:正常运行</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="sys-title">CTMS 临床试验管理系统</h2>
|
||||||
|
<p class="sys-subtitle">Log in to your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
@keyup.enter="onSubmit"
|
||||||
|
label-position="top"
|
||||||
|
class="login-form"
|
||||||
|
>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
size="large"
|
||||||
|
autocomplete="email"
|
||||||
|
class="login-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
show-password
|
||||||
|
size="large"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="login-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="remember-row">
|
||||||
|
<el-checkbox id="remember" v-model="form.remember" class="remember-checkbox">
|
||||||
|
<span class="remember-text">记住此设备</span>
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<p class="footer-text">
|
||||||
|
还没有账户?
|
||||||
|
<RouterLink to="/register" class="footer-link">立即注册</RouterLink>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted, onUnmounted } from "vue";
|
import { reactive, ref, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
@@ -124,7 +108,6 @@ const auth = useAuthStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const vantaCanvas = ref<HTMLDivElement>();
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -140,9 +123,7 @@ const rules: FormRules<typeof form> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
let vantaEffect: any = null;
|
|
||||||
|
|
||||||
// 初始化 Vanta.js 效果
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const logoutReason = consumeLogoutReason();
|
const logoutReason = consumeLogoutReason();
|
||||||
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
if (logoutReason === LOGOUT_REASON_TIMEOUT) {
|
||||||
@@ -154,61 +135,8 @@ onMounted(() => {
|
|||||||
form.password = cached.password;
|
form.password = cached.password;
|
||||||
form.remember = true;
|
form.remember = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动态加载 Three.js 和 Vanta.js
|
|
||||||
loadVantaEffect();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (vantaEffect) {
|
|
||||||
vantaEffect.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载 Vanta.js 网络效果
|
|
||||||
const loadVantaEffect = () => {
|
|
||||||
// 检查是否已加载
|
|
||||||
if ((window as any).VANTA && (window as any).THREE) {
|
|
||||||
initVanta();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载 Three.js
|
|
||||||
const threeScript = document.createElement('script');
|
|
||||||
threeScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js';
|
|
||||||
threeScript.onload = () => {
|
|
||||||
// 加载 Vanta.js NET 效果
|
|
||||||
const vantaScript = document.createElement('script');
|
|
||||||
vantaScript.src = 'https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.net.min.js';
|
|
||||||
vantaScript.onload = () => {
|
|
||||||
initVanta();
|
|
||||||
};
|
|
||||||
document.head.appendChild(vantaScript);
|
|
||||||
};
|
|
||||||
document.head.appendChild(threeScript);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initVanta = () => {
|
|
||||||
if (vantaCanvas.value && (window as any).VANTA) {
|
|
||||||
vantaEffect = (window as any).VANTA.NET({
|
|
||||||
el: vantaCanvas.value,
|
|
||||||
mouseControls: true,
|
|
||||||
touchControls: true,
|
|
||||||
gyroControls: false,
|
|
||||||
minHeight: 200.00,
|
|
||||||
minWidth: 200.00,
|
|
||||||
scale: 1.00,
|
|
||||||
scaleMobile: 1.00,
|
|
||||||
color: 0xd1d5db,
|
|
||||||
backgroundColor: 0x171717,
|
|
||||||
points: 8,
|
|
||||||
maxDistance: 20.00,
|
|
||||||
spacing: 18.00,
|
|
||||||
showDots: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!formRef.value) return;
|
if (!formRef.value) return;
|
||||||
const valid = await formRef.value.validate();
|
const valid = await formRef.value.validate();
|
||||||
@@ -251,364 +179,219 @@ const onSubmit = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 导入 Inter 字体 */
|
.login-wrapper {
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
position: relative;
|
||||||
|
|
||||||
/* 主容器 */
|
|
||||||
.login-container {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: #0a0a0a;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1rem;
|
background-color: #f8fafc;
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vanta 背景 */
|
|
||||||
.vanta-background {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 登录包装器 */
|
.login-bg-shape {
|
||||||
.login-wrapper {
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: float 10s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-1 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: #c0cdd7;
|
||||||
|
top: -100px;
|
||||||
|
left: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-2 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: #dde5ec;
|
||||||
|
bottom: -150px;
|
||||||
|
right: -100px;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(30px, 50px) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 28rem;
|
max-width: 440px;
|
||||||
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片容器 - 带渐变边框效果 */
|
.login-card {
|
||||||
.card-container {
|
background: rgba(255, 255, 255, 0.95);
|
||||||
position: relative;
|
backdrop-filter: blur(20px);
|
||||||
z-index: 0;
|
border-radius: 20px;
|
||||||
|
padding: 48px 40px 28px;
|
||||||
|
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-container::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -1px;
|
|
||||||
background: linear-gradient(to bottom right, #525252, transparent, #262626);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片内容 */
|
|
||||||
.card-content {
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #171717;
|
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 登录头部 */
|
|
||||||
.login-header {
|
.login-header {
|
||||||
height: 140px;
|
text-align: center;
|
||||||
position: relative;
|
margin-bottom: 36px;
|
||||||
background: #171717;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左上角 */
|
.logo-box {
|
||||||
.header-left {
|
display: inline-flex;
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
left: 1rem;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 中间区域 */
|
|
||||||
.header-center {
|
|
||||||
position: absolute;
|
|
||||||
top: 70%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(135deg, #3f5d75, #24496f);
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 8px 20px -6px rgba(63, 93, 117, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CTMS Logo */
|
.sys-title {
|
||||||
.ctms-logo {
|
font-size: 24px;
|
||||||
font-size: 3.5rem;
|
font-weight: 700;
|
||||||
font-weight: 800;
|
color: #1e293b;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
margin: 0 0 8px;
|
||||||
-webkit-background-clip: text;
|
letter-spacing: -0.5px;
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
text-shadow: 0 0 30px rgba(102, 126, 234, 0.3);
|
|
||||||
animation: glow 3s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes glow {
|
.sys-subtitle {
|
||||||
0%, 100% {
|
font-size: 14px;
|
||||||
filter: drop-shadow(0 0 8px rgba(102, 126, 234, 0.4));
|
color: #64748b;
|
||||||
}
|
|
||||||
50% {
|
|
||||||
filter: drop-shadow(0 0 16px rgba(102, 126, 234, 0.6));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.secure-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: rgba(38, 38, 38, 0.8);
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #a3a3a3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-divider {
|
|
||||||
height: 0.25rem;
|
|
||||||
width: 3rem;
|
|
||||||
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb);
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 登录表单区域 */
|
|
||||||
.login-form-section {
|
|
||||||
padding: 1.5rem 1.5rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label-section {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: #262626;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #a3a3a3;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e5e5e5;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表单样式 */
|
.login-form :deep(.el-form-item) {
|
||||||
.login-form {
|
margin-bottom: 22px;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form :deep(.el-form-item) {
|
.login-form :deep(.el-form-item__label) {
|
||||||
margin-bottom: 1rem;
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form :deep(.el-form-item__error) {
|
.login-form :deep(.el-form-item__error) {
|
||||||
color: #f87171;
|
margin-top: 6px;
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.login-input :deep(.el-input__wrapper) {
|
||||||
margin-bottom: 1rem;
|
box-shadow: 0 0 0 1px #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px 14px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-label {
|
.login-input :deep(.el-input__wrapper:hover) {
|
||||||
display: block;
|
box-shadow: 0 0 0 1px #cbd5e1;
|
||||||
color: #d4d4d4;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-label-row {
|
.login-input :deep(.el-input__wrapper.is-focus) {
|
||||||
display: flex;
|
box-shadow: 0 0 0 2px #3f5d75 !important;
|
||||||
justify-content: space-between;
|
background-color: #ffffff;
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-link {
|
.login-input :deep(.el-input__prefix) {
|
||||||
color: #a3a3a3;
|
color: #94a3b8;
|
||||||
font-size: 0.75rem;
|
margin-right: 8px;
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-link:hover {
|
.login-input :deep(.el-input__inner) {
|
||||||
color: #d4d4d4;
|
height: 44px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义输入框样式 */
|
.login-input :deep(.el-input__inner:-webkit-autofill),
|
||||||
.custom-input :deep(.el-input__wrapper) {
|
.login-input :deep(.el-input__inner:-webkit-autofill:hover),
|
||||||
background: #262626;
|
.login-input :deep(.el-input__inner:-webkit-autofill:focus) {
|
||||||
border: 1px solid #404040;
|
-webkit-box-shadow: 0 0 0 100px white inset !important;
|
||||||
border-radius: 0.5rem;
|
-webkit-text-fill-color: #1e293b !important;
|
||||||
padding: 0.5rem 1rem;
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
box-shadow: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-input :deep(.el-input__wrapper:hover) {
|
|
||||||
background: #262626;
|
|
||||||
border-color: #525252;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-input :deep(.el-input__wrapper.is-focus) {
|
|
||||||
background: #262626;
|
|
||||||
border-color: #525252;
|
|
||||||
box-shadow: 0 0 0 2px rgba(82, 82, 82, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-input :deep(.el-input__inner) {
|
|
||||||
color: #e5e5e5;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-input :deep(.el-input__inner::placeholder) {
|
|
||||||
color: #737373;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-input :deep(.el-icon) {
|
|
||||||
color: #a3a3a3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 记住设备行 */
|
|
||||||
.remember-row {
|
.remember-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
justify-content: flex-start;
|
||||||
|
margin: -6px 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-checkbox :deep(.el-checkbox__inner) {
|
.remember-checkbox :deep(.el-checkbox__label) {
|
||||||
width: 1rem;
|
padding-left: 8px;
|
||||||
height: 1rem;
|
|
||||||
background: #262626;
|
|
||||||
border-color: #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
|
||||||
background: #262626;
|
|
||||||
border-color: #404040;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-checkbox :deep(.el-checkbox__inner::after) {
|
|
||||||
border-color: #e5e5e5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-text {
|
.remember-text {
|
||||||
color: #a3a3a3;
|
color: #64748b;
|
||||||
font-size: 0.75rem;
|
font-size: 13px;
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮组 */
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2.75rem;
|
height: 48px;
|
||||||
background: #262626;
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: linear-gradient(135deg, #3f5d75, #4a6d87);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3);
|
||||||
color: #e5e5e5;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn:hover {
|
.login-btn:hover {
|
||||||
background: #404040;
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 12px 20px -6px rgba(63, 93, 117, 0.4);
|
||||||
|
background: linear-gradient(135deg, #365067, #3f5d75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
/* 底部区域 */
|
box-shadow: 0 4px 8px -2px rgba(63, 93, 117, 0.3);
|
||||||
.footer-section {
|
|
||||||
padding-top: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(to right, transparent, #525252, transparent);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-text {
|
.footer-text {
|
||||||
color: #a3a3a3;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
color: #94a3b8;
|
||||||
margin: 0 0 1rem 0;
|
font-size: 13px;
|
||||||
|
margin: 18px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-link {
|
.footer-link {
|
||||||
color: #d4d4d4;
|
color: #3f5d75;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: text-decoration 0.2s;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-link:hover {
|
.footer-link:hover {
|
||||||
text-decoration: underline;
|
color: #365067;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
color: #a3a3a3;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.login-wrapper {
|
.login-card {
|
||||||
padding: 0.5rem;
|
padding: 36px 24px 24px;
|
||||||
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.sys-title {
|
||||||
height: 120px;
|
font-size: 22px;
|
||||||
}
|
|
||||||
|
|
||||||
.login-form-section {
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user