feat: expand vocabulary study overview

This commit is contained in:
Codex
2026-06-30 18:10:51 +08:00
parent 400f9bfc72
commit ef153c05fd
8 changed files with 99 additions and 17 deletions

View File

@@ -689,6 +689,23 @@
margin-top: 18px;
}
.vocabulary-overview-panel {
border-color: #bbf7d0;
background: #f7fdf9;
}
.vocabulary-stats-grid {
margin-top: 18px;
}
.vocabulary-plan-line {
margin-top: 16px;
}
.vocabulary-mastery-track {
margin-top: 18px;
}
.vocabulary-study {
display: flex;
flex-direction: column;

View File

@@ -21,6 +21,12 @@ type SessionStats = {
unknown: number;
};
type ReviewPlanSummary = {
dueCount: number;
newCount: number;
totalPlanned: number;
};
function wordKey(word: VocabularyWord) {
return word.wordId || word.id || word.word;
}
@@ -38,6 +44,11 @@ function statNumber(stats: Record<string, unknown> | null, key: string) {
return typeof value === 'number' ? value : Number(value || 0);
}
function percentText(value: number, total: number) {
if (!total) return '0%';
return `${Math.round((value / total) * 100)}%`;
}
function modeLabel(mode: StudyMode) {
if (mode === 'favorites') return '收藏练习';
if (mode === 'unit') return '单元学习';
@@ -51,6 +62,7 @@ export default function StudentVocabularyPage() {
const [words, setWords] = useState<VocabularyWord[]>([]);
const [favoriteIds, setFavoriteIds] = useState<Record<string, boolean>>({});
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
const [planSummary, setPlanSummary] = useState<ReviewPlanSummary>({ dueCount: 0, newCount: 0, totalPlanned: 0 });
const [index, setIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [completed, setCompleted] = useState(false);
@@ -80,7 +92,7 @@ export default function StudentVocabularyPage() {
Promise.all([
mode === 'favorites' ? loadFavoriteWords(unitId) : Promise.resolve({ items: [] as VocabularyWord[] }),
mode !== 'favorites' ? loadVocabularyWords(unitId) : Promise.resolve({ items: [] as VocabularyWord[] }),
mode === 'plan' ? loadVocabularyReviewPlan(unitId) : Promise.resolve({ item: { words: [] as VocabularyWord[] } }),
loadVocabularyReviewPlan(unitId).catch(() => ({ item: { words: [] as VocabularyWord[], dueCount: 0, newCount: 0, totalPlanned: 0 } })),
loadFavoriteWords(unitId).catch(() => ({ items: [] as VocabularyWord[] })),
loadVocabularyStats(unitId),
])
@@ -93,6 +105,11 @@ export default function StudentVocabularyPage() {
: planned.length
? planned
: wordPayload.items || [];
setPlanSummary({
dueCount: Number(planPayload.item?.dueCount || 0),
newCount: Number(planPayload.item?.newCount || 0),
totalPlanned: Number(planPayload.item?.totalPlanned || planned.length || 0),
});
const savedIndex = getStorage<number>(progressStorageKey(unitId, mode));
setWords(nextWords);
setFavoriteIds(Object.fromEntries((favoritesForStatus.items || []).map(item => wordRecordId(item)).filter(Boolean).map(id => [id, true])));
@@ -111,6 +128,15 @@ export default function StudentVocabularyPage() {
const current = words[index] || null;
const progressPercent = words.length ? Math.round(((Math.min(index + 1, words.length)) / words.length) * 100) : 0;
const favoriteCurrent = current ? !!favoriteIds[wordRecordId(current)] : false;
const totalWords = statNumber(stats, 'totalWords');
const progressedWords = statNumber(stats, 'progressedWords');
const masteredWords = statNumber(stats, 'masteredWords');
const learningWords = statNumber(stats, 'learningWords');
const todayReviewed = statNumber(stats, 'todayReviewed');
const favoriteWords = statNumber(stats, 'favoriteWords');
const unstartedWords = Math.max(0, totalWords - progressedWords);
const sessionTotal = sessionStats.known + sessionStats.unknown;
const masteryBucket = Math.max(0, Math.min(10, Math.round((totalWords ? masteredWords / totalWords : 0) * 10)));
const unitName = useMemo(() => units.find(item => item.id === unitId)?.name || '单词单元', [units, unitId]);
@@ -151,11 +177,15 @@ export default function StudentVocabularyPage() {
unknown: prev.unknown + (result === 'unknown' ? 1 : 0),
}));
if (mode === 'plan') {
setWords(prev => prev.filter(item => wordKey(item) !== id));
setIndex(prev => Math.min(prev, Math.max(0, words.length - 2)));
const nextWords = words.filter(item => wordKey(item) !== id);
setWords(nextWords);
setCompleted(!nextWords.length);
setIndex(prev => Math.min(prev, Math.max(0, nextWords.length - 1)));
setShowAnswer(false);
} else {
nextWord();
}
void refreshStats();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '复习提交失败');
}
@@ -176,6 +206,7 @@ export default function StudentVocabularyPage() {
setWords(prev => prev.filter(item => wordKey(item) !== id));
setIndex(prev => Math.min(prev, Math.max(0, words.length - 2)));
}
void refreshStats();
Taro.showToast({ title: payload.favorite ? '已收藏' : '已取消收藏', icon: 'success' });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '收藏操作失败');
@@ -188,6 +219,16 @@ export default function StudentVocabularyPage() {
setCompleted(false);
}
async function refreshStats() {
if (!unitId) return;
try {
const payload = await loadVocabularyStats(unitId);
setStats(payload.item || null);
} catch {
// Keep the current snapshot; the next page reload or mode switch will reconcile stats.
}
}
async function playCurrent() {
if (!current?.word) return;
await playWordPronunciation(current.word, accent);
@@ -218,9 +259,31 @@ export default function StudentVocabularyPage() {
<Button className='secondary-button' onClick={() => resetPosition()}></Button>
</View>
<View className='grid-two section-block'>
<View className='metric'><Text className='metric-value'>{String(statNumber(stats, 'masteredWords'))}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{String(statNumber(stats, 'todayReviewed'))}</Text><Text className='metric-label'></Text></View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='report-panel vocabulary-overview-panel'>
<View className='split-row'>
<View>
<Text className='row-main'>{unitName}</Text>
<Text className='row-meta'> {percentText(masteredWords, totalWords)} · {percentText(progressedWords, totalWords)}</Text>
</View>
<Text className='status-badge'>{modeLabel(mode)}</Text>
</View>
<View className='progress-track vocabulary-mastery-track'>
<View className={`progress-fill w-bucket-${masteryBucket}`} />
</View>
<View className='grid-two vocabulary-stats-grid'>
<View className='metric compact-metric'><Text className='metric-value'>{String(totalWords)}</Text><Text className='metric-label'></Text></View>
<View className='metric compact-metric'><Text className='metric-value'>{String(masteredWords)}</Text><Text className='metric-label'></Text></View>
<View className='metric compact-metric'><Text className='metric-value'>{String(learningWords)}</Text><Text className='metric-label'></Text></View>
<View className='metric compact-metric'><Text className='metric-value'>{String(unstartedWords)}</Text><Text className='metric-label'></Text></View>
<View className='metric compact-metric'><Text className='metric-value'>{String(todayReviewed)}</Text><Text className='metric-label'></Text></View>
<View className='metric compact-metric'><Text className='metric-value'>{String(favoriteWords)}</Text><Text className='metric-label'></Text></View>
</View>
<Text className='row-meta vocabulary-plan-line'>
{String(planSummary.totalPlanned)} · {String(planSummary.dueCount)} · {String(planSummary.newCount)}
</Text>
</View>
</View>
{loading ? <View className='empty-state section-block'>...</View> : null}
@@ -267,6 +330,8 @@ export default function StudentVocabularyPage() {
<View className='grid-two section-block'>
<View className='metric'><Text className='metric-value'>{String(sessionStats.known)}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{String(sessionStats.unknown)}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{String(sessionTotal)}</Text><Text className='metric-label'></Text></View>
<View className='metric'><Text className='metric-value'>{percentText(sessionStats.known, sessionTotal)}</Text><Text className='metric-label'></Text></View>
</View>
</View>
) : null}