@octo-cyber/trainer 0.5.2
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/data/training-items.ts +217 -0
- package/lib/storage.ts +76 -0
- package/lib/tts.ts +34 -0
- package/lib/use-practice.ts +186 -0
- package/package.json +41 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +8 -0
- package/web/index.ts +9 -0
- package/web/manifest.ts +18 -0
- package/web/messages/en-US.json +46 -0
- package/web/messages/zh-CN.json +46 -0
- package/web/pages/TrainerPage.tsx +388 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export type Category = 'ln' | 'nasal' | 'flat-retro' | 'hf' | 'r' | 'er'
|
|
2
|
+
|
|
3
|
+
export interface TrainingItem {
|
|
4
|
+
id: string
|
|
5
|
+
char: string
|
|
6
|
+
pinyin: string
|
|
7
|
+
category: Category
|
|
8
|
+
correct: string
|
|
9
|
+
options: string[]
|
|
10
|
+
tip: string
|
|
11
|
+
confusedWith: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CATEGORY_LABELS: Record<Category, string> = {
|
|
15
|
+
ln: 'l/n 不分',
|
|
16
|
+
nasal: '前后鼻音',
|
|
17
|
+
'flat-retro': '平翘舌',
|
|
18
|
+
hf: 'h/f 不分',
|
|
19
|
+
r: 'r 声母',
|
|
20
|
+
er: '儿化音',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CATEGORY_DESCRIPTIONS: Record<Category, string> = {
|
|
24
|
+
ln: '区分声母 l 和 n',
|
|
25
|
+
nasal: '区分 an/ang, en/eng, in/ing',
|
|
26
|
+
'flat-retro': '区分 z/zh, c/ch, s/sh',
|
|
27
|
+
hf: '区分声母 h 和 f',
|
|
28
|
+
r: '正确发出 r 声母',
|
|
29
|
+
er: '正确发出儿化音 er',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ln(id: number, char: string, pinyin: string, correct: 'l' | 'n', confusedWith: string, tip: string): TrainingItem {
|
|
33
|
+
return { id: `ln-${id}`, char, pinyin, category: 'ln', correct, options: ['l', 'n'], tip, confusedWith }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nasal(id: number, char: string, pinyin: string, correct: 'front' | 'back', confusedWith: string, tip: string): TrainingItem {
|
|
37
|
+
return { id: `nasal-${id}`, char, pinyin, category: 'nasal', correct: correct === 'front' ? '前鼻音' : '后鼻音', options: ['前鼻音', '后鼻音'], tip, confusedWith }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fr(id: number, char: string, pinyin: string, correct: 'flat' | 'retro', confusedWith: string, tip: string): TrainingItem {
|
|
41
|
+
return { id: `fr-${id}`, char, pinyin, category: 'flat-retro', correct: correct === 'flat' ? '平舌' : '翘舌', options: ['平舌', '翘舌'], tip, confusedWith }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hf(id: number, char: string, pinyin: string, correct: 'h' | 'f', confusedWith: string, tip: string): TrainingItem {
|
|
45
|
+
return { id: `hf-${id}`, char, pinyin, category: 'hf', correct: correct === 'h' ? 'h 声母' : 'f 声母', options: ['h 声母', 'f 声母'], tip, confusedWith }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ═══════════ l/n 不分 ═══════════
|
|
49
|
+
|
|
50
|
+
const LN_ITEMS: TrainingItem[] = [
|
|
51
|
+
ln(1, '女', 'nǚ', 'n', '旅', '舌尖抵上齿龈,气流从鼻腔出'),
|
|
52
|
+
ln(2, '旅', 'lǚ', 'l', '女', '舌尖抵上齿龈,气流从舌头两侧出'),
|
|
53
|
+
ln(3, '南', 'nán', 'n', '兰', '鼻音:捏住鼻子发不出来的是 n'),
|
|
54
|
+
ln(4, '兰', 'lán', 'l', '南', '边音:捏住鼻子仍能发出的是 l'),
|
|
55
|
+
ln(5, '脑', 'nǎo', 'n', '老', '鼻腔共振'),
|
|
56
|
+
ln(6, '老', 'lǎo', 'l', '脑', '舌侧出气'),
|
|
57
|
+
ln(7, '牛', 'niú', 'n', '刘', '鼻音,舌尖抵住上齿龈'),
|
|
58
|
+
ln(8, '刘', 'liú', 'l', '牛', '边音,气流从舌侧通过'),
|
|
59
|
+
ln(9, '泥', 'ní', 'n', '离', '鼻音'),
|
|
60
|
+
ln(10, '离', 'lí', 'l', '泥', '边音'),
|
|
61
|
+
ln(11, '怒', 'nù', 'n', '路', '鼻音'),
|
|
62
|
+
ln(12, '路', 'lù', 'l', '怒', '边音'),
|
|
63
|
+
ln(13, '内', 'nèi', 'n', '类', '鼻音'),
|
|
64
|
+
ln(14, '类', 'lèi', 'l', '内', '边音'),
|
|
65
|
+
ln(15, '年', 'nián', 'n', '连', '鼻音'),
|
|
66
|
+
ln(16, '连', 'lián', 'l', '年', '边音'),
|
|
67
|
+
ln(17, '娘', 'niáng', 'n', '凉', '鼻音'),
|
|
68
|
+
ln(18, '凉', 'liáng', 'l', '娘', '边音'),
|
|
69
|
+
ln(19, '农', 'nóng', 'n', '龙', '鼻音'),
|
|
70
|
+
ln(20, '龙', 'lóng', 'l', '农', '边音'),
|
|
71
|
+
ln(21, '暖', 'nuǎn', 'n', '乱', '鼻音'),
|
|
72
|
+
ln(22, '乱', 'luàn', 'l', '暖', '边音'),
|
|
73
|
+
ln(23, '能', 'néng', 'n', '冷', '鼻音'),
|
|
74
|
+
ln(24, '冷', 'lěng', 'l', '能', '边音'),
|
|
75
|
+
ln(25, '你', 'nǐ', 'n', '里', '鼻音'),
|
|
76
|
+
ln(26, '里', 'lǐ', 'l', '你', '边音'),
|
|
77
|
+
ln(27, '奶', 'nǎi', 'n', '来', '鼻音'),
|
|
78
|
+
ln(28, '来', 'lái', 'l', '奶', '边音'),
|
|
79
|
+
ln(29, '鸟', 'niǎo', 'n', '了', '鼻音'),
|
|
80
|
+
ln(30, '了', 'le', 'l', '鸟', '边音'),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
// ═══════════ 前后鼻音 ═══════════
|
|
84
|
+
|
|
85
|
+
const NASAL_ITEMS: TrainingItem[] = [
|
|
86
|
+
// an / ang
|
|
87
|
+
nasal(1, '班', 'bān', 'front', '帮', '前鼻音 an:舌尖抵上齿龈收尾'),
|
|
88
|
+
nasal(2, '帮', 'bāng', 'back', '班', '后鼻音 ang:舌根抵软腭收尾'),
|
|
89
|
+
nasal(3, '反', 'fǎn', 'front', '方', '前鼻音 an'),
|
|
90
|
+
nasal(4, '方', 'fāng', 'back', '反', '后鼻音 ang'),
|
|
91
|
+
nasal(5, '干', 'gān', 'front', '刚', '前鼻音 an'),
|
|
92
|
+
nasal(6, '刚', 'gāng', 'back', '干', '后鼻音 ang'),
|
|
93
|
+
nasal(7, '山', 'shān', 'front', '伤', '前鼻音 an'),
|
|
94
|
+
nasal(8, '伤', 'shāng', 'back', '山', '后鼻音 ang'),
|
|
95
|
+
nasal(9, '谈', 'tán', 'front', '糖', '前鼻音 an'),
|
|
96
|
+
nasal(10, '糖', 'táng', 'back', '谈', '后鼻音 ang'),
|
|
97
|
+
nasal(11, '蓝', 'lán', 'front', '郎', '前鼻音 an'),
|
|
98
|
+
nasal(12, '郎', 'láng', 'back', '蓝', '后鼻音 ang'),
|
|
99
|
+
// en / eng
|
|
100
|
+
nasal(13, '分', 'fēn', 'front', '风', '前鼻音 en:舌尖抵上齿龈'),
|
|
101
|
+
nasal(14, '风', 'fēng', 'back', '分', '后鼻音 eng:舌根后缩'),
|
|
102
|
+
nasal(15, '门', 'mén', 'front', '猛', '前鼻音 en'),
|
|
103
|
+
nasal(16, '猛', 'měng', 'back', '门', '后鼻音 eng'),
|
|
104
|
+
nasal(17, '真', 'zhēn', 'front', '正', '前鼻音 en'),
|
|
105
|
+
nasal(18, '正', 'zhèng', 'back', '真', '后鼻音 eng'),
|
|
106
|
+
nasal(19, '身', 'shēn', 'front', '生', '前鼻音 en'),
|
|
107
|
+
nasal(20, '生', 'shēng', 'back', '身', '后鼻音 eng'),
|
|
108
|
+
nasal(21, '人', 'rén', 'front', '仍', '前鼻音 en'),
|
|
109
|
+
nasal(22, '仍', 'réng', 'back', '人', '后鼻音 eng'),
|
|
110
|
+
// in / ing
|
|
111
|
+
nasal(23, '心', 'xīn', 'front', '星', '前鼻音 in:舌尖抵上齿龈'),
|
|
112
|
+
nasal(24, '星', 'xīng', 'back', '心', '后鼻音 ing:舌根后缩,嘴微开'),
|
|
113
|
+
nasal(25, '金', 'jīn', 'front', '京', '前鼻音 in'),
|
|
114
|
+
nasal(26, '京', 'jīng', 'back', '金', '后鼻音 ing'),
|
|
115
|
+
nasal(27, '林', 'lín', 'front', '灵', '前鼻音 in'),
|
|
116
|
+
nasal(28, '灵', 'líng', 'back', '林', '后鼻音 ing'),
|
|
117
|
+
nasal(29, '民', 'mín', 'front', '明', '前鼻音 in'),
|
|
118
|
+
nasal(30, '明', 'míng', 'back', '民', '后鼻音 ing'),
|
|
119
|
+
nasal(31, '品', 'pǐn', 'front', '拼', '前鼻音 in'),
|
|
120
|
+
nasal(32, '拼', 'pīng', 'back', '品', '后鼻音 ing'),
|
|
121
|
+
nasal(33, '信', 'xìn', 'front', '姓', '前鼻音 in'),
|
|
122
|
+
nasal(34, '姓', 'xìng', 'back', '信', '后鼻音 ing'),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
// ═══════════ 平翘舌 ═══════════
|
|
126
|
+
|
|
127
|
+
const FLAT_RETRO_ITEMS: TrainingItem[] = [
|
|
128
|
+
// z / zh
|
|
129
|
+
fr(1, '租', 'zū', 'flat', '猪', '平舌 z:舌尖平伸抵上齿背'),
|
|
130
|
+
fr(2, '猪', 'zhū', 'retro', '租', '翘舌 zh:舌尖翘起抵硬腭前部'),
|
|
131
|
+
fr(3, '资', 'zī', 'flat', '知', '平舌 z'),
|
|
132
|
+
fr(4, '知', 'zhī', 'retro', '资', '翘舌 zh'),
|
|
133
|
+
fr(5, '在', 'zài', 'flat', '窄', '平舌 z'),
|
|
134
|
+
fr(6, '窄', 'zhǎi', 'retro', '在', '翘舌 zh'),
|
|
135
|
+
fr(7, '增', 'zēng', 'flat', '争', '平舌 z'),
|
|
136
|
+
fr(8, '争', 'zhēng', 'retro', '增', '翘舌 zh'),
|
|
137
|
+
fr(9, '脏', 'zāng', 'flat', '张', '平舌 z'),
|
|
138
|
+
fr(10, '张', 'zhāng', 'retro', '脏', '翘舌 zh'),
|
|
139
|
+
// c / ch
|
|
140
|
+
fr(11, '草', 'cǎo', 'flat', '吵', '平舌 c:舌尖抵上齿背送气'),
|
|
141
|
+
fr(12, '吵', 'chǎo', 'retro', '草', '翘舌 ch:舌尖翘起送气'),
|
|
142
|
+
fr(13, '才', 'cái', 'flat', '柴', '平舌 c'),
|
|
143
|
+
fr(14, '柴', 'chái', 'retro', '才', '翘舌 ch'),
|
|
144
|
+
fr(15, '从', 'cóng', 'flat', '虫', '平舌 c'),
|
|
145
|
+
fr(16, '虫', 'chóng', 'retro', '从', '翘舌 ch'),
|
|
146
|
+
fr(17, '村', 'cūn', 'flat', '春', '平舌 c'),
|
|
147
|
+
fr(18, '春', 'chūn', 'retro', '村', '翘舌 ch'),
|
|
148
|
+
// s / sh
|
|
149
|
+
fr(19, '四', 'sì', 'flat', '是', '平舌 s:舌尖平伸,不翘'),
|
|
150
|
+
fr(20, '是', 'shì', 'retro', '四', '翘舌 sh:舌尖翘起'),
|
|
151
|
+
fr(21, '三', 'sān', 'flat', '山', '平舌 s'),
|
|
152
|
+
fr(22, '山', 'shān', 'retro', '三', '翘舌 sh'),
|
|
153
|
+
fr(23, '松', 'sōng', 'flat', '双', '平舌 s'),
|
|
154
|
+
fr(24, '双', 'shuāng', 'retro', '松', '翘舌 sh'),
|
|
155
|
+
fr(25, '送', 'sòng', 'flat', '受', '平舌 s'),
|
|
156
|
+
fr(26, '受', 'shòu', 'retro', '送', '翘舌 sh'),
|
|
157
|
+
fr(27, '扫', 'sǎo', 'flat', '少', '平舌 s'),
|
|
158
|
+
fr(28, '少', 'shǎo', 'retro', '扫', '翘舌 sh'),
|
|
159
|
+
fr(29, '苏', 'sū', 'flat', '书', '平舌 s'),
|
|
160
|
+
fr(30, '书', 'shū', 'retro', '苏', '翘舌 sh'),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
// ═══════════ h/f 不分 ═══════════
|
|
164
|
+
|
|
165
|
+
const HF_ITEMS: TrainingItem[] = [
|
|
166
|
+
hf(1, '飞', 'fēi', 'f', '灰', 'f:上齿咬下唇出气'),
|
|
167
|
+
hf(2, '灰', 'huī', 'h', '飞', 'h:舌根抬起,气流从喉部出'),
|
|
168
|
+
hf(3, '花', 'huā', 'h', '发', 'h:舌根音,不咬唇'),
|
|
169
|
+
hf(4, '发', 'fā', 'f', '花', 'f:唇齿音,上齿咬下唇'),
|
|
170
|
+
hf(5, '虎', 'hǔ', 'h', '斧', 'h:舌根音'),
|
|
171
|
+
hf(6, '斧', 'fǔ', 'f', '虎', 'f:唇齿音'),
|
|
172
|
+
hf(7, '回', 'huí', 'h', '肥', 'h:舌根音'),
|
|
173
|
+
hf(8, '肥', 'féi', 'f', '回', 'f:唇齿音'),
|
|
174
|
+
hf(9, '黄', 'huáng', 'h', '房', 'h:舌根音'),
|
|
175
|
+
hf(10, '房', 'fáng', 'f', '黄', 'f:唇齿音'),
|
|
176
|
+
hf(11, '红', 'hóng', 'h', '风', 'h:舌根音'),
|
|
177
|
+
hf(12, '风', 'fēng', 'f', '红', 'f:唇齿音'),
|
|
178
|
+
hf(13, '火', 'huǒ', 'h', '佛', 'h:舌根音'),
|
|
179
|
+
hf(14, '佛', 'fó', 'f', '火', 'f:唇齿音'),
|
|
180
|
+
hf(15, '婚', 'hūn', 'h', '分', 'h:舌根音'),
|
|
181
|
+
hf(16, '分', 'fēn', 'f', '婚', 'f:唇齿音'),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
// ═══════════ r 声母 ═══════════
|
|
185
|
+
|
|
186
|
+
const R_ITEMS: TrainingItem[] = [
|
|
187
|
+
{ id: 'r-1', char: '人', pinyin: 'rén', category: 'r', correct: 'r', options: ['r', '其他'], tip: 'r:舌尖翘起接近硬腭,振动声带', confusedWith: '银' },
|
|
188
|
+
{ id: 'r-2', char: '肉', pinyin: 'ròu', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起,不要发成 y 或 l', confusedWith: '又' },
|
|
189
|
+
{ id: 'r-3', char: '热', pinyin: 'rè', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起接近硬腭', confusedWith: '乐' },
|
|
190
|
+
{ id: 'r-4', char: '日', pinyin: 'rì', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起,与 zh 位置相近但不阻塞气流', confusedWith: '一' },
|
|
191
|
+
{ id: 'r-5', char: '让', pinyin: 'ràng', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起接近硬腭,振动声带', confusedWith: '样' },
|
|
192
|
+
{ id: 'r-6', char: '然', pinyin: 'rán', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起接近硬腭', confusedWith: '颜' },
|
|
193
|
+
{ id: 'r-7', char: '弱', pinyin: 'ruò', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起,不要发成 l 或 y', confusedWith: '约' },
|
|
194
|
+
{ id: 'r-8', char: '入', pinyin: 'rù', category: 'r', correct: 'r', options: ['r', '其他'], tip: '舌尖翘起接近硬腭', confusedWith: '玉' },
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
// ═══════════ 儿化音 ═══════════
|
|
198
|
+
|
|
199
|
+
const ER_ITEMS: TrainingItem[] = [
|
|
200
|
+
{ id: 'er-1', char: '二', pinyin: 'èr', category: 'er', correct: 'er', options: ['er', 'e'], tip: 'er:发 e 后舌尖卷起', confusedWith: '鹅' },
|
|
201
|
+
{ id: 'er-2', char: '耳', pinyin: 'ěr', category: 'er', correct: 'er', options: ['er', 'e'], tip: '舌尖卷起抵住硬腭', confusedWith: '额' },
|
|
202
|
+
{ id: 'er-3', char: '而', pinyin: 'ér', category: 'er', correct: 'er', options: ['er', 'e'], tip: '发 e 的同时舌尖上卷', confusedWith: '鹅' },
|
|
203
|
+
{ id: 'er-4', char: '儿', pinyin: 'ér', category: 'er', correct: 'er', options: ['er', 'e'], tip: '舌尖卷起是关键', confusedWith: '额' },
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
export const ALL_ITEMS: TrainingItem[] = [
|
|
207
|
+
...LN_ITEMS,
|
|
208
|
+
...NASAL_ITEMS,
|
|
209
|
+
...FLAT_RETRO_ITEMS,
|
|
210
|
+
...HF_ITEMS,
|
|
211
|
+
...R_ITEMS,
|
|
212
|
+
...ER_ITEMS,
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
export function getItemsByCategory(category: Category): TrainingItem[] {
|
|
216
|
+
return ALL_ITEMS.filter((item) => item.category === category)
|
|
217
|
+
}
|
package/lib/storage.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Category } from '@/data/training-items'
|
|
2
|
+
|
|
3
|
+
interface WrongPoolEntry {
|
|
4
|
+
itemId: string
|
|
5
|
+
correctStreak: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UserProgress {
|
|
9
|
+
history: { itemId: string; correct: boolean; timestamp: number }[]
|
|
10
|
+
streak: number
|
|
11
|
+
lastPracticeDate: string
|
|
12
|
+
wrongPool: WrongPoolEntry[]
|
|
13
|
+
categoryStats: Record<Category, { correct: number; total: number }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = 'trainer-progress'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_STATS: Record<Category, { correct: number; total: number }> = {
|
|
19
|
+
ln: { correct: 0, total: 0 },
|
|
20
|
+
nasal: { correct: 0, total: 0 },
|
|
21
|
+
'flat-retro': { correct: 0, total: 0 },
|
|
22
|
+
hf: { correct: 0, total: 0 },
|
|
23
|
+
r: { correct: 0, total: 0 },
|
|
24
|
+
er: { correct: 0, total: 0 },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadProgress(): UserProgress {
|
|
28
|
+
if (typeof window === 'undefined') return createDefault()
|
|
29
|
+
try {
|
|
30
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
31
|
+
if (!raw) return createDefault()
|
|
32
|
+
const data = JSON.parse(raw) as UserProgress
|
|
33
|
+
// Ensure all categories exist
|
|
34
|
+
if (!data.categoryStats) data.categoryStats = { ...DEFAULT_STATS }
|
|
35
|
+
for (const cat of Object.keys(DEFAULT_STATS) as Category[]) {
|
|
36
|
+
if (!data.categoryStats[cat]) data.categoryStats[cat] = { correct: 0, total: 0 }
|
|
37
|
+
}
|
|
38
|
+
return data
|
|
39
|
+
} catch {
|
|
40
|
+
return createDefault()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveProgress(progress: UserProgress): void {
|
|
45
|
+
if (typeof window === 'undefined') return
|
|
46
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createDefault(): UserProgress {
|
|
50
|
+
return {
|
|
51
|
+
history: [],
|
|
52
|
+
streak: 0,
|
|
53
|
+
lastPracticeDate: '',
|
|
54
|
+
wrongPool: [],
|
|
55
|
+
categoryStats: { ...DEFAULT_STATS },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getToday(): string {
|
|
60
|
+
return new Date().toISOString().slice(0, 10)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isToday(dateStr: string): boolean {
|
|
64
|
+
return dateStr === getToday()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isYesterday(dateStr: string): boolean {
|
|
68
|
+
const yesterday = new Date()
|
|
69
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
70
|
+
return dateStr === yesterday.toISOString().slice(0, 10)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getAccuracy(stats: { correct: number; total: number }): number {
|
|
74
|
+
if (stats.total === 0) return 0
|
|
75
|
+
return Math.round((stats.correct / stats.total) * 100)
|
|
76
|
+
}
|
package/lib/tts.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
let supported: boolean | null = null
|
|
2
|
+
|
|
3
|
+
export function isTtsAvailable(): boolean {
|
|
4
|
+
if (supported !== null) return supported
|
|
5
|
+
supported = typeof window !== 'undefined' && 'speechSynthesis' in window
|
|
6
|
+
return supported
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function speak(text: string): Promise<void> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
if (!isTtsAvailable()) {
|
|
12
|
+
reject(new Error('TTS not supported'))
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Cancel any ongoing speech
|
|
17
|
+
window.speechSynthesis.cancel()
|
|
18
|
+
|
|
19
|
+
const utterance = new SpeechSynthesisUtterance(text)
|
|
20
|
+
utterance.lang = 'zh-CN'
|
|
21
|
+
utterance.rate = 0.8 // Slower for clarity
|
|
22
|
+
utterance.pitch = 1
|
|
23
|
+
|
|
24
|
+
// Try to find a Chinese voice
|
|
25
|
+
const voices = window.speechSynthesis.getVoices()
|
|
26
|
+
const zhVoice = voices.find((v) => v.lang.startsWith('zh'))
|
|
27
|
+
if (zhVoice) utterance.voice = zhVoice
|
|
28
|
+
|
|
29
|
+
utterance.onend = () => resolve()
|
|
30
|
+
utterance.onerror = (e) => reject(e)
|
|
31
|
+
|
|
32
|
+
window.speechSynthesis.speak(utterance)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
4
|
+
import { ALL_ITEMS, getItemsByCategory, type Category, type TrainingItem } from '@/data/training-items'
|
|
5
|
+
import { loadProgress, saveProgress, getToday, isToday, isYesterday, type UserProgress } from './storage'
|
|
6
|
+
|
|
7
|
+
export type PracticeState = 'idle' | 'question' | 'feedback' | 'done'
|
|
8
|
+
|
|
9
|
+
export interface PracticeSession {
|
|
10
|
+
state: PracticeState
|
|
11
|
+
items: TrainingItem[]
|
|
12
|
+
currentIndex: number
|
|
13
|
+
answers: { itemId: string; selected: string; correct: boolean }[]
|
|
14
|
+
progress: UserProgress
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function shuffle<T>(arr: T[]): T[] {
|
|
18
|
+
const a = [...arr]
|
|
19
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
20
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
21
|
+
;[a[i], a[j]] = [a[j], a[i]]
|
|
22
|
+
}
|
|
23
|
+
return a
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pickItems(progress: UserProgress, category?: Category, count = 10): TrainingItem[] {
|
|
27
|
+
const pool = category ? getItemsByCategory(category) : ALL_ITEMS
|
|
28
|
+
|
|
29
|
+
// Priority 1: wrong pool items
|
|
30
|
+
const wrongIds = new Set(progress.wrongPool.map((w) => w.itemId))
|
|
31
|
+
const wrongItems = pool.filter((item) => wrongIds.has(item.id))
|
|
32
|
+
|
|
33
|
+
// Priority 2: weakest category items (if no specific category)
|
|
34
|
+
let remaining = pool.filter((item) => !wrongIds.has(item.id))
|
|
35
|
+
if (!category) {
|
|
36
|
+
const categories = Object.entries(progress.categoryStats)
|
|
37
|
+
.filter(([, stats]) => stats.total > 0)
|
|
38
|
+
.sort(([, a], [, b]) => {
|
|
39
|
+
const accA = a.total > 0 ? a.correct / a.total : 1
|
|
40
|
+
const accB = b.total > 0 ? b.correct / b.total : 1
|
|
41
|
+
return accA - accB // Weakest first
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (categories.length > 0) {
|
|
45
|
+
const weakest = categories[0][0] as Category
|
|
46
|
+
const weakItems = remaining.filter((item) => item.category === weakest)
|
|
47
|
+
const others = remaining.filter((item) => item.category !== weakest)
|
|
48
|
+
remaining = [...weakItems, ...others]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const selected = [...shuffle(wrongItems), ...shuffle(remaining)].slice(0, count)
|
|
53
|
+
return shuffle(selected)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function usePractice() {
|
|
57
|
+
const [session, setSession] = useState<PracticeSession>({
|
|
58
|
+
state: 'idle',
|
|
59
|
+
items: [],
|
|
60
|
+
currentIndex: 0,
|
|
61
|
+
answers: [],
|
|
62
|
+
progress: loadProgress(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Load progress on mount
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
setSession((s) => ({ ...s, progress: loadProgress() }))
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
const startPractice = useCallback((category?: Category) => {
|
|
71
|
+
const progress = loadProgress()
|
|
72
|
+
const items = pickItems(progress, category)
|
|
73
|
+
setSession({
|
|
74
|
+
state: 'question',
|
|
75
|
+
items,
|
|
76
|
+
currentIndex: 0,
|
|
77
|
+
answers: [],
|
|
78
|
+
progress,
|
|
79
|
+
})
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const submitAnswer = useCallback((selected: string) => {
|
|
83
|
+
setSession((prev) => {
|
|
84
|
+
const item = prev.items[prev.currentIndex]
|
|
85
|
+
if (!item) return prev
|
|
86
|
+
|
|
87
|
+
const isCorrect = selected === item.correct
|
|
88
|
+
const newProgress = { ...prev.progress }
|
|
89
|
+
|
|
90
|
+
// Update category stats
|
|
91
|
+
const catStats = { ...newProgress.categoryStats[item.category] }
|
|
92
|
+
catStats.total++
|
|
93
|
+
if (isCorrect) catStats.correct++
|
|
94
|
+
newProgress.categoryStats = { ...newProgress.categoryStats, [item.category]: catStats }
|
|
95
|
+
|
|
96
|
+
// Update wrong pool
|
|
97
|
+
const wrongIdx = newProgress.wrongPool.findIndex((w) => w.itemId === item.id)
|
|
98
|
+
if (isCorrect) {
|
|
99
|
+
if (wrongIdx >= 0) {
|
|
100
|
+
const entry = { ...newProgress.wrongPool[wrongIdx] }
|
|
101
|
+
entry.correctStreak++
|
|
102
|
+
if (entry.correctStreak >= 3) {
|
|
103
|
+
// Remove from wrong pool
|
|
104
|
+
newProgress.wrongPool = newProgress.wrongPool.filter((_, i) => i !== wrongIdx)
|
|
105
|
+
} else {
|
|
106
|
+
newProgress.wrongPool = [...newProgress.wrongPool]
|
|
107
|
+
newProgress.wrongPool[wrongIdx] = entry
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
if (wrongIdx < 0) {
|
|
112
|
+
newProgress.wrongPool = [...newProgress.wrongPool, { itemId: item.id, correctStreak: 0 }]
|
|
113
|
+
} else {
|
|
114
|
+
// Reset streak
|
|
115
|
+
newProgress.wrongPool = [...newProgress.wrongPool]
|
|
116
|
+
newProgress.wrongPool[wrongIdx] = { itemId: item.id, correctStreak: 0 }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Update history
|
|
121
|
+
newProgress.history = [
|
|
122
|
+
...newProgress.history,
|
|
123
|
+
{ itemId: item.id, correct: isCorrect, timestamp: Date.now() },
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
const answer = { itemId: item.id, selected, correct: isCorrect }
|
|
127
|
+
saveProgress(newProgress)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...prev,
|
|
131
|
+
state: 'feedback',
|
|
132
|
+
answers: [...prev.answers, answer],
|
|
133
|
+
progress: newProgress,
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}, [])
|
|
137
|
+
|
|
138
|
+
const nextQuestion = useCallback(() => {
|
|
139
|
+
setSession((prev) => {
|
|
140
|
+
const nextIdx = prev.currentIndex + 1
|
|
141
|
+
if (nextIdx >= prev.items.length) {
|
|
142
|
+
// Session complete — update streak
|
|
143
|
+
const newProgress = { ...prev.progress }
|
|
144
|
+
const today = getToday()
|
|
145
|
+
|
|
146
|
+
if (!isToday(newProgress.lastPracticeDate)) {
|
|
147
|
+
if (isYesterday(newProgress.lastPracticeDate)) {
|
|
148
|
+
newProgress.streak++
|
|
149
|
+
} else {
|
|
150
|
+
newProgress.streak = 1
|
|
151
|
+
}
|
|
152
|
+
newProgress.lastPracticeDate = today
|
|
153
|
+
saveProgress(newProgress)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { ...prev, state: 'done', progress: newProgress }
|
|
157
|
+
}
|
|
158
|
+
return { ...prev, state: 'question', currentIndex: nextIdx }
|
|
159
|
+
})
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
const reset = useCallback(() => {
|
|
163
|
+
setSession({
|
|
164
|
+
state: 'idle',
|
|
165
|
+
items: [],
|
|
166
|
+
currentIndex: 0,
|
|
167
|
+
answers: [],
|
|
168
|
+
progress: loadProgress(),
|
|
169
|
+
})
|
|
170
|
+
}, [])
|
|
171
|
+
|
|
172
|
+
const currentItem = session.items[session.currentIndex] ?? null
|
|
173
|
+
const lastAnswer = session.answers[session.answers.length - 1] ?? null
|
|
174
|
+
const todayDone = isToday(session.progress.lastPracticeDate)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...session,
|
|
178
|
+
currentItem,
|
|
179
|
+
lastAnswer,
|
|
180
|
+
todayDone,
|
|
181
|
+
startPractice,
|
|
182
|
+
submitAnswer,
|
|
183
|
+
nextQuestion,
|
|
184
|
+
reset,
|
|
185
|
+
}
|
|
186
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octo-cyber/trainer",
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"description": "普通话纠音训练器 — 解决 l/n、前后鼻音、平翘舌等发音混淆",
|
|
5
|
+
"exports": {
|
|
6
|
+
"./web": {
|
|
7
|
+
"types": "./web/index.ts",
|
|
8
|
+
"import": "./web/index.ts",
|
|
9
|
+
"default": "./web/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./web/pages/*": {
|
|
12
|
+
"types": "./web/pages/*.tsx",
|
|
13
|
+
"import": "./web/pages/*.tsx",
|
|
14
|
+
"default": "./web/pages/*.tsx"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@octo-cyber/core": "^0.5.4",
|
|
19
|
+
"@octo-cyber/ui": "^0.5.3"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"next": ">=15",
|
|
23
|
+
"next-intl": ">=3",
|
|
24
|
+
"react": ">=19",
|
|
25
|
+
"react-dom": ">=19"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/jefflower/octo-trainer.git"
|
|
30
|
+
},
|
|
31
|
+
"publishMeta": {
|
|
32
|
+
"repository": "jefflower/octo-trainer",
|
|
33
|
+
"branch": "main",
|
|
34
|
+
"commitHash": "7df0d224921fed88b879a69d0119dca56a300dac",
|
|
35
|
+
"publishedAt": "2026-03-27T15:20:23.785Z",
|
|
36
|
+
"publishedFrom": "monorepo:packages/trainer"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/tsconfig.json
ADDED
package/web/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Manifest
|
|
2
|
+
export { trainerFrontendManifest } from './manifest'
|
|
3
|
+
|
|
4
|
+
// i18n Messages
|
|
5
|
+
export { default as trainerMessagesZhCN } from './messages/zh-CN.json'
|
|
6
|
+
export { default as trainerMessagesEnUS } from './messages/en-US.json'
|
|
7
|
+
|
|
8
|
+
// Pages
|
|
9
|
+
export { default as TrainerPage } from './pages/TrainerPage'
|
package/web/manifest.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IFrontendManifest } from '@octo-cyber/core'
|
|
2
|
+
|
|
3
|
+
export const trainerFrontendManifest: IFrontendManifest = {
|
|
4
|
+
moduleId: 'trainer',
|
|
5
|
+
icon: 'Mic',
|
|
6
|
+
color: 'violet',
|
|
7
|
+
position: 'main',
|
|
8
|
+
titleKey: 'trainer.title',
|
|
9
|
+
descriptionKey: 'trainer.description',
|
|
10
|
+
pages: [
|
|
11
|
+
{
|
|
12
|
+
path: '/trainer',
|
|
13
|
+
titleKey: 'trainer.pages.home',
|
|
14
|
+
icon: 'Mic',
|
|
15
|
+
component: '@octo-cyber/trainer/web/pages/TrainerPage',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"trainer": {
|
|
4
|
+
"title": "Pronunciation",
|
|
5
|
+
"description": "Mandarin pronunciation training",
|
|
6
|
+
"pages": {
|
|
7
|
+
"home": "Pronunciation"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"trainer": {
|
|
12
|
+
"home": {
|
|
13
|
+
"title": "Mandarin Pronunciation Trainer",
|
|
14
|
+
"subtitle": "5 minutes a day, fix pronunciation confusion",
|
|
15
|
+
"startPractice": "Start Daily Practice",
|
|
16
|
+
"todayDone": "Completed Today",
|
|
17
|
+
"categoryTitle": "Category Practice",
|
|
18
|
+
"notPracticed": "Not practiced",
|
|
19
|
+
"accuracy": "Accuracy {value}%",
|
|
20
|
+
"streak": "{count} day streak"
|
|
21
|
+
},
|
|
22
|
+
"quiz": {
|
|
23
|
+
"playAudio": "Play pronunciation",
|
|
24
|
+
"playing": "Playing...",
|
|
25
|
+
"nextQuestion": "Next",
|
|
26
|
+
"correct": "Correct!",
|
|
27
|
+
"wrong": "You chose \"{selected}\", correct answer is \"{correct}\"",
|
|
28
|
+
"confusedWith": "Confused with: {char}"
|
|
29
|
+
},
|
|
30
|
+
"result": {
|
|
31
|
+
"accuracy": "Accuracy {value}%",
|
|
32
|
+
"streak": "{count} day streak",
|
|
33
|
+
"needsWork": "Needs improvement",
|
|
34
|
+
"retry": "Practice again",
|
|
35
|
+
"backHome": "Back to home"
|
|
36
|
+
},
|
|
37
|
+
"categories": {
|
|
38
|
+
"ln": "l/n confusion",
|
|
39
|
+
"nasal": "Nasal finals",
|
|
40
|
+
"flat-retro": "Flat/Retroflex",
|
|
41
|
+
"hf": "h/f confusion",
|
|
42
|
+
"r": "r initial",
|
|
43
|
+
"er": "er sound"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"trainer": {
|
|
4
|
+
"title": "纠音训练",
|
|
5
|
+
"description": "普通话发音纠正训练",
|
|
6
|
+
"pages": {
|
|
7
|
+
"home": "纠音训练"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"trainer": {
|
|
12
|
+
"home": {
|
|
13
|
+
"title": "普通话纠音训练",
|
|
14
|
+
"subtitle": "每天 5 分钟,告别发音混淆",
|
|
15
|
+
"startPractice": "开始今日练习",
|
|
16
|
+
"todayDone": "今日已完成",
|
|
17
|
+
"categoryTitle": "专项练习",
|
|
18
|
+
"notPracticed": "未练习",
|
|
19
|
+
"accuracy": "准确率 {value}%",
|
|
20
|
+
"streak": "连续打卡 {count} 天"
|
|
21
|
+
},
|
|
22
|
+
"quiz": {
|
|
23
|
+
"playAudio": "点击听发音",
|
|
24
|
+
"playing": "播放中...",
|
|
25
|
+
"nextQuestion": "下一题",
|
|
26
|
+
"correct": "正确!",
|
|
27
|
+
"wrong": "你选了「{selected}」,正确答案是「{correct}」",
|
|
28
|
+
"confusedWith": "易混字:{char}"
|
|
29
|
+
},
|
|
30
|
+
"result": {
|
|
31
|
+
"accuracy": "正确率 {value}%",
|
|
32
|
+
"streak": "连续打卡 {count} 天",
|
|
33
|
+
"needsWork": "需要加强",
|
|
34
|
+
"retry": "再练一组",
|
|
35
|
+
"backHome": "回到首页"
|
|
36
|
+
},
|
|
37
|
+
"categories": {
|
|
38
|
+
"ln": "l/n 不分",
|
|
39
|
+
"nasal": "前后鼻音",
|
|
40
|
+
"flat-retro": "平翘舌",
|
|
41
|
+
"hf": "h/f 不分",
|
|
42
|
+
"r": "r 声母",
|
|
43
|
+
"er": "儿化音"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
import { useTranslations } from 'next-intl'
|
|
5
|
+
import { Card, CardContent } from '@octo-cyber/ui/components/ui/card'
|
|
6
|
+
import { Button } from '@octo-cyber/ui/components/ui/button'
|
|
7
|
+
import { Badge } from '@octo-cyber/ui/components/ui/badge'
|
|
8
|
+
import { Volume2 } from 'lucide-react'
|
|
9
|
+
import {
|
|
10
|
+
ALL_ITEMS, getItemsByCategory, CATEGORY_LABELS,
|
|
11
|
+
type Category, type TrainingItem,
|
|
12
|
+
} from '../../data/training-items'
|
|
13
|
+
import {
|
|
14
|
+
loadProgress, saveProgress, getToday, isToday, isYesterday, getAccuracy,
|
|
15
|
+
type UserProgress,
|
|
16
|
+
} from '../../lib/storage'
|
|
17
|
+
import { speak, isTtsAvailable } from '../../lib/tts'
|
|
18
|
+
|
|
19
|
+
// ── Types ──
|
|
20
|
+
|
|
21
|
+
type ViewState = 'home' | 'question' | 'feedback' | 'done'
|
|
22
|
+
|
|
23
|
+
interface Answer {
|
|
24
|
+
itemId: string
|
|
25
|
+
selected: string
|
|
26
|
+
correct: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Helpers ──
|
|
30
|
+
|
|
31
|
+
function shuffle<T>(arr: T[]): T[] {
|
|
32
|
+
const a = [...arr]
|
|
33
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
34
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
35
|
+
;[a[i], a[j]] = [a[j], a[i]]
|
|
36
|
+
}
|
|
37
|
+
return a
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pickItems(progress: UserProgress, category?: Category, count = 10): TrainingItem[] {
|
|
41
|
+
const pool = category ? getItemsByCategory(category) : ALL_ITEMS
|
|
42
|
+
const wrongIds = new Set(progress.wrongPool.map((w) => w.itemId))
|
|
43
|
+
const wrongItems = pool.filter((item) => wrongIds.has(item.id))
|
|
44
|
+
let remaining = pool.filter((item) => !wrongIds.has(item.id))
|
|
45
|
+
|
|
46
|
+
if (!category) {
|
|
47
|
+
const categories = Object.entries(progress.categoryStats)
|
|
48
|
+
.filter(([, stats]) => stats.total > 0)
|
|
49
|
+
.sort(([, a], [, b]) => {
|
|
50
|
+
const accA = a.total > 0 ? a.correct / a.total : 1
|
|
51
|
+
const accB = b.total > 0 ? b.correct / b.total : 1
|
|
52
|
+
return accA - accB
|
|
53
|
+
})
|
|
54
|
+
if (categories.length > 0) {
|
|
55
|
+
const weakest = categories[0][0] as Category
|
|
56
|
+
const weakItems = remaining.filter((item) => item.category === weakest)
|
|
57
|
+
const others = remaining.filter((item) => item.category !== weakest)
|
|
58
|
+
remaining = [...weakItems, ...others]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return shuffle([...shuffle(wrongItems), ...shuffle(remaining)].slice(0, count))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Constants ──
|
|
66
|
+
|
|
67
|
+
const CATEGORIES: Category[] = ['ln', 'nasal', 'flat-retro', 'hf', 'r', 'er']
|
|
68
|
+
|
|
69
|
+
// ── Main Component ──
|
|
70
|
+
|
|
71
|
+
export default function TrainerPage() {
|
|
72
|
+
const t = useTranslations('trainer')
|
|
73
|
+
const [view, setView] = useState<ViewState>('home')
|
|
74
|
+
const [items, setItems] = useState<TrainingItem[]>([])
|
|
75
|
+
const [currentIndex, setCurrentIndex] = useState(0)
|
|
76
|
+
const [answers, setAnswers] = useState<Answer[]>([])
|
|
77
|
+
const [progress, setProgress] = useState<UserProgress>(loadProgress)
|
|
78
|
+
const [isPlaying, setIsPlaying] = useState(false)
|
|
79
|
+
const mountedRef = useRef(false)
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!mountedRef.current) {
|
|
83
|
+
mountedRef.current = true
|
|
84
|
+
setProgress(loadProgress())
|
|
85
|
+
}
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
const todayDone = isToday(progress.lastPracticeDate)
|
|
89
|
+
const currentItem = items[currentIndex] ?? null
|
|
90
|
+
const lastAnswer = answers[answers.length - 1] ?? null
|
|
91
|
+
|
|
92
|
+
// ── Actions ──
|
|
93
|
+
|
|
94
|
+
const startPractice = useCallback((category?: Category) => {
|
|
95
|
+
const p = loadProgress()
|
|
96
|
+
setProgress(p)
|
|
97
|
+
setItems(pickItems(p, category))
|
|
98
|
+
setCurrentIndex(0)
|
|
99
|
+
setAnswers([])
|
|
100
|
+
setView('question')
|
|
101
|
+
}, [])
|
|
102
|
+
|
|
103
|
+
const submitAnswer = useCallback((selected: string) => {
|
|
104
|
+
if (!currentItem) return
|
|
105
|
+
const isCorrect = selected === currentItem.correct
|
|
106
|
+
|
|
107
|
+
setProgress((prev) => {
|
|
108
|
+
const np = { ...prev }
|
|
109
|
+
const cs = { ...np.categoryStats[currentItem.category] }
|
|
110
|
+
cs.total++
|
|
111
|
+
if (isCorrect) cs.correct++
|
|
112
|
+
np.categoryStats = { ...np.categoryStats, [currentItem.category]: cs }
|
|
113
|
+
|
|
114
|
+
const wrongIdx = np.wrongPool.findIndex((w) => w.itemId === currentItem.id)
|
|
115
|
+
if (isCorrect) {
|
|
116
|
+
if (wrongIdx >= 0) {
|
|
117
|
+
const entry = { ...np.wrongPool[wrongIdx] }
|
|
118
|
+
entry.correctStreak++
|
|
119
|
+
if (entry.correctStreak >= 3) {
|
|
120
|
+
np.wrongPool = np.wrongPool.filter((_, i) => i !== wrongIdx)
|
|
121
|
+
} else {
|
|
122
|
+
np.wrongPool = [...np.wrongPool]
|
|
123
|
+
np.wrongPool[wrongIdx] = entry
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
if (wrongIdx < 0) {
|
|
128
|
+
np.wrongPool = [...np.wrongPool, { itemId: currentItem.id, correctStreak: 0 }]
|
|
129
|
+
} else {
|
|
130
|
+
np.wrongPool = [...np.wrongPool]
|
|
131
|
+
np.wrongPool[wrongIdx] = { itemId: currentItem.id, correctStreak: 0 }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
np.history = [...np.history, { itemId: currentItem.id, correct: isCorrect, timestamp: Date.now() }]
|
|
136
|
+
saveProgress(np)
|
|
137
|
+
return np
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
setAnswers((prev) => [...prev, { itemId: currentItem.id, selected, correct: isCorrect }])
|
|
141
|
+
setView('feedback')
|
|
142
|
+
}, [currentItem])
|
|
143
|
+
|
|
144
|
+
const nextQuestion = useCallback(() => {
|
|
145
|
+
const nextIdx = currentIndex + 1
|
|
146
|
+
if (nextIdx >= items.length) {
|
|
147
|
+
setProgress((prev) => {
|
|
148
|
+
const np = { ...prev }
|
|
149
|
+
const today = getToday()
|
|
150
|
+
if (!isToday(np.lastPracticeDate)) {
|
|
151
|
+
np.streak = isYesterday(np.lastPracticeDate) ? np.streak + 1 : 1
|
|
152
|
+
np.lastPracticeDate = today
|
|
153
|
+
saveProgress(np)
|
|
154
|
+
}
|
|
155
|
+
return np
|
|
156
|
+
})
|
|
157
|
+
setView('done')
|
|
158
|
+
} else {
|
|
159
|
+
setCurrentIndex(nextIdx)
|
|
160
|
+
setView('question')
|
|
161
|
+
}
|
|
162
|
+
}, [currentIndex, items.length])
|
|
163
|
+
|
|
164
|
+
const playAudio = useCallback(async () => {
|
|
165
|
+
if (!currentItem) return
|
|
166
|
+
setIsPlaying(true)
|
|
167
|
+
try { await speak(currentItem.char) } catch { /* TTS unavailable */ }
|
|
168
|
+
setIsPlaying(false)
|
|
169
|
+
}, [currentItem])
|
|
170
|
+
|
|
171
|
+
// ── Render ──
|
|
172
|
+
|
|
173
|
+
if (view === 'home') {
|
|
174
|
+
return (
|
|
175
|
+
<div className="mx-auto max-w-md space-y-6 py-6">
|
|
176
|
+
<div className="text-center">
|
|
177
|
+
<h1 className="text-2xl font-bold tracking-tight">{t('home.title')}</h1>
|
|
178
|
+
<p className="mt-1 text-sm text-muted-foreground">{t('home.subtitle')}</p>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{progress.streak > 0 && (
|
|
182
|
+
<Card className="bg-primary/5 border-primary/20">
|
|
183
|
+
<CardContent className="py-4 text-center">
|
|
184
|
+
<div className="text-3xl">🔥</div>
|
|
185
|
+
<div className="mt-1 text-lg font-bold">
|
|
186
|
+
{t('home.streak', { count: progress.streak })}
|
|
187
|
+
</div>
|
|
188
|
+
</CardContent>
|
|
189
|
+
</Card>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
<Button
|
|
193
|
+
onClick={() => startPractice()}
|
|
194
|
+
disabled={todayDone}
|
|
195
|
+
className="w-full py-6 text-lg"
|
|
196
|
+
size="lg"
|
|
197
|
+
>
|
|
198
|
+
{todayDone ? `✅ ${t('home.todayDone')}` : `▶ ${t('home.startPractice')}`}
|
|
199
|
+
</Button>
|
|
200
|
+
|
|
201
|
+
<div>
|
|
202
|
+
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">
|
|
203
|
+
{t('home.categoryTitle')}
|
|
204
|
+
</h2>
|
|
205
|
+
<div className="grid grid-cols-2 gap-3">
|
|
206
|
+
{CATEGORIES.map((cat) => {
|
|
207
|
+
const stats = progress.categoryStats[cat]
|
|
208
|
+
const acc = getAccuracy(stats)
|
|
209
|
+
return (
|
|
210
|
+
<Card
|
|
211
|
+
key={cat}
|
|
212
|
+
className="cursor-pointer transition-shadow hover:shadow-md active:scale-[0.98]"
|
|
213
|
+
onClick={() => startPractice(cat)}
|
|
214
|
+
>
|
|
215
|
+
<CardContent className="p-3">
|
|
216
|
+
<div className="text-sm font-medium">{t(`categories.${cat}`)}</div>
|
|
217
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
218
|
+
{stats.total > 0 ? t('home.accuracy', { value: acc }) : t('home.notPracticed')}
|
|
219
|
+
</div>
|
|
220
|
+
</CardContent>
|
|
221
|
+
</Card>
|
|
222
|
+
)
|
|
223
|
+
})}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if ((view === 'question' || view === 'feedback') && currentItem) {
|
|
231
|
+
const progressValue = ((currentIndex + 1) / items.length) * 100
|
|
232
|
+
const ttsOk = isTtsAvailable()
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div className="mx-auto max-w-md space-y-6 py-6">
|
|
236
|
+
{/* Progress */}
|
|
237
|
+
<div className="space-y-1">
|
|
238
|
+
<div className="flex justify-between text-xs text-muted-foreground">
|
|
239
|
+
<span>{CATEGORY_LABELS[currentItem.category]}</span>
|
|
240
|
+
<span>{currentIndex + 1}/{items.length}</span>
|
|
241
|
+
</div>
|
|
242
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
243
|
+
<div className="h-full rounded-full bg-primary transition-all duration-300" style={{ width: `${progressValue}%` }} />
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Question card */}
|
|
248
|
+
<Card>
|
|
249
|
+
<CardContent className="py-12 text-center">
|
|
250
|
+
{view === 'question' && (
|
|
251
|
+
<>
|
|
252
|
+
<div className="font-bold" style={{ fontSize: '6rem', lineHeight: 1 }}>{currentItem.char}</div>
|
|
253
|
+
{ttsOk && (
|
|
254
|
+
<Button
|
|
255
|
+
onClick={playAudio}
|
|
256
|
+
disabled={isPlaying}
|
|
257
|
+
className="mt-4"
|
|
258
|
+
variant="default"
|
|
259
|
+
>
|
|
260
|
+
<Volume2 className="mr-2 h-4 w-4" />
|
|
261
|
+
{isPlaying ? t('quiz.playing') : t('quiz.playAudio')}
|
|
262
|
+
</Button>
|
|
263
|
+
)}
|
|
264
|
+
{!ttsOk && (
|
|
265
|
+
<div className="mt-4 font-mono text-lg text-muted-foreground">
|
|
266
|
+
{currentItem.pinyin}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{view === 'feedback' && lastAnswer && (
|
|
273
|
+
<>
|
|
274
|
+
<div className="font-bold" style={{ fontSize: '5rem', lineHeight: 1 }}>{currentItem.char}</div>
|
|
275
|
+
<div className="mt-3 font-mono text-muted-foreground" style={{ fontSize: '1.25rem' }}>{currentItem.pinyin}</div>
|
|
276
|
+
<div className={`mt-4 rounded-xl px-4 py-3 ${lastAnswer.correct ? 'bg-green-50 dark:bg-green-950/30' : 'bg-red-50 dark:bg-red-950/30'}`}>
|
|
277
|
+
{lastAnswer.correct ? (
|
|
278
|
+
<div className="text-base font-semibold text-green-600 dark:text-green-400">
|
|
279
|
+
✅ {t('quiz.correct')}
|
|
280
|
+
</div>
|
|
281
|
+
) : (
|
|
282
|
+
<div>
|
|
283
|
+
<div className="text-base font-semibold text-red-600 dark:text-red-400">
|
|
284
|
+
❌ {t('quiz.wrong', { selected: lastAnswer.selected, correct: currentItem.correct })}
|
|
285
|
+
</div>
|
|
286
|
+
<div className="mt-2 text-sm text-muted-foreground">
|
|
287
|
+
💡 {currentItem.tip}
|
|
288
|
+
</div>
|
|
289
|
+
{currentItem.confusedWith && (
|
|
290
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
291
|
+
{t('quiz.confusedWith', { char: currentItem.confusedWith })}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
</CardContent>
|
|
300
|
+
</Card>
|
|
301
|
+
|
|
302
|
+
{/* Options / Next */}
|
|
303
|
+
{view === 'question' && (
|
|
304
|
+
<div className="grid grid-cols-2 gap-4">
|
|
305
|
+
{currentItem.options.map((opt) => (
|
|
306
|
+
<Button
|
|
307
|
+
key={opt}
|
|
308
|
+
variant="outline"
|
|
309
|
+
style={{ height: 'auto', padding: '2rem 0', fontSize: '1.25rem', fontWeight: 600 }}
|
|
310
|
+
className="hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.97] transition-all"
|
|
311
|
+
onClick={() => submitAnswer(opt)}
|
|
312
|
+
>
|
|
313
|
+
{opt}
|
|
314
|
+
</Button>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{view === 'feedback' && (
|
|
320
|
+
<Button onClick={nextQuestion} className="w-full py-3" size="lg">
|
|
321
|
+
{t('quiz.nextQuestion')} →
|
|
322
|
+
</Button>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (view === 'done') {
|
|
329
|
+
const correct = answers.filter((a) => a.correct).length
|
|
330
|
+
const total = answers.length
|
|
331
|
+
const pct = total > 0 ? Math.round((correct / total) * 100) : 0
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className="mx-auto max-w-md space-y-6 py-6 text-center">
|
|
335
|
+
<Card>
|
|
336
|
+
<CardContent className="py-8">
|
|
337
|
+
<div style={{ fontSize: '4rem' }}>{pct >= 80 ? '🎉' : pct >= 60 ? '💪' : '📚'}</div>
|
|
338
|
+
<div className="mt-4 font-bold" style={{ fontSize: '3rem' }}>{correct}/{total}</div>
|
|
339
|
+
<div className="mt-1 text-sm text-muted-foreground">
|
|
340
|
+
{t('result.accuracy', { value: pct })}
|
|
341
|
+
</div>
|
|
342
|
+
{progress.streak > 0 && (
|
|
343
|
+
<Badge variant="secondary" className="mt-4">
|
|
344
|
+
🔥 {t('result.streak', { count: progress.streak })}
|
|
345
|
+
</Badge>
|
|
346
|
+
)}
|
|
347
|
+
</CardContent>
|
|
348
|
+
</Card>
|
|
349
|
+
|
|
350
|
+
{answers.some((a) => !a.correct) && (
|
|
351
|
+
<div className="text-left">
|
|
352
|
+
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
|
|
353
|
+
{t('result.needsWork')}
|
|
354
|
+
</h3>
|
|
355
|
+
<div className="space-y-2">
|
|
356
|
+
{answers.filter((a) => !a.correct).map((a) => {
|
|
357
|
+
const item = items.find((i) => i.id === a.itemId)
|
|
358
|
+
if (!item) return null
|
|
359
|
+
return (
|
|
360
|
+
<Card key={a.itemId} className="bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900">
|
|
361
|
+
<CardContent className="flex items-center gap-3 p-3">
|
|
362
|
+
<span className="text-xl font-bold">{item.char}</span>
|
|
363
|
+
<div className="flex-1">
|
|
364
|
+
<span className="font-mono text-sm">{item.pinyin}</span>
|
|
365
|
+
<span className="ml-2 text-xs text-muted-foreground">{item.tip}</span>
|
|
366
|
+
</div>
|
|
367
|
+
</CardContent>
|
|
368
|
+
</Card>
|
|
369
|
+
)
|
|
370
|
+
})}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
<div className="flex gap-3">
|
|
376
|
+
<Button onClick={() => startPractice()} className="flex-1" size="lg">
|
|
377
|
+
{t('result.retry')}
|
|
378
|
+
</Button>
|
|
379
|
+
<Button onClick={() => setView('home')} variant="outline" className="flex-1" size="lg">
|
|
380
|
+
{t('result.backHome')}
|
|
381
|
+
</Button>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return null
|
|
388
|
+
}
|