@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.
- package/README.md +96 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.js +17 -0
- package/dist/components/celebration.d.ts +7 -0
- package/dist/components/celebration.js +8 -0
- package/dist/components/cjk-test.d.ts +1 -0
- package/dist/components/cjk-test.js +57 -0
- package/dist/components/daily-session.d.ts +1 -0
- package/dist/components/daily-session.js +128 -0
- package/dist/components/dashboard.d.ts +7 -0
- package/dist/components/dashboard.js +13 -0
- package/dist/components/doctor.d.ts +1 -0
- package/dist/components/doctor.js +120 -0
- package/dist/components/explore.d.ts +1 -0
- package/dist/components/explore.js +5 -0
- package/dist/components/result.d.ts +7 -0
- package/dist/components/result.js +5 -0
- package/dist/components/review-session.d.ts +1 -0
- package/dist/components/review-session.js +108 -0
- package/dist/components/root-lesson.d.ts +9 -0
- package/dist/components/root-lesson.js +8 -0
- package/dist/components/session-summary.d.ts +8 -0
- package/dist/components/session-summary.js +5 -0
- package/dist/components/stats.d.ts +1 -0
- package/dist/components/stats.js +17 -0
- package/dist/components/streak-header.d.ts +7 -0
- package/dist/components/streak-header.js +7 -0
- package/dist/components/word-detail.d.ts +9 -0
- package/dist/components/word-detail.js +17 -0
- package/dist/components/word-drill.d.ts +20 -0
- package/dist/components/word-drill.js +54 -0
- package/dist/data/roots.json +1802 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +29 -0
- package/dist/lib/ai.d.ts +1 -0
- package/dist/lib/ai.js +5 -0
- package/dist/lib/progress.d.ts +17 -0
- package/dist/lib/progress.js +103 -0
- package/dist/lib/roots-db.d.ts +9 -0
- package/dist/lib/roots-db.js +25 -0
- package/dist/lib/store.d.ts +6 -0
- package/dist/lib/store.js +75 -0
- package/dist/lib/types.d.ts +39 -0
- package/dist/lib/types.js +1 -0
- 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
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,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,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,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 {};
|