@derekhut/alvy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +96 -0
  2. package/dist/app.d.ts +6 -0
  3. package/dist/app.js +17 -0
  4. package/dist/components/celebration.d.ts +7 -0
  5. package/dist/components/celebration.js +8 -0
  6. package/dist/components/cjk-test.d.ts +1 -0
  7. package/dist/components/cjk-test.js +57 -0
  8. package/dist/components/daily-session.d.ts +1 -0
  9. package/dist/components/daily-session.js +128 -0
  10. package/dist/components/dashboard.d.ts +7 -0
  11. package/dist/components/dashboard.js +13 -0
  12. package/dist/components/doctor.d.ts +1 -0
  13. package/dist/components/doctor.js +120 -0
  14. package/dist/components/explore.d.ts +1 -0
  15. package/dist/components/explore.js +5 -0
  16. package/dist/components/result.d.ts +7 -0
  17. package/dist/components/result.js +5 -0
  18. package/dist/components/review-session.d.ts +1 -0
  19. package/dist/components/review-session.js +108 -0
  20. package/dist/components/root-lesson.d.ts +9 -0
  21. package/dist/components/root-lesson.js +8 -0
  22. package/dist/components/session-summary.d.ts +8 -0
  23. package/dist/components/session-summary.js +5 -0
  24. package/dist/components/stats.d.ts +1 -0
  25. package/dist/components/stats.js +17 -0
  26. package/dist/components/streak-header.d.ts +7 -0
  27. package/dist/components/streak-header.js +7 -0
  28. package/dist/components/word-detail.d.ts +9 -0
  29. package/dist/components/word-detail.js +17 -0
  30. package/dist/components/word-drill.d.ts +20 -0
  31. package/dist/components/word-drill.js +54 -0
  32. package/dist/data/roots.json +1802 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +29 -0
  35. package/dist/lib/ai.d.ts +1 -0
  36. package/dist/lib/ai.js +5 -0
  37. package/dist/lib/progress.d.ts +17 -0
  38. package/dist/lib/progress.js +103 -0
  39. package/dist/lib/roots-db.d.ts +9 -0
  40. package/dist/lib/roots-db.js +25 -0
  41. package/dist/lib/store.d.ts +6 -0
  42. package/dist/lib/store.js +75 -0
  43. package/dist/lib/types.d.ts +39 -0
  44. package/dist/lib/types.js +1 -0
  45. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # alvy 词根学习
2
+
3
+ 终端词根记忆工具,用新东方词根词缀记忆法帮你拆解英语单词。
4
+
5
+ 30个词根 × 5个单词 = 150个核心词汇
6
+
7
+ ## 安装
8
+
9
+ ### macOS / Linux
10
+
11
+ 需要 Node.js 18+。如果没有,安装脚本会自动安装。
12
+
13
+ ```bash
14
+ curl -fsSL https://raw.githubusercontent.com/derekhut/alvy/main/install.sh | bash
15
+ ```
16
+
17
+ ### Windows
18
+
19
+ ```powershell
20
+ winget install OpenJS.NodeJS.LTS
21
+ npm install -g @derekhut/alvy
22
+ ```
23
+
24
+ ## 命令
25
+
26
+ | 命令 | 说明 |
27
+ |------|------|
28
+ | `alvy` | 开始今天的学习(每次3个词根) |
29
+ | `alvy review` | 复习已学词根 |
30
+ | `alvy stats` | 导出学习进度 |
31
+ | `alvy doctor` | 检查运行环境 |
32
+
33
+ ## 学习方式
34
+
35
+ 每个单词通过词根推导链来记忆:
36
+
37
+ ```
38
+ bene(好的) + fit(做) → 做好事 → 益处
39
+ bene(好的) + vol(意愿) + ent(…的) → 有好意愿的 → 仁慈的
40
+ pre(提前) + dict(说) → 提前说出 → 预测
41
+ ```
42
+
43
+ 每次学习3个词根,每个词根5个单词。按回车逐个学习,每个单词都有词根拆解、推导链、中文释义和例句。
44
+
45
+ ## 操作方式
46
+
47
+ - **Enter** — 下一步(进入下一个单词/词根)
48
+ - **q** — 退出(自动保存进度)
49
+ - **Ctrl+C** — 退出(自动保存进度)
50
+
51
+ ## 数据存储
52
+
53
+ 学习进度保存在 `~/.alvy/data.json`。
54
+
55
+ - 首次运行自动创建该文件
56
+ - 从旧版(`~/.toefl-roots/`)升级时自动迁移数据
57
+ - 如果文件损坏,自动备份至 `data.backup.json` 并重新开始
58
+ - 重置进度:删除 `~/.alvy/data.json` 即可从头开始
59
+
60
+ ```bash
61
+ # 查看进度文件
62
+ cat ~/.alvy/data.json
63
+
64
+ # 重置所有进度
65
+ rm ~/.alvy/data.json
66
+ ```
67
+
68
+ ## 卸载
69
+
70
+ ```bash
71
+ npm uninstall -g @derekhut/alvy
72
+ ```
73
+
74
+ ## 开发
75
+
76
+ ### 项目结构
77
+
78
+ ```
79
+ src/
80
+ index.tsx # CLI 入口
81
+ app.tsx # 命令路由
82
+ components/ # UI 组件(Ink/React)
83
+ lib/ # 业务逻辑和数据层
84
+ data/roots.json # 词根数据库
85
+ DESIGN.md # 设计系统
86
+ ARCHITECTURE.md # 架构文档
87
+ ```
88
+
89
+ ### 开发命令
90
+
91
+ ```bash
92
+ npm run build # 编译 TypeScript
93
+ npm run dev # 监听模式编译
94
+ npm test # 运行测试
95
+ node dist/index.js # 运行
96
+ ```
package/dist/app.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { Command } from "./lib/types.js";
2
+ interface AppProps {
3
+ command: Command;
4
+ }
5
+ export default function App({ command }: AppProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
package/dist/app.js ADDED
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import Doctor from "./components/doctor.js";
3
+ import DailySession from "./components/daily-session.js";
4
+ import ReviewSession from "./components/review-session.js";
5
+ import Stats from "./components/stats.js";
6
+ export default function App({ command }) {
7
+ switch (command) {
8
+ case "daily":
9
+ return _jsx(DailySession, {});
10
+ case "review":
11
+ return _jsx(ReviewSession, {});
12
+ case "stats":
13
+ return _jsx(Stats, {});
14
+ case "doctor":
15
+ return _jsx(Doctor, {});
16
+ }
17
+ }
@@ -0,0 +1,7 @@
1
+ import type { UserData } from "../lib/types.js";
2
+ interface CelebrationProps {
3
+ data: UserData;
4
+ totalRoots: number;
5
+ }
6
+ export default function Celebration({ data, totalRoots }: CelebrationProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export default function Celebration({ data, totalRoots }) {
4
+ const totalWords = Array.isArray(data.wordsStudied)
5
+ ? data.wordsStudied.length
6
+ : 0;
7
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "#FFAF00", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "#FFAF00", children: "\uD83C\uDF31 \u606D\u559C\u4F60\uFF01\u5168\u90E8\u8BCD\u6839\u5B66\u4E60\u5B8C\u6210\uFF01" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["\u4F60\u5DF2\u7ECF\u5B66\u4E60\u4E86\u6240\u6709 ", _jsx(Text, { bold: true, color: "#FFAF00", children: totalRoots }), " \u4E2A\u8BCD\u6839\uFF01"] }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: ["\u2B50 \u603B\u7ECF\u9A8C\u503C: ", _jsx(Text, { bold: true, color: "#FFAF00", children: data.xp.total }), " XP"] }), _jsxs(Text, { children: ["\uD83D\uDD25 \u6700\u957F\u8FDE\u7EED: ", _jsx(Text, { bold: true, color: "#FFAF00", children: data.streak.longest }), " \u5929"] }), _jsxs(Text, { children: ["\uD83D\uDCDA \u5DF2\u5B66\u5355\u8BCD: ", _jsx(Text, { bold: true, color: "#FFAF00", children: totalWords }), " \u4E2A"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u7EE7\u7EED\u4F7F\u7528 alvy review \u6765\u590D\u4E60\u5DE9\u56FA\uFF01" }) })] }) }));
8
+ }
@@ -0,0 +1 @@
1
+ export default function CjkTest(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import RootLesson from "./root-lesson.js";
4
+ // Sample entry for CJK alignment testing
5
+ const sampleEntry = {
6
+ root: "bene-",
7
+ type: "root",
8
+ meaning_en: "good, well",
9
+ meaning_zh: "好的,善良的",
10
+ origin: "Latin",
11
+ related: ["mal-"],
12
+ words: [
13
+ {
14
+ word: "benefit",
15
+ breakdown: "bene-(好的) + fit-(做)",
16
+ meaning_en: "an advantage or profit",
17
+ meaning_zh: "益处,好处",
18
+ example: "Exercise has many health benefits.",
19
+ toefl_frequency: "high",
20
+ },
21
+ {
22
+ word: "benevolent",
23
+ breakdown: "bene-(好的) + vol-(愿意) + -ent(形容词)",
24
+ meaning_en: "well-meaning and kindly",
25
+ meaning_zh: "仁慈的,善意的",
26
+ example: "The benevolent donor gave millions to charity.",
27
+ toefl_frequency: "medium",
28
+ },
29
+ {
30
+ word: "benefactor",
31
+ breakdown: "bene-(好的) + fact-(做) + -or(人)",
32
+ meaning_en: "a person who gives money or help",
33
+ meaning_zh: "恩人,捐助者",
34
+ example: "The university honored its greatest benefactor.",
35
+ toefl_frequency: "medium",
36
+ },
37
+ {
38
+ word: "benediction",
39
+ breakdown: "bene-(好的) + dict-(说) + -ion(名词)",
40
+ meaning_en: "a blessing or prayer",
41
+ meaning_zh: "祝福,祈祷",
42
+ example: "The priest offered a benediction at the ceremony.",
43
+ toefl_frequency: "low",
44
+ },
45
+ {
46
+ word: "beneficial",
47
+ breakdown: "bene-(好的) + fic-(做) + -ial(形容词)",
48
+ meaning_en: "favorable or advantageous",
49
+ meaning_zh: "有益的,有利的",
50
+ example: "A balanced diet is beneficial to your health.",
51
+ toefl_frequency: "high",
52
+ },
53
+ ],
54
+ };
55
+ export default function CjkTest() {
56
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { borderStyle: "double", borderColor: "magenta", paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: "magenta", children: "CJK Alignment Test \u2014 \u4E2D\u6587\u5BF9\u9F50\u6D4B\u8BD5" }) }), _jsx(RootLesson, { entry: sampleEntry, index: 0, total: 1, relatedMeanings: { "mal-": "bad, evil 坏的" }, onContinue: () => { } })] }));
57
+ }
@@ -0,0 +1 @@
1
+ export default function DailySession(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import { Box, useInput, useApp } from "ink";
4
+ import { loadData, saveData } from "../lib/store.js";
5
+ import { getAllRoots, getRootCount, getRelatedMeanings } from "../lib/roots-db.js";
6
+ import { selectNextMorphemes, markRootSeen, markWordStudied, addXP, updateStreak, masteredCount, } from "../lib/progress.js";
7
+ import Dashboard from "./dashboard.js";
8
+ import RootLesson from "./root-lesson.js";
9
+ import WordDetail from "./word-detail.js";
10
+ import SessionSummary from "./session-summary.js";
11
+ import Celebration from "./celebration.js";
12
+ import StreakHeader from "./streak-header.js";
13
+ export default function DailySession() {
14
+ const { exit } = useApp();
15
+ const [data, setData] = useState(() => loadData());
16
+ const [phase, setPhase] = useState("dashboard");
17
+ const [morphemes, setMorphemes] = useState([]);
18
+ const [morphemeIdx, setMorphemeIdx] = useState(0);
19
+ const [wordIdx, setWordIdx] = useState(0);
20
+ // Session totals
21
+ const [sessionXP, setSessionXP] = useState(0);
22
+ const [sessionWords, setSessionWords] = useState(0);
23
+ const allRoots = getAllRoots();
24
+ const totalRoots = getRootCount();
25
+ // Save on unmount / SIGINT
26
+ useEffect(() => {
27
+ const save = () => saveData(data);
28
+ const handleSigint = () => {
29
+ save();
30
+ process.exit(0);
31
+ };
32
+ process.on("SIGINT", handleSigint);
33
+ return () => {
34
+ save();
35
+ process.removeListener("SIGINT", handleSigint);
36
+ };
37
+ }, [data]);
38
+ const startSession = useCallback(() => {
39
+ const selected = selectNextMorphemes(data, allRoots, data.dailyGoal);
40
+ if (selected.length === 0) {
41
+ setPhase("celebration");
42
+ return;
43
+ }
44
+ setMorphemes(selected);
45
+ setMorphemeIdx(0);
46
+ setPhase("root-intro");
47
+ }, [data, allRoots]);
48
+ const startWordWalkthrough = useCallback(() => {
49
+ const entry = morphemes[morphemeIdx];
50
+ const newData = { ...data };
51
+ markRootSeen(newData, entry.root);
52
+ setData(newData);
53
+ setWordIdx(0);
54
+ setPhase("word-detail");
55
+ }, [morphemes, morphemeIdx, data]);
56
+ const advanceWord = useCallback(() => {
57
+ const entry = morphemes[morphemeIdx];
58
+ const currentWord = entry.words[wordIdx];
59
+ // Mark word studied and add XP
60
+ const newData = { ...data };
61
+ markWordStudied(newData, entry.root, currentWord.word);
62
+ addXP(newData, 10);
63
+ setData(newData);
64
+ setSessionXP((x) => x + 10);
65
+ setSessionWords((w) => w + 1);
66
+ const nextWord = wordIdx + 1;
67
+ if (nextWord < entry.words.length) {
68
+ // More words in this root
69
+ setWordIdx(nextWord);
70
+ }
71
+ else {
72
+ // Root complete, move to next
73
+ const nextMorpheme = morphemeIdx + 1;
74
+ if (nextMorpheme < morphemes.length) {
75
+ setMorphemeIdx(nextMorpheme);
76
+ setPhase("root-intro");
77
+ }
78
+ else {
79
+ // Session complete
80
+ updateStreak(newData);
81
+ setData(newData);
82
+ saveData(newData);
83
+ if (masteredCount(newData) >= totalRoots) {
84
+ setPhase("celebration");
85
+ }
86
+ else {
87
+ setPhase("summary");
88
+ }
89
+ }
90
+ }
91
+ }, [morphemes, morphemeIdx, wordIdx, data, totalRoots]);
92
+ useInput((input, key) => {
93
+ if (input === "q") {
94
+ saveData(data);
95
+ exit();
96
+ return;
97
+ }
98
+ if (key.return) {
99
+ switch (phase) {
100
+ case "dashboard":
101
+ startSession();
102
+ break;
103
+ case "root-intro":
104
+ startWordWalkthrough();
105
+ break;
106
+ case "word-detail":
107
+ advanceWord();
108
+ break;
109
+ case "summary":
110
+ case "celebration":
111
+ exit();
112
+ break;
113
+ }
114
+ }
115
+ });
116
+ switch (phase) {
117
+ case "dashboard":
118
+ return _jsx(Dashboard, { data: data, totalRoots: totalRoots });
119
+ case "root-intro":
120
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StreakHeader, { data: data, totalRoots: totalRoots }), _jsx(RootLesson, { entry: morphemes[morphemeIdx], index: morphemeIdx, total: morphemes.length, relatedMeanings: getRelatedMeanings(morphemes[morphemeIdx]) })] }));
121
+ case "word-detail":
122
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StreakHeader, { data: data, totalRoots: totalRoots }), _jsx(WordDetail, { word: morphemes[morphemeIdx].words[wordIdx], wordNum: wordIdx + 1, totalWords: morphemes[morphemeIdx].words.length, rootKey: morphemes[morphemeIdx].root })] }));
123
+ case "summary":
124
+ return (_jsx(SessionSummary, { xpEarned: sessionXP, wordsLearned: sessionWords, rootsStudied: morphemes.length, streak: data.streak.current }));
125
+ case "celebration":
126
+ return _jsx(Celebration, { data: data, totalRoots: totalRoots });
127
+ }
128
+ }
@@ -0,0 +1,7 @@
1
+ import type { UserData } from "../lib/types.js";
2
+ interface DashboardProps {
3
+ data: UserData;
4
+ totalRoots: number;
5
+ }
6
+ export default function Dashboard({ data, totalRoots }: DashboardProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { masteredCount, seenCount } from "../lib/progress.js";
4
+ export default function Dashboard({ data, totalRoots }) {
5
+ const mastered = masteredCount(data);
6
+ const seen = seenCount(data);
7
+ const barWidth = 30;
8
+ const filled = Math.round((mastered / totalRoots) * barWidth);
9
+ const empty = barWidth - filled;
10
+ const bar = "█".repeat(filled) + "░".repeat(empty);
11
+ const pct = totalRoots > 0 ? Math.round((mastered / totalRoots) * 100) : 0;
12
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#4E4E4E", paddingX: 2, paddingY: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "#AF5FFF", children: "alvy" }), _jsx(Text, { color: "#AF5FFF", children: " \u8BCD\u6839\u5B66\u4E60" })] }), _jsx(Text, { dimColor: true, children: "\u89E3\u9501\u82F1\u8BED\u7684\u5E95\u5C42\u903B\u8F91" }), _jsxs(Box, { marginTop: 1, gap: 3, children: [_jsxs(Text, { children: ["\uD83D\uDD25 \u8FDE\u7EED ", _jsx(Text, { bold: true, color: "#FFAF00", children: data.streak.current }), " \u5929"] }), _jsxs(Text, { children: ["\u2B50 ", _jsx(Text, { bold: true, color: "#FFAF00", children: data.xp.total }), " XP"] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: ["\u8FDB\u5EA6: ", _jsxs(Text, { bold: true, color: "#FFAF00", children: [mastered, "/", totalRoots] }), " \u5DF2\u638C\u63E1\uFF08", seen, " \u5DF2\u5B66\u4E60\uFF09"] }), _jsxs(Box, { children: [_jsx(Text, { color: "#AF5FFF", children: bar }), _jsxs(Text, { children: [" ", pct, "%"] })] })] }), data.streak.longest > 1 && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u6700\u957F\u8FDE\u7EED: ", data.streak.longest, " \u5929"] }) }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u6309\u56DE\u8F66\u5F00\u59CB\u4ECA\u5929\u7684\u5B66\u4E60 \u2192" }) })] }));
13
+ }
@@ -0,0 +1 @@
1
+ export default function Doctor(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { execSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { DATA_DIR } from "../lib/store.js";
8
+ export default function Doctor() {
9
+ const [checks, setChecks] = useState([
10
+ { label: "Node.js 版本", status: "pending", detail: "" },
11
+ { label: "npm 访问", status: "pending", detail: "" },
12
+ { label: "中文字体渲染", status: "pending", detail: "" },
13
+ { label: "数据目录", status: "pending", detail: "" },
14
+ ]);
15
+ const [done, setDone] = useState(false);
16
+ useEffect(() => {
17
+ const results = [];
18
+ // 1. Node.js version (>= 18 required)
19
+ const nodeVersion = process.version;
20
+ const major = parseInt(nodeVersion.slice(1).split(".")[0], 10);
21
+ if (major >= 18) {
22
+ results.push({
23
+ label: "Node.js 版本",
24
+ status: "pass",
25
+ detail: `${nodeVersion}(需要 >= 18)`,
26
+ });
27
+ }
28
+ else {
29
+ results.push({
30
+ label: "Node.js 版本",
31
+ status: "fail",
32
+ detail: `${nodeVersion} — 需要 Node.js 18+,请访问 https://nodejs.org`,
33
+ });
34
+ }
35
+ // 2. npm access
36
+ try {
37
+ const npmVersion = execSync("npm --version", { encoding: "utf-8" }).trim();
38
+ results.push({
39
+ label: "npm 访问",
40
+ status: "pass",
41
+ detail: `npm ${npmVersion}`,
42
+ });
43
+ }
44
+ catch {
45
+ results.push({
46
+ label: "npm 访问",
47
+ status: "fail",
48
+ detail: "未找到 npm,请确认 Node.js 安装正确",
49
+ });
50
+ }
51
+ // 3. CJK font rendering test
52
+ const lang = process.env["LANG"] || process.env["LC_ALL"] || "";
53
+ const hasUtf8 = lang.toLowerCase().includes("utf") || process.platform === "darwin";
54
+ if (hasUtf8) {
55
+ results.push({
56
+ label: "中文字体渲染",
57
+ status: "pass",
58
+ detail: `检测到 UTF-8(${lang || "macOS 默认"})测试: 善良 仁慈 好的`,
59
+ });
60
+ }
61
+ else {
62
+ results.push({
63
+ label: "中文字体渲染",
64
+ status: "warn",
65
+ detail: `当前: "${lang}",请设置 LANG=en_US.UTF-8 以支持中文。测试: 善良 仁慈 好的`,
66
+ });
67
+ }
68
+ // 4. Data directory (~/.alvy/)
69
+ try {
70
+ if (!fs.existsSync(DATA_DIR)) {
71
+ fs.mkdirSync(DATA_DIR, { recursive: true });
72
+ }
73
+ // Test write permission
74
+ const testFile = path.join(DATA_DIR, ".doctor-test");
75
+ fs.writeFileSync(testFile, "ok");
76
+ fs.unlinkSync(testFile);
77
+ results.push({
78
+ label: "数据目录",
79
+ status: "pass",
80
+ detail: `${DATA_DIR}(可写)`,
81
+ });
82
+ }
83
+ catch (err) {
84
+ results.push({
85
+ label: "数据目录",
86
+ status: "fail",
87
+ detail: `无法写入 ${DATA_DIR}: ${err instanceof Error ? err.message : String(err)}`,
88
+ });
89
+ }
90
+ setChecks(results);
91
+ setDone(true);
92
+ }, []);
93
+ const icon = (status) => {
94
+ switch (status) {
95
+ case "pass":
96
+ return "✓";
97
+ case "fail":
98
+ return "✗";
99
+ case "warn":
100
+ return "!";
101
+ case "pending":
102
+ return "…";
103
+ }
104
+ };
105
+ const color = (status) => {
106
+ switch (status) {
107
+ case "pass":
108
+ return "#5FD7FF";
109
+ case "fail":
110
+ return "#FF5F87";
111
+ case "warn":
112
+ return "#FFAF00";
113
+ case "pending":
114
+ return "#6C6C6C";
115
+ }
116
+ };
117
+ const passCount = checks.filter((c) => c.status === "pass").length;
118
+ const failCount = checks.filter((c) => c.status === "fail").length;
119
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "#AF5FFF", children: "alvy \u73AF\u5883\u68C0\u67E5" }), _jsx(Text, { dimColor: true, children: "\u6B63\u5728\u68C0\u67E5\u4F60\u7684\u73AF\u5883\u2026" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: checks.map((check) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color(check.status), children: icon(check.status) }), _jsx(Text, { bold: true, children: check.label }), _jsx(Text, { dimColor: true, children: check.detail })] }, check.label))) }), done && (_jsx(Box, { marginTop: 1, children: failCount === 0 ? (_jsxs(Text, { color: "#5FD7FF", bold: true, children: ["\u5168\u90E8\u68C0\u67E5\u901A\u8FC7 (", passCount, "/", checks.length, ")\uFF0C\u53EF\u4EE5\u5F00\u59CB\u5B66\u4E60\uFF01"] })) : (_jsxs(Text, { color: "#FF5F87", bold: true, children: [failCount, " \u9879\u68C0\u67E5\u672A\u901A\u8FC7\uFF0C\u8BF7\u5148\u89E3\u51B3\u4E0A\u9762\u7684\u95EE\u9898\u3002"] })) }))] }));
120
+ }
@@ -0,0 +1 @@
1
+ export default function Explore(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export default function Explore() {
4
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: [_jsx(Box, { borderStyle: "round", borderColor: "#4E4E4E", paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "\u63A2\u7D22\u6A21\u5F0F \u2014 \u5373\u5C06\u5728 V2 \u63A8\u51FA" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "AI \u9A71\u52A8\u7684\u8BCD\u6839\u63A2\u7D22\u529F\u80FD\u5C06\u5728\u672A\u6765\u66F4\u65B0\u4E2D\u63A8\u51FA\u3002" }) })] }));
5
+ }
@@ -0,0 +1,7 @@
1
+ import type { RootWord } from "../lib/types.js";
2
+ interface ResultProps {
3
+ word: RootWord;
4
+ isCorrect: boolean;
5
+ }
6
+ export default function Result({ word, isCorrect }: ResultProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export default function Result({ word, isCorrect }) {
4
+ return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: isCorrect ? "green" : "red", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: isCorrect ? "green" : "red", children: isCorrect ? "✓ Correct!" : "✗ Incorrect" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: word.word }), " = ", word.breakdown] }) }), _jsx(Box, { marginTop: 1, gap: 2, children: _jsxs(Text, { children: ["English: ", word.meaning_en] }) }), _jsx(Box, { gap: 2, children: _jsxs(Text, { children: ["\u4E2D\u6587: ", _jsx(Text, { color: "yellow", children: word.meaning_zh })] }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: ["\"", word.example, "\""] }) })] }) }));
5
+ }
@@ -0,0 +1 @@
1
+ export default function ReviewSession(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import { Box, Text, useInput, useApp } from "ink";
4
+ import { loadData, saveData } from "../lib/store.js";
5
+ import { getAllRoots, getRootCount, getRelatedMeanings } from "../lib/roots-db.js";
6
+ import { markWordStudied, addXP, updateStreak, } from "../lib/progress.js";
7
+ import RootLesson from "./root-lesson.js";
8
+ import WordDetail from "./word-detail.js";
9
+ import SessionSummary from "./session-summary.js";
10
+ import StreakHeader from "./streak-header.js";
11
+ export default function ReviewSession() {
12
+ const { exit } = useApp();
13
+ const [data, setData] = useState(() => loadData());
14
+ const allRoots = getAllRoots();
15
+ const totalRoots = getRootCount();
16
+ // Review: pick seen roots, sorted by fewest words studied
17
+ const reviewMorphemes = allRoots
18
+ .filter((r) => data.rootProgress[r.root]?.seen)
19
+ .sort((a, b) => {
20
+ const wA = data.rootProgress[a.root]?.wordsStudied ?? 0;
21
+ const wB = data.rootProgress[b.root]?.wordsStudied ?? 0;
22
+ return wA - wB;
23
+ })
24
+ .slice(0, 3);
25
+ const [phase, setPhase] = useState(reviewMorphemes.length === 0 ? "empty" : "intro");
26
+ const [morphemeIdx, setMorphemeIdx] = useState(0);
27
+ const [wordIdx, setWordIdx] = useState(0);
28
+ const [sessionXP, setSessionXP] = useState(0);
29
+ const [sessionWords, setSessionWords] = useState(0);
30
+ useEffect(() => {
31
+ const save = () => saveData(data);
32
+ const handleSigint = () => {
33
+ save();
34
+ process.exit(0);
35
+ };
36
+ process.on("SIGINT", handleSigint);
37
+ return () => {
38
+ save();
39
+ process.removeListener("SIGINT", handleSigint);
40
+ };
41
+ }, [data]);
42
+ const startWordWalkthrough = useCallback(() => {
43
+ setWordIdx(0);
44
+ setPhase("word-detail");
45
+ }, []);
46
+ const advanceWord = useCallback(() => {
47
+ const entry = reviewMorphemes[morphemeIdx];
48
+ const currentWord = entry.words[wordIdx];
49
+ const newData = { ...data };
50
+ markWordStudied(newData, entry.root, currentWord.word);
51
+ addXP(newData, 10);
52
+ setData(newData);
53
+ setSessionXP((x) => x + 10);
54
+ setSessionWords((w) => w + 1);
55
+ const nextWord = wordIdx + 1;
56
+ if (nextWord < entry.words.length) {
57
+ setWordIdx(nextWord);
58
+ }
59
+ else {
60
+ const nextMorpheme = morphemeIdx + 1;
61
+ if (nextMorpheme < reviewMorphemes.length) {
62
+ setMorphemeIdx(nextMorpheme);
63
+ setPhase("root-intro");
64
+ }
65
+ else {
66
+ updateStreak(newData);
67
+ setData(newData);
68
+ saveData(newData);
69
+ setPhase("summary");
70
+ }
71
+ }
72
+ }, [reviewMorphemes, morphemeIdx, wordIdx, data]);
73
+ useInput((input, key) => {
74
+ if (input === "q") {
75
+ saveData(data);
76
+ exit();
77
+ return;
78
+ }
79
+ if (key.return) {
80
+ switch (phase) {
81
+ case "intro":
82
+ case "root-intro":
83
+ startWordWalkthrough();
84
+ break;
85
+ case "word-detail":
86
+ advanceWord();
87
+ break;
88
+ case "summary":
89
+ case "empty":
90
+ exit();
91
+ break;
92
+ }
93
+ }
94
+ });
95
+ if (phase === "empty") {
96
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "#AF5FFF", children: "\u8FD8\u6CA1\u6709\u53EF\u4EE5\u590D\u4E60\u7684\u8BCD\u6839" }), _jsx(Text, { children: "\u5148\u8FD0\u884C alvy \u5B66\u4E60\u65B0\u8BCD\u6839\u5427\uFF01" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u6309\u56DE\u8F66\u9000\u51FA" }) })] }));
97
+ }
98
+ switch (phase) {
99
+ case "intro":
100
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#4E4E4E", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "#AF5FFF", children: "\u590D\u4E60\u6A21\u5F0F" }), _jsxs(Text, { children: ["\u4ECA\u5929\u590D\u4E60 ", reviewMorphemes.length, " \u4E2A\u8BCD\u6839:"] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: reviewMorphemes.map((r) => (_jsxs(Text, { children: ["\u00B7 ", r.root, " (", _jsx(Text, { dimColor: true, children: r.meaning_zh }), ")"] }, r.root))) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u6309\u56DE\u8F66\u5F00\u59CB \u2192" }) })] }));
101
+ case "root-intro":
102
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StreakHeader, { data: data, totalRoots: totalRoots }), _jsx(RootLesson, { entry: reviewMorphemes[morphemeIdx], index: morphemeIdx, total: reviewMorphemes.length, relatedMeanings: getRelatedMeanings(reviewMorphemes[morphemeIdx]) })] }));
103
+ case "word-detail":
104
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StreakHeader, { data: data, totalRoots: totalRoots }), _jsx(WordDetail, { word: reviewMorphemes[morphemeIdx].words[wordIdx], wordNum: wordIdx + 1, totalWords: reviewMorphemes[morphemeIdx].words.length, rootKey: reviewMorphemes[morphemeIdx].root })] }));
105
+ case "summary":
106
+ return (_jsx(SessionSummary, { xpEarned: sessionXP, wordsLearned: sessionWords, rootsStudied: reviewMorphemes.length, streak: data.streak.current }));
107
+ }
108
+ }
@@ -0,0 +1,9 @@
1
+ import type { RootEntry } from "../lib/types.js";
2
+ interface RootLessonProps {
3
+ entry: RootEntry;
4
+ index: number;
5
+ total: number;
6
+ relatedMeanings?: Record<string, string>;
7
+ }
8
+ export default function RootLesson({ entry, index, total, relatedMeanings, }: RootLessonProps): import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export default function RootLesson({ entry, index, total, relatedMeanings, }) {
4
+ const typeLabel = entry.type === "root" ? "词根" : entry.type === "prefix" ? "前缀" : "后缀";
5
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, paddingY: 1, children: [_jsxs(Text, { dimColor: true, children: ["\u8BCD\u6839 ", index + 1, "/", total] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#4E4E4E", paddingX: 2, paddingY: 1, marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "#AF5FFF", children: entry.root }), _jsxs(Text, { dimColor: true, children: ["(", entry.origin, ")"] }), _jsx(Text, { dimColor: true, children: typeLabel })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["\u8868\u793A \"", _jsx(Text, { dimColor: true, children: entry.meaning_zh }), "\"\uFF08", entry.meaning_en, "\uFF09"] }) }), entry.related.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u76F8\u5173:", " ", entry.related
6
+ .map((r) => relatedMeanings?.[r] ? `${r}(${relatedMeanings[r]})` : r)
7
+ .join("、")] }) }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u5305\u542B\u8FD9\u4E2A\u8BCD\u6839\u7684\u5355\u8BCD:" }), _jsx(Box, { marginTop: 0, gap: 2, flexWrap: "wrap", children: entry.words.map((w) => (_jsx(Text, { bold: true, children: w.word }, w.word))) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u6309\u56DE\u8F66\u9010\u4E2A\u5B66\u4E60 \u2192" }) })] }));
8
+ }
@@ -0,0 +1,8 @@
1
+ interface SessionSummaryProps {
2
+ xpEarned: number;
3
+ wordsLearned: number;
4
+ rootsStudied: number;
5
+ streak: number;
6
+ }
7
+ export default function SessionSummary({ xpEarned, wordsLearned, rootsStudied, streak, }: SessionSummaryProps): import("react/jsx-runtime").JSX.Element;
8
+ export {};