|
|
@@ -126,6 +126,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
readonly selectionRevealed = signal<Set<string>>(new Set());
|
|
|
readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
|
|
|
readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending');
|
|
|
+ readonly coursePassTabs = ['未学习', '已学一遍', '已学第二遍', '已学第三遍'];
|
|
|
+ readonly coursePass = signal(0);
|
|
|
+ readonly coursePassCounts = signal<number[] | null>(null);
|
|
|
+ readonly bookPassCounts = signal<number[] | null>(null);
|
|
|
+ private readonly courseSelectionCache = signal<Word[]>([]);
|
|
|
+ readonly visibleCourseWords = computed(() => {
|
|
|
+ const candidates = this.coursePass() === 1 ? [...this.words(), ...this.newlyKnownWords()] : this.words();
|
|
|
+ return [...new Map(candidates.map(word => [word.id, word])).values()]
|
|
|
+ .filter(word => this.courseWordPass(word) === this.coursePass());
|
|
|
+ });
|
|
|
+ readonly firstPassProgress = computed(() => {
|
|
|
+ const counts = this.bookPassCounts();
|
|
|
+ const total = counts?.reduce((sum, n) => sum + n, 0) || 0;
|
|
|
+ return total && counts ? Math.round((total - counts[0]) / total * 100) : 0;
|
|
|
+ });
|
|
|
readonly newWordCounts = signal({ pending: 0, completed: 0 });
|
|
|
private readonly newWordPageSize = 30;
|
|
|
private readonly newWordSelectionCache = new Map<string, Word>();
|
|
|
@@ -237,7 +252,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
readonly visibleNewWords = computed(() => this.activeSelectionBucket() === 'completed' ? this.learnedNewWords() : this.unlearnedNewWords());
|
|
|
readonly newWordPageCount = computed(() => Math.max(1, Math.ceil(this.selectionTotal() / this.newWordPageSize)));
|
|
|
readonly allVisibleNewWordsSelected = computed(() => this.visibleNewWords().length > 0 && this.visibleNewWords().every((word) => this.selected().has(word.id)));
|
|
|
- readonly newlyKnownWords = computed(() => this.words().filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
|
|
|
+ readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
|
|
|
readonly selectionActionLabel = computed(() => {
|
|
|
const hasLearningWords = this.selected().size > 0;
|
|
|
const hasKnownWords = this.newlyKnownWords().length > 0;
|
|
|
@@ -602,6 +617,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
toggleWord(word: Word): void {
|
|
|
+ if (this.isRefineSelectionPage && !this.canRelearnCourseWord(word)) return;
|
|
|
const shouldSelect = !this.selected().has(word.id);
|
|
|
if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return;
|
|
|
if (shouldSelect) this.setSelectionAssessment(word, 'unknown');
|
|
|
@@ -624,8 +640,45 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
return this.wordHistoricallyLearned(word) || this.selectionAssessment()[word.id] === 'known';
|
|
|
}
|
|
|
|
|
|
+ courseWordPass(word: Word): number {
|
|
|
+ return Math.min(3, Math.max(this.selectionAssessment()[word.id] === 'known' ? 1 : 0, Math.floor(Number(word['w_learned']) || 0)));
|
|
|
+ }
|
|
|
+
|
|
|
+ coursePassCount(pass: number): number {
|
|
|
+ const counts = this.coursePassCounts();
|
|
|
+ if (!counts) return 0;
|
|
|
+ const known = this.newlyKnownWords().length;
|
|
|
+ return Math.max(0, counts[pass] + (pass === 0 ? -known : pass === 1 ? known : 0));
|
|
|
+ }
|
|
|
+
|
|
|
+ canRelearnCourseWord(word: Word): boolean {
|
|
|
+ const pass = this.courseWordPass(word);
|
|
|
+ const counts = this.bookPassCounts();
|
|
|
+ return !this.isReadOnlySelectionPage && !!counts && pass < 3
|
|
|
+ && (pass === 0 || counts.slice(0, pass).every(count => count === 0));
|
|
|
+ }
|
|
|
+
|
|
|
+ get coursePassHint(): string {
|
|
|
+ const pass = this.coursePass();
|
|
|
+ if (pass === 0) return this.isReadOnlySelectionPage ? '这些单词尚未完成第一遍学习,点击可查看释义。' : '先回忆中文,核对后选择“认识”或“加入学习”。';
|
|
|
+ if (pass === 3) return '这些单词已完成三遍学习,可查看和听读音。';
|
|
|
+ if (!this.bookPassCounts()) return '正在读取整本学习进度,读取完成后可继续学习。';
|
|
|
+ if (this.bookPassCounts()!.slice(0, pass).some(count => count > 0)) return `整本单词完成第${pass === 1 ? '一' : '二'}遍后,可从这里选择单词继续学习。`;
|
|
|
+ return this.isReadOnlySelectionPage ? `这些单词已学${pass}遍,可由老师选词继续学习。` : `选择单词开始第${pass === 1 ? '二' : '三'}遍学习。`;
|
|
|
+ }
|
|
|
+
|
|
|
+ selectCoursePass(pass: number): void {
|
|
|
+ if (pass === this.coursePass() || pass < 0 || pass > 3) return;
|
|
|
+ this.selectionRequest?.unsubscribe();
|
|
|
+ this.coursePass.set(pass);
|
|
|
+ this.selectionPage.set(1);
|
|
|
+ this.words.set([]);
|
|
|
+ this.selectionTotal.set(0);
|
|
|
+ this.loadSelection();
|
|
|
+ }
|
|
|
+
|
|
|
selectionCompletionLabel(word: Word): string {
|
|
|
- return this.wordHistoricallyLearned(word) ? '历史已学完' : '本次已认识';
|
|
|
+ return this.wordHistoricallyLearned(word) ? `已学${this.courseWordPass(word)}遍` : '本次已认识';
|
|
|
}
|
|
|
|
|
|
revealSelectionWord(word: Word): void {
|
|
|
@@ -639,11 +692,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
markWordKnown(word: Word): void {
|
|
|
+ if (this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
|
|
|
this.setWordSelected(word, false);
|
|
|
this.setSelectionAssessment(word, 'known');
|
|
|
}
|
|
|
|
|
|
markWordUnknown(word: Word): void {
|
|
|
+ if (this.isReadOnlySelectionPage || this.wordHistoricallyLearned(word)) return;
|
|
|
if (this.setWordSelected(word, true)) this.setSelectionAssessment(word, 'unknown');
|
|
|
}
|
|
|
|
|
|
@@ -670,7 +725,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
startSelected(): void {
|
|
|
- const available = this.isNewWordSelectionPage ? [...this.newWordSelectionCache.values()] : this.isRefineSelectionPage ? this.pendingSelectionWords() : this.words();
|
|
|
+ if (this.submitting() || this.isReadOnlySelectionPage) return;
|
|
|
+ const available = this.isNewWordSelectionPage ? [...this.newWordSelectionCache.values()] : this.isRefineSelectionPage ? this.courseSelectionCache().filter(word => this.canRelearnCourseWord(word) && this.selectionAssessment()[word.id] !== 'known') : this.words();
|
|
|
const chosen = available
|
|
|
.filter((word) => this.selected().has(word.id));
|
|
|
const newlyKnown = this.newlyKnownWords();
|
|
|
@@ -1308,7 +1364,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
const failure=(error:ApiError)=>{if(more){this.selectionPage.update(p=>p-1);this.platform.toast(error.message,'error');}else this.error.set(error.message);};
|
|
|
if (this.meta.path === 'pages/stu/stu_abc') {
|
|
|
const student = this.session.student() ?? this.session.user();this.selectionLoading.set(true);
|
|
|
- this.selectionRequest=this.api.get<unknown>('e_ck_list',{checkwords:2,nids:this.query('nids')||this.query('nid'),modelId:52,cpage:this.selectionPage(),psize:100,uid:student?.userId}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure});return;
|
|
|
+ this.selectionRequest=this.api.get<unknown>('e_ck_list',{checkwords:2,nids:this.query('nids')||this.query('nid'),pnid:this.query('pnid'),learningPass:this.coursePass(),modelId:52,cpage:this.selectionPage(),psize:100,uid:student?.userId}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>{
|
|
|
+ accept(result,page?.itemCount);
|
|
|
+ const merged=new Map(this.courseSelectionCache().map(word=>[word.id,word]));
|
|
|
+ for(const word of this.words()) merged.set(word.id,word);
|
|
|
+ this.courseSelectionCache.set([...merged.values()]);
|
|
|
+ const counts=page?.['passCounts'],bookCounts=page?.['bookPassCounts'];
|
|
|
+ if(Array.isArray(counts)&&counts.length===4&&Array.isArray(bookCounts)&&bookCounts.length===4){
|
|
|
+ this.coursePassCounts.set(counts.map(Number));this.bookPassCounts.set(bookCounts.map(Number));
|
|
|
+ }
|
|
|
+ this.error.set('');
|
|
|
+ },error:failure});return;
|
|
|
}
|
|
|
|
|
|
if (this.meta.path === 'pages/his/his_words') {
|
|
|
@@ -2115,6 +2181,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
this.selectionRevealed.set(new Set());
|
|
|
this.selectionAssessment.set({});
|
|
|
this.activeSelectionBucket.set('pending');
|
|
|
+ this.coursePass.set(0);
|
|
|
+ this.coursePassCounts.set(null);
|
|
|
+ this.bookPassCounts.set(null);
|
|
|
+ this.courseSelectionCache.set([]);
|
|
|
this.currentIndex.set(0);
|
|
|
this.revealed.set(false);
|
|
|
this.completed.set(false);
|
|
|
@@ -2221,9 +2291,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
|
|
|
}
|
|
|
|
|
|
private commitKnownWords(words: Word[]): void {
|
|
|
- const ids = new Set(words.map((word) => word.id));
|
|
|
- this.words.update((current) => current.map((word) => ids.has(word.id) ? { ...word, w_learned: Math.max(1, Number(word['w_learned'] ?? 0)) } : word));
|
|
|
- this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);
|
|
|
+ const ids = new Set(words.map(word => word.id));
|
|
|
+ const update = (current: Word[]) => current.map(word => ids.has(word.id) ? {...word, w_learned: 1} : word);
|
|
|
+ this.words.update(update);
|
|
|
+ this.courseSelectionCache.update(update);
|
|
|
+ const advance = (counts: number[] | null) => counts ? [Math.max(0,counts[0]-ids.size),counts[1]+ids.size,counts[2],counts[3]] : null;
|
|
|
+ this.coursePassCounts.update(advance);
|
|
|
+ this.bookPassCounts.update(advance);
|
|
|
+ this.selectionAssessment.update(current => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);
|
|
|
}
|
|
|
|
|
|
private findList(value: unknown): SourceItem[] {
|