@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.
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["**/*.test.ts", "**/*.spec.ts", "**/*.test.tsx", "**/*.spec.tsx"]
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "noEmit": true
6
+ },
7
+ "include": ["lib/**/*", "data/**/*", "web/**/*"]
8
+ }
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'
@@ -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
+ }