Compare commits

..

209 Commits

Author SHA1 Message Date
54554ba0db 上传文件至 src/data/image
计算一页纸
2026-05-24 06:58:13 +08:00
ittoview
74b2eb0ec3 feat: add performance domains learning image 2026-05-23 23:54:23 +01:00
ittoview
529885176d feat: restore contract classification image 2026-05-23 06:21:43 +01:00
ittoview
69dfdcc094 style: refine ITTO sticky highlight bar 2026-05-23 03:56:20 +01:00
ittoview
8a66eb50e1 feat: keep ITTO highlight controls sticky 2026-05-23 03:47:58 +01:00
ittoview
d3ae9697ef feat: add ITTO text highlight filter 2026-05-23 03:39:40 +01:00
ittoview
8ca1d820aa fix: avoid performance domain list disappearing on return 2026-05-23 03:31:59 +01:00
ittoview
c49ff2a6fc chore: cache learning map images 2026-05-22 08:36:51 +01:00
ittoview
828ee0c1c0 feat: persist learning map images and upload skill 2026-05-22 08:06:03 +01:00
ittoview
11dd75d0cc fix: update stakeholder performance key points 2026-05-21 17:57:51 +01:00
ittoview
ddc5901015 feat: add input-output table menu 2026-05-18 18:01:34 +01:00
ittoview
93b9a031b4 fix: show ITTO rows by process 2026-05-18 17:54:09 +01:00
ittoview
48dd37554e fix: restore ITTO collection header 2026-05-18 17:34:27 +01:00
ittoview
f9cb27b14e feat: add ITTO collections view 2026-05-18 16:12:41 +01:00
ittoview
35c0146a96 feat: add performance challenge mode 2026-05-16 12:19:01 +01:00
ittoview
3f90031185 fix: remove sticky hover style from practice options 2026-05-16 12:10:26 +01:00
ittoview
183a5ed285 fix: emphasize correct mobile option 2026-05-15 17:42:16 +01:00
ittoview
d8432b2c64 fix: remove sticky mobile practice options 2026-05-15 17:37:19 +01:00
ittoview
23e732fdfe fix: lift mobile practice options 2026-05-15 17:33:51 +01:00
ittoview
6d6b9fb381 fix: compact mobile feedback area 2026-05-15 17:29:54 +01:00
ittoview
a1db3794bd fix: hide verbose feedback on mobile 2026-05-15 17:26:12 +01:00
ittoview
53706bb3e4 fix: optimize performance practice options on mobile 2026-05-15 17:22:48 +01:00
ittoview
cb0af5eba4 fix: adjust practice question label and action alignment 2026-05-14 02:33:41 +01:00
ittoview
5d30ad6d04 docs: add performance domain practice changelog 2026-05-13 19:39:43 +01:00
ittoview
ae7391ecab fix: scope option disable logic 2026-05-13 19:36:00 +01:00
ittoview
643aeb498d fix: show relevant option progress for scoped practice 2026-05-13 19:29:22 +01:00
ittoview
45dd71311f fix: keep completed options dimmed during feedback 2026-05-13 19:26:16 +01:00
ittoview
0be095d583 fix: disable domain option only when fully complete 2026-05-13 19:18:34 +01:00
ittoview
bf81e6d225 fix: restore option highlight and reduce height 2026-05-13 19:15:25 +01:00
ittoview
ea2413cb24 fix: stabilize answer option highlights 2026-05-13 19:12:06 +01:00
ittoview
77183cb340 feat: show domain option progress 2026-05-13 19:08:55 +01:00
ittoview
6b180576b4 fix: avoid wrong answer highlight layout shift 2026-05-13 19:04:26 +01:00
ittoview
25dabc699b fix: auto advance correct performance answers 2026-05-13 19:01:02 +01:00
ittoview
a696fc8ec1 fix: clean performance practice feedback copy 2026-05-13 18:54:56 +01:00
ittoview
49dcbc4e59 fix: simplify performance domain practice layout 2026-05-13 18:44:57 +01:00
ittoview
67436d20fc fix: stabilize performance domain practice ui 2026-05-13 18:37:56 +01:00
ittoview
f5d0e5bc0c feat: refine performance domain practice flow 2026-05-13 18:20:35 +01:00
ittoview
c5e71812d4 feat: add performance domain practice page 2026-05-13 18:02:52 +01:00
ittoview
3a338fe059 fix: increase current card top offset 2026-05-11 10:07:05 +01:00
ittoview
ade0c6fd6b fix: add top breathing room for current card 2026-05-11 10:03:43 +01:00
ittoview
2821d21221 fix: use single scrolling practice track 2026-05-11 10:00:58 +01:00
ittoview
2d8ee406f5 fix: keep preview in card flow 2026-05-11 09:56:02 +01:00
ittoview
d986932e85 fix: anchor current practice card higher 2026-05-11 09:53:13 +01:00
ittoview
755b1c5c95 fix: keep current practice card visible 2026-05-11 09:50:21 +01:00
ittoview
1a4c524635 fix: adjust current card position 2026-05-11 09:48:07 +01:00
ittoview
f0c7145309 fix: prevent practice card jitter 2026-05-11 09:46:56 +01:00
ittoview
a80fa715c3 fix: emphasize current practice card 2026-05-11 09:45:20 +01:00
ittoview
fdc2097f67 feat: add stacked practice cards 2026-05-11 09:42:19 +01:00
ittoview
bd181a5d5b feat: add process purpose practice entry 2026-05-10 16:06:04 +01:00
ittoview
3f1d5bc221 fix: compact answer badge 2026-05-10 15:57:57 +01:00
ittoview
834fb37616 fix: refine purpose practice feedback 2026-05-10 15:52:14 +01:00
ittoview
f6f165e148 fix: refine answer display in purpose practice 2026-05-10 15:43:34 +01:00
ittoview
a1b8f3064d feat: support practice input modes 2026-05-10 15:36:24 +01:00
ittoview
ac55300e69 fix: align process purpose practice interactions 2026-05-10 15:24:22 +01:00
ittoview
69d2752104 feat: add process purpose practice page 2026-05-10 13:37:39 +01:00
ittoview
22d9abe0f2 fix: ensure apidoc uses utf-8 text 2026-05-10 12:19:51 +01:00
ittoview
71b8b34df0 feat: add API links to settings 2026-05-10 12:14:43 +01:00
ittoview
1a8948761b feat: expose API markdown doc 2026-05-09 17:04:22 +01:00
ittoview
693aa7df61 chore: generate API data during build 2026-05-09 16:55:22 +01:00
ittoview
1e38167f15 feat: add knowledge API docs 2026-05-09 16:52:07 +01:00
ittoview
1b9f6da480 fix: guard learning map pinch zoom state 2026-05-03 07:32:19 +01:00
ittoview
667553649f feat: lazy load learning map images 2026-05-03 04:01:50 +01:00
ittoview
cf96a9727c feat: add learning maps navigation 2026-04-28 09:10:02 +01:00
ittoview
d897bf3a68 chore: update learning map image names 2026-04-28 09:05:48 +01:00
ittoview
297728c367 feat: add learning maps viewer 2026-04-28 09:05:16 +01:00
ittoview
daf94170df 移除侧边栏图标边框 2026-04-28 09:00:59 +01:00
ittoview
2eb4788e0e 配置站点图标 2026-04-28 08:58:43 +01:00
ittoview
62b63c79e3 docs(changelog): 调整裁剪因素更新文案 2026-04-26 08:55:23 +01:00
ittoview
06a45c4716 添加裁剪因素菜单并更新变更记录 2026-04-26 08:22:40 +01:00
ittoview
a15c8b60d8 修正裁剪因素页底部浮层与内容块样式 2026-04-26 08:13:13 +01:00
ittoview
6b0f9d0f80 优化知识领域裁剪因素页布局 2026-04-26 08:02:54 +01:00
ittoview
ce3b7859cf feat: 新增知识领域敏捷裁剪因素页面 2026-04-26 07:42:08 +01:00
ittoview
6c7cbbba47 fix(采购): 修复控制采购输出,移除多余项目管理计划,明细归入项目管理计划更新 2026-04-15 02:07:54 +01:00
ittoview
89a89358a4 feat: 新增八大绩效域页面与导航入口 2026-04-10 16:20:01 +01:00
ittoview
4e7831ac48 feat: 新增八大绩效域目录页并更新部署配置 2026-04-10 15:11:15 +01:00
ittoview
566eb3492f fix(过程详情): 恢复练习模式查看答案快捷键 2026-03-24 05:49:41 +00:00
ittoview
d9bbd28da5 feat(过程详情): 新增ITTO熟练模式并优化练习交互 2026-03-23 15:24:16 +00:00
ittoview
b09e203a90 feat(命令): 新增时间轴数据维护 Skill 命令 2026-03-22 14:59:59 +00:00
ittoview
acaffa75e9 feat: 新增软考高项时间轴数据骨架 2026-03-22 14:14:47 +00:00
ittoview
9b02e707fb feat(质量): 规划质量管理工具更新,新增测试与检查的规划 2026-03-19 02:28:44 +00:00
ittoview
2e271a295b fix(整合): 修复十二项原则布局、格子配色及菜单位置 2026-03-18 16:34:49 +00:00
ittoview
2dbc2a5e0a feat(整合): 新增十二项原则页面,支持查看表格与打字填空练习 2026-03-18 15:52:39 +00:00
ittoview
a0c38fe9d4 docs(整合): 补充庆祝动画更新记录
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-09 14:47:58 +00:00
ittoview
0b5f35d5b9 feat(练习): 添加完成练习时的庆祝动画
- 创建 CelebrationAnimation 组件,使用 CSS 动画实现彩带效果
- 矩阵练习完成最后一个格子时显示庆祝动画
- 过程详情练习完成所有条目时显示庆祝动画
- 动画持续2秒后自动消失

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-09 14:36:53 +00:00
ittoview
4f76fec906 docs(整合): 补充最近5条练习模式相关更新记录
- 2026-03-09: 添加 Ctrl+H 快捷键显示答案
- 2026-03-08: 修正底部固定区域偏移方式
- 2026-03-08: 优化辅助信息显示和布局
- 2026-03-07: 答对后显示ITTO明细信息
- 2026-03-02: 修复练习模式输入框完全无法输入的问题

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-09 14:20:26 +00:00
ittoview
a7229e40f0 feat(练习): 添加 Ctrl+H 快捷键显示答案
- ProcessPracticePage: 按住 Ctrl+H 显示答案,松开隐藏
- ProcessDetailPage: 按住 Ctrl+H 显示答案,松开隐藏
- 解决手在键盘上时不方便点击屏幕查看答案的问题

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-09 14:16:48 +00:00
ittoview
bece501657 feat(整合): 补全更新日志至项目初始提交,共127条记录
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 04:09:31 +00:00
ittoview
cdf0009602 style(整合): 优化更新日志显示,同日期更新合并为单卡片行式布局
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:46:11 +00:00
ittoview
18461b685c fix(整合): 使用 React Portal 修复模态框层级遮挡问题
根本原因:
- 模态框被渲染在 Header 内部
- Header 本身是 z-10 的层叠上下文
- 导致模态框无法超越侧边栏(z-30)和全屏矩阵(z-50)等根级元素

解决方案:
- 使用 createPortal 将模态框直接渲染到 document.body
- 脱离 Header 的层叠上下文限制
- z-[9999]/z-[10000] 现在真正作用在根层级

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:43:24 +00:00
ittoview
f67f84f24b feat(整合): 优化更新日志显示,按日期分组并突出日期标题
- 实现按日期分组显示更新记录
- 将日期标题放在卡片上方,使用更大的字号(text-lg)
- 添加日期图标和分隔线,增强视觉层次
- 移除时间轴竖线,改用日期分组的扁平化布局
- 优化卡片间距和动画效果

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:38:57 +00:00
ittoview
b2ec80c199 fix(整合): 再次提升更新日志模态框层级至最高优先级
- 将背景遮罩 z-index 从 z-[100] 提升到 z-[9999]
- 将模态框内容 z-index 从 z-[101] 提升到 z-[10000]
- 确保模态框在所有可能的元素之上显示,包括 Header 搜索下拉等

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:36:50 +00:00
ittoview
b860ed67ea docs(整合): 补全 changelog.json 缺失的更新记录
- 添加 2026-03-08 的 refactor 提交记录
- 补充 2026-03-03 至 2026-03-02 期间的10条更新记录
- 涵盖练习模式、设置页面、资源管理等模块的更新
- 现在 changelog 包含最近30条完整的提交历史

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:32:08 +00:00
ittoview
6548032b06 fix(整合): 修复更新日志模态框层级被其他元素遮挡的问题
- 将模态框背景遮罩 z-index 从 z-50 提升到 z-[100]
- 将模态框内容 z-index 从 z-50 提升到 z-[101]
- 确保模态框在所有其他元素(Header 搜索、Sidebar、全屏矩阵等)之上显示

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:30:20 +00:00
ittoview
c15e54fd8c docs(整合): 在 CLAUDE.md 中添加提交前更新 changelog 的规范
- 在操作流程规范中增加「更新 changelog」步骤
- 新增「更新 changelog 规范」章节,详细说明字段格式和注意事项
- 要求每次提交前必须更新 src/data/changelog.json
- 提供完整的字段说明和示例代码

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:28:12 +00:00
ittoview
c5c19362c5 refactor(整合): 将更新日志改为模态框实现并补充最近20条更新记录
- 将 ChangelogPage 改为 ChangelogModal 模态框组件
- 移除 /changelog 和 /updates 路由,改为模态框弹出
- 修改 Header 按钮点击行为,触发模态框而非路由跳转
- 根据最近20条 git 提交记录补充更新数据
- 优化模态框样式,支持响应式布局和深色模式
- 修复 JSON 中的引号转义问题

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:26:34 +00:00
ittoview
8a02139c85 feat(整合): 新增更新时间轴浏览页面与顶部快捷入口
- 创建 src/data/changelog.json 数据文件
- 添加 ChangelogType 和 ChangelogEntry 类型定义
- 实现更新时间轴页面组件,支持按时间倒序展示
- 添加 /changelog 主路由和 /updates 别名路由
- 在顶部导航右侧添加 History 图标入口,支持激活态高亮
- 使用 Framer Motion 实现渐进式动画效果
- 支持深色模式和响应式布局

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 03:13:33 +00:00
ittoview
9f43f1e0e8 fix(练习): 修正底部固定区域偏移方式
将 mb-8 改为 pb-8,使 sticky bottom-0 元素真正向上偏移

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 01:44:56 +00:00
ittoview
27200e5cd7 feat(练习): 优化辅助信息显示和布局
- 裁剪因素只显示标题,用分号分隔(不显示描述问题)
- 裁剪因素和主要作用字号从 text-xs/text-sm 增大到 text-base
- 输入区域 py-4 改为 py-3,向上收紧
- 底部固定区域添加 mb-8 增加底部偏移
- 辅助信息区域 max-h-48 改为 max-h-40

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-08 01:38:18 +00:00
ittoview
da18f99863 feat(练习): 答对后显示ITTO明细信息
- PracticeItem 接口添加 originalData 字段保留原始数据
- practiceItems 构建时保存完整的 detail/nameEn/note 信息
- PracticeList 答对状态下显示英文名称、明细列表、备注

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-07 15:24:31 +00:00
ittoview
a74741b8a1 fix(相关方): 修复P10.4监督干系人参与输出重复项目管理计划
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-04 15:55:56 +00:00
ittoview
780550bd3c fix(相关方): 修复P10.3管理干系人参与输出重复项目管理计划
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-04 15:54:34 +00:00
ittoview
12c4759a00 fix(干系人管理): 修正识别干系人输出项目管理计划更新
P10.1识别干系人输出修正:
- 删除重复的A078项目管理计划更新
- 将A008项目管理计划改为A078项目管理计划更新
- 明细保持不变:需求管理计划、沟通管理计划、风险管理计划、干系人参与计划

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:44:03 +00:00
ittoview
ff85290891 fix(采购管理): 修正实施采购输出项目文件更新明细
P9.2实施采购输出A077项目文件更新明细中,将"经验教训登记册"改为"经验教训登记表"

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:40:38 +00:00
ittoview
2dceee7788 fix(风险管理): 修正实施定量风险分析工具从决策树分析改为数据分析
P8.4实施定量风险分析工具从TT079(决策树分析)改为TT008(数据分析)
明细保持不变:模拟、敏感性分析、决策树分析、影响图

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:34:32 +00:00
ittoview
40dafe1401 fix(风险管理): 修正识别风险输出项目文件更新明细
P8.2识别风险输出A077项目文件更新明细中,将"经验教训登记册"改为"经验教训登记表"

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:30:12 +00:00
ittoview
ecd60de827 feat(沟通管理): 为管理沟通输出项目沟通记录添加明细
P7.2管理沟通输出A054项目沟通记录添加明细:
- 绩效报告
- 可交付成果的状态
- 进度展示
- 产生的成本
- 演示
- 以及干系人需要的其他信息

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:24:53 +00:00
ittoview
77b2075f4e feat(沟通管理): 新增项目报告工具并修正管理沟通工具列表
- 新增TT134项目报告工具
- P7.2管理沟通修正工具列表顺序和明细
- TT088沟通技能添加明细:沟通胜任力、反馈、非口头技能、演示
- 将TT090演示替换为TT134项目报告

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:22:06 +00:00
ittoview
a3773abbd6 feat(沟通管理): 新增沟通需求分析工具并修正规划沟通管理工具列表
- 新增TT133沟通需求分析工具
- P7.1规划沟通管理删除TT008数据分析
- P7.1规划沟通管理在第二位置插入TT133沟通需求分析

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:14:25 +00:00
ittoview
831dd11f3a feat(资源管理): 新增事业环境因素更新并修正资源管理过程输出
- 新增A094事业环境因素更新工件
- P6.3获取资源输出改为事业环境因素更新和组织过程资产更新
- P6.4建设团队输出改为事业环境因素更新和组织过程资产更新
- P6.5管理团队输出改为事业环境因素更新和组织过程资产更新

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:08:35 +00:00
ittoview
ec4c565f6c fix(质量工具): 修正TT066名称从"测试与检查的规则"改为"测试/产品评估"
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 15:01:02 +00:00
ittoview
bc96f1991d feat(质量管理): 新增决策技术工具并应用于规划质量管理和管理质量
- 新增TT132决策技术工具条目
- P5.1规划质量管理工具从TT018(决策)改为TT132(决策技术)
- P5.2管理质量工具从TT018(决策)改为TT132(决策技术)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:58:04 +00:00
ittoview
fcaaee746c fix(结束项目或阶段): 修复P1.7输出应为项目文件更新而非项目管理计划更新
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:38:21 +00:00
ittoview
a25b00cd79 fix(过程名称): 修正P1.2和P3.5过程名称"制定"为"制订"
- P1.2 制订项目管理计划
- P3.5 制订进度计划

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-04 10:35:13 +00:00
ittoview
70e60027c6 fix(整体变更控制): 修复P1.6输出移除变更日志保留项目管理计划更新
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-04 10:04:31 +00:00
ittoview
3148ef6828 feat(智能助手): 集成Dify智能对话助手
在页面中嵌入非入侵式智能对话助手,提供学习辅助功能

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-03 11:41:16 +00:00
ittoview
bb96981785 fix(管理团队): 修复P6.5输入工作绩效数据应为工作绩效报告
feat(范围): 添加KA02项目范围管理裁剪因素

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-03 03:31:10 +00:00
ittoview
19f0ee7bc4 fix(过程详情): 修复练习模式输入框完全无法输入的问题
根本原因:
1. getProcessDetail(id) 每次渲染产生新对象 → practiceItems/currentPracticeItem
   引用不稳定 → reset useEffect 每次渲染触发 → userInput 被立即清空
2. exitPractice 定义在 validateInput 之后 → 闭包捕获到 undefined

修复:
- 用 useMemo([id]) 稳定 processDetail 引用
- 将 exitPractice 移至 validateInput 之前定义
- reset useEffect 依赖改为 [isPracticeMode, currentPracticeId, currentPracticeNameLength]
  使用原始类型值避免对象引用不稳定触发误重置

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 07:54:21 +00:00
ittoview
83a3791f25 fix(过程详情): 修复练习模式输入框无法输入及查看答案问题
- 切换练习项后延迟聚焦第一个输入框,修复横线不能输入问题
- 查看答案改为点击图标按钮,3秒后自动隐藏,参照process-practice实现

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 07:39:24 +00:00
ittoview
6879a6bd54 feat(过程详情): 内嵌 ITTO 练习模式
- 标题区右侧新增"开始练习"/"退出练习"按钮
- 练习模式下 ITTO 三列强制展开,隐藏显示/隐藏控制按钮
- 列表项渲染三态:已答对(✓)、当前作答(高亮虚线)、未作答(下划线遮盖)
- 页面底部 sticky 输入区,复用 InputArea 组件,支持中文输入法
- 按住"按住看答案"按钮或列表项长按显示答案,松开隐藏
- 答题顺序:输入→工具→输出,答对自动跳下一项,全部完成后退出
- 切换过程(URL 变化)时自动退出练习,避免定时器跨过程触发

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 07:35:10 +00:00
ittoview
71c611edf3 fix(设置): 修复微信二维码图片比例
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 02:22:42 +00:00
ittoview
1dcf0bcc52 feat(设置): 添加微信二维码联系方式
- 在设置页面新增"联系作者"区块
- 展示微信二维码供用户扫码添加

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 01:46:53 +00:00
ittoview
5d97c70e06 fix(练习): 修复输入法组合期间焦点跳转导致字母分散问题
- 组合输入期间禁止useEffect自动聚焦到下一个空输入框
- onCompositionEnd直接传入index和value,不再扫描数组查找
- 确保拼音字母留在同一输入框,确认后正确分散成中文

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 01:34:31 +00:00
ittoview
b4dcd565d6 fix(练习): 使用nativeEvent.isComposing同步判断输入法状态
- 在InputArea中读取nativeEvent.isComposing同步判断组合状态
- 添加isComposingRef避免状态更新时序问题
- 确保输入法组合期间不触发自动跳转和字符分散

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 01:24:48 +00:00
ittoview
426a7b0327 fix(练习): 修复Windows平台中文输入法问题
- 输入法组合期间阻止自动跳转和字符分散
- 输入法确认后将组合字符正确分散到多个输入框
- 确保中文输入正常工作

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 01:15:52 +00:00
ittoview
713c11b382 fix(练习): 修复答案隐藏后焦点恢复逻辑
feat(知识领域): 添加敏捷裁剪因素数据

- 修复答案隐藏后聚焦到第一个空输入框而非第一个输入框
- 添加 restoreFocus 辅助函数统一处理焦点恢复
- 更新知识领域裁剪因素数据

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 16:57:16 +00:00
ittoview
8f96865ebf fix(练习): 修复嵌套滚动和底部空白问题
- Layout: 将 h-screen 改为 min-h-screen,移除嵌套滚动容器
- ProcessPracticePage: 底部区域从 fixed 改为 sticky,移除动态高度计算
- 使用 flex 布局管理页面结构,消除双滚动条和大片空白
- 清理未使用的 state 和 imports

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 16:31:02 +00:00
ittoview
08bd8dd4dc fix(练习): 修复底部区域布局和焦点问题
- 动态计算底部固定区域高度,避免固定值导致的空白或遮挡
- 底部区域适配侧边栏宽度,不再被左侧菜单遮挡
- 答案隐藏后自动恢复输入框焦点
- 增加辅助信息显示高度(max-h-48)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 16:12:50 +00:00
ittoview
4b347be9f5 feat(练习): 添加进度缓存和用户体验优化
- 使用 localStorage 缓存答题进度,支持切换页面后继续
- 修复暗色主题下输入框文字不可见问题
- 添加"想不起来"提示按钮,引导用户查看答案
- 添加清除进度按钮,方便重新开始练习

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 15:27:19 +00:00
ittoview
a38e275642 fix(练习): 修复中文输入法和字符验证问题
- 移除 input maxLength 限制,支持输入法多字符输入
- 使用 ref 保存最新输入状态,避免闭包导致的状态滞后
- 重构验证逻辑,修复字符对比错误(对比原始答案而非标准化答案)
- 修复输入法确认后验证使用旧数据的问题

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 15:15:37 +00:00
ittoview
977187b2d5 fix(练习): 修复知识领域显示和输入焦点问题
- 隐藏未答对的知识领域名称,只在答对后显示
- 增加底部输入区域透明度(80% -> 60%)
- 修复切换格子后输入框未自动聚焦的问题
- 优化连续输入处理,支持多字符自动分配到后续输入框

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 14:57:25 +00:00
ittoview
7edaebf0ab fix(练习): 重构布局和修复需求问题
- 修复知识领域显示完整名称(如"项目整合管理")
- 改用 table 布局,参考 process-matrix 样式
- 输入区域添加半透明背景(bg-white/80 + backdrop-blur-md)
- 辅助信息不再省略,显示完整内容
- 删除不需要的 KnowledgeAreaCell 组件
- 知识领域显示在左侧列,过程显示在单元格内

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 14:47:24 +00:00
ittoview
32172bec2d docs(练习): 添加过程背诵练习模块需求与实现记录
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 14:38:27 +00:00
ittoview
da04583703 fix(练习): 优化移动端布局和样式
- 调整底部固定区域布局,输入框和辅助信息分层显示
- 压缩矩阵格子间距和内边距,适配小屏幕
- 辅助信息区域限高并可滚动,只显示前2个裁剪因素
- 减小字体大小和组件尺寸,提升移动端体验
- 修复表头吸顶位置偏移

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 14:37:01 +00:00
ittoview
cc8dd1e751 feat(练习): 新增过程背诵练习模块
- 实现知识领域和过程的背诵练习功能
- 矩阵布局:知识领域格子横跨5列,过程按过程组分列
- 动态输入框:根据答案长度自动调整横线数量
- 实时验证:逐字符验证,错误标红,正确后自动跳转
- 辅助信息:知识领域显示裁剪因素,过程显示主要作用
- 长按显示答案:支持触摸、鼠标和键盘(空格键)
- TAB键切换:按顺序切换格子,自动跳过空单元格
- 支持输入法和批量粘贴
- 完整的无障碍支持(aria-live、tabIndex、scrollIntoView)
- 进度跟踪:顶部显示答题进度条

新增文件:
- src/utils/practice.ts - 工具函数
- src/hooks/useLongPress.ts - 长按 Hook
- src/components/practice/ - 练习组件
- src/pages/ProcessPracticePage.tsx - 练习页面

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 14:28:59 +00:00
ittoview
dd76db193c style(导航): 隐藏流程总览图页面入口
- App.tsx 移除 /process-roadmap 路由及组件导入
- 首页功能入口卡片移除"流程总览图"
- 侧边栏导航移除"流程总览图"链接
- ProcessDetailPage 移除"返回总览图"按钮及相关逻辑

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 08:46:35 +00:00
ittoview
492406b540 style(首页): 移除工具技术统计卡片及PMBOK第6版字样
- 首页删除"工具技术"统计卡片,数据不准确不宜展示
- 知识领域页、过程组页副标题去除"PMBOK第6版定义的"前缀
- 侧边栏底部删除"PMBOK 第6版"标签
- 设置页删除"基于 PMBOK 第6版"说明

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 08:42:32 +00:00
ittoview
13916c8939 feat(采购/干系人): 完善采购和干系人管理ITTO明细及主要作用
- P9.3控制采购:添加项目管理计划、项目文件输入明细,数据分析明细,项目管理计划更新和项目文件更新输出明细
- KA10项目干系人管理:新增3项裁剪考虑因素(干系人多样性、关系复杂性、沟通技术)
- P10.1识别干系人:添加项目管理计划、项目文件输入明细,数据收集、数据分析、数据表现明细,项目管理计划更新和项目文件更新输出明细,新增主要作用说明
- P10.2规划干系人参与:添加项目管理计划、项目文件输入明细,数据收集、数据分析、决策、数据表现明细,新增主要作用说明
- P10.3管理干系人参与:添加项目管理计划、项目文件输入明细,人际关系技能明细,项目管理计划更新和项目文件更新输出明细,新增主要作用说明
- P10.4监督干系人参与:添加项目管理计划、项目文件输入明细,数据分析、决策、数据表现、沟通技能、人际关系技能明细,项目管理计划更新和项目文件更新输出明细,新增主要作用说明

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 08:02:38 +00:00
ittoview
ac114bb766 feat(采购): 完善P9.2-P9.3 ITTO明细和主要作用
- P9.2实施采购:添加项目管理计划、项目文件、采购文档输入明细,数据分析(建议书评估)和人际关系技能(谈判)明细,项目管理计划更新和项目文件更新输出明细
- P9.3控制采购:新增主要作用说明

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 07:22:00 +00:00
ittoview
aca120c41b feat(采购): 完善P9.1-P9.2 ITTO明细和主要作用
- P9.1规划采购管理:添加项目管理计划和项目文件输入明细、数据收集(市场调研)和数据分析(自制或外购分析)明细、项目文件更新输出明细
- P9.2实施采购:新增主要作用说明

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 07:16:50 +00:00
ittoview
c9268cb628 feat(采购): 添加KA09裁剪因素和P9.1主要作用
- KA09项目采购管理:新增4项裁剪考虑因素(采购复杂性、物理地点、治理和法规环境、承包商可用性)
- P9.1规划采购管理:新增主要作用说明

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 07:08:45 +00:00
ittoview
084bc10b2c fix(干系人): 修复干系人管理数据
- P10.1识别干系人:新增输入A002立项管理文件
- A064工件名称从"相关方登记册"更正为"干系人登记册"
- P10.2规划干系人参与:工具列表新增TT008数据分析
- A018工件名称从"相关方参与计划"更正为"干系人参与计划"
- P10.4监督干系人参与:工具列表新增TT022人际关系与团队技能

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 07:04:32 +00:00
ittoview
456198b183 fix(采购/干系人): 修复采购和干系人管理数据
- P9.1规划采购管理:新增输入A002立项管理文件,输出A091替换A090采购策略
- P9.2实施采购:工具TT094建议书评价替换为TT131广告(新增)
- P9.3控制采购:输出A057替换为A091采购文档更新,A088改名为采购关闭
- A057工件名称从"采购文件"更正为"采购文档"
- KA10知识领域名称从"项目相关方管理"更正为"项目干系人管理"
- P10.1-P10.4过程名称"相关方"统一更正为"干系人"

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-25 07:00:55 +00:00
ittoview
6a49f7c058 fix(风险): 修复P8.4实施定量风险分析工具列表
- 新增TT130不确定性表现方式(Representations of Uncertainty)
- 移除错误工具TT034/TT075/TT080/TT077
- 工具列表更正为:专家判断、数据收集(访谈)、人际关系技能(引导)、不确定性表现方式、数据分析(模拟/敏感性分析/决策树分析/影响图)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 14:13:51 +00:00
ittoview
14a102854f fix(风险): 修复P8.2识别风险工具数据
- TT120名称从"核对单"更正为"提示清单"(Prompt Lists)
- P8.2数据收集(TT002)明细中加入"核查单"
- P8.2工具列表重新加入TT120(提示清单)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 14:10:04 +00:00
ittoview
e9e9ef89f6 fix(风险): 修正P8.2和P8.3工具列表
- P8.2识别风险:删除数据收集中的"核查单",保留"提示清单"作为独立工具
- P8.3实施定性风险分析:添加"风险分类"工具

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 11:05:28 +00:00
ittoview
ba2221c049 feat(风险): 添加P8.6-P8.7 ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 10:55:35 +00:00
ittoview
a60732c99e feat(风险): 添加P8.6实施风险应对purpose
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 10:47:52 +00:00
ittoview
cea7dad299 feat(风险): 添加P8.5规划风险应对purpose及ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 10:39:48 +00:00
ittoview
8d304ac27b feat(风险): 添加P8.4实施定量风险分析purpose及ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 09:56:47 +00:00
ittoview
3b64d66ee1 feat(风险): 添加P8.3实施定性风险分析purpose及ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 09:10:30 +00:00
ittoview
dc3b3c56f5 feat(风险): 添加KA08裁剪因素和P8.1-P8.2主要作用及ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 08:23:17 +00:00
ittoview
a784fb966b feat(沟通): 更新P7.1规划沟通管理ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 03:18:36 +00:00
ittoview
c6c4f5c748 feat(沟通): 添加P7.2管理沟通purpose及P7.3监督沟通ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 02:38:29 +00:00
ittoview
4fcf5d18d8 feat(沟通): 添加P7.2管理沟通ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 02:16:44 +00:00
ittoview
789821f101 feat(沟通): 添加KA07裁剪因素和P7.1-P7.2主要作用及P7.1 ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 02:14:24 +00:00
ittoview
f0c1283db6 feat(资源): 添加P6.2-P6.7资源管理过程主要作用
via [HAPI](https://hapi.run)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-24 01:34:27 +00:00
ittoview
caa5a773d9 feat(成本/质量): 添加P4.1-P4.4及P5.1-P5.3主要作用
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:24:50 +00:00
ittoview
5e5e27d898 feat(进度): 添加P3.6控制进度ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:15:38 +00:00
ittoview
a72f06822c feat(进度): 添加P3.6控制进度主要作用及ITTO明细
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 15:54:24 +00:00
ittoview
7ee66e2ac9 feat(进度管理): 添加P3.4-P3.5过程作用及P3.4 ITTO明细
- P3.4 估算活动持续时间:添加purpose字段及完整ITTO明细
- P3.5 制定进度计划:添加purpose字段

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-23 15:27:53 +00:00
ittoview
2de352e869 feat(进度): 添加P3.3排列活动顺序的ITTO明细 2026-02-23 15:19:47 +00:00
ittoview
65f3074a7a feat(进度): 添加KA03裁剪因素和P3.1-P3.3主要作用及ITTO明细 2026-02-23 14:36:38 +00:00
ittoview
aef2201028 feat(范围): 添加P2.6主要作用及ITTO明细 2026-02-23 09:07:10 +00:00
ittoview
72a1b53476 feat(范围): 添加P2.4和P2.5主要作用及ITTO明细 2026-02-23 08:46:29 +00:00
ittoview
52dcc9bec8 feat(范围): 添加P2.1-P2.3主要作用及ITTO明细 2026-02-23 08:36:11 +00:00
ittoview
0fb0a83cbe feat(整合): 添加P1.6的ITTO明细和P1.7主要作用及ITTO明细 2026-02-23 07:27:46 +00:00
ittoview
64887bd3af feat(整合): 添加P1.5的ITTO明细和P1.6主要作用 2026-02-23 07:11:19 +00:00
ittoview
e65c4ecf63 feat(整合): 添加P1.4和P1.5主要作用及P1.4的ITTO明细 2026-02-23 07:06:06 +00:00
ittoview
6d4aecfd91 feat(整合): 添加P1.2和P1.3主要作用及P1.3的ITTO明细 2026-02-23 07:01:16 +00:00
ittoview
2abd6d876b feat(整合): 添加KA01裁剪因素和P1.1主要作用及工具明细 2026-02-23 06:48:33 +00:00
ittoview
d6a2236469 docs(CLAUDE): 移除提交尾部追加规范 2026-02-23 06:38:15 +00:00
ittoview
dde61644c1 docs: 添加日常更新操作指南和Git提交规范 2026-02-23 06:36:36 +00:00
ittoview
0a5788e52c feat(过程详情): 新增主要作用字段替换5W1H显示
- types/itto.ts: Process 新增 purpose 可选字段
- processes.json: P8.7 监督风险添加 purpose 示例数据
- ProcessDetailPage: 隐藏5W1H,改为显示主要作用卡片
- CLAUDE.md: 记录三类日常学习内容更新操作指南

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-23 06:32:09 +00:00
ittoview
943ad2fe85 fix(动画): 优化ITTO显示隐藏过渡效果
使用maxHeight+opacity替代height动画,消除滚动条跳动问题

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-22 14:28:40 +00:00
ittoview
b5f6f47138 feat: ITTO显示隐藏平滑过渡动画 + 资源管理数据更新
- ProcessDetailPage: 用motion height动画替换AnimatePresence,消除滚动条跳动
- P6.3 获取资源:添加ITTO明细
- P6.5 管理团队:添加ITTO明细
- P6.6 控制资源:添加ITTO明细

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-22 14:20:27 +00:00
ittoview
cfbd06f676 feat(资源): 添加P6.2-P6.6的ITTO明细
- P6.2 估算活动资源:添加输入/工具/输出明细
- P6.3 获取资源:添加输入/工具/输出明细
- P6.4 建设团队:添加输入/工具/输出明细
- P6.5 管理团队:添加输入/工具/输出明细
- P6.6 控制资源:添加输入/工具/输出明细

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-22 14:10:52 +00:00
ittoview
47277eb29a feat(资源): 更新P6.1/P6.3/P6.4的ITTO数据
- P6.1 规划资源管理:添加输入/工具/输出明细
- P6.3 获取资源:输出补充组织过程资产更新
- P6.4 建设团队:输出补充组织过程资产更新

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-22 13:44:09 +00:00
ittoview
074066195e feat(资源): 添加KA06项目资源管理的裁剪考虑因素
包含:多元化、物理位置、行业特定资源、团队成员的获得、团队管理、生命周期方法

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-22 13:37:04 +00:00
ittoview
e4f7632818 fix(质量): 修复P5.3控制质量明细格式为{label}对象数组
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 15:49:42 +00:00
ittoview
a95160e6fb feat(质量): 添加P5.3控制质量的ITTO明细
- 输入A008(项目管理计划)明细:质量管理计划
- 输入A076(项目文件)明细:测试与评估文件、质量测量指标、经验教训登记册
- 工具TT002(数据收集)明细:核对单、核查表、统计抽样、问卷调查
- 工具TT008(数据分析)明细:绩效审查、根本原因分析
- 工具TT034(数据表现)明细:因果图、控制图、直方图、散点图
- 输出A077(项目文件更新)明细:问题日志、经验教训登记册、风险登记册、测试与评估文件

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 15:43:37 +00:00
ittoview
b8e90c6572 feat(质量管理): 为管理质量添加ITTO明细
为P5.2管理质量添加详细明细信息:
- 输入A076(项目文件)明细:经验教训登记册、质量控制测量结果、质量测量指标、风险报告
- 工具TT002(数据收集)明细:核对单
- 工具TT008(数据分析)明细:备选方案分析、文件分析、过程分析、根本原因分析
- 工具TT018(决策)明细:多标准决策分析
- 工具TT034(数据表现)明细:亲和图、因果图、流程图、直方图、矩阵图、散点图
- 输出A078(项目管理计划更新)明细:质量管理计划、范围基准、进度基准、成本基准
- 输出A077(项目文件更新)明细:问题日志、经验教训登记册、风险登记册

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 15:09:18 +00:00
ittoview
d309f33189 feat(成本管理): 添加成本管理敏捷裁剪因素
为KA04(项目成本管理)添加5个敏捷裁剪因素:
- 知识管理:组织知识管理体系和财务数据库
- 估算和预算:成本估算和预算相关的政策、程序和指南
- 挣值管理:组织是否采用挣值管理
- 敏捷方法的使用:敏捷或适应型方法对成本估算的影响
- 治理:审计和治理政策、程序和指南

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 13:33:17 +00:00
ittoview
b8260c8036 feat(质量管理): 为规划质量管理添加ITTO明细
为P5.1规划质量管理添加详细明细信息:
- 输入A008(项目管理计划)明细:需求管理计划、风险管理计划、干系人参与计划、范围基准
- 输入A076(项目文件)明细:假设日志、需求文件、需求跟踪矩阵、风险登记册、干系人登记册
- 工具TT002(数据收集)明细:标杆对照、头脑风暴、访谈
- 工具TT008(数据分析)明细:成本效益分析、质量成本
- 工具TT018(决策)明细:多标准决策分析
- 输出A078(项目管理计划更新)明细:风险管理计划、范围基准
- 输出A077(项目文件更新)明细:经验教训登记册、需求跟踪矩阵、风险登记册、干系人登记册

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 13:25:42 +00:00
ittoview
c5a8e0525b feat(知识领域): 添加敏捷裁剪因素功能
1. 类型定义:
   - 新增TailoringFactor接口定义敏捷裁剪因素
   - 为KnowledgeArea添加可选的tailoringFactors字段

2. 数据更新:
   - 为KA05(项目质量管理)添加4个敏捷裁剪因素:
     * 政策合规与审计
     * 标准与法规合规性
     * 持续改进
     * 干系人参与

3. 页面展示:
   - 在知识领域详情页添加敏捷裁剪因素展示区域
   - 使用灯泡图标和编号列表样式
   - 支持深色模式

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 13:15:45 +00:00
ittoview
2a5b971f2f feat(质量管理): 完善质量管理ITTO数据
1. 修改工具名称:
   - TT066: "测试/产品评估" → "测试与检查的规则"

2. 更新过程工具:
   - P5.3 控制质量:添加会议(TT032)工具

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-18 13:10:18 +00:00
ittoview
3d294ae641 feat(成本管理): 为控制成本添加ITTO明细
为P4.4控制成本添加详细明细信息:
- 输入A008(项目管理计划)明细:成本管理计划、成本基准、绩效测量基准
- 输入A076(项目文件)明细:经验教训登记册
- 工具TT008(数据分析)明细:挣值分析、偏差分析、趋势分析、储备分析
- 输出A078(项目管理计划更新)明细:成本管理计划、成本基准、绩效测量基准
- 输出A077(项目文件更新)明细:假设日志、估算依据、成本估算、经验教训登记册、风险登记册

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-15 14:20:28 +00:00
ittoview
424819b756 feat(成本管理): 为制定预算添加ITTO明细
为P4.3制定预算添加详细明细信息:
- 输入A008(项目管理计划)明细:成本管理计划、资源管理计划、范围基准
- 输入A076(项目文件)明细:估算依据、成本估算、项目进度计划、风险登记册
- 输入A093(可行性研究文件)明细:可行性研究报告、项目评估报告
- 工具TT008(数据分析)明细:储备分析
- 输出A077(项目文件更新)明细:成本估算、项目进度计划、风险登记册

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-15 07:37:58 +00:00
ittoview
8c10cb5000 feat(成本管理): 为估算成本添加ITTO明细
为P4.2估算成本添加详细明细信息:
- 输入A008(项目管理计划)明细:成本管理计划、质量管理计划、范围基准
- 输入A076(项目文件)明细:风险登记册、经验教训登记册、资源需求、项目进度计划
- 工具TT008(数据分析)明细:备选方案分析、储备分析、质量成本
- 工具TT018(决策)明细:投票
- 输出A077(项目文件更新)明细:假设日志、经验教训登记册、风险登记册

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-15 06:49:53 +00:00
ittoview
118dc69dd8 feat(成本管理): 为规划成本管理添加ITTO明细
为P4.1规划成本管理添加明细信息:
- 输入A008(项目管理计划)明细:进度管理计划、风险管理计划
- 工具TT008(数据分析)明细:备选方案分析

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-15 06:42:58 +00:00
ittoview
f22116a57f feat(进度与范围管理): 完善ITTO数据
1. 新增工具:
   - TT126 计划评审技术
   - TT127 敏捷或适应型发布规划
   - TT128 箭线图法
   - TT129 系统交互图

2. 更新过程工具:
   - P3.5 制定进度计划:添加计划评审技术、敏捷或适应型发布规划
   - P3.4 估算活动持续时间:添加数据分析
   - P3.3 排列活动顺序:添加箭线图法
   - P2.2 收集需求:添加系统交互图

3. 更新过程输入:
   - P2.2 收集需求:添加立项管理文件(A002)

4. 修改工件名称:
   - A073: "最终产品、服务或成果移交" → "最终产品、服务或成果"
   - A074: "最终报告" → "项目最终报告"

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 15:18:45 +00:00
ittoview
fb9fd98cfb feat(成本管理): 完善制定预算和控制成本的ITTO数据
1. 新增工件A093"可行性研究文件"
2. 新增工具TT121-TT125(成本汇总、历史信息审核、资金限制平衡、融资、完工尚需绩效指数)
3. 更新P4.3制定预算:
   - 输入添加A093可行性研究文件
   - 工具更新为:专家判断、成本汇总、数据分析、历史信息审核、资金限制平衡、融资
4. 更新P4.4控制成本:
   - 工具更新为:专家判断、数据分析、完工尚需绩效指数、项目管理信息系统

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 15:04:14 +00:00
ittoview
c480525433 feat(成本管理): 为P4.3制定预算添加可行性研究文件输入
添加A002(立项管理文件/可行性研究文件)到P4.3(制定预算)的输入列表中。

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 14:55:00 +00:00
ittoview
0d586ce280 移除明细展示中的"包含:"前缀
- 简化明细显示,直接展示明细项
- 更加简洁清爽

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 14:12:59 +00:00
ittoview
eb464cff12 优化 ITTO 明细展示
- 为 P1.2 输出"项目管理计划"添加明细(子管理计划、基准、其他组件)
- 将明细展示从纵向列表改为横向显示,用顿号分隔
- 节省空间,提升可读性

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 14:07:36 +00:00
ittoview
145e6e7549 feat: 支持 ITTO 明细功能
- 更新类型定义,支持 ProcessRef(字符串或对象)
- 添加 DetailItem 和 ProcessEntityUse 接口
- 为 P1.2(制定项目管理计划)添加工具明细示例
  - 数据收集:头脑风暴、核对单、焦点小组、访谈
  - 人际关系与团队技能:冲突管理、引导、会议管理
- 更新数据查询函数,支持新数据结构
- 更新前端展示,支持明细显示(带缩进和项目符号)
- 修复 ProcessGraphPage 类型错误

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 13:49:42 +00:00
ittoview
3c1451ca3f 修正 P1.7 结束项目或阶段的输入数据
- 移除:A005(事业环境因素)
- 添加:A002(立项管理文件)、A004(协议)、A057(采购文档)

现在符合 PMBOK 第6版标准 ITTO

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-02-14 13:25:17 +00:00
史悦
6505f977d9 feat(矩阵): 添加知识领域和过程组的显示/隐藏功能
feat(详情页): 为ITTO内容添加显示/隐藏控制功能

refactor: 优化状态管理使用localStorage持久化
2026-02-14 00:42:45 +08:00
033ae6b121 更新 src/data/processes.json 2026-02-13 17:23:58 +08:00
3cafae890b 更新 src/data/artifacts.json 2026-02-13 17:23:15 +08:00
1e0082798c 更新 src/data/processes.json 2026-02-13 17:18:44 +08:00
61ce02ca40 更新 src/data/artifacts.json 2026-02-13 17:17:53 +08:00
史悦
409e388403 feat: 新增流程总览图页面及导航功能
添加流程总览图页面,包含五组十域可交互SVG流程图,支持模块点击跳转至对应流程详情页。同时在侧边栏和首页添加导航入口,优化流程详情页的返回逻辑和布局样式。
2026-02-06 10:59:26 +08:00
史悦
59974c4969 feat(页面动画): 添加返回页面时跳过动画的效果
在知识领域页面添加检测机制,当用户返回页面时跳过入场动画
使用 useRef 记录访问状态,通过 useEffect 标记已访问
根据访问状态选择不同的动画变体实现平滑过渡
2026-02-04 17:31:26 +08:00
史悦
fee9f3db15 build: 将基础路径从相对路径改为绝对路径
修改vite配置中的base选项,从'./'改为'/',以使用绝对路径作为项目基础路径
2026-02-04 17:15:02 +08:00
史悦
eee40fa071 docs: 添加项目文档 CLAUDE.md 和 README.md
添加项目开发指南 CLAUDE.md 和项目说明 README.md
- CLAUDE.md 包含项目架构、开发规范和技术细节
- README.md 提供项目简介、功能说明和快速开始指南
2026-02-03 17:14:50 +08:00
史悦
f6e92c5526 feat(ProcessMatrix): 添加全屏模式下的布局优化
在全屏模式下将流程卡片改为网格布局,优化显示效果并添加文本截断功能
2026-02-03 14:13:54 +08:00
史悦
2226bca3b0 内网发布 2026-02-03 10:21:56 +08:00
史悦
8651747c12 feat(process): 添加5W1H记忆辅助信息并优化页面布局
- 在Process接口中添加5W1H可选字段
- 为所有过程添加5W1H记忆辅助信息
- 优化知识领域、过程组和过程详情页面的紧凑布局
- 在过程详情页添加5W1H记忆卡片展示
- 调整动画效果和间距提升用户体验
2026-02-03 10:14:24 +08:00
史悦
f0823fad30 feat(导航): 调整导航菜单和首页功能顺序
将"49过程矩阵"移至导航菜单第二位,并在首页添加对应功能卡片
移除不再使用的可视化功能
优化全屏模式下的样式处理
2026-02-03 09:21:10 +08:00
95 changed files with 15011 additions and 684 deletions

View 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
- [ ] 构建通过
- [ ] 用户确认提交

View 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 提交推送;未提交图片等同于未完成。

View File

@@ -0,0 +1,4 @@
interface:
display_name: "一图流上传"
short_description: "上传学习图谱图片、校验线上可见并提交推送到 Git"
default_prompt: "把这张学习图谱图片上传到一图流目录,按内容命名,确认线上可见,并提交推送。"

View 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
View File

@@ -7,3 +7,6 @@ dist/
.env.local .env.local
*.pdf *.pdf
pdf_images/ pdf_images/
public/api/
public/apidoc

58
AGENTS.md Normal file
View 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
View 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 项目管理 ITTOInput-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
View File

@@ -0,0 +1,298 @@
# ITTOView - PMP 项目管理 ITTO 可视化学习平台
<div align="center">
**一个精美、交互式的 PMP 认证考试 ITTO 知识点可视化学习工具**
[![React](https://img.shields.io/badge/React-18.3-61dafb?logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178c6?logo=typescript)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/Vite-5.1-646cff?logo=vite)](https://vitejs.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4-38bdf8?logo=tailwind-css)](https://tailwindcss.com/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
</div>
---
## 📖 项目简介
ITTOView 是一个专为 PMPProject Management Professional认证考生设计的 ITTOInput-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/` 的路径别名
---

View File

@@ -7,5 +7,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: ittoview container_name: ittoview
ports: ports:
- "8035:80" - "11.144.144.9:8035:80"
restart: unless-stopped volumes:
- ./src/data/image:/usr/share/nginx/html/learning-images:ro
restart: always

View 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
```

View 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;
}
```
该结构已经足以支撑:
- 时间排序
- 主题归类
- 原文追溯
- 来源说明
同时也为第二阶段页面开发预留了足够扩展空间。

View 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
- ✅ 创建本需求记录文档
---
**备注**: 本模块已完成核心功能开发和移动端优化,可以正常使用。后续可根据用户反馈进行功能增强和体验优化。

View File

@@ -2,7 +2,8 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITTOView - PMP项目管理ITTO可视化学习平台</title> <title>ITTOView - PMP项目管理ITTO可视化学习平台</title>
<meta name="description" content="PMP认证考试ITTO可视化学习平台帮助您高效掌握49个项目管理过程" /> <meta name="description" content="PMP认证考试ITTO可视化学习平台帮助您高效掌握49个项目管理过程" />
@@ -10,5 +11,16 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <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> </body>
</html> </html>

View File

@@ -9,6 +9,38 @@ server {
try_files $uri $uri/ /index.html; 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 { location /assets {
expires 1y; expires 1y;

View File

@@ -5,9 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "npm run generate:api && tsc && vite build",
"preview": "vite preview", "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": { "dependencies": {
"@antv/g6": "^4.8.25", "@antv/g6": "^4.8.25",

BIN
public/wechat-qrcode.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

300
scripts/generate-api.mjs Normal file
View 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)}`)

View File

@@ -9,6 +9,15 @@ import { ProcessGraphPage } from './pages/ProcessGraphPage'
import { ArtifactDetailPage } from './pages/ArtifactDetailPage' import { ArtifactDetailPage } from './pages/ArtifactDetailPage'
import { ToolDetailPage } from './pages/ToolDetailPage' import { ToolDetailPage } from './pages/ToolDetailPage'
import { SettingsPage } from './pages/SettingsPage' 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() { function App() {
return ( return (
@@ -17,14 +26,24 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/knowledge-areas" element={<KnowledgeAreasPage />} /> <Route path="/knowledge-areas" element={<KnowledgeAreasPage />} />
<Route path="/knowledge-areas/:id" 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" element={<ProcessGroupsPage />} />
<Route path="/process-groups/:id" element={<ProcessGroupsPage />} /> <Route path="/process-groups/:id" element={<ProcessGroupsPage />} />
<Route path="/process/:id" element={<ProcessDetailPage />} /> <Route path="/process/:id" element={<ProcessDetailPage />} />
<Route path="/process-matrix" element={<ProcessMatrixPage />} /> <Route path="/process-matrix" element={<ProcessMatrixPage />} />
<Route path="/itto-collections" element={<IttoCollectionsPage />} />
<Route path="/process-graph" element={<ProcessGraphPage />} /> <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="/artifact/:id" element={<ArtifactDetailPage />} />
<Route path="/tool/:id" element={<ToolDetailPage />} /> <Route path="/tool/:id" element={<ToolDetailPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/doc" element={<ApiDocPage />} />
</Routes> </Routes>
</Layout> </Layout>
) )

View 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
)
}

View File

@@ -1,8 +1,9 @@
import { useAppStore } from '@/stores/useAppStore' 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 { useState, useMemo, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { processes, artifacts, tools, knowledgeAreaMap } from '@/data' import { processes, artifacts, tools, knowledgeAreaMap } from '@/data'
import { ChangelogModal } from '@/components/ChangelogModal'
interface SearchResult { interface SearchResult {
type: 'process' | 'artifact' | 'tool' type: 'process' | 'artifact' | 'tool'
@@ -21,6 +22,7 @@ export function Header() {
const searchQuery = useAppStore((s) => s.searchQuery) const searchQuery = useAppStore((s) => s.searchQuery)
const setSearchQuery = useAppStore((s) => s.setSearchQuery) const setSearchQuery = useAppStore((s) => s.setSearchQuery)
const [isSearchOpen, setIsSearchOpen] = useState(false) const [isSearchOpen, setIsSearchOpen] = useState(false)
const [isChangelogOpen, setIsChangelogOpen] = useState(false)
const searchRef = useRef<HTMLDivElement>(null) const searchRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate() const navigate = useNavigate()
@@ -240,6 +242,16 @@ export function Header() {
{/* 右侧:操作按钮 */} {/* 右侧:操作按钮 */}
<div className="flex items-center gap-2"> <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 <button
onClick={toggleDarkMode} onClick={toggleDarkMode}
@@ -250,6 +262,9 @@ export function Header() {
{darkMode ? <Sun size={20} /> : <Moon size={20} />} {darkMode ? <Sun size={20} /> : <Moon size={20} />}
</button> </button>
</div> </div>
{/* 更新日志模态框 */}
<ChangelogModal isOpen={isChangelogOpen} onClose={() => setIsChangelogOpen(false)} />
</header> </header>
) )
} }

View File

@@ -14,25 +14,23 @@ export function Layout({ children }: LayoutProps) {
return ( return (
<div className={clsx('min-h-screen', darkMode && 'dark')}> <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 /> <Sidebar />
{/* 主内容区 */} {/* 主内容区 */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col">
{/* 顶部导航 */} {/* 顶部导航 */}
<Header /> <Header />
{/* 页面内容 */} {/* 页面内容 */}
<main <main
className={clsx( 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' sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'
)} )}
> >
<div className="mx-auto max-w-7xl"> {children}
{children}
</div>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,23 +1,36 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useAppStore } from '@/stores/useAppStore' import { useAppStore } from '@/stores/useAppStore'
import ittoIcon from '@/data/icon/ittoico.png'
import { import {
Home, Home,
BookOpen, BookOpen,
Layers, Layers,
Scissors,
LayoutGrid, LayoutGrid,
TableProperties,
Share2, Share2,
Settings, Settings,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
GraduationCap,
BookMarked,
Gauge,
Images,
} from 'lucide-react' } from 'lucide-react'
const navItems = [ const navItems = [
{ path: '/', label: '首页', icon: Home }, { 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: '/knowledge-areas', label: '知识领域', icon: BookOpen },
{ path: '/process-groups', label: '过程组', icon: Layers }, { path: '/process-groups', label: '过程组', icon: Layers },
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid }, { path: '/knowledge-areas-tailoring', label: '裁剪因素', icon: Scissors },
{ path: '/process-graph', label: '过程关系图', icon: Share2 }, { 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 }, { path: '/settings', label: '设置', icon: Settings },
] ]
@@ -48,8 +61,8 @@ export function Sidebar() {
{/* Logo */} {/* Logo */}
<div className="flex h-16 items-center justify-between px-4 border-b border-gray-200 dark:border-gray-700"> <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"> <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"> <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg bg-white shadow-sm dark:bg-gray-900">
IT <img src={ittoIcon} alt="" className="h-full w-full object-cover" aria-hidden="true" />
</div> </div>
{sidebarOpen && ( {sidebarOpen && (
<span className="text-lg font-semibold text-gray-900 dark:text-white"> <span className="text-lg font-semibold text-gray-900 dark:text-white">
@@ -71,7 +84,7 @@ export function Sidebar() {
<ul className="space-y-1"> <ul className="space-y-1">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = location.pathname === item.path || const isActive = location.pathname === item.path ||
(item.path !== '/' && location.pathname.startsWith(item.path)) (item.path !== '/' && location.pathname.startsWith(`${item.path}/`))
const Icon = item.icon const Icon = item.icon
return ( return (
@@ -98,7 +111,6 @@ export function Sidebar() {
{sidebarOpen && ( {sidebarOpen && (
<div className="absolute bottom-4 left-4 right-4"> <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="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"> <div className="text-sm font-medium text-gray-700 dark:text-gray-300">
49 · 10 49 · 10
</div> </div>

View 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>
)
}

View 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>
)
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -5,6 +5,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Eye, EyeOff } from 'lucide-react'
import { import {
knowledgeAreas, knowledgeAreas,
processGroups, processGroups,
@@ -13,9 +14,21 @@ import {
interface ProcessMatrixProps { interface ProcessMatrixProps {
className?: string className?: string
isFullScreen?: boolean
hiddenKnowledgeAreaIds?: Set<string>
hiddenProcessGroupIds?: Set<string>
onToggleKnowledgeArea?: (id: string) => void
onToggleProcessGroup?: (id: string) => void
} }
export function ProcessMatrix({ className }: ProcessMatrixProps) { export function ProcessMatrix({
className,
isFullScreen = false,
hiddenKnowledgeAreaIds = new Set(),
hiddenProcessGroupIds = new Set(),
onToggleKnowledgeArea,
onToggleProcessGroup,
}: ProcessMatrixProps) {
// 构建矩阵数据knowledgeAreaId -> processGroupId -> Process[] // 构建矩阵数据knowledgeAreaId -> processGroupId -> Process[]
const matrix = new Map<string, Map<string, typeof processes>>() const matrix = new Map<string, Map<string, typeof processes>>()
@@ -46,75 +59,132 @@ export function ProcessMatrix({ className }: ProcessMatrixProps) {
<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 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> </th>
{processGroups.map(pg => ( {processGroups.map(pg => {
<th const isHidden = hiddenProcessGroupIds.has(pg.id)
key={pg.id} return (
className="p-3 text-center text-sm font-semibold text-white border border-gray-200 dark:border-gray-700 min-w-[140px]" <th
style={{ backgroundColor: pg.color }} 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]"
<div>{pg.name}</div> style={{ backgroundColor: pg.color }}
<div className="text-xs opacity-80 font-normal mt-1"> >
{pg.processCount} <div className="flex flex-col items-center gap-2">
</div> <div className={clsx("transition-opacity duration-150", isHidden && "opacity-30")}>
</th> <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> </tr>
</thead> </thead>
{/* 表体:知识领域 × 过程 */} {/* 表体:知识领域 × 过程 */}
<tbody> <tbody>
{knowledgeAreas.map((ka, kaIndex) => ( {knowledgeAreas.map((ka, kaIndex) => {
<motion.tr const isKaHidden = hiddenKnowledgeAreaIds.has(ka.id)
key={ka.id} return (
initial={{ opacity: 0, y: 10 }} <motion.tr
animate={{ opacity: 1, y: 0 }} key={ka.id}
transition={{ delay: kaIndex * 0.05 }} initial={{ opacity: 0, y: 10 }}
> animate={{ opacity: 1, y: 0 }}
{/* 知识领域名称 */} transition={{ delay: kaIndex * 0.05 }}
<td
className="sticky left-0 z-10 p-3 border border-gray-200 dark:border-gray-700 font-medium"
style={{
backgroundColor: `${ka.color}15`,
borderLeftWidth: 4,
borderLeftColor: ka.color,
}}
> >
<Link {/* 知识领域名称 */}
to={`/knowledge-areas/${ka.id}`} <td
className="hover:underline text-gray-900 dark:text-white" className="sticky left-0 z-10 p-3 border border-gray-200 dark:border-gray-700 font-medium"
style={{
backgroundColor: `${ka.color}15`,
borderLeftWidth: 4,
borderLeftColor: ka.color,
}}
> >
<span className="font-bold mr-2" style={{ color: ka.color }}> <div className="flex items-center justify-between gap-2">
{ka.order} <Link
</span> to={`/knowledge-areas/${ka.id}`}
{ka.name} className={clsx(
</Link> "hover:underline text-gray-900 dark:text-white transition-opacity duration-150",
</td> 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 => { {processGroups.map(pg => {
const cellProcesses = matrix.get(ka.id)?.get(pg.id) || [] const cellProcesses = matrix.get(ka.id)?.get(pg.id) || []
const isCellHidden = isKaHidden || hiddenProcessGroupIds.has(pg.id)
return ( return (
<td <td
key={pg.id} key={pg.id}
className="p-2 border border-gray-200 dark:border-gray-700 align-top bg-white dark:bg-gray-800" className="p-2 border border-gray-200 dark:border-gray-700 align-top bg-white dark:bg-gray-800"
> >
<div className="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 => ( {cellProcesses.map(p => (
<Link <Link
key={p.id} key={p.id}
to={`/process/${p.id}`} to={`/process/${p.id}`}
state={{ from: 'matrix' }} state={{ from: 'matrix' }}
className="block px-2 py-1.5 rounded text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" className={clsx(
"block px-2 py-1.5 rounded text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors",
isFullScreen && "h-full"
)}
title={p.name}
> >
<span <div className={clsx("flex items-center gap-1 min-w-0", isFullScreen && "h-full")}>
className="inline-block px-1.5 py-0.5 rounded text-white font-medium mr-1" <span
style={{ backgroundColor: ka.color, fontSize: '10px' }} 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> {p.code}
<span className="text-gray-700 dark:text-gray-300"> </span>
{p.name} <span
</span> className={clsx(
"block min-w-0 text-gray-700 dark:text-gray-300",
isFullScreen ? "line-clamp-2" : "truncate"
)}
>
{p.name}
</span>
</div>
</Link> </Link>
))} ))}
{cellProcesses.length === 0 && ( {cellProcesses.length === 0 && (
@@ -127,7 +197,7 @@ export function ProcessMatrix({ className }: ProcessMatrixProps) {
) )
})} })}
</motion.tr> </motion.tr>
))} )})}
</tbody> </tbody>
{/* 表尾:统计 */} {/* 表尾:统计 */}

View File

@@ -1,7 +1,7 @@
{ {
"artifacts": [ "artifacts": [
{ "id": "A001", "name": "项目章程", "nameEn": "Project Charter", "category": "document" }, { "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": "A003", "name": "效益管理计划", "nameEn": "Benefits Management Plan", "category": "plan" },
{ "id": "A004", "name": "协议", "nameEn": "Agreements", "category": "document" }, { "id": "A004", "name": "协议", "nameEn": "Agreements", "category": "document" },
{ "id": "A005", "name": "事业环境因素", "nameEn": "Enterprise Environmental Factors", "category": "other" }, { "id": "A005", "name": "事业环境因素", "nameEn": "Enterprise Environmental Factors", "category": "other" },
@@ -17,7 +17,7 @@
{ "id": "A015", "name": "沟通管理计划", "nameEn": "Communications Management Plan", "category": "plan" }, { "id": "A015", "name": "沟通管理计划", "nameEn": "Communications Management Plan", "category": "plan" },
{ "id": "A016", "name": "风险管理计划", "nameEn": "Risk Management Plan", "category": "plan" }, { "id": "A016", "name": "风险管理计划", "nameEn": "Risk Management Plan", "category": "plan" },
{ "id": "A017", "name": "采购管理计划", "nameEn": "Procurement 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": "A019", "name": "变更管理计划", "nameEn": "Change Management Plan", "category": "plan" },
{ "id": "A020", "name": "配置管理计划", "nameEn": "Configuration Management Plan", "category": "plan" }, { "id": "A020", "name": "配置管理计划", "nameEn": "Configuration Management Plan", "category": "plan" },
{ "id": "A021", "name": "范围基准", "nameEn": "Scope Baseline", "category": "baseline" }, { "id": "A021", "name": "范围基准", "nameEn": "Scope Baseline", "category": "baseline" },
@@ -56,14 +56,14 @@
{ "id": "A054", "name": "项目沟通记录", "nameEn": "Project Communications", "category": "document" }, { "id": "A054", "name": "项目沟通记录", "nameEn": "Project Communications", "category": "document" },
{ "id": "A055", "name": "风险登记册", "nameEn": "Risk Register", "category": "register" }, { "id": "A055", "name": "风险登记册", "nameEn": "Risk Register", "category": "register" },
{ "id": "A056", "name": "风险报告", "nameEn": "Risk Report", "category": "report" }, { "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": "A058", "name": "采购工作说明书", "nameEn": "Procurement Statement of Work", "category": "document" },
{ "id": "A059", "name": "招标文件", "nameEn": "Bid Documents", "category": "document" }, { "id": "A059", "name": "招标文件", "nameEn": "Bid Documents", "category": "document" },
{ "id": "A060", "name": "供方选择标准", "nameEn": "Source Selection Criteria", "category": "document" }, { "id": "A060", "name": "供方选择标准", "nameEn": "Source Selection Criteria", "category": "document" },
{ "id": "A061", "name": "自制或外购决策", "nameEn": "Make-or-Buy Decisions", "category": "document" }, { "id": "A061", "name": "自制或外购决策", "nameEn": "Make-or-Buy Decisions", "category": "document" },
{ "id": "A062", "name": "独立成本估算", "nameEn": "Independent Cost Estimates", "category": "document" }, { "id": "A062", "name": "独立成本估算", "nameEn": "Independent Cost Estimates", "category": "document" },
{ "id": "A063", "name": "选定的卖方", "nameEn": "Selected Sellers", "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": "A065", "name": "问题日志", "nameEn": "Issue Log", "category": "log" },
{ "id": "A066", "name": "经验教训登记册", "nameEn": "Lessons Learned Register", "category": "register" }, { "id": "A066", "name": "经验教训登记册", "nameEn": "Lessons Learned Register", "category": "register" },
{ "id": "A067", "name": "工作绩效数据", "nameEn": "Work Performance Data", "category": "document" }, { "id": "A067", "name": "工作绩效数据", "nameEn": "Work Performance Data", "category": "document" },
@@ -72,8 +72,8 @@
{ "id": "A070", "name": "可交付成果", "nameEn": "Deliverables", "category": "deliverable" }, { "id": "A070", "name": "可交付成果", "nameEn": "Deliverables", "category": "deliverable" },
{ "id": "A071", "name": "核实的可交付成果", "nameEn": "Verified Deliverables", "category": "deliverable" }, { "id": "A071", "name": "核实的可交付成果", "nameEn": "Verified Deliverables", "category": "deliverable" },
{ "id": "A072", "name": "验收的可交付成果", "nameEn": "Accepted Deliverables", "category": "deliverable" }, { "id": "A072", "name": "验收的可交付成果", "nameEn": "Accepted Deliverables", "category": "deliverable" },
{ "id": "A073", "name": "最终产品、服务或成果移交", "nameEn": "Final Product, Service, or Result Transition", "category": "deliverable" }, { "id": "A073", "name": "最终产品、服务或成果", "nameEn": "Final Product, Service, or Result Transition", "category": "deliverable" },
{ "id": "A074", "name": "最终报告", "nameEn": "Final Report", "category": "report" }, { "id": "A074", "name": "项目最终报告", "nameEn": "Final Report", "category": "report" },
{ "id": "A075", "name": "组织过程资产更新", "nameEn": "Organizational Process Assets Updates", "category": "other" }, { "id": "A075", "name": "组织过程资产更新", "nameEn": "Organizational Process Assets Updates", "category": "other" },
{ "id": "A076", "name": "项目文件", "nameEn": "Project Documents", "category": "document" }, { "id": "A076", "name": "项目文件", "nameEn": "Project Documents", "category": "document" },
{ "id": "A077", "name": "项目文件更新", "nameEn": "Project Documents Updates", "category": "document" }, { "id": "A077", "name": "项目文件更新", "nameEn": "Project Documents Updates", "category": "document" },
@@ -87,9 +87,12 @@
{ "id": "A085", "name": "预测", "nameEn": "Forecasts", "category": "document" }, { "id": "A085", "name": "预测", "nameEn": "Forecasts", "category": "document" },
{ "id": "A086", "name": "成本预测", "nameEn": "Cost Forecasts", "category": "document" }, { "id": "A086", "name": "成本预测", "nameEn": "Cost Forecasts", "category": "document" },
{ "id": "A087", "name": "进度预测", "nameEn": "Schedule 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": "A089", "name": "卖方绩效评价文档", "nameEn": "Seller Performance Evaluation Documentation", "category": "document" },
{ "id": "A090", "name": "采购策略", "nameEn": "Procurement Strategy", "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

File diff suppressed because it is too large Load Diff

BIN
src/data/icon/ittoico.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/data/icon/ittologo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

0
src/data/image/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -8,6 +8,8 @@ import processGroupsData from './process-groups.json';
import processesData from './processes.json'; import processesData from './processes.json';
import artifactsData from './artifacts.json'; import artifactsData from './artifacts.json';
import toolsData from './tools.json'; import toolsData from './tools.json';
import changelogData from './changelog.json';
import timelineItemsData from './timeline-items.json';
import type { import type {
KnowledgeArea, KnowledgeArea,
@@ -15,8 +17,12 @@ import type {
Process, Process,
Artifact, Artifact,
ToolTechnique, ToolTechnique,
ChangelogEntry,
DataFlow, DataFlow,
ProcessRef,
ProcessEntityUse,
} from '../types/itto'; } from '../types/itto';
import type { TimelineItem } from '../types/timeline';
// 导出原始数据 // 导出原始数据
export const knowledgeAreas: KnowledgeArea[] = knowledgeAreasData.knowledgeAreas as KnowledgeArea[]; 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 processes: Process[] = processesData.processes as Process[];
export const artifacts: Artifact[] = artifactsData.artifacts as Artifact[]; export const artifacts: Artifact[] = artifactsData.artifacts as Artifact[];
export const tools: ToolTechnique[] = toolsData.tools as ToolTechnique[]; 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>( export const knowledgeAreaMap = new Map<string, KnowledgeArea>(
@@ -46,6 +59,18 @@ export const toolMap = new Map<string, ToolTechnique>(
tools.map(t => [t.id, t]) 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[]>(); export const processesByKnowledgeArea = new Map<string, Process[]>();
knowledgeAreas.forEach(ka => { 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[] { export function computeDataFlows(): DataFlow[] {
const flows: DataFlow[] = []; const flows: DataFlow[] = [];
processes.forEach(sourceProcess => { processes.forEach(sourceProcess => {
sourceProcess.outputs.forEach(outputId => { sourceProcess.outputs.forEach(outputRef => {
const outputId = extractId(outputRef);
processes.forEach(targetProcess => { processes.forEach(targetProcess => {
if (targetProcess.id !== sourceProcess.id && targetProcess.inputs.includes(outputId)) { if (targetProcess.id !== sourceProcess.id && includesId(targetProcess.inputs, outputId)) {
flows.push({ flows.push({
sourceProcessId: sourceProcess.id, sourceProcessId: sourceProcess.id,
targetProcessId: targetProcess.id, targetProcessId: targetProcess.id,
@@ -91,14 +139,14 @@ export function getArtifactUsage(artifactId: string): {
asOutput: Process[]; asOutput: Process[];
} { } {
return { return {
asInput: processes.filter(p => p.inputs.includes(artifactId)), asInput: processes.filter(p => includesId(p.inputs, artifactId)),
asOutput: processes.filter(p => p.outputs.includes(artifactId)), asOutput: processes.filter(p => includesId(p.outputs, artifactId)),
}; };
} }
// 获取工具的使用情况 // 获取工具的使用情况
export function getToolUsage(toolId: string): Process[] { 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, ...process,
knowledgeArea: knowledgeAreaMap.get(process.knowledgeAreaId), knowledgeArea: knowledgeAreaMap.get(process.knowledgeAreaId),
processGroup: processGroupMap.get(process.processGroupId), processGroup: processGroupMap.get(process.processGroupId),
inputDetails: process.inputs.map(id => artifactMap.get(id)).filter(Boolean), inputDetails: process.inputs.map(ref => {
toolDetails: process.tools.map(id => toolMap.get(id)).filter(Boolean), const normalized = normalizeProcessRef(ref);
outputDetails: process.outputs.map(id => artifactMap.get(id)).filter(Boolean), 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),
}; };
} }

View File

@@ -8,7 +8,41 @@
"order": 1, "order": 1,
"color": "#6366F1", "color": "#6366F1",
"description": "包括识别、定义、组合、统一和协调各项目管理过程组的各种过程和活动", "description": "包括识别、定义、组合、统一和协调各项目管理过程组的各种过程和活动",
"processCount": 7 "processCount": 7,
"tailoringFactors": [
{
"title": "项目生命周期",
"description": "本项目合适的项目生命周期?项目生命周期应包括哪些阶段?"
},
{
"title": "开发生命周期",
"description": "对特定产品、服务或成果而言,什么是合适的开发生命周期和开发方法?预测型或适应型方法是否适当?如果使用适应型方法,开发产品是该采用增量还是迭代的方式?混合型方法是否为最佳选择?"
},
{
"title": "管理方法",
"description": "考虑到组织文化和项目的复杂性,哪种管理过程最有效?"
},
{
"title": "知识管理",
"description": "在项目中如何管理知识以营造合作的工作氛围?"
},
{
"title": "变更",
"description": "在项目中如何管理变更?"
},
{
"title": "治理",
"description": "有哪些监控机构、委员会和其他干系人该参与项目治理?对项目状态报告的要求是什么?"
},
{
"title": "经验教训",
"description": "在项目期间及项目结束时,应收集哪些信息?历史信息和经验教训是否适用于未来的项目?"
},
{
"title": "效益",
"description": "应该在何时以何种方式报告效益,是在项目结束时还是在每次迭代或阶段结束时?"
}
]
}, },
{ {
"id": "KA02", "id": "KA02",
@@ -18,7 +52,29 @@
"order": 2, "order": 2,
"color": "#8B5CF6", "color": "#8B5CF6",
"description": "确保项目包含且只包含成功完成项目所需的全部工作", "description": "确保项目包含且只包含成功完成项目所需的全部工作",
"processCount": 6 "processCount": 6,
"tailoringFactors": [
{
"title": "知识和需求管理",
"description": "项目经理应建立哪些指南?为了在未来项目中重复使用需求,组织是否拥有正式或非正式的知识和需求管理体系?"
},
{
"title": "确认和控制",
"description": "组织是否有正式或非正式的与确认和控制相关政策、程序和指南?"
},
{
"title": "开发方法",
"description": "组织是否采用敏捷方法管理项目?开发方法属于迭代型还是增量型?是否采用预测型方法?混合型方法是否有效?"
},
{
"title": "需求的稳定性",
"description": "项目中是否存在需求不稳定的领域?是否有必要采用精益、敏捷或其他适应型技术来处理不稳定的需求,直至需求稳定且定义明确?"
},
{
"title": "治理",
"description": "组织是否拥有正式或非正式的审计和治理政策、程序和指南?"
}
]
}, },
{ {
"id": "KA03", "id": "KA03",
@@ -28,7 +84,25 @@
"order": 3, "order": 3,
"color": "#EC4899", "color": "#EC4899",
"description": "管理项目按时完成所需的各个过程", "description": "管理项目按时完成所需的各个过程",
"processCount": 6 "processCount": 6,
"tailoringFactors": [
{
"title": "生命周期方法",
"description": "哪种生命周期方法最适合制订详细的进度计划?"
},
{
"title": "资源可用性",
"description": "影响资源可持续时间的因素是什么(如可用资源与其生产效率之间的相关性)?"
},
{
"title": "项目维度",
"description": "项目复杂性、技术不确定性、产品新颖度、速度或进度跟踪(如挣值、完成百分比)如何影响预期的控制水平?"
},
{
"title": "技术支持",
"description": "是否采用技术来制定、记录、传递、接收和存储项目进度模型的信息以及是否易于获取?"
}
]
}, },
{ {
"id": "KA04", "id": "KA04",
@@ -38,7 +112,29 @@
"order": 4, "order": 4,
"color": "#10B981", "color": "#10B981",
"description": "规划、估算、预算、融资、筹资、管理和控制成本的各过程", "description": "规划、估算、预算、融资、筹资、管理和控制成本的各过程",
"processCount": 4 "processCount": 4,
"tailoringFactors": [
{
"title": "知识管理",
"description": "组织是否拥有易于使用的、正式的知识管理体系和财务数据库并要求项目经理使用?"
},
{
"title": "估算和预算",
"description": "组织是否拥有正式或非正式的,与成本估算和预算相关的政策、程序和指南?"
},
{
"title": "挣值管理",
"description": "组织是否采用挣值管理来管理项目?"
},
{
"title": "敏捷方法的使用",
"description": "组织是否采用敏捷或适应型方法管理项目?这对成本估算有什么影响?"
},
{
"title": "治理",
"description": "组织是否拥有正式或非正式的审计和治理政策、程序和指南?"
}
]
}, },
{ {
"id": "KA05", "id": "KA05",
@@ -48,7 +144,25 @@
"order": 5, "order": 5,
"color": "#F59E0B", "color": "#F59E0B",
"description": "把组织的质量政策应用于规划、管理、控制项目和产品质量要求", "description": "把组织的质量政策应用于规划、管理、控制项目和产品质量要求",
"processCount": 3 "processCount": 3,
"tailoringFactors": [
{
"title": "政策合规与审计",
"description": "有哪些质量政策和程序?使用哪些质量工具、技术和模板?"
},
{
"title": "标准与法规合规性",
"description": "是否存在必须遵守的行业质量标准?需要考虑哪些政府、法律或法规方面的制约因素?"
},
{
"title": "持续改进",
"description": "如何管理项目中的质量改进?在组织层面还是单个项目层面管理?"
},
{
"title": "干系人参与",
"description": "项目环境是否有利于与干系人及供应商合作?"
}
]
}, },
{ {
"id": "KA06", "id": "KA06",
@@ -58,7 +172,33 @@
"order": 6, "order": 6,
"color": "#3B82F6", "color": "#3B82F6",
"description": "识别、获取和管理所需资源以成功完成项目", "description": "识别、获取和管理所需资源以成功完成项目",
"processCount": 6 "processCount": 6,
"tailoringFactors": [
{
"title": "多元化",
"description": "团队的多元化背景是什么?"
},
{
"title": "物理位置",
"description": "团队成员和实物资源的物理位置在哪里?"
},
{
"title": "行业特定资源",
"description": "所在行业需要哪些特殊资源?"
},
{
"title": "团队成员的获得",
"description": "如何获得项目团队成员?项目团队成员是全职还是兼职?"
},
{
"title": "团队管理",
"description": "如何管理项目团队建设?组织是否有管理团队建设的工具或是否需要创建新工具?是否存在有特殊需求的团队成员?是否需要为团队提供有关多元化管理的特别培训?"
},
{
"title": "生命周期方法",
"description": "项目采用哪些生命周期方法?"
}
]
}, },
{ {
"id": "KA07", "id": "KA07",
@@ -68,7 +208,29 @@
"order": 7, "order": 7,
"color": "#06B6D4", "color": "#06B6D4",
"description": "确保项目信息及时且恰当地规划、收集、生成、发布、存储、检索、管理、控制、监督和最终处置", "description": "确保项目信息及时且恰当地规划、收集、生成、发布、存储、检索、管理、控制、监督和最终处置",
"processCount": 3 "processCount": 3,
"tailoringFactors": [
{
"title": "干系人",
"description": "干系人是属于组织内部或外部,或者二者都是?"
},
{
"title": "物理地点",
"description": "团队成员身处何地?团队是否集中办公?团队是否位于相同地理区域?团队是否分散于多个时区?"
},
{
"title": "沟通技术",
"description": "哪项技术可用于创建、记录、传输、检索、追踪和存储沟通成果?哪些技术最适用于与干系人沟通且成本效益最高?"
},
{
"title": "语言",
"description": "语言是沟通活动中要考虑的主要因素。沟通时使用的是一种语言还是多种语言?是否已为适应多语种团队的复杂情况安排了资金?"
},
{
"title": "知识管理",
"description": "组织是否有正式的知识管理库?是否采用管理库?"
}
]
}, },
{ {
"id": "KA08", "id": "KA08",
@@ -78,7 +240,25 @@
"order": 8, "order": 8,
"color": "#EF4444", "color": "#EF4444",
"description": "规划风险管理、识别风险、开展风险分析、规划风险应对、实施风险应对和监督风险的各个过程", "description": "规划风险管理、识别风险、开展风险分析、规划风险应对、实施风险应对和监督风险的各个过程",
"processCount": 7 "processCount": 7,
"tailoringFactors": [
{
"title": "项目规模",
"description": "由预算、进度、范围和人数所体现的项目规模,要求采取更详细的风险管理方法吗?或者项目小到只需用简化的风险管理过程吗?"
},
{
"title": "项目复杂性",
"description": "由高水平创新、新技术采用、界面或外部依赖关系导致的项目复杂性提高,是否要求采用更稳健的风险管理方法?或者项目是否简单到只需用简化的风险管理过程?"
},
{
"title": "项目重要性",
"description": "项目的战略重要性有多大?项目风险的高级别是因为在创造突破性机会、克服组织经营的重大障碍或涉及重大产品创新吗?"
},
{
"title": "开发方法",
"description": "瀑布或预测型开发方式,项目的风险管理过程可以按阶段开展;敏捷或适应型开发方法,项目的风险管理过程可以在每个迭代过程中开展。"
}
]
}, },
{ {
"id": "KA09", "id": "KA09",
@@ -88,17 +268,49 @@
"order": 9, "order": 9,
"color": "#84CC16", "color": "#84CC16",
"description": "从项目团队外部采购或获取所需产品、服务或成果", "description": "从项目团队外部采购或获取所需产品、服务或成果",
"processCount": 3 "processCount": 3,
"tailoringFactors": [
{
"title": "采购的复杂性",
"description": "只开展一次主要的采购,或者需要在不同时间向不同卖方进行多次采购(会提高采购的复杂性)?"
},
{
"title": "物理地点",
"description": "买方和卖方在同一或邻近地点,或者位于不同时区、国家或大洲?"
},
{
"title": "治理和法规环境",
"description": "组织的采购政策是否和当地相关的法律法规兼容?当地的法律法规会如何影响合同审计工作?"
},
{
"title": "承包商的可用性",
"description": "是否有具备工作执行能力的承包商可供选择?"
}
]
}, },
{ {
"id": "KA10", "id": "KA10",
"name": "项目相关方管理", "name": "项目干系人管理",
"nameEn": "Project Stakeholder Management", "nameEn": "Project Stakeholder Management",
"chapter": 13, "chapter": 13,
"order": 10, "order": 10,
"color": "#F97316", "color": "#F97316",
"description": "识别影响或受项目影响的人员、团体或组织,分析其期望和影响,制定管理策略", "description": "识别影响或受项目影响的人员、团体或组织,分析其期望和影响,制定管理策略",
"processCount": 4 "processCount": 4,
"tailoringFactors": [
{
"title": "干系人多样性",
"description": "现有多少干系人?干系人群体中的文化多样性情况?"
},
{
"title": "干系人关系的复杂性",
"description": "干系人群体内的关系有多复杂?干系人或干系人群体加入的网络越多,与其相关的信息或误传网络就越复杂。"
},
{
"title": "沟通技术",
"description": "有哪些可用的沟通技术?为了实现该技术的最大价值,目前采用什么支持机制?"
}
]
} }
] ]
} }

View 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
View 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
}

View 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 || ''),
}))

File diff suppressed because it is too large Load Diff

View 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"
}
]
}

View File

@@ -119,6 +119,21 @@
{ "id": "TT117", "name": "引导式研讨会", "nameEn": "Facilitated Workshops", "type": "technique", "category": "data-gathering" }, { "id": "TT117", "name": "引导式研讨会", "nameEn": "Facilitated Workshops", "type": "technique", "category": "data-gathering" },
{ "id": "TT118", "name": "群体创新技术", "nameEn": "Group Creativity Techniques", "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": "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
View 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
View 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>
)
}

View File

@@ -1,9 +1,17 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { BookOpen, Layers, GitBranch, ArrowRight } from 'lucide-react' import { BookOpen, Layers, LayoutGrid, ArrowRight } from 'lucide-react'
import { stats } from '@/data' import { stats } from '@/data'
const features = [ const features = [
{
icon: LayoutGrid,
title: '49过程矩阵',
description: '全景展示5大过程组与10大知识领域的49个过程',
link: '/process-matrix',
color: 'from-emerald-500 to-teal-500',
count: stats.processCount,
},
{ {
icon: BookOpen, icon: BookOpen,
title: '知识领域', title: '知识领域',
@@ -20,14 +28,6 @@ const features = [
color: 'from-blue-500 to-cyan-500', color: 'from-blue-500 to-cyan-500',
count: stats.processGroupCount, count: stats.processGroupCount,
}, },
{
icon: GitBranch,
title: '可视化',
description: 'ITTO流程图和数据流向可视化分析',
link: '/visualize',
color: 'from-emerald-500 to-teal-500',
count: stats.processCount,
},
] ]
const containerVariants = { const containerVariants = {
@@ -75,10 +75,10 @@ export function HomePage() {
<ArrowRight size={18} /> <ArrowRight size={18} />
</Link> </Link>
<Link <Link
to="/visualize" to="/process-matrix"
className="inline-flex items-center gap-2 px-6 py-3 bg-white/20 text-white rounded-lg font-medium hover:bg-white/30 transition-colors" className="inline-flex items-center gap-2 px-6 py-3 bg-white/20 text-white rounded-lg font-medium hover:bg-white/30 transition-colors"
> >
</Link> </Link>
</div> </div>
</div> </div>
@@ -92,13 +92,12 @@ export function HomePage() {
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="visible" 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.knowledgeAreaCount, color: 'text-indigo-600' },
{ label: '过程组', value: stats.processGroupCount, color: 'text-blue-600' }, { label: '过程组', value: stats.processGroupCount, color: 'text-blue-600' },
{ label: '项目过程', value: stats.processCount, color: 'text-emerald-600' }, { label: '项目过程', value: stats.processCount, color: 'text-emerald-600' },
{ label: '工具技术', value: stats.toolCount, color: 'text-amber-600' },
].map((stat) => ( ].map((stat) => (
<motion.div <motion.div
key={stat.label} key={stat.label}
@@ -116,7 +115,7 @@ export function HomePage() {
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="visible" 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) => { {features.map((feature) => {
const Icon = feature.icon const Icon = feature.icon

View 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>
)
}

View File

@@ -1,18 +1,22 @@
import { useEffect, useRef } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { motion } from 'framer-motion' 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' import { knowledgeAreas, processesByKnowledgeArea, knowledgeAreaMap, processGroupMap } from '@/data'
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: { opacity: 1, transition: { staggerChildren: 0.03 } },
opacity: 1,
transition: { staggerChildren: 0.05 },
},
} }
const itemVariants = { const itemVariants = {
hidden: { opacity: 0, y: 20 }, hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 },
}
// 跳过动画时的变体(立即显示)
const skipAnimationVariants = {
hidden: { opacity: 1, y: 0 },
visible: { opacity: 1, y: 0 }, visible: { opacity: 1, y: 0 },
} }
@@ -21,107 +25,112 @@ export function KnowledgeAreasPage() {
const selectedKA = id ? knowledgeAreaMap.get(id) : null const selectedKA = id ? knowledgeAreaMap.get(id) : null
const processes = id ? processesByKnowledgeArea.get(id) || [] : [] 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) { if (selectedKA) {
// 显示知识领域详情
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* 面包屑 */} {/* 面包屑 */}
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> <nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Link to="/knowledge-areas" className="hover:text-indigo-600 dark:hover:text-indigo-400"> <Link to="/knowledge-areas" className="hover:text-indigo-600 dark:hover:text-indigo-400"></Link>
</Link>
<span>/</span> <span>/</span>
<span className="text-gray-900 dark:text-white">{selectedKA.name}</span> <span className="text-gray-900 dark:text-white">{selectedKA.name}</span>
</nav> </nav>
{/* 知识领域标题 */} {/* 知识领域标题 - 紧凑版 */}
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-6" className="rounded-xl p-4"
style={{ backgroundColor: `${selectedKA.color}15` }} style={{ backgroundColor: `${selectedKA.color}15` }}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div <div
className="flex h-14 w-14 items-center justify-center rounded-xl text-white font-bold text-xl" className="flex h-12 w-12 items-center justify-center rounded-lg text-white font-bold text-lg"
style={{ backgroundColor: selectedKA.color }} style={{ backgroundColor: selectedKA.color }}
> >
{selectedKA.order} {selectedKA.order}
</div> </div>
<div> <div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-xl font-bold text-gray-900 dark:text-white">{selectedKA.name}</h1>
{selectedKA.name} <p className="text-sm text-gray-500 dark:text-gray-400">{selectedKA.nameEn}</p>
</h1> </div>
<p className="text-gray-500 dark:text-gray-400">{selectedKA.nameEn}</p> <div className="text-right">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{processes.length}</div>
<div className="text-xs text-gray-500"></div>
</div> </div>
</div> </div>
<p className="mt-4 text-gray-600 dark:text-gray-300">{selectedKA.description}</p> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{selectedKA.description}</p>
</motion.div> </motion.div>
{/* 过程列表 */} {/* 敏捷裁剪因素 */}
<motion.div {selectedKA.tailoringFactors && selectedKA.tailoringFactors.length > 0 && (
variants={containerVariants} <motion.div
initial="hidden" initial={{ opacity: 0, y: 10 }}
animate="visible" animate={{ opacity: 1, y: 0 }}
className="space-y-4" 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"
<h2 className="text-lg font-semibold text-gray-900 dark:text-white"> >
{processes.length} <div className="flex items-center gap-2 mb-3">
</h2> <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) => { {processes.map((process) => {
const pg = processGroupMap.get(process.processGroupId) const pg = processGroupMap.get(process.processGroupId)
return ( return (
<motion.div key={process.id} variants={itemVariants}> <motion.div key={process.id} variants={activeItemVariants}>
<Link <Link
to={`/process/${process.id}`} to={`/process/${process.id}`}
className="group block bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md hover:border-gray-200 dark:hover:border-gray-600 transition-all" 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"
> >
<div className="flex items-center justify-between"> <div
<div className="flex items-center gap-4"> className="flex h-9 w-9 items-center justify-center rounded-lg text-white font-medium text-sm shrink-0"
<div style={{ backgroundColor: selectedKA.color }}
className="flex h-10 w-10 items-center justify-center rounded-lg text-white font-medium" >
style={{ backgroundColor: selectedKA.color }} {process.code}
>
{process.code}
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{process.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{process.nameEn}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{pg && (
<span
className="px-3 py-1 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: pg.color }}
>
{pg.name}
</span>
)}
<ArrowRight
size={20}
className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all"
/>
</div>
</div> </div>
{/* ITTO统计 */} <div className="flex-1 min-w-0">
<div className="mt-4 flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400"> <h3 className="font-medium text-gray-900 dark:text-white text-sm truncate">{process.name}</h3>
<span className="flex items-center gap-1"> <p className="text-xs text-gray-500 dark:text-gray-400 truncate">{process.nameEn}</p>
<FileText size={14} /> </div>
{process.inputs.length} <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 shrink-0">
</span> <span className="flex items-center gap-1"><FileText size={12} />{process.inputs.length}</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><Wrench size={12} />{process.tools.length}</span>
<Wrench size={14} /> <span className="flex items-center gap-1"><FileOutput size={12} />{process.outputs.length}</span>
{process.tools.length} {pg && (
</span> <span className="px-2 py-0.5 rounded-full text-xs font-medium text-white hidden sm:inline" style={{ backgroundColor: pg.color }}>
<span className="flex items-center gap-1"> {pg.name}
<FileOutput size={14} /> </span>
{process.outputs.length} )}
</span> <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" />
</div> </div>
</Link> </Link>
</motion.div> </motion.div>
@@ -132,55 +141,50 @@ export function KnowledgeAreasPage() {
) )
} }
// 显示知识领域列表 // 显示知识领域列表 - 紧凑版双列
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"></h1> <div>
<p className="text-gray-500 dark:text-gray-400 mt-1"> <h1 className="text-xl font-bold text-gray-900 dark:text-white"></h1>
PMBOK第6版定义的10大项目管理知识领域 <p className="text-sm text-gray-500 dark:text-gray-400">10</p>
</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> </div>
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
className="grid md:grid-cols-2 gap-4" className="grid md:grid-cols-2 gap-3"
> >
{knowledgeAreas.map((ka) => ( {knowledgeAreas.map((ka) => (
<motion.div key={ka.id} variants={itemVariants}> <motion.div key={ka.id} variants={activeItemVariants}>
<Link <Link
to={`/knowledge-areas/${ka.id}`} to={`/knowledge-areas/${ka.id}`}
className="group block bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md transition-all" 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: 4, borderLeftColor: ka.color }} style={{ borderLeftWidth: 3, borderLeftColor: ka.color }}
> >
<div className="flex items-center justify-between"> <div
<div className="flex items-center gap-4"> className="flex h-10 w-10 items-center justify-center rounded-lg text-white font-bold shrink-0"
<div style={{ backgroundColor: ka.color }}
className="flex h-12 w-12 items-center justify-center rounded-lg text-white font-bold text-lg" >
style={{ backgroundColor: ka.color }} {ka.order}
> </div>
{ka.order} <div className="flex-1 min-w-0">
</div> <h3 className="font-semibold text-gray-900 dark:text-white text-sm">{ka.name}</h3>
<div> <p className="text-xs text-gray-500 dark:text-gray-400 truncate">{ka.nameEn}</p>
<h3 className="font-semibold text-gray-900 dark:text-white">{ka.name}</h3> </div>
<p className="text-sm text-gray-500 dark:text-gray-400">{ka.nameEn}</p> <div className="flex items-center gap-2 shrink-0">
</div> <span className="text-sm font-medium text-gray-600 dark:text-gray-300">{ka.processCount}</span>
</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" />
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
{ka.processCount}
</span>
<ArrowRight
size={20}
className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all"
/>
</div>
</div> </div>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{ka.description}
</p>
</Link> </Link>
</motion.div> </motion.div>
))} ))}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ import {
getProcessDetail, getProcessDetail,
getArtifactUsage, getArtifactUsage,
getToolUsage, getToolUsage,
extractId,
} from '@/data' } from '@/data'
export function ProcessGraphPage() { export function ProcessGraphPage() {
@@ -92,8 +93,8 @@ export function ProcessGraphPage() {
// 2. 添加工件节点和关系 // 2. 添加工件节点和关系
const usedArtifacts = new Set<string>() const usedArtifacts = new Set<string>()
processes.forEach(p => { processes.forEach(p => {
p.inputs.forEach(id => usedArtifacts.add(id)) p.inputs.forEach(ref => usedArtifacts.add(extractId(ref)))
p.outputs.forEach(id => usedArtifacts.add(id)) p.outputs.forEach(ref => usedArtifacts.add(extractId(ref)))
}) })
artifacts.forEach(a => { artifacts.forEach(a => {
@@ -127,7 +128,7 @@ export function ProcessGraphPage() {
// 3. 添加工具节点和关系 // 3. 添加工具节点和关系
const usedTools = new Set<string>() const usedTools = new Set<string>()
processes.forEach(p => { processes.forEach(p => {
p.tools.forEach(id => usedTools.add(id)) p.tools.forEach(ref => usedTools.add(extractId(ref)))
}) })
tools.forEach(t => { tools.forEach(t => {
@@ -160,7 +161,8 @@ export function ProcessGraphPage() {
// 4. 构建边 // 4. 构建边
processes.forEach(p => { processes.forEach(p => {
// 输入关系: Artifact -> Process // 输入关系: Artifact -> Process
p.inputs.forEach(inputId => { p.inputs.forEach(inputRef => {
const inputId = extractId(inputRef)
if (addedNodeIds.has(inputId)) { if (addedNodeIds.has(inputId)) {
edges.push({ edges.push({
source: inputId, source: inputId,
@@ -176,7 +178,8 @@ export function ProcessGraphPage() {
}) })
// 输出关系: Process -> Artifact // 输出关系: Process -> Artifact
p.outputs.forEach(outputId => { p.outputs.forEach(outputRef => {
const outputId = extractId(outputRef)
if (addedNodeIds.has(outputId)) { if (addedNodeIds.has(outputId)) {
edges.push({ edges.push({
source: p.id, source: p.id,
@@ -192,7 +195,8 @@ export function ProcessGraphPage() {
}) })
// 工具关系: Tool -> Process // 工具关系: Tool -> Process
p.tools.forEach(toolId => { p.tools.forEach(toolRef => {
const toolId = extractId(toolRef)
if (addedNodeIds.has(toolId)) { if (addedNodeIds.has(toolId)) {
edges.push({ edges.push({
source: toolId, source: toolId,

View File

@@ -1,18 +1,15 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { motion } from 'framer-motion' 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' import { processGroups, processesByProcessGroup, processGroupMap, knowledgeAreaMap } from '@/data'
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: { opacity: 1, transition: { staggerChildren: 0.03 } },
opacity: 1,
transition: { staggerChildren: 0.05 },
},
} }
const itemVariants = { const itemVariants = {
hidden: { opacity: 0, y: 20 }, hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 }, visible: { opacity: 1, y: 0 },
} }
@@ -22,106 +19,71 @@ export function ProcessGroupsPage() {
const processes = id ? processesByProcessGroup.get(id) || [] : [] const processes = id ? processesByProcessGroup.get(id) || [] : []
if (selectedPG) { if (selectedPG) {
// 显示过程组详情
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* 面包屑 */} {/* 面包屑 */}
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> <nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Link to="/process-groups" className="hover:text-indigo-600 dark:hover:text-indigo-400"> <Link to="/process-groups" className="hover:text-indigo-600 dark:hover:text-indigo-400"></Link>
</Link>
<span>/</span> <span>/</span>
<span className="text-gray-900 dark:text-white">{selectedPG.name}</span> <span className="text-gray-900 dark:text-white">{selectedPG.name}</span>
</nav> </nav>
{/* 过程组标题 */} {/* 过程组标题 - 紧凑版 */}
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-6" className="rounded-xl p-4"
style={{ backgroundColor: `${selectedPG.color}15` }} style={{ backgroundColor: `${selectedPG.color}15` }}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div <div
className="flex h-14 w-14 items-center justify-center rounded-xl text-white font-bold text-xl" className="flex h-12 w-12 items-center justify-center rounded-lg text-white font-bold text-lg"
style={{ backgroundColor: selectedPG.color }} style={{ backgroundColor: selectedPG.color }}
> >
{selectedPG.order} {selectedPG.order}
</div> </div>
<div> <div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-xl font-bold text-gray-900 dark:text-white">{selectedPG.name}</h1>
{selectedPG.name} <p className="text-sm text-gray-500 dark:text-gray-400">{selectedPG.nameEn}</p>
</h1> </div>
<p className="text-gray-500 dark:text-gray-400">{selectedPG.nameEn}</p> <div className="text-right">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{processes.length}</div>
<div className="text-xs text-gray-500"></div>
</div> </div>
</div> </div>
<p className="mt-4 text-gray-600 dark:text-gray-300">{selectedPG.description}</p> <p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{selectedPG.description}</p>
</motion.div> </motion.div>
{/* 过程列表 */} {/* 过程列表 - 紧凑版 */}
<motion.div <motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-2">
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4"
>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{processes.length}
</h2>
{processes.map((process) => { {processes.map((process) => {
const ka = knowledgeAreaMap.get(process.knowledgeAreaId) const ka = knowledgeAreaMap.get(process.knowledgeAreaId)
return ( return (
<motion.div key={process.id} variants={itemVariants}> <motion.div key={process.id} variants={itemVariants}>
<Link <Link
to={`/process/${process.id}`} to={`/process/${process.id}`}
className="group block bg-white dark:bg-gray-800 rounded-xl p-5 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md hover:border-gray-200 dark:hover:border-gray-600 transition-all" 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"
> >
<div className="flex items-center justify-between"> <div
<div className="flex items-center gap-4"> className="flex h-9 w-9 items-center justify-center rounded-lg text-white font-medium text-sm shrink-0"
<div style={{ backgroundColor: ka?.color || selectedPG.color }}
className="flex h-10 w-10 items-center justify-center rounded-lg text-white font-medium" >
style={{ backgroundColor: ka?.color || selectedPG.color }} {process.code}
>
{process.code}
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{process.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{process.nameEn}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{ka && (
<span
className="px-3 py-1 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: ka.color }}
>
{ka.name}
</span>
)}
<ArrowRight
size={20}
className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all"
/>
</div>
</div> </div>
{/* ITTO统计 */} <div className="flex-1 min-w-0">
<div className="mt-4 flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400"> <h3 className="font-medium text-gray-900 dark:text-white text-sm truncate">{process.name}</h3>
<span className="flex items-center gap-1"> <p className="text-xs text-gray-500 dark:text-gray-400 truncate">{process.nameEn}</p>
<FileText size={14} /> </div>
{process.inputs.length} <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 shrink-0">
</span> <span className="flex items-center gap-1"><FileText size={12} />{process.inputs.length}</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><Wrench size={12} />{process.tools.length}</span>
<Wrench size={14} /> <span className="flex items-center gap-1"><FileOutput size={12} />{process.outputs.length}</span>
{process.tools.length} {ka && (
</span> <span className="px-2 py-0.5 rounded-full text-xs font-medium text-white hidden sm:inline" style={{ backgroundColor: ka.color }}>
<span className="flex items-center gap-1"> {ka.name}
<FileOutput size={14} /> </span>
{process.outputs.length} )}
</span> <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" />
</div> </div>
</Link> </Link>
</motion.div> </motion.div>
@@ -132,58 +94,49 @@ export function ProcessGroupsPage() {
) )
} }
// 显示过程组列表 // 显示过程组列表 - 紧凑版
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"></h1> <div>
<p className="text-gray-500 dark:text-gray-400 mt-1"> <h1 className="text-xl font-bold text-gray-900 dark:text-white"></h1>
PMBOK第6版定义的5大项目管理过程组 <p className="text-sm text-gray-500 dark:text-gray-400">5</p>
</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> </div>
<motion.div <motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-2">
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4"
>
{processGroups.map((pg) => ( {processGroups.map((pg) => (
<motion.div key={pg.id} variants={itemVariants}> <motion.div key={pg.id} variants={itemVariants}>
<Link <Link
to={`/process-groups/${pg.id}`} to={`/process-groups/${pg.id}`}
className="group block bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md transition-all" className="group flex items-center gap-4 bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md transition-all"
style={{ borderLeftWidth: 4, borderLeftColor: pg.color }} style={{ borderLeftWidth: 4, borderLeftColor: pg.color }}
> >
<div className="flex items-center justify-between"> <div
<div className="flex items-center gap-4"> className="flex h-12 w-12 items-center justify-center rounded-lg text-white font-bold text-lg shrink-0"
<div style={{ backgroundColor: pg.color }}
className="flex h-14 w-14 items-center justify-center rounded-xl text-white font-bold text-xl" >
style={{ backgroundColor: pg.color }} {pg.order}
> </div>
{pg.order} <div className="flex-1 min-w-0">
</div> <h3 className="font-semibold text-gray-900 dark:text-white">{pg.name}</h3>
<div> <p className="text-sm text-gray-500 dark:text-gray-400">{pg.nameEn}</p>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{pg.name}</h3> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1 line-clamp-1">{pg.description}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{pg.nameEn}</p> </div>
</div> <div className="flex items-center gap-3 shrink-0">
</div> <div className="text-right">
<div className="flex items-center gap-4"> <div className="text-xl font-bold text-gray-900 dark:text-white">{pg.processCount}</div>
<div className="text-right"> <div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-gray-900 dark:text-white"> </div>
{pg.processCount} <ArrowRight size={20} className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all" />
</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
<ArrowRight
size={24}
className="text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all"
/>
</div>
</div> </div>
<p className="mt-4 text-gray-500 dark:text-gray-400">
{pg.description}
</p>
</Link> </Link>
</motion.div> </motion.div>
))} ))}

View File

@@ -1,17 +1,102 @@
/** /**
* 49过程矩阵页面 * 49过程矩阵页面
*/ */
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { ProcessMatrix } from '@/components/visualize' import { ProcessMatrix } from '@/components/visualize'
import { Maximize2, Minimize2 } from 'lucide-react' import { Maximize2, Minimize2, Eye } from 'lucide-react'
import { useAppStore } from '@/stores/useAppStore' import { useAppStore } from '@/stores/useAppStore'
import { clsx } from 'clsx' 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() { export function ProcessMatrixPage() {
const isFullScreen = useAppStore((s) => s.matrixFullScreen) const isFullScreen = useAppStore((s) => s.matrixFullScreen)
const setMatrixFullScreen = useAppStore((s) => s.setMatrixFullScreen) const setMatrixFullScreen = useAppStore((s) => s.setMatrixFullScreen)
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) 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 = () => { const toggleFullScreen = () => {
if (!isFullScreen) { if (!isFullScreen) {
setSidebarOpen(false) setSidebarOpen(false)
@@ -31,7 +116,7 @@ export function ProcessMatrixPage() {
}, [isFullScreen, setMatrixFullScreen]) }, [isFullScreen, setMatrixFullScreen])
return ( return (
<div className={clsx("space-y-6", isFullScreen && "fixed inset-0 z-50 bg-white dark:bg-gray-900 p-0 m-0 space-y-0")}> <div className={clsx(isFullScreen ? "fixed inset-0 z-50 bg-white dark:bg-gray-900 p-0 m-0" : "space-y-6")}>
{/* 隐藏滚动条的样式 */} {/* 隐藏滚动条的样式 */}
{isFullScreen && ( {isFullScreen && (
<style>{` <style>{`
@@ -46,20 +131,34 @@ export function ProcessMatrixPage() {
)} )}
{!isFullScreen && ( {!isFullScreen && (
<div className="flex justify-between items-end"> <div className="flex justify-between items-end gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">49</h1> <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 className="text-gray-500 dark:text-gray-400 mt-1">
× ×
</p> </p>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={toggleFullScreen} {hasHidden && (
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" <button
> onClick={showAll}
<Maximize2 className="w-4 h-4" /> 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"
>
</button> <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"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
</div> </div>
)} )}
@@ -71,18 +170,39 @@ export function ProcessMatrixPage() {
{isFullScreen && ( {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"> <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> <span className="font-medium text-gray-900 dark:text-white">49</span>
<button <div className="flex items-center gap-2">
onClick={toggleFullScreen} {hasHidden && (
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" <button
> onClick={showAll}
<Minimize2 className="w-4 h-4" /> 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"
退 >
</button> <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"
>
<Minimize2 className="w-4 h-4" />
退
</button>
</div>
</div> </div>
)} )}
<div className={clsx("relative", isFullScreen ? "flex-1 overflow-hidden" : "")}> <div className={clsx("relative", isFullScreen ? "flex-1 overflow-hidden" : "")}>
<ProcessMatrix className={clsx(isFullScreen && "h-full w-full overflow-auto no-scrollbar")} /> <ProcessMatrix
className={clsx(isFullScreen && "h-full w-full overflow-auto no-scrollbar")}
isFullScreen={isFullScreen}
hiddenKnowledgeAreaIds={hiddenKnowledgeAreaIds}
hiddenProcessGroupIds={hiddenProcessGroupIds}
onToggleKnowledgeArea={toggleKnowledgeArea}
onToggleProcessGroup={toggleProcessGroup}
/>
</div> </div>
</div> </div>
</div> </div>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -1,9 +1,26 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Sun, Moon, Palette } from 'lucide-react' import { Sun, Moon, Palette, BrainCircuit } from 'lucide-react'
import { useAppStore } from '@/stores/useAppStore' 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() { export function SettingsPage() {
const { darkMode, setDarkMode } = useAppStore() const { darkMode, setDarkMode, practiceMode, setPracticeMode } = useAppStore()
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -68,18 +85,111 @@ export function SettingsPage() {
</div> </div>
</motion.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 <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} 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" 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> <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>1.0.0</p>
<p> PMBOK 6</p>
<p> 49 ITTO </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> </div>
</motion.div> </motion.div>
</div> </div>

View File

@@ -116,7 +116,7 @@ export function ToolDetailPage() {
{usedByProcesses.length === 0 && ( {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 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>
)} )}
</div> </div>

View File

@@ -3,7 +3,12 @@ export { KnowledgeAreasPage } from './KnowledgeAreasPage'
export { ProcessGroupsPage } from './ProcessGroupsPage' export { ProcessGroupsPage } from './ProcessGroupsPage'
export { ProcessDetailPage } from './ProcessDetailPage' export { ProcessDetailPage } from './ProcessDetailPage'
export { ProcessMatrixPage } from './ProcessMatrixPage' export { ProcessMatrixPage } from './ProcessMatrixPage'
export { ProcessRoadmapPage } from './ProcessRoadmapPage'
export { ProcessGraphPage } from './ProcessGraphPage' export { ProcessGraphPage } from './ProcessGraphPage'
export { ArtifactDetailPage } from './ArtifactDetailPage' export { ArtifactDetailPage } from './ArtifactDetailPage'
export { ToolDetailPage } from './ToolDetailPage' export { ToolDetailPage } from './ToolDetailPage'
export { SettingsPage } from './SettingsPage' export { SettingsPage } from './SettingsPage'
export { PerformanceDomainsPage } from './PerformanceDomainsPage'
export { LearningMapsPage } from './LearningMapsPage'
export { IttoCollectionsPage } from './IttoCollectionsPage'

View File

@@ -1,12 +1,15 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
export type PracticeMode = 'standard' | 'proficient'
interface AppState { interface AppState {
// UI状态 // UI状态
sidebarOpen: boolean sidebarOpen: boolean
darkMode: boolean darkMode: boolean
searchQuery: string searchQuery: string
matrixFullScreen: boolean matrixFullScreen: boolean
practiceMode: PracticeMode
// 操作 // 操作
toggleSidebar: () => void toggleSidebar: () => void
@@ -15,6 +18,7 @@ interface AppState {
setDarkMode: (dark: boolean) => void setDarkMode: (dark: boolean) => void
setSearchQuery: (query: string) => void setSearchQuery: (query: string) => void
setMatrixFullScreen: (fullScreen: boolean) => void setMatrixFullScreen: (fullScreen: boolean) => void
setPracticeMode: (mode: PracticeMode) => void
} }
export const useAppStore = create<AppState>()( export const useAppStore = create<AppState>()(
@@ -25,6 +29,7 @@ export const useAppStore = create<AppState>()(
darkMode: false, darkMode: false,
searchQuery: '', searchQuery: '',
matrixFullScreen: false, matrixFullScreen: false,
practiceMode: 'standard',
// 操作方法 // 操作方法
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
@@ -33,6 +38,7 @@ export const useAppStore = create<AppState>()(
setDarkMode: (dark) => set({ darkMode: dark }), setDarkMode: (dark) => set({ darkMode: dark }),
setSearchQuery: (query) => set({ searchQuery: query }), setSearchQuery: (query) => set({ searchQuery: query }),
setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }), setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }),
setPracticeMode: (mode) => set({ practiceMode: mode }),
}), }),
{ {
name: 'ittoview-app-storage', name: 'ittoview-app-storage',
@@ -40,6 +46,7 @@ export const useAppStore = create<AppState>()(
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
darkMode: state.darkMode, darkMode: state.darkMode,
matrixFullScreen: state.matrixFullScreen, matrixFullScreen: state.matrixFullScreen,
practiceMode: state.practiceMode,
// searchQuery 不持久化到 localStorage刷新后重置 // searchQuery 不持久化到 localStorage刷新后重置
}), }),
} }

View File

@@ -3,6 +3,12 @@
* PMP项目管理ITTO可视化学习平台 * PMP项目管理ITTO可视化学习平台
*/ */
// 敏捷裁剪因素项
export interface TailoringFactor {
title: string; // 因素标题
description: string; // 因素描述
}
// 知识领域 // 知识领域
export interface KnowledgeArea { export interface KnowledgeArea {
id: string; // 如 "KA01" id: string; // 如 "KA01"
@@ -13,6 +19,7 @@ export interface KnowledgeArea {
color: string; // 主题色 color: string; // 主题色
description: string; // 简要描述 description: string; // 简要描述
processCount: number; // 包含的过程数量 processCount: number; // 包含的过程数量
tailoringFactors?: TailoringFactor[]; // 敏捷裁剪因素(可选)
} }
// 过程组 // 过程组
@@ -26,6 +33,33 @@ export interface ProcessGroup {
processCount: number; // 包含的过程数量 processCount: number; // 包含的过程数量
} }
// 5W1H记忆辅助信息
export interface Process5W1H {
who: string; // 谁负责执行
what: string; // 做什么(核心目的)
when: string; // 什么时候执行
where: string; // 在哪里/什么场景执行
why: string; // 为什么要执行(价值意义)
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 { export interface Process {
id: string; // 如 "P4.1" id: string; // 如 "P4.1"
@@ -35,9 +69,11 @@ export interface Process {
knowledgeAreaId: string; // 所属知识领域ID knowledgeAreaId: string; // 所属知识领域ID
processGroupId: string; // 所属过程组ID processGroupId: string; // 所属过程组ID
order: number; // 在知识领域内的序号 order: number; // 在知识领域内的序号
inputs: string[]; // 输入工件ID列表 inputs: ProcessRef[]; // 输入工件ID列表(支持明细)
tools: string[]; // 工具与技术ID列表 tools: ProcessRef[]; // 工具与技术ID列表(支持明细)
outputs: string[]; // 输出工件ID列表 outputs: ProcessRef[]; // 输出工件ID列表(支持明细)
purpose?: string; // 本过程的主要作用
w5h1?: Process5W1H; // 5W1H记忆辅助信息
} }
// 工件类别 // 工件类别
@@ -116,6 +152,26 @@ export interface LearningStats {
lastStudyDate: Date; 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 { export interface DataFlow {
sourceProcessId: string; // 源过程ID sourceProcessId: string; // 源过程ID

30
src/types/timeline.ts Normal file
View 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
View 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)
}
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -5,7 +5,7 @@ import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: './', base: '/',
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),