Compare commits
205 Commits
f6e92c5526
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54554ba0db | |||
|
|
74b2eb0ec3 | ||
|
|
529885176d | ||
|
|
69dfdcc094 | ||
|
|
8a66eb50e1 | ||
|
|
d3ae9697ef | ||
|
|
8ca1d820aa | ||
|
|
c49ff2a6fc | ||
|
|
828ee0c1c0 | ||
|
|
11dd75d0cc | ||
|
|
ddc5901015 | ||
|
|
93b9a031b4 | ||
|
|
48dd37554e | ||
|
|
f9cb27b14e | ||
|
|
35c0146a96 | ||
|
|
3f90031185 | ||
|
|
183a5ed285 | ||
|
|
d8432b2c64 | ||
|
|
23e732fdfe | ||
|
|
6d6b9fb381 | ||
|
|
a1db3794bd | ||
|
|
53706bb3e4 | ||
|
|
cb0af5eba4 | ||
|
|
5d30ad6d04 | ||
|
|
ae7391ecab | ||
|
|
643aeb498d | ||
|
|
45dd71311f | ||
|
|
0be095d583 | ||
|
|
bf81e6d225 | ||
|
|
ea2413cb24 | ||
|
|
77183cb340 | ||
|
|
6b180576b4 | ||
|
|
25dabc699b | ||
|
|
a696fc8ec1 | ||
|
|
49dcbc4e59 | ||
|
|
67436d20fc | ||
|
|
f5d0e5bc0c | ||
|
|
c5e71812d4 | ||
|
|
3a338fe059 | ||
|
|
ade0c6fd6b | ||
|
|
2821d21221 | ||
|
|
2d8ee406f5 | ||
|
|
d986932e85 | ||
|
|
755b1c5c95 | ||
|
|
1a4c524635 | ||
|
|
f0c7145309 | ||
|
|
a80fa715c3 | ||
|
|
fdc2097f67 | ||
|
|
bd181a5d5b | ||
|
|
3f1d5bc221 | ||
|
|
834fb37616 | ||
|
|
f6f165e148 | ||
|
|
a1b8f3064d | ||
|
|
ac55300e69 | ||
|
|
69d2752104 | ||
|
|
22d9abe0f2 | ||
|
|
71b8b34df0 | ||
|
|
1a8948761b | ||
|
|
693aa7df61 | ||
|
|
1e38167f15 | ||
|
|
1b9f6da480 | ||
|
|
667553649f | ||
|
|
cf96a9727c | ||
|
|
d897bf3a68 | ||
|
|
297728c367 | ||
|
|
daf94170df | ||
|
|
2eb4788e0e | ||
|
|
62b63c79e3 | ||
|
|
06a45c4716 | ||
|
|
a15c8b60d8 | ||
|
|
6b0f9d0f80 | ||
|
|
ce3b7859cf | ||
|
|
6c7cbbba47 | ||
|
|
89a89358a4 | ||
|
|
4e7831ac48 | ||
|
|
566eb3492f | ||
|
|
d9bbd28da5 | ||
|
|
b09e203a90 | ||
|
|
acaffa75e9 | ||
|
|
9b02e707fb | ||
|
|
2e271a295b | ||
|
|
2dbc2a5e0a | ||
|
|
a0c38fe9d4 | ||
|
|
0b5f35d5b9 | ||
|
|
4f76fec906 | ||
|
|
a7229e40f0 | ||
|
|
bece501657 | ||
|
|
cdf0009602 | ||
|
|
18461b685c | ||
|
|
f67f84f24b | ||
|
|
b2ec80c199 | ||
|
|
b860ed67ea | ||
|
|
6548032b06 | ||
|
|
c15e54fd8c | ||
|
|
c5c19362c5 | ||
|
|
8a02139c85 | ||
|
|
9f43f1e0e8 | ||
|
|
27200e5cd7 | ||
|
|
da18f99863 | ||
|
|
a74741b8a1 | ||
|
|
780550bd3c | ||
|
|
12c4759a00 | ||
|
|
ff85290891 | ||
|
|
2dceee7788 | ||
|
|
40dafe1401 | ||
|
|
ecd60de827 | ||
|
|
77b2075f4e | ||
|
|
a3773abbd6 | ||
|
|
831dd11f3a | ||
|
|
ec4c565f6c | ||
|
|
bc96f1991d | ||
|
|
fcaaee746c | ||
|
|
a25b00cd79 | ||
|
|
70e60027c6 | ||
|
|
3148ef6828 | ||
|
|
bb96981785 | ||
|
|
19f0ee7bc4 | ||
|
|
83a3791f25 | ||
|
|
6879a6bd54 | ||
|
|
71c611edf3 | ||
|
|
1dcf0bcc52 | ||
|
|
5d97c70e06 | ||
|
|
b4dcd565d6 | ||
|
|
426a7b0327 | ||
|
|
713c11b382 | ||
|
|
8f96865ebf | ||
|
|
08bd8dd4dc | ||
|
|
4b347be9f5 | ||
|
|
a38e275642 | ||
|
|
977187b2d5 | ||
|
|
7edaebf0ab | ||
|
|
32172bec2d | ||
|
|
da04583703 | ||
|
|
cc8dd1e751 | ||
|
|
dd76db193c | ||
|
|
492406b540 | ||
|
|
13916c8939 | ||
|
|
ac114bb766 | ||
|
|
aca120c41b | ||
|
|
c9268cb628 | ||
|
|
084bc10b2c | ||
|
|
456198b183 | ||
|
|
6a49f7c058 | ||
|
|
14a102854f | ||
|
|
e9e9ef89f6 | ||
|
|
ba2221c049 | ||
|
|
a60732c99e | ||
|
|
cea7dad299 | ||
|
|
8d304ac27b | ||
|
|
3b64d66ee1 | ||
|
|
dc3b3c56f5 | ||
|
|
a784fb966b | ||
|
|
c6c4f5c748 | ||
|
|
4fcf5d18d8 | ||
|
|
789821f101 | ||
|
|
f0c1283db6 | ||
|
|
caa5a773d9 | ||
|
|
5e5e27d898 | ||
|
|
a72f06822c | ||
|
|
7ee66e2ac9 | ||
|
|
2de352e869 | ||
|
|
65f3074a7a | ||
|
|
aef2201028 | ||
|
|
72a1b53476 | ||
|
|
52dcc9bec8 | ||
|
|
0fb0a83cbe | ||
|
|
64887bd3af | ||
|
|
e65c4ecf63 | ||
|
|
6d4aecfd91 | ||
|
|
2abd6d876b | ||
|
|
d6a2236469 | ||
|
|
dde61644c1 | ||
|
|
0a5788e52c | ||
|
|
943ad2fe85 | ||
|
|
b5f6f47138 | ||
|
|
cfbd06f676 | ||
|
|
47277eb29a | ||
|
|
074066195e | ||
|
|
e4f7632818 | ||
|
|
a95160e6fb | ||
|
|
b8e90c6572 | ||
|
|
d309f33189 | ||
|
|
b8260c8036 | ||
|
|
c5a8e0525b | ||
|
|
2a5b971f2f | ||
|
|
3d294ae641 | ||
|
|
424819b756 | ||
|
|
8c10cb5000 | ||
|
|
118dc69dd8 | ||
|
|
f22116a57f | ||
|
|
fb9fd98cfb | ||
|
|
c480525433 | ||
|
|
0d586ce280 | ||
|
|
eb464cff12 | ||
|
|
145e6e7549 | ||
|
|
3c1451ca3f | ||
|
|
6505f977d9 | ||
| 033ae6b121 | |||
| 3cafae890b | |||
| 1e0082798c | |||
| 61ce02ca40 | |||
|
|
409e388403 | ||
|
|
59974c4969 | ||
|
|
fee9f3db15 | ||
|
|
eee40fa071 |
120
.claude/commands/timeline.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: timeline
|
||||
description: 维护软考高项时间轴数据
|
||||
---
|
||||
|
||||
# 时间轴数据维护
|
||||
|
||||
维护 `src/data/timeline-items.json` 的时间轴数据。
|
||||
|
||||
## 核心数据结构
|
||||
|
||||
```typescript
|
||||
interface TimelineItem {
|
||||
id: string; // TL001, TL002...
|
||||
timeText: string; // "2035年", "2021年3月"
|
||||
sortKey: string; // "20350000", "20210300"
|
||||
timePrecision: 'year' | 'month' | 'day';
|
||||
theme: string; // 主题/章节
|
||||
excerpt: string; // 原文摘录(必须保真)
|
||||
sourceAnchor?: string; // 来源锚点
|
||||
sourceAnchorType?: 'meeting' | 'document' | 'organization' | 'person' | 'policy' | 'report' | 'other';
|
||||
sourcePosition?: string; // 教材定位
|
||||
}
|
||||
```
|
||||
|
||||
## sortKey 生成规则
|
||||
|
||||
- 年: `2035年` → `20350000`
|
||||
- 年月: `2021年3月` → `20210300`
|
||||
- 年月日: `2021年3月15日` → `20210315`
|
||||
|
||||
## 时间类型判断
|
||||
|
||||
**提出时间 vs 目标时间:**
|
||||
- 提出时间: 政策/文件发布时间
|
||||
- 目标时间: "到XX年实现"的目标年份
|
||||
|
||||
示例:
|
||||
```
|
||||
"《十四五规划和2035年远景目标纲要》提出..."
|
||||
→ 挂载到 2021年(提出时间)
|
||||
|
||||
"到2035年基本实现现代化"
|
||||
→ 挂载到 2035年(目标时间)
|
||||
```
|
||||
|
||||
## 原文摘录规范
|
||||
|
||||
**必须遵守:**
|
||||
- 保留原文句式
|
||||
- 只修正明显 OCR 错字(如"深人"→"深入")
|
||||
- 不改写、不总结、不替换
|
||||
- 不确定的内容必须标记或询问
|
||||
|
||||
## 操作流程
|
||||
|
||||
1. 读取 `src/data/timeline-items.json`
|
||||
2. 校验数据(ID唯一性、sortKey格式、原文校对)
|
||||
3. 执行修改
|
||||
4. 更新 `src/data/changelog.json`
|
||||
5. 询问用户是否提交
|
||||
6. 运行 `npm run build` 检查
|
||||
7. 提交推送
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `excerpt` 必须是原文,只修正OCR错字
|
||||
- 同类主题统一命名
|
||||
- 提交格式: `feat(时间轴): 添加XX年XX主题节点`
|
||||
|
||||
## 常见错误
|
||||
|
||||
### ❌ 错误示例
|
||||
|
||||
**时间类型混淆:**
|
||||
```json
|
||||
// 错误:将远景目标年份当作提出时间
|
||||
{ "timeText": "2035年", "excerpt": "《...2035年远景目标纲要》提出..." }
|
||||
```
|
||||
|
||||
**sortKey 格式错误:**
|
||||
```json
|
||||
// 错误:月份未补零
|
||||
{ "timeText": "2021年3月", "sortKey": "202103" }
|
||||
```
|
||||
|
||||
**原文改写:**
|
||||
```json
|
||||
// 错误:用摘要替代原文
|
||||
{ "excerpt": "提出了数字化转型目标" }
|
||||
```
|
||||
|
||||
### ✅ 正确示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "TL004",
|
||||
"timeText": "2021年",
|
||||
"sortKey": "20210000",
|
||||
"timePrecision": "year",
|
||||
"theme": "产业数字化转型",
|
||||
"excerpt": "《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》明确提出了推进产业数字化转型,实施"上云用数赋智"行动,推动数据赋能全产业链协同转型。",
|
||||
"sourceAnchor": "《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》",
|
||||
"sourceAnchorType": "document"
|
||||
}
|
||||
```
|
||||
|
||||
## 快速检查清单
|
||||
|
||||
添加新节点前必须确认:
|
||||
|
||||
- [ ] ID 按序递增且唯一
|
||||
- [ ] timeText 与 sortKey 匹配
|
||||
- [ ] timePrecision 正确
|
||||
- [ ] excerpt 为校对后的原文
|
||||
- [ ] theme 命名统一
|
||||
- [ ] sourceAnchor 准确
|
||||
- [ ] 已更新 changelog
|
||||
- [ ] 构建通过
|
||||
- [ ] 用户确认提交
|
||||
102
.codex/skills/learning-map-upload/SKILL.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: learning-map-upload
|
||||
description: Upload and persist ITTOView 一图流 learning-map images. Use when the user provides an image path or asks to 上传/传一下/放一下/删除/隐藏 a learning-map image in this project; requires copying into /home/ittoview/src/data/image, choosing a stable numbered Chinese filename, verifying local and /learning-images/ visibility, then committing and pushing the image changes to Git so they are not lost.
|
||||
---
|
||||
|
||||
# Learning Map Upload
|
||||
|
||||
## 核心原则
|
||||
|
||||
一图流图片目录属于 Git 项目,同时被 Docker 运行时挂载:
|
||||
|
||||
- Git/宿主机真实目录:`/home/ittoview/src/data/image`
|
||||
- 线上静态目录:`https://itto.topwind.top/learning-images/`
|
||||
- 内网静态目录:`http://11.144.144.9:8035/learning-images/`
|
||||
- 页面路径:`https://itto.topwind.top/learning-maps`
|
||||
|
||||
新增、覆盖或删除图片时,只操作真实目录。新增图片**不需要重启 Docker**;但因为目录属于 Git 仓库,所有图片变更必须提交并推送,避免后续拉取/重置/部署导致图片丢失。
|
||||
|
||||
## 必须执行的闭环流程
|
||||
|
||||
1. 读取用户给出的图片路径,必要时用 `view_image` 查看内容。
|
||||
2. 根据图片主题命名,格式为:`NN-主题.png/jpg` 或 `NN-主题一图流.png/jpg`。
|
||||
3. 复制到 `/home/ittoview/src/data/image/`,不要复制到其他目录。
|
||||
4. 用 `ls -lh` 确认本地文件真实存在。
|
||||
5. 用 `curl https://itto.topwind.top/learning-images/ | grep 'NN-'` 确认公网线上挂载目录可见。
|
||||
6. 用 `git status --short` 确认图片变更已出现。
|
||||
7. 执行 `git add src/data/image ...`,提交并推送。
|
||||
8. 用 `git status --short` 确认工作区干净或只剩无关改动。
|
||||
9. 回复用户时必须包含:本地文件、线上可见、Git 提交号。
|
||||
|
||||
如果第 5 步失败,不要说已上传成功;如果第 7 步失败,不要说流程完成。要明确说明卡在哪一步。
|
||||
|
||||
## 编号规则
|
||||
|
||||
- 默认使用当前最大编号 + 1。
|
||||
- 如果用户要求覆盖某张图,保持原编号和文件名。
|
||||
- 如果用户要求删除或隐藏,删除对应文件,也要提交推送。
|
||||
- 文件名优先使用图片标题中的业务主题,去掉冗余词。
|
||||
- 尽量保留“找问题”“一图流”“绩效域”“管理”等识别关键词。
|
||||
- 保留源文件格式;如果源文件是 JPG,可以使用 `.jpg`。
|
||||
|
||||
示例:
|
||||
|
||||
- `变更管理找问题` → `15-变更管理找问题一图流.png`
|
||||
- `常见分解结构一图流` → `16-常见分解结构一图流.png`
|
||||
- `项目可行性研究关键文档` → `25-项目可行性研究关键文档一图流.png`
|
||||
|
||||
## 推荐脚本
|
||||
|
||||
上传时优先用:
|
||||
|
||||
```bash
|
||||
.codex/skills/learning-map-upload/scripts/upload_learning_image.sh <source-image> <target-filename>
|
||||
```
|
||||
|
||||
脚本只负责复制和线上校验;脚本成功后仍必须 Git 提交推送。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
.codex/skills/learning-map-upload/scripts/upload_learning_image.sh \
|
||||
/tmp/hapi-blobs/example/image.png \
|
||||
26-示例主题一图流.png
|
||||
|
||||
git add src/data/image/26-示例主题一图流.png
|
||||
git commit -m "feat: add learning map image"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 回复模板
|
||||
|
||||
成功时:
|
||||
|
||||
```txt
|
||||
已上传、线上确认可见,并已提交推送。
|
||||
|
||||
本地文件:
|
||||
/home/ittoview/src/data/image/NN-主题.png
|
||||
|
||||
线上 /learning-images/ 已确认存在:
|
||||
NN-主题.png
|
||||
|
||||
提交:
|
||||
<commit-sha> <commit-message>
|
||||
```
|
||||
|
||||
失败时:
|
||||
|
||||
```txt
|
||||
流程未完成,卡在:<本地复制 / 线上校验 / Git 提交 / Git 推送>。
|
||||
|
||||
已完成:...
|
||||
失败原因:...
|
||||
```
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 不要只复制文件后回复“已放好”。
|
||||
- 不要跳过公网 `/learning-images/` 校验。
|
||||
- 不要因为新增图片而重启 Docker。
|
||||
- 不要把图片放到 `dist/`、`public/`、临时目录或非真实映射目录。
|
||||
- 不要忘记 Git 提交推送;未提交图片等同于未完成。
|
||||
4
.codex/skills/learning-map-upload/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "一图流上传"
|
||||
short_description: "上传学习图谱图片、校验线上可见并提交推送到 Git"
|
||||
default_prompt: "把这张学习图谱图片上传到一图流目录,按内容命名,确认线上可见,并提交推送。"
|
||||
34
.codex/skills/learning-map-upload/scripts/upload_learning_image.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "用法: $0 <source-image> <target-filename>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SRC="$1"
|
||||
NAME="$2"
|
||||
IMG_DIR="/home/ittoview/src/data/image"
|
||||
BASE_URL="https://itto.topwind.top/learning-images/"
|
||||
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "源文件不存在: $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$IMG_DIR"
|
||||
cp "$SRC" "$IMG_DIR/$NAME"
|
||||
|
||||
echo "--- 本地目录确认 ---"
|
||||
ls -lh "$IMG_DIR/$NAME"
|
||||
|
||||
echo "--- 公网线上挂载目录确认 ---"
|
||||
HTML="$(curl -sS --max-time 8 "$BASE_URL")"
|
||||
NUMBER_PREFIX="${NAME%%-*}-"
|
||||
if printf '%s\n' "$HTML" | grep -F "$NAME" >/dev/null || printf '%s\n' "$HTML" | grep -F "$NUMBER_PREFIX" >/dev/null; then
|
||||
printf '%s\n' "$HTML" | grep -F "$NAME" || printf '%s\n' "$HTML" | grep -F "$NUMBER_PREFIX"
|
||||
echo "线上可见: $NAME"
|
||||
else
|
||||
echo "线上未确认可见: $NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
3
.gitignore
vendored
@@ -7,3 +7,6 @@ dist/
|
||||
.env.local
|
||||
*.pdf
|
||||
pdf_images/
|
||||
|
||||
public/api/
|
||||
public/apidoc
|
||||
|
||||
58
AGENTS.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件用于约束本项目中的协作式开发行为,尤其是前端页面内容生成与文案表达。
|
||||
|
||||
## 一、前台页面文案总原则
|
||||
|
||||
前台页面是直接面向最终用户的,不是面向开发者的。
|
||||
|
||||
因此,在所有前台展示内容中,必须严格遵守以下规则:
|
||||
|
||||
1. 不要出现解释性词语
|
||||
- 禁止出现类似:
|
||||
- “这里先提供……”
|
||||
- “方便你确认……”
|
||||
- “后续再补……”
|
||||
- “当前状态……”
|
||||
- “首版……”
|
||||
- “目录入口页面……”
|
||||
- “样式展示……”
|
||||
- 页面文案只能表达用户真正需要看到的信息,不得解释页面是怎么做的、为什么这样做、现在做到哪一步。
|
||||
|
||||
2. 不要出现程序开发过程中的逻辑性话术
|
||||
- 禁止出现类似:
|
||||
- “入口页”
|
||||
- “详情页后续再补”
|
||||
- “左侧菜单未加入”
|
||||
- “页面阶段”
|
||||
- “开发中”
|
||||
- “占位内容”
|
||||
- “待完善”
|
||||
- 不要把研发过程、实现阶段、功能规划暴露给用户。
|
||||
|
||||
3. 前台页面文案必须自然、直接、面向用户
|
||||
- 用户看到的应该是业务信息、学习内容、导航信息。
|
||||
- 文案应简洁、稳定、可阅读,不带“给开发者看的说明”。
|
||||
|
||||
4. 页面标题、副标题、说明文案要贴近现有页面风格
|
||||
- 优先参考:
|
||||
- `knowledge-areas`
|
||||
- `process-groups`
|
||||
- `principles`
|
||||
- 保持简洁、克制、信息明确。
|
||||
|
||||
## 二、前端实现约束
|
||||
|
||||
1. 新增页面前,优先参考现有同类页面结构与视觉层级。
|
||||
2. 若用户明确要求“仿照某页面”,应优先复用该页面的信息组织方式,而不是自行发挥。
|
||||
3. 若只是为了后续扩展而暂时占位,也不能在前台页面展示“占位式说明文案”;应展示为正常页面。
|
||||
4. 开发说明、阶段说明、技术备注,只能出现在:
|
||||
- 对用户的回复中
|
||||
- 提交说明中
|
||||
- 代码注释中
|
||||
- 文档中
|
||||
不得出现在前台页面正文中。
|
||||
|
||||
## 三、执行优先级
|
||||
|
||||
当“开发便利”与“前台用户体验”冲突时,优先保证前台用户体验。
|
||||
417
CLAUDE.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
ITTOView 是一个 PMP 项目管理 ITTO(Input-Tools & Techniques-Output)可视化学习平台。帮助 PMP 考生通过交互式可视化方式学习和记忆 PMBOK 第6版中的 49 个项目管理过程及其输入、工具技术、输出关系。
|
||||
|
||||
**核心数据结构:**
|
||||
- 10 大知识领域(Knowledge Areas)
|
||||
- 5 大过程组(Process Groups)
|
||||
- 49 个项目管理过程(Processes)
|
||||
- 工件/文档(Artifacts)- 作为输入和输出
|
||||
- 工具与技术(Tools & Techniques)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**: React 18 + TypeScript
|
||||
- **构建工具**: Vite 5
|
||||
- **路由**: React Router v6
|
||||
- **状态管理**: Zustand (持久化到 localStorage)
|
||||
- **样式**: Tailwind CSS 3 + Framer Motion
|
||||
- **可视化**: ReactFlow, AntV G6, Recharts
|
||||
- **UI组件**: Radix UI
|
||||
- **部署**: Docker + Nginx
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器 (http://localhost:3000)
|
||||
npm run dev
|
||||
|
||||
# 类型检查 + 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览生产构建
|
||||
npm run preview
|
||||
|
||||
# 代码检查
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t ittoview .
|
||||
|
||||
# 使用 docker-compose 启动
|
||||
docker-compose up -d
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── layout/ # 布局组件 (Header, Sidebar, Layout)
|
||||
│ ├── ui/ # 通用UI组件 (目前为空)
|
||||
│ └── visualize/ # 可视化组件 (ProcessMatrix)
|
||||
├── data/ # 静态数据文件 (JSON)
|
||||
│ ├── knowledge-areas.json # 10大知识领域
|
||||
│ ├── process-groups.json # 5大过程组
|
||||
│ ├── processes.json # 49个过程 (含5W1H记忆辅助)
|
||||
│ ├── artifacts.json # 工件/文档
|
||||
│ ├── tools.json # 工具与技术
|
||||
│ └── index.ts # 数据索引和查询工具函数
|
||||
├── hooks/ # 自定义 React Hooks (目前为空)
|
||||
├── pages/ # 页面组件
|
||||
│ ├── HomePage.tsx # 首页/仪表盘
|
||||
│ ├── KnowledgeAreasPage.tsx # 知识领域视图
|
||||
│ ├── ProcessGroupsPage.tsx # 过程组视图
|
||||
│ ├── ProcessDetailPage.tsx # 过程详情页
|
||||
│ ├── ProcessMatrixPage.tsx # 49过程矩阵图
|
||||
│ ├── ProcessGraphPage.tsx # 流程图/关系图
|
||||
│ ├── ArtifactDetailPage.tsx # 工件详情页
|
||||
│ ├── ToolDetailPage.tsx # 工具详情页
|
||||
│ └── SettingsPage.tsx # 设置页
|
||||
├── stores/
|
||||
│ └── useAppStore.ts # Zustand 全局状态 (侧边栏、深色模式、搜索等)
|
||||
├── types/
|
||||
│ └── itto.ts # TypeScript 类型定义
|
||||
├── utils/ # 工具函数 (目前为空)
|
||||
├── App.tsx # 路由配置
|
||||
└── main.tsx # 应用入口
|
||||
```
|
||||
|
||||
## 核心架构设计
|
||||
|
||||
### 1. 数据层 (src/data/)
|
||||
|
||||
**数据加载机制:**
|
||||
- 所有 ITTO 数据以 JSON 格式静态存储
|
||||
- `src/data/index.ts` 提供统一的数据导出和查询 API
|
||||
- 使用 Map 数据结构建立索引,提升查询性能
|
||||
|
||||
**关键数据查询函数:**
|
||||
```typescript
|
||||
// 计算数据流向关系 (输出 → 输入)
|
||||
computeDataFlows(): DataFlow[]
|
||||
|
||||
// 获取工件的使用情况
|
||||
getArtifactUsage(artifactId: string): { asInput: Process[], asOutput: Process[] }
|
||||
|
||||
// 获取工具的使用情况
|
||||
getToolUsage(toolId: string): Process[]
|
||||
|
||||
// 获取过程的完整信息(含关联数据)
|
||||
getProcessDetail(processId: string)
|
||||
```
|
||||
|
||||
### 2. 状态管理 (Zustand)
|
||||
|
||||
**全局状态 (useAppStore):**
|
||||
- `sidebarOpen`: 侧边栏展开/收起
|
||||
- `darkMode`: 深色模式开关
|
||||
- `searchQuery`: 全局搜索关键词
|
||||
- `matrixFullScreen`: 矩阵图全屏模式
|
||||
|
||||
**持久化策略:**
|
||||
- 使用 `zustand/middleware` 的 `persist` 中间件
|
||||
- 存储到 `localStorage` (key: `ittoview-app-storage`)
|
||||
- `searchQuery` 不持久化,刷新后重置
|
||||
|
||||
### 3. 路由设计
|
||||
|
||||
**主要路由:**
|
||||
- `/` - 首页/仪表盘
|
||||
- `/knowledge-areas` - 知识领域列表
|
||||
- `/knowledge-areas/:id` - 特定知识领域详情
|
||||
- `/process-groups` - 过程组列表
|
||||
- `/process-groups/:id` - 特定过程组详情
|
||||
- `/process/:id` - 过程详情页
|
||||
- `/process-matrix` - 49过程矩阵图
|
||||
- `/process-graph` - 流程图/关系图
|
||||
- `/artifact/:id` - 工件详情页
|
||||
- `/tool/:id` - 工具详情页
|
||||
- `/settings` - 设置页
|
||||
|
||||
### 4. 样式系统
|
||||
|
||||
**Tailwind 自定义主题:**
|
||||
- 知识领域主题色:`ka.integration`, `ka.scope`, `ka.schedule` 等
|
||||
- 过程组主题色:`pg.initiating`, `pg.planning`, `pg.executing` 等
|
||||
- 支持深色模式:使用 `class` 策略 (`darkMode: 'class'`)
|
||||
|
||||
**动画:**
|
||||
- 使用 Framer Motion 实现页面过渡和交互动画
|
||||
- 矩阵图单元格使用渐进式动画 (`delay: kaIndex * 0.05`)
|
||||
|
||||
### 5. 类型系统 (src/types/itto.ts)
|
||||
|
||||
**核心类型:**
|
||||
- `KnowledgeArea` - 知识领域
|
||||
- `ProcessGroup` - 过程组
|
||||
- `Process` - 过程 (含 `w5h1?: Process5W1H` 记忆辅助)
|
||||
- `Artifact` - 工件 (含 `category: ArtifactCategory`)
|
||||
- `ToolTechnique` - 工具与技术 (含 `type: ToolType`)
|
||||
- `DataFlow` - 数据流向关系
|
||||
- `MasteryLevel` - 学习掌握程度 (`familiar` | `fuzzy` | `unfamiliar`)
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 数据修改
|
||||
|
||||
**修改 ITTO 数据时:**
|
||||
1. 直接编辑 `src/data/*.json` 文件
|
||||
2. 确保 ID 引用一致性(如 `process.inputs` 中的 ID 必须存在于 `artifacts.json`)
|
||||
3. 更新对应的 `processCount` 统计字段
|
||||
4. 保持 JSON 格式正确(使用 JSON 验证工具)
|
||||
|
||||
### 添加新页面
|
||||
|
||||
1. 在 `src/pages/` 创建新组件
|
||||
2. 在 `src/App.tsx` 添加路由
|
||||
3. 在 `src/components/layout/Sidebar.tsx` 添加导航链接(如需要)
|
||||
|
||||
### 可视化组件开发
|
||||
|
||||
**使用的可视化库:**
|
||||
- **ReactFlow**: 用于流程图、节点关系图
|
||||
- **AntV G6**: 用于复杂图谱可视化
|
||||
- **Recharts**: 用于统计图表
|
||||
|
||||
**性能优化建议:**
|
||||
- 大型矩阵/图谱使用虚拟滚动
|
||||
- 使用 `React.memo` 优化列表渲染
|
||||
- 图谱节点数量过多时提供筛选/分组功能
|
||||
|
||||
### 路径别名
|
||||
|
||||
使用 `@/` 作为 `src/` 的别名:
|
||||
```typescript
|
||||
import { processes } from '@/data'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
```
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 组件使用函数式组件 + TypeScript
|
||||
- 优先使用命名导出而非默认导出(除 `App.tsx`)
|
||||
- 使用 `clsx` 处理条件类名
|
||||
- 遵循 ESLint 规则(`npm run lint`)
|
||||
|
||||
## 未来扩展方向
|
||||
|
||||
根据需求文档 (`docs/需求设计文档.md`),以下功能尚未实现:
|
||||
|
||||
1. **学习模块** (P0 优先级)
|
||||
- 记忆卡片系统(间隔重复算法)
|
||||
- 自测模式(选择题、填空题、连线题)
|
||||
- 错题本功能
|
||||
- 掌握度标记
|
||||
|
||||
2. **搜索与筛选** (P0-P1)
|
||||
- 全局搜索功能
|
||||
- 分类筛选器
|
||||
- 关联查询
|
||||
|
||||
3. **可视化增强** (P1-P2)
|
||||
- 数据流向图
|
||||
- 知识领域全景图
|
||||
- 全局关系图谱
|
||||
|
||||
4. **用户体验** (P1-P2)
|
||||
- 键盘导航/快捷键
|
||||
- 数据导出功能
|
||||
- 复习计划/提醒
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 需求设计文档: `docs/需求设计文档.md`
|
||||
- ITTO 手册 PDF: `⑦ ITTO输入输出工具手册.pdf`
|
||||
|
||||
## 日常学习内容更新操作指南
|
||||
|
||||
### 1. 更新知识领域的敏捷裁剪因素
|
||||
|
||||
**文件:** `src/data/knowledge-areas.json`
|
||||
|
||||
在对应知识领域对象中添加 `tailoringFactors` 数组:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "KA06",
|
||||
"name": "项目资源管理",
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "多元化",
|
||||
"description": "团队的多元化背景是什么?"
|
||||
},
|
||||
{
|
||||
"title": "物理位置",
|
||||
"description": "团队成员和实物资源的物理位置在哪里?"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** `tailoringFactors` 为可选字段,未填写的知识领域页面不显示该区块。
|
||||
|
||||
---
|
||||
|
||||
### 2. 更新过程的 ITTO 明细
|
||||
|
||||
**文件:** `src/data/processes.json`
|
||||
|
||||
`inputs` / `tools` / `outputs` 支持两种格式:
|
||||
- 纯字符串 ID(无明细):`"A008"`
|
||||
- 带明细对象(有子项展开):`{ "id": "A008", "detail": [{ "label": "质量管理计划" }, { "label": "范围基准" }] }`
|
||||
|
||||
**示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "P5.1",
|
||||
"inputs": [
|
||||
{ "id": "A008", "detail": [{ "label": "质量管理计划" }, { "label": "范围基准" }] },
|
||||
{ "id": "A076", "detail": [{ "label": "假设日志" }, { "label": "需求文件" }] },
|
||||
"A005",
|
||||
"A006"
|
||||
],
|
||||
"tools": [
|
||||
"TT001",
|
||||
{ "id": "TT008", "detail": [{ "label": "成本效益分析" }, { "label": "质量成本" }] }
|
||||
],
|
||||
"outputs": [
|
||||
"A021",
|
||||
{ "id": "A077", "detail": [{ "label": "经验教训登记册" }, { "label": "风险登记册" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意:**
|
||||
- `detail` 数组中每项必须是 `{ "label": "名称" }` 对象,不能是纯字符串
|
||||
- 如果引用的工件/工具 ID 不存在于 `artifacts.json` / `tools.json`,需先添加
|
||||
|
||||
---
|
||||
|
||||
### 3. 更新过程的主要作用
|
||||
|
||||
**文件:** `src/data/processes.json`
|
||||
|
||||
在对应过程对象中添加 `purpose` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "P8.7",
|
||||
"name": "监督风险",
|
||||
"purpose": "保证项目决策是在整体项目风险和单个项目风险当前信息的基础上进行。本过程需要在整个项目期间开展。"
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** `purpose` 为可选字段,填写后会在过程详情页标题下方以蓝色卡片展示;未填写则不显示。
|
||||
|
||||
---
|
||||
|
||||
### 操作流程规范
|
||||
|
||||
**日常更新 JSON 数据时,无需 Codex 参与,按流程操作即可。**
|
||||
|
||||
每次完成数据更新后,必须按以下步骤操作:
|
||||
|
||||
1. **更新 changelog**:在提交前,必须先更新 `src/data/changelog.json`,添加本次变更记录
|
||||
2. **询问提交**:数据更新完成后,询问用户是否提交推送,不得自动提交
|
||||
3. **构建检查**:用户确认提交前,运行 `npm run build`,确保无类型错误和编译错误(无需每次修改后立即检查,在提交推送前统一检查即可)
|
||||
4. **提交推送**:构建通过后执行 `git add` + `git commit` + `git push`
|
||||
|
||||
---
|
||||
|
||||
### 更新 changelog 规范
|
||||
|
||||
**文件:** `src/data/changelog.json`
|
||||
|
||||
每次提交代码前,必须在 `changelogEntries` 数组开头添加一条新记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "YYYY-MM-DD-brief-description",
|
||||
"date": "YYYY-MM-DD",
|
||||
"type": "feat|fix|style|refactor|docs|perf|test|chore",
|
||||
"title": "简短描述本次变更内容",
|
||||
"scope": "整合|范围|进度|成本|质量|资源|沟通|风险|采购|相关方|练习|矩阵等"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
- `id`:唯一标识符,格式为 `日期-简短描述`,使用小写字母和连字符
|
||||
- `date`:提交日期,格式 `YYYY-MM-DD`
|
||||
- `type`:变更类型,与 Git 提交规范的 type 保持一致
|
||||
- `title`:变更标题,简洁描述本次变更的内容
|
||||
- `scope`:关联范围,与 Git 提交规范的 scope 保持一致
|
||||
|
||||
**注意事项:**
|
||||
- 新记录添加在数组开头(最新记录在最前)
|
||||
- `title` 中如需使用引号,使用「」而非 "",避免 JSON 解析错误
|
||||
- 每次提交只添加一条记录,对应本次 commit
|
||||
- `type` 和 `scope` 应与 Git 提交信息保持一致
|
||||
|
||||
**示例:**
|
||||
```json
|
||||
{
|
||||
"changelogEntries": [
|
||||
{
|
||||
"id": "2026-03-08-changelog-modal",
|
||||
"date": "2026-03-08",
|
||||
"type": "feat",
|
||||
"title": "新增更新时间轴浏览页面与顶部快捷入口",
|
||||
"scope": "整合"
|
||||
},
|
||||
{
|
||||
"id": "2026-03-08-practice-fix-offset",
|
||||
"date": "2026-03-08",
|
||||
"type": "fix",
|
||||
"title": "修正底部固定区域偏移方式",
|
||||
"scope": "练习"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
提交信息格式:`<type>(<scope>): <subject>`
|
||||
|
||||
**type 类型:**
|
||||
| type | 说明 |
|
||||
|------|------|
|
||||
| `feat` | 新增功能或数据 |
|
||||
| `fix` | 修复错误数据或 bug |
|
||||
| `refactor` | 代码重构(不影响功能) |
|
||||
| `style` | 样式调整(不影响逻辑) |
|
||||
| `docs` | 文档更新 |
|
||||
| `chore` | 构建配置、依赖等杂项 |
|
||||
|
||||
**scope 范围(常用):**
|
||||
- 知识领域数据:`整合` / `范围` / `进度` / `成本` / `质量` / `资源` / `沟通` / `风险` / `采购` / `相关方`
|
||||
- 过程数据:过程名称,如 `控制质量`
|
||||
- 前台功能:`矩阵` / `过程详情` / `动画` / `导航`
|
||||
|
||||
**示例:**
|
||||
```
|
||||
feat(风险): 添加P8.7监督风险的主要作用
|
||||
fix(质量): 修复P5.3控制质量明细格式
|
||||
feat(资源): 添加KA06项目资源管理裁剪因素
|
||||
style(动画): 优化ITTO显示隐藏过渡效果
|
||||
docs(CLAUDE): 更新日常操作指南
|
||||
```
|
||||
|
||||
298
README.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# ITTOView - PMP 项目管理 ITTO 可视化学习平台
|
||||
|
||||
<div align="center">
|
||||
|
||||
**一个精美、交互式的 PMP 认证考试 ITTO 知识点可视化学习工具**
|
||||
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://vitejs.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
ITTOView 是一个专为 PMP(Project Management Professional)认证考生设计的 ITTO(Input-Tools & Techniques-Output)可视化学习平台。
|
||||
|
||||
### 什么是 ITTO?
|
||||
|
||||
PMBOK 第6版包含:
|
||||
- **10 大知识领域**:整合、范围、进度、成本、质量、资源、沟通、风险、采购、相关方管理
|
||||
- **5 大过程组**:启动、规划、执行、监控、收尾
|
||||
- **49 个项目管理过程**:每个过程都有对应的输入(Input)、工具与技术(Tools & Techniques)、输出(Output)
|
||||
|
||||
### 为什么需要 ITTOView?
|
||||
|
||||
传统的 ITTO 学习方式(PDF/表格)存在以下痛点:
|
||||
- ❌ **信息孤立**:难以看到过程之间的数据流向关系
|
||||
- ❌ **记忆困难**:大量术语和对应关系难以记忆
|
||||
- ❌ **缺乏互动**:无法进行自测和针对性复习
|
||||
- ❌ **查找低效**:无法快速定位特定输入/输出的使用场景
|
||||
|
||||
ITTOView 通过可视化和交互式设计解决这些问题:
|
||||
- ✅ **直观理解**:通过流程图和矩阵图可视化 ITTO 关系
|
||||
- ✅ **高效记忆**:5W1H 记忆辅助信息帮助理解过程本质
|
||||
- ✅ **快速查询**:支持按知识领域、过程组、工件、工具多维度浏览
|
||||
- ✅ **精美界面**:现代化 UI 设计,支持深色模式
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🗂️ 多维度知识浏览
|
||||
|
||||
- **知识领域视图**:按 10 大知识领域分类展示所有过程
|
||||
- **过程组视图**:按 5 大过程组分类展示所有过程
|
||||
- **过程详情页**:展示单个过程的完整 ITTO 信息及 5W1H 记忆辅助
|
||||
- **工件详情页**:查看工件作为输入/输出的所有过程
|
||||
- **工具详情页**:查看工具在哪些过程中被使用
|
||||
|
||||
### 📊 可视化矩阵图
|
||||
|
||||
- **49 过程矩阵图**:知识领域(行)× 过程组(列)的二维矩阵
|
||||
- **全屏模式**:支持全屏查看,优化大屏显示效果
|
||||
- **颜色编码**:使用主题色区分不同知识领域和过程组
|
||||
- **交互式导航**:点击单元格快速跳转到过程详情
|
||||
|
||||
### 🎨 用户体验
|
||||
|
||||
- **响应式设计**:适配 PC 和平板设备
|
||||
- **深色模式**:支持明暗主题切换,保护视力
|
||||
- **流畅动画**:使用 Framer Motion 实现优雅的页面过渡
|
||||
- **侧边栏导航**:可折叠的侧边栏,提供快速导航
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- npm >= 9.0.0
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd ITTOView
|
||||
|
||||
# 2. 安装依赖
|
||||
npm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 4. 在浏览器中打开 http://localhost:3000
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
# 类型检查 + 构建
|
||||
npm run build
|
||||
|
||||
# 预览生产构建
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
# 运行 ESLint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 使用 Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
docker compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
服务将在 `http://11.144.144.9:8035` 上运行(可在 `docker-compose.yml` 中修改端口)。
|
||||
|
||||
### 手动构建 Docker 镜像
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t ittoview:latest .
|
||||
|
||||
# 运行容器
|
||||
docker run -d -p 8080:80 --name ittoview ittoview:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 核心框架
|
||||
|
||||
- **[React 18](https://reactjs.org/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 下一代前端构建工具
|
||||
|
||||
### UI 与样式
|
||||
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - 原子化 CSS 框架
|
||||
- **[Radix UI](https://www.radix-ui.com/)** - 无样式的可访问组件库
|
||||
- **[Framer Motion](https://www.framer.com/motion/)** - 生产级动画库
|
||||
- **[Lucide React](https://lucide.dev/)** - 精美的图标库
|
||||
|
||||
### 可视化
|
||||
|
||||
- **[ReactFlow](https://reactflow.dev/)** - 流程图和节点编辑器
|
||||
- **[AntV G6](https://g6.antv.antgroup.com/)** - 图可视化引擎
|
||||
- **[Recharts](https://recharts.org/)** - 基于 React 的图表库
|
||||
|
||||
### 状态管理与路由
|
||||
|
||||
- **[Zustand](https://zustand-demo.pmnd.rs/)** - 轻量级状态管理
|
||||
- **[React Router](https://reactrouter.com/)** - 声明式路由
|
||||
|
||||
### 部署
|
||||
|
||||
- **[Docker](https://www.docker.com/)** - 容器化部署
|
||||
- **[Nginx](https://nginx.org/)** - 高性能 Web 服务器
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
ITTOView/
|
||||
├── src/
|
||||
│ ├── components/ # React 组件
|
||||
│ │ ├── layout/ # 布局组件(Header, Sidebar, Layout)
|
||||
│ │ ├── ui/ # 通用 UI 组件
|
||||
│ │ └── visualize/ # 可视化组件(ProcessMatrix)
|
||||
│ ├── data/ # 静态数据文件
|
||||
│ │ ├── knowledge-areas.json # 10 大知识领域
|
||||
│ │ ├── process-groups.json # 5 大过程组
|
||||
│ │ ├── processes.json # 49 个过程(含 5W1H)
|
||||
│ │ ├── artifacts.json # 工件/文档
|
||||
│ │ ├── tools.json # 工具与技术
|
||||
│ │ └── index.ts # 数据索引和查询 API
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── HomePage.tsx # 首页
|
||||
│ │ ├── KnowledgeAreasPage.tsx # 知识领域页
|
||||
│ │ ├── ProcessGroupsPage.tsx # 过程组页
|
||||
│ │ ├── ProcessDetailPage.tsx # 过程详情页
|
||||
│ │ ├── ProcessMatrixPage.tsx # 矩阵图页
|
||||
│ │ ├── ProcessGraphPage.tsx # 流程图页
|
||||
│ │ ├── ArtifactDetailPage.tsx # 工件详情页
|
||||
│ │ ├── ToolDetailPage.tsx # 工具详情页
|
||||
│ │ └── SettingsPage.tsx # 设置页
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ │ └── useAppStore.ts
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ └── itto.ts
|
||||
│ ├── hooks/ # 自定义 React Hooks
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── App.tsx # 应用根组件
|
||||
│ └── main.tsx # 应用入口
|
||||
├── docs/ # 文档
|
||||
│ └── 需求设计文档.md
|
||||
├── Dockerfile # Docker 镜像构建文件
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── nginx.conf # Nginx 配置(SPA 路由支持)
|
||||
├── package.json # 项目依赖
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tailwind.config.js # Tailwind CSS 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── CLAUDE.md # Claude Code 开发指南
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据说明
|
||||
|
||||
### 数据来源
|
||||
|
||||
项目数据基于 **PMBOK 第6版**(项目管理知识体系指南),包含:
|
||||
- 10 大知识领域的完整定义
|
||||
- 5 大过程组的完整定义
|
||||
- 49 个项目管理过程的详细 ITTO 信息
|
||||
- 每个过程的 5W1H 记忆辅助信息
|
||||
|
||||
### 数据格式
|
||||
|
||||
所有数据以 JSON 格式存储在 `src/data/` 目录下:
|
||||
- `knowledge-areas.json` - 知识领域数据
|
||||
- `process-groups.json` - 过程组数据
|
||||
- `processes.json` - 过程数据(含输入、工具、输出 ID 引用)
|
||||
- `artifacts.json` - 工件/文档数据
|
||||
- `tools.json` - 工具与技术数据
|
||||
|
||||
### 数据查询 API
|
||||
|
||||
`src/data/index.ts` 提供了丰富的数据查询工具函数:
|
||||
```typescript
|
||||
// 计算数据流向关系
|
||||
computeDataFlows(): DataFlow[]
|
||||
|
||||
// 获取工件的使用情况
|
||||
getArtifactUsage(artifactId: string): { asInput: Process[], asOutput: Process[] }
|
||||
|
||||
// 获取工具的使用情况
|
||||
getToolUsage(toolId: string): Process[]
|
||||
|
||||
// 获取过程的完整信息
|
||||
getProcessDetail(processId: string)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 路线图
|
||||
|
||||
### ✅ 已完成功能
|
||||
|
||||
- [x] 知识领域视图
|
||||
- [x] 过程组视图
|
||||
- [x] 过程详情页(含 5W1H 记忆辅助)
|
||||
- [x] 49 过程矩阵图
|
||||
- [x] 工件详情页
|
||||
- [x] 工具详情页
|
||||
- [x] 深色模式
|
||||
- [x] 响应式布局
|
||||
- [x] Docker 部署支持
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎贡献代码、报告问题或提出新功能建议!
|
||||
|
||||
### 如何贡献
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 TypeScript 编写代码
|
||||
- 遵循 ESLint 规则(运行 `npm run lint` 检查)
|
||||
- 组件使用函数式组件 + Hooks
|
||||
- 使用 `@/` 作为 `src/` 的路径别名
|
||||
|
||||
---
|
||||
|
||||
@@ -8,4 +8,6 @@ services:
|
||||
container_name: ittoview
|
||||
ports:
|
||||
- "11.144.144.9:8035:80"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./src/data/image:/usr/share/nginx/html/learning-images:ro
|
||||
restart: always
|
||||
|
||||
444
docs/知识库API接口说明.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# 知识库 API 接口说明
|
||||
|
||||
面向知识库调用,不面向前端展示。只保留知识库回答问题需要的数据:ID、名称、归属关系、主要作用、ITTO、裁剪因素、绩效域详情、实体反查。
|
||||
|
||||
## 1. 通用约定
|
||||
|
||||
- 请求方式:`GET`
|
||||
- 响应格式:`JSON`
|
||||
- 静态文件路径统一放在 `/api` 下
|
||||
- 不提供颜色、页面样式、搜索、五问一法
|
||||
|
||||
## 2. 接口列表
|
||||
|
||||
| 用途 | 路径 |
|
||||
|---|---|
|
||||
| 过程组列表 | `/api/process-groups.json` |
|
||||
| 知识领域列表 | `/api/knowledge-areas.json` |
|
||||
| 某知识领域裁剪因素 | `/api/knowledge-areas/{id}/tailoring-factors.json` |
|
||||
| 某知识领域下的过程 | `/api/knowledge-areas/{id}/processes.json` |
|
||||
| 某过程组下的过程 | `/api/process-groups/{id}/processes.json` |
|
||||
| 49 个过程 | `/api/processes.json` |
|
||||
| 单过程基础信息 | `/api/processes/{id}.json` |
|
||||
| 单过程 ITTO | `/api/processes/{id}/itto.json` |
|
||||
| 八大绩效域 | `/api/performance-domains.json` |
|
||||
| 单绩效域详情 | `/api/performance-domains/{id}.json` |
|
||||
| 工件反查使用情况 | `/api/artifacts/{id}/usage.json` |
|
||||
| 工具反查使用情况 | `/api/tools/{id}/usage.json` |
|
||||
| Markdown 接口文档 | `/apidoc` |
|
||||
|
||||
## 3. 接口说明
|
||||
|
||||
### 3.1 过程组列表
|
||||
|
||||
`GET /api/process-groups.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": "PG01", "name": "启动过程组" },
|
||||
{ "id": "PG02", "name": "规划过程组" }
|
||||
]
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `id` | 过程组 ID |
|
||||
| `name` | 过程组名称 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 知识领域列表
|
||||
|
||||
`GET /api/knowledge-areas.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "KA01",
|
||||
"name": "项目整合管理",
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "项目生命周期",
|
||||
"description": "本项目合适的项目生命周期?项目生命周期应包括哪些阶段?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `id` | 知识领域 ID |
|
||||
| `name` | 知识领域名称 |
|
||||
| `tailoringFactors` | 裁剪因素数组 |
|
||||
| `title` | 裁剪因素标题 |
|
||||
| `description` | 裁剪因素说明 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 某知识领域裁剪因素
|
||||
|
||||
`GET /api/knowledge-areas/{id}/tailoring-factors.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/knowledge-areas/KA01/tailoring-factors.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "项目生命周期",
|
||||
"description": "本项目合适的项目生命周期?项目生命周期应包括哪些阶段?"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 某知识领域下的过程
|
||||
|
||||
`GET /api/knowledge-areas/{id}/processes.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/knowledge-areas/KA01/processes.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "P1.1",
|
||||
"name": "制定项目章程",
|
||||
"processGroupId": "PG01",
|
||||
"processGroupName": "启动过程组",
|
||||
"purpose": "项目章程是正式批准项目并授权项目经理使用组织资源的文件。"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `id` | 过程 ID |
|
||||
| `name` | 过程名称 |
|
||||
| `processGroupId` | 所属过程组 ID |
|
||||
| `processGroupName` | 所属过程组名称 |
|
||||
| `purpose` | 主要作用 |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 某过程组下的过程
|
||||
|
||||
`GET /api/process-groups/{id}/processes.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/process-groups/PG02/processes.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "P1.2",
|
||||
"name": "制订项目管理计划",
|
||||
"knowledgeAreaId": "KA01",
|
||||
"knowledgeAreaName": "项目整合管理",
|
||||
"purpose": "生成一份综合文件,用于确定所有项目工作的基础及其执行方式。"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `id` | 过程 ID |
|
||||
| `name` | 过程名称 |
|
||||
| `knowledgeAreaId` | 所属知识领域 ID |
|
||||
| `knowledgeAreaName` | 所属知识领域名称 |
|
||||
| `purpose` | 主要作用 |
|
||||
|
||||
---
|
||||
|
||||
### 3.6 49 个过程
|
||||
|
||||
`GET /api/processes.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "P1.1",
|
||||
"name": "制定项目章程",
|
||||
"knowledgeAreaId": "KA01",
|
||||
"knowledgeAreaName": "项目整合管理",
|
||||
"processGroupId": "PG01",
|
||||
"processGroupName": "启动过程组",
|
||||
"purpose": "项目章程是正式批准项目并授权项目经理使用组织资源的文件。"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 单过程基础信息
|
||||
|
||||
`GET /api/processes/{id}.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/processes/P1.1.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "P1.1",
|
||||
"name": "制定项目章程",
|
||||
"knowledgeAreaId": "KA01",
|
||||
"knowledgeAreaName": "项目整合管理",
|
||||
"processGroupId": "PG01",
|
||||
"processGroupName": "启动过程组",
|
||||
"purpose": "项目章程是正式批准项目并授权项目经理使用组织资源的文件。"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 单过程 ITTO
|
||||
|
||||
`GET /api/processes/{id}/itto.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/processes/P1.1/itto.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "P1.1",
|
||||
"name": "制定项目章程",
|
||||
"inputs": [
|
||||
{
|
||||
"id": "A002",
|
||||
"name": "立项管理文件",
|
||||
"details": []
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"id": "TT002",
|
||||
"name": "数据收集",
|
||||
"details": [
|
||||
{ "label": "头脑风暴" },
|
||||
{ "label": "焦点小组" },
|
||||
{ "label": "访谈" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "A001",
|
||||
"name": "项目章程",
|
||||
"details": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `inputs` | 输入数组 |
|
||||
| `tools` | 工具与技术数组 |
|
||||
| `outputs` | 输出数组 |
|
||||
| `details` | 当前过程下的明细项 |
|
||||
| `label` | 明细项名称 |
|
||||
| `note` | 补充说明,有才返回 |
|
||||
|
||||
---
|
||||
|
||||
### 3.9 八大绩效域
|
||||
|
||||
`GET /api/performance-domains.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": "PD01", "name": "干系人绩效域" },
|
||||
{ "id": "PD02", "name": "团队绩效域" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.10 单绩效域详情
|
||||
|
||||
`GET /api/performance-domains/{id}.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/performance-domains/PD01.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "PD01",
|
||||
"name": "干系人绩效域",
|
||||
"expectedGoals": [
|
||||
"与干系人建立高效的工作关系"
|
||||
],
|
||||
"keyPoints": [
|
||||
"促进干系人的参与"
|
||||
],
|
||||
"interactions": [
|
||||
"干系人为项目团队定义需求和范围并对其进行优先级排序。"
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"goal": "与干系人建立高效的工作关系",
|
||||
"indicators": [
|
||||
"干系人参与的连续性。"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `expectedGoals` | 预期目标 |
|
||||
| `keyPoints` | 绩效要点 |
|
||||
| `interactions` | 相互作用 |
|
||||
| `checks` | 检查方法 |
|
||||
| `goal` | 检查目标 |
|
||||
| `indicators` | 检查指标 |
|
||||
|
||||
---
|
||||
|
||||
### 3.11 工件反查使用情况
|
||||
|
||||
用于回答:某个输入 / 输出从哪里来、流向哪里。
|
||||
|
||||
`GET /api/artifacts/{id}/usage.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/artifacts/A001/usage.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "A001",
|
||||
"name": "项目章程",
|
||||
"asInput": [
|
||||
{
|
||||
"id": "P1.2",
|
||||
"name": "制订项目管理计划",
|
||||
"knowledgeAreaId": "KA01",
|
||||
"knowledgeAreaName": "项目整合管理",
|
||||
"processGroupId": "PG02",
|
||||
"processGroupName": "规划过程组",
|
||||
"purpose": "生成一份综合文件,用于确定所有项目工作的基础及其执行方式。"
|
||||
}
|
||||
],
|
||||
"asOutput": [
|
||||
{
|
||||
"id": "P1.1",
|
||||
"name": "制定项目章程",
|
||||
"knowledgeAreaId": "KA01",
|
||||
"knowledgeAreaName": "项目整合管理",
|
||||
"processGroupId": "PG01",
|
||||
"processGroupName": "启动过程组",
|
||||
"purpose": "项目章程是正式批准项目并授权项目经理使用组织资源的文件。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `asInput` | 哪些过程把它作为输入 |
|
||||
| `asOutput` | 哪些过程产出它 |
|
||||
|
||||
---
|
||||
|
||||
### 3.12 工具反查使用情况
|
||||
|
||||
用于回答:某个工具与技术在哪些过程中使用。
|
||||
|
||||
`GET /api/tools/{id}/usage.json`
|
||||
|
||||
示例:
|
||||
|
||||
`GET /api/tools/TT001/usage.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "TT001",
|
||||
"name": "专家判断",
|
||||
"usedIn": [
|
||||
{
|
||||
"id": "P1.1",
|
||||
"name": "制定项目章程",
|
||||
"knowledgeAreaId": "KA01",
|
||||
"knowledgeAreaName": "项目整合管理",
|
||||
"processGroupId": "PG01",
|
||||
"processGroupName": "启动过程组",
|
||||
"purpose": "项目章程是正式批准项目并授权项目经理使用组织资源的文件。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `usedIn` | 使用该工具与技术的过程数组 |
|
||||
|
||||
---
|
||||
|
||||
### 3.13 Markdown 接口文档
|
||||
|
||||
用于让外部系统直接读取本说明文档。
|
||||
|
||||
`GET /apidoc`
|
||||
|
||||
响应体为 Markdown 文本字符串,不作为附件下载。
|
||||
|
||||
示例响应:
|
||||
|
||||
```markdown
|
||||
# 知识库 API 接口说明
|
||||
...
|
||||
```
|
||||
|
||||
## 4. 常用调用示例
|
||||
|
||||
```text
|
||||
得到某知识领域的裁剪因素:
|
||||
/api/knowledge-areas/KA01/tailoring-factors.json
|
||||
|
||||
得到某知识领域下的所有子过程:
|
||||
/api/knowledge-areas/KA01/processes.json
|
||||
|
||||
得到某过程组下的所有子过程:
|
||||
/api/process-groups/PG02/processes.json
|
||||
|
||||
得到某过程的输入、输出、工具,包括明细项:
|
||||
/api/processes/P1.1/itto.json
|
||||
|
||||
得到某绩效域详情:
|
||||
/api/performance-domains/PD01.json
|
||||
|
||||
反查项目章程的来源和流向:
|
||||
/api/artifacts/A001/usage.json
|
||||
|
||||
反查专家判断在哪些过程中使用:
|
||||
/api/tools/TT001/usage.json
|
||||
|
||||
读取接口 Markdown 文档:
|
||||
/apidoc
|
||||
```
|
||||
879
docs/软考高项时间轴-需求与初设.md
Normal file
@@ -0,0 +1,879 @@
|
||||
# 软考高项时间轴功能 - 需求与初设文档
|
||||
|
||||
## 文档信息
|
||||
|
||||
- **创建日期**: 2026-03-22
|
||||
- **模块名称**: 软考高项时间轴 (Timeline)
|
||||
- **当前阶段**: 第一阶段 - 数据 JSON 设计与整理
|
||||
- **适用范围**: 软考高项教材中的时间类知识点整理、结构化存储与后续时间轴展示
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
当前项目已经形成较成熟的静态数据驱动模式,主要特征包括:
|
||||
|
||||
- 使用 `src/data/*.json` 维护结构化知识数据
|
||||
- 使用 `src/types/*.ts` 或 `src/types/itto.ts` 维护类型定义
|
||||
- 使用 `src/data/index.ts` 统一导出数据与查询能力
|
||||
- 页面展示层基于结构化数据进行可视化学习与交互
|
||||
|
||||
在此基础上,计划增加一个新的学习功能:
|
||||
|
||||
> 将软考高项中的关键时间类知识点整理为统一 JSON 数据,并在后续以时间轴形式进行展示。
|
||||
|
||||
该能力本质上不是普通的“历史事件时间线”,而是:
|
||||
|
||||
> **按教材主题归类、按显式时间排序、保留原文可追溯性的知识点时间轴。**
|
||||
|
||||
---
|
||||
|
||||
### 1.2 第一阶段目标
|
||||
|
||||
第一阶段不以页面开发为重点,而是优先夯实数据基础,主要完成:
|
||||
|
||||
1. 明确时间轴节点的数据结构
|
||||
2. 明确字段命名与语义边界
|
||||
3. 明确时间标准化规则
|
||||
4. 明确原文抽取规则
|
||||
5. 形成首批可落库的 JSON 数据样例
|
||||
|
||||
第一阶段的核心交付物包括:
|
||||
|
||||
- 时间轴数据结构 V1
|
||||
- 抽取规则 V1
|
||||
- `timeline-items.json` 初始样例数据
|
||||
- 供后续前端展示使用的数据基础
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
### 2.1 显式时间优先
|
||||
|
||||
时间轴的核心是“时间可排序”,因此每个节点必须存在明确的显式时间。
|
||||
|
||||
显式时间可以是:
|
||||
|
||||
- 年:如 `2035年`
|
||||
- 年月:如 `2021年3月`
|
||||
- 年月日:如 `2021年3月15日`
|
||||
|
||||
时间排序只能依赖显式时间,不依赖会议名称、政策名称或组织名称。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 原文可追溯
|
||||
|
||||
每个节点必须保留教材原文摘录,用于:
|
||||
|
||||
- 回看原始语境
|
||||
- 避免二次摘要失真
|
||||
- 支持后续搜索与核对
|
||||
- 提高复习时的可信度与可解释性
|
||||
|
||||
原文摘录必须尽量保真,不以转述替代原文。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 主题稳定可归类
|
||||
|
||||
时间轴中的每个节点都必须归属于某个明确主题,用于:
|
||||
|
||||
- 按章节筛选
|
||||
- 按知识域复习
|
||||
- 后续主题聚合展示
|
||||
|
||||
主题应优先使用教材章节名、小节名或知识点标题,避免同类主题命名不一致。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 来源与时间解耦
|
||||
|
||||
原先讨论中的“隐式时间”命名不够准确,因为这类信息不一定是时间,也可能是:
|
||||
|
||||
- 会议
|
||||
- 文件
|
||||
- 组织
|
||||
- 人物
|
||||
- 报告
|
||||
- 规划
|
||||
|
||||
因此,V1 中统一将该字段定义为:
|
||||
|
||||
> **来源锚点(sourceAnchor)**
|
||||
|
||||
它的作用是说明该节点“由谁提出、在哪里提出、依附于什么来源语境”,而不是承担排序功能。
|
||||
|
||||
---
|
||||
|
||||
## 3. 需求范围
|
||||
|
||||
### 3.1 纳入范围
|
||||
|
||||
第一阶段纳入时间轴整理范围的内容包括:
|
||||
|
||||
1. 教材中带有明确年份的战略目标、规划节点、发展目标
|
||||
2. 教材中带有明确年月的政策、制度、会议、文件、重要部署
|
||||
3. 教材中带有明确年月日的重要事件、发布动作、政策出台节点
|
||||
4. 与明确显式时间强相关,且适合纳入时间轴展示的知识点原文
|
||||
|
||||
---
|
||||
|
||||
### 3.2 暂不纳入范围
|
||||
|
||||
以下内容第一阶段不进入主时间轴 JSON:
|
||||
|
||||
1. 没有显式时间的纯概念性描述
|
||||
2. 只有意义、路径、措施,但缺少明确时间锚点的句子
|
||||
3. 只有会议/文件/组织来源,但原文中没有显式时间且暂不准备人工补全的内容
|
||||
4. 需要复杂推理或联网核验后才能确认时间的内容
|
||||
|
||||
这类内容可在后续进入:
|
||||
|
||||
- 候选池
|
||||
- 待补录列表
|
||||
- 二阶段人工校验列表
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心需求定义
|
||||
|
||||
### 4.1 节点必备字段
|
||||
|
||||
每条时间轴节点至少应包含以下 5 个要素:
|
||||
|
||||
#### 1)显式时间
|
||||
必须存在。
|
||||
|
||||
作用:
|
||||
|
||||
- 时间轴展示
|
||||
- 统一排序
|
||||
- 构建标准化排序键
|
||||
|
||||
---
|
||||
|
||||
#### 2)时间精度
|
||||
必须存在。
|
||||
|
||||
用于区分当前节点时间粒度:
|
||||
|
||||
- `year`
|
||||
- `month`
|
||||
- `day`
|
||||
|
||||
这样可以解决不同时间粒度混排的问题。
|
||||
|
||||
---
|
||||
|
||||
#### 3)主题
|
||||
必须存在。
|
||||
|
||||
用于说明该时间点归属的教材章节或知识主题。
|
||||
|
||||
例如:
|
||||
|
||||
- `农业现代化与乡村振兴战略`
|
||||
- `科技创新`
|
||||
- `数字中国`
|
||||
- `新型基础设施`
|
||||
|
||||
---
|
||||
|
||||
#### 4)原文摘录
|
||||
必须存在,且必须是原文。
|
||||
|
||||
要求:
|
||||
|
||||
- 保留原教材表述
|
||||
- 尽量保留最小完整语义片段
|
||||
- 不以摘要替代原文
|
||||
|
||||
---
|
||||
|
||||
#### 5)来源锚点
|
||||
建议存在,可为空。
|
||||
|
||||
用于说明该内容的提出来源、语境来源或依附来源。
|
||||
|
||||
例如:
|
||||
|
||||
- `党的十九届五中全会`
|
||||
- `国务院`
|
||||
- `“十四五”规划`
|
||||
- `某重要讲话`
|
||||
|
||||
该字段不参与排序,只承担来源说明作用。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 排序需求
|
||||
|
||||
排序规则必须满足以下约束:
|
||||
|
||||
1. 仅基于显式时间排序
|
||||
2. 支持年、年月、年月日混排
|
||||
3. 不依赖来源锚点排序
|
||||
4. 没有显式时间的记录不能进入主时间轴
|
||||
|
||||
---
|
||||
|
||||
### 4.3 检索需求
|
||||
|
||||
虽然第一阶段重点是数据整理,但字段设计需提前支持后续使用场景:
|
||||
|
||||
1. 按时间顺序浏览
|
||||
2. 按主题筛选
|
||||
3. 按来源锚点筛选
|
||||
4. 按原文关键词搜索
|
||||
5. 查看原文与教材定位信息
|
||||
|
||||
---
|
||||
|
||||
## 5. 术语定义
|
||||
|
||||
### 5.1 显式时间(Explicit Time)
|
||||
|
||||
指原文中明确写出的、可直接抽取的时间表达。
|
||||
|
||||
示例:
|
||||
|
||||
- `2035年`
|
||||
- `2021年3月`
|
||||
- `2021年3月15日`
|
||||
|
||||
显式时间是主时间轴排序的唯一依据。
|
||||
|
||||
---
|
||||
|
||||
### 5.2 来源锚点(Source Anchor)
|
||||
|
||||
指原文中说明该知识点“由谁提出、在哪提出、基于什么会议/文件/组织/人物”的来源信息。
|
||||
|
||||
示例:
|
||||
|
||||
- `党的十九届五中全会`
|
||||
- `国务院`
|
||||
- `“十四五”规划`
|
||||
- `政府工作报告`
|
||||
|
||||
说明:
|
||||
|
||||
- 该字段不是时间字段
|
||||
- 该字段不参与排序
|
||||
- 该字段主要用于补充上下文和来源语境
|
||||
|
||||
---
|
||||
|
||||
### 5.3 主题(Theme)
|
||||
|
||||
指该节点归属的教材章节、小节或知识点主题,用于分类和筛选。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 原文摘录(Excerpt)
|
||||
|
||||
指直接来自教材或整理材料中的原文片段,用于支撑该时间点。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据结构设计 V1
|
||||
|
||||
### 6.1 TypeScript 类型定义建议
|
||||
|
||||
建议新增时间轴类型定义,推荐单独创建文件:
|
||||
|
||||
- `src/types/timeline.ts`
|
||||
|
||||
类型建议如下:
|
||||
|
||||
```ts
|
||||
export type TimelineTimePrecision = 'year' | 'month' | 'day';
|
||||
|
||||
export type SourceAnchorType =
|
||||
| 'meeting'
|
||||
| 'document'
|
||||
| 'organization'
|
||||
| 'person'
|
||||
| 'policy'
|
||||
| 'report'
|
||||
| 'other';
|
||||
|
||||
export interface TimelineItem {
|
||||
id: string;
|
||||
|
||||
// 显式时间原文,用于展示
|
||||
timeText: string;
|
||||
|
||||
// 排序键,用于统一排序
|
||||
sortKey: string;
|
||||
|
||||
// 时间精度:年 / 月 / 日
|
||||
timePrecision: TimelineTimePrecision;
|
||||
|
||||
// 主题,通常对应章节或知识点
|
||||
theme: string;
|
||||
|
||||
// 原文摘录,必须保留原文
|
||||
excerpt: string;
|
||||
|
||||
// 来源锚点,可为空
|
||||
sourceAnchor?: string;
|
||||
|
||||
// 来源锚点类型,可为空
|
||||
sourceAnchorType?: SourceAnchorType;
|
||||
|
||||
// 可选:原文在教材中的定位信息
|
||||
sourcePosition?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 字段说明
|
||||
|
||||
| 字段名 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | 是 | 唯一标识,建议使用稳定编号 |
|
||||
| `timeText` | 是 | 原文中的显式时间表达 |
|
||||
| `sortKey` | 是 | 用于排序的标准化值 |
|
||||
| `timePrecision` | 是 | 时间粒度:年 / 月 / 日 |
|
||||
| `theme` | 是 | 所属主题或章节 |
|
||||
| `excerpt` | 是 | 原文摘录 |
|
||||
| `sourceAnchor` | 否 | 来源锚点 |
|
||||
| `sourceAnchorType` | 否 | 来源锚点类型 |
|
||||
| `sourcePosition` | 否 | 教材定位,如章节、页码、小节 |
|
||||
|
||||
---
|
||||
|
||||
### 6.3 命名设计说明
|
||||
|
||||
#### 为什么不用 `implicitTime`
|
||||
|
||||
因为这个字段并不稳定指向“时间”,它更多时候表示的是:
|
||||
|
||||
- 提出来源
|
||||
- 会议名称
|
||||
- 文件名称
|
||||
- 组织主体
|
||||
- 人物主体
|
||||
|
||||
如果继续命名为 `implicitTime`,会误导后续开发和数据维护。
|
||||
|
||||
---
|
||||
|
||||
#### 为什么推荐 `sourceAnchor`
|
||||
|
||||
因为它更准确表达了这个字段的职责:
|
||||
|
||||
- 是一个“来源锚点”
|
||||
- 是知识点的出处说明
|
||||
- 是原文上下文的承载信息
|
||||
- 不参与时间排序
|
||||
|
||||
---
|
||||
|
||||
## 7. JSON 文件组织方案
|
||||
|
||||
### 7.1 第一阶段文件建议
|
||||
|
||||
结合当前项目结构,建议新增:
|
||||
|
||||
```bash
|
||||
src/data/timeline-items.json
|
||||
```
|
||||
|
||||
采用与现有数据一致的静态 JSON 管理方式。
|
||||
|
||||
---
|
||||
|
||||
### 7.2 JSON 结构建议
|
||||
|
||||
建议采用统一外层包装结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"timelineItems": [
|
||||
{
|
||||
"id": "TL001",
|
||||
"timeText": "2035年",
|
||||
"sortKey": "20350000",
|
||||
"timePrecision": "year",
|
||||
"theme": "农业现代化与乡村振兴战略",
|
||||
"excerpt": "党的十九届五中全会着眼2035年基本实现社会主义现代化,提出“关键核心技术实现重大突破,进入创新型国家前列”的远景目标。",
|
||||
"sourceAnchor": "党的十九届五中全会",
|
||||
"sourceAnchorType": "meeting",
|
||||
"sourcePosition": "第X章 第X节"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `timelineItems` 命名与当前项目数据文件组织方式保持一致
|
||||
- 后续若数据规模扩大,可再考虑按主题拆分多个 JSON 文件
|
||||
|
||||
---
|
||||
|
||||
## 8. 时间标准化规则
|
||||
|
||||
为了实现稳定排序,需要将时间文本转为标准化排序键 `sortKey`。
|
||||
|
||||
### 8.1 标准化规则
|
||||
|
||||
#### 年
|
||||
|
||||
- 原文:`2035年`
|
||||
- `timePrecision`: `year`
|
||||
- `sortKey`: `20350000`
|
||||
|
||||
#### 年月
|
||||
|
||||
- 原文:`2021年3月`
|
||||
- `timePrecision`: `month`
|
||||
- `sortKey`: `20210300`
|
||||
|
||||
#### 年月日
|
||||
|
||||
- 原文:`2021年3月15日`
|
||||
- `timePrecision`: `day`
|
||||
- `sortKey`: `20210315`
|
||||
|
||||
---
|
||||
|
||||
### 8.2 字段设计建议
|
||||
|
||||
- `sortKey` 建议统一保存为字符串
|
||||
- 长度固定为 8 位
|
||||
- 年月不足位使用 `00` 补齐
|
||||
|
||||
这样可以减少:
|
||||
|
||||
- 数字前导零问题
|
||||
- 字符串排序与数值排序不一致问题
|
||||
- 前端处理时的格式分支
|
||||
|
||||
---
|
||||
|
||||
### 8.3 时间合法性要求
|
||||
|
||||
正式数据应满足以下约束:
|
||||
|
||||
1. 年份必须为四位数
|
||||
2. 月份必须在 `01-12`
|
||||
3. 日期必须在 `01-31`
|
||||
4. 非法时间不得进入正式时间轴 JSON
|
||||
|
||||
---
|
||||
### 8.4 标题锚点与目标锚点的区分说明
|
||||
|
||||
在时间标准化前,应先判断当前原文中的时间究竟属于哪一类:
|
||||
|
||||
#### 1)提出时间锚点
|
||||
用于表示某项政策、规划、文件、会议“在何时提出”。
|
||||
|
||||
例如:
|
||||
|
||||
- `《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》明确提出了推进产业数字化转型……`
|
||||
|
||||
这类句子的时间轴挂载时间,应优先取:
|
||||
|
||||
- 该纲要所属年份 `2021年`
|
||||
- 或进一步精确到 `2021年3月`
|
||||
|
||||
#### 2)目标实现锚点
|
||||
用于表示某项目标“到何时实现”。
|
||||
|
||||
例如:
|
||||
|
||||
- `到2035年基本实现社会主义现代化`
|
||||
|
||||
这类句子的时间轴挂载时间,才应直接取:
|
||||
|
||||
- `2035年`
|
||||
|
||||
因此,时间抽取不能只看句子里出现了哪个年份,还要判断该年份在语义上属于:
|
||||
|
||||
- **提出时间**
|
||||
- 还是 **目标时间**
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据抽取规则 V1
|
||||
|
||||
### 9.1 总体规则
|
||||
|
||||
#### 规则 1:没有显式时间,不入主时间轴
|
||||
|
||||
主时间轴只接收可明确排序的节点。
|
||||
|
||||
若仅有来源锚点而没有显式时间,则先不纳入主时间轴数据。
|
||||
|
||||
---
|
||||
|
||||
#### 规则 2:一段原文出现多个显式时间,应拆为多个节点
|
||||
|
||||
例如同一段中出现:
|
||||
|
||||
- `2020年`
|
||||
- `2035年`
|
||||
- `本世纪中叶`
|
||||
|
||||
若均可确定为可排序时间点,则应拆分成多条时间轴记录。
|
||||
|
||||
---
|
||||
|
||||
#### 规则 3:多个节点可共用同一段原文摘录
|
||||
|
||||
拆分后的多条节点可以共用:
|
||||
|
||||
- `theme`
|
||||
- `excerpt`
|
||||
- `sourceAnchor`
|
||||
- `sourcePosition`
|
||||
|
||||
但必须拥有独立的:
|
||||
|
||||
- `timeText`
|
||||
- `sortKey`
|
||||
- `timePrecision`
|
||||
- `id`
|
||||
|
||||
---
|
||||
|
||||
#### 规则 4:来源锚点不参与排序
|
||||
|
||||
来源锚点的职责是提供语义补充,不承担排序责任。
|
||||
|
||||
---
|
||||
|
||||
#### 规则 5:原文摘录必须尽量保真
|
||||
|
||||
要求:
|
||||
|
||||
- 不得用改写内容替代原文
|
||||
- 允许适度截取,但应保留完整语义
|
||||
- 以“能支撑该时间点成立的最小完整片段”为宜
|
||||
|
||||
#### 规则 5.1:若原始素材来自 OCR,必须先校对再入库
|
||||
|
||||
这里需要明确区分:
|
||||
|
||||
- **教材标准原文**
|
||||
- **OCR 识别结果**
|
||||
|
||||
时间轴中的 `excerpt` 字段应保存:
|
||||
|
||||
- **校对后的规范原文**
|
||||
|
||||
而不是未经处理的 OCR 脏文本。
|
||||
|
||||
处理原则如下:
|
||||
|
||||
1. 对于明显 OCR 错字,应直接人工校正后再入库,例如:
|
||||
- `深人` → `深入`
|
||||
- `进人` → `进入`
|
||||
2. 对于不确定是否识别错误的字句,不应主观猜测,应回到教材、原始资料或权威来源核对后再录入
|
||||
3. 若当前只能拿到 OCR 文本而无法完成核对,则该条数据应暂缓正式入库,避免错误内容污染主数据集
|
||||
|
||||
这样做的目的是:
|
||||
|
||||
- 保证 `excerpt` 可作为后续检索与复习依据
|
||||
- 避免把 OCR 噪声误当作教材原文长期沉淀
|
||||
- 提高数据质量,减少后续返工成本
|
||||
|
||||
#### 规则 5.2:`excerpt` 字段的入库边界
|
||||
|
||||
`excerpt` 字段的处理必须遵循以下边界:
|
||||
|
||||
1. **保留原文句式**
|
||||
2. **只修明显 OCR 错字**
|
||||
3. **不擅自改写、不总结、不替换原句**
|
||||
4. **如果怀疑句子不通顺,但又不能确定是 OCR 错误,必须先询问或标记,不得直接改写后入库**
|
||||
|
||||
也就是说,`excerpt` 的职责是:
|
||||
|
||||
- 保留知识点在原始材料中的表达方式
|
||||
- 为后续查找、比对、复习提供可信原句
|
||||
|
||||
而不是:
|
||||
|
||||
- 由系统进行润色
|
||||
- 改写成更通顺的表述
|
||||
- 用摘要句替代原文
|
||||
|
||||
该规则用于确保时间轴数据既可追溯,又不会因为主观改写而偏离原始材料。
|
||||
|
||||
#### 规则 6:文件名或规划名中同时出现‘规划期’与‘远景目标年’时,优先取提出时间
|
||||
|
||||
这是时间轴抽取中的高频易错点。
|
||||
|
||||
例如:
|
||||
|
||||
- `《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》`
|
||||
|
||||
该类名称中可能同时包含:
|
||||
|
||||
- 规划期信息,如“第十四个五年规划”
|
||||
- 远景目标信息,如“2035年远景目标”
|
||||
|
||||
若原文语义是:
|
||||
|
||||
- `某规划明确提出……`
|
||||
- `某纲要提出……`
|
||||
- `某文件要求……`
|
||||
|
||||
那么时间轴节点应优先挂载为:
|
||||
|
||||
- **该规划/文件的提出时间、发布年份或所属规划起始年份**
|
||||
|
||||
而不应直接挂载到远景目标年份。
|
||||
|
||||
也就是说,需要区分:
|
||||
|
||||
1. **提出时间**:指该政策、规划、文件、纲要被提出、发布或实施启动的时间
|
||||
2. **目标时间**:指原文明确表达“到某年实现某目标”的目标年份
|
||||
|
||||
只有当原文语义明确指向“到2035年实现……”时,才应将 `2035年` 作为该节点的显式时间。
|
||||
|
||||
若原文只是说:
|
||||
|
||||
- `《……2035年远景目标纲要》明确提出……`
|
||||
|
||||
则更适合将该节点时间挂到:
|
||||
|
||||
- `2021年`(按该纲要所属年份归类)
|
||||
- 或在资料足够精确时挂到 `2021年3月`(按正式通过/发布节点归类)
|
||||
|
||||
此规则的核心目的是:
|
||||
|
||||
- 避免将文件标题中的“远景目标年份”误当作当前知识点的发生时间
|
||||
- 保证时间轴表达的是“提出/发布时间”与“目标实现时间”的真实差异
|
||||
|
||||
---
|
||||
|
||||
### 9.2 主题提取规则
|
||||
|
||||
主题提取建议遵循以下优先级:
|
||||
|
||||
1. 当前教材章节标题
|
||||
2. 当前小节标题
|
||||
3. 当前知识点标题
|
||||
4. 无法直接归类时人工指定统一主题名
|
||||
|
||||
注意:
|
||||
|
||||
- 同类主题应统一命名
|
||||
- 不应同一主题出现多个近义版本
|
||||
|
||||
例如不建议同时出现:
|
||||
|
||||
- `农业现代化`
|
||||
- `农业现代化与乡村振兴`
|
||||
- `乡村振兴战略`
|
||||
|
||||
若其本质上属于同一章节主题,应统一。
|
||||
|
||||
---
|
||||
|
||||
### 9.3 来源锚点提取规则
|
||||
|
||||
来源锚点优先抽取原文中最直接、最核心的来源表达。
|
||||
|
||||
优先级建议如下:
|
||||
|
||||
1. 会议名称
|
||||
2. 文件/规划名称
|
||||
3. 组织名称
|
||||
4. 人物名称
|
||||
5. 其他来源描述
|
||||
|
||||
若原文中存在多个来源锚点,V1 可先只保留一个最核心锚点。
|
||||
|
||||
---
|
||||
|
||||
## 10. 样例数据
|
||||
|
||||
以下为基于当前讨论生成的样例节点:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "TL001",
|
||||
"timeText": "2035年",
|
||||
"sortKey": "20350000",
|
||||
"timePrecision": "year",
|
||||
"theme": "农业现代化与乡村振兴战略",
|
||||
"excerpt": "党的十九届五中全会着眼2035年基本实现社会主义现代化,提出“关键核心技术实现重大突破,进入创新型国家前列”的远景目标。",
|
||||
"sourceAnchor": "党的十九届五中全会",
|
||||
"sourceAnchorType": "meeting"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 与现有项目架构的对齐方案
|
||||
|
||||
### 11.1 数据层
|
||||
|
||||
新增时间轴 JSON 文件:
|
||||
|
||||
```bash
|
||||
src/data/timeline-items.json
|
||||
```
|
||||
|
||||
与当前项目的数据组织方式保持一致。
|
||||
|
||||
---
|
||||
|
||||
### 11.2 类型层
|
||||
|
||||
建议新增文件:
|
||||
|
||||
```bash
|
||||
src/types/timeline.ts
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 避免 `src/types/itto.ts` 职责继续膨胀
|
||||
- 便于后续扩展时间轴相关类型
|
||||
- 降低不同业务域之间的耦合
|
||||
|
||||
如果项目当前更偏向集中维护类型,也可以先临时放在 `src/types/itto.ts`,但从中长期维护上看,独立文件更合理。
|
||||
|
||||
---
|
||||
|
||||
### 11.3 数据统一导出
|
||||
|
||||
后续建议在 `src/data/index.ts` 中增加:
|
||||
|
||||
```ts
|
||||
import timelineItemsData from './timeline-items.json';
|
||||
import type { TimelineItem } from '../types/timeline';
|
||||
|
||||
export const timelineItems: TimelineItem[] =
|
||||
timelineItemsData.timelineItems as TimelineItem[];
|
||||
```
|
||||
|
||||
后续如有需要,可继续扩展:
|
||||
|
||||
- 按主题分组索引
|
||||
- 按来源锚点分组索引
|
||||
- 时间排序工具函数
|
||||
- 时间区间筛选工具函数
|
||||
|
||||
---
|
||||
|
||||
## 12. 第一阶段实施边界
|
||||
|
||||
### 12.1 本阶段要完成
|
||||
|
||||
1. 明确字段定义
|
||||
2. 明确抽取规则
|
||||
3. 建立时间标准化规范
|
||||
4. 建立首批时间轴 JSON 数据
|
||||
5. 准备后续页面开发所需的数据基础
|
||||
|
||||
---
|
||||
|
||||
### 12.2 本阶段暂不处理
|
||||
|
||||
1. 自动 NLP 抽取
|
||||
2. 自动时间纠错
|
||||
3. 复杂时间表达解析
|
||||
4. 时间轴页面 UI
|
||||
5. 多条件高级检索面板
|
||||
6. 时间轴与其他知识图谱的联动展示
|
||||
|
||||
---
|
||||
|
||||
## 13. 风险与注意事项
|
||||
|
||||
### 13.1 时间与来源混淆风险
|
||||
|
||||
这是当前模块最容易出错的点。
|
||||
|
||||
必须明确:
|
||||
|
||||
- 显式时间负责排序
|
||||
- 来源锚点负责说明出处
|
||||
- 两者不能混用
|
||||
|
||||
---
|
||||
|
||||
### 13.2 主题命名不统一风险
|
||||
|
||||
若主题命名粒度不统一,后续筛选与聚合会出现严重碎片化。
|
||||
|
||||
建议尽早制定主题命名规范,并优先以教材目录层级为准。
|
||||
|
||||
---
|
||||
|
||||
### 13.3 原文过长风险
|
||||
|
||||
原文摘录必须保留,但如果过长,会影响后续展示体验。
|
||||
|
||||
建议数据层保留完整有效片段,展示层再决定是否截断。
|
||||
|
||||
---
|
||||
|
||||
### 13.4 JSON 维护成本风险
|
||||
|
||||
第一阶段采用人工整理 JSON 的方式,优点是可控、准确;缺点是维护成本较高。
|
||||
|
||||
因此应尽早明确:
|
||||
|
||||
- 字段规范
|
||||
- 抽取规则
|
||||
- 主题命名规范
|
||||
- 时间标准化规则
|
||||
|
||||
这样才能降低后续补录和返工成本。
|
||||
|
||||
---
|
||||
|
||||
## 14. 后续演进建议
|
||||
|
||||
在第一阶段数据稳定后,后续可逐步演进:
|
||||
|
||||
1. 时间轴浏览页面
|
||||
2. 按主题筛选
|
||||
3. 按来源锚点筛选
|
||||
4. 原文搜索
|
||||
5. 节点详情弹层
|
||||
6. 同主题聚合展示
|
||||
7. 与章节知识点页面联动
|
||||
8. 导出 JSON / CSV
|
||||
|
||||
---
|
||||
|
||||
## 15. 当前推荐结论
|
||||
|
||||
第一阶段推荐采用如下最小可行数据结构:
|
||||
|
||||
```ts
|
||||
export interface TimelineItem {
|
||||
id: string;
|
||||
timeText: string;
|
||||
sortKey: string;
|
||||
timePrecision: 'year' | 'month' | 'day';
|
||||
theme: string;
|
||||
excerpt: string;
|
||||
sourceAnchor?: string;
|
||||
sourceAnchorType?: 'meeting' | 'document' | 'organization' | 'person' | 'policy' | 'report' | 'other';
|
||||
sourcePosition?: string;
|
||||
}
|
||||
```
|
||||
|
||||
该结构已经足以支撑:
|
||||
|
||||
- 时间排序
|
||||
- 主题归类
|
||||
- 原文追溯
|
||||
- 来源说明
|
||||
|
||||
同时也为第二阶段页面开发预留了足够扩展空间。
|
||||
293
docs/过程背诵练习-需求与实现.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 过程背诵练习模块 - 需求与实现记录
|
||||
|
||||
## 项目信息
|
||||
- **创建日期**: 2026-03-01
|
||||
- **模块名称**: 过程背诵练习 (Process Practice)
|
||||
- **路由地址**: `/process-practice`
|
||||
- **提交记录**:
|
||||
- 初始实现: `cc8dd1e`
|
||||
- 移动端优化: `da04583`
|
||||
|
||||
## 需求概述
|
||||
|
||||
创建一个交互式背诵练习模块,帮助用户记忆 PMP 项目管理的 10 个知识领域和 49 个过程。
|
||||
|
||||
### 核心功能需求
|
||||
|
||||
#### 1. 矩阵布局
|
||||
- 显示知识领域 × 过程组的矩阵(10行 × 5列)
|
||||
- 表头:5个过程组(启动、规划、执行、监控、收尾)
|
||||
- 知识领域格子横跨5列
|
||||
- 过程格子按过程组分列显示
|
||||
- 空单元格置灰显示(如范围管理-启动过程组)
|
||||
|
||||
#### 2. 背诵对象
|
||||
- **知识领域**(10个):如"整合管理"(去除"项目"前缀)
|
||||
- **过程**(49个):如"制定项目章程"
|
||||
- 总计:59个格子需要背诵
|
||||
|
||||
#### 3. 输入交互
|
||||
- 动态输入框:根据答案长度显示对应数量的横线
|
||||
- 实时验证:逐字符验证,错误标红,正确显示绿色
|
||||
- 答对后自动跳转到下一个格子(延迟300ms)
|
||||
- 支持中文输入法(监听 compositionStart/End)
|
||||
- 支持批量粘贴(固定长度数组,避免错位)
|
||||
|
||||
#### 4. 辅助信息
|
||||
- **知识领域格子**:显示敏捷裁剪因素(tailoringFactors)
|
||||
- **过程格子**:显示主要作用(purpose)
|
||||
- 位置:固定在底部,输入框下方
|
||||
- 移动端优化:限高可滚动,只显示前2个裁剪因素
|
||||
|
||||
#### 5. 格子切换
|
||||
- **TAB键**:按顺序切换(KA01 → P1.1 → P1.2 → ... → KA02 → ...)
|
||||
- **点击格子**:直接切换到目标格子
|
||||
- **自动跳过**:空单元格不可聚焦
|
||||
- **允许回顾**:可以点击已答对的格子重新查看
|
||||
|
||||
#### 6. 长按显示答案
|
||||
- 支持触摸、鼠标和键盘(空格键)
|
||||
- 长按600ms后显示答案
|
||||
- 松开后隐藏答案
|
||||
- 3秒后自动隐藏
|
||||
- 显示答案期间锁定输入区域
|
||||
|
||||
#### 7. 进度跟踪
|
||||
- 顶部显示进度条
|
||||
- 显示已答对数量 / 总数量
|
||||
- 进度条动画效果
|
||||
|
||||
#### 8. 无障碍支持
|
||||
- `aria-live` 区域通告答案显示/隐藏
|
||||
- `tabIndex` 控制键盘导航
|
||||
- `aria-describedby` 关联辅助信息
|
||||
- `scrollIntoView` 确保格子可见
|
||||
- `role="button"` 和 `aria-current` 标记
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── utils/
|
||||
│ └── practice.ts # 工具函数
|
||||
├── hooks/
|
||||
│ └── useLongPress.ts # 长按 Hook
|
||||
├── components/
|
||||
│ └── practice/
|
||||
│ ├── KnowledgeAreaCell.tsx # 知识领域格子
|
||||
│ ├── ProcessCell.tsx # 过程格子
|
||||
│ ├── InputArea.tsx # 输入区域
|
||||
│ ├── HintInfo.tsx # 辅助信息
|
||||
│ └── PracticeMatrix.tsx # 矩阵组件
|
||||
├── pages/
|
||||
│ └── ProcessPracticePage.tsx # 主页面
|
||||
├── App.tsx # 路由配置
|
||||
└── components/layout/Sidebar.tsx # 导航菜单
|
||||
```
|
||||
|
||||
### 核心算法
|
||||
|
||||
#### 1. 格子顺序生成 (`generateCellSequence`)
|
||||
```typescript
|
||||
// 顺序:KA01 → P1.1 → P1.2 → ... → P1.7 → KA02 → P2.1 → ...
|
||||
// 总计:10个知识领域 + 49个过程 = 59个格子
|
||||
```
|
||||
|
||||
#### 2. 答案标准化 (`normalizeAnswer`)
|
||||
```typescript
|
||||
// 去除空格、标点
|
||||
// 只对知识领域去除"项目"前缀
|
||||
// 过程名称保留"项目"字样
|
||||
```
|
||||
|
||||
#### 3. 输入验证逻辑
|
||||
- 使用原始答案长度渲染横线
|
||||
- 使用标准化答案进行验证
|
||||
- 逐字符比对,维护 `charStatuses` 数组
|
||||
- 完整答案验证后触发跳转或错误提示
|
||||
|
||||
#### 4. 长按检测 (`useLongPress`)
|
||||
- `onPointerDown` 启动600ms定时器
|
||||
- `onPointerUp/Leave/Cancel` 清理定时器
|
||||
- 支持键盘空格键长按
|
||||
- 阻止 `contextMenu` 事件
|
||||
|
||||
### 样式设计
|
||||
|
||||
#### 桌面端
|
||||
- 矩阵格子:p-4, gap-3, min-h-60px
|
||||
- 字体大小:text-sm, text-base
|
||||
- 输入框:w-10, h-12, text-2xl
|
||||
|
||||
#### 移动端优化
|
||||
- 矩阵格子:p-2, gap-2, min-h-40px
|
||||
- 字体大小:text-xs, text-sm
|
||||
- 输入框:保持原大小(便于输入)
|
||||
- 底部固定区域:分层显示输入框和辅助信息
|
||||
- 辅助信息:max-h-32, overflow-y-auto
|
||||
|
||||
### 状态管理
|
||||
|
||||
```typescript
|
||||
// 格子顺序
|
||||
cellSequence: CellInfo[]
|
||||
|
||||
// 答题记录
|
||||
answeredCells: Map<string, boolean>
|
||||
|
||||
// 当前焦点
|
||||
currentCellId: string | null
|
||||
|
||||
// 输入状态
|
||||
userInput: string[]
|
||||
charStatuses: CharStatus[]
|
||||
isComposing: boolean
|
||||
lastErrorTimestamp: number | null
|
||||
|
||||
// 长按显示答案
|
||||
showAnswerForCell: { cellId, answer, expiresAt } | null
|
||||
inputLocked: boolean
|
||||
```
|
||||
|
||||
## 实现进度
|
||||
|
||||
### ✅ 已完成功能
|
||||
|
||||
1. **基础框架**
|
||||
- [x] 创建工具函数和类型定义
|
||||
- [x] 创建长按 Hook
|
||||
- [x] 创建格子组件
|
||||
- [x] 创建输入区域组件
|
||||
- [x] 创建辅助信息组件
|
||||
- [x] 创建矩阵组件
|
||||
- [x] 创建主页面组件
|
||||
|
||||
2. **核心功能**
|
||||
- [x] 格子顺序生成(59个格子)
|
||||
- [x] 答案标准化(知识领域去除"项目")
|
||||
- [x] 动态输入框(根据答案长度)
|
||||
- [x] 实时验证(逐字符验证)
|
||||
- [x] 自动跳转(答对后300ms延迟)
|
||||
- [x] TAB键切换(按顺序,跳过空格)
|
||||
- [x] 点击格子切换(允许回顾)
|
||||
- [x] 长按显示答案(支持多端)
|
||||
- [x] 进度跟踪(顶部进度条)
|
||||
|
||||
3. **输入法支持**
|
||||
- [x] 监听 compositionStart/End
|
||||
- [x] 输入法期间暂停验证
|
||||
- [x] 确认后立即验证
|
||||
|
||||
4. **批量粘贴**
|
||||
- [x] 创建固定长度数组
|
||||
- [x] 不足部分保留空字符串
|
||||
- [x] 避免输入框数量错位
|
||||
|
||||
5. **无障碍支持**
|
||||
- [x] aria-live 通告
|
||||
- [x] tabIndex 控制
|
||||
- [x] aria-describedby 关联
|
||||
- [x] scrollIntoView 滚动
|
||||
- [x] role 和 aria-current
|
||||
|
||||
6. **移动端优化**
|
||||
- [x] 压缩间距和内边距
|
||||
- [x] 减小字体大小
|
||||
- [x] 底部固定区域分层
|
||||
- [x] 辅助信息限高可滚动
|
||||
- [x] 修复吸顶位置冲突
|
||||
|
||||
### 🔧 已修复问题
|
||||
|
||||
1. **Codex Review 后的改进**
|
||||
- [x] 修复批量粘贴数组长度问题
|
||||
- [x] 格子可聚焦(支持键盘长按)
|
||||
- [x] 允许回顾已答对的格子
|
||||
- [x] 修复表头吸顶位置冲突
|
||||
|
||||
2. **移动端样式问题**
|
||||
- [x] 底部区域布局重构
|
||||
- [x] 压缩矩阵格子尺寸
|
||||
- [x] 辅助信息区域优化
|
||||
- [x] 主内容区域底部内边距
|
||||
|
||||
### 📝 待优化项(可选)
|
||||
|
||||
1. **性能优化**
|
||||
- [ ] 虚拟滚动(如果格子数量增加)
|
||||
- [ ] 使用 React.memo 优化格子渲染
|
||||
- [ ] 防抖输入验证(目前已有 requestAnimationFrame)
|
||||
|
||||
2. **功能增强**
|
||||
- [ ] 添加"重置"按钮(清空所有答题记录)
|
||||
- [ ] 添加"跳过"按钮(跳过当前格子)
|
||||
- [ ] 保存练习进度到 localStorage
|
||||
- [ ] 统计答题时间和错误次数
|
||||
- [ ] 提供"只练习知识领域"或"只练习过程"模式
|
||||
|
||||
3. **用户体验**
|
||||
- [ ] 添加音效反馈(答对/答错)
|
||||
- [ ] 添加震动反馈(移动端)
|
||||
- [ ] 提供快捷键说明(帮助面板)
|
||||
- [ ] 添加"提示"按钮(显示首字母)
|
||||
|
||||
## 数据依赖
|
||||
|
||||
### 知识领域数据
|
||||
- 文件:`src/data/knowledge-areas.json`
|
||||
- 字段:`id`, `name`, `order`, `color`, `tailoringFactors`
|
||||
|
||||
### 过程数据
|
||||
- 文件:`src/data/processes.json`
|
||||
- 字段:`id`, `code`, `name`, `knowledgeAreaId`, `processGroupId`, `order`, `purpose`
|
||||
|
||||
### 过程组数据
|
||||
- 文件:`src/data/process-groups.json`
|
||||
- 字段:`id`, `name`, `order`, `color`
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 功能测试
|
||||
1. 访问 `/process-practice` 页面
|
||||
2. 验证矩阵初始状态(所有格子空白占位)
|
||||
3. 输入"整合管理",验证格子显示"1.整合管理"
|
||||
4. 验证自动跳转到下一个格子
|
||||
5. 测试 TAB 键切换顺序
|
||||
6. 测试点击格子切换
|
||||
7. 测试长按显示答案(600ms)
|
||||
8. 测试输入法输入
|
||||
9. 测试批量粘贴
|
||||
10. 完成所有59个格子的答题
|
||||
|
||||
### 边界测试
|
||||
- 第一个格子按 Shift+Tab(不应有反应)
|
||||
- 最后一个格子按 Tab(不应有反应)
|
||||
- 长按空单元格(不应有反应)
|
||||
- 快速连续输入(验证防抖)
|
||||
- 输入错误后按 Escape(验证清空输入)
|
||||
|
||||
### 移动端测试
|
||||
- 验证底部固定区域不遮挡内容
|
||||
- 验证辅助信息区域可滚动
|
||||
- 验证格子尺寸适配小屏幕
|
||||
- 验证触摸长按功能
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 需求设计文档:`docs/需求设计文档.md`
|
||||
- 实现计划:`/root/.claude/plans/iridescent-cooking-bee.md`
|
||||
- CLAUDE.md:`/home/ittoview/CLAUDE.md`
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 2026-03-01
|
||||
- ✅ 完成初始实现(提交 cc8dd1e)
|
||||
- ✅ 修复 Codex Review 发现的问题
|
||||
- ✅ 优化移动端布局和样式(提交 da04583)
|
||||
- ✅ 创建本需求记录文档
|
||||
|
||||
---
|
||||
|
||||
**备注**: 本模块已完成核心功能开发和移动端优化,可以正常使用。后续可根据用户反馈进行功能增强和体验优化。
|
||||
14
index.html
@@ -2,7 +2,8 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/src/data/icon/ittoico.png" />
|
||||
<link rel="apple-touch-icon" href="/src/data/icon/ittoico.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ITTOView - PMP项目管理ITTO可视化学习平台</title>
|
||||
<meta name="description" content="PMP认证考试ITTO可视化学习平台,帮助您高效掌握49个项目管理过程" />
|
||||
@@ -10,5 +11,16 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://dify.shizhuoran.top/js/iframe.js"
|
||||
id="chatbot-iframe"
|
||||
data-bot-src="https://dify.shizhuoran.top/chat/share?shareId=fZrDl1k8EO9L4eLXXinm6A53"
|
||||
data-default-open="false"
|
||||
data-drag="false"
|
||||
data-open-icon="data:image/svg+xml;base64,PHN2ZyB0PSIxNjkwNTMyNzg1NjY0IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjQxMzIiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48cGF0aCBkPSJNNTEyIDMyQzI0Ny4wNCAzMiAzMiAyMjQgMzIgNDY0QTQxMC4yNCA0MTAuMjQgMCAwIDAgMTcyLjQ4IDc2OEwxNjAgOTY1LjEyYTI1LjI4IDI1LjI4IDAgMCAwIDM5LjA0IDIyLjRsMTY4LTExMkE1MjguNjQgNTI4LjY0IDAgMCAwIDUxMiA4OTZjMjY0Ljk2IDAgNDgwLTE5MiA0ODAtNDMyUzc3Ni45NiAzMiA1MTIgMzJ6IG0yNDQuOCA0MTZsLTM2MS42IDMwMS43NmExMi40OCAxMi40OCAwIDAgMS0xOS44NC0xMi40OGw1OS4yLTIzMy45MmgtMTYwYTEyLjQ4IDEyLjQ4IDAgMCAxLTcuMzYtMjMuMzZsMzYxLjYtMzAxLjc2YTEyLjQ4IDEyLjQ4IDAgMCAxIDE5Ljg0IDEyLjQ4bC01OS4yIDIzMy45MmgxNjBhMTIuNDggMTIuNDggMCAwIDEgOCAyMi4wOHoiIGZpbGw9IiM0ZTgzZmQiIHAtaWQ9IjQxMzMiPjwvcGF0aD48L3N2Zz4="
|
||||
data-close-icon="data:image/svg+xml;base64,PHN2ZyB0PSIxNjkwNTM1NDQxNTI2IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjYzNjciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj48cGF0aCBkPSJNNTEyIDEwMjRBNTEyIDUxMiAwIDEgMSA1MTIgMGE1MTIgNTEyIDAgMCAxIDAgMTAyNHpNMzA1Ljk1NjU3MSAzNzAuMzk1NDI5TDQ0Ny40ODggNTEyIDMwNS45NTY1NzEgNjUzLjYwNDU3MWE0NS41NjggNDUuNTY4IDAgMSAwIDY0LjQzODg1OCA2NC40Mzg4NThMNTEyIDU3Ni41MTJsMTQxLjYwNDU3MSAxNDEuNTMxNDI5YTQ1LjU2OCA0NS41NjggMCAwIDAgNjQuNDM4ODU4LTY0LjQzODg1OEw1NzYuNTEyIDUxMmwxNDEuNTMxNDI5LTE0MS42MDQ1NzFhNDUuNTY4IDQ1LjU2OCAwIDEgMC02NC40Mzg4NTgtNjQuNDM4ODU4TDUxMiA0NDcuNDg4IDM3MC4zOTU0MjkgMzA1Ljk1NjU3MWE0NS41NjggNDUuNTY4IDAgMCAwLTY0LjQzODg1OCA2NC40Mzg4NTh6IiBmaWxsPSIjNGU4M2ZkIiBwLWlkPSI2MzY4Ij48L3BhdGg+PC9zdmc+"
|
||||
defer
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
32
nginx.conf
@@ -9,6 +9,38 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Markdown 接口说明,直接返回文本内容
|
||||
location = /apidoc {
|
||||
types { }
|
||||
default_type text/plain;
|
||||
charset utf-8;
|
||||
try_files /apidoc =404;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# 知识库静态 JSON API,接口路径不存在时返回 404,避免被 SPA 回退到 index.html
|
||||
location /api/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# 一图流图片文件缓存 30 天;新增图片建议使用新文件名,避免同名覆盖缓存未刷新
|
||||
location ~* ^/learning-images/.+\.(png|jpg|jpeg|webp|gif|avif)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# 一图流图片目录,由 Docker 挂载提供;目录列表不缓存,保证新增图片能及时被页面发现
|
||||
location /learning-images/ {
|
||||
alias /usr/share/nginx/html/learning-images/;
|
||||
autoindex on;
|
||||
charset utf-8;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location /assets {
|
||||
expires 1y;
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "npm run generate:api && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"generate:api": "node scripts/generate-api.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/g6": "^4.8.25",
|
||||
|
||||
BIN
public/wechat-qrcode.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
300
scripts/generate-api.mjs
Normal file
@@ -0,0 +1,300 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const dataDir = path.join(rootDir, 'src', 'data')
|
||||
const apiDir = path.join(rootDir, 'public', 'api')
|
||||
const apiDocPath = path.join(rootDir, 'public', 'apidoc')
|
||||
|
||||
function readJson(relativePath) {
|
||||
return JSON.parse(fs.readFileSync(path.join(rootDir, relativePath), 'utf8'))
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
function writeJson(relativePath, data) {
|
||||
const target = path.join(apiDir, relativePath)
|
||||
ensureDir(path.dirname(target))
|
||||
fs.writeFileSync(target, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function cleanApiDir() {
|
||||
fs.rmSync(apiDir, { recursive: true, force: true })
|
||||
fs.rmSync(apiDocPath, { force: true })
|
||||
ensureDir(apiDir)
|
||||
}
|
||||
|
||||
function writeTextFile(target, content) {
|
||||
ensureDir(path.dirname(target))
|
||||
fs.writeFileSync(target, content, 'utf8')
|
||||
}
|
||||
|
||||
function copyApiDoc() {
|
||||
const source = path.join(rootDir, 'docs', '知识库API接口说明.md')
|
||||
const content = fs.readFileSync(source, 'utf8')
|
||||
// /apidoc 是无扩展名文本接口;写入 UTF-8 BOM,避免部分静态服务器或浏览器按非 UTF-8 猜测导致中文乱码。
|
||||
writeTextFile(apiDocPath, `\uFEFF${content}`)
|
||||
}
|
||||
|
||||
function extractConstArrayFromTs(filePath, constName) {
|
||||
const text = fs.readFileSync(filePath, 'utf8')
|
||||
const marker = `export const ${constName}`
|
||||
const markerIndex = text.indexOf(marker)
|
||||
if (markerIndex === -1) {
|
||||
throw new Error(`未找到 ${constName}`)
|
||||
}
|
||||
|
||||
const assignmentIndex = text.indexOf('=', markerIndex)
|
||||
if (assignmentIndex === -1) {
|
||||
throw new Error(`未找到 ${constName} 赋值符号`)
|
||||
}
|
||||
|
||||
const arrayStart = text.indexOf('[', assignmentIndex)
|
||||
if (arrayStart === -1) {
|
||||
throw new Error(`未找到 ${constName} 数组起点`)
|
||||
}
|
||||
|
||||
let depth = 0
|
||||
let quote = null
|
||||
let escaped = false
|
||||
let lineComment = false
|
||||
let blockComment = false
|
||||
|
||||
for (let index = arrayStart; index < text.length; index += 1) {
|
||||
const char = text[index]
|
||||
const next = text[index + 1]
|
||||
|
||||
if (lineComment) {
|
||||
if (char === '\n') lineComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (blockComment) {
|
||||
if (char === '*' && next === '/') {
|
||||
blockComment = false
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (char === '\\') {
|
||||
escaped = true
|
||||
} else if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
lineComment = true
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
blockComment = true
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\'' || char === '"' || char === '`') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '[') depth += 1
|
||||
if (char === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
const source = text.slice(arrayStart, index + 1)
|
||||
return vm.runInNewContext(`(${source})`, {}, { timeout: 1000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`未能完整解析 ${constName}`)
|
||||
}
|
||||
|
||||
const knowledgeAreas = readJson('src/data/knowledge-areas.json').knowledgeAreas
|
||||
const processGroups = readJson('src/data/process-groups.json').processGroups
|
||||
const processes = readJson('src/data/processes.json').processes
|
||||
const artifacts = readJson('src/data/artifacts.json').artifacts
|
||||
const tools = readJson('src/data/tools.json').tools
|
||||
const performanceDomains = extractConstArrayFromTs(
|
||||
path.join(dataDir, 'performance-domains.ts'),
|
||||
'performanceDomains'
|
||||
)
|
||||
|
||||
const knowledgeAreaMap = new Map(knowledgeAreas.map((item) => [item.id, item]))
|
||||
const processGroupMap = new Map(processGroups.map((item) => [item.id, item]))
|
||||
const artifactMap = new Map(artifacts.map((item) => [item.id, item]))
|
||||
const toolMap = new Map(tools.map((item) => [item.id, item]))
|
||||
|
||||
function normalizeRef(ref) {
|
||||
if (typeof ref === 'string') return { id: ref, details: [], note: undefined }
|
||||
return {
|
||||
id: ref.id,
|
||||
details: Array.isArray(ref.detail) ? ref.detail.map((item) => ({ label: item.label })) : [],
|
||||
note: ref.note,
|
||||
}
|
||||
}
|
||||
|
||||
function processSummary(process) {
|
||||
const knowledgeArea = knowledgeAreaMap.get(process.knowledgeAreaId)
|
||||
const processGroup = processGroupMap.get(process.processGroupId)
|
||||
return {
|
||||
id: process.id,
|
||||
name: process.name,
|
||||
knowledgeAreaId: process.knowledgeAreaId,
|
||||
knowledgeAreaName: knowledgeArea?.name ?? '',
|
||||
processGroupId: process.processGroupId,
|
||||
processGroupName: processGroup?.name ?? '',
|
||||
purpose: process.purpose ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function attachRefMeta(ref, entityMap) {
|
||||
const normalized = normalizeRef(ref)
|
||||
const entity = entityMap.get(normalized.id)
|
||||
return {
|
||||
id: normalized.id,
|
||||
name: entity?.name ?? normalized.id,
|
||||
details: normalized.details,
|
||||
...(normalized.note ? { note: normalized.note } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function processItto(process) {
|
||||
return {
|
||||
id: process.id,
|
||||
name: process.name,
|
||||
inputs: process.inputs.map((ref) => attachRefMeta(ref, artifactMap)),
|
||||
tools: process.tools.map((ref) => attachRefMeta(ref, toolMap)),
|
||||
outputs: process.outputs.map((ref) => attachRefMeta(ref, artifactMap)),
|
||||
}
|
||||
}
|
||||
|
||||
function includesRef(refs, targetId) {
|
||||
return refs.some((ref) => normalizeRef(ref).id === targetId)
|
||||
}
|
||||
|
||||
function artifactUsage(artifact) {
|
||||
return {
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
asInput: processes.filter((process) => includesRef(process.inputs, artifact.id)).map(processSummary),
|
||||
asOutput: processes.filter((process) => includesRef(process.outputs, artifact.id)).map(processSummary),
|
||||
}
|
||||
}
|
||||
|
||||
function toolUsage(tool) {
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
usedIn: processes.filter((process) => includesRef(process.tools, tool.id)).map(processSummary),
|
||||
}
|
||||
}
|
||||
|
||||
cleanApiDir()
|
||||
|
||||
writeJson('process-groups.json', processGroups.map((item) => ({ id: item.id, name: item.name })))
|
||||
|
||||
writeJson(
|
||||
'knowledge-areas.json',
|
||||
knowledgeAreas.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
tailoringFactors: (item.tailoringFactors ?? []).map((factor) => ({
|
||||
title: factor.title,
|
||||
description: factor.description,
|
||||
})),
|
||||
}))
|
||||
)
|
||||
|
||||
for (const knowledgeArea of knowledgeAreas) {
|
||||
writeJson(
|
||||
`knowledge-areas/${knowledgeArea.id}/tailoring-factors.json`,
|
||||
(knowledgeArea.tailoringFactors ?? []).map((factor) => ({
|
||||
title: factor.title,
|
||||
description: factor.description,
|
||||
}))
|
||||
)
|
||||
|
||||
writeJson(
|
||||
`knowledge-areas/${knowledgeArea.id}/processes.json`,
|
||||
processes
|
||||
.filter((process) => process.knowledgeAreaId === knowledgeArea.id)
|
||||
.map((process) => {
|
||||
const summary = processSummary(process)
|
||||
return {
|
||||
id: summary.id,
|
||||
name: summary.name,
|
||||
processGroupId: summary.processGroupId,
|
||||
processGroupName: summary.processGroupName,
|
||||
purpose: summary.purpose,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
for (const processGroup of processGroups) {
|
||||
writeJson(
|
||||
`process-groups/${processGroup.id}/processes.json`,
|
||||
processes
|
||||
.filter((process) => process.processGroupId === processGroup.id)
|
||||
.map((process) => {
|
||||
const summary = processSummary(process)
|
||||
return {
|
||||
id: summary.id,
|
||||
name: summary.name,
|
||||
knowledgeAreaId: summary.knowledgeAreaId,
|
||||
knowledgeAreaName: summary.knowledgeAreaName,
|
||||
purpose: summary.purpose,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
writeJson('processes.json', processes.map(processSummary))
|
||||
|
||||
for (const process of processes) {
|
||||
writeJson(`processes/${process.id}.json`, processSummary(process))
|
||||
writeJson(`processes/${process.id}/itto.json`, processItto(process))
|
||||
}
|
||||
|
||||
writeJson('performance-domains.json', performanceDomains.map((item) => ({ id: item.id, name: item.name })))
|
||||
|
||||
for (const domain of performanceDomains) {
|
||||
writeJson(`performance-domains/${domain.id}.json`, {
|
||||
id: domain.id,
|
||||
name: domain.name,
|
||||
expectedGoals: domain.detail?.expectedGoals ?? [],
|
||||
keyPoints: domain.detail?.keyPoints ?? [],
|
||||
interactions: domain.detail?.interactions ?? [],
|
||||
checks: domain.detail?.checks ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
writeJson('artifacts.json', artifacts.map((item) => ({ id: item.id, name: item.name })))
|
||||
writeJson('tools.json', tools.map((item) => ({ id: item.id, name: item.name })))
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
writeJson(`artifacts/${artifact.id}/usage.json`, artifactUsage(artifact))
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
writeJson(`tools/${tool.id}/usage.json`, toolUsage(tool))
|
||||
}
|
||||
|
||||
copyApiDoc()
|
||||
|
||||
console.log(`已生成静态 API 文件:${path.relative(rootDir, apiDir)}`)
|
||||
console.log(`已生成 Markdown 接口文档:${path.relative(rootDir, apiDocPath)}`)
|
||||
19
src/App.tsx
@@ -9,6 +9,15 @@ import { ProcessGraphPage } from './pages/ProcessGraphPage'
|
||||
import { ArtifactDetailPage } from './pages/ArtifactDetailPage'
|
||||
import { ToolDetailPage } from './pages/ToolDetailPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import ProcessPracticePage from './pages/ProcessPracticePage'
|
||||
import ProcessPurposePracticePage from './pages/ProcessPurposePracticePage'
|
||||
import PrinciplesPage from './pages/PrinciplesPage'
|
||||
import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage'
|
||||
import PerformanceDomainPracticePage from './pages/PerformanceDomainPracticePage'
|
||||
import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage'
|
||||
import { LearningMapsPage } from './pages/LearningMapsPage'
|
||||
import { ApiDocPage } from './pages/ApiDocPage'
|
||||
import { IttoCollectionsPage } from './pages/IttoCollectionsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -17,14 +26,24 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/knowledge-areas" element={<KnowledgeAreasPage />} />
|
||||
<Route path="/knowledge-areas/:id" element={<KnowledgeAreasPage />} />
|
||||
<Route path="/knowledge-areas-tailoring" element={<KnowledgeAreasTailoringPage />} />
|
||||
<Route path="/process-groups" element={<ProcessGroupsPage />} />
|
||||
<Route path="/process-groups/:id" element={<ProcessGroupsPage />} />
|
||||
<Route path="/process/:id" element={<ProcessDetailPage />} />
|
||||
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
|
||||
<Route path="/itto-collections" element={<IttoCollectionsPage />} />
|
||||
<Route path="/process-graph" element={<ProcessGraphPage />} />
|
||||
<Route path="/process-practice" element={<ProcessPracticePage />} />
|
||||
<Route path="/process-purpose-practice" element={<ProcessPurposePracticePage />} />
|
||||
<Route path="/principles" element={<PrinciplesPage />} />
|
||||
<Route path="/performance-domains" element={<PerformanceDomainsPage />} />
|
||||
<Route path="/performance-domains/practice" element={<PerformanceDomainPracticePage />} />
|
||||
<Route path="/performance-domains/:id" element={<PerformanceDomainsPage />} />
|
||||
<Route path="/learning-maps" element={<LearningMapsPage />} />
|
||||
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
||||
<Route path="/tool/:id" element={<ToolDetailPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/doc" element={<ApiDocPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
182
src/components/ChangelogModal.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, History, CalendarDays, Tag } from 'lucide-react'
|
||||
import { changelogEntries } from '@/data'
|
||||
import type { ChangelogType } from '@/types/itto'
|
||||
|
||||
interface ChangelogModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const typeMeta: Record<ChangelogType, { label: string; className: string }> = {
|
||||
feat: { label: '新功能', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
|
||||
fix: { label: '修复', className: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300' },
|
||||
style: { label: '样式', className: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300' },
|
||||
refactor: { label: '重构', className: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300' },
|
||||
docs: { label: '文档', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
perf: { label: '性能', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
|
||||
test: { label: '测试', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300' },
|
||||
chore: { label: '工程', className: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' },
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
if (!year || !month || !day) return date
|
||||
return `${year}年${month}月${day}日`
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
function groupByDate(entries: typeof changelogEntries) {
|
||||
const groups = new Map<string, typeof changelogEntries>()
|
||||
entries.forEach((entry) => {
|
||||
const existing = groups.get(entry.date) || []
|
||||
groups.set(entry.date, [...existing, entry])
|
||||
})
|
||||
return Array.from(groups.entries()).sort((a, b) => b[0].localeCompare(a[0]))
|
||||
}
|
||||
|
||||
export function ChangelogModal({ isOpen, onClose }: ChangelogModalProps) {
|
||||
const groupedEntries = groupByDate(changelogEntries)
|
||||
|
||||
// 使用 Portal 将模态框渲染到 body,避免被 Header 的层叠上下文限制
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/50 z-[9999]"
|
||||
/>
|
||||
|
||||
{/* 模态框 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="fixed inset-4 md:inset-8 lg:inset-16 z-[10000] flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full h-full flex flex-col overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/50">
|
||||
<History size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">更新日志</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {changelogEntries.length} 条记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{changelogEntries.length > 0 ? (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="max-w-4xl mx-auto space-y-8"
|
||||
>
|
||||
{/* 按日期分组显示 */}
|
||||
{groupedEntries.map(([date, entries]) => (
|
||||
<div key={date} className="space-y-4">
|
||||
{/* 日期标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{formatDate(date)}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* 该日期下的更新列表 - 统一卡片 */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
|
||||
>
|
||||
{entries.map((entry, index) => {
|
||||
const meta = typeMeta[entry.type]
|
||||
return (
|
||||
<div
|
||||
key={entry.id || `${entry.date}-${index}`}
|
||||
className="p-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 标签 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${meta.className}`}>
|
||||
<Tag size={12} />
|
||||
{meta.label}
|
||||
</span>
|
||||
{entry.scope && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{entry.scope}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<p className="text-sm text-gray-900 dark:text-white flex-1">
|
||||
{entry.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 dark:text-gray-500">
|
||||
<History size={48} className="mb-4" />
|
||||
<p className="text-sm">暂无更新记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { Menu, Search, Sun, Moon, X } from 'lucide-react'
|
||||
import { Menu, Search, Sun, Moon, X, History } from 'lucide-react'
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { processes, artifacts, tools, knowledgeAreaMap } from '@/data'
|
||||
import { ChangelogModal } from '@/components/ChangelogModal'
|
||||
|
||||
interface SearchResult {
|
||||
type: 'process' | 'artifact' | 'tool'
|
||||
@@ -21,6 +22,7 @@ export function Header() {
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const [isChangelogOpen, setIsChangelogOpen] = useState(false)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useNavigate()
|
||||
@@ -240,6 +242,16 @@ export function Header() {
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 更新日志 */}
|
||||
<button
|
||||
onClick={() => setIsChangelogOpen(true)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
|
||||
aria-label="查看更新日志"
|
||||
title="更新日志"
|
||||
>
|
||||
<History size={20} />
|
||||
</button>
|
||||
|
||||
{/* 主题切换 */}
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
@@ -250,6 +262,9 @@ export function Header() {
|
||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 更新日志模态框 */}
|
||||
<ChangelogModal isOpen={isChangelogOpen} onClose={() => setIsChangelogOpen(false)} />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,25 +14,23 @@ export function Layout({ children }: LayoutProps) {
|
||||
|
||||
return (
|
||||
<div className={clsx('min-h-screen', darkMode && 'dark')}>
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* 侧边栏 */}
|
||||
<Sidebar />
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* 顶部导航 */}
|
||||
<Header />
|
||||
|
||||
{/* 页面内容 */}
|
||||
<main
|
||||
className={clsx(
|
||||
'flex-1 overflow-y-auto p-6 transition-all duration-300',
|
||||
'flex-1 p-6 transition-all duration-300',
|
||||
sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { clsx } from 'clsx'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import ittoIcon from '@/data/icon/ittoico.png'
|
||||
import {
|
||||
Home,
|
||||
BookOpen,
|
||||
Layers,
|
||||
Scissors,
|
||||
LayoutGrid,
|
||||
TableProperties,
|
||||
Share2,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
GraduationCap,
|
||||
BookMarked,
|
||||
Gauge,
|
||||
Images,
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页', icon: Home },
|
||||
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
||||
{ path: '/process-practice', label: '过程背诵练习', icon: GraduationCap },
|
||||
{ path: '/itto-collections', label: '输入输出表', icon: TableProperties },
|
||||
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
||||
{ path: '/process-groups', label: '过程组', icon: Layers },
|
||||
{ path: '/knowledge-areas-tailoring', label: '裁剪因素', icon: Scissors },
|
||||
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
||||
{ path: '/principles', label: '十二项原则', icon: BookMarked },
|
||||
{ path: '/performance-domains', label: '八大绩效域', icon: Gauge },
|
||||
{ path: '/learning-maps', label: '一图流', icon: Images },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
]
|
||||
|
||||
@@ -48,8 +61,8 @@ export function Sidebar() {
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-bold text-lg">
|
||||
IT
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg bg-white shadow-sm dark:bg-gray-900">
|
||||
<img src={ittoIcon} alt="" className="h-full w-full object-cover" aria-hidden="true" />
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
@@ -71,7 +84,7 @@ export function Sidebar() {
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path))
|
||||
(item.path !== '/' && location.pathname.startsWith(`${item.path}/`))
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
@@ -98,7 +111,6 @@ export function Sidebar() {
|
||||
{sidebarOpen && (
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<div className="rounded-lg bg-gray-50 dark:bg-gray-700/50 p-4">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">PMBOK 第6版</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
49个过程 · 10大知识领域
|
||||
</div>
|
||||
|
||||
65
src/components/practice/CelebrationAnimation.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface CelebrationAnimationProps {
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function CelebrationAnimation({ onComplete }: CelebrationAnimationProps) {
|
||||
const [confetti] = useState(() =>
|
||||
Array.from({ length: 30 }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 0.5,
|
||||
duration: 1.5 + Math.random() * 0.5,
|
||||
color: ['#f59e0b', '#3b82f6', '#ef4444', '#10b981', '#8b5cf6'][Math.floor(Math.random() * 5)],
|
||||
}))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onComplete?.()
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onComplete])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
|
||||
{/* 恭喜文字 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 via-pink-500 to-purple-600 animate-bounce">
|
||||
🎉 恭喜完成!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 彩带 */}
|
||||
{confetti.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="absolute top-0 w-2 h-8 rounded-full animate-fall"
|
||||
style={{
|
||||
left: `${item.left}%`,
|
||||
backgroundColor: item.color,
|
||||
animationDelay: `${item.delay}s`,
|
||||
animationDuration: `${item.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<style>{`
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-100px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(360deg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
.animate-fall {
|
||||
animation: fall linear forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/components/practice/HintInfo.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CellInfo } from '@/utils/practice'
|
||||
import { knowledgeAreaMap, processMap } from '@/data'
|
||||
|
||||
interface HintInfoProps {
|
||||
currentCell: CellInfo | undefined
|
||||
}
|
||||
|
||||
export function HintInfo({ currentCell }: HintInfoProps) {
|
||||
if (!currentCell) return null
|
||||
|
||||
if (currentCell.type === 'knowledge-area') {
|
||||
const ka = knowledgeAreaMap.get(currentCell.knowledgeAreaId)
|
||||
if (!ka?.tailoringFactors || ka.tailoringFactors.length === 0) {
|
||||
return (
|
||||
<div className="text-base text-gray-500 dark:text-gray-400">
|
||||
暂无裁剪因素信息
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
敏捷裁剪因素
|
||||
</h3>
|
||||
<p className="text-base text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{ka.tailoringFactors.map((factor) => factor.title).join(';')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const process = processMap.get(currentCell.processId!)
|
||||
const purpose = process?.purpose
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
主要作用
|
||||
</h3>
|
||||
{purpose ? (
|
||||
<p className="text-base text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{purpose}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-base text-gray-500 dark:text-gray-400">暂无主要作用说明</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
185
src/components/practice/InputArea.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
type CharStatus = 'pending' | 'correct' | 'error'
|
||||
|
||||
interface InputAreaProps {
|
||||
userInput: string[]
|
||||
charStatuses: CharStatus[]
|
||||
isComposing: boolean
|
||||
inputLocked: boolean
|
||||
lastErrorTimestamp: number | null
|
||||
onInputChange: (newInput: string[]) => void
|
||||
onCompositionStart: (index: number) => void
|
||||
onCompositionEnd: (index: number, value: string) => void
|
||||
onPaste: (e: React.ClipboardEvent) => void
|
||||
showLockedHint?: boolean
|
||||
}
|
||||
|
||||
export function InputArea({
|
||||
userInput,
|
||||
charStatuses,
|
||||
isComposing,
|
||||
inputLocked,
|
||||
lastErrorTimestamp,
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
onPaste,
|
||||
showLockedHint = true,
|
||||
}: InputAreaProps) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
// 自动聚焦到第一个空输入框
|
||||
useEffect(() => {
|
||||
if (isComposing || inputLocked) return
|
||||
const firstEmptyIndex = userInput.findIndex((char) => !char)
|
||||
if (firstEmptyIndex !== -1 && inputRefs.current[firstEmptyIndex]) {
|
||||
inputRefs.current[firstEmptyIndex]?.focus()
|
||||
}
|
||||
}, [userInput, isComposing, inputLocked])
|
||||
|
||||
const handleCharInput = (
|
||||
index: number,
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (inputLocked) return
|
||||
|
||||
const value = e.target.value
|
||||
const nativeIsComposing =
|
||||
typeof (e.nativeEvent as InputEvent).isComposing === 'boolean'
|
||||
? (e.nativeEvent as InputEvent).isComposing
|
||||
: false
|
||||
const composing = isComposing || nativeIsComposing
|
||||
|
||||
const newInput = [...userInput]
|
||||
|
||||
// 输入法组合期间,只更新当前输入框的值,不做任何跳转
|
||||
if (composing) {
|
||||
newInput[index] = value
|
||||
onInputChange(newInput)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理多字符输入(连续输入或粘贴)
|
||||
if (value.length > 1) {
|
||||
const chars = value.split('')
|
||||
for (let i = 0; i < chars.length && index + i < userInput.length; i++) {
|
||||
newInput[index + i] = chars[i]
|
||||
}
|
||||
onInputChange(newInput)
|
||||
|
||||
// 聚焦到最后填充的位置的下一个
|
||||
const nextIndex = Math.min(index + chars.length, userInput.length - 1)
|
||||
inputRefs.current[nextIndex]?.focus()
|
||||
} else {
|
||||
// 单字符输入
|
||||
newInput[index] = value
|
||||
onInputChange(newInput)
|
||||
|
||||
// 自动跳转到下一个输入框
|
||||
if (value && index < userInput.length - 1) {
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (inputLocked) return
|
||||
|
||||
// 退格键:清空当前输入并跳转到上一个
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
const newInput = [...userInput]
|
||||
if (newInput[index]) {
|
||||
newInput[index] = ''
|
||||
onInputChange(newInput)
|
||||
} else if (index > 0) {
|
||||
newInput[index - 1] = ''
|
||||
onInputChange(newInput)
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
}
|
||||
}
|
||||
// 左箭头:跳转到上一个
|
||||
else if (e.key === 'ArrowLeft' && index > 0) {
|
||||
e.preventDefault()
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
}
|
||||
// 右箭头:跳转到下一个
|
||||
else if (e.key === 'ArrowRight' && index < userInput.length - 1) {
|
||||
e.preventDefault()
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 practice-input-area">
|
||||
<div className="flex gap-3">
|
||||
{userInput.map((char, index) => {
|
||||
const status = charStatuses[index] || 'pending'
|
||||
const isError = status === 'error'
|
||||
const isCorrect = status === 'correct'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative"
|
||||
animate={
|
||||
isError && lastErrorTimestamp
|
||||
? {
|
||||
x: [0, -10, 10, -10, 10, 0],
|
||||
transition: { duration: 0.4 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<input
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
type="text"
|
||||
value={char}
|
||||
onChange={(e) => handleCharInput(index, e)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onCompositionStart={() => onCompositionStart(index)}
|
||||
onCompositionEnd={(e) =>
|
||||
onCompositionEnd(index, e.currentTarget.value)
|
||||
}
|
||||
onPaste={onPaste}
|
||||
disabled={inputLocked}
|
||||
className={clsx(
|
||||
'w-10 h-12 text-center text-2xl font-medium',
|
||||
'bg-transparent border-b-2 transition-all duration-200',
|
||||
'focus:outline-none',
|
||||
'text-gray-900 dark:text-gray-100',
|
||||
isComposing && 'border-gray-300 dark:border-gray-600 opacity-70',
|
||||
!isComposing && !char && 'border-gray-400 dark:border-gray-500',
|
||||
!isComposing && isCorrect && 'border-green-500',
|
||||
!isComposing && isError && 'border-red-500',
|
||||
inputLocked && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 输入锁定提示 */}
|
||||
<AnimatePresence>
|
||||
{showLockedHint && inputLocked && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
答案显示中,输入已锁定
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
src/components/practice/PracticeMatrix.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { knowledgeAreas, processGroups } from '@/data'
|
||||
import { getProcessesByKaAndPg } from '@/utils/practice'
|
||||
import { ProcessCell } from './ProcessCell'
|
||||
import { useLongPress } from '@/hooks/useLongPress'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface PracticeMatrixProps {
|
||||
answeredCells: Map<string, boolean>
|
||||
currentCellId: string | null
|
||||
showAnswerForCell: { cellId: string; answer: string; expiresAt: number } | null
|
||||
onLongPress: (cellId: string) => void
|
||||
onLongPressEnd: () => void
|
||||
onCellClick: (cellId: string) => void
|
||||
getCellTabIndex: (cellId: string) => number
|
||||
}
|
||||
|
||||
interface KnowledgeAreaCellProps {
|
||||
ka: any
|
||||
isAnswered: boolean
|
||||
isFocused: boolean
|
||||
showAnswer?: string | null
|
||||
onLongPress: (cellId: string) => void
|
||||
onLongPressEnd: () => void
|
||||
onClick: (cellId: string) => void
|
||||
tabIndex: number
|
||||
}
|
||||
|
||||
function KnowledgeAreaCell({
|
||||
ka,
|
||||
isAnswered,
|
||||
isFocused,
|
||||
showAnswer,
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
onClick,
|
||||
tabIndex,
|
||||
}: KnowledgeAreaCellProps) {
|
||||
const cellId = `ka-${ka.id}`
|
||||
const longPressHandlers = useLongPress(cellId, {
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
})
|
||||
|
||||
return (
|
||||
<td
|
||||
data-cell-id={cellId}
|
||||
className={clsx(
|
||||
'sticky left-0 z-10 p-2 border border-gray-200 dark:border-gray-700 font-medium cursor-pointer transition-all duration-200',
|
||||
isFocused && 'ring-2 ring-blue-500 ring-inset',
|
||||
!isAnswered && 'border-dashed'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isAnswered ? `${ka.color}15` : 'transparent',
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: isFocused ? '#3b82f6' : ka.color,
|
||||
}}
|
||||
onClick={() => onClick(cellId)}
|
||||
tabIndex={tabIndex}
|
||||
role="button"
|
||||
aria-label={`知识领域:${ka.name}`}
|
||||
{...longPressHandlers}
|
||||
>
|
||||
{isAnswered ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-xs" style={{ color: ka.color }}>
|
||||
{ka.order}
|
||||
</span>
|
||||
<span className="text-xs text-gray-900 dark:text-white">
|
||||
{ka.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-[24px]" />
|
||||
)}
|
||||
|
||||
{/* 长按显示答案 */}
|
||||
{showAnswer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 rounded z-20">
|
||||
<span className="text-white text-sm font-medium px-2 text-center">
|
||||
{showAnswer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export function PracticeMatrix({
|
||||
answeredCells,
|
||||
currentCellId,
|
||||
showAnswerForCell,
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
onCellClick,
|
||||
getCellTabIndex,
|
||||
}: PracticeMatrixProps) {
|
||||
const sortedKAs = [...knowledgeAreas].sort((a, b) => a.order - b.order)
|
||||
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[900px]">
|
||||
{/* 表头:过程组 */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-gray-100 dark:bg-gray-800 p-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 min-w-[120px]">
|
||||
知识领域 / 过程组
|
||||
</th>
|
||||
{sortedPGs.map((pg) => (
|
||||
<th
|
||||
key={pg.id}
|
||||
className="p-2 text-center text-xs font-semibold text-white border border-gray-200 dark:border-gray-700 min-w-[120px]"
|
||||
style={{ backgroundColor: pg.color }}
|
||||
>
|
||||
{pg.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* 表体:知识领域 × 过程 */}
|
||||
<tbody>
|
||||
{sortedKAs.map((ka) => {
|
||||
const kaCellId = `ka-${ka.id}`
|
||||
return (
|
||||
<tr key={ka.id}>
|
||||
{/* 知识领域名称 */}
|
||||
<KnowledgeAreaCell
|
||||
ka={ka}
|
||||
isAnswered={answeredCells.get(kaCellId) || false}
|
||||
isFocused={currentCellId === kaCellId}
|
||||
showAnswer={
|
||||
showAnswerForCell?.cellId === kaCellId
|
||||
? showAnswerForCell.answer
|
||||
: null
|
||||
}
|
||||
onLongPress={onLongPress}
|
||||
onLongPressEnd={onLongPressEnd}
|
||||
onClick={onCellClick}
|
||||
tabIndex={getCellTabIndex(kaCellId)}
|
||||
/>
|
||||
|
||||
{/* 每个过程组的单元格 */}
|
||||
{sortedPGs.map((pg) => {
|
||||
const processes = getProcessesByKaAndPg(ka.id, pg.id)
|
||||
|
||||
return (
|
||||
<td
|
||||
key={pg.id}
|
||||
className="p-2 border border-gray-200 dark:border-gray-700 align-top bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{processes.map((p) => {
|
||||
const processCellId = `process-${p.id}`
|
||||
|
||||
return (
|
||||
<ProcessCell
|
||||
key={p.id}
|
||||
process={p}
|
||||
isAnswered={answeredCells.get(processCellId) || false}
|
||||
isFocused={currentCellId === processCellId}
|
||||
showAnswer={
|
||||
showAnswerForCell?.cellId === processCellId
|
||||
? showAnswerForCell.answer
|
||||
: null
|
||||
}
|
||||
onLongPress={onLongPress}
|
||||
onLongPressEnd={onLongPressEnd}
|
||||
onClick={onCellClick}
|
||||
tabIndex={getCellTabIndex(processCellId)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
src/components/practice/ProcessCell.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
import type { Process } from '@/types/itto'
|
||||
import { useLongPress } from '@/hooks/useLongPress'
|
||||
import { knowledgeAreaMap } from '@/data'
|
||||
|
||||
interface ProcessCellProps {
|
||||
process: Process
|
||||
isAnswered: boolean
|
||||
isFocused: boolean
|
||||
showAnswer?: string | null
|
||||
onLongPress: (cellId: string) => void
|
||||
onLongPressEnd: () => void
|
||||
onClick: (cellId: string) => void
|
||||
tabIndex: number
|
||||
}
|
||||
|
||||
export function ProcessCell({
|
||||
process,
|
||||
isAnswered,
|
||||
isFocused,
|
||||
showAnswer,
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
onClick,
|
||||
tabIndex,
|
||||
}: ProcessCellProps) {
|
||||
const cellId = `process-${process.id}`
|
||||
const ka = knowledgeAreaMap.get(process.knowledgeAreaId)
|
||||
|
||||
const longPressHandlers = useLongPress(cellId, {
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-cell-id={cellId}
|
||||
className={clsx(
|
||||
'relative rounded-lg p-2 transition-all duration-200 cursor-pointer',
|
||||
'border-2 focus:outline-none',
|
||||
isAnswered
|
||||
? 'bg-white dark:bg-gray-800 border-solid border-gray-300 dark:border-gray-600'
|
||||
: 'border-dashed border-gray-300 dark:border-gray-600',
|
||||
isFocused && 'ring-2 ring-blue-500 border-blue-500',
|
||||
!isAnswered && 'min-h-[40px]'
|
||||
)}
|
||||
onClick={() => onClick(cellId)}
|
||||
tabIndex={tabIndex}
|
||||
role="button"
|
||||
aria-label={`过程:${process.name}`}
|
||||
aria-describedby="hint-info"
|
||||
aria-current={isFocused ? 'true' : undefined}
|
||||
{...longPressHandlers}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{isAnswered && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block px-1 py-0.5 rounded text-white font-medium shrink-0"
|
||||
style={{ backgroundColor: ka?.color, fontSize: '9px' }}
|
||||
>
|
||||
{process.code}
|
||||
</span>
|
||||
<span className="text-gray-900 dark:text-gray-100 text-xs">
|
||||
{process.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 长按显示答案 */}
|
||||
{showAnswer && (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/80 rounded-lg z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<span className="text-white text-sm font-medium px-2 text-center">
|
||||
{showAnswer}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
73
src/components/practice/ProficientInputArea.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
interface ProficientInputAreaProps {
|
||||
value: string
|
||||
isComposing: boolean
|
||||
inputLocked: boolean
|
||||
hasError: boolean
|
||||
onChange: (value: string) => void
|
||||
onCompositionStart: () => void
|
||||
onCompositionEnd: (value: string) => void
|
||||
showLockedHint?: boolean
|
||||
}
|
||||
|
||||
export function ProficientInputArea({
|
||||
value,
|
||||
isComposing,
|
||||
inputLocked,
|
||||
hasError,
|
||||
onChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
showLockedHint = true,
|
||||
}: ProficientInputAreaProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputLocked) return
|
||||
inputRef.current?.focus()
|
||||
}, [inputLocked, value])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-3 practice-input-area">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={(e) => onCompositionEnd(e.currentTarget.value)}
|
||||
disabled={inputLocked}
|
||||
placeholder="输入当前分组中的一个完整条目"
|
||||
className={clsx(
|
||||
'w-full rounded-xl border px-4 py-3 text-base md:text-lg',
|
||||
'bg-white dark:bg-gray-800/80 text-gray-900 dark:text-gray-100',
|
||||
'transition-all duration-200 focus:outline-none focus:ring-2',
|
||||
isComposing && 'border-gray-300 dark:border-gray-600 opacity-80',
|
||||
!isComposing && !hasError && 'border-gray-300 dark:border-gray-600 focus:ring-indigo-400 focus:border-indigo-400',
|
||||
!isComposing && hasError && 'border-red-400 focus:ring-red-400 focus:border-red-400',
|
||||
inputLocked && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{showLockedHint && inputLocked && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
className="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
答案显示中,输入已锁定
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import {
|
||||
knowledgeAreas,
|
||||
processGroups,
|
||||
@@ -14,9 +15,20 @@ import {
|
||||
interface ProcessMatrixProps {
|
||||
className?: string
|
||||
isFullScreen?: boolean
|
||||
hiddenKnowledgeAreaIds?: Set<string>
|
||||
hiddenProcessGroupIds?: Set<string>
|
||||
onToggleKnowledgeArea?: (id: string) => void
|
||||
onToggleProcessGroup?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ProcessMatrix({ className, isFullScreen = false }: ProcessMatrixProps) {
|
||||
export function ProcessMatrix({
|
||||
className,
|
||||
isFullScreen = false,
|
||||
hiddenKnowledgeAreaIds = new Set(),
|
||||
hiddenProcessGroupIds = new Set(),
|
||||
onToggleKnowledgeArea,
|
||||
onToggleProcessGroup,
|
||||
}: ProcessMatrixProps) {
|
||||
// 构建矩阵数据:knowledgeAreaId -> processGroupId -> Process[]
|
||||
const matrix = new Map<string, Map<string, typeof processes>>()
|
||||
|
||||
@@ -47,24 +59,45 @@ export function ProcessMatrix({ className, isFullScreen = false }: ProcessMatrix
|
||||
<th className="sticky left-0 z-10 bg-gray-100 dark:bg-gray-800 p-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 min-w-[160px]">
|
||||
知识领域 / 过程组
|
||||
</th>
|
||||
{processGroups.map(pg => (
|
||||
{processGroups.map(pg => {
|
||||
const isHidden = hiddenProcessGroupIds.has(pg.id)
|
||||
return (
|
||||
<th
|
||||
key={pg.id}
|
||||
className="p-3 text-center text-sm font-semibold text-white border border-gray-200 dark:border-gray-700 min-w-[140px]"
|
||||
style={{ backgroundColor: pg.color }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={clsx("transition-opacity duration-150", isHidden && "opacity-30")}>
|
||||
<div>{pg.name}</div>
|
||||
<div className="text-xs opacity-80 font-normal mt-1">
|
||||
{pg.processCount} 个过程
|
||||
</div>
|
||||
</div>
|
||||
{onToggleProcessGroup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleProcessGroup(pg.id)}
|
||||
aria-label={isHidden ? `显示${pg.name}` : `隐藏${pg.name}`}
|
||||
aria-pressed={!isHidden}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-white/20 hover:bg-white/30 transition-colors"
|
||||
>
|
||||
{isHidden ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
<span>{isHidden ? '显示' : '隐藏'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* 表体:知识领域 × 过程 */}
|
||||
<tbody>
|
||||
{knowledgeAreas.map((ka, kaIndex) => (
|
||||
{knowledgeAreas.map((ka, kaIndex) => {
|
||||
const isKaHidden = hiddenKnowledgeAreaIds.has(ka.id)
|
||||
return (
|
||||
<motion.tr
|
||||
key={ka.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -80,29 +113,51 @@ export function ProcessMatrix({ className, isFullScreen = false }: ProcessMatrix
|
||||
borderLeftColor: ka.color,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link
|
||||
to={`/knowledge-areas/${ka.id}`}
|
||||
className="hover:underline text-gray-900 dark:text-white"
|
||||
className={clsx(
|
||||
"hover:underline text-gray-900 dark:text-white transition-opacity duration-150",
|
||||
isKaHidden && "opacity-30"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold mr-2" style={{ color: ka.color }}>
|
||||
{ka.order}
|
||||
</span>
|
||||
{ka.name}
|
||||
</Link>
|
||||
{onToggleKnowledgeArea && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleKnowledgeArea(ka.id)}
|
||||
aria-label={isKaHidden ? `显示${ka.name}` : `隐藏${ka.name}`}
|
||||
aria-pressed={!isKaHidden}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors shrink-0"
|
||||
style={{ color: ka.color }}
|
||||
>
|
||||
{isKaHidden ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* 每个过程组的单元格 */}
|
||||
{processGroups.map(pg => {
|
||||
const cellProcesses = matrix.get(ka.id)?.get(pg.id) || []
|
||||
const isCellHidden = isKaHidden || hiddenProcessGroupIds.has(pg.id)
|
||||
return (
|
||||
<td
|
||||
key={pg.id}
|
||||
className="p-2 border border-gray-200 dark:border-gray-700 align-top bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className={clsx(
|
||||
"gap-1",
|
||||
isFullScreen ? "grid grid-cols-2" : "space-y-1"
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
"gap-1 transition-opacity duration-150",
|
||||
isFullScreen ? "grid grid-cols-2" : "space-y-1",
|
||||
isCellHidden && "opacity-0 pointer-events-none"
|
||||
)}
|
||||
style={isCellHidden ? { visibility: 'hidden' } : undefined}
|
||||
>
|
||||
{cellProcesses.map(p => (
|
||||
<Link
|
||||
key={p.id}
|
||||
@@ -110,18 +165,26 @@ export function ProcessMatrix({ className, isFullScreen = false }: ProcessMatrix
|
||||
state={{ from: 'matrix' }}
|
||||
className={clsx(
|
||||
"block px-2 py-1.5 rounded text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors",
|
||||
isFullScreen && "flex items-center h-full"
|
||||
isFullScreen && "h-full"
|
||||
)}
|
||||
title={p.name}
|
||||
>
|
||||
<div className={clsx("flex items-center gap-1 min-w-0", isFullScreen && "h-full")}>
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-white font-medium mr-1 shrink-0"
|
||||
className="inline-block px-1.5 py-0.5 rounded text-white font-medium shrink-0"
|
||||
style={{ backgroundColor: ka.color, fontSize: '10px' }}
|
||||
>
|
||||
{p.code}
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||
<span
|
||||
className={clsx(
|
||||
"block min-w-0 text-gray-700 dark:text-gray-300",
|
||||
isFullScreen ? "line-clamp-2" : "truncate"
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{cellProcesses.length === 0 && (
|
||||
@@ -134,7 +197,7 @@ export function ProcessMatrix({ className, isFullScreen = false }: ProcessMatrix
|
||||
)
|
||||
})}
|
||||
</motion.tr>
|
||||
))}
|
||||
)})}
|
||||
</tbody>
|
||||
|
||||
{/* 表尾:统计 */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"artifacts": [
|
||||
{ "id": "A001", "name": "项目章程", "nameEn": "Project Charter", "category": "document" },
|
||||
{ "id": "A002", "name": "商业论证", "nameEn": "Business Case", "category": "document" },
|
||||
{ "id": "A002", "name": "立项管理文件", "nameEn": "Business Case", "category": "document" },
|
||||
{ "id": "A003", "name": "效益管理计划", "nameEn": "Benefits Management Plan", "category": "plan" },
|
||||
{ "id": "A004", "name": "协议", "nameEn": "Agreements", "category": "document" },
|
||||
{ "id": "A005", "name": "事业环境因素", "nameEn": "Enterprise Environmental Factors", "category": "other" },
|
||||
@@ -17,7 +17,7 @@
|
||||
{ "id": "A015", "name": "沟通管理计划", "nameEn": "Communications Management Plan", "category": "plan" },
|
||||
{ "id": "A016", "name": "风险管理计划", "nameEn": "Risk Management Plan", "category": "plan" },
|
||||
{ "id": "A017", "name": "采购管理计划", "nameEn": "Procurement Management Plan", "category": "plan" },
|
||||
{ "id": "A018", "name": "相关方参与计划", "nameEn": "Stakeholder Engagement Plan", "category": "plan" },
|
||||
{ "id": "A018", "name": "干系人参与计划", "nameEn": "Stakeholder Engagement Plan", "category": "plan" },
|
||||
{ "id": "A019", "name": "变更管理计划", "nameEn": "Change Management Plan", "category": "plan" },
|
||||
{ "id": "A020", "name": "配置管理计划", "nameEn": "Configuration Management Plan", "category": "plan" },
|
||||
{ "id": "A021", "name": "范围基准", "nameEn": "Scope Baseline", "category": "baseline" },
|
||||
@@ -56,14 +56,14 @@
|
||||
{ "id": "A054", "name": "项目沟通记录", "nameEn": "Project Communications", "category": "document" },
|
||||
{ "id": "A055", "name": "风险登记册", "nameEn": "Risk Register", "category": "register" },
|
||||
{ "id": "A056", "name": "风险报告", "nameEn": "Risk Report", "category": "report" },
|
||||
{ "id": "A057", "name": "采购文件", "nameEn": "Procurement Documentation", "category": "document" },
|
||||
{ "id": "A057", "name": "采购文档", "nameEn": "Procurement Documentation", "category": "document" },
|
||||
{ "id": "A058", "name": "采购工作说明书", "nameEn": "Procurement Statement of Work", "category": "document" },
|
||||
{ "id": "A059", "name": "招标文件", "nameEn": "Bid Documents", "category": "document" },
|
||||
{ "id": "A060", "name": "供方选择标准", "nameEn": "Source Selection Criteria", "category": "document" },
|
||||
{ "id": "A061", "name": "自制或外购决策", "nameEn": "Make-or-Buy Decisions", "category": "document" },
|
||||
{ "id": "A062", "name": "独立成本估算", "nameEn": "Independent Cost Estimates", "category": "document" },
|
||||
{ "id": "A063", "name": "选定的卖方", "nameEn": "Selected Sellers", "category": "document" },
|
||||
{ "id": "A064", "name": "相关方登记册", "nameEn": "Stakeholder Register", "category": "register" },
|
||||
{ "id": "A064", "name": "干系人登记册", "nameEn": "Stakeholder Register", "category": "register" },
|
||||
{ "id": "A065", "name": "问题日志", "nameEn": "Issue Log", "category": "log" },
|
||||
{ "id": "A066", "name": "经验教训登记册", "nameEn": "Lessons Learned Register", "category": "register" },
|
||||
{ "id": "A067", "name": "工作绩效数据", "nameEn": "Work Performance Data", "category": "document" },
|
||||
@@ -72,8 +72,8 @@
|
||||
{ "id": "A070", "name": "可交付成果", "nameEn": "Deliverables", "category": "deliverable" },
|
||||
{ "id": "A071", "name": "核实的可交付成果", "nameEn": "Verified Deliverables", "category": "deliverable" },
|
||||
{ "id": "A072", "name": "验收的可交付成果", "nameEn": "Accepted Deliverables", "category": "deliverable" },
|
||||
{ "id": "A073", "name": "最终产品、服务或成果移交", "nameEn": "Final Product, Service, or Result Transition", "category": "deliverable" },
|
||||
{ "id": "A074", "name": "最终报告", "nameEn": "Final Report", "category": "report" },
|
||||
{ "id": "A073", "name": "最终产品、服务或成果", "nameEn": "Final Product, Service, or Result Transition", "category": "deliverable" },
|
||||
{ "id": "A074", "name": "项目最终报告", "nameEn": "Final Report", "category": "report" },
|
||||
{ "id": "A075", "name": "组织过程资产更新", "nameEn": "Organizational Process Assets Updates", "category": "other" },
|
||||
{ "id": "A076", "name": "项目文件", "nameEn": "Project Documents", "category": "document" },
|
||||
{ "id": "A077", "name": "项目文件更新", "nameEn": "Project Documents Updates", "category": "document" },
|
||||
@@ -87,9 +87,12 @@
|
||||
{ "id": "A085", "name": "预测", "nameEn": "Forecasts", "category": "document" },
|
||||
{ "id": "A086", "name": "成本预测", "nameEn": "Cost Forecasts", "category": "document" },
|
||||
{ "id": "A087", "name": "进度预测", "nameEn": "Schedule Forecasts", "category": "document" },
|
||||
{ "id": "A088", "name": "关闭的采购", "nameEn": "Closed Procurements", "category": "document" },
|
||||
{ "id": "A088", "name": "采购关闭", "nameEn": "Closed Procurements", "category": "document" },
|
||||
{ "id": "A089", "name": "卖方绩效评价文档", "nameEn": "Seller Performance Evaluation Documentation", "category": "document" },
|
||||
{ "id": "A090", "name": "采购策略", "nameEn": "Procurement Strategy", "category": "document" },
|
||||
{ "id": "A091", "name": "采购文档更新", "nameEn": "Procurement Documentation Updates", "category": "document" }
|
||||
{ "id": "A091", "name": "采购文档更新", "nameEn": "Procurement Documentation Updates", "category": "document" },
|
||||
{ "id": "A092", "name": "其他知识领域规划过程的输出", "nameEn": "Other Documentation", "category": "document" },
|
||||
{ "id": "A093", "name": "可行性研究文件", "nameEn": "Feasibility Study", "category": "document" },
|
||||
{ "id": "A094", "name": "事业环境因素更新", "nameEn": "Enterprise Environmental Factors Updates", "category": "other" }
|
||||
]
|
||||
}
|
||||
|
||||
1005
src/data/changelog.json
Normal file
BIN
src/data/icon/ittoico.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/data/icon/ittologo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
0
src/data/image/.gitkeep
Normal file
BIN
src/data/image/01-挣值一图流1.jpg
Normal file
|
After Width: | Height: | Size: 503 KiB |
BIN
src/data/image/01-挣值一图流2.jpg
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
src/data/image/02-合同分类.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
src/data/image/03-案例万金油.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
src/data/image/04-进度赶工.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
src/data/image/05-范围管理找问题.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
src/data/image/06-整合与范围管理一图流.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/data/image/07-进度与成本管理一图流.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/08-质量与资源管理一图流.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/data/image/09-沟通与风险管理一图流.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/10-采购与干系人管理一图流.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/data/image/11-三种成本加成合同一图流.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
src/data/image/12-项目管理三种方法对比一图流.png
Normal file
|
After Width: | Height: | Size: 924 KiB |
BIN
src/data/image/13-八大绩效域目标速记一图流.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/14-十大知识领域裁剪因素一图流.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/16-常见分解结构一图流.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
src/data/image/18-可行性研究内容全景一图流.png
Normal file
|
After Width: | Height: | Size: 989 KiB |
BIN
src/data/image/19-项目管理计划与项目文件清单.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
src/data/image/20-进度管理找问题.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
src/data/image/21-沟通与干系人管理找问题.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/data/image/22-变更管理找问题.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
src/data/image/23-配置管理找问题.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
src/data/image/24-开发方法和生命周期绩效域.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
src/data/image/25-项目可行性研究关键文档一图流.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/26-八大绩效域红罪笔记整理一图流.jpg
Normal file
|
After Width: | Height: | Size: 633 KiB |
BIN
src/data/image/26计算一页纸.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -8,6 +8,8 @@ import processGroupsData from './process-groups.json';
|
||||
import processesData from './processes.json';
|
||||
import artifactsData from './artifacts.json';
|
||||
import toolsData from './tools.json';
|
||||
import changelogData from './changelog.json';
|
||||
import timelineItemsData from './timeline-items.json';
|
||||
|
||||
import type {
|
||||
KnowledgeArea,
|
||||
@@ -15,8 +17,12 @@ import type {
|
||||
Process,
|
||||
Artifact,
|
||||
ToolTechnique,
|
||||
ChangelogEntry,
|
||||
DataFlow,
|
||||
ProcessRef,
|
||||
ProcessEntityUse,
|
||||
} from '../types/itto';
|
||||
import type { TimelineItem } from '../types/timeline';
|
||||
|
||||
// 导出原始数据
|
||||
export const knowledgeAreas: KnowledgeArea[] = knowledgeAreasData.knowledgeAreas as KnowledgeArea[];
|
||||
@@ -24,6 +30,13 @@ export const processGroups: ProcessGroup[] = processGroupsData.processGroups as
|
||||
export const processes: Process[] = processesData.processes as Process[];
|
||||
export const artifacts: Artifact[] = artifactsData.artifacts as Artifact[];
|
||||
export const tools: ToolTechnique[] = toolsData.tools as ToolTechnique[];
|
||||
export const changelogEntries: ChangelogEntry[] = [
|
||||
...(changelogData.changelogEntries as ChangelogEntry[]),
|
||||
].sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
export const timelineItems: TimelineItem[] = [
|
||||
...(timelineItemsData.timelineItems as TimelineItem[]),
|
||||
].sort((a, b) => a.sortKey.localeCompare(b.sortKey));
|
||||
|
||||
// 创建查找映射表
|
||||
export const knowledgeAreaMap = new Map<string, KnowledgeArea>(
|
||||
@@ -46,6 +59,18 @@ export const toolMap = new Map<string, ToolTechnique>(
|
||||
tools.map(t => [t.id, t])
|
||||
);
|
||||
|
||||
export const timelineItemMap = new Map<string, TimelineItem>(
|
||||
timelineItems.map(item => [item.id, item])
|
||||
);
|
||||
|
||||
export const timelineItemsByTheme = new Map<string, TimelineItem[]>();
|
||||
[...new Set(timelineItems.map(item => item.theme))].forEach(theme => {
|
||||
timelineItemsByTheme.set(
|
||||
theme,
|
||||
timelineItems.filter(item => item.theme === theme)
|
||||
);
|
||||
});
|
||||
|
||||
// 按知识领域分组的过程
|
||||
export const processesByKnowledgeArea = new Map<string, Process[]>();
|
||||
knowledgeAreas.forEach(ka => {
|
||||
@@ -64,14 +89,37 @@ processGroups.forEach(pg => {
|
||||
);
|
||||
});
|
||||
|
||||
// 工具函数:规范化 ProcessRef(将字符串或对象统一处理)
|
||||
export function normalizeProcessRef(ref: ProcessRef): {
|
||||
id: string;
|
||||
detail?: ProcessEntityUse['detail'];
|
||||
note?: string;
|
||||
} {
|
||||
if (typeof ref === 'string') {
|
||||
return { id: ref };
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
// 工具函数:从 ProcessRef 中提取 ID
|
||||
export function extractId(ref: ProcessRef): string {
|
||||
return typeof ref === 'string' ? ref : ref.id;
|
||||
}
|
||||
|
||||
// 工具函数:检查 ProcessRef 数组中是否包含某个 ID
|
||||
export function includesId(refs: ProcessRef[], targetId: string): boolean {
|
||||
return refs.some(ref => extractId(ref) === targetId);
|
||||
}
|
||||
|
||||
// 计算数据流向关系
|
||||
export function computeDataFlows(): DataFlow[] {
|
||||
const flows: DataFlow[] = [];
|
||||
|
||||
processes.forEach(sourceProcess => {
|
||||
sourceProcess.outputs.forEach(outputId => {
|
||||
sourceProcess.outputs.forEach(outputRef => {
|
||||
const outputId = extractId(outputRef);
|
||||
processes.forEach(targetProcess => {
|
||||
if (targetProcess.id !== sourceProcess.id && targetProcess.inputs.includes(outputId)) {
|
||||
if (targetProcess.id !== sourceProcess.id && includesId(targetProcess.inputs, outputId)) {
|
||||
flows.push({
|
||||
sourceProcessId: sourceProcess.id,
|
||||
targetProcessId: targetProcess.id,
|
||||
@@ -91,14 +139,14 @@ export function getArtifactUsage(artifactId: string): {
|
||||
asOutput: Process[];
|
||||
} {
|
||||
return {
|
||||
asInput: processes.filter(p => p.inputs.includes(artifactId)),
|
||||
asOutput: processes.filter(p => p.outputs.includes(artifactId)),
|
||||
asInput: processes.filter(p => includesId(p.inputs, artifactId)),
|
||||
asOutput: processes.filter(p => includesId(p.outputs, artifactId)),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取工具的使用情况
|
||||
export function getToolUsage(toolId: string): Process[] {
|
||||
return processes.filter(p => p.tools.includes(toolId));
|
||||
return processes.filter(p => includesId(p.tools, toolId));
|
||||
}
|
||||
|
||||
// 获取过程的完整信息(包含关联数据)
|
||||
@@ -110,9 +158,21 @@ export function getProcessDetail(processId: string) {
|
||||
...process,
|
||||
knowledgeArea: knowledgeAreaMap.get(process.knowledgeAreaId),
|
||||
processGroup: processGroupMap.get(process.processGroupId),
|
||||
inputDetails: process.inputs.map(id => artifactMap.get(id)).filter(Boolean),
|
||||
toolDetails: process.tools.map(id => toolMap.get(id)).filter(Boolean),
|
||||
outputDetails: process.outputs.map(id => artifactMap.get(id)).filter(Boolean),
|
||||
inputDetails: process.inputs.map(ref => {
|
||||
const normalized = normalizeProcessRef(ref);
|
||||
const artifact = artifactMap.get(normalized.id);
|
||||
return artifact ? { ...artifact, detail: normalized.detail, note: normalized.note } : null;
|
||||
}).filter(Boolean),
|
||||
toolDetails: process.tools.map(ref => {
|
||||
const normalized = normalizeProcessRef(ref);
|
||||
const tool = toolMap.get(normalized.id);
|
||||
return tool ? { ...tool, detail: normalized.detail, note: normalized.note } : null;
|
||||
}).filter(Boolean),
|
||||
outputDetails: process.outputs.map(ref => {
|
||||
const normalized = normalizeProcessRef(ref);
|
||||
const artifact = artifactMap.get(normalized.id);
|
||||
return artifact ? { ...artifact, detail: normalized.detail, note: normalized.note } : null;
|
||||
}).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,41 @@
|
||||
"order": 1,
|
||||
"color": "#6366F1",
|
||||
"description": "包括识别、定义、组合、统一和协调各项目管理过程组的各种过程和活动",
|
||||
"processCount": 7
|
||||
"processCount": 7,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "项目生命周期",
|
||||
"description": "本项目合适的项目生命周期?项目生命周期应包括哪些阶段?"
|
||||
},
|
||||
{
|
||||
"title": "开发生命周期",
|
||||
"description": "对特定产品、服务或成果而言,什么是合适的开发生命周期和开发方法?预测型或适应型方法是否适当?如果使用适应型方法,开发产品是该采用增量还是迭代的方式?混合型方法是否为最佳选择?"
|
||||
},
|
||||
{
|
||||
"title": "管理方法",
|
||||
"description": "考虑到组织文化和项目的复杂性,哪种管理过程最有效?"
|
||||
},
|
||||
{
|
||||
"title": "知识管理",
|
||||
"description": "在项目中如何管理知识以营造合作的工作氛围?"
|
||||
},
|
||||
{
|
||||
"title": "变更",
|
||||
"description": "在项目中如何管理变更?"
|
||||
},
|
||||
{
|
||||
"title": "治理",
|
||||
"description": "有哪些监控机构、委员会和其他干系人该参与项目治理?对项目状态报告的要求是什么?"
|
||||
},
|
||||
{
|
||||
"title": "经验教训",
|
||||
"description": "在项目期间及项目结束时,应收集哪些信息?历史信息和经验教训是否适用于未来的项目?"
|
||||
},
|
||||
{
|
||||
"title": "效益",
|
||||
"description": "应该在何时以何种方式报告效益,是在项目结束时还是在每次迭代或阶段结束时?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA02",
|
||||
@@ -18,7 +52,29 @@
|
||||
"order": 2,
|
||||
"color": "#8B5CF6",
|
||||
"description": "确保项目包含且只包含成功完成项目所需的全部工作",
|
||||
"processCount": 6
|
||||
"processCount": 6,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "知识和需求管理",
|
||||
"description": "项目经理应建立哪些指南?为了在未来项目中重复使用需求,组织是否拥有正式或非正式的知识和需求管理体系?"
|
||||
},
|
||||
{
|
||||
"title": "确认和控制",
|
||||
"description": "组织是否有正式或非正式的与确认和控制相关政策、程序和指南?"
|
||||
},
|
||||
{
|
||||
"title": "开发方法",
|
||||
"description": "组织是否采用敏捷方法管理项目?开发方法属于迭代型还是增量型?是否采用预测型方法?混合型方法是否有效?"
|
||||
},
|
||||
{
|
||||
"title": "需求的稳定性",
|
||||
"description": "项目中是否存在需求不稳定的领域?是否有必要采用精益、敏捷或其他适应型技术来处理不稳定的需求,直至需求稳定且定义明确?"
|
||||
},
|
||||
{
|
||||
"title": "治理",
|
||||
"description": "组织是否拥有正式或非正式的审计和治理政策、程序和指南?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA03",
|
||||
@@ -28,7 +84,25 @@
|
||||
"order": 3,
|
||||
"color": "#EC4899",
|
||||
"description": "管理项目按时完成所需的各个过程",
|
||||
"processCount": 6
|
||||
"processCount": 6,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "生命周期方法",
|
||||
"description": "哪种生命周期方法最适合制订详细的进度计划?"
|
||||
},
|
||||
{
|
||||
"title": "资源可用性",
|
||||
"description": "影响资源可持续时间的因素是什么(如可用资源与其生产效率之间的相关性)?"
|
||||
},
|
||||
{
|
||||
"title": "项目维度",
|
||||
"description": "项目复杂性、技术不确定性、产品新颖度、速度或进度跟踪(如挣值、完成百分比)如何影响预期的控制水平?"
|
||||
},
|
||||
{
|
||||
"title": "技术支持",
|
||||
"description": "是否采用技术来制定、记录、传递、接收和存储项目进度模型的信息以及是否易于获取?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA04",
|
||||
@@ -38,7 +112,29 @@
|
||||
"order": 4,
|
||||
"color": "#10B981",
|
||||
"description": "规划、估算、预算、融资、筹资、管理和控制成本的各过程",
|
||||
"processCount": 4
|
||||
"processCount": 4,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "知识管理",
|
||||
"description": "组织是否拥有易于使用的、正式的知识管理体系和财务数据库并要求项目经理使用?"
|
||||
},
|
||||
{
|
||||
"title": "估算和预算",
|
||||
"description": "组织是否拥有正式或非正式的,与成本估算和预算相关的政策、程序和指南?"
|
||||
},
|
||||
{
|
||||
"title": "挣值管理",
|
||||
"description": "组织是否采用挣值管理来管理项目?"
|
||||
},
|
||||
{
|
||||
"title": "敏捷方法的使用",
|
||||
"description": "组织是否采用敏捷或适应型方法管理项目?这对成本估算有什么影响?"
|
||||
},
|
||||
{
|
||||
"title": "治理",
|
||||
"description": "组织是否拥有正式或非正式的审计和治理政策、程序和指南?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA05",
|
||||
@@ -48,7 +144,25 @@
|
||||
"order": 5,
|
||||
"color": "#F59E0B",
|
||||
"description": "把组织的质量政策应用于规划、管理、控制项目和产品质量要求",
|
||||
"processCount": 3
|
||||
"processCount": 3,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "政策合规与审计",
|
||||
"description": "有哪些质量政策和程序?使用哪些质量工具、技术和模板?"
|
||||
},
|
||||
{
|
||||
"title": "标准与法规合规性",
|
||||
"description": "是否存在必须遵守的行业质量标准?需要考虑哪些政府、法律或法规方面的制约因素?"
|
||||
},
|
||||
{
|
||||
"title": "持续改进",
|
||||
"description": "如何管理项目中的质量改进?在组织层面还是单个项目层面管理?"
|
||||
},
|
||||
{
|
||||
"title": "干系人参与",
|
||||
"description": "项目环境是否有利于与干系人及供应商合作?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA06",
|
||||
@@ -58,7 +172,33 @@
|
||||
"order": 6,
|
||||
"color": "#3B82F6",
|
||||
"description": "识别、获取和管理所需资源以成功完成项目",
|
||||
"processCount": 6
|
||||
"processCount": 6,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "多元化",
|
||||
"description": "团队的多元化背景是什么?"
|
||||
},
|
||||
{
|
||||
"title": "物理位置",
|
||||
"description": "团队成员和实物资源的物理位置在哪里?"
|
||||
},
|
||||
{
|
||||
"title": "行业特定资源",
|
||||
"description": "所在行业需要哪些特殊资源?"
|
||||
},
|
||||
{
|
||||
"title": "团队成员的获得",
|
||||
"description": "如何获得项目团队成员?项目团队成员是全职还是兼职?"
|
||||
},
|
||||
{
|
||||
"title": "团队管理",
|
||||
"description": "如何管理项目团队建设?组织是否有管理团队建设的工具或是否需要创建新工具?是否存在有特殊需求的团队成员?是否需要为团队提供有关多元化管理的特别培训?"
|
||||
},
|
||||
{
|
||||
"title": "生命周期方法",
|
||||
"description": "项目采用哪些生命周期方法?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA07",
|
||||
@@ -68,7 +208,29 @@
|
||||
"order": 7,
|
||||
"color": "#06B6D4",
|
||||
"description": "确保项目信息及时且恰当地规划、收集、生成、发布、存储、检索、管理、控制、监督和最终处置",
|
||||
"processCount": 3
|
||||
"processCount": 3,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "干系人",
|
||||
"description": "干系人是属于组织内部或外部,或者二者都是?"
|
||||
},
|
||||
{
|
||||
"title": "物理地点",
|
||||
"description": "团队成员身处何地?团队是否集中办公?团队是否位于相同地理区域?团队是否分散于多个时区?"
|
||||
},
|
||||
{
|
||||
"title": "沟通技术",
|
||||
"description": "哪项技术可用于创建、记录、传输、检索、追踪和存储沟通成果?哪些技术最适用于与干系人沟通且成本效益最高?"
|
||||
},
|
||||
{
|
||||
"title": "语言",
|
||||
"description": "语言是沟通活动中要考虑的主要因素。沟通时使用的是一种语言还是多种语言?是否已为适应多语种团队的复杂情况安排了资金?"
|
||||
},
|
||||
{
|
||||
"title": "知识管理",
|
||||
"description": "组织是否有正式的知识管理库?是否采用管理库?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA08",
|
||||
@@ -78,7 +240,25 @@
|
||||
"order": 8,
|
||||
"color": "#EF4444",
|
||||
"description": "规划风险管理、识别风险、开展风险分析、规划风险应对、实施风险应对和监督风险的各个过程",
|
||||
"processCount": 7
|
||||
"processCount": 7,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "项目规模",
|
||||
"description": "由预算、进度、范围和人数所体现的项目规模,要求采取更详细的风险管理方法吗?或者项目小到只需用简化的风险管理过程吗?"
|
||||
},
|
||||
{
|
||||
"title": "项目复杂性",
|
||||
"description": "由高水平创新、新技术采用、界面或外部依赖关系导致的项目复杂性提高,是否要求采用更稳健的风险管理方法?或者项目是否简单到只需用简化的风险管理过程?"
|
||||
},
|
||||
{
|
||||
"title": "项目重要性",
|
||||
"description": "项目的战略重要性有多大?项目风险的高级别是因为在创造突破性机会、克服组织经营的重大障碍或涉及重大产品创新吗?"
|
||||
},
|
||||
{
|
||||
"title": "开发方法",
|
||||
"description": "瀑布或预测型开发方式,项目的风险管理过程可以按阶段开展;敏捷或适应型开发方法,项目的风险管理过程可以在每个迭代过程中开展。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA09",
|
||||
@@ -88,17 +268,49 @@
|
||||
"order": 9,
|
||||
"color": "#84CC16",
|
||||
"description": "从项目团队外部采购或获取所需产品、服务或成果",
|
||||
"processCount": 3
|
||||
"processCount": 3,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "采购的复杂性",
|
||||
"description": "只开展一次主要的采购,或者需要在不同时间向不同卖方进行多次采购(会提高采购的复杂性)?"
|
||||
},
|
||||
{
|
||||
"title": "物理地点",
|
||||
"description": "买方和卖方在同一或邻近地点,或者位于不同时区、国家或大洲?"
|
||||
},
|
||||
{
|
||||
"title": "治理和法规环境",
|
||||
"description": "组织的采购政策是否和当地相关的法律法规兼容?当地的法律法规会如何影响合同审计工作?"
|
||||
},
|
||||
{
|
||||
"title": "承包商的可用性",
|
||||
"description": "是否有具备工作执行能力的承包商可供选择?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "KA10",
|
||||
"name": "项目相关方管理",
|
||||
"name": "项目干系人管理",
|
||||
"nameEn": "Project Stakeholder Management",
|
||||
"chapter": 13,
|
||||
"order": 10,
|
||||
"color": "#F97316",
|
||||
"description": "识别影响或受项目影响的人员、团体或组织,分析其期望和影响,制定管理策略",
|
||||
"processCount": 4
|
||||
"processCount": 4,
|
||||
"tailoringFactors": [
|
||||
{
|
||||
"title": "干系人多样性",
|
||||
"description": "现有多少干系人?干系人群体中的文化多样性情况?"
|
||||
},
|
||||
{
|
||||
"title": "干系人关系的复杂性",
|
||||
"description": "干系人群体内的关系有多复杂?干系人或干系人群体加入的网络越多,与其相关的信息或误传网络就越复杂。"
|
||||
},
|
||||
{
|
||||
"title": "沟通技术",
|
||||
"description": "有哪些可用的沟通技术?为了实现该技术的最大价值,目前采用什么支持机制?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
532
src/data/performance-domains.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
export interface PerformanceDomainCheckItem {
|
||||
goal: string
|
||||
indicators: string[]
|
||||
}
|
||||
|
||||
export interface PerformanceDomainDetail {
|
||||
summary?: string
|
||||
expectedGoals: string[]
|
||||
keyPoints: string[]
|
||||
interactions: string[]
|
||||
checks: PerformanceDomainCheckItem[]
|
||||
}
|
||||
|
||||
export interface PerformanceDomainItem {
|
||||
id: string
|
||||
name: string
|
||||
nameEn: string
|
||||
description: string
|
||||
color: string
|
||||
accentClass: string
|
||||
detail?: PerformanceDomainDetail
|
||||
}
|
||||
|
||||
export const performanceDomains: PerformanceDomainItem[] = [
|
||||
{
|
||||
id: 'PD01',
|
||||
name: '干系人绩效域',
|
||||
nameEn: 'Stakeholders',
|
||||
description: '关注干系人的识别、参与、沟通与期望管理。',
|
||||
color: '#6366F1',
|
||||
accentClass: 'from-indigo-500 to-violet-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'与干系人建立高效的工作关系',
|
||||
'干系人认同项目目标',
|
||||
'支持项目的干系人提高了满意度,并从中收益',
|
||||
'反对项目的干系人没有对项目产生负面影响',
|
||||
],
|
||||
keyPoints: [
|
||||
'识别',
|
||||
'理解和分析',
|
||||
'优先级排序',
|
||||
'参与',
|
||||
'监督',
|
||||
],
|
||||
interactions: [
|
||||
'干系人为项目团队定义需求和范围并对其进行优先级排序。',
|
||||
'干系人参与并制定规划。',
|
||||
'干系人确定项目可交付物和项目成果的验收和质量标准。',
|
||||
'客户、高层管理人员、项目管理办公室领导或项目集经理等干系人将重点关注项目及其可交付物绩效的测量。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '与干系人建立高效的工作关系',
|
||||
indicators: [
|
||||
'干系人参与的连续性。通过观察、记录方式,对干系人参与的连续性进行衡量。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '干系人认同项目目标',
|
||||
indicators: [
|
||||
'变更的频率。对项目范围、产品需求的大量变更或修改可能表明干系人没有参与进来或与项目目标不一致。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '支持项目的干系人提高了满意度,并从中收益',
|
||||
indicators: [
|
||||
'干系人行为。干系人的行为可表明项目受益人是否对项目感到满意和表示支持,或者他们是否反对项目。',
|
||||
'干系人满意度。可通过调研、访谈和焦点小组方式,确定干系人满意度,判断干系人是否感到满意和表示支持,或者他们对项目及其可交付物是否表示反对。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '反对项目的干系人没有对项目产生负面影响',
|
||||
indicators: [
|
||||
'干系人相关问题和风险。对项目问题日志和风险登记册的审查可以识别与单个干系人有关的问题和风险。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD02',
|
||||
name: '团队绩效域',
|
||||
nameEn: 'Team',
|
||||
description: '聚焦团队文化、协作方式、领导力与能力建设。',
|
||||
color: '#8B5CF6',
|
||||
accentClass: 'from-violet-500 to-fuchsia-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'共享责任',
|
||||
'建立高绩效团队',
|
||||
'所有团队成员都展现出相应的领导力和人际关系技能',
|
||||
],
|
||||
keyPoints: [
|
||||
'项目团队文化',
|
||||
'高绩效项目团队',
|
||||
'领导力技能',
|
||||
],
|
||||
interactions: [
|
||||
'团队绩效域聚焦于项目经理和项目团队成员在整个项目生命周期过程中的技能。',
|
||||
'这些技能已融入项目的其他各个方面。',
|
||||
'在整个项目期间,项目团队成员都需要全程展现团队相关的领导力素质和技能。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '共享责任',
|
||||
indicators: [
|
||||
'目标和责任心。所有项目团队成员都了解愿景和目标。',
|
||||
'项目团队对项目的可交付物和项目成果承担责任。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '建立高绩效团队',
|
||||
indicators: [
|
||||
'信任与协作程度。项目团队彼此信任,相互协作。',
|
||||
'适应变化的能力。项目团队适应不断变化的情况并在面对挑战时有韧性。',
|
||||
'彼此赋能。项目团队感到被赋能,同时项目团队对其成员赋能并认可。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '所有团队成员都展现出相应的领导力和人际关系技能',
|
||||
indicators: [
|
||||
'管理和领导力风格适宜性。项目团队成员运用批判性思维和人际关系技能。',
|
||||
'项目团队成员的管理和领导力风格适合项目的背景和环境。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD03',
|
||||
name: '开发/生命周期绩效域',
|
||||
nameEn: 'Development Approach & Life Cycle',
|
||||
description: '围绕开发方法、生命周期模型与交付节奏进行选择和管理。',
|
||||
color: '#EC4899',
|
||||
accentClass: 'from-pink-500 to-rose-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'开发方法与项目可交付物相符合',
|
||||
'将项目交付与干系人价值紧密关联',
|
||||
'项目生命周期由促进交付节奏的项目阶段和产生项目交付物所需的开发方法组成',
|
||||
],
|
||||
keyPoints: [
|
||||
'交付节奏',
|
||||
'开发方法',
|
||||
],
|
||||
interactions: [
|
||||
'开发方法和生命周期绩效域与干系人绩效域、规划绩效域、不确定性绩效域、交付绩效域、项目工作绩效域和团队绩效域相互作用。',
|
||||
'如果一个可交付物存在要与干系人验收相关的大量风险,则可能会选择迭代方法,向市场发布最小可行产品,以便在开发其他特性和功能之前获得反馈。',
|
||||
'所选的生命周期会影响进行规划的方式。预测型生命周期会提前进行大部分规划工作,项目进展中使用滚动式规划和渐进明细来重新规划,随着威胁和机会的发生,计划也会得到更新。',
|
||||
'开发方法和交付节奏是减轻项目不确定性的方法。如果一个可交付物存在与监管要求相关的大量风险,则可能会选择预测型方法,进行额外测试、文档编写,并采用健全的流程和程序。',
|
||||
'在考虑交付节奏和开发方法时,开发方法和生命周期绩效域与交付绩效域的关注点会有很多重叠。交付节奏是确保实际项目的价值交付和可行性规划保持一致的主要因素之一。',
|
||||
'在项目团队能力和项目团队领导力技能方面,项目工作绩效域、团队绩效域与开发方法和生命周期绩效域会相互作用。项目团队的工作方式和项目经理的风格会因开发方法的不同而存在很大差异。采用预测型方法时,通常需要更加重视预先规划、测量和控制;适应型方法,特别是在使用敏捷方法时,需要更多的服务型领导风格,而且可能会形成自我管理的项目团队。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '开发方法与项目可交付物相符合',
|
||||
indicators: [
|
||||
'产品质量和变更成本。采用适宜的开发方法,预测型、混合型或适应型,可交付物的产品质量比较高,变更成本相对较小。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '将项目交付与干系人价值紧密关联',
|
||||
indicators: [
|
||||
'价值导向型项目阶段。按照价值导向将项目工作从启动到收尾划分为多个项目阶段,项目阶段中包括适当的退出标准。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '项目生命周期由促进交付节奏的项目阶段和产生项目交付物所需的开发方法组成',
|
||||
indicators: [
|
||||
'适宜的交付节奏和开发方法。如果项目具有多个可交付物,且交付节奏和开发方法不同,可将生命周期阶段进行重叠或重复。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD04',
|
||||
name: '规划绩效域',
|
||||
nameEn: 'Planning',
|
||||
description: '强调范围、进度、成本、资源等多维规划活动。',
|
||||
color: '#F59E0B',
|
||||
accentClass: 'from-amber-500 to-orange-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'项目以有条理、协调一致的方式推进',
|
||||
'应用系统的方法交付项目成果',
|
||||
'对演变情况进行详细说明',
|
||||
'规划投入的时间成本是适当的',
|
||||
'规划的内容对管理干系人的需求而言是充分的',
|
||||
'可以根据新出现的和不断变化的需求进行调整',
|
||||
],
|
||||
keyPoints: [
|
||||
'规划的影响因素。每个项目都是独特的,不同项目规划的数量、时间安排和频率也各不相同。',
|
||||
'项目估算',
|
||||
'项目团队组成和结构规划',
|
||||
'沟通规划',
|
||||
'实物资源规划',
|
||||
'采购规划',
|
||||
'变更规划',
|
||||
'度量指标和一致性',
|
||||
],
|
||||
interactions: [
|
||||
'规划会在整个项目生命周期过程中进行,并与其他各个绩效域相整合。',
|
||||
'在项目开始时,会确定预期成果,并制订实现这些成果的高层级计划。根据选定的开发方法和生命周期,可以提前进行详细的规划,在项目进行中可根据实际情况对计划做出调整。',
|
||||
'在项目团队规划如何应对不确定性和风险时,不确定性绩效域和规划绩效域会相互作用。',
|
||||
'在整个项目执行过程中,规划将指导项目工作、成果和价值的交付。',
|
||||
'项目团队和干系人将制定度量指标并将绩效与计划进行比较,需要时可能会修订计划或制订新计划。',
|
||||
'项目团队成员、环境和项目的细节会影响项目团队有效合作以及干系人的积极参与。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '项目以有条理、协调一致的方式推进',
|
||||
indicators: [
|
||||
'绩效偏差。对照项目基准和其他度量指标对项目结果进行绩效审查,表明项目正在按计划进行,绩效偏差处于临界值范围内。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '应用系统的方法交付项目成果',
|
||||
indicators: [
|
||||
'规划的整体性。交付进度、资金提供、资源可用性、采购等表明项目是以整体方式进行规划的,没有差距或不一致之处。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '对演变情况进行详细说明',
|
||||
indicators: [
|
||||
'规划的详尽程度。与当前信息相比,可交付物和需求的初步信息是适当的、详尽的。与可行性研究与评估相比,当前信息表明项目可以生成预期的可交付物和成果。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '规划投入的时间成本是适当的',
|
||||
indicators: [
|
||||
'规划适宜性。项目计划和文件表明规划水平适合于项目。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '规划的内容对管理干系人的需求而言是充分的',
|
||||
indicators: [
|
||||
'规划的充分性。沟通管理计划和干系人信息表明沟通足以满足干系人的期望。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '可以根据新出现的和不断变化的需求进行调整',
|
||||
indicators: [
|
||||
'可适应变化。采用待办事项列表的项目,在整个项目期间会对各个计划做出调整。采用变更控制过程的项目具有变更控制委员会,会议的变更日志和文档表明变更控制过程正在得到应用。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD05',
|
||||
name: '项目工作绩效域',
|
||||
nameEn: 'Project Work',
|
||||
description: '关注项目工作的组织、协调、实施与持续改进。',
|
||||
color: '#10B981',
|
||||
accentClass: 'from-emerald-500 to-teal-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'高效且有效的项目绩效',
|
||||
'适合项目和环境的项目过程',
|
||||
'干系人适当的沟通和参与',
|
||||
'对实物资源进行了有效管理',
|
||||
'对采购进行了有效管理',
|
||||
'有效处理了变更',
|
||||
'通过持续学习和过程改进提高了团队能力',
|
||||
],
|
||||
keyPoints: [
|
||||
'项目过程',
|
||||
'项目制约因素',
|
||||
'专注于工作过程和能力',
|
||||
'管理沟通和参与',
|
||||
'管理实物资源',
|
||||
'处理采购事宜',
|
||||
'监督新工作和变更',
|
||||
'学习和持续改进',
|
||||
],
|
||||
interactions: [
|
||||
'项目工作绩效域与项目的其他绩效域相互作用,而且对其他绩效域具有促进作用。',
|
||||
'项目工作可促进并支持有效率且有效果的规划、交付和度量。',
|
||||
'项目工作可为项目团队互动和干系人参与提供有效的环境。',
|
||||
'项目工作可为驾驭不确定性、模糊性和复杂性提供支持,平衡其他项目制约因素。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '高效且有效的项目绩效',
|
||||
indicators: [
|
||||
'状态报告。通过状态报告可以表明项目工作有效率且有效果。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '适合项目和环境的项目过程',
|
||||
indicators: [
|
||||
'过程的适宜性。证据表明,项目过程是为满足项目和环境的需要而裁剪的。',
|
||||
'过程相关性和有效性。过程审计和质量保证活动表明,过程具有相关性且正得到有效使用。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '干系人适当的沟通和参与',
|
||||
indicators: [
|
||||
'沟通有效性。项目沟通管理计划和沟通文件表明,所计划的信息与干系人进行了沟通。如有新的信息沟通需求或误解,可能表明干系人的沟通和参与活动缺乏成效。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '对实物资源进行了有效管理',
|
||||
indicators: [
|
||||
'资源利用率。所用材料的数量、抛弃的废料和返工量表明,资源正得到高效利用。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '对采购进行了有效管理',
|
||||
indicators: [
|
||||
'采购过程适宜。采购审计表明,所采用的适当流程足以开展采购工作,而且承包商正在按计划开展工作。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '有效处理了变更',
|
||||
indicators: [
|
||||
'变更处理情况。使用预测型方法的项目已建立变更日志,该日志表明,正在对变更做出全面评估,同时考虑了范围、进度、预算、资源、干系人和风险的影响。采用适应型方法的项目已建立待办事项列表,该列表显示完成范围的比率和增加新范围的比率。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '通过持续学习和过程改进提高了团队能力',
|
||||
indicators: [
|
||||
'团队绩效。团队状态报告表明,错误和返工减少,而效率提高。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD06',
|
||||
name: '交付绩效域',
|
||||
nameEn: 'Delivery',
|
||||
description: '聚焦价值交付、质量达成与成果验收。',
|
||||
color: '#06B6D4',
|
||||
accentClass: 'from-cyan-500 to-sky-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'项目有助于实现业务目标和战略',
|
||||
'项目实现了预期成果',
|
||||
'在预定时间内实现了项目收益',
|
||||
'项目团队对需求有清晰的理解',
|
||||
'干系人接受项目可交付物和成果,并对其满意',
|
||||
],
|
||||
keyPoints: [
|
||||
'价值的交付',
|
||||
'可交付物',
|
||||
'质量',
|
||||
],
|
||||
interactions: [
|
||||
'交付绩效域是在规划绩效域中所执行所有工作的终点。',
|
||||
'交付节奏基于开发方法和生命周期绩效域中工作的结构方式。',
|
||||
'项目工作绩效域通过建立各种过程、管理实物资源、管理采购等促使交付工作。',
|
||||
'项目团队成员在此绩效域中执行工作,工作性质会影响项目团队驾驭不确定性的方式。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '项目有助于实现业务目标和战略',
|
||||
indicators: [
|
||||
'目标一致性。组织的战略计划、可行性研究报告以及项目授权文件表明,项目可交付物和业务目标保持一致。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '项目实现了预期成果',
|
||||
indicators: [
|
||||
'项目完成度。项目基础数据表明,项目仍处于正轨,可实现预期成果。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '在预定时间内实现了项目收益',
|
||||
indicators: [
|
||||
'项目收益。进度表明财务指标和所规划的交付正在按计划实现。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '项目团队对需求有清晰的理解',
|
||||
indicators: [
|
||||
'需求稳定性。在预测型项目中,初始需求的变更很少,表明对需求的真正理解度较高。在需求不断演变的适应型项目中,项目进展中阶段性需求确认反映了干系人对需求的理解。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '干系人接受项目可交付物和成果,并对其满意',
|
||||
indicators: [
|
||||
'干系人满意度。访谈、观察和最终用户反馈可表明干系人对可交付物的满意度。',
|
||||
'质量问题。投诉或退货等质量相关问题的数量也可用于表示满意度。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD07',
|
||||
name: '测量绩效域',
|
||||
nameEn: 'Measurement',
|
||||
description: '通过度量指标、绩效数据和分析机制掌握项目状态。',
|
||||
color: '#3B82F6',
|
||||
accentClass: 'from-blue-500 to-indigo-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'对项目状况充分理解',
|
||||
'数据充分,可支持决策',
|
||||
'及时采取行动,确保项目最佳绩效',
|
||||
'能够基于预测和评估作出决策,实现目标并产生价值',
|
||||
],
|
||||
keyPoints: [
|
||||
'制定有效的度量指标',
|
||||
'度量内容及相应指标',
|
||||
'展示度量信息和结果',
|
||||
'度量陷阱',
|
||||
'基于度量进行诊断',
|
||||
'持续改进',
|
||||
],
|
||||
interactions: [
|
||||
'度量绩效域与规划绩效域、项目工作绩效域和交付绩效域相互作用。',
|
||||
'规划构成了交付和规划比较的基础。',
|
||||
'度量绩效域通过提供最新信息来支持规划绩效域的活动。',
|
||||
'在项目团队成员制订计划并创建可度量的可交付物时,团队绩效域和干系人绩效域会相互作用。',
|
||||
'当不可预测的事件发生时,无论是积极事件还是消极事件,它们会影响项目绩效,从而影响项目的度量指标。应对不确定事件带来的变更时,要同时更新受此变更影响的度量。',
|
||||
'可以根据绩效度量结果启动不确定性绩效域中的活动,例如识别风险和机会。',
|
||||
'作为项目工作的一部分,应与项目团队和其他干系人合作,以便制定度量指标、收集数据、分析数据、做出决策并报告项目状态。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '对项目状况充分理解',
|
||||
indicators: [
|
||||
'度量结果和报告。通过审计度量结果和报告,可表明数据是否可靠。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '数据充分,可支持决策',
|
||||
indicators: [
|
||||
'度量结果。度量结果可表明项目是否按预期进行,或者是否存在偏差。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '及时采取行动,确保项目最佳绩效',
|
||||
indicators: [
|
||||
'度量结果。度量结果提供了提前指标以及当前状态,可导致及时的决策和行动。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '能够基于预测和评估作出决策,实现目标并产生价值',
|
||||
indicators: [
|
||||
'工作绩效数据。回顾过去的预测和当前的工作绩效数据,可发现以前的预测是否准确地反映了目前的情况。',
|
||||
'将实际绩效与计划绩效进行比较,并评估业务文档,可表明项目实现预期价值的可能性。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PD08',
|
||||
name: '不确定性绩效域',
|
||||
nameEn: 'Uncertainty',
|
||||
description: '针对风险、复杂性、模糊性和变化进行识别与应对。',
|
||||
color: '#EF4444',
|
||||
accentClass: 'from-red-500 to-pink-500',
|
||||
detail: {
|
||||
expectedGoals: [
|
||||
'了解项目的运行环境,包括技术、社会、政治、市场和经济环境等',
|
||||
'积极识别、分析和应对不确定性',
|
||||
'了解项目中多个因素之间的相互依赖关系',
|
||||
'能够对威胁和机会进行预测,了解问题的后果',
|
||||
'最小化不确定性对项目交付的负面影响',
|
||||
'能够利用机会改进项目的绩效和成果',
|
||||
'有效利用成本和进度储备,与项目目标保持一致等',
|
||||
],
|
||||
keyPoints: [
|
||||
'风险',
|
||||
'模糊性',
|
||||
'复杂性',
|
||||
'不确定性的应对方法',
|
||||
],
|
||||
interactions: [
|
||||
'从产品或可交付物角度看,不确定性绩效域与其他7个绩效域都相互作用。',
|
||||
'随着规划的进行,可将减少不确定性和风险的活动纳入计划。这些活动是在交付绩效域中执行的,度量可以表明随着时间的推移风险级别是否会有所变化。',
|
||||
'项目团队成员和其他干系人是不确定性的主要信息来源,在应对各种形式的不确定性方面,他们可以提供信息、建议和协助。',
|
||||
'生命周期和开发方法的选择将影响不确定性的应对方式。在范围相对稳定并采用预测型方法的项目中,可以使用进度和预算储备来应对风险。',
|
||||
'在采用适应型方法的项目中,在系统如何互动或干系人如何反应方面可能存在不确定性,项目团队可以调整计划,以反映对不断演变情况的理解,还可以使用储备来应对不确定性的影响。',
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
goal: '了解项目的运行环境,包括技术、社会、政治、市场和经济环境等',
|
||||
indicators: [
|
||||
'环境因素。团队在评估不确定性、风险和应对措施时考虑了环境因素。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '积极识别、分析和应对不确定性',
|
||||
indicators: [
|
||||
'风险应对措施。与项目制约因素,例如预算、进度和绩效的优先级排序保持一致。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '了解项目中多个因素之间的相互依赖关系',
|
||||
indicators: [
|
||||
'应对措施适宜性。应对风险、复杂性和模糊性的措施适合于项目。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '能够对威胁和机会进行预测,了解问题的后果',
|
||||
indicators: [
|
||||
'风险管理机制或系统。用于识别、分析和应对风险的系统非常强大。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '最小化不确定性对项目交付的负面影响',
|
||||
indicators: [
|
||||
'项目绩效处于临界值内。满足计划的交付日期,预算执行情况处于偏差临界值内。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '能够利用机会改进项目的绩效和成果',
|
||||
indicators: [
|
||||
'利用机会的机制。团队使用既定机制来识别和利用机会。',
|
||||
],
|
||||
},
|
||||
{
|
||||
goal: '有效利用成本和进度储备,与项目目标保持一致',
|
||||
indicators: [
|
||||
'储备使用。团队采取步骤主动预防威胁,有效使用成本或进度储备。',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const performanceDomainMap = new Map<string, PerformanceDomainItem>(
|
||||
performanceDomains.map(domain => [domain.id, domain])
|
||||
)
|
||||
145
src/data/principles.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
export type PrincipleCategoryId = 'people' | 'environment' | 'things'
|
||||
|
||||
export interface Principle {
|
||||
id: string
|
||||
categoryId: PrincipleCategoryId
|
||||
order: number
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PrincipleGroup {
|
||||
id: PrincipleCategoryId
|
||||
label: string
|
||||
order: number
|
||||
items: Principle[]
|
||||
}
|
||||
|
||||
export const principleGroups: PrincipleGroup[] = [
|
||||
{
|
||||
id: 'people',
|
||||
label: '人',
|
||||
order: 1,
|
||||
items: [
|
||||
{
|
||||
id: 'people-stewardship',
|
||||
categoryId: 'people',
|
||||
order: 1,
|
||||
name: '管家式管理',
|
||||
description: '勤勉、尊重和关心他人',
|
||||
},
|
||||
{
|
||||
id: 'people-stakeholders',
|
||||
categoryId: 'people',
|
||||
order: 2,
|
||||
name: '干系人',
|
||||
description: '促进干系人有效参与',
|
||||
},
|
||||
{
|
||||
id: 'people-leadership',
|
||||
categoryId: 'people',
|
||||
order: 3,
|
||||
name: '领导力',
|
||||
description: '展现领导力行为',
|
||||
},
|
||||
{
|
||||
id: 'people-team',
|
||||
categoryId: 'people',
|
||||
order: 4,
|
||||
name: '团队',
|
||||
description: '营造协作的项目团队环境',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'environment',
|
||||
label: '环境',
|
||||
order: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'env-complexity',
|
||||
categoryId: 'environment',
|
||||
order: 1,
|
||||
name: '复杂性',
|
||||
description: '驾驭复杂性',
|
||||
},
|
||||
{
|
||||
id: 'env-change',
|
||||
categoryId: 'environment',
|
||||
order: 2,
|
||||
name: '变革',
|
||||
description: '为实现目标而驱动变革',
|
||||
},
|
||||
{
|
||||
id: 'env-value',
|
||||
categoryId: 'environment',
|
||||
order: 3,
|
||||
name: '价值',
|
||||
description: '聚焦于价值',
|
||||
},
|
||||
{
|
||||
id: 'env-adaptability',
|
||||
categoryId: 'environment',
|
||||
order: 4,
|
||||
name: '适应性和韧性',
|
||||
description: '拥抱适应性和韧性',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'things',
|
||||
label: '事',
|
||||
order: 3,
|
||||
items: [
|
||||
{
|
||||
id: 'things-tailoring',
|
||||
categoryId: 'things',
|
||||
order: 1,
|
||||
name: '裁剪',
|
||||
description: '根据环境进行裁剪',
|
||||
},
|
||||
{
|
||||
id: 'things-risk',
|
||||
categoryId: 'things',
|
||||
order: 2,
|
||||
name: '风险',
|
||||
description: '优化风险应对',
|
||||
},
|
||||
{
|
||||
id: 'things-quality',
|
||||
categoryId: 'things',
|
||||
order: 3,
|
||||
name: '质量',
|
||||
description: '将质量融入到过程和成果中',
|
||||
},
|
||||
{
|
||||
id: 'things-system',
|
||||
categoryId: 'things',
|
||||
order: 4,
|
||||
name: '系统交互',
|
||||
description: '识别、评估和响应系统交互',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 所有原则的扁平数组,顺序为:人×4 → 环境×4 → 事×4 */
|
||||
export const principles: Principle[] = principleGroups.flatMap((g) => g.items)
|
||||
|
||||
/** 原则 id → Principle 快速查找 */
|
||||
export const principleMap = new Map<string, Principle>(
|
||||
principles.map((p) => [p.id, p])
|
||||
)
|
||||
|
||||
/** 从当前原则 id 出发,找下一个未答对的原则(环形搜索) */
|
||||
export function getNextUnanswered(
|
||||
currentId: string,
|
||||
answered: Map<string, boolean>
|
||||
): Principle | null {
|
||||
const startIdx = principles.findIndex((p) => p.id === currentId)
|
||||
for (let offset = 1; offset <= principles.length; offset++) {
|
||||
const candidate = principles[(startIdx + offset) % principles.length]
|
||||
if (!answered.get(candidate.id)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
25
src/data/process-purpose-practice.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { processes } from '@/data'
|
||||
|
||||
export interface ProcessPurposePracticeItem {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
}
|
||||
|
||||
function getPracticePurpose(id: string, purpose: string): string {
|
||||
if (id === 'P1.1') {
|
||||
return purpose.replace(/^项目章程/, '')
|
||||
}
|
||||
|
||||
return purpose
|
||||
}
|
||||
|
||||
export const processPurposePracticeItems: ProcessPurposePracticeItem[] =
|
||||
processes
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((process) => ({
|
||||
id: process.id,
|
||||
name: process.name,
|
||||
purpose: getPracticePurpose(process.id, process.purpose || ''),
|
||||
}))
|
||||
34
src/data/timeline-items.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"timelineItems": [
|
||||
{
|
||||
"id": "TL001",
|
||||
"timeText": "2020年",
|
||||
"sortKey": "20200000",
|
||||
"timePrecision": "year",
|
||||
"theme": "农业现代化与乡村振兴战略",
|
||||
"excerpt": "党的十九届五中全会着眼2035年基本实现社会主义现代化,提出“关键核心技术实现重大突破,进入创新型国家前列”的远景目标。",
|
||||
"sourceAnchor": "党的十九届五中全会",
|
||||
"sourceAnchorType": "meeting"
|
||||
},
|
||||
{
|
||||
"id": "TL002",
|
||||
"timeText": "2021年",
|
||||
"sortKey": "20210000",
|
||||
"timePrecision": "year",
|
||||
"theme": "产业数字化转型",
|
||||
"excerpt": "《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》明确提出了推进产业数字化转型,实施“上云用数赋智”行动,推动数据赋能全产业链协同转型。",
|
||||
"sourceAnchor": "《中华人民共和国国民经济和社会发展第十四个五年规划和2035年远景目标纲要》",
|
||||
"sourceAnchorType": "document"
|
||||
},
|
||||
{
|
||||
"id": "TL003",
|
||||
"timeText": "2021年12月",
|
||||
"sortKey": "20211200",
|
||||
"timePrecision": "month",
|
||||
"theme": "数字政府",
|
||||
"excerpt": "《“十四五”国家信息化规划》中提出打造协同高效的数字政府服务体系,深入坚持整体集约建设数字政府。",
|
||||
"sourceAnchor": "《“十四五”国家信息化规划》",
|
||||
"sourceAnchorType": "document"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,6 +119,21 @@
|
||||
{ "id": "TT117", "name": "引导式研讨会", "nameEn": "Facilitated Workshops", "type": "technique", "category": "data-gathering" },
|
||||
{ "id": "TT118", "name": "群体创新技术", "nameEn": "Group Creativity Techniques", "type": "technique", "category": "data-gathering" },
|
||||
{ "id": "TT119", "name": "群体决策技术", "nameEn": "Group Decision-Making Techniques", "type": "technique", "category": "decision-making" },
|
||||
{ "id": "TT120", "name": "核对单", "nameEn": "Checklists", "type": "tool", "category": "general" }
|
||||
{ "id": "TT120", "name": "提示清单", "nameEn": "Prompt Lists", "type": "technique", "category": "data-gathering" },
|
||||
{ "id": "TT121", "name": "成本汇总", "nameEn": "Cost Aggregation", "type": "technique", "category": "cost" },
|
||||
{ "id": "TT122", "name": "历史信息审核", "nameEn": "Historical Information Review", "type": "technique", "category": "cost" },
|
||||
{ "id": "TT123", "name": "资金限制平衡", "nameEn": "Funding Limit Reconciliation", "type": "technique", "category": "cost" },
|
||||
{ "id": "TT124", "name": "融资", "nameEn": "Financing", "type": "technique", "category": "cost" },
|
||||
{ "id": "TT125", "name": "完工尚需绩效指数", "nameEn": "To-Complete Performance Index", "type": "technique", "category": "cost" },
|
||||
{ "id": "TT126", "name": "计划评审技术", "nameEn": "Program Evaluation and Review Technique", "type": "technique", "category": "scheduling" },
|
||||
{ "id": "TT127", "name": "敏捷或适应型发布规划", "nameEn": "Agile Release Planning", "type": "technique", "category": "scheduling" },
|
||||
{ "id": "TT128", "name": "箭线图法", "nameEn": "Arrow Diagramming Method", "type": "technique", "category": "scheduling" },
|
||||
{ "id": "TT129", "name": "系统交互图", "nameEn": "Context Diagram", "type": "technique", "category": "scope" },
|
||||
{ "id": "TT130", "name": "不确定性表现方式", "nameEn": "Representations of Uncertainty", "type": "technique", "category": "risk" },
|
||||
{ "id": "TT131", "name": "广告", "nameEn": "Advertising", "type": "technique", "category": "procurement" },
|
||||
{ "id": "TT132", "name": "决策技术", "nameEn": "Decision-Making Techniques", "type": "technique", "category": "decision-making" },
|
||||
{ "id": "TT133", "name": "沟通需求分析", "nameEn": "Communication Requirements Analysis", "type": "technique", "category": "communication" },
|
||||
{ "id": "TT134", "name": "项目报告", "nameEn": "Project Reporting", "type": "technique", "category": "communication" },
|
||||
{ "id": "TT135", "name": "测试与检查的规划", "nameEn": "Test and Inspection Planning", "type": "technique", "category": "quality" }
|
||||
]
|
||||
}
|
||||
|
||||
62
src/hooks/useLongPress.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
interface UseLongPressOptions {
|
||||
onLongPress: (cellId: string) => void
|
||||
onLongPressEnd: () => void
|
||||
delay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义长按 Hook
|
||||
* 支持触摸、鼠标和键盘(空格键)
|
||||
*/
|
||||
export function useLongPress(
|
||||
cellId: string,
|
||||
{ onLongPress, onLongPressEnd, delay = 600 }: UseLongPressOptions
|
||||
) {
|
||||
const timerRef = useRef<number>()
|
||||
const isLongPressRef = useRef(false)
|
||||
|
||||
const start = useCallback(() => {
|
||||
isLongPressRef.current = false
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
isLongPressRef.current = true
|
||||
onLongPress(cellId)
|
||||
}, delay)
|
||||
}, [cellId, onLongPress, delay])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
// 只有在长按成功触发后才调用 onLongPressEnd
|
||||
if (isLongPressRef.current) {
|
||||
onLongPressEnd()
|
||||
}
|
||||
isLongPressRef.current = false
|
||||
}, [onLongPressEnd])
|
||||
|
||||
return {
|
||||
onPointerDown: start,
|
||||
onPointerUp: cancel,
|
||||
onPointerLeave: cancel,
|
||||
onPointerCancel: cancel,
|
||||
onContextMenu: (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
},
|
||||
// 键盘支持(空格键长按)
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' && !e.repeat) {
|
||||
e.preventDefault()
|
||||
start()
|
||||
}
|
||||
},
|
||||
onKeyUp: (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
380
src/pages/ApiDocPage.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { CheckCircle2, Copy, Play, Server, TerminalSquare } from 'lucide-react'
|
||||
|
||||
type ApiEndpoint = {
|
||||
id: string
|
||||
name: string
|
||||
method: 'GET'
|
||||
path: string
|
||||
samplePath: string
|
||||
description: string
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
const endpoints: ApiEndpoint[] = [
|
||||
{
|
||||
id: 'process-groups',
|
||||
name: '过程组列表',
|
||||
method: 'GET',
|
||||
path: '/api/process-groups.json',
|
||||
samplePath: '/api/process-groups.json',
|
||||
description: '返回五大过程组基础信息。',
|
||||
fields: ['id:过程组 ID', 'name:过程组名称'],
|
||||
},
|
||||
{
|
||||
id: 'knowledge-areas',
|
||||
name: '知识领域列表',
|
||||
method: 'GET',
|
||||
path: '/api/knowledge-areas.json',
|
||||
samplePath: '/api/knowledge-areas.json',
|
||||
description: '返回十大知识领域及其裁剪因素。',
|
||||
fields: ['id:知识领域 ID', 'name:知识领域名称', 'tailoringFactors:裁剪因素数组', 'title:裁剪因素标题', 'description:裁剪因素说明'],
|
||||
},
|
||||
{
|
||||
id: 'knowledge-area-tailoring',
|
||||
name: '知识领域裁剪因素',
|
||||
method: 'GET',
|
||||
path: '/api/knowledge-areas/{id}/tailoring-factors.json',
|
||||
samplePath: '/api/knowledge-areas/KA01/tailoring-factors.json',
|
||||
description: '返回指定知识领域的裁剪因素。',
|
||||
fields: ['title:裁剪因素标题', 'description:裁剪因素说明'],
|
||||
},
|
||||
{
|
||||
id: 'knowledge-area-processes',
|
||||
name: '知识领域过程',
|
||||
method: 'GET',
|
||||
path: '/api/knowledge-areas/{id}/processes.json',
|
||||
samplePath: '/api/knowledge-areas/KA01/processes.json',
|
||||
description: '返回指定知识领域下的过程。',
|
||||
fields: ['id:过程 ID', 'name:过程名称', 'processGroupId:过程组 ID', 'processGroupName:过程组名称', 'purpose:主要作用'],
|
||||
},
|
||||
{
|
||||
id: 'process-group-processes',
|
||||
name: '过程组过程',
|
||||
method: 'GET',
|
||||
path: '/api/process-groups/{id}/processes.json',
|
||||
samplePath: '/api/process-groups/PG02/processes.json',
|
||||
description: '返回指定过程组下的过程。',
|
||||
fields: ['id:过程 ID', 'name:过程名称', 'knowledgeAreaId:知识领域 ID', 'knowledgeAreaName:知识领域名称', 'purpose:主要作用'],
|
||||
},
|
||||
{
|
||||
id: 'processes',
|
||||
name: '过程列表',
|
||||
method: 'GET',
|
||||
path: '/api/processes.json',
|
||||
samplePath: '/api/processes.json',
|
||||
description: '返回 49 个项目管理过程。',
|
||||
fields: ['id:过程 ID', 'name:过程名称', 'knowledgeAreaId:知识领域 ID', 'knowledgeAreaName:知识领域名称', 'processGroupId:过程组 ID', 'processGroupName:过程组名称', 'purpose:主要作用'],
|
||||
},
|
||||
{
|
||||
id: 'process-detail',
|
||||
name: '过程详情',
|
||||
method: 'GET',
|
||||
path: '/api/processes/{id}.json',
|
||||
samplePath: '/api/processes/P1.1.json',
|
||||
description: '返回指定过程基础信息。',
|
||||
fields: ['id:过程 ID', 'name:过程名称', 'knowledgeAreaId:知识领域 ID', 'knowledgeAreaName:知识领域名称', 'processGroupId:过程组 ID', 'processGroupName:过程组名称', 'purpose:主要作用'],
|
||||
},
|
||||
{
|
||||
id: 'process-itto',
|
||||
name: '过程 ITTO',
|
||||
method: 'GET',
|
||||
path: '/api/processes/{id}/itto.json',
|
||||
samplePath: '/api/processes/P1.1/itto.json',
|
||||
description: '返回指定过程的输入、工具与技术、输出。',
|
||||
fields: ['id:过程 ID', 'name:过程名称', 'inputs:输入数组', 'tools:工具数组', 'outputs:输出数组', 'details:明细项数组', 'note:过程语境备注'],
|
||||
},
|
||||
{
|
||||
id: 'performance-domains',
|
||||
name: '绩效域列表',
|
||||
method: 'GET',
|
||||
path: '/api/performance-domains.json',
|
||||
samplePath: '/api/performance-domains.json',
|
||||
description: '返回八大项目绩效域。',
|
||||
fields: ['id:绩效域 ID', 'name:绩效域名称'],
|
||||
},
|
||||
{
|
||||
id: 'performance-domain-detail',
|
||||
name: '绩效域详情',
|
||||
method: 'GET',
|
||||
path: '/api/performance-domains/{id}.json',
|
||||
samplePath: '/api/performance-domains/PD01.json',
|
||||
description: '返回指定绩效域的目标、要点、交互与检查项。',
|
||||
fields: ['id:绩效域 ID', 'name:绩效域名称', 'expectedGoals:预期目标', 'keyPoints:绩效要点', 'interactions:相互作用', 'checks:检查方法', 'goal:检查目标', 'indicators:检查指标'],
|
||||
},
|
||||
{
|
||||
id: 'artifact-usage',
|
||||
name: '工件使用情况',
|
||||
method: 'GET',
|
||||
path: '/api/artifacts/{id}/usage.json',
|
||||
samplePath: '/api/artifacts/A001/usage.json',
|
||||
description: '返回指定工件作为输入或输出的过程。',
|
||||
fields: ['id:工件 ID', 'name:工件名称', 'asInput:作为输入被哪些过程使用', 'asOutput:由哪些过程输出'],
|
||||
},
|
||||
{
|
||||
id: 'tool-usage',
|
||||
name: '工具与技术使用情况',
|
||||
method: 'GET',
|
||||
path: '/api/tools/{id}/usage.json',
|
||||
samplePath: '/api/tools/TT001/usage.json',
|
||||
description: '返回指定工具与技术出现的过程。',
|
||||
fields: ['id:工具 ID', 'name:工具名称', 'usedIn:使用该工具的过程数组'],
|
||||
},
|
||||
{
|
||||
id: 'api-doc-markdown',
|
||||
name: 'Markdown 接口文档',
|
||||
method: 'GET',
|
||||
path: '/apidoc',
|
||||
samplePath: '/apidoc',
|
||||
description: '返回接口说明 Markdown 文本字符串。',
|
||||
fields: ['响应体:Markdown 文本字符串', '用途:供知识库或外部系统直接读取接口说明'],
|
||||
},
|
||||
]
|
||||
|
||||
const fieldGroups = [
|
||||
{
|
||||
title: '通用字段',
|
||||
items: ['id:稳定编号', 'name:中文名称', 'purpose:主要作用'],
|
||||
},
|
||||
{
|
||||
title: '过程字段',
|
||||
items: ['knowledgeAreaId:所属知识领域 ID', 'knowledgeAreaName:所属知识领域名称', 'processGroupId:所属过程组 ID', 'processGroupName:所属过程组名称'],
|
||||
},
|
||||
{
|
||||
title: '引用字段',
|
||||
items: ['inputs:输入数组', 'tools:工具数组', 'outputs:输出数组', 'details:明细项数组', 'note:补充说明'],
|
||||
},
|
||||
]
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
export function ApiDocPage() {
|
||||
const [selectedId, setSelectedId] = useState(endpoints[0].id)
|
||||
const selectedEndpoint = useMemo(
|
||||
() => endpoints.find((endpoint) => endpoint.id === selectedId) ?? endpoints[0],
|
||||
[selectedId]
|
||||
)
|
||||
const [requestPath, setRequestPath] = useState(selectedEndpoint.samplePath)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<string>('')
|
||||
const [status, setStatus] = useState<string>('')
|
||||
|
||||
function handleEndpointChange(endpointId: string) {
|
||||
const endpoint = endpoints.find((item) => item.id === endpointId) ?? endpoints[0]
|
||||
setSelectedId(endpoint.id)
|
||||
setRequestPath(endpoint.samplePath)
|
||||
setStatus('')
|
||||
setResult('')
|
||||
}
|
||||
|
||||
async function handleFetchTest() {
|
||||
setLoading(true)
|
||||
setStatus('请求中')
|
||||
setResult('')
|
||||
|
||||
try {
|
||||
const response = await fetch(requestPath, { headers: { Accept: 'application/json, text/markdown, text/plain, */*' } })
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
const body = contentType.includes('application/json') ? await response.json() : await response.text()
|
||||
|
||||
setStatus(`${response.status} ${response.statusText || 'OK'}`)
|
||||
setResult(typeof body === 'string' ? body : formatJson(body))
|
||||
} catch (error) {
|
||||
setStatus('请求失败')
|
||||
setResult(error instanceof Error ? error.message : '无法完成请求')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPath() {
|
||||
await navigator.clipboard?.writeText(requestPath)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
<Server size={14} />
|
||||
知识库接口
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">API 调用说明</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-gray-500 dark:text-gray-400">
|
||||
静态接口采用 GET 请求,/api 返回 JSON,/apidoc 返回 Markdown 文本;面向知识库读取,不包含搜索、展示样式和五问一法。
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/apidoc"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-gray-700 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200"
|
||||
>
|
||||
查看 Markdown 文档 /apidoc
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-900/50">
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{endpoints.length}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">个接口</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-900/50">
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">GET</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">请求方法</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-900/50">
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">JSON/MD</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">响应格式</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.04 }}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-5 py-4 dark:border-gray-700">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">接口列表</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{endpoints.map((endpoint) => (
|
||||
<button
|
||||
key={endpoint.id}
|
||||
type="button"
|
||||
onClick={() => handleEndpointChange(endpoint.id)}
|
||||
className={`w-full px-5 py-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/40 ${
|
||||
selectedEndpoint.id === endpoint.id ? 'bg-indigo-50/70 dark:bg-indigo-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md bg-emerald-50 px-2 py-0.5 text-xs font-bold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{endpoint.name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-xs text-indigo-600 dark:text-indigo-300">{endpoint.path}</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{endpoint.description}</p>
|
||||
</div>
|
||||
{selectedEndpoint.id === endpoint.id && <CheckCircle2 className="h-5 w-5 text-indigo-500" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
<motion.aside
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.08 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<TerminalSquare className="h-5 w-5 text-indigo-500" />
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">调用测试</h2>
|
||||
</div>
|
||||
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300" htmlFor="api-endpoint">
|
||||
选择接口
|
||||
</label>
|
||||
<select
|
||||
id="api-endpoint"
|
||||
value={selectedId}
|
||||
onChange={(event) => handleEndpointChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 dark:border-gray-600 dark:bg-gray-900 dark:text-white dark:focus:ring-indigo-900/40"
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
<option key={endpoint.id} value={endpoint.id}>{endpoint.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="mt-4 block text-sm font-medium text-gray-700 dark:text-gray-300" htmlFor="api-path">
|
||||
请求路径
|
||||
</label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
id="api-path"
|
||||
value={requestPath}
|
||||
onChange={(event) => setRequestPath(event.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 font-mono text-sm text-gray-900 outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 dark:border-gray-600 dark:bg-gray-900 dark:text-white dark:focus:ring-indigo-900/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPath}
|
||||
className="rounded-lg border border-gray-200 px-3 text-gray-600 transition hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
aria-label="复制请求路径"
|
||||
>
|
||||
<Copy size={17} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchTest}
|
||||
disabled={loading || !requestPath.trim()}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Play size={16} />
|
||||
{loading ? '请求中' : '发送请求'}
|
||||
</button>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3 dark:bg-gray-900/60">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">响应</div>
|
||||
<div className="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{status}</div>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-950 p-3 text-xs leading-5 text-gray-100">
|
||||
{result}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">字段说明</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{fieldGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="text-sm font-medium text-gray-800 dark:text-gray-200">{group.title}</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm leading-6 text-gray-500 dark:text-gray-400">
|
||||
{group.items.map((item) => <li key={item}>• {item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</motion.aside>
|
||||
</div>
|
||||
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.12 }}
|
||||
className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">{selectedEndpoint.name}字段</h2>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedEndpoint.fields.map((field) => (
|
||||
<div key={field} className="rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:bg-gray-900/50 dark:text-gray-300">
|
||||
{field}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -92,13 +92,12 @@ export function HomePage() {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4"
|
||||
className="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{[
|
||||
{ label: '知识领域', value: stats.knowledgeAreaCount, color: 'text-indigo-600' },
|
||||
{ label: '过程组', value: stats.processGroupCount, color: 'text-blue-600' },
|
||||
{ label: '项目过程', value: stats.processCount, color: 'text-emerald-600' },
|
||||
{ label: '工具技术', value: stats.toolCount, color: 'text-amber-600' },
|
||||
].map((stat) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
@@ -116,7 +115,7 @@ export function HomePage() {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid md:grid-cols-3 gap-6"
|
||||
className="grid md:grid-cols-2 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon
|
||||
|
||||
309
src/pages/IttoCollectionsPage.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { FileOutput, FileText, Wrench, X } from 'lucide-react'
|
||||
import {
|
||||
artifactMap,
|
||||
knowledgeAreas,
|
||||
processes,
|
||||
toolMap,
|
||||
normalizeProcessRef,
|
||||
} from '@/data'
|
||||
import type { Process, ProcessRef } from '@/types/itto'
|
||||
|
||||
type ViewKey = 'inputs' | 'tools' | 'outputs'
|
||||
|
||||
type CollectionItem = {
|
||||
label: string
|
||||
details: string[]
|
||||
}
|
||||
|
||||
type CollectionRow = {
|
||||
processId: string
|
||||
processCode: string
|
||||
processName: string
|
||||
processNameEn: string
|
||||
items: CollectionItem[]
|
||||
}
|
||||
|
||||
type CollectionArea = {
|
||||
id: string
|
||||
order: number
|
||||
name: string
|
||||
nameEn: string
|
||||
color: string
|
||||
rows: CollectionRow[]
|
||||
}
|
||||
|
||||
const tabs: Array<{ key: ViewKey; label: string; icon: typeof FileText }> = [
|
||||
{ key: 'inputs', label: '输入', icon: FileText },
|
||||
{ key: 'tools', label: '工具', icon: Wrench },
|
||||
{ key: 'outputs', label: '输出', icon: FileOutput },
|
||||
]
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.03 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
function formatRef(ref: ProcessRef, viewKey: ViewKey): CollectionItem {
|
||||
const normalized = normalizeProcessRef(ref)
|
||||
const entity = viewKey === 'tools' ? toolMap.get(normalized.id) : artifactMap.get(normalized.id)
|
||||
const label = entity?.name ?? normalized.id
|
||||
const details = normalized.detail?.map((item) => item.label).filter(Boolean) ?? []
|
||||
|
||||
return { label, details }
|
||||
}
|
||||
|
||||
function getRefsByView(process: Process, viewKey: ViewKey) {
|
||||
if (viewKey === 'inputs') return process.inputs
|
||||
if (viewKey === 'tools') return process.tools
|
||||
return process.outputs
|
||||
}
|
||||
|
||||
function uniqueItems(items: CollectionItem[]) {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = `${item.label}__${item.details.join('、')}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function itemMatchesSelected(item: CollectionItem, selectedText: string | null) {
|
||||
if (!selectedText) return false
|
||||
return item.label === selectedText || item.details.includes(selectedText)
|
||||
}
|
||||
|
||||
function renderSelectableText(
|
||||
text: string,
|
||||
selectedText: string | null,
|
||||
onSelect: (text: string) => void
|
||||
) {
|
||||
const isSelected = selectedText === text
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onSelect(text)}
|
||||
className={`mx-[-2px] rounded px-0.5 text-left transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/40 dark:hover:text-indigo-300 ${
|
||||
isSelected
|
||||
? 'bg-yellow-200 text-yellow-950 ring-1 ring-yellow-300 dark:bg-yellow-500/30 dark:text-yellow-100 dark:ring-yellow-500/40'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCollectionItems(
|
||||
items: CollectionItem[],
|
||||
selectedText: string | null,
|
||||
onSelect: (text: string) => void
|
||||
) {
|
||||
return items.map((item, itemIndex) => (
|
||||
<span key={`${item.label}-${item.details.join('-')}-${itemIndex}`}>
|
||||
{itemIndex > 0 && <span className="text-gray-400 dark:text-gray-500">;</span>}
|
||||
{renderSelectableText(item.label, selectedText, onSelect)}
|
||||
{item.details.length > 0 && (
|
||||
<span>
|
||||
<span className="text-gray-500 dark:text-gray-400">(</span>
|
||||
{item.details.map((detail, detailIndex) => (
|
||||
<span key={`${item.label}-${detail}-${detailIndex}`}>
|
||||
{detailIndex > 0 && <span className="text-gray-400 dark:text-gray-500">、</span>}
|
||||
{renderSelectableText(detail, selectedText, onSelect)}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-gray-500 dark:text-gray-400">)</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
|
||||
function buildCollection(viewKey: ViewKey): CollectionArea[] {
|
||||
return knowledgeAreas.map((area) => {
|
||||
const rows = processes
|
||||
.filter((process) => process.knowledgeAreaId === area.id)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((process) => ({
|
||||
processId: process.id,
|
||||
processCode: process.code,
|
||||
processName: process.name,
|
||||
processNameEn: process.nameEn,
|
||||
items: uniqueItems(getRefsByView(process, viewKey).map((ref) => formatRef(ref, viewKey))),
|
||||
}))
|
||||
|
||||
return {
|
||||
id: area.id,
|
||||
order: area.order,
|
||||
name: area.name,
|
||||
nameEn: area.nameEn,
|
||||
color: area.color,
|
||||
rows,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function IttoCollectionsPage() {
|
||||
const [activeTab, setActiveTab] = useState<ViewKey>('inputs')
|
||||
const [selectedText, setSelectedText] = useState<string | null>(null)
|
||||
const collection = useMemo(() => buildCollection(activeTab), [activeTab])
|
||||
const activeLabel = tabs.find((tab) => tab.key === activeTab)?.label
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">输入 · 工具 · 输出</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">按知识领域与子过程汇总</p>
|
||||
</div>
|
||||
<div className="inline-flex rounded-xl bg-white p-1 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedText && (
|
||||
<div className="sticky top-16 z-20 -mx-1 rounded-xl border border-yellow-200/80 bg-white/90 px-3 py-2 shadow-sm backdrop-blur dark:border-yellow-500/25 dark:bg-gray-900/90">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-yellow-50 px-3 py-1.5 text-sm text-yellow-900 dark:bg-yellow-500/10 dark:text-yellow-100">
|
||||
<span className="font-medium">标签:{selectedText}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedText(null)}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-yellow-700 transition-colors hover:bg-yellow-100 hover:text-yellow-900 dark:text-yellow-200 dark:hover:bg-yellow-500/20"
|
||||
aria-label="清除标签"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-800">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-4"
|
||||
>
|
||||
{collection.map((area) => (
|
||||
<motion.section
|
||||
key={area.id}
|
||||
variants={itemVariants}
|
||||
className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 border-b border-gray-100 p-4 dark:border-gray-700"
|
||||
style={{ backgroundColor: `${area.color}10` }}
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white"
|
||||
style={{ backgroundColor: area.color }}
|
||||
>
|
||||
{area.order}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">{area.name}</h2>
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{area.nameEn}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/40">
|
||||
<tr>
|
||||
<th className="w-52 px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
子过程
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{activeLabel}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{area.rows.map((row) => {
|
||||
const rowMatched = row.items.some((item) => itemMatchesSelected(item, selectedText))
|
||||
return (
|
||||
<tr
|
||||
key={row.processId}
|
||||
className={`align-top transition-colors ${
|
||||
rowMatched ? 'bg-yellow-50/60 dark:bg-yellow-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className="mt-0.5 inline-flex shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: area.color }}
|
||||
>
|
||||
{row.processCode}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{row.processName}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{row.processNameEn}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
{renderCollectionItems(row.items, selectedText, setSelectedText)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.section>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, FileText, Wrench, FileOutput } from 'lucide-react'
|
||||
import { ArrowRight, FileText, Wrench, FileOutput, Lightbulb, Target } from 'lucide-react'
|
||||
import { knowledgeAreas, processesByKnowledgeArea, knowledgeAreaMap, processGroupMap } from '@/data'
|
||||
|
||||
const containerVariants = {
|
||||
@@ -13,11 +14,29 @@ const itemVariants = {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
// 跳过动画时的变体(立即显示)
|
||||
const skipAnimationVariants = {
|
||||
hidden: { opacity: 1, y: 0 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
export function KnowledgeAreasPage() {
|
||||
const { id } = useParams()
|
||||
const selectedKA = id ? knowledgeAreaMap.get(id) : null
|
||||
const processes = id ? processesByKnowledgeArea.get(id) || [] : []
|
||||
|
||||
// 检测是否应该跳过动画(返回页面时)
|
||||
const hasVisitedRef = useRef(false)
|
||||
const shouldSkipAnimation = hasVisitedRef.current
|
||||
|
||||
useEffect(() => {
|
||||
// 标记已访问,下次渲染时跳过动画
|
||||
hasVisitedRef.current = true
|
||||
}, [])
|
||||
|
||||
// 根据是否跳过动画选择变体
|
||||
const activeItemVariants = shouldSkipAnimation ? skipAnimationVariants : itemVariants
|
||||
|
||||
if (selectedKA) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -54,12 +73,40 @@ export function KnowledgeAreasPage() {
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{selectedKA.description}</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 敏捷裁剪因素 */}
|
||||
{selectedKA.tailoringFactors && selectedKA.tailoringFactors.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb size={18} className="text-amber-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">敏捷裁剪因素</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{selectedKA.tailoringFactors.map((factor, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-600 dark:text-amber-400 text-xs font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-1">{factor.title}</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">{factor.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 过程列表 - 紧凑版 */}
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-2">
|
||||
{processes.map((process) => {
|
||||
const pg = processGroupMap.get(process.processGroupId)
|
||||
return (
|
||||
<motion.div key={process.id} variants={itemVariants}>
|
||||
<motion.div key={process.id} variants={activeItemVariants}>
|
||||
<Link
|
||||
to={`/process/${process.id}`}
|
||||
className="group flex items-center gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md hover:border-gray-200 dark:hover:border-gray-600 transition-all"
|
||||
@@ -97,9 +144,18 @@ export function KnowledgeAreasPage() {
|
||||
// 显示知识领域列表 - 紧凑版双列
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">知识领域</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">PMBOK第6版定义的10大项目管理知识领域</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">10大项目管理知识领域</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/process-purpose-practice"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Target size={16} />
|
||||
子过程主要作用专项练习
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
@@ -109,7 +165,7 @@ export function KnowledgeAreasPage() {
|
||||
className="grid md:grid-cols-2 gap-3"
|
||||
>
|
||||
{knowledgeAreas.map((ka) => (
|
||||
<motion.div key={ka.id} variants={itemVariants}>
|
||||
<motion.div key={ka.id} variants={activeItemVariants}>
|
||||
<Link
|
||||
to={`/knowledge-areas/${ka.id}`}
|
||||
className="group flex items-center gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md transition-all"
|
||||
|
||||
813
src/pages/KnowledgeAreasTailoringPage.tsx
Normal file
@@ -0,0 +1,813 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Lightbulb, Maximize2, Minimize2, RotateCcw } from 'lucide-react'
|
||||
import { knowledgeAreas } from '@/data'
|
||||
import { InputArea } from '@/components/practice/InputArea'
|
||||
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||
import { useLongPress } from '@/hooks/useLongPress'
|
||||
import { announceToScreenReader, normalizeAnswer } from '@/utils/practice'
|
||||
import {
|
||||
generateTailoringPracticeItems,
|
||||
type TailoringPracticeItem,
|
||||
} from '@/utils/tailoringPractice'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
|
||||
type CharStatus = 'pending' | 'correct' | 'error'
|
||||
|
||||
const STORAGE_KEY = 'knowledge-areas-tailoring-practice-progress'
|
||||
|
||||
interface AnswerOverlayState {
|
||||
itemId: string
|
||||
answer: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface FactorTitleCellProps {
|
||||
item: TailoringPracticeItem
|
||||
isPracticeMode: boolean
|
||||
isAnswered: boolean
|
||||
isCurrent: boolean
|
||||
showAnswer: string | null
|
||||
onLongPress: (itemId: string) => void
|
||||
onLongPressEnd: () => void
|
||||
onClick: (itemId: string) => void
|
||||
}
|
||||
|
||||
function FactorTitleCell({
|
||||
item,
|
||||
isPracticeMode,
|
||||
isAnswered,
|
||||
isCurrent,
|
||||
showAnswer,
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
onClick,
|
||||
}: FactorTitleCellProps) {
|
||||
const longPressHandlers = useLongPress(item.id, {
|
||||
onLongPress,
|
||||
onLongPressEnd,
|
||||
})
|
||||
|
||||
if (!isPracticeMode) {
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-amber-100 px-2 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
{item.factorIndex + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium leading-6 text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(item.id)}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
aria-label={`裁剪因素:${item.title}`}
|
||||
className={clsx(
|
||||
'relative w-full px-4 py-4 text-left transition-all duration-200 focus:outline-none',
|
||||
isCurrent
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/20 ring-2 ring-inset ring-indigo-500'
|
||||
: 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/60',
|
||||
)}
|
||||
{...longPressHandlers}
|
||||
>
|
||||
<div className="flex min-h-[96px] items-start gap-3">
|
||||
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-amber-100 px-2 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
{item.factorIndex + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{isAnswered ? (
|
||||
<div className="text-sm font-medium leading-6 text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-6 items-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'block h-3 rounded-full bg-gray-200 dark:bg-gray-600',
|
||||
isCurrent && 'bg-indigo-200 dark:bg-indigo-700/70'
|
||||
)}
|
||||
style={{ width: `${Math.min(Math.max(item.title.length, 4) * 0.8, 8)}rem` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnswer && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/80 px-4">
|
||||
<span className="text-center text-sm font-medium text-white">
|
||||
{showAnswer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function KnowledgeAreasTailoringPage() {
|
||||
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
||||
const [practiceItems] = useState<TailoringPracticeItem[]>(() => generateTailoringPracticeItems())
|
||||
const practiceItemMap = useMemo(
|
||||
() => new Map(practiceItems.map((item) => [item.id, item])),
|
||||
[practiceItems]
|
||||
)
|
||||
const sortedKnowledgeAreas = useMemo(
|
||||
() => [...knowledgeAreas].sort((a, b) => a.order - b.order),
|
||||
[]
|
||||
)
|
||||
|
||||
const groupedKnowledgeAreas = useMemo(
|
||||
() =>
|
||||
sortedKnowledgeAreas.map((knowledgeArea) => ({
|
||||
...knowledgeArea,
|
||||
items: practiceItems.filter((item) => item.knowledgeAreaId === knowledgeArea.id),
|
||||
})),
|
||||
[practiceItems, sortedKnowledgeAreas]
|
||||
)
|
||||
|
||||
const loadProgress = useCallback(() => {
|
||||
const validIds = new Set(practiceItems.map((item) => item.id))
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) {
|
||||
return {
|
||||
answeredItems: new Map<string, boolean>(),
|
||||
currentItemId: practiceItems[0]?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(saved)
|
||||
const answeredEntries = Array.isArray(parsed.answeredItems)
|
||||
? parsed.answeredItems.filter(
|
||||
(entry: unknown) =>
|
||||
Array.isArray(entry) && entry[1] === true && typeof entry[0] === 'string' && validIds.has(entry[0])
|
||||
)
|
||||
: []
|
||||
|
||||
return {
|
||||
answeredItems: new Map<string, boolean>(answeredEntries),
|
||||
currentItemId:
|
||||
typeof parsed.currentItemId === 'string' && validIds.has(parsed.currentItemId)
|
||||
? parsed.currentItemId
|
||||
: practiceItems[0]?.id ?? null,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载裁剪因素练习进度失败:', error)
|
||||
return {
|
||||
answeredItems: new Map<string, boolean>(),
|
||||
currentItemId: practiceItems[0]?.id ?? null,
|
||||
}
|
||||
}
|
||||
}, [practiceItems])
|
||||
|
||||
const [isPracticeMode, setIsPracticeMode] = useState(false)
|
||||
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||
const [answeredItems, setAnsweredItems] = useState<Map<string, boolean>>(
|
||||
() => loadProgress().answeredItems
|
||||
)
|
||||
const [currentItemId, setCurrentItemId] = useState<string | null>(
|
||||
() => loadProgress().currentItemId
|
||||
)
|
||||
const [userInput, setUserInput] = useState<string[]>([])
|
||||
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const isComposingRef = useRef(false)
|
||||
const latestInputRef = useRef<string[]>([])
|
||||
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(null)
|
||||
const [showAnswerForItem, setShowAnswerForItem] = useState<AnswerOverlayState | null>(null)
|
||||
const [inputLocked, setInputLocked] = useState(false)
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
|
||||
const currentItem = currentItemId ? practiceItemMap.get(currentItemId) ?? null : null
|
||||
const answeredCount = answeredItems.size
|
||||
const totalCount = practiceItems.length
|
||||
|
||||
useEffect(() => {
|
||||
latestInputRef.current = userInput
|
||||
}, [userInput])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
answeredItems: Array.from(answeredItems.entries()),
|
||||
currentItemId,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('保存裁剪因素练习进度失败:', error)
|
||||
}
|
||||
}, [answeredItems, currentItemId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPracticeMode) {
|
||||
setShowAnswerForItem(null)
|
||||
setInputLocked(false)
|
||||
setIsComposing(false)
|
||||
isComposingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentItem && practiceItems[0]) {
|
||||
setCurrentItemId(practiceItems[0].id)
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentItem) return
|
||||
|
||||
setUserInput(new Array(currentItem.title.length).fill(''))
|
||||
setCharStatuses(new Array(currentItem.title.length).fill('pending'))
|
||||
}, [currentItem, isPracticeMode, practiceItems])
|
||||
|
||||
const restoreFocus = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll('.practice-input-area input')
|
||||
const firstEmptyInput = Array.from(inputs).find(
|
||||
(input) => !(input as HTMLInputElement).value
|
||||
) as HTMLInputElement | undefined
|
||||
|
||||
if (firstEmptyInput) {
|
||||
firstEmptyInput.focus()
|
||||
} else {
|
||||
;(inputs[0] as HTMLInputElement | undefined)?.focus()
|
||||
}
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const switchToItem = useCallback((item: TailoringPracticeItem) => {
|
||||
setCurrentItemId(item.id)
|
||||
setUserInput(new Array(item.title.length).fill(''))
|
||||
setCharStatuses(new Array(item.title.length).fill('pending'))
|
||||
setLastErrorTimestamp(null)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.querySelector(`[data-factor-id="${item.id}"]`) as HTMLElement | null
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
const firstInput = document.querySelector('.practice-input-area input') as HTMLInputElement | null
|
||||
firstInput?.focus()
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
const moveToNextItem = useCallback(() => {
|
||||
const currentIndex = practiceItems.findIndex((item) => item.id === currentItemId)
|
||||
if (currentIndex === -1 || currentIndex === practiceItems.length - 1) return
|
||||
switchToItem(practiceItems[currentIndex + 1])
|
||||
}, [currentItemId, practiceItems, switchToItem])
|
||||
|
||||
const moveToPrevItem = useCallback(() => {
|
||||
const currentIndex = practiceItems.findIndex((item) => item.id === currentItemId)
|
||||
if (currentIndex <= 0) return
|
||||
switchToItem(practiceItems[currentIndex - 1])
|
||||
}, [currentItemId, practiceItems, switchToItem])
|
||||
|
||||
const validateInput = useCallback(
|
||||
(input: string[]) => {
|
||||
if (!currentItem || !currentItemId) return
|
||||
|
||||
const originalAnswer = currentItem.title
|
||||
const normalizedInput = normalizeAnswer(input.join(''), false)
|
||||
const normalizedAnswer = currentItem.normalizedAnswer
|
||||
const normalizeChar = (char: string) => normalizeAnswer(char, false) || char
|
||||
|
||||
const nextStatuses = input.map((char, index) => {
|
||||
if (!char) return 'pending' as CharStatus
|
||||
const expectedChar = originalAnswer[index] || ''
|
||||
if (!expectedChar) return 'error' as CharStatus
|
||||
return normalizeChar(char) === normalizeChar(expectedChar)
|
||||
? ('correct' as CharStatus)
|
||||
: ('error' as CharStatus)
|
||||
})
|
||||
setCharStatuses(nextStatuses)
|
||||
|
||||
const isComplete = input.every((char) => char !== '') && input.length === originalAnswer.length
|
||||
const isCorrect = isComplete && normalizedInput === normalizedAnswer
|
||||
|
||||
if (isCorrect) {
|
||||
const alreadyAnswered = answeredItems.get(currentItemId) === true
|
||||
const nextAnsweredCount = alreadyAnswered ? answeredItems.size : answeredItems.size + 1
|
||||
|
||||
setAnsweredItems((prev) => new Map(prev).set(currentItemId, true))
|
||||
|
||||
if (nextAnsweredCount === practiceItems.length) {
|
||||
setTimeout(() => {
|
||||
setShowCelebration(true)
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
moveToNextItem()
|
||||
}, 300)
|
||||
} else if (isComplete) {
|
||||
setLastErrorTimestamp(Date.now())
|
||||
}
|
||||
},
|
||||
[answeredItems, currentItem, currentItemId, moveToNextItem, practiceItems.length]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(newInput: string[]) => {
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
|
||||
if (isComposingRef.current) return
|
||||
|
||||
validateInput(newInput)
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
const handleCompositionStart = useCallback((_index: number) => {
|
||||
isComposingRef.current = true
|
||||
setIsComposing(true)
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(index: number, value: string) => {
|
||||
isComposingRef.current = false
|
||||
setIsComposing(false)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const currentInput = latestInputRef.current
|
||||
const nextInput = [...currentInput]
|
||||
|
||||
if (value) {
|
||||
const chars = value.split('')
|
||||
for (let i = 0; i < chars.length && index + i < nextInput.length; i += 1) {
|
||||
nextInput[index + i] = chars[i]
|
||||
}
|
||||
} else {
|
||||
nextInput[index] = ''
|
||||
}
|
||||
|
||||
latestInputRef.current = nextInput
|
||||
setUserInput(nextInput)
|
||||
validateInput(nextInput)
|
||||
})
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentItem) return
|
||||
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const nextInput = new Array(currentItem.title.length).fill('')
|
||||
const chars = pastedText.split('')
|
||||
|
||||
for (let i = 0; i < Math.min(chars.length, currentItem.title.length); i += 1) {
|
||||
nextInput[i] = chars[i]
|
||||
}
|
||||
|
||||
handleInputChange(nextInput)
|
||||
},
|
||||
[currentItem, handleInputChange]
|
||||
)
|
||||
|
||||
const handleLongPress = useCallback(
|
||||
(itemId: string) => {
|
||||
const item = practiceItemMap.get(itemId)
|
||||
if (!item) return
|
||||
|
||||
setShowAnswerForItem({
|
||||
itemId,
|
||||
answer: item.title,
|
||||
expiresAt: Date.now() + 3000,
|
||||
})
|
||||
setInputLocked(true)
|
||||
announceToScreenReader('答案已显示')
|
||||
},
|
||||
[practiceItemMap]
|
||||
)
|
||||
|
||||
const handleLongPressEnd = useCallback(() => {
|
||||
if (!showAnswerForItem) return
|
||||
|
||||
setShowAnswerForItem(null)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已隐藏')
|
||||
restoreFocus()
|
||||
}, [restoreFocus, showAnswerForItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAnswerForItem) return
|
||||
|
||||
const remaining = showAnswerForItem.expiresAt - Date.now()
|
||||
if (remaining <= 0) {
|
||||
setShowAnswerForItem(null)
|
||||
setInputLocked(false)
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setShowAnswerForItem(null)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已自动隐藏')
|
||||
restoreFocus()
|
||||
}, remaining)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [restoreFocus, showAnswerForItem])
|
||||
|
||||
const handleFactorClick = useCallback(
|
||||
(itemId: string) => {
|
||||
const item = practiceItemMap.get(itemId)
|
||||
if (!item) return
|
||||
switchToItem(item)
|
||||
},
|
||||
[practiceItemMap, switchToItem]
|
||||
)
|
||||
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (!isFullScreen) {
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
setIsFullScreen((prev) => !prev)
|
||||
}, [isFullScreen, setSidebarOpen])
|
||||
|
||||
const handleResetProgress = useCallback(() => {
|
||||
if (!window.confirm('确定要清除当前练习进度吗?')) return
|
||||
|
||||
setAnsweredItems(new Map())
|
||||
setCurrentItemId(practiceItems[0]?.id ?? null)
|
||||
setUserInput([])
|
||||
setCharStatuses([])
|
||||
setLastErrorTimestamp(null)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
announceToScreenReader('练习进度已重置')
|
||||
}, [practiceItems])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const lowerKey = e.key.toLowerCase()
|
||||
|
||||
if (e.ctrlKey && lowerKey === 'h') {
|
||||
e.preventDefault()
|
||||
if (isPracticeMode && currentItemId && !showAnswerForItem) {
|
||||
handleLongPress(currentItemId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (showAnswerForItem) {
|
||||
handleLongPressEnd()
|
||||
return
|
||||
}
|
||||
|
||||
if (isFullScreen) {
|
||||
setIsFullScreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPracticeMode) {
|
||||
setUserInput((prev) => new Array(prev.length).fill(''))
|
||||
setCharStatuses((prev) => new Array(prev.length).fill('pending'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPracticeMode) return
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
moveToPrevItem()
|
||||
} else {
|
||||
moveToNextItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
const lowerKey = e.key.toLowerCase()
|
||||
if ((lowerKey === 'control' || lowerKey === 'h') && showAnswerForItem) {
|
||||
handleLongPressEnd()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [
|
||||
currentItemId,
|
||||
handleLongPress,
|
||||
handleLongPressEnd,
|
||||
isFullScreen,
|
||||
isPracticeMode,
|
||||
moveToNextItem,
|
||||
moveToPrevItem,
|
||||
showAnswerForItem,
|
||||
])
|
||||
|
||||
const renderToolbar = (compact = false) => (
|
||||
<>
|
||||
<div className={clsx('flex items-center justify-between gap-4', compact ? 'px-4 py-3' : 'px-5 py-4')}>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Lightbulb className="h-4 w-4 text-amber-500" />
|
||||
<span>10 个知识领域 · {totalCount} 项敏捷裁剪因素</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
结合知识领域查看裁剪关注点,并在练习模式中完成因素标题记忆。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isPracticeMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetProgress}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:text-red-600 dark:border-gray-700 dark:text-gray-300 dark:hover:text-red-400"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
清除进度
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(false)}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
!isPracticeMode
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(true)}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isPracticeMode
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
练习
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullScreen}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
{isFullScreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
{isFullScreen ? '退出全屏' : '全屏查看'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPracticeMode && (
|
||||
<div className={clsx('border-t border-gray-100 dark:border-gray-700', compact ? 'px-4 py-3' : 'px-5 py-3')}>
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>练习进度:{answeredCount} / {totalCount}</span>
|
||||
{currentItem && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
当前:{currentItem.knowledgeAreaName} · 第 {currentItem.factorIndex + 1} 项
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<motion.div
|
||||
className="h-2 rounded-full bg-indigo-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(answeredCount / totalCount) * 100}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
isFullScreen
|
||||
? 'fixed inset-0 z-50 flex flex-col bg-gray-50 dark:bg-gray-900'
|
||||
: 'flex min-h-[calc(100vh-7rem)] flex-col gap-6'
|
||||
)}
|
||||
>
|
||||
{isFullScreen && (
|
||||
<style>{`
|
||||
.tailoring-no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.tailoring-no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
{!isFullScreen && (
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">知识领域敏捷裁剪因素</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden border border-gray-100 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800',
|
||||
isFullScreen
|
||||
? 'flex min-h-0 flex-1 flex-col border-0 rounded-none shadow-none'
|
||||
: 'flex min-h-0 flex-1 flex-col rounded-2xl'
|
||||
)}
|
||||
>
|
||||
{renderToolbar(isFullScreen)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 overflow-y-auto overflow-x-hidden',
|
||||
isFullScreen && 'tailoring-no-scrollbar'
|
||||
)}
|
||||
>
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[12rem] sm:w-[14rem] lg:w-[16rem]" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 top-0 z-20 border border-gray-200 bg-gray-100 px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
知识领域
|
||||
</th>
|
||||
<th className="sticky top-0 z-10 border border-gray-200 bg-gray-100 px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
裁剪因素与说明
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedKnowledgeAreas.map((knowledgeArea) => {
|
||||
if (knowledgeArea.items.length === 0) return null
|
||||
|
||||
const isCurrentKnowledgeArea = currentItem?.knowledgeAreaId === knowledgeArea.id
|
||||
|
||||
return knowledgeArea.items.map((item, itemIndex) => {
|
||||
const isAnswered = answeredItems.get(item.id) === true
|
||||
const isCurrent = isPracticeMode && currentItemId === item.id
|
||||
const isShowingAnswer = showAnswerForItem?.itemId === item.id ? showAnswerForItem.answer : null
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
data-factor-id={item.id}
|
||||
className={clsx(isCurrent && 'bg-indigo-50/40 dark:bg-indigo-900/10')}
|
||||
>
|
||||
{itemIndex === 0 && (
|
||||
<td
|
||||
rowSpan={knowledgeArea.items.length}
|
||||
className="sticky left-0 z-10 border border-gray-200 px-4 py-4 align-top dark:border-gray-700"
|
||||
style={{
|
||||
backgroundColor: `${knowledgeArea.color}16`,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: knowledgeArea.color,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg text-sm font-bold text-white"
|
||||
style={{ backgroundColor: knowledgeArea.color }}
|
||||
>
|
||||
{knowledgeArea.order}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{knowledgeArea.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{knowledgeArea.items.length} 项裁剪因素
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{knowledgeArea.description}
|
||||
</p>
|
||||
{isPracticeMode && isCurrentKnowledgeArea && (
|
||||
<div className="rounded-lg bg-white/70 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900/20 dark:text-gray-300">
|
||||
当前正在练习该知识领域中的第 {currentItem ? currentItem.factorIndex + 1 : 1} 项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="border border-gray-200 p-0 align-top dark:border-gray-700">
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden bg-white dark:bg-gray-800',
|
||||
isCurrent && 'bg-indigo-50/30 dark:bg-indigo-900/10'
|
||||
)}
|
||||
>
|
||||
<FactorTitleCell
|
||||
item={item}
|
||||
isPracticeMode={isPracticeMode}
|
||||
isAnswered={isAnswered}
|
||||
isCurrent={isCurrent}
|
||||
showAnswer={isShowingAnswer}
|
||||
onLongPress={handleLongPress}
|
||||
onLongPressEnd={handleLongPressEnd}
|
||||
onClick={handleFactorClick}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{isPracticeMode && currentItem && (
|
||||
<div className="sticky bottom-0 z-10 border-t border-gray-200 bg-white/60 pb-8 backdrop-blur-md dark:border-gray-700 dark:bg-gray-800/60">
|
||||
<div className="px-6">
|
||||
<div className="border-b border-gray-200/50 py-3 dark:border-gray-700/50">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<InputArea
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => currentItemId && handleLongPress(currentItemId)}
|
||||
className="p-2 text-gray-500 transition-colors hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
|
||||
title="查看答案(长按表格中的因素标题也可以)"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 py-3 md:grid-cols-[220px,1fr] md:items-start">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{currentItem.knowledgeAreaName}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
第 {currentItem.factorIndex + 1} 项 · 长按标题或 Ctrl+H 查看答案 · Tab 切换
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||
{currentItem.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="aria-live-region" className="sr-only" aria-live="polite" aria-atomic="true" />
|
||||
|
||||
{showCelebration && <CelebrationAnimation onComplete={() => setShowCelebration(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
485
src/pages/LearningMapsPage.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { PointerEvent, WheelEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, Clipboard, Download, RotateCcw, X, ZoomIn } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
type LearningMapImage = {
|
||||
src: string
|
||||
fileName: string
|
||||
title: string
|
||||
}
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const IMAGE_DIRECTORY = '/learning-images/'
|
||||
const IMAGE_EXTENSIONS = /\.(png|jpe?g|webp|avif|gif)$/i
|
||||
|
||||
function imageHrefToItem(href: string): LearningMapImage | null {
|
||||
const cleanHref = href.split('?')[0].split('#')[0]
|
||||
const rawFileName = cleanHref.split('/').pop()
|
||||
|
||||
if (!rawFileName || rawFileName === '..' || !IMAGE_EXTENSIONS.test(rawFileName)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fileName = decodeURIComponent(rawFileName)
|
||||
const title = fileName.replace(/\.[^.]+$/, '')
|
||||
|
||||
return {
|
||||
src: `${IMAGE_DIRECTORY}${encodeURIComponent(fileName)}`,
|
||||
fileName,
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLearningMapImages() {
|
||||
const response = await fetch(IMAGE_DIRECTORY, { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error('无法读取学习图谱')
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const documentHtml = new DOMParser().parseFromString(html, 'text/html')
|
||||
|
||||
return Array.from(documentHtml.querySelectorAll<HTMLAnchorElement>('a'))
|
||||
.map((link) => imageHrefToItem(link.getAttribute('href') || ''))
|
||||
.filter((item): item is LearningMapImage => Boolean(item))
|
||||
.sort((a, b) => a.fileName.localeCompare(b.fileName, 'zh-CN', { numeric: true }))
|
||||
}
|
||||
|
||||
function getDistance(a: Point, b: Point) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function getMidpoint(a: Point, b: Point) {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
||||
}
|
||||
|
||||
function clampScale(value: number) {
|
||||
if (!Number.isFinite(value)) return 1
|
||||
return Math.min(5, Math.max(1, value))
|
||||
}
|
||||
|
||||
async function blobToPngBlob(blob: Blob) {
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('无法读取图片')
|
||||
|
||||
context.drawImage(bitmap, 0, 0)
|
||||
bitmap.close()
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((pngBlob) => {
|
||||
if (pngBlob) resolve(pngBlob)
|
||||
else reject(new Error('无法复制图片'))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
function LazyLearningMapImage({ image }: { image: LearningMapImage }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [shouldLoad, setShouldLoad] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || shouldLoad) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) return
|
||||
setShouldLoad(true)
|
||||
observer.disconnect()
|
||||
},
|
||||
{
|
||||
rootMargin: '700px 0px',
|
||||
threshold: 0.01,
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(container)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [shouldLoad])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative min-h-64 w-full overflow-hidden bg-gray-100 dark:bg-gray-900">
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 animate-pulse bg-gradient-to-br from-gray-100 via-gray-50 to-gray-200 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900" />
|
||||
)}
|
||||
{shouldLoad && (
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
className={clsx(
|
||||
'w-full object-contain transition duration-300 group-hover:scale-[1.01]',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LearningMapsPage() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [learningMapImages, setLearningMapImages] = useState<LearningMapImage[]>([])
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
const pointersRef = useRef(new Map<number, Point>())
|
||||
const lastDragPointRef = useRef<Point | null>(null)
|
||||
const pinchRef = useRef<{ distance: number; scale: number; midpoint: Point } | null>(null)
|
||||
|
||||
const activeImage = activeIndex === null ? null : learningMapImages[activeIndex]
|
||||
const hasPrevious = activeIndex !== null && activeIndex > 0
|
||||
const hasNext = activeIndex !== null && activeIndex < learningMapImages.length - 1
|
||||
|
||||
const viewerStyle = useMemo(
|
||||
() => ({ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }),
|
||||
[offset.x, offset.y, scale]
|
||||
)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
loadLearningMapImages()
|
||||
.then((images) => {
|
||||
if (!isMounted) return
|
||||
setLearningMapImages(images)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted) return
|
||||
setLearningMapImages([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMounted) return
|
||||
setIsLoadingImages(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showMessage = (text: string) => {
|
||||
setMessage(text)
|
||||
window.setTimeout(() => setMessage(''), 1800)
|
||||
}
|
||||
|
||||
const resetView = () => {
|
||||
setScale(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
pointersRef.current.clear()
|
||||
lastDragPointRef.current = null
|
||||
pinchRef.current = null
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const openViewer = (index: number) => {
|
||||
setActiveIndex(index)
|
||||
resetView()
|
||||
}
|
||||
|
||||
const closeViewer = () => {
|
||||
setActiveIndex(null)
|
||||
resetView()
|
||||
}
|
||||
|
||||
const goPrevious = () => {
|
||||
setActiveIndex((current) => (current === null || current <= 0 ? current : current - 1))
|
||||
resetView()
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
setActiveIndex((current) => (current === null || current >= learningMapImages.length - 1 ? current : current + 1))
|
||||
resetView()
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY > 0 ? -0.18 : 0.18
|
||||
setScale((current) => {
|
||||
const nextScale = clampScale(Number((current + delta).toFixed(2)))
|
||||
if (nextScale === 1) setOffset({ x: 0, y: 0 })
|
||||
return nextScale
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
const point = { x: event.clientX, y: event.clientY }
|
||||
pointersRef.current.set(event.pointerId, point)
|
||||
|
||||
if (pointersRef.current.size === 1) {
|
||||
lastDragPointRef.current = point
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
if (pointersRef.current.size === 2) {
|
||||
const [first, second] = Array.from(pointersRef.current.values())
|
||||
pinchRef.current = { distance: getDistance(first, second), scale, midpoint: getMidpoint(first, second) }
|
||||
setIsDragging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointersRef.current.has(event.pointerId)) return
|
||||
|
||||
const point = { x: event.clientX, y: event.clientY }
|
||||
pointersRef.current.set(event.pointerId, point)
|
||||
|
||||
if (pointersRef.current.size === 2 && pinchRef.current) {
|
||||
const [first, second] = Array.from(pointersRef.current.values())
|
||||
const distance = getDistance(first, second)
|
||||
const midpoint = getMidpoint(first, second)
|
||||
const pinch = pinchRef.current
|
||||
|
||||
if (!Number.isFinite(distance) || distance <= 0 || pinch.distance <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextScale = clampScale(pinch.scale * (distance / pinch.distance))
|
||||
const deltaX = midpoint.x - pinch.midpoint.x
|
||||
const deltaY = midpoint.y - pinch.midpoint.y
|
||||
|
||||
setScale(nextScale)
|
||||
setOffset((current) => ({
|
||||
x: current.x + deltaX * 0.8,
|
||||
y: current.y + deltaY * 0.8,
|
||||
}))
|
||||
pinchRef.current.midpoint = midpoint
|
||||
return
|
||||
}
|
||||
|
||||
if (pointersRef.current.size === 1 && lastDragPointRef.current && scale > 1) {
|
||||
const previous = lastDragPointRef.current
|
||||
setOffset((current) => ({ x: current.x + point.x - previous.x, y: current.y + point.y - previous.y }))
|
||||
lastDragPointRef.current = point
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.delete(event.pointerId)
|
||||
pinchRef.current = null
|
||||
|
||||
if (pointersRef.current.size === 1) {
|
||||
lastDragPointRef.current = Array.from(pointersRef.current.values())[0]
|
||||
setIsDragging(true)
|
||||
} else {
|
||||
lastDragPointRef.current = null
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
if (scale <= 1) {
|
||||
setScale(1)
|
||||
setOffset({ x: 0, y: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!activeImage) return
|
||||
const link = document.createElement('a')
|
||||
link.href = activeImage.src
|
||||
link.download = activeImage.fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
const copyImage = async () => {
|
||||
if (!activeImage) return
|
||||
|
||||
try {
|
||||
if (!navigator.clipboard || !window.ClipboardItem) {
|
||||
showMessage('可下载后保存使用')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(activeImage.src)
|
||||
const blob = await response.blob()
|
||||
const pngBlob = blob.type === 'image/png' ? blob : await blobToPngBlob(blob)
|
||||
await navigator.clipboard.write([new ClipboardItem({ [pngBlob.type]: pngBlob })])
|
||||
showMessage('图片已复制')
|
||||
} catch {
|
||||
showMessage('可下载后保存使用')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeViewer()
|
||||
if (event.key === 'ArrowLeft') goPrevious()
|
||||
if (event.key === 'ArrowRight') goNext()
|
||||
}
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">学习图谱</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">用一张图看清知识结构。</p>
|
||||
</div>
|
||||
|
||||
{isLoadingImages ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center text-sm text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700">
|
||||
正在加载学习图谱
|
||||
</div>
|
||||
) : learningMapImages.length > 0 ? (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.04 } } }}
|
||||
className="columns-1 gap-4 sm:columns-2 xl:columns-3"
|
||||
>
|
||||
{learningMapImages.map((image, index) => (
|
||||
<motion.button
|
||||
key={image.fileName}
|
||||
type="button"
|
||||
variants={{ hidden: { opacity: 0, y: 12 }, visible: { opacity: 1, y: 0 } }}
|
||||
onClick={() => openViewer(index)}
|
||||
className="group mb-4 block w-full break-inside-avoid overflow-hidden rounded-2xl bg-white text-left shadow-sm ring-1 ring-gray-200 transition duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200 dark:bg-gray-800 dark:ring-gray-700 dark:hover:ring-indigo-500/50"
|
||||
>
|
||||
<LazyLearningMapImage image={image} />
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-white p-8 text-center text-sm text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700">
|
||||
暂无学习图谱
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeImage && activeIndex !== null && (
|
||||
<div className="fixed inset-0 z-50 bg-gray-950/95 text-white">
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeViewer}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center text-sm text-white/80">
|
||||
{activeIndex + 1} / {learningMapImages.length}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="hidden h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20 sm:flex"
|
||||
>
|
||||
<RotateCcw size={17} />
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyImage}
|
||||
className="flex h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20"
|
||||
>
|
||||
<Clipboard size={17} />
|
||||
<span className="hidden sm:inline">复制</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadImage}
|
||||
className="flex h-10 items-center gap-2 rounded-full bg-white/10 px-3 text-sm text-white backdrop-blur transition hover:bg-white/20"
|
||||
>
|
||||
<Download size={17} />
|
||||
<span className="hidden sm:inline">下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="absolute left-1/2 top-16 z-20 -translate-x-1/2 rounded-full bg-white px-4 py-2 text-sm font-medium text-gray-900 shadow-xl">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrevious}
|
||||
disabled={!hasPrevious}
|
||||
className={clsx(
|
||||
'absolute left-3 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20 md:flex',
|
||||
!hasPrevious && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
aria-label="上一张"
|
||||
>
|
||||
<ChevronLeft size={30} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!hasNext}
|
||||
className={clsx(
|
||||
'absolute right-3 top-1/2 z-10 hidden h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition hover:bg-white/20 md:flex',
|
||||
!hasNext && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
aria-label="下一张"
|
||||
>
|
||||
<ChevronRight size={30} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full w-full items-center justify-center overflow-hidden px-3 pb-20 pt-20 touch-none',
|
||||
scale > 1 && isDragging ? 'cursor-grabbing' : scale > 1 ? 'cursor-grab' : 'cursor-zoom-in'
|
||||
)}
|
||||
onWheel={handleWheel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={() => {
|
||||
setScale((current) => (current === 1 ? 2.4 : 1))
|
||||
if (scale !== 1) setOffset({ x: 0, y: 0 })
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={activeImage.src}
|
||||
alt={activeImage.title}
|
||||
draggable={false}
|
||||
style={viewerStyle}
|
||||
className="max-h-full max-w-full select-none object-contain transition-transform duration-75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-xs text-white/75 backdrop-blur">
|
||||
<ZoomIn size={15} />
|
||||
<span>双指缩放 · 拖拽移动</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
696
src/pages/PerformanceDomainPracticePage.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
GitBranch,
|
||||
GraduationCap,
|
||||
Handshake,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||
import {
|
||||
performanceDomains,
|
||||
performanceDomainMap,
|
||||
} from '@/data/performance-domains'
|
||||
|
||||
type QuestionKind = 'expectedGoal' | 'keyPoint'
|
||||
type PracticeScope = 'all' | QuestionKind
|
||||
|
||||
interface PracticeQuestion {
|
||||
id: string
|
||||
domainId: string
|
||||
kind: QuestionKind
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PracticeProgress {
|
||||
scope: PracticeScope
|
||||
queue: string[]
|
||||
completedIds: string[]
|
||||
totalCount: number
|
||||
correctCount: number
|
||||
wrongCount: number
|
||||
}
|
||||
|
||||
interface AnswerState {
|
||||
selectedDomainId: string
|
||||
correctDomainId: string
|
||||
isCorrect: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'performance-domain-practice-progress-v3'
|
||||
const CORRECT_AUTO_NEXT_DELAY = 1000
|
||||
const CHALLENGE_RESTART_DELAY = 3000
|
||||
|
||||
const scopeOptions: Array<{ value: PracticeScope; label: string }> = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'expectedGoal', label: '预期目标' },
|
||||
{ value: 'keyPoint', label: '绩效要点' },
|
||||
]
|
||||
|
||||
const kindLabelMap: Record<QuestionKind, string> = {
|
||||
expectedGoal: '预期目标',
|
||||
keyPoint: '绩效要点',
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
PD01: Handshake,
|
||||
PD02: Users,
|
||||
PD03: GitBranch,
|
||||
PD04: Target,
|
||||
PD05: Workflow,
|
||||
PD06: Rocket,
|
||||
PD07: BarChart3,
|
||||
PD08: AlertTriangle,
|
||||
} as const
|
||||
|
||||
function shuffleArray<T>(items: T[]): T[] {
|
||||
const result = [...items]
|
||||
for (let i = result.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildQuestionBank(): PracticeQuestion[] {
|
||||
return performanceDomains.flatMap((domain) => {
|
||||
const detail = domain.detail
|
||||
if (!detail) return []
|
||||
|
||||
const expectedGoalQuestions = detail.expectedGoals.map((text, index) => ({
|
||||
id: `${domain.id}-goal-${index}`,
|
||||
domainId: domain.id,
|
||||
kind: 'expectedGoal' as const,
|
||||
text,
|
||||
}))
|
||||
|
||||
const keyPointQuestions = detail.keyPoints.map((text, index) => ({
|
||||
id: `${domain.id}-point-${index}`,
|
||||
domainId: domain.id,
|
||||
kind: 'keyPoint' as const,
|
||||
text,
|
||||
}))
|
||||
|
||||
return [...expectedGoalQuestions, ...keyPointQuestions]
|
||||
})
|
||||
}
|
||||
|
||||
function createProgress(
|
||||
scope: PracticeScope,
|
||||
questions: PracticeQuestion[]
|
||||
): PracticeProgress {
|
||||
const filteredIds = questions
|
||||
.filter((question) => (scope === 'all' ? true : question.kind === scope))
|
||||
.map((question) => question.id)
|
||||
|
||||
return {
|
||||
scope,
|
||||
queue: shuffleArray(filteredIds),
|
||||
completedIds: [],
|
||||
totalCount: filteredIds.length,
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress {
|
||||
const questionIdSet = new Set(questions.map((question) => question.id))
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return createProgress('all', questions)
|
||||
|
||||
const parsed = JSON.parse(saved) as Partial<PracticeProgress>
|
||||
const scope =
|
||||
parsed.scope === 'all' ||
|
||||
parsed.scope === 'expectedGoal' ||
|
||||
parsed.scope === 'keyPoint'
|
||||
? parsed.scope
|
||||
: 'all'
|
||||
|
||||
const filteredIds = questions
|
||||
.filter((question) => (scope === 'all' ? true : question.kind === scope))
|
||||
.map((question) => question.id)
|
||||
const validIdSet = new Set(filteredIds)
|
||||
|
||||
const completedIds = Array.isArray(parsed.completedIds)
|
||||
? parsed.completedIds.filter(
|
||||
(id, index, array): id is string =>
|
||||
questionIdSet.has(String(id)) &&
|
||||
validIdSet.has(String(id)) &&
|
||||
array.indexOf(id) === index
|
||||
)
|
||||
: []
|
||||
|
||||
const queue = Array.isArray(parsed.queue)
|
||||
? parsed.queue.filter(
|
||||
(id): id is string =>
|
||||
questionIdSet.has(String(id)) &&
|
||||
validIdSet.has(String(id)) &&
|
||||
!completedIds.includes(String(id))
|
||||
)
|
||||
: []
|
||||
|
||||
const missingIds = filteredIds.filter(
|
||||
(id) => !completedIds.includes(id) && !queue.includes(id)
|
||||
)
|
||||
|
||||
return {
|
||||
scope,
|
||||
queue: [...queue, ...shuffleArray(missingIds)],
|
||||
completedIds,
|
||||
totalCount: filteredIds.length,
|
||||
correctCount: Math.max(Number(parsed.correctCount) || 0, 0),
|
||||
wrongCount: Math.max(Number(parsed.wrongCount) || 0, 0),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载绩效域练习进度失败:', error)
|
||||
return createProgress('all', questions)
|
||||
}
|
||||
}
|
||||
|
||||
export default function PerformanceDomainPracticePage() {
|
||||
const questionBank = useMemo(() => buildQuestionBank(), [])
|
||||
const questionMap = useMemo(
|
||||
() => new Map(questionBank.map((question) => [question.id, question])),
|
||||
[questionBank]
|
||||
)
|
||||
|
||||
const [progress, setProgress] = useState<PracticeProgress>(() =>
|
||||
getStoredProgress(questionBank)
|
||||
)
|
||||
const [answerState, setAnswerState] = useState<AnswerState | null>(null)
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
const [challengeMode, setChallengeMode] = useState(false)
|
||||
const autoNextTimerRef = useRef<number | null>(null)
|
||||
const challengeRestartTimerRef = useRef<number | null>(null)
|
||||
|
||||
const currentQuestionId = progress.queue[0] ?? null
|
||||
const currentQuestion = currentQuestionId
|
||||
? questionMap.get(currentQuestionId) ?? null
|
||||
: null
|
||||
const currentDomain = currentQuestion
|
||||
? performanceDomainMap.get(currentQuestion.domainId) ?? null
|
||||
: null
|
||||
const selectedDomain = answerState
|
||||
? performanceDomainMap.get(answerState.selectedDomainId) ?? null
|
||||
: null
|
||||
const isFinished = progress.totalCount > 0 && progress.queue.length === 0
|
||||
const completedCount = progress.completedIds.length + (answerState?.isCorrect ? 1 : 0)
|
||||
const remainingCount = Math.max(progress.totalCount - completedCount, 0)
|
||||
const accuracyBase = progress.correctCount + progress.wrongCount
|
||||
const accuracy = accuracyBase > 0 ? Math.round((progress.correctCount / accuracyBase) * 100) : 0
|
||||
const progressPercent = progress.totalCount > 0 ? (completedCount / progress.totalCount) * 100 : 0
|
||||
const completedIdSet = useMemo(() => new Set(progress.completedIds), [progress.completedIds])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress))
|
||||
} catch (error) {
|
||||
console.error('保存绩效域练习进度失败:', error)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFinished) setShowCelebration(true)
|
||||
}, [isFinished])
|
||||
|
||||
useEffect(() => {
|
||||
if (!answerState?.isCorrect) return
|
||||
|
||||
if (autoNextTimerRef.current) {
|
||||
window.clearTimeout(autoNextTimerRef.current)
|
||||
}
|
||||
|
||||
autoNextTimerRef.current = window.setTimeout(() => {
|
||||
advanceToNext(true)
|
||||
}, CORRECT_AUTO_NEXT_DELAY)
|
||||
|
||||
return () => {
|
||||
if (autoNextTimerRef.current) {
|
||||
window.clearTimeout(autoNextTimerRef.current)
|
||||
autoNextTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [answerState])
|
||||
|
||||
const restartPractice = (scope = progress.scope) => {
|
||||
if (autoNextTimerRef.current) {
|
||||
window.clearTimeout(autoNextTimerRef.current)
|
||||
autoNextTimerRef.current = null
|
||||
}
|
||||
if (challengeRestartTimerRef.current) {
|
||||
window.clearTimeout(challengeRestartTimerRef.current)
|
||||
challengeRestartTimerRef.current = null
|
||||
}
|
||||
setProgress(createProgress(scope, questionBank))
|
||||
setAnswerState(null)
|
||||
setShowCelebration(false)
|
||||
}
|
||||
|
||||
const switchScope = (scope: PracticeScope) => {
|
||||
if (scope === progress.scope) return
|
||||
restartPractice(scope)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!challengeMode || !answerState || answerState.isCorrect) return
|
||||
|
||||
if (challengeRestartTimerRef.current) {
|
||||
window.clearTimeout(challengeRestartTimerRef.current)
|
||||
}
|
||||
|
||||
challengeRestartTimerRef.current = window.setTimeout(() => {
|
||||
restartPractice(progress.scope)
|
||||
}, CHALLENGE_RESTART_DELAY)
|
||||
|
||||
return () => {
|
||||
if (challengeRestartTimerRef.current) {
|
||||
window.clearTimeout(challengeRestartTimerRef.current)
|
||||
challengeRestartTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [answerState, challengeMode, progress.scope])
|
||||
|
||||
const advanceToNext = (isCorrect: boolean) => {
|
||||
if (!currentQuestionId) return
|
||||
|
||||
if (autoNextTimerRef.current) {
|
||||
window.clearTimeout(autoNextTimerRef.current)
|
||||
autoNextTimerRef.current = null
|
||||
}
|
||||
if (challengeRestartTimerRef.current) {
|
||||
window.clearTimeout(challengeRestartTimerRef.current)
|
||||
challengeRestartTimerRef.current = null
|
||||
}
|
||||
setAnswerState(null)
|
||||
setProgress((prev) => {
|
||||
if (prev.queue[0] !== currentQuestionId) return prev
|
||||
|
||||
const [, ...restQueue] = prev.queue
|
||||
if (isCorrect) {
|
||||
return {
|
||||
...prev,
|
||||
queue: restQueue,
|
||||
completedIds: prev.completedIds.includes(currentQuestionId)
|
||||
? prev.completedIds
|
||||
: [...prev.completedIds, currentQuestionId],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
queue: [...restQueue, currentQuestionId],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelect = (domainId: string) => {
|
||||
if (!currentQuestion || answerState) return
|
||||
|
||||
const isCorrect = domainId === currentQuestion.domainId
|
||||
setAnswerState({
|
||||
selectedDomainId: domainId,
|
||||
correctDomainId: currentQuestion.domainId,
|
||||
isCorrect,
|
||||
})
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
correctCount: prev.correctCount + (isCorrect ? 1 : 0),
|
||||
wrongCount: prev.wrongCount + (isCorrect ? 0 : 1),
|
||||
}))
|
||||
}
|
||||
|
||||
const renderOptionButton = (domainId: string) => {
|
||||
const domain = performanceDomainMap.get(domainId)
|
||||
if (!domain) return null
|
||||
|
||||
const Icon = iconMap[domain.id as keyof typeof iconMap]
|
||||
const isAnswerShown = Boolean(answerState)
|
||||
const isSelected = answerState?.selectedDomainId === domain.id
|
||||
const isCorrectDomain = answerState?.correctDomainId === domain.id
|
||||
const isCorrectSelected = isAnswerShown && isSelected && answerState?.isCorrect
|
||||
const isWrongSelected = isAnswerShown && isSelected && !answerState?.isCorrect
|
||||
const shouldHighlightCorrect = isAnswerShown && isCorrectDomain
|
||||
const getKindProgress = (kind: QuestionKind) => {
|
||||
const ids = questionBank
|
||||
.filter((question) => question.domainId === domain.id && question.kind === kind)
|
||||
.map((question) => question.id)
|
||||
const completed = ids.filter((id) => completedIdSet.has(id)).length
|
||||
return {
|
||||
completed,
|
||||
total: ids.length,
|
||||
percent: ids.length > 0 ? (completed / ids.length) * 100 : 0,
|
||||
}
|
||||
}
|
||||
const expectedGoalProgress = getKindProgress('expectedGoal')
|
||||
const keyPointProgress = getKindProgress('keyPoint')
|
||||
const expectedGoalDone = expectedGoalProgress.total > 0 && expectedGoalProgress.completed >= expectedGoalProgress.total
|
||||
const keyPointDone = keyPointProgress.total > 0 && keyPointProgress.completed >= keyPointProgress.total
|
||||
const domainDone = expectedGoalDone && keyPointDone
|
||||
const scopedDone = progress.scope === 'expectedGoal'
|
||||
? expectedGoalDone
|
||||
: progress.scope === 'keyPoint'
|
||||
? keyPointDone
|
||||
: domainDone
|
||||
const shouldKeepDisabled = scopedDone && !shouldHighlightCorrect && !isWrongSelected
|
||||
const optionDisabled = isAnswerShown || scopedDone
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={domain.id}
|
||||
type="button"
|
||||
whileTap={!optionDisabled ? { scale: 0.98 } : undefined}
|
||||
onClick={() => handleSelect(domain.id)}
|
||||
disabled={optionDisabled}
|
||||
className={clsx(
|
||||
'relative flex h-16 items-center justify-center overflow-hidden rounded-xl border bg-white p-1 text-center shadow-sm transition-colors dark:bg-gray-800 sm:h-[112px] sm:flex-col sm:items-stretch sm:justify-start sm:gap-2 sm:p-3 sm:text-left',
|
||||
'border-gray-100 dark:border-gray-700 focus:outline-none',
|
||||
optionDisabled && 'cursor-default',
|
||||
shouldKeepDisabled && 'bg-gray-50 opacity-55 dark:bg-gray-800/70',
|
||||
shouldHighlightCorrect &&
|
||||
'border-emerald-500 bg-emerald-100 text-emerald-900 shadow-[inset_0_0_0_3px_rgba(16,185,129,0.78),0_0_0_1px_rgba(16,185,129,0.55)] dark:border-emerald-400 dark:bg-emerald-900/50 dark:text-emerald-50 dark:shadow-[inset_0_0_0_3px_rgba(52,211,153,0.65),0_0_0_1px_rgba(52,211,153,0.45)] sm:bg-emerald-50 sm:shadow-[inset_0_0_0_2px_rgba(52,211,153,0.65)] sm:dark:bg-emerald-950/30 sm:dark:shadow-[inset_0_0_0_2px_rgba(16,185,129,0.45)]',
|
||||
isWrongSelected &&
|
||||
'border-rose-300 bg-rose-50 shadow-[inset_0_0_0_2px_rgba(251,113,133,0.65)] dark:border-rose-600 dark:bg-rose-950/30 dark:shadow-[inset_0_0_0_2px_rgba(244,63,94,0.45)]'
|
||||
)}
|
||||
>
|
||||
<div className="hidden items-center gap-3 sm:flex">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-white',
|
||||
shouldKeepDisabled && 'grayscale'
|
||||
)}
|
||||
style={{ backgroundColor: domain.color }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-8">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{domain.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{domain.nameEn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
'text-2xl font-bold leading-none text-gray-900 dark:text-white sm:hidden',
|
||||
shouldHighlightCorrect && 'text-emerald-800 dark:text-emerald-50',
|
||||
isWrongSelected && 'text-rose-800 dark:text-rose-50'
|
||||
)}>
|
||||
{domain.name.charAt(0)}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
{(() => {
|
||||
const activeProgress = progress.scope === 'expectedGoal' ? expectedGoalProgress : keyPointProgress
|
||||
const activeLabel = progress.scope === 'expectedGoal' ? '目标' : '要点'
|
||||
const isFull = activeProgress.total > 0 && activeProgress.completed >= activeProgress.total
|
||||
|
||||
if (progress.scope === 'all') {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ label: '目标', data: expectedGoalProgress },
|
||||
{ label: '要点', data: keyPointProgress },
|
||||
].map((item) => {
|
||||
const itemFull = item.data.total > 0 && item.data.completed >= item.data.total
|
||||
return (
|
||||
<div key={item.label} className="min-w-0">
|
||||
<div className="mb-1 flex items-center justify-between gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<span>{item.label}</span>
|
||||
<span>{item.data.completed}/{item.data.total}</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
itemFull ? 'bg-gray-300 dark:bg-gray-500' : 'bg-indigo-500'
|
||||
)}
|
||||
style={{ width: `${item.data.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<span>{activeLabel}</span>
|
||||
<span>{activeProgress.completed}/{activeProgress.total}</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
isFull ? 'bg-gray-300 dark:bg-gray-500' : 'bg-indigo-500'
|
||||
)}
|
||||
style={{ width: `${activeProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute right-1 top-1 flex h-4 w-8 items-center justify-end sm:right-3 sm:top-3 sm:h-5 sm:w-12">
|
||||
{isCorrectSelected && (
|
||||
<CheckCircle2 className="text-emerald-600 dark:text-emerald-300" size={20} />
|
||||
)}
|
||||
{isWrongSelected && (
|
||||
<XCircle className="text-rose-500" size={16} />
|
||||
)}
|
||||
{shouldHighlightCorrect && !isCorrectSelected && (
|
||||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 sm:px-2 sm:text-xs">
|
||||
正确
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-4">
|
||||
{showCelebration && (
|
||||
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link to="/performance-domains" className="hover:text-indigo-600 dark:hover:text-indigo-400">
|
||||
绩效域
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 dark:text-white">练习</span>
|
||||
</nav>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">八大绩效域练习</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
根据预期目标和绩效要点,判断对应的绩效域。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={challengeMode}
|
||||
onClick={() => setChallengeMode((value) => !value)}
|
||||
className={clsx(
|
||||
'rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
||||
challengeMode
|
||||
? 'border-indigo-500 bg-indigo-600 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
挑战模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restartPractice()}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
重新开始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopeOptions.map((option) => {
|
||||
const isActive = option.value === progress.scope
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => switchScope(option.value)}
|
||||
className={clsx(
|
||||
'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>已完成 <b className="text-gray-900 dark:text-white">{completedCount}/{progress.totalCount}</b></span>
|
||||
<span>剩余 <b className="text-gray-900 dark:text-white">{remainingCount}</b></span>
|
||||
<span>正确率 <b className="text-gray-900 dark:text-white">{accuracy}%</b></span>
|
||||
<span>错误 <b className="text-gray-900 dark:text-white">{progress.wrongCount}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-600 transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFinished ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<div className="bg-indigo-600 px-6 py-7 text-white">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-sm">
|
||||
<GraduationCap size={16} />
|
||||
本轮完成
|
||||
</div>
|
||||
<h2 className="mt-4 text-2xl font-bold">八大绩效域练习已完成</h2>
|
||||
<p className="mt-2 text-sm text-white/80">
|
||||
共完成 {progress.totalCount} 题,错误 {progress.wrongCount} 次。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-6 py-6 md:grid-cols-3">
|
||||
<div className="rounded-xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">总题数</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{progress.totalCount}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">正确率</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{accuracy}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">当前范围</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{scopeOptions.find((option) => option.value === progress.scope)?.label ?? '全部'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 px-6 pb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restartPractice()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
再来一轮
|
||||
</button>
|
||||
<Link
|
||||
to="/performance-domains"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
返回绩效域
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : currentQuestion ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_1.45fr]">
|
||||
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full bg-indigo-50 px-3 py-1 text-2xl font-semibold leading-relaxed text-indigo-600 dark:bg-indigo-900/40 dark:text-indigo-300 md:text-3xl">
|
||||
{kindLabelMap[currentQuestion.kind]}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{completedCount + 1} / {progress.totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[180px] items-center py-6 md:min-h-[220px] xl:min-h-[300px]">
|
||||
<p className="text-2xl font-semibold leading-relaxed text-gray-900 dark:text-white md:text-3xl">
|
||||
{currentQuestion.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'h-16 overflow-hidden rounded-xl border px-4 py-3 transition-colors sm:h-[132px]',
|
||||
answerState?.isCorrect
|
||||
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30'
|
||||
: answerState
|
||||
? 'border-rose-200 bg-rose-50 dark:border-rose-800 dark:bg-rose-950/30'
|
||||
: 'border-gray-100 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/30'
|
||||
)}
|
||||
>
|
||||
{answerState && currentDomain ? (
|
||||
<div className="flex h-full items-center justify-end sm:flex-col sm:items-stretch sm:justify-between sm:gap-3">
|
||||
<div className="hidden sm:block">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 text-sm font-semibold',
|
||||
answerState.isCorrect
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-rose-700 dark:text-rose-300'
|
||||
)}
|
||||
>
|
||||
{answerState.isCorrect ? <CheckCircle2 size={17} /> : <XCircle size={17} />}
|
||||
{answerState.isCorrect
|
||||
? `正确:${currentDomain.name}`
|
||||
: `错误:你选了 ${selectedDomain?.name ?? ''}`}
|
||||
</div>
|
||||
{!answerState.isCorrect && (
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
正确答案:{currentDomain.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => advanceToNext(answerState.isCorrect)}
|
||||
className="self-end rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
|
||||
>
|
||||
下一个
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-28 grid grid-cols-4 gap-2 sm:mb-0 sm:grid-cols-2 sm:gap-3">
|
||||
{performanceDomains.map((domain) => renderOptionButton(domain.id))}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
src/pages/PerformanceDomainsPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowRight,
|
||||
GitBranch,
|
||||
GraduationCap,
|
||||
Handshake,
|
||||
Rocket,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Link2,
|
||||
SearchCheck,
|
||||
} from 'lucide-react'
|
||||
import { performanceDomains, performanceDomainMap } from '@/data/performance-domains'
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.03 } },
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
// 返回页面时直接显示内容,避免移动端浏览器恢复页面时动画停留在透明状态
|
||||
const skipAnimationVariants = {
|
||||
hidden: { opacity: 1, y: 0 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
PD01: Handshake,
|
||||
PD02: Users,
|
||||
PD03: GitBranch,
|
||||
PD04: Target,
|
||||
PD05: Workflow,
|
||||
PD06: Rocket,
|
||||
PD07: BarChart3,
|
||||
PD08: AlertTriangle,
|
||||
} as const
|
||||
|
||||
export function PerformanceDomainsPage() {
|
||||
const { id } = useParams()
|
||||
const selectedDomain = id ? performanceDomainMap.get(id) : null
|
||||
|
||||
// Android Chrome 返回页面时可能从 bfcache 恢复,Framer Motion 子项偶发停在 hidden 状态。
|
||||
// 首次进入保留入场动画;页面恢复或再次渲染时跳过子项动画,确保列表始终可见。
|
||||
const hasVisitedRef = useRef(false)
|
||||
const shouldSkipAnimation = hasVisitedRef.current
|
||||
|
||||
useEffect(() => {
|
||||
hasVisitedRef.current = true
|
||||
}, [])
|
||||
|
||||
const activeItemVariants = shouldSkipAnimation ? skipAnimationVariants : itemVariants
|
||||
|
||||
if (selectedDomain?.detail) {
|
||||
const Icon = iconMap[selectedDomain.id as keyof typeof iconMap]
|
||||
const detail = selectedDomain.detail
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link to="/performance-domains" className="hover:text-indigo-600 dark:hover:text-indigo-400">绩效域</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 dark:text-white">{selectedDomain.name}</span>
|
||||
</nav>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-4"
|
||||
style={{ backgroundColor: `${selectedDomain.color}15` }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-lg text-white shrink-0"
|
||||
style={{ backgroundColor: selectedDomain.color }}
|
||||
>
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{selectedDomain.name}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{selectedDomain.nameEn}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/performance-domains/practice"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-white/70 transition-colors hover:bg-white dark:bg-gray-900/40 dark:text-gray-200 dark:ring-gray-700 dark:hover:bg-gray-900/70"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
练习
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl bg-white dark:bg-gray-800 p-4 shadow-sm border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CheckCircle2 size={18} className="text-emerald-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">预期目标</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{detail.expectedGoals.map((goal, index) => (
|
||||
<div key={goal} className="flex gap-3">
|
||||
<div
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: selectedDomain.color }}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{goal}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="rounded-xl bg-white dark:bg-gray-800 p-4 shadow-sm border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Target size={18} className="text-amber-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">绩效要点</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{detail.keyPoints.map((point, index) => (
|
||||
<div key={index} className="rounded-lg bg-gray-50 dark:bg-gray-700/40 px-3 py-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{point}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
</div>
|
||||
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-xl bg-white dark:bg-gray-800 p-4 shadow-sm border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Link2 size={18} className="text-indigo-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">与其他绩效域的相互作用</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{detail.interactions.map((item, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<div className="mt-1 h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: selectedDomain.color }} />
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="rounded-xl bg-white dark:bg-gray-800 p-4 shadow-sm border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<SearchCheck size={18} className="text-cyan-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">检查方法</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{detail.checks.map((check) => (
|
||||
<div key={check.goal} className="rounded-lg border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 text-sm font-semibold text-white" style={{ backgroundColor: selectedDomain.color }}>
|
||||
{check.goal}
|
||||
</div>
|
||||
<div className="space-y-2 bg-gray-50 dark:bg-gray-900/40 px-4 py-3">
|
||||
{check.indicators.map((indicator, index) => (
|
||||
<div key={index} className="flex gap-3">
|
||||
<div className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400" />
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{indicator}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">绩效域</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">8大项目绩效域</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/performance-domains/practice"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-500"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
开始练习
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid md:grid-cols-2 gap-3"
|
||||
>
|
||||
{performanceDomains.map((domain, index) => {
|
||||
const Icon = iconMap[domain.id as keyof typeof iconMap]
|
||||
const canOpen = Boolean(domain.detail)
|
||||
|
||||
return (
|
||||
<motion.div key={domain.id} variants={activeItemVariants}>
|
||||
{canOpen ? (
|
||||
<Link
|
||||
to={`/performance-domains/${domain.id}`}
|
||||
className="group flex items-center gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md transition-all"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: domain.color }}
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg text-white shrink-0"
|
||||
style={{ backgroundColor: domain.color }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-gray-400 dark:text-gray-500">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">{domain.name}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{domain.nameEn}</p>
|
||||
</div>
|
||||
<ArrowRight size={16} className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-0.5 transition-all shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: domain.color }}
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg text-white shrink-0"
|
||||
style={{ backgroundColor: domain.color }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-gray-400 dark:text-gray-500">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">{domain.name}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{domain.nameEn}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
636
src/pages/PrinciplesPage.tsx
Normal file
@@ -0,0 +1,636 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { normalizeAnswer, announceToScreenReader } from '@/utils/practice'
|
||||
import { InputArea } from '@/components/practice/InputArea'
|
||||
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||
import {
|
||||
principleGroups,
|
||||
principles,
|
||||
principleMap,
|
||||
getNextUnanswered,
|
||||
type Principle,
|
||||
} from '@/data/principles'
|
||||
|
||||
type CharStatus = 'pending' | 'correct' | 'error'
|
||||
|
||||
const STORAGE_KEY = 'principles-practice-progress'
|
||||
|
||||
export default function PrinciplesPage() {
|
||||
const [isPracticeMode, setIsPracticeMode] = useState(false)
|
||||
|
||||
// 答题进度
|
||||
const [answeredCells, setAnsweredCells] = useState<Map<string, boolean>>(
|
||||
() => new Map()
|
||||
)
|
||||
const [currentPrincipleId, setCurrentPrincipleId] = useState<string | null>(
|
||||
() => principles[0]?.id ?? null
|
||||
)
|
||||
|
||||
// 输入状态
|
||||
const [userInput, setUserInput] = useState<string[]>([])
|
||||
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const isComposingRef = useRef(false)
|
||||
const latestInputRef = useRef<string[]>([])
|
||||
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(null)
|
||||
|
||||
// 显示答案
|
||||
const [showAnswerForCell, setShowAnswerForCell] = useState<{
|
||||
principleId: string
|
||||
answer: string
|
||||
expiresAt: number
|
||||
} | null>(null)
|
||||
const [inputLocked, setInputLocked] = useState(false)
|
||||
|
||||
// 庆祝动画
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
|
||||
// 长按计时器
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
|
||||
const currentPrinciple = currentPrincipleId
|
||||
? (principleMap.get(currentPrincipleId) ?? null)
|
||||
: null
|
||||
|
||||
const answeredCount = principles.filter((p) => answeredCells.get(p.id)).length
|
||||
|
||||
// ─── 焦点恢复 ────────────────────────────────────────────────
|
||||
const restoreFocus = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll('.practice-input-area input')
|
||||
const firstEmpty = Array.from(inputs).find(
|
||||
(el) => !(el as HTMLInputElement).value
|
||||
) as HTMLInputElement | undefined
|
||||
if (firstEmpty) {
|
||||
firstEmpty.focus()
|
||||
} else {
|
||||
(inputs[0] as HTMLInputElement)?.focus()
|
||||
}
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
// ─── 长按显示答案 ─────────────────────────────────────────────
|
||||
const handleLongPress = useCallback((principleId: string) => {
|
||||
const principle = principleMap.get(principleId)
|
||||
if (!principle) return
|
||||
setShowAnswerForCell({
|
||||
principleId,
|
||||
answer: principle.name,
|
||||
expiresAt: Date.now() + 3000,
|
||||
})
|
||||
setInputLocked(true)
|
||||
announceToScreenReader('答案已显示')
|
||||
}, [])
|
||||
|
||||
const handleLongPressEnd = useCallback(() => {
|
||||
if (showAnswerForCell) {
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已隐藏')
|
||||
restoreFocus()
|
||||
}
|
||||
}, [showAnswerForCell, restoreFocus])
|
||||
|
||||
// 答案自动过期
|
||||
useEffect(() => {
|
||||
if (!showAnswerForCell) return
|
||||
const remaining = showAnswerForCell.expiresAt - Date.now()
|
||||
if (remaining <= 0) {
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已自动隐藏')
|
||||
restoreFocus()
|
||||
}, remaining)
|
||||
return () => clearTimeout(timer)
|
||||
}, [showAnswerForCell, restoreFocus])
|
||||
|
||||
// ─── 切换题目 ─────────────────────────────────────────────────
|
||||
const switchToPrinciple = useCallback((principle: Principle) => {
|
||||
setCurrentPrincipleId(principle.id)
|
||||
setUserInput(new Array(principle.name.length).fill(''))
|
||||
setCharStatuses(new Array(principle.name.length).fill('pending'))
|
||||
setLastErrorTimestamp(null)
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-principle-id="${principle.id}"]`
|
||||
) as HTMLElement | null
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
setTimeout(() => {
|
||||
const firstInput = document.querySelector(
|
||||
'.practice-input-area input'
|
||||
) as HTMLInputElement | null
|
||||
firstInput?.focus()
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
const moveToNextPrinciple = useCallback(() => {
|
||||
const idx = principles.findIndex((p) => p.id === currentPrincipleId)
|
||||
if (idx === -1 || idx >= principles.length - 1) return
|
||||
switchToPrinciple(principles[idx + 1])
|
||||
}, [currentPrincipleId, switchToPrinciple])
|
||||
|
||||
const moveToPrevPrinciple = useCallback(() => {
|
||||
const idx = principles.findIndex((p) => p.id === currentPrincipleId)
|
||||
if (idx <= 0) return
|
||||
switchToPrinciple(principles[idx - 1])
|
||||
}, [currentPrincipleId, switchToPrinciple])
|
||||
|
||||
// ─── 进度持久化 ───────────────────────────────────────────────
|
||||
const loadProgress = useCallback(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved)
|
||||
return {
|
||||
answeredCells: new Map<string, boolean>(data.answeredCells || []),
|
||||
currentPrincipleId: data.currentPrincipleId || principles[0]?.id || null,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载原则练习进度失败:', e)
|
||||
}
|
||||
return {
|
||||
answeredCells: new Map<string, boolean>(),
|
||||
currentPrincipleId: principles[0]?.id ?? null,
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 进入练习模式时恢复进度
|
||||
useEffect(() => {
|
||||
if (!isPracticeMode) return
|
||||
const progress = loadProgress()
|
||||
setAnsweredCells(progress.answeredCells)
|
||||
setCurrentPrincipleId(progress.currentPrincipleId)
|
||||
}, [isPracticeMode, loadProgress])
|
||||
|
||||
// 保存进度
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
answeredCells: Array.from(answeredCells.entries()),
|
||||
currentPrincipleId,
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('保存原则练习进度失败:', e)
|
||||
}
|
||||
}, [answeredCells, currentPrincipleId])
|
||||
|
||||
// currentPrincipleId 变化时初始化输入框(安全网,兼容进度恢复场景)
|
||||
useEffect(() => {
|
||||
if (!isPracticeMode) return
|
||||
const principle = currentPrincipleId
|
||||
? (principleMap.get(currentPrincipleId) ?? null)
|
||||
: null
|
||||
if (!principle) return
|
||||
setUserInput(new Array(principle.name.length).fill(''))
|
||||
setCharStatuses(new Array(principle.name.length).fill('pending'))
|
||||
}, [currentPrincipleId, isPracticeMode])
|
||||
|
||||
// 同步输入快照
|
||||
useEffect(() => {
|
||||
latestInputRef.current = userInput
|
||||
}, [userInput])
|
||||
|
||||
// ─── 输入验证 ─────────────────────────────────────────────────
|
||||
const validateInput = useCallback(
|
||||
(input: string[]) => {
|
||||
if (!currentPrinciple || !currentPrincipleId) return
|
||||
|
||||
const originalAnswer = currentPrinciple.name
|
||||
const normalizedInput = normalizeAnswer(input.join(''), false)
|
||||
const normalizedAnswer = normalizeAnswer(originalAnswer, false)
|
||||
const normalizeChar = (char: string) => normalizeAnswer(char, false) || char
|
||||
|
||||
const newStatuses = input.map((char, i) => {
|
||||
if (!char) return 'pending' as CharStatus
|
||||
const expected = originalAnswer[i] || ''
|
||||
if (!expected) return 'error' as CharStatus
|
||||
return normalizeChar(char) === normalizeChar(expected)
|
||||
? ('correct' as CharStatus)
|
||||
: ('error' as CharStatus)
|
||||
})
|
||||
setCharStatuses(newStatuses)
|
||||
|
||||
const isComplete =
|
||||
input.every((c) => c !== '') && input.length === originalAnswer.length
|
||||
const isCorrect = isComplete && normalizedInput === normalizedAnswer
|
||||
|
||||
if (isCorrect) {
|
||||
const nextAnswered = new Map(answeredCells).set(currentPrincipleId, true)
|
||||
setAnsweredCells(nextAnswered)
|
||||
|
||||
const allDone = principles.every((p) => nextAnswered.get(p.id))
|
||||
if (allDone) {
|
||||
setTimeout(() => setShowCelebration(true), 300)
|
||||
} else {
|
||||
const next = getNextUnanswered(currentPrincipleId, nextAnswered)
|
||||
if (next) {
|
||||
setTimeout(() => switchToPrinciple(next), 300)
|
||||
}
|
||||
}
|
||||
} else if (isComplete) {
|
||||
setLastErrorTimestamp(Date.now())
|
||||
}
|
||||
},
|
||||
[answeredCells, currentPrinciple, currentPrincipleId, switchToPrinciple]
|
||||
)
|
||||
|
||||
// ─── 输入处理 ─────────────────────────────────────────────────
|
||||
const handleInputChange = useCallback(
|
||||
(newInput: string[]) => {
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
if (isComposingRef.current) return
|
||||
validateInput(newInput)
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
const handleCompositionStart = useCallback((_index: number) => {
|
||||
isComposingRef.current = true
|
||||
setIsComposing(true)
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(index: number, value: string) => {
|
||||
isComposingRef.current = false
|
||||
setIsComposing(false)
|
||||
requestAnimationFrame(() => {
|
||||
const current = latestInputRef.current
|
||||
const newInput = [...current]
|
||||
if (value) {
|
||||
const chars = value.split('')
|
||||
for (let i = 0; i < chars.length && index + i < newInput.length; i++) {
|
||||
newInput[index + i] = chars[i]
|
||||
}
|
||||
} else {
|
||||
newInput[index] = ''
|
||||
}
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
validateInput(newInput)
|
||||
})
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentPrinciple) return
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const targetLength = currentPrinciple.name.length
|
||||
const newInput = new Array(targetLength).fill('')
|
||||
const chars = pastedText.split('')
|
||||
for (let i = 0; i < Math.min(chars.length, targetLength); i++) {
|
||||
newInput[i] = chars[i]
|
||||
}
|
||||
handleInputChange(newInput)
|
||||
},
|
||||
[currentPrinciple, handleInputChange]
|
||||
)
|
||||
|
||||
// ─── 长按指针事件 ─────────────────────────────────────────────
|
||||
const handlePointerDown = useCallback(
|
||||
(principleId: string) => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
}
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
handleLongPress(principleId)
|
||||
longPressTimerRef.current = null
|
||||
}, 600)
|
||||
},
|
||||
[handleLongPress]
|
||||
)
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
// 长按已触发且答案正在显示时,松开指针立即隐藏(与 ProcessPracticePage 行为一致)
|
||||
handleLongPressEnd()
|
||||
}, [handleLongPressEnd])
|
||||
|
||||
// ─── 键盘快捷键 ───────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isPracticeMode) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'h') {
|
||||
e.preventDefault()
|
||||
if (currentPrincipleId && !showAnswerForCell) {
|
||||
handleLongPress(currentPrincipleId)
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
moveToPrevPrinciple()
|
||||
} else {
|
||||
moveToNextPrinciple()
|
||||
}
|
||||
} else if (e.key === 'Escape' && currentPrinciple) {
|
||||
setUserInput(new Array(currentPrinciple.name.length).fill(''))
|
||||
setCharStatuses(new Array(currentPrinciple.name.length).fill('pending'))
|
||||
setLastErrorTimestamp(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'h') {
|
||||
if (showAnswerForCell) handleLongPressEnd()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [
|
||||
isPracticeMode,
|
||||
currentPrinciple,
|
||||
currentPrincipleId,
|
||||
showAnswerForCell,
|
||||
handleLongPress,
|
||||
handleLongPressEnd,
|
||||
moveToNextPrinciple,
|
||||
moveToPrevPrinciple,
|
||||
])
|
||||
|
||||
// 组件卸载时清理长按计时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ─── 重置 ─────────────────────────────────────────────────────
|
||||
const resetPractice = useCallback(() => {
|
||||
if (!window.confirm('确定要清除练习进度吗?')) return
|
||||
setAnsweredCells(new Map())
|
||||
const first = principles[0] ?? null
|
||||
setCurrentPrincipleId(first?.id ?? null)
|
||||
if (first) {
|
||||
setUserInput(new Array(first.name.length).fill(''))
|
||||
setCharStatuses(new Array(first.name.length).fill('pending'))
|
||||
}
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
setLastErrorTimestamp(null)
|
||||
setShowCelebration(false)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
announceToScreenReader('练习进度已重置')
|
||||
}, [])
|
||||
|
||||
const handleCelebrationComplete = useCallback(() => {
|
||||
setShowCelebration(false)
|
||||
setAnsweredCells(new Map())
|
||||
const first = principles[0] ?? null
|
||||
setCurrentPrincipleId(first?.id ?? null)
|
||||
if (first) {
|
||||
setUserInput(new Array(first.name.length).fill(''))
|
||||
setCharStatuses(new Array(first.name.length).fill('pending'))
|
||||
}
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}, [])
|
||||
|
||||
// ─── 渲染 ─────────────────────────────────────────────────────
|
||||
return (
|
||||
// 与 ProcessPracticePage 保持相同的 flex-col 布局结构
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col">
|
||||
|
||||
{/* 顶部粘性区:标题 + 进度条 */}
|
||||
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
十二项原则
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{isPracticeMode && (
|
||||
<>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
进度:{answeredCount} / {principles.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetPractice}
|
||||
className="text-xs px-2 py-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
清除进度
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(false)}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
||||
!isPracticeMode
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(true)}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
||||
isPracticeMode
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
)}
|
||||
>
|
||||
练习
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPracticeMode && (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(answeredCount / principles.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间可滚动区:原则表格 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto py-4 px-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[720px] w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{principleGroups.map((group) => (
|
||||
<th
|
||||
key={group.id}
|
||||
className="bg-blue-700 px-4 py-3 text-center text-base font-bold text-white dark:bg-blue-800"
|
||||
>
|
||||
{group.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2, 3].map((rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{principleGroups.map((group) => {
|
||||
const principle = group.items[rowIdx]
|
||||
if (!principle) {
|
||||
return (
|
||||
<td
|
||||
key={`${group.id}-empty`}
|
||||
className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isAnswered = !!answeredCells.get(principle.id)
|
||||
const isCurrent =
|
||||
isPracticeMode && principle.id === currentPrincipleId
|
||||
const isShowingAnswer =
|
||||
showAnswerForCell?.principleId === principle.id
|
||||
|
||||
return (
|
||||
<td
|
||||
key={principle.id}
|
||||
className="border border-gray-200 dark:border-gray-700 p-0 align-top"
|
||||
>
|
||||
<div className="flex min-h-[72px]">
|
||||
{/* 原则名称列 */}
|
||||
{isPracticeMode ? (
|
||||
<button
|
||||
type="button"
|
||||
data-principle-id={principle.id}
|
||||
onClick={() => switchToPrinciple(principle)}
|
||||
onPointerDown={() => handlePointerDown(principle.id)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
aria-label={`原则:${principle.name}`}
|
||||
className={clsx(
|
||||
'relative flex w-32 shrink-0 items-center justify-center',
|
||||
'border-r border-2 cursor-pointer transition-all duration-200 focus:outline-none',
|
||||
'border-r-gray-200 dark:border-r-gray-700',
|
||||
isAnswered
|
||||
? 'bg-white dark:bg-gray-800 border-solid border-gray-300 dark:border-gray-600'
|
||||
: 'border-dashed border-gray-300 dark:border-gray-600',
|
||||
isCurrent && 'ring-2 ring-blue-500 border-blue-500',
|
||||
!isAnswered && 'min-h-[40px]'
|
||||
)}
|
||||
>
|
||||
{isAnswered && (
|
||||
<span className="px-2 text-xs font-semibold text-gray-900 dark:text-gray-100 text-center leading-snug">
|
||||
{principle.name}
|
||||
</span>
|
||||
)}
|
||||
{isShowingAnswer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 rounded z-10">
|
||||
<span className="text-white text-sm font-medium px-2 text-center">
|
||||
{showAnswerForCell.answer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-32 shrink-0 items-center justify-center bg-blue-700 px-2 py-3 text-center text-sm font-bold leading-snug text-white dark:bg-blue-800">
|
||||
{principle.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述列 */}
|
||||
<div className="flex flex-1 items-center bg-white px-4 py-3 dark:bg-gray-800">
|
||||
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-200">
|
||||
{principle.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部粘性区:练习输入(与 ProcessPracticePage 结构一致) */}
|
||||
{isPracticeMode && currentPrinciple && (
|
||||
<div className="sticky bottom-0 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 z-10 pb-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="py-3 border-b border-gray-200/50 dark:border-gray-700/50">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<InputArea
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => currentPrincipleId && handleLongPress(currentPrincipleId)}
|
||||
className="p-2 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title="查看答案(长按格子也可以)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{currentPrinciple.description}
|
||||
<span className="ml-3 text-gray-400 dark:text-gray-500">
|
||||
· 长按格子 / Ctrl+H 查看答案 · Tab 切换
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无障碍播报区 */}
|
||||
<div
|
||||
id="aria-live-region"
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
/>
|
||||
|
||||
{showCelebration && (
|
||||
<CelebrationAnimation onComplete={handleCelebrationComplete} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getProcessDetail,
|
||||
getArtifactUsage,
|
||||
getToolUsage,
|
||||
extractId,
|
||||
} from '@/data'
|
||||
|
||||
export function ProcessGraphPage() {
|
||||
@@ -92,8 +93,8 @@ export function ProcessGraphPage() {
|
||||
// 2. 添加工件节点和关系
|
||||
const usedArtifacts = new Set<string>()
|
||||
processes.forEach(p => {
|
||||
p.inputs.forEach(id => usedArtifacts.add(id))
|
||||
p.outputs.forEach(id => usedArtifacts.add(id))
|
||||
p.inputs.forEach(ref => usedArtifacts.add(extractId(ref)))
|
||||
p.outputs.forEach(ref => usedArtifacts.add(extractId(ref)))
|
||||
})
|
||||
|
||||
artifacts.forEach(a => {
|
||||
@@ -127,7 +128,7 @@ export function ProcessGraphPage() {
|
||||
// 3. 添加工具节点和关系
|
||||
const usedTools = new Set<string>()
|
||||
processes.forEach(p => {
|
||||
p.tools.forEach(id => usedTools.add(id))
|
||||
p.tools.forEach(ref => usedTools.add(extractId(ref)))
|
||||
})
|
||||
|
||||
tools.forEach(t => {
|
||||
@@ -160,7 +161,8 @@ export function ProcessGraphPage() {
|
||||
// 4. 构建边
|
||||
processes.forEach(p => {
|
||||
// 输入关系: Artifact -> Process
|
||||
p.inputs.forEach(inputId => {
|
||||
p.inputs.forEach(inputRef => {
|
||||
const inputId = extractId(inputRef)
|
||||
if (addedNodeIds.has(inputId)) {
|
||||
edges.push({
|
||||
source: inputId,
|
||||
@@ -176,7 +178,8 @@ export function ProcessGraphPage() {
|
||||
})
|
||||
|
||||
// 输出关系: Process -> Artifact
|
||||
p.outputs.forEach(outputId => {
|
||||
p.outputs.forEach(outputRef => {
|
||||
const outputId = extractId(outputRef)
|
||||
if (addedNodeIds.has(outputId)) {
|
||||
edges.push({
|
||||
source: p.id,
|
||||
@@ -192,7 +195,8 @@ export function ProcessGraphPage() {
|
||||
})
|
||||
|
||||
// 工具关系: Tool -> Process
|
||||
p.tools.forEach(toolId => {
|
||||
p.tools.forEach(toolRef => {
|
||||
const toolId = extractId(toolRef)
|
||||
if (addedNodeIds.has(toolId)) {
|
||||
edges.push({
|
||||
source: toolId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, FileText, Wrench, FileOutput } from 'lucide-react'
|
||||
import { ArrowRight, FileText, Wrench, FileOutput, Target } from 'lucide-react'
|
||||
import { processGroups, processesByProcessGroup, processGroupMap, knowledgeAreaMap } from '@/data'
|
||||
|
||||
const containerVariants = {
|
||||
@@ -97,9 +97,18 @@ export function ProcessGroupsPage() {
|
||||
// 显示过程组列表 - 紧凑版
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">过程组</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">PMBOK第6版定义的5大项目管理过程组</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">5大项目管理过程组</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/process-purpose-practice"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Target size={16} />
|
||||
子过程主要作用专项练习
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-2">
|
||||
|
||||
@@ -1,17 +1,102 @@
|
||||
/**
|
||||
* 49过程矩阵页面
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ProcessMatrix } from '@/components/visualize'
|
||||
import { Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { Maximize2, Minimize2, Eye } from 'lucide-react'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { clsx } from 'clsx'
|
||||
import { knowledgeAreas, processGroups } from '@/data'
|
||||
|
||||
const STORAGE_KEY = 'ittoview:process-matrix:hidden-items'
|
||||
|
||||
interface HiddenIds {
|
||||
knowledgeAreas: Set<string>
|
||||
processGroups: Set<string>
|
||||
}
|
||||
|
||||
// 从 localStorage 加载并清洗无效 ID
|
||||
function loadHiddenIds(): HiddenIds {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { knowledgeAreas: new Set<string>(), processGroups: new Set<string>() }
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed !== 'object' || !parsed) {
|
||||
return { knowledgeAreas: new Set<string>(), processGroups: new Set<string>() }
|
||||
}
|
||||
|
||||
// 清洗无效 ID
|
||||
const validKaIds = new Set(knowledgeAreas.map(ka => ka.id))
|
||||
const validPgIds = new Set(processGroups.map(pg => pg.id))
|
||||
|
||||
const kaIds = Array.isArray(parsed.knowledgeAreas)
|
||||
? parsed.knowledgeAreas.filter((id: string) => validKaIds.has(id))
|
||||
: []
|
||||
const pgIds = Array.isArray(parsed.processGroups)
|
||||
? parsed.processGroups.filter((id: string) => validPgIds.has(id))
|
||||
: []
|
||||
|
||||
return {
|
||||
knowledgeAreas: new Set(kaIds),
|
||||
processGroups: new Set(pgIds),
|
||||
}
|
||||
} catch {
|
||||
return { knowledgeAreas: new Set<string>(), processGroups: new Set<string>() }
|
||||
}
|
||||
}
|
||||
|
||||
export function ProcessMatrixPage() {
|
||||
const isFullScreen = useAppStore((s) => s.matrixFullScreen)
|
||||
const setMatrixFullScreen = useAppStore((s) => s.setMatrixFullScreen)
|
||||
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
||||
|
||||
// 显示/隐藏状态管理
|
||||
const [hiddenIds, setHiddenIds] = useState(() => loadHiddenIds())
|
||||
const hiddenKnowledgeAreaIds = hiddenIds.knowledgeAreas
|
||||
const hiddenProcessGroupIds = hiddenIds.processGroups
|
||||
|
||||
// 持久化状态
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
knowledgeAreas: Array.from(hiddenKnowledgeAreaIds),
|
||||
processGroups: Array.from(hiddenProcessGroupIds),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn('无法保存矩阵显示状态:', error)
|
||||
}
|
||||
}, [hiddenKnowledgeAreaIds, hiddenProcessGroupIds])
|
||||
|
||||
const toggleKnowledgeArea = (id: string) => {
|
||||
setHiddenIds(prev => {
|
||||
const next = new Set(prev.knowledgeAreas)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return { ...prev, knowledgeAreas: next }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleProcessGroup = (id: string) => {
|
||||
setHiddenIds(prev => {
|
||||
const next = new Set(prev.processGroups)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return { ...prev, processGroups: next }
|
||||
})
|
||||
}
|
||||
|
||||
const showAll = () => {
|
||||
setHiddenIds({ knowledgeAreas: new Set(), processGroups: new Set() })
|
||||
}
|
||||
|
||||
const hasHidden = hiddenKnowledgeAreaIds.size > 0 || hiddenProcessGroupIds.size > 0
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!isFullScreen) {
|
||||
setSidebarOpen(false)
|
||||
@@ -46,13 +131,26 @@ export function ProcessMatrixPage() {
|
||||
)}
|
||||
|
||||
{!isFullScreen && (
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex justify-between items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">49过程矩阵</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
知识领域 × 过程组 的全景矩阵视图,点击过程可查看详情
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasHidden && (
|
||||
<button
|
||||
onClick={showAll}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
全部显示
|
||||
<span className="text-xs opacity-75">
|
||||
(已隐藏 {hiddenKnowledgeAreaIds.size} 行 / {hiddenProcessGroupIds.size} 列)
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors shadow-sm"
|
||||
@@ -61,6 +159,7 @@ export function ProcessMatrixPage() {
|
||||
全屏查看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx(
|
||||
@@ -71,6 +170,19 @@ export function ProcessMatrixPage() {
|
||||
{isFullScreen && (
|
||||
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<span className="font-medium text-gray-900 dark:text-white">49过程矩阵全景图</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasHidden && (
|
||||
<button
|
||||
onClick={showAll}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-800 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
全部显示
|
||||
<span className="text-xs opacity-75">
|
||||
({hiddenKnowledgeAreaIds.size} 行 / {hiddenProcessGroupIds.size} 列)
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
@@ -79,12 +191,17 @@ export function ProcessMatrixPage() {
|
||||
退出全屏
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx("relative", isFullScreen ? "flex-1 overflow-hidden" : "")}>
|
||||
<ProcessMatrix
|
||||
className={clsx(isFullScreen && "h-full w-full overflow-auto no-scrollbar")}
|
||||
isFullScreen={isFullScreen}
|
||||
hiddenKnowledgeAreaIds={hiddenKnowledgeAreaIds}
|
||||
hiddenProcessGroupIds={hiddenProcessGroupIds}
|
||||
onToggleKnowledgeArea={toggleKnowledgeArea}
|
||||
onToggleProcessGroup={toggleProcessGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
522
src/pages/ProcessPracticePage.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
generateCellSequence,
|
||||
normalizeAnswer,
|
||||
announceToScreenReader,
|
||||
type CellInfo,
|
||||
} from '@/utils/practice'
|
||||
import { PracticeMatrix } from '@/components/practice/PracticeMatrix'
|
||||
import { InputArea } from '@/components/practice/InputArea'
|
||||
import { HintInfo } from '@/components/practice/HintInfo'
|
||||
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||
|
||||
type CharStatus = 'pending' | 'correct' | 'error'
|
||||
|
||||
export default function ProcessPracticePage() {
|
||||
// 生成格子顺序
|
||||
const [cellSequence] = useState<CellInfo[]>(() => generateCellSequence())
|
||||
|
||||
// 从 localStorage 加载答题进度
|
||||
const loadProgress = useCallback(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('practice-progress')
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved)
|
||||
return {
|
||||
answeredCells: new Map<string, boolean>(data.answeredCells || []),
|
||||
currentCellId: data.currentCellId || cellSequence[0]?.id || null,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载进度失败:', e)
|
||||
}
|
||||
return {
|
||||
answeredCells: new Map<string, boolean>(),
|
||||
currentCellId: cellSequence[0]?.id || null,
|
||||
}
|
||||
}, [cellSequence])
|
||||
|
||||
// 答题状态
|
||||
const [answeredCells, setAnsweredCells] = useState<Map<string, boolean>>(
|
||||
() => loadProgress().answeredCells
|
||||
)
|
||||
const [currentCellId, setCurrentCellId] = useState<string | null>(
|
||||
() => loadProgress().currentCellId
|
||||
)
|
||||
|
||||
// 输入状态
|
||||
const [userInput, setUserInput] = useState<string[]>([])
|
||||
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const isComposingRef = useRef(false)
|
||||
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// 长按显示答案
|
||||
const [showAnswerForCell, setShowAnswerForCell] = useState<{
|
||||
cellId: string
|
||||
answer: string
|
||||
expiresAt: number
|
||||
} | null>(null)
|
||||
const [inputLocked, setInputLocked] = useState(false)
|
||||
const latestInputRef = useRef<string[]>([])
|
||||
|
||||
// 庆祝动画
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
|
||||
// 初始化输入框
|
||||
useEffect(() => {
|
||||
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
||||
if (currentCell) {
|
||||
setUserInput(new Array(currentCell.answer.length).fill(''))
|
||||
setCharStatuses(new Array(currentCell.answer.length).fill('pending'))
|
||||
}
|
||||
}, [currentCellId, cellSequence])
|
||||
|
||||
// 同步当前输入快照,供输入法确认后复用
|
||||
useEffect(() => {
|
||||
latestInputRef.current = userInput
|
||||
}, [userInput])
|
||||
|
||||
// 保存答题进度到 localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'practice-progress',
|
||||
JSON.stringify({
|
||||
answeredCells: Array.from(answeredCells.entries()),
|
||||
currentCellId,
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('保存进度失败:', e)
|
||||
}
|
||||
}, [answeredCells, currentCellId])
|
||||
|
||||
// 恢复焦点到第一个空输入框
|
||||
const restoreFocus = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll('.practice-input-area input')
|
||||
const firstEmptyInput = Array.from(inputs).find(
|
||||
(input) => !(input as HTMLInputElement).value
|
||||
) as HTMLInputElement
|
||||
|
||||
if (firstEmptyInput) {
|
||||
firstEmptyInput.focus()
|
||||
} else {
|
||||
// 如果所有输入框都有值,聚焦到第一个
|
||||
(inputs[0] as HTMLInputElement)?.focus()
|
||||
}
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
// 切换到指定格子
|
||||
const switchToCell = useCallback(
|
||||
(cell: CellInfo) => {
|
||||
setCurrentCellId(cell.id)
|
||||
setUserInput(new Array(cell.answer.length).fill(''))
|
||||
setCharStatuses(new Array(cell.answer.length).fill('pending'))
|
||||
setLastErrorTimestamp(null)
|
||||
|
||||
// 滚动到可见区域
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.querySelector(
|
||||
`[data-cell-id="${cell.id}"]`
|
||||
) as HTMLElement
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
|
||||
// 延迟聚焦到第一个输入框,确保 DOM 已更新
|
||||
setTimeout(() => {
|
||||
const firstInput = document.querySelector(
|
||||
'.practice-input-area input'
|
||||
) as HTMLInputElement
|
||||
firstInput?.focus()
|
||||
}, 150)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// 移动到下一个格子
|
||||
const moveToNextCell = useCallback(() => {
|
||||
const currentIndex = cellSequence.findIndex((c) => c.id === currentCellId)
|
||||
if (currentIndex === -1 || currentIndex === cellSequence.length - 1) return
|
||||
|
||||
const nextCell = cellSequence[currentIndex + 1]
|
||||
switchToCell(nextCell)
|
||||
}, [currentCellId, cellSequence, switchToCell])
|
||||
|
||||
// 移动到上一个格子
|
||||
const moveToPrevCell = useCallback(() => {
|
||||
const currentIndex = cellSequence.findIndex((c) => c.id === currentCellId)
|
||||
if (currentIndex <= 0) return
|
||||
|
||||
const prevCell = cellSequence[currentIndex - 1]
|
||||
switchToCell(prevCell)
|
||||
}, [currentCellId, cellSequence, switchToCell])
|
||||
|
||||
// 统一的输入验证逻辑
|
||||
const validateInput = useCallback(
|
||||
(input: string[]) => {
|
||||
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
||||
if (!currentCell || !currentCellId) return
|
||||
|
||||
const originalAnswer = currentCell.answer
|
||||
const normalizedInput = normalizeAnswer(
|
||||
input.join(''),
|
||||
currentCell.type === 'knowledge-area'
|
||||
)
|
||||
const normalizedAnswer = currentCell.normalizedAnswer
|
||||
const normalizeChar = (char: string) =>
|
||||
normalizeAnswer(char, false) || char
|
||||
|
||||
const newCharStatuses = input.map((char, i) => {
|
||||
if (!char) return 'pending' as CharStatus
|
||||
const expectedChar = originalAnswer[i] || ''
|
||||
if (!expectedChar) return 'error' as CharStatus
|
||||
return normalizeChar(char) === normalizeChar(expectedChar)
|
||||
? ('correct' as CharStatus)
|
||||
: ('error' as CharStatus)
|
||||
})
|
||||
setCharStatuses(newCharStatuses)
|
||||
|
||||
const isComplete =
|
||||
input.every((c) => c !== '') && input.length === originalAnswer.length
|
||||
const isCorrect = isComplete && normalizedInput === normalizedAnswer
|
||||
|
||||
if (isCorrect) {
|
||||
setAnsweredCells((prev) => new Map(prev).set(currentCellId, true))
|
||||
|
||||
// 检查是否完成所有格子
|
||||
const currentIndex = cellSequence.findIndex((c) => c.id === currentCellId)
|
||||
const isLastCell = currentIndex === cellSequence.length - 1
|
||||
|
||||
if (isLastCell) {
|
||||
// 最后一个格子,显示庆祝动画
|
||||
setTimeout(() => {
|
||||
setShowCelebration(true)
|
||||
}, 300)
|
||||
} else {
|
||||
// 不是最后一个,继续下一个
|
||||
setTimeout(() => {
|
||||
moveToNextCell()
|
||||
}, 300)
|
||||
}
|
||||
} else if (isComplete) {
|
||||
setLastErrorTimestamp(Date.now())
|
||||
}
|
||||
},
|
||||
[cellSequence, currentCellId, moveToNextCell]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(newInput: string[]) => {
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
|
||||
if (isComposingRef.current) return
|
||||
|
||||
validateInput(newInput)
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
// 输入法状态管理
|
||||
const handleCompositionStart = useCallback((_index: number) => {
|
||||
isComposingRef.current = true
|
||||
setIsComposing(true)
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(index: number, value: string) => {
|
||||
isComposingRef.current = false
|
||||
setIsComposing(false)
|
||||
// 输入法确认后,需要将当前输入框中的所有字符分散到后续输入框
|
||||
requestAnimationFrame(() => {
|
||||
const currentInput = latestInputRef.current
|
||||
const composedText = value
|
||||
const newInput = [...currentInput]
|
||||
|
||||
if (composedText) {
|
||||
const chars = composedText.split('')
|
||||
for (
|
||||
let i = 0;
|
||||
i < chars.length && index + i < newInput.length;
|
||||
i++
|
||||
) {
|
||||
newInput[index + i] = chars[i]
|
||||
}
|
||||
} else {
|
||||
newInput[index] = ''
|
||||
}
|
||||
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
validateInput(newInput)
|
||||
})
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
// 批量粘贴处理
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
||||
if (!currentCell) return
|
||||
|
||||
// 创建固定长度的数组,不足部分保留为空字符串
|
||||
const targetLength = currentCell.answer.length
|
||||
const newInput = new Array(targetLength).fill('')
|
||||
const chars = pastedText.split('')
|
||||
|
||||
for (let i = 0; i < Math.min(chars.length, targetLength); i++) {
|
||||
newInput[i] = chars[i]
|
||||
}
|
||||
|
||||
handleInputChange(newInput)
|
||||
},
|
||||
[currentCellId, cellSequence, handleInputChange]
|
||||
)
|
||||
|
||||
// 长按显示答案
|
||||
const handleLongPress = useCallback(
|
||||
(cellId: string) => {
|
||||
const cell = cellSequence.find((c) => c.id === cellId)
|
||||
if (!cell) return
|
||||
|
||||
// 显示答案,并设置过期时间
|
||||
setShowAnswerForCell({
|
||||
cellId,
|
||||
answer: cell.answer,
|
||||
expiresAt: Date.now() + 3000, // 3秒后自动隐藏
|
||||
})
|
||||
|
||||
// 暂时锁定输入区域
|
||||
setInputLocked(true)
|
||||
|
||||
// 无障碍通告
|
||||
announceToScreenReader('答案已显示')
|
||||
},
|
||||
[cellSequence]
|
||||
)
|
||||
|
||||
const handleLongPressEnd = useCallback(() => {
|
||||
// 只有在答案仍显示时才隐藏(避免重复调用)
|
||||
if (showAnswerForCell) {
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已隐藏')
|
||||
restoreFocus()
|
||||
}
|
||||
}, [showAnswerForCell, restoreFocus])
|
||||
|
||||
// 自动过期检查
|
||||
useEffect(() => {
|
||||
if (!showAnswerForCell) return
|
||||
|
||||
const remainingTime = showAnswerForCell.expiresAt - Date.now()
|
||||
if (remainingTime <= 0) {
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
restoreFocus()
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setShowAnswerForCell(null)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已自动隐藏')
|
||||
restoreFocus()
|
||||
}, remainingTime)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [showAnswerForCell, restoreFocus])
|
||||
|
||||
// Ctrl+H 快捷键显示/隐藏答案
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'h') {
|
||||
e.preventDefault()
|
||||
if (currentCellId && !showAnswerForCell) {
|
||||
handleLongPress(currentCellId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'h') {
|
||||
if (showAnswerForCell) {
|
||||
handleLongPressEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [currentCellId, showAnswerForCell, handleLongPress, handleLongPressEnd])
|
||||
|
||||
// 点击格子切换(允许回顾已答对的格子)
|
||||
const handleCellClick = useCallback(
|
||||
(cellId: string) => {
|
||||
const cell = cellSequence.find((c) => c.id === cellId)
|
||||
if (cell) {
|
||||
switchToCell(cell)
|
||||
}
|
||||
},
|
||||
[cellSequence, switchToCell]
|
||||
)
|
||||
|
||||
// 计算 tabIndex
|
||||
const getCellTabIndex = useCallback(
|
||||
(cellId: string) => {
|
||||
return cellId === currentCellId ? 0 : -1
|
||||
},
|
||||
[currentCellId]
|
||||
)
|
||||
|
||||
// 键盘事件监听
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
moveToPrevCell()
|
||||
} else {
|
||||
moveToNextCell()
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// 清空当前输入
|
||||
setUserInput(new Array(userInput.length).fill(''))
|
||||
setCharStatuses(new Array(userInput.length).fill('pending'))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [moveToNextCell, moveToPrevCell, userInput.length])
|
||||
|
||||
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
||||
const answeredCount = answeredCells.size
|
||||
const totalCount = cellSequence.length
|
||||
|
||||
// 清除进度
|
||||
const handleClearProgress = useCallback(() => {
|
||||
if (confirm('确定要清除所有答题进度吗?')) {
|
||||
setAnsweredCells(new Map())
|
||||
setCurrentCellId(cellSequence[0]?.id || null)
|
||||
localStorage.removeItem('practice-progress')
|
||||
}
|
||||
}, [cellSequence])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col">
|
||||
{/* 顶部进度条 */}
|
||||
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
过程背诵练习
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
进度:{answeredCount} / {totalCount}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearProgress}
|
||||
className="text-xs px-2 py-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
清除进度
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${(answeredCount / totalCount) * 100}%`,
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto pb-10">
|
||||
{/* 矩阵区域 */}
|
||||
<div className="max-w-7xl mx-auto py-4">
|
||||
<PracticeMatrix
|
||||
answeredCells={answeredCells}
|
||||
currentCellId={currentCellId}
|
||||
showAnswerForCell={showAnswerForCell}
|
||||
onLongPress={handleLongPress}
|
||||
onLongPressEnd={handleLongPressEnd}
|
||||
onCellClick={handleCellClick}
|
||||
getCellTabIndex={getCellTabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部固定区域(粘附底部,参与文档流) */}
|
||||
<div className="sticky bottom-0 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 z-10 pb-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 输入区域 */}
|
||||
<div className="py-3 border-b border-gray-200/50 dark:border-gray-700/50">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<InputArea
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{/* 查看答案按钮 */}
|
||||
<button
|
||||
onClick={() => currentCellId && handleLongPress(currentCellId)}
|
||||
className="p-2 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title="查看答案(长按格子也可以)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 辅助信息区域 */}
|
||||
<div className="py-3 px-4 max-h-40 overflow-y-auto">
|
||||
<HintInfo currentCell={currentCell} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 无障碍通告区域 */}
|
||||
<div
|
||||
id="aria-live-region"
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
/>
|
||||
|
||||
{/* 庆祝动画 */}
|
||||
{showCelebration && (
|
||||
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
648
src/pages/ProcessPurposePracticePage.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { processPurposePracticeItems } from '@/data/process-purpose-practice'
|
||||
import { InputArea } from '@/components/practice/InputArea'
|
||||
import { ProficientInputArea } from '@/components/practice/ProficientInputArea'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { normalizeAnswer, announceToScreenReader } from '@/utils/practice'
|
||||
|
||||
type CharStatus = 'pending' | 'correct' | 'error'
|
||||
|
||||
type PracticeProgress = {
|
||||
order: string[]
|
||||
currentIndex: number
|
||||
completedIds: string[]
|
||||
currentInput: string[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'process-purpose-practice-progress'
|
||||
const NEXT_QUESTION_DELAY = 650
|
||||
const ANSWER_VISIBLE_DURATION = 3000
|
||||
|
||||
function shuffleItems<T>(items: T[]): T[] {
|
||||
const result = [...items]
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createFreshProgress(): PracticeProgress {
|
||||
return {
|
||||
order: shuffleItems(processPurposePracticeItems.map((item) => item.id)),
|
||||
currentIndex: 0,
|
||||
completedIds: [],
|
||||
currentInput: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredProgress(): PracticeProgress {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return createFreshProgress()
|
||||
|
||||
const parsed = JSON.parse(saved) as Partial<PracticeProgress>
|
||||
const validIds = new Set(processPurposePracticeItems.map((item) => item.id))
|
||||
const order = Array.isArray(parsed.order)
|
||||
? parsed.order.filter((id): id is string => validIds.has(String(id)))
|
||||
: []
|
||||
const missingIds = processPurposePracticeItems
|
||||
.map((item) => item.id)
|
||||
.filter((id) => !order.includes(id))
|
||||
const completedIds = Array.isArray(parsed.completedIds)
|
||||
? parsed.completedIds.filter((id): id is string => validIds.has(String(id)))
|
||||
: []
|
||||
const mergedOrder = [...order, ...shuffleItems(missingIds)]
|
||||
|
||||
if (mergedOrder.length === 0) return createFreshProgress()
|
||||
|
||||
return {
|
||||
order: mergedOrder,
|
||||
currentIndex: Math.min(
|
||||
Math.max(Number(parsed.currentIndex) || 0, 0),
|
||||
mergedOrder.length - 1
|
||||
),
|
||||
completedIds,
|
||||
currentInput: Array.isArray(parsed.currentInput)
|
||||
? parsed.currentInput.map((char) => String(char || ''))
|
||||
: [],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载子过程主要作用练习进度失败:', error)
|
||||
return createFreshProgress()
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProcessPurposePracticePage() {
|
||||
const [progress, setProgress] = useState<PracticeProgress>(() =>
|
||||
getStoredProgress()
|
||||
)
|
||||
const [userInput, setUserInput] = useState<string[]>([])
|
||||
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const [inputLocked, setInputLocked] = useState(false)
|
||||
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [showAnswer, setShowAnswer] = useState(false)
|
||||
const [correctFeedback, setCorrectFeedback] = useState(false)
|
||||
const [proficientInput, setProficientInput] = useState('')
|
||||
const [proficientHasError, setProficientHasError] = useState(false)
|
||||
const isComposingRef = useRef(false)
|
||||
const latestInputRef = useRef<string[]>([])
|
||||
const answerTimerRef = useRef<number | null>(null)
|
||||
const deckScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const currentScrollLimitRef = useRef(0)
|
||||
const { practiceMode } = useAppStore()
|
||||
|
||||
const itemMap = useMemo(
|
||||
() => new Map(processPurposePracticeItems.map((item) => [item.id, item])),
|
||||
[]
|
||||
)
|
||||
const currentItem = itemMap.get(progress.order[progress.currentIndex])
|
||||
const totalCount = progress.order.length
|
||||
const completedCount = progress.completedIds.length
|
||||
const isFinished = completedCount >= totalCount
|
||||
const deckCards = progress.order
|
||||
.map((id, index) => ({ item: itemMap.get(id), index }))
|
||||
.filter((card): card is { item: NonNullable<typeof currentItem>; index: number } => Boolean(card.item))
|
||||
|
||||
const focusFirstEmptyInput = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll('.practice-input-area input')
|
||||
const firstEmptyInput = Array.from(inputs).find(
|
||||
(input) => !(input as HTMLInputElement).value
|
||||
) as HTMLInputElement | undefined
|
||||
;(firstEmptyInput || (inputs[0] as HTMLInputElement | undefined))?.focus()
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const hideAnswer = useCallback(() => {
|
||||
if (answerTimerRef.current) {
|
||||
window.clearTimeout(answerTimerRef.current)
|
||||
answerTimerRef.current = null
|
||||
}
|
||||
setShowAnswer(false)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已隐藏')
|
||||
focusFirstEmptyInput()
|
||||
}, [focusFirstEmptyInput])
|
||||
|
||||
const showCurrentAnswer = useCallback(() => {
|
||||
if (!currentItem || isFinished) return
|
||||
if (answerTimerRef.current) {
|
||||
window.clearTimeout(answerTimerRef.current)
|
||||
}
|
||||
if (practiceMode === 'proficient') {
|
||||
setProficientInput('')
|
||||
setProficientHasError(false)
|
||||
}
|
||||
setShowAnswer(true)
|
||||
setInputLocked(true)
|
||||
announceToScreenReader('答案已显示')
|
||||
answerTimerRef.current = window.setTimeout(() => {
|
||||
hideAnswer()
|
||||
}, ANSWER_VISIBLE_DURATION)
|
||||
}, [currentItem, hideAnswer, isFinished, practiceMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentItem) return
|
||||
|
||||
const nextInput = new Array(currentItem.name.length).fill('')
|
||||
progress.currentInput.slice(0, currentItem.name.length).forEach((char, index) => {
|
||||
nextInput[index] = char
|
||||
})
|
||||
latestInputRef.current = nextInput
|
||||
setUserInput(nextInput)
|
||||
setCharStatuses(new Array(currentItem.name.length).fill('pending'))
|
||||
setLastErrorTimestamp(null)
|
||||
setShowAnswer(false)
|
||||
setCorrectFeedback(false)
|
||||
setInputLocked(false)
|
||||
setProficientInput('')
|
||||
setProficientHasError(false)
|
||||
}, [currentItem?.id])
|
||||
|
||||
useEffect(() => {
|
||||
latestInputRef.current = userInput
|
||||
}, [userInput])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
...progress,
|
||||
currentInput: userInput,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('保存子过程主要作用练习进度失败:', error)
|
||||
}
|
||||
}, [progress, userInput])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'h') {
|
||||
e.preventDefault()
|
||||
if (!showAnswer) showCurrentAnswer()
|
||||
} else if (e.key === 'Escape' && !inputLocked) {
|
||||
if (practiceMode === 'proficient') {
|
||||
setProficientInput('')
|
||||
setProficientHasError(false)
|
||||
} else {
|
||||
setUserInput(new Array(userInput.length).fill(''))
|
||||
setCharStatuses(new Array(userInput.length).fill('pending'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if ((e.key === 'Control' || e.key.toLowerCase() === 'h') && showAnswer) {
|
||||
hideAnswer()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [hideAnswer, inputLocked, practiceMode, showAnswer, showCurrentAnswer, userInput.length])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (answerTimerRef.current) window.clearTimeout(answerTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const deck = deckScrollRef.current
|
||||
if (!deck) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const currentCard = deck.querySelector('[data-current-question="true"]') as HTMLElement | null
|
||||
if (!currentCard) return
|
||||
|
||||
const deckTop = deck.getBoundingClientRect().top
|
||||
const cardTop = currentCard.getBoundingClientRect().top
|
||||
const targetTop = Math.max(deck.scrollTop + cardTop - deckTop - 90, 0)
|
||||
currentScrollLimitRef.current = targetTop
|
||||
deck.scrollTo({ top: targetTop, behavior: 'smooth' })
|
||||
})
|
||||
}, [progress.currentIndex])
|
||||
|
||||
const moveToNextQuestion = useCallback(() => {
|
||||
setShowAnswer(false)
|
||||
setCorrectFeedback(false)
|
||||
setInputLocked(false)
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
currentIndex: Math.min(prev.currentIndex + 1, prev.order.length - 1),
|
||||
currentInput: [],
|
||||
}))
|
||||
focusFirstEmptyInput()
|
||||
}, [focusFirstEmptyInput])
|
||||
|
||||
const validateInput = useCallback(
|
||||
(input: string[]) => {
|
||||
if (!currentItem || inputLocked || isFinished) return
|
||||
|
||||
const answer = currentItem.name
|
||||
const normalizeChar = (char: string) => normalizeAnswer(char) || char
|
||||
const newCharStatuses = input.map((char, index) => {
|
||||
if (!char) return 'pending' as CharStatus
|
||||
return normalizeChar(char) === normalizeChar(answer[index] || '')
|
||||
? ('correct' as CharStatus)
|
||||
: ('error' as CharStatus)
|
||||
})
|
||||
setCharStatuses(newCharStatuses)
|
||||
|
||||
const isComplete = input.every(Boolean) && input.length === answer.length
|
||||
const isCorrect =
|
||||
isComplete && normalizeAnswer(input.join('')) === normalizeAnswer(answer)
|
||||
|
||||
if (isCorrect) {
|
||||
setCorrectFeedback(true)
|
||||
setInputLocked(true)
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedIds: prev.completedIds.includes(currentItem.id)
|
||||
? prev.completedIds
|
||||
: [...prev.completedIds, currentItem.id],
|
||||
currentInput: input,
|
||||
}))
|
||||
announceToScreenReader('回答正确')
|
||||
|
||||
window.setTimeout(() => {
|
||||
const isLastQuestion =
|
||||
progress.currentIndex >= progress.order.length - 1
|
||||
if (isLastQuestion) {
|
||||
setInputLocked(false)
|
||||
setCorrectFeedback(false)
|
||||
} else {
|
||||
moveToNextQuestion()
|
||||
}
|
||||
}, NEXT_QUESTION_DELAY)
|
||||
} else if (isComplete) {
|
||||
setLastErrorTimestamp(Date.now())
|
||||
}
|
||||
},
|
||||
[currentItem, inputLocked, isFinished, moveToNextQuestion, progress.currentIndex, progress.order.length]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(newInput: string[]) => {
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
if (isComposingRef.current) return
|
||||
validateInput(newInput)
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
const handleCompositionStart = useCallback((_index?: number) => {
|
||||
isComposingRef.current = true
|
||||
setIsComposing(true)
|
||||
}, [])
|
||||
|
||||
const submitProficientAnswer = useCallback(
|
||||
(rawValue: string) => {
|
||||
if (!currentItem || inputLocked || isFinished) return
|
||||
|
||||
const normalizedValue = normalizeAnswer(rawValue)
|
||||
if (!normalizedValue) return
|
||||
|
||||
if (normalizedValue !== normalizeAnswer(currentItem.name)) {
|
||||
setProficientHasError(true)
|
||||
return
|
||||
}
|
||||
|
||||
setCorrectFeedback(true)
|
||||
setInputLocked(true)
|
||||
setProficientInput('')
|
||||
setProficientHasError(false)
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedIds: prev.completedIds.includes(currentItem.id)
|
||||
? prev.completedIds
|
||||
: [...prev.completedIds, currentItem.id],
|
||||
currentInput: [],
|
||||
}))
|
||||
announceToScreenReader('回答正确')
|
||||
|
||||
window.setTimeout(() => {
|
||||
const isLastQuestion = progress.currentIndex >= progress.order.length - 1
|
||||
if (isLastQuestion) {
|
||||
setInputLocked(false)
|
||||
setCorrectFeedback(false)
|
||||
} else {
|
||||
moveToNextQuestion()
|
||||
}
|
||||
}, NEXT_QUESTION_DELAY)
|
||||
},
|
||||
[currentItem, inputLocked, isFinished, moveToNextQuestion, progress.currentIndex, progress.order.length]
|
||||
)
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(indexOrValue: number | string, maybeValue?: string) => {
|
||||
isComposingRef.current = false
|
||||
setIsComposing(false)
|
||||
|
||||
if (practiceMode === 'proficient') {
|
||||
const nextValue =
|
||||
typeof indexOrValue === 'string' ? indexOrValue : maybeValue ?? ''
|
||||
setProficientInput(nextValue)
|
||||
setProficientHasError(false)
|
||||
return
|
||||
}
|
||||
|
||||
const index = indexOrValue as number
|
||||
const value = maybeValue ?? ''
|
||||
requestAnimationFrame(() => {
|
||||
const newInput = [...latestInputRef.current]
|
||||
const chars = value.split('')
|
||||
if (chars.length > 0) {
|
||||
for (let i = 0; i < chars.length && index + i < newInput.length; i++) {
|
||||
newInput[index + i] = chars[i]
|
||||
}
|
||||
} else {
|
||||
newInput[index] = ''
|
||||
}
|
||||
handleInputChange(newInput)
|
||||
})
|
||||
},
|
||||
[handleInputChange, practiceMode]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentItem || inputLocked) return
|
||||
const nextInput = new Array(currentItem.name.length).fill('')
|
||||
e.clipboardData
|
||||
.getData('text')
|
||||
.split('')
|
||||
.slice(0, currentItem.name.length)
|
||||
.forEach((char, index) => {
|
||||
nextInput[index] = char
|
||||
})
|
||||
handleInputChange(nextInput)
|
||||
},
|
||||
[currentItem, handleInputChange, inputLocked]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (practiceMode !== 'proficient' || isComposing || inputLocked || isFinished) return
|
||||
if (!normalizeAnswer(proficientInput)) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
submitProficientAnswer(proficientInput)
|
||||
}, 800)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [
|
||||
isComposing,
|
||||
inputLocked,
|
||||
isFinished,
|
||||
practiceMode,
|
||||
proficientInput,
|
||||
submitProficientAnswer,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setProficientInput('')
|
||||
setProficientHasError(false)
|
||||
setShowAnswer(false)
|
||||
setInputLocked(false)
|
||||
}, [practiceMode])
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
const fresh = createFreshProgress()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh))
|
||||
setProgress(fresh)
|
||||
setShowAnswer(false)
|
||||
setCorrectFeedback(false)
|
||||
setInputLocked(false)
|
||||
setProficientInput('')
|
||||
setProficientHasError(false)
|
||||
focusFirstEmptyInput()
|
||||
}, [focusFirstEmptyInput])
|
||||
|
||||
const handleDeckScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const deck = event.currentTarget
|
||||
const maxScrollTop = currentScrollLimitRef.current
|
||||
if (deck.scrollTop > maxScrollTop + 2) {
|
||||
deck.scrollTop = maxScrollTop
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!currentItem) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3rem)] min-h-[620px] flex-col overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<div className="z-20 shrink-0 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
|
||||
<div className="mx-auto max-w-5xl px-4 py-4">
|
||||
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
子过程主要作用专项练习
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
根据主要作用写出对应的过程名称
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
进度:{completedCount} / {totalCount}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
||||
>
|
||||
重新练习
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<motion.div
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(completedCount / totalCount) * 100}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col gap-3 overflow-hidden px-4 pb-4 pt-3">
|
||||
<div
|
||||
ref={deckScrollRef}
|
||||
onScroll={handleDeckScroll}
|
||||
className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain pr-1 scroll-smooth"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{deckCards.map(({ item, index }) => {
|
||||
const isCurrent = index === progress.currentIndex
|
||||
const isAnsweredHistory = index < progress.currentIndex
|
||||
const isFuture = index > progress.currentIndex
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
animate={{
|
||||
opacity: isCurrent ? 1 : isFuture ? 0.42 : 0.62,
|
||||
y: 0,
|
||||
scale: isCurrent ? 1 : 0.975,
|
||||
}}
|
||||
exit={{ opacity: 0, y: -16, scale: 0.98 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
data-current-question={isCurrent ? 'true' : undefined}
|
||||
className={`rounded-2xl border bg-white p-6 transition-all duration-200 dark:bg-gray-800 ${
|
||||
isCurrent && correctFeedback
|
||||
? 'border-green-300 shadow-lg shadow-green-100/70 ring-2 ring-green-100 dark:border-green-700 dark:shadow-none dark:ring-green-900/40'
|
||||
: isCurrent
|
||||
? 'border-blue-200 shadow-xl shadow-blue-100/80 ring-2 ring-blue-100 dark:border-blue-800 dark:shadow-none dark:ring-blue-900/30'
|
||||
: isFuture
|
||||
? 'pointer-events-none border-dashed border-gray-200 bg-white/55 shadow-sm blur-[0.4px] dark:border-gray-700 dark:bg-gray-800/45'
|
||||
: 'border-gray-100 shadow-sm dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex min-h-9 flex-wrap items-center justify-between gap-3">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
isCurrent
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/70 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
第 {index + 1} / {totalCount} 题
|
||||
</span>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isCurrent && showAnswer ? (
|
||||
<motion.span
|
||||
key="current-answer"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-1 text-base font-semibold leading-6 text-indigo-800 shadow-sm shadow-indigo-100 dark:border-indigo-500/70 dark:bg-indigo-950/50 dark:text-indigo-100 dark:shadow-none"
|
||||
>
|
||||
{item.name}
|
||||
</motion.span>
|
||||
) : isAnsweredHistory ? (
|
||||
<motion.span
|
||||
key="history-answer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-full bg-green-50 px-3 py-1 text-sm font-medium text-green-700 dark:bg-green-900/30 dark:text-green-200"
|
||||
>
|
||||
{item.name}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`leading-9 text-gray-900 dark:text-gray-100 ${
|
||||
isCurrent ? 'text-xl' : isFuture ? 'line-clamp-2 select-none text-base text-gray-500 blur-[1.5px] dark:text-gray-400' : 'text-base'
|
||||
}`}
|
||||
>
|
||||
{item.purpose}
|
||||
</p>
|
||||
</motion.section>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
|
||||
{isFinished && (
|
||||
<section className="shrink-0 rounded-2xl border border-gray-200 bg-white p-10 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
已完成本轮练习
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 dark:text-gray-300">
|
||||
继续巩固 49 个项目管理过程。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="mt-6 rounded-xl bg-blue-600 px-5 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
再练一轮
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!isFinished && (
|
||||
<div className="z-10 shrink-0 border-t border-gray-200 bg-white/70 pb-8 backdrop-blur-md dark:border-gray-700 dark:bg-gray-800/70">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="border-b border-gray-200/50 py-3 dark:border-gray-700/50">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{practiceMode === 'proficient' ? (
|
||||
<ProficientInputArea
|
||||
value={proficientInput}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
hasError={proficientHasError}
|
||||
onChange={(value) => {
|
||||
setProficientInput(value)
|
||||
setProficientHasError(false)
|
||||
}}
|
||||
onCompositionStart={() => handleCompositionStart()}
|
||||
onCompositionEnd={(value) => handleCompositionEnd(value)}
|
||||
showLockedHint={false}
|
||||
/>
|
||||
) : (
|
||||
<InputArea
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={(index, value) => handleCompositionEnd(index, value)}
|
||||
onPaste={handlePaste}
|
||||
showLockedHint={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={showCurrentAnswer}
|
||||
disabled={inputLocked}
|
||||
className="p-2 text-gray-500 transition-colors hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
title="查看答案(Ctrl+H)"
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Ctrl + H 查看答案
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id="aria-live-region"
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
361
src/pages/ProcessRoadmapPage.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { processes } from '@/data'
|
||||
|
||||
interface Hotspot {
|
||||
id: string
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
to: string
|
||||
}
|
||||
|
||||
const svgStyles = `
|
||||
.text { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 11px; fill: #333; text-anchor: middle; }
|
||||
.group-title { font-size: 16px; font-weight: bold; }
|
||||
.box { stroke-width: 1; rx: 4; ry: 4; }
|
||||
.box-init { fill: #FFF9E6; stroke: #FAD266; }
|
||||
.box-plan { fill: #EBF5FF; stroke: #91C7F2; }
|
||||
.box-exec { fill: #F0F9EB; stroke: #C2E7B0; }
|
||||
.box-moni { fill: #FFF9E6; stroke: #FAD266; }
|
||||
.box-close { fill: #EBF5FF; stroke: #91C7F2; }
|
||||
.box-red { fill: #FF0000; stroke: #CC0000; }
|
||||
.text-white { fill: #FFFFFF; }
|
||||
.arrow { stroke: #FF0000; stroke-width: 2; fill: none; }
|
||||
.arrow-grey { stroke: #CCC; stroke-width: 1.5; fill: none; }
|
||||
.marker { fill: #FF0000; }
|
||||
.marker-grey { fill: #CCC; }
|
||||
`
|
||||
|
||||
const staticSvgBody = `
|
||||
<!-- 1. 启动过程组 -->
|
||||
<rect x="20" y="20" width="100" height="350" class="box box-init" />
|
||||
<rect x="30" y="30" width="80" height="40" class="box box-init" />
|
||||
<text x="70" y="55" class="text group-title">启动</text>
|
||||
<rect x="30" y="85" width="80" height="40" class="box" style="fill:white; stroke:#EEE;" />
|
||||
<text x="70" y="110" class="text">10.1识别干系人</text>
|
||||
<rect x="30" y="140" width="80" height="30" class="box box-red" />
|
||||
<text x="70" y="159" class="text text-white">1.1制定项目章程</text>
|
||||
|
||||
<!-- 2. 规划过程组 -->
|
||||
<rect x="130" y="20" width="450" height="350" class="box box-plan" />
|
||||
<rect x="140" y="30" width="80" height="40" class="box" style="fill:#A0B7CC; stroke:none;" />
|
||||
<text x="180" y="55" class="text group-title" style="fill:white;">规划</text>
|
||||
|
||||
<!-- 规划左侧列表 -->
|
||||
<rect x="140" y="80" width="100" height="230" class="box" style="fill:#B8D5EB; stroke:none;" />
|
||||
<text x="190" y="100" class="text">2.1规划范围管理</text>
|
||||
<text x="190" y="123" class="text">3.1规划进度管理</text>
|
||||
<text x="190" y="146" class="text">4.1规划成本管理</text>
|
||||
<text x="190" y="169" class="text">5.1规划质量管理</text>
|
||||
<text x="190" y="192" class="text">6.1规划资源管理</text>
|
||||
<text x="190" y="215" class="text">7.1规划沟通管理</text>
|
||||
<text x="190" y="238" class="text">8.1规划风险管理</text>
|
||||
<text x="190" y="261" class="text">9.1规划采购管理</text>
|
||||
<text x="190" y="284" class="text">10.2规划干系人</text>
|
||||
|
||||
<!-- 规划中部流程 -->
|
||||
<rect x="250" y="30" width="70" height="30" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="285" y="49" class="text">2.2收集需求</text>
|
||||
<path d="M320,45 L345,45" class="arrow-grey" marker-end="url(#arrowhead-grey)" />
|
||||
<rect x="350" y="30" width="70" height="30" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="385" y="49" class="text">2.3定义范围</text>
|
||||
<path d="M420,45 L445,45" class="arrow-grey" marker-end="url(#arrowhead-grey)" />
|
||||
<rect x="450" y="30" width="70" height="30" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="485" y="49" class="text">2.4创建WBS</text>
|
||||
<path d="M485,60 L485,85" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="250" y="90" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="285" y="102" class="text">6.2估算活动</text>
|
||||
<text x="285" y="117" class="text">资源</text>
|
||||
<path d="M285,125 L285,145" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="350" y="90" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="385" y="102" class="text">3.3排列活动</text>
|
||||
<text x="385" y="117" class="text">顺序</text>
|
||||
<path d="M350,107 L325,107" class="arrow-grey" marker-end="url(#arrowhead-grey)" />
|
||||
|
||||
<rect x="450" y="90" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="485" y="110" class="text">3.2定义活动</text>
|
||||
<path d="M450,107 L425,107" class="arrow-grey" marker-end="url(#arrowhead-grey)" />
|
||||
|
||||
<rect x="250" y="150" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="285" y="162" class="text">3.4估算活动</text>
|
||||
<text x="285" y="177" class="text">持续时间</text>
|
||||
<path d="M320,167 L345,167" class="arrow-grey" marker-end="url(#arrowhead-grey)" />
|
||||
|
||||
<rect x="350" y="150" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="385" y="162" class="text">3.5制定进度</text>
|
||||
<text x="385" y="177" class="text">计划</text>
|
||||
<path d="M420,167 L445,167" class="arrow-grey" marker-end="url(#arrowhead-grey)" />
|
||||
|
||||
<rect x="450" y="150" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="485" y="167" class="text">4.2估算成本</text>
|
||||
<path d="M485,185 L485,205" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="450" y="210" width="70" height="30" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="485" y="229" class="text">4.3制定预算</text>
|
||||
|
||||
<rect x="250" y="235" width="70" height="30" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="285" y="254" class="text">8.2识别风险</text>
|
||||
<path d="M320,250 L345,250" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="350" y="235" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="385" y="247" class="text">8.3实施定性</text>
|
||||
<text x="385" y="262" class="text">风险分析</text>
|
||||
<path d="M420,250 L445,250" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="450" y="235" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="485" y="247" class="text">8.4实施定量</text>
|
||||
<text x="485" y="262" class="text">风险分析</text>
|
||||
<path d="M485,270 L485,285" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="450" y="290" width="70" height="35" class="box" style="fill:#C7E1F5; stroke:none;" />
|
||||
<text x="485" y="302" class="text">8.5规划风险</text>
|
||||
<text x="485" y="317" class="text">应对</text>
|
||||
|
||||
<rect x="140" y="325" width="430" height="30" class="box box-red" />
|
||||
<text x="355" y="344" class="text text-white">1.2 制定项目管理计划</text>
|
||||
|
||||
<!-- 3. 执行过程组 -->
|
||||
<rect x="590" y="20" width="220" height="350" class="box box-exec" />
|
||||
<rect x="660" y="30" width="80" height="40" class="box" style="fill:#A5C294; stroke:none;" />
|
||||
<text x="700" y="55" class="text group-title" style="fill:white;">执行</text>
|
||||
|
||||
<rect x="600" y="35" width="50" height="30" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="625" y="54" class="text" style="font-size:9px">6.3获取资源</text>
|
||||
<path d="M625,65 L625,85" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="600" y="90" width="70" height="30" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="635" y="109" class="text">6.4建设团队</text>
|
||||
<path d="M670,105 L695,105" class="arrow" marker-end="url(#arrowhead)" />
|
||||
|
||||
<rect x="700" y="90" width="70" height="30" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="735" y="109" class="text">6.5管理团队</text>
|
||||
|
||||
<rect x="600" y="140" width="70" height="35" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="635" y="152" class="text">8.6实施风险</text>
|
||||
<text x="635" y="167" class="text">应对</text>
|
||||
|
||||
<rect x="700" y="140" width="70" height="30" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="735" y="159" class="text">9.2实施采购</text>
|
||||
|
||||
<rect x="600" y="190" width="70" height="30" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="635" y="209" class="text">7.2管理沟通</text>
|
||||
|
||||
<rect x="700" y="190" width="70" height="35" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="735" y="202" class="text">10.3干系人</text>
|
||||
<text x="735" y="217" class="text">参与管理</text>
|
||||
|
||||
<rect x="600" y="245" width="200" height="30" class="box" style="fill:#E2F0D9; stroke:none;" />
|
||||
<text x="700" y="264" class="text">5.2管理质量</text>
|
||||
|
||||
<rect x="600" y="285" width="80" height="35" class="box box-red" />
|
||||
<text x="640" y="297" class="text text-white">1.4 管理项目</text>
|
||||
<text x="640" y="312" class="text text-white">知识</text>
|
||||
|
||||
<rect x="600" y="325" width="200" height="30" class="box box-red" />
|
||||
<text x="700" y="344" class="text text-white">1.3 指导与管理项目工作</text>
|
||||
|
||||
<!-- 4. 监控过程组 -->
|
||||
<rect x="130" y="380" width="530" height="150" class="box box-moni" />
|
||||
<rect x="140" y="470" width="80" height="40" class="box" style="fill:#FCDD8B; stroke:none;" />
|
||||
<text x="180" y="495" class="text group-title">监控</text>
|
||||
|
||||
<g transform="translate(230, 390)">
|
||||
<rect x="0" y="0" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="37" y="19" class="text">2.6控制范围</text>
|
||||
<rect x="85" y="0" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="122" y="19" class="text">6.6控制资源</text>
|
||||
<rect x="170" y="0" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="207" y="19" class="text">9.3控制采购</text>
|
||||
<rect x="255" y="0" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="292" y="19" class="text">5.3控制质量</text>
|
||||
|
||||
<rect x="0" y="45" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="37" y="64" class="text">3.6控制进度</text>
|
||||
<rect x="85" y="45" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="122" y="64" class="text">7.3监督沟通</text>
|
||||
<rect x="170" y="45" width="85" height="35" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="212" y="57" class="text">10.4监督</text>
|
||||
<text x="212" y="72" class="text">干系人参与</text>
|
||||
|
||||
<rect x="85" y="95" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="122" y="114" class="text">8.7监督风险</text>
|
||||
<rect x="170" y="95" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="207" y="114" class="text">4.4控制成本</text>
|
||||
<rect x="255" y="95" width="75" height="30" class="box" style="fill:#FFF2CC; stroke:none;" />
|
||||
<text x="292" y="114" class="text">2.5确认范围</text>
|
||||
</g>
|
||||
|
||||
<rect x="575" y="400" width="75" height="35" class="box box-red" />
|
||||
<text x="612" y="412" class="text text-white">1.5监控项目</text>
|
||||
<text x="612" y="427" class="text text-white">工作</text>
|
||||
|
||||
<rect x="575" y="445" width="75" height="35" class="box box-red" />
|
||||
<text x="612" y="457" class="text text-white">1.6实施整体</text>
|
||||
<text x="612" y="472" class="text text-white">变更控制</text>
|
||||
|
||||
<!-- 5. 收尾过程组 -->
|
||||
<rect x="670" y="380" width="140" height="150" class="box box-close" />
|
||||
<rect x="700" y="470" width="80" height="40" class="box" style="fill:#A0B7CC; stroke:none;" />
|
||||
<text x="740" y="495" class="text group-title" style="fill:white;">收尾</text>
|
||||
|
||||
<rect x="690" y="400" width="100" height="35" class="box box-red" />
|
||||
<text x="740" y="412" class="text text-white">1.7 结束项目</text>
|
||||
<text x="740" y="427" class="text text-white">或阶段</text>
|
||||
|
||||
<!-- 核心流程箭头 -->
|
||||
<path d="M110,155 L140,325" class="arrow" marker-end="url(#arrowhead)" />
|
||||
<path d="M570,340 L590,340" class="arrow" marker-end="url(#arrowhead)" />
|
||||
<path d="M700,355 L700,380 L612,380 L612,395" class="arrow" marker-end="url(#arrowhead)" />
|
||||
<path d="M650,417 L685,417" class="arrow" marker-end="url(#arrowhead)" />
|
||||
`
|
||||
|
||||
export function ProcessRoadmapPage() {
|
||||
const navigate = useNavigate()
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
const processIdByCode = useMemo(
|
||||
() => new Map(processes.map((process) => [process.code, process.id])),
|
||||
[]
|
||||
)
|
||||
|
||||
const hotspots = useMemo<Hotspot[]>(() => {
|
||||
const routeByCode = (code: string) => {
|
||||
const processId = processIdByCode.get(code)
|
||||
return processId ? `/process/${processId}` : '/process-matrix'
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: '10.1', label: '10.1 识别干系人', x: 30, y: 85, w: 80, h: 40, to: routeByCode('10.1') },
|
||||
{ id: '1.1', label: '1.1 制定项目章程', x: 30, y: 140, w: 80, h: 30, to: routeByCode('1.1') },
|
||||
|
||||
{ id: '2.1', label: '2.1 规划范围管理', x: 142, y: 87, w: 96, h: 20, to: routeByCode('2.1') },
|
||||
{ id: '3.1', label: '3.1 规划进度管理', x: 142, y: 110, w: 96, h: 20, to: routeByCode('3.1') },
|
||||
{ id: '4.1', label: '4.1 规划成本管理', x: 142, y: 133, w: 96, h: 20, to: routeByCode('4.1') },
|
||||
{ id: '5.1', label: '5.1 规划质量管理', x: 142, y: 156, w: 96, h: 20, to: routeByCode('5.1') },
|
||||
{ id: '6.1', label: '6.1 规划资源管理', x: 142, y: 179, w: 96, h: 20, to: routeByCode('6.1') },
|
||||
{ id: '7.1', label: '7.1 规划沟通管理', x: 142, y: 202, w: 96, h: 20, to: routeByCode('7.1') },
|
||||
{ id: '8.1', label: '8.1 规划风险管理', x: 142, y: 225, w: 96, h: 20, to: routeByCode('8.1') },
|
||||
{ id: '9.1', label: '9.1 规划采购管理', x: 142, y: 248, w: 96, h: 20, to: routeByCode('9.1') },
|
||||
{ id: '10.2', label: '10.2 规划相关方参与', x: 142, y: 271, w: 96, h: 20, to: routeByCode('10.2') },
|
||||
|
||||
{ id: '2.2', label: '2.2 收集需求', x: 250, y: 30, w: 70, h: 30, to: routeByCode('2.2') },
|
||||
{ id: '2.3', label: '2.3 定义范围', x: 350, y: 30, w: 70, h: 30, to: routeByCode('2.3') },
|
||||
{ id: '2.4', label: '2.4 创建WBS', x: 450, y: 30, w: 70, h: 30, to: routeByCode('2.4') },
|
||||
{ id: '6.2', label: '6.2 估算活动资源', x: 250, y: 90, w: 70, h: 35, to: routeByCode('6.2') },
|
||||
{ id: '3.3', label: '3.3 排列活动顺序', x: 350, y: 90, w: 70, h: 35, to: routeByCode('3.3') },
|
||||
{ id: '3.2', label: '3.2 定义活动', x: 450, y: 90, w: 70, h: 35, to: routeByCode('3.2') },
|
||||
{ id: '3.4', label: '3.4 估算活动持续时间', x: 250, y: 150, w: 70, h: 35, to: routeByCode('3.4') },
|
||||
{ id: '3.5', label: '3.5 制定进度计划', x: 350, y: 150, w: 70, h: 35, to: routeByCode('3.5') },
|
||||
{ id: '4.2', label: '4.2 估算成本', x: 450, y: 150, w: 70, h: 35, to: routeByCode('4.2') },
|
||||
{ id: '4.3', label: '4.3 制定预算', x: 450, y: 210, w: 70, h: 30, to: routeByCode('4.3') },
|
||||
{ id: '8.2', label: '8.2 识别风险', x: 250, y: 235, w: 70, h: 30, to: routeByCode('8.2') },
|
||||
{ id: '8.3', label: '8.3 实施定性风险分析', x: 350, y: 235, w: 70, h: 35, to: routeByCode('8.3') },
|
||||
{ id: '8.4', label: '8.4 实施定量风险分析', x: 450, y: 235, w: 70, h: 35, to: routeByCode('8.4') },
|
||||
{ id: '8.5', label: '8.5 规划风险应对', x: 450, y: 290, w: 70, h: 35, to: routeByCode('8.5') },
|
||||
{ id: '1.2', label: '1.2 制定项目管理计划', x: 140, y: 325, w: 430, h: 30, to: routeByCode('1.2') },
|
||||
|
||||
{ id: '6.3', label: '6.3 获取资源', x: 600, y: 35, w: 50, h: 30, to: routeByCode('6.3') },
|
||||
{ id: '6.4', label: '6.4 建设团队', x: 600, y: 90, w: 70, h: 30, to: routeByCode('6.4') },
|
||||
{ id: '6.5', label: '6.5 管理团队', x: 700, y: 90, w: 70, h: 30, to: routeByCode('6.5') },
|
||||
{ id: '8.6', label: '8.6 实施风险应对', x: 600, y: 140, w: 70, h: 35, to: routeByCode('8.6') },
|
||||
{ id: '9.2', label: '9.2 实施采购', x: 700, y: 140, w: 70, h: 30, to: routeByCode('9.2') },
|
||||
{ id: '7.2', label: '7.2 管理沟通', x: 600, y: 190, w: 70, h: 30, to: routeByCode('7.2') },
|
||||
{ id: '10.3', label: '10.3 管理相关方参与', x: 700, y: 190, w: 70, h: 35, to: routeByCode('10.3') },
|
||||
{ id: '5.2', label: '5.2 管理质量', x: 600, y: 245, w: 200, h: 30, to: routeByCode('5.2') },
|
||||
{ id: '1.4', label: '1.4 管理项目知识', x: 600, y: 285, w: 80, h: 35, to: routeByCode('1.4') },
|
||||
{ id: '1.3', label: '1.3 指导与管理项目工作', x: 600, y: 325, w: 200, h: 30, to: routeByCode('1.3') },
|
||||
|
||||
{ id: '2.6', label: '2.6 控制范围', x: 230, y: 390, w: 75, h: 30, to: routeByCode('2.6') },
|
||||
{ id: '6.6', label: '6.6 控制资源', x: 315, y: 390, w: 75, h: 30, to: routeByCode('6.6') },
|
||||
{ id: '9.3', label: '9.3 控制采购', x: 400, y: 390, w: 75, h: 30, to: routeByCode('9.3') },
|
||||
{ id: '5.3', label: '5.3 控制质量', x: 485, y: 390, w: 75, h: 30, to: routeByCode('5.3') },
|
||||
{ id: '3.6', label: '3.6 控制进度', x: 230, y: 435, w: 75, h: 30, to: routeByCode('3.6') },
|
||||
{ id: '7.3', label: '7.3 监督沟通', x: 315, y: 435, w: 75, h: 30, to: routeByCode('7.3') },
|
||||
{ id: '10.4', label: '10.4 监督相关方参与', x: 400, y: 435, w: 85, h: 35, to: routeByCode('10.4') },
|
||||
{ id: '8.7', label: '8.7 监督风险', x: 315, y: 485, w: 75, h: 30, to: routeByCode('8.7') },
|
||||
{ id: '4.4', label: '4.4 控制成本', x: 400, y: 485, w: 75, h: 30, to: routeByCode('4.4') },
|
||||
{ id: '2.5', label: '2.5 确认范围', x: 485, y: 485, w: 75, h: 30, to: routeByCode('2.5') },
|
||||
{ id: '1.5', label: '1.5 监控项目工作', x: 575, y: 400, w: 75, h: 35, to: routeByCode('1.5') },
|
||||
{ id: '1.6', label: '1.6 实施整体变更控制', x: 575, y: 445, w: 75, h: 35, to: routeByCode('1.6') },
|
||||
{ id: '1.7', label: '1.7 结束项目或阶段', x: 690, y: 400, w: 100, h: 35, to: routeByCode('1.7') },
|
||||
|
||||
]
|
||||
}, [processIdByCode])
|
||||
|
||||
const handleOpen = (to: string) => {
|
||||
if (to.startsWith('/process/')) {
|
||||
navigate(to, { state: { from: 'roadmap' } })
|
||||
return
|
||||
}
|
||||
navigate(to)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">五组十域流程总览图</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-2">
|
||||
<svg
|
||||
width="1260"
|
||||
height="840"
|
||||
viewBox="0 0 840 560"
|
||||
className="block w-full h-auto max-w-[1260px] mx-auto"
|
||||
>
|
||||
<style>{svgStyles}</style>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" className="marker" />
|
||||
</marker>
|
||||
<marker id="arrowhead-grey" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
|
||||
<path d="M0,0 L5,2.5 L0,5 Z" className="marker-grey" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g dangerouslySetInnerHTML={{ __html: staticSvgBody }} />
|
||||
|
||||
{hotspots.map((spot) => {
|
||||
const isHovered = hoveredId === spot.id
|
||||
return (
|
||||
<g
|
||||
key={spot.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleOpen(spot.to)}
|
||||
onMouseEnter={() => setHoveredId(spot.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onFocus={() => setHoveredId(spot.id)}
|
||||
onBlur={() => setHoveredId(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
handleOpen(spot.to)
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<title>{`${spot.label}(点击进入)`}</title>
|
||||
<rect
|
||||
x={spot.x}
|
||||
y={spot.y}
|
||||
width={spot.w}
|
||||
height={spot.h}
|
||||
fill={isHovered ? 'rgba(245, 158, 11, 0.12)' : 'transparent'}
|
||||
stroke={isHovered ? '#F59E0B' : 'transparent'}
|
||||
strokeWidth={1.5}
|
||||
rx={4}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Sun, Moon, Palette } from 'lucide-react'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { Sun, Moon, Palette, BrainCircuit } from 'lucide-react'
|
||||
import { useAppStore, type PracticeMode } from '@/stores/useAppStore'
|
||||
|
||||
const PRACTICE_MODE_OPTIONS: Array<{
|
||||
value: PracticeMode
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
value: 'standard',
|
||||
label: '标准模式',
|
||||
description: '按当前顺序逐项、逐字符输入,适合建立记忆路径。',
|
||||
},
|
||||
{
|
||||
value: 'proficient',
|
||||
label: '熟练模式',
|
||||
description: '单输入框自动核对,组内可乱序输入,适合冲刺强化。',
|
||||
},
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const { darkMode, setDarkMode } = useAppStore()
|
||||
const { darkMode, setDarkMode, practiceMode, setPracticeMode } = useAppStore()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -68,18 +85,111 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 关于 */}
|
||||
{/* 练习设置 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<BrainCircuit size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">练习设置</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
过程详情页练习模式
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
标准模式适合逐步记忆,熟练模式适合在输入、工具、输出三个分组中自由回忆。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{PRACTICE_MODE_OPTIONS.map((option) => {
|
||||
const isSelected = practiceMode === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setPracticeMode(option.value)}
|
||||
className={`text-left rounded-xl border-2 p-4 transition-all ${
|
||||
isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={`font-semibold ${
|
||||
isSelected
|
||||
? 'text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
||||
当前使用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 p-6"
|
||||
>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white mb-4">联系作者</h2>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img
|
||||
src="/wechat-qrcode.jpg"
|
||||
alt="微信二维码"
|
||||
className="w-48 rounded-lg border border-gray-200 dark:border-gray-600"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
扫一扫上面的二维码图案,加我为朋友
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 关于 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 p-6"
|
||||
>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white mb-4">关于 ITTOView</h2>
|
||||
<div className="space-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="space-y-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>版本:1.0.0</p>
|
||||
<p>基于 PMBOK 第6版</p>
|
||||
<p>包含 49 个项目管理过程的完整 ITTO 数据</p>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<a
|
||||
href="/doc"
|
||||
className="inline-flex items-center rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700 transition-colors hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50"
|
||||
>
|
||||
API 调用说明
|
||||
</a>
|
||||
<a
|
||||
href="/apidoc"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center rounded-lg bg-gray-100 px-3 py-2 font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Markdown 接口文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,7 @@ export function ToolDetailPage() {
|
||||
|
||||
{usedByProcesses.length === 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700 text-center text-gray-500 dark:text-gray-400">
|
||||
暂无使用此工具的过程记录
|
||||
未找到相关过程
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,12 @@ export { KnowledgeAreasPage } from './KnowledgeAreasPage'
|
||||
export { ProcessGroupsPage } from './ProcessGroupsPage'
|
||||
export { ProcessDetailPage } from './ProcessDetailPage'
|
||||
export { ProcessMatrixPage } from './ProcessMatrixPage'
|
||||
export { ProcessRoadmapPage } from './ProcessRoadmapPage'
|
||||
export { ProcessGraphPage } from './ProcessGraphPage'
|
||||
export { ArtifactDetailPage } from './ArtifactDetailPage'
|
||||
export { ToolDetailPage } from './ToolDetailPage'
|
||||
export { SettingsPage } from './SettingsPage'
|
||||
|
||||
export { PerformanceDomainsPage } from './PerformanceDomainsPage'
|
||||
export { LearningMapsPage } from './LearningMapsPage'
|
||||
export { IttoCollectionsPage } from './IttoCollectionsPage'
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type PracticeMode = 'standard' | 'proficient'
|
||||
|
||||
interface AppState {
|
||||
// UI状态
|
||||
sidebarOpen: boolean
|
||||
darkMode: boolean
|
||||
searchQuery: string
|
||||
matrixFullScreen: boolean
|
||||
practiceMode: PracticeMode
|
||||
|
||||
// 操作
|
||||
toggleSidebar: () => void
|
||||
@@ -15,6 +18,7 @@ interface AppState {
|
||||
setDarkMode: (dark: boolean) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setMatrixFullScreen: (fullScreen: boolean) => void
|
||||
setPracticeMode: (mode: PracticeMode) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -25,6 +29,7 @@ export const useAppStore = create<AppState>()(
|
||||
darkMode: false,
|
||||
searchQuery: '',
|
||||
matrixFullScreen: false,
|
||||
practiceMode: 'standard',
|
||||
|
||||
// 操作方法
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
@@ -33,6 +38,7 @@ export const useAppStore = create<AppState>()(
|
||||
setDarkMode: (dark) => set({ darkMode: dark }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }),
|
||||
setPracticeMode: (mode) => set({ practiceMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'ittoview-app-storage',
|
||||
@@ -40,6 +46,7 @@ export const useAppStore = create<AppState>()(
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
darkMode: state.darkMode,
|
||||
matrixFullScreen: state.matrixFullScreen,
|
||||
practiceMode: state.practiceMode,
|
||||
// searchQuery 不持久化到 localStorage,刷新后重置
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
* PMP项目管理ITTO可视化学习平台
|
||||
*/
|
||||
|
||||
// 敏捷裁剪因素项
|
||||
export interface TailoringFactor {
|
||||
title: string; // 因素标题
|
||||
description: string; // 因素描述
|
||||
}
|
||||
|
||||
// 知识领域
|
||||
export interface KnowledgeArea {
|
||||
id: string; // 如 "KA01"
|
||||
@@ -13,6 +19,7 @@ export interface KnowledgeArea {
|
||||
color: string; // 主题色
|
||||
description: string; // 简要描述
|
||||
processCount: number; // 包含的过程数量
|
||||
tailoringFactors?: TailoringFactor[]; // 敏捷裁剪因素(可选)
|
||||
}
|
||||
|
||||
// 过程组
|
||||
@@ -36,6 +43,23 @@ export interface Process5W1H {
|
||||
how: string; // 如何执行(关键方法)
|
||||
}
|
||||
|
||||
// 明细项
|
||||
export interface DetailItem {
|
||||
id?: string; // 可选:若明细在工具库里注册,可复用该 ID
|
||||
label: string; // 明细名称
|
||||
description?: string; // 明细描述
|
||||
}
|
||||
|
||||
// 过程实体使用(支持明细)
|
||||
export interface ProcessEntityUse {
|
||||
id: string; // 实体ID(工件/工具ID)
|
||||
detail?: DetailItem[]; // 明细列表
|
||||
note?: string; // 针对此过程的补充说明
|
||||
}
|
||||
|
||||
// 过程引用类型(字符串ID 或 带明细的对象)
|
||||
export type ProcessRef = string | ProcessEntityUse;
|
||||
|
||||
// 过程
|
||||
export interface Process {
|
||||
id: string; // 如 "P4.1"
|
||||
@@ -45,9 +69,10 @@ export interface Process {
|
||||
knowledgeAreaId: string; // 所属知识领域ID
|
||||
processGroupId: string; // 所属过程组ID
|
||||
order: number; // 在知识领域内的序号
|
||||
inputs: string[]; // 输入工件ID列表
|
||||
tools: string[]; // 工具与技术ID列表
|
||||
outputs: string[]; // 输出工件ID列表
|
||||
inputs: ProcessRef[]; // 输入工件ID列表(支持明细)
|
||||
tools: ProcessRef[]; // 工具与技术ID列表(支持明细)
|
||||
outputs: ProcessRef[]; // 输出工件ID列表(支持明细)
|
||||
purpose?: string; // 本过程的主要作用
|
||||
w5h1?: Process5W1H; // 5W1H记忆辅助信息
|
||||
}
|
||||
|
||||
@@ -127,6 +152,26 @@ export interface LearningStats {
|
||||
lastStudyDate: Date;
|
||||
}
|
||||
|
||||
// 更新类型
|
||||
export type ChangelogType =
|
||||
| 'feat'
|
||||
| 'fix'
|
||||
| 'style'
|
||||
| 'refactor'
|
||||
| 'docs'
|
||||
| 'perf'
|
||||
| 'test'
|
||||
| 'chore';
|
||||
|
||||
// 更新记录
|
||||
export interface ChangelogEntry {
|
||||
id?: string; // 可选:推荐提供,便于稳定渲染和后续扩展
|
||||
date: string; // 日期,格式如 "2026-03-08"
|
||||
type: ChangelogType; // 更新类型
|
||||
title: string; // 更新标题
|
||||
scope?: string; // 关联范围,如"整合""风险""矩阵"
|
||||
}
|
||||
|
||||
// 数据流向关系
|
||||
export interface DataFlow {
|
||||
sourceProcessId: string; // 源过程ID
|
||||
|
||||
30
src/types/timeline.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 时间轴数据类型定义
|
||||
* 用于软考高项时间类知识点的结构化存储与展示
|
||||
*/
|
||||
|
||||
// 时间精度
|
||||
export type TimelineTimePrecision = 'year' | 'month' | 'day';
|
||||
|
||||
// 来源锚点类型
|
||||
export type SourceAnchorType =
|
||||
| 'meeting' // 会议
|
||||
| 'document' // 文件
|
||||
| 'organization' // 组织
|
||||
| 'person' // 人物
|
||||
| 'policy' // 政策
|
||||
| 'report' // 报告
|
||||
| 'other'; // 其他
|
||||
|
||||
// 时间轴节点
|
||||
export interface TimelineItem {
|
||||
id: string; // 唯一标识,如 TL001
|
||||
timeText: string; // 原文中的显式时间,如“2035年”
|
||||
sortKey: string; // 排序键,如“20350000”
|
||||
timePrecision: TimelineTimePrecision; // 时间精度:年/月/日
|
||||
theme: string; // 所属主题/章节
|
||||
excerpt: string; // 原文摘录
|
||||
sourceAnchor?: string; // 来源锚点,如“党的十九届五中全会”
|
||||
sourceAnchorType?: SourceAnchorType; // 来源锚点类型
|
||||
sourcePosition?: string; // 教材定位信息,如“第3章 第2节”
|
||||
}
|
||||
143
src/utils/practice.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
knowledgeAreas,
|
||||
processGroups,
|
||||
processes,
|
||||
} from '@/data'
|
||||
import type { Process } from '@/types/itto'
|
||||
|
||||
export interface CellInfo {
|
||||
id: string // 格子唯一标识
|
||||
type: 'knowledge-area' | 'process'
|
||||
knowledgeAreaId: string
|
||||
processGroupId?: string // 知识领域格子无此字段
|
||||
processId?: string // 过程格子才有
|
||||
answer: string // 正确答案(原始)
|
||||
normalizedAnswer: string // 标准化答案(用于比对)
|
||||
order: number // 全局顺序
|
||||
}
|
||||
|
||||
const FULL_WIDTH_TO_HALF_WIDTH_MAP: Record<string, string> = {
|
||||
'(': '(',
|
||||
')': ')',
|
||||
'【': '[',
|
||||
'】': ']',
|
||||
'{': '{',
|
||||
'}': '}',
|
||||
'《': '<',
|
||||
'》': '>',
|
||||
'“': '"',
|
||||
'”': '"',
|
||||
'‘': "'",
|
||||
'’': "'",
|
||||
':': ':',
|
||||
';': ';',
|
||||
',': ',',
|
||||
'。': '.',
|
||||
'、': ',',
|
||||
'!': '!',
|
||||
'?': '?',
|
||||
'—': '-',
|
||||
'-': '-',
|
||||
'·': '',
|
||||
' ': ' ',
|
||||
}
|
||||
|
||||
/**
|
||||
* 答案标准化函数
|
||||
* @param str 原始字符串
|
||||
* @param isKnowledgeArea 是否为知识领域(只对知识领域去除"项目"前缀)
|
||||
*/
|
||||
export function normalizeAnswer(
|
||||
str: string,
|
||||
isKnowledgeArea: boolean = false
|
||||
): string {
|
||||
let normalized = Array.from(str)
|
||||
.map((char) => FULL_WIDTH_TO_HALF_WIDTH_MAP[char] ?? char)
|
||||
.join('')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[,:;.!?"'()\[\]{}<>,。、;:?!“”‘’()【】《》]/g, '')
|
||||
|
||||
// 只对知识领域去除"项目"前缀
|
||||
if (isKnowledgeArea) {
|
||||
normalized = normalized.replace(/^项目/, '')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成格子顺序列表
|
||||
* 顺序:KA01 → P1.1 → P1.2 → ... → P1.7 → KA02 → P2.1 → ...
|
||||
*/
|
||||
export function generateCellSequence(): CellInfo[] {
|
||||
const sequence: CellInfo[] = []
|
||||
let order = 0
|
||||
|
||||
// 确保数据源按 order 字段排序
|
||||
const sortedKAs = [...knowledgeAreas].sort((a, b) => a.order - b.order)
|
||||
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
|
||||
|
||||
sortedKAs.forEach((ka) => {
|
||||
// 1. 添加知识领域格子
|
||||
sequence.push({
|
||||
id: `ka-${ka.id}`,
|
||||
type: 'knowledge-area',
|
||||
knowledgeAreaId: ka.id,
|
||||
answer: ka.name,
|
||||
normalizedAnswer: normalizeAnswer(ka.name, true),
|
||||
order: order++,
|
||||
})
|
||||
|
||||
// 2. 添加该知识领域下的所有过程格子(按过程组顺序)
|
||||
sortedPGs.forEach((pg) => {
|
||||
const kaProcesses = processes
|
||||
.filter((p) => p.knowledgeAreaId === ka.id && p.processGroupId === pg.id)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
kaProcesses.forEach((p) => {
|
||||
sequence.push({
|
||||
id: `process-${p.id}`,
|
||||
type: 'process',
|
||||
knowledgeAreaId: ka.id,
|
||||
processGroupId: pg.id,
|
||||
processId: p.id,
|
||||
answer: p.name,
|
||||
normalizedAnswer: normalizeAnswer(p.name, false),
|
||||
order: order++,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return sequence
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定知识领域和过程组下的所有过程
|
||||
*/
|
||||
export function getProcessesByKaAndPg(
|
||||
knowledgeAreaId: string,
|
||||
processGroupId: string
|
||||
): Process[] {
|
||||
return processes
|
||||
.filter(
|
||||
(p) =>
|
||||
p.knowledgeAreaId === knowledgeAreaId &&
|
||||
p.processGroupId === processGroupId
|
||||
)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
/**
|
||||
* 无障碍通告函数
|
||||
*/
|
||||
export function announceToScreenReader(message: string): void {
|
||||
const liveRegion = document.getElementById('aria-live-region')
|
||||
if (liveRegion) {
|
||||
liveRegion.textContent = message
|
||||
setTimeout(() => {
|
||||
liveRegion.textContent = ''
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
41
src/utils/tailoringPractice.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { knowledgeAreas } from '@/data'
|
||||
import { normalizeAnswer } from '@/utils/practice'
|
||||
|
||||
export interface TailoringPracticeItem {
|
||||
id: string
|
||||
knowledgeAreaId: string
|
||||
knowledgeAreaName: string
|
||||
knowledgeAreaColor: string
|
||||
knowledgeAreaOrder: number
|
||||
factorIndex: number
|
||||
title: string
|
||||
description: string
|
||||
normalizedAnswer: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成敏捷裁剪因素练习序列
|
||||
* 顺序:按知识领域排序,再按该知识领域中的裁剪因素原始顺序展开
|
||||
*/
|
||||
export function generateTailoringPracticeItems(): TailoringPracticeItem[] {
|
||||
const sortedKnowledgeAreas = [...knowledgeAreas].sort((a, b) => a.order - b.order)
|
||||
const items: TailoringPracticeItem[] = []
|
||||
|
||||
sortedKnowledgeAreas.forEach((knowledgeArea) => {
|
||||
knowledgeArea.tailoringFactors?.forEach((factor, index) => {
|
||||
items.push({
|
||||
id: `${knowledgeArea.id}-factor-${index + 1}`,
|
||||
knowledgeAreaId: knowledgeArea.id,
|
||||
knowledgeAreaName: knowledgeArea.name,
|
||||
knowledgeAreaColor: knowledgeArea.color,
|
||||
knowledgeAreaOrder: knowledgeArea.order,
|
||||
factorIndex: index,
|
||||
title: factor.title,
|
||||
description: factor.description,
|
||||
normalizedAnswer: normalizeAnswer(factor.title, false),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -5,7 +5,7 @@ import path from 'path'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
base: '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||