import { lessonDateLabel } from '../appointment/lesson-display'; import { TeacherTodayReviewService } from '../../core/teacher-today-review.service'; import { lessonStudySources } from '../appointment/lesson-study-sources'; import { DropdownDirective } from '../../shared/dropdown.directive'; import { LearningDraft } from '../../core/learning-draft'; import { ReviewPage, reviewCalendar } from '../../core/review-page'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Activity, BookOpen, BrainCircuit, Check, CheckCheck, ChevronLeft, ChevronRight, CircleHelp, Clock3, Flag, Headphones, Library, LucideAngularModule, Play, RefreshCw, RotateCcw, Star, Trash2, Volume2, X, } from 'lucide-angular'; import { catchError, finalize, forkJoin, map, Observable, of, Subscription, switchMap, tap, throwError } from 'rxjs'; import { listeningChoices, shuffledStudy } from '../../core/learning-random'; import { ApiError, ApiService } from '../../core/api.service'; import { AntiForgetCalendarDay, antiForgetCompleted, antiForgetDate, antiForgetTasksOnDate, buildAntiForgetCalendar, buildAntiForgetView, mergeAntiForgetItems, nearestAntiForgetDate, } from '../../core/anti-forget'; import { AppUser, PageMeta } from '../../core/models'; import { canStartTeacherLedLearning, isTeacherLedLearningPath } from '../../core/learning-access'; import { isLearningStage, learningReturnUrl } from '../../core/learning-navigation'; import { PlatformService } from '../../core/platform.service'; import { SessionService } from '../../core/session.service'; import { nextReviewWordOrder } from '../../core/review-word-order'; type SourceItem = Record; interface Word extends SourceItem { id: string; wordId: string; nodeId: string; word: string; meaning: string; phonetic: string; audio: string; } interface LetterChoice { value: string; used: boolean; } interface ExamOption { text: string; value: string | number; checked?: boolean; } interface ExamQuestion extends SourceItem { ID: string | number; AskID?: string | number; QType?: string | number; QContent: string; QTitle?: string; QOption: ExamOption[]; } interface ExamAnswer { qid: string | number; qtype: string | number; answer: string | number; result: 0 | 1 | 2; } interface MemoryStudentOption { id: string; name: string; taskCount: number; } const WORD_CATALOG_CATEGORIES = [ { label: '小学', pid: 8 }, { label: '初中', pid: 9 }, { label: '高中', pid: 10 }, { label: '大学+', pid: 11 }, { label: '出国', pid: 12 }, ] as const; @Component({ selector: 'app-learning-page', standalone: true, imports: [DropdownDirective, CommonModule, LucideAngularModule, RouterLink], templateUrl: './learning-page.component.html', styleUrl: './learning-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class LearningPageComponent implements OnInit, OnDestroy { private static readonly reviewCacheByApi = new WeakMap }>(); readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta; readonly nodes = signal([]); readonly catalogCategories = WORD_CATALOG_CATEGORIES; readonly catalogCategoryIndex = signal(0); readonly selectedNodes = signal>(new Set()); readonly words = signal([]); readonly wordPreviewPage = signal(1); readonly wordPreviewTotal = signal(0); readonly wordPreviewLoading = signal(false); private historyPreviewWords: Word[] = []; readonly selected = signal>(new Set()); readonly selectionRevealed = signal>(new Set()); readonly selectionAssessment = signal>({}); readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending'); readonly coursePassTabs = ['未学习', '已学一遍', '已学第二遍', '已学第三遍']; readonly coursePass = signal(0); readonly coursePassCounts = signal(null); readonly bookPassCounts = signal(null); private readonly courseSelectionCache = signal([]); 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(); private legacyNewWords: Word[] | null = null; readonly currentIndex = signal(0); readonly revealed = signal(false); readonly completed = signal(false); readonly lessonLearnedCount = signal(0); readonly historySources = signal>([]); readonly correct = signal(0); readonly unknown = signal([]); readonly loading = signal(false); readonly calendarLoading = signal(false); readonly calendarError = signal(''); readonly submitting = signal(false); readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription; readonly error = signal(''); readonly recordWordless = signal(false); readonly studyStep = signal<1 | 2 | 3>(1); readonly repetitions = signal>({}); readonly matchLeft = signal([]); readonly matchRight = signal([]); readonly leftChoice = signal(''); readonly rightChoice = signal(''); readonly choicesLoading = signal(false); readonly choiceOptions = signal([]); readonly letters = signal([]); readonly assembled = signal(''); readonly spellingCorrect = signal(false); readonly reviewState = signal>({}); readonly seen = signal>(new Set()); readonly recordRevealed = signal>(new Set()); readonly calendarItems = signal([]); readonly memoryStudentId = signal(''); readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12)); readonly memorySelectedDate = signal(''); readonly reviewScope=signal(''); readonly reviewCompletion=signal<{amount:number;rewardStatus:string;ineligibleReason?:string;unchanged?:boolean}|null>(null); private reviewReturn: {date?:string;studentId?:string;page?:number;scope?:string;scroll?:number}={}; readonly orderInfo = signal({}); readonly learnedRecord = signal({}); readonly antiHistory = signal({}); readonly myNewWords = signal({}); readonly examStarted = signal(false); readonly examSubmitted = signal(false); readonly askInfo = signal({}); readonly examHistory = signal([]); readonly examHistoryLoading = signal(false); readonly examHistoryError = signal(''); readonly examHistoryMore = signal(false); readonly examCompareIds = signal([]); readonly comparedExams = computed(() => this.examHistory().filter(r => this.examCompareIds().includes(String(r['GeneralID'])))); readonly examSaveError = signal(''); readonly examDateLabel = lessonDateLabel; private examHistoryPage = 0; private examHistoryRequest?: Subscription; private examLoadRequest?: Subscription; private examAttemptStamp = ''; private examLogKey = ''; private examAnswerId: string | number | null = null; readonly previousExam = signal({}); readonly questions = signal([]); readonly answers = signal([]); readonly timer = signal(10); readonly examScore = signal(0); readonly icons = { Activity, BookOpen, BrainCircuit, Check, CheckCheck, ChevronLeft, ChevronRight, CircleHelp, Clock3, Flag, Headphones, Library, Play, RefreshCw, RotateCcw, Star, Trash2, Volume2, X, }; readonly antiForgetChart = { forgettingPath: 'M 26 12 C 38 60 82 88 304 95', learningPath: 'M 26 98 C 31 80 35 68 40 61 C 47 50 58 43 68 40 C 81 33 95 29 110 27 C 132 22 156 19 179 19 C 210 18 244 17 276 17 C 286 17 296 17 304 17', continuationPath: 'M 276 12 C 284 15 294 16 304 16', reviewEvents: [ { day: 2, x: 40, y: 61, path: 'M 26 12 C 30 30 34 49 40 61' }, { day: 4, x: 68, y: 40, path: 'M 40 12 C 47 22 57 35 68 40' }, { day: 7, x: 110, y: 27, path: 'M 68 12 C 79 17 94 24 110 27' }, { day: 12, x: 179, y: 19, path: 'M 110 12 C 128 15 155 19 179 19' }, { day: 19, x: 276, y: 17, path: 'M 179 12 C 205 15 244 17 276 17' }, ], days: Array.from({ length: 21 }, (_, index) => ({ day: index + 1, x: 26 + index * 13.9 })), }; readonly current = computed(() => this.words()[this.currentIndex()] ?? null); readonly completedSelectionWords = computed(() => this.words().filter((word) => this.selectionWordCompleted(word))); readonly pendingSelectionWords = computed(() => this.words().filter((word) => !this.selectionWordCompleted(word))); readonly learnedNewWords = computed(() => this.words().filter((word) => this.newWordLearned(word))); readonly unlearnedNewWords = computed(() => this.words().filter((word) => !this.newWordLearned(word))); 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.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; if (hasLearningWords && hasKnownWords) return '保存并开始学习'; if (hasKnownWords) return '保存识词结果'; return '开始学习'; }); readonly currentQuestion = computed(() => this.questions()[this.currentIndex()] ?? null); readonly wordPreviewPageCount = computed(() => Math.max(1, Math.ceil(this.wordPreviewTotal() / this.wordPreviewPageSize))); readonly wordPreviewPages = computed(() => { const pageCount = this.wordPreviewPageCount(); if (pageCount <= 3) return Array.from({ length: pageCount }, (_, index) => index + 1); const start = Math.min(Math.max(1, this.wordPreviewPage() - 1), pageCount - 2); return Array.from({ length: 3 }, (_, index) => start + index); }); readonly wordPreviewRangeStart = computed(() => this.wordPreviewTotal() ? ((this.wordPreviewPage() - 1) * this.wordPreviewPageSize) + 1 : 0); readonly wordPreviewRangeEnd = computed(() => Math.min(this.wordPreviewPage() * this.wordPreviewPageSize, this.wordPreviewTotal())); readonly memoryStudents = computed(() => { if(this.reviewPageData()?.students)return this.reviewPageData()!.students!.map(s=>({id:String(s.id),name:s.name,taskCount:s.taskCount})); const students = new Map(); for (const item of this.calendarItems()) { const id = String(item['yhid'] ?? '').trim(); if (!id) continue; const current = students.get(id); if (current) current.taskCount += 1; else students.set(id, { id, name: this.calendarStudent(item) || `学员 ${id}`, taskCount: 1 }); } return [...students.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')); }); readonly memorySelectedStudentName = computed(() => this.memoryStudents().find((student) => student.id === this.memoryStudentId())?.name || '全部学员'); readonly reviewPageData=signal(null); readonly reviewListPage=signal(1); readonly memoryFilteredItems = computed(() => { const studentId = this.memoryStudentId(); return studentId ? this.calendarItems().filter((item) => String(item['yhid'] ?? '') === studentId) : this.calendarItems(); }); readonly memoryView = computed(() => buildAntiForgetView(this.memoryFilteredItems())); readonly memorySummary = computed(() => this.memoryView().summary); readonly memoryCalendar=computed(()=>reviewCalendar(this.reviewPageData()?.dates||[],this.memoryMonth(),this.memorySelectedDate())); readonly memorySelectedTasks = computed(() => this.reviewScope() === 'overdue' ? this.memoryFilteredItems() : antiForgetTasksOnDate(this.memoryFilteredItems(), this.memorySelectedDate())); readonly memorySelectedLabel = computed(() => { const value = this.memorySelectedDate(); if (!value) return ''; const [year, month, day] = value.split('-').map(Number); return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(year, month - 1, day, 12)); }); readonly memoryTotalCount = computed(() => this.reviewPageData()?.summary.total||0); readonly memoryCompletedCount = computed(() => this.reviewPageData()?.summary.completed||0); readonly memoryDueCount = computed(() => this.reviewPageData()?.summary.due||0); readonly memoryProgress=computed(()=>{const s=this.reviewPageData()?.summary;return s?.total?Math.round(s.completed/s.total*100):0;}); readonly progress = computed(() => { const total = this.isMatchingPage ? this.words().length : this.words().length || this.questions().length; if (!total) return 0; if (this.isMatchingPage) return Math.round(((total - this.matchLeft().length) / total) * 100); return Math.round(((this.currentIndex() + (this.completed() ? 1 : 0)) / total) * 100); }); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly api = inject(ApiService); private readonly session = inject(SessionService); private readonly todayReviews = inject(TeacherTodayReviewService); private readonly platform = inject(PlatformService); private readonly wordPreviewPageSize = 30; private examTimer: ReturnType | null = null; private antiStartedAt = Date.now(); private calendarRequest?: Subscription; private choiceOptionsRequest?: Subscription; private choiceOptionsVersion = 0; private outsideChoicePool: Word[] | null = null; ngOnInit(): void { if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading && !this.isNewWordSelfStudyStage) { this.platform.toast('点读学习由陪练老师在上课时发起', 'error'); void this.router.navigateByUrl('/pages/home/cart'); return; } if (this.isCatalogPage && Number(this.session.user()?.groupId) === 1) { void this.router.navigateByUrl('/pages/home/cart'); return; } if(this.isCalendarPage){ this.reviewScope.set(this.query('scope')==='overdue'?'overdue':''); this.memorySelectedDate.set(this.query('date')||this.dateOnly()); this.memoryStudentId.set(this.query('studentId')||''); this.reviewListPage.set(Math.max(1,Number(this.query('page'))||1)); const [y,m]=this.memorySelectedDate().split('-').map(Number);this.memoryMonth.set(new Date(y,m-1,1,12)); } if(this.isRecordPage)this.reviewReturn=history.state?.reviewReturn||{date:this.query('date')||this.dateOnly()}; this.load(); } ngOnDestroy(): void { this.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe(); this.choiceOptionsVersion++; this.choiceOptionsRequest?.unsubscribe(); this.stopExamTimer(); this.examHistoryRequest?.unsubscribe();this.examLoadRequest?.unsubscribe(); this.platform.stopAudio(); } get isNodePage(): boolean { return ['pages/stu/index', 'pages/stu/stu_list'].includes(this.meta.path); } get isCatalogPage(): boolean { return this.meta.path === 'pages/stu/index'; } get isUnitListPage(): boolean { return this.meta.path === 'pages/stu/stu_list'; } get isSelectionPage(): boolean { return ['pages/stu/stu_abc', 'pages/his/his_words', 'pages/member/starshop'].includes(this.meta.path); } get isNewWordSelectionPage(): boolean { return this.meta.path === 'pages/member/starshop'; } get isHistoryWordView(): boolean { return this.meta.path === 'pages/his/his_words'; } get isRefineSelectionPage(): boolean { return this.meta.path === 'pages/stu/stu_abc'; } get isStudyListPage(): boolean { return this.meta.path === 'pages/stu/stu_con'; } get isReadOnlySelectionPage(): boolean { return !this.canStartPointReading && this.isRefineSelectionPage && this.query('readonly') === '1'; } get canStartPointReading(): boolean { return canStartTeacherLedLearning(this.session.groupId()); } get isNewWordSelfStudyStage(): boolean { if (Number(this.session.groupId()) !== 1 || !['pages/stu/stu_con', 'pages/stu/stu3', 'pages/stu/stu4', 'pages/stu/stu5'].includes(this.meta.path) || ['nid', 'nids', 'bindingId', 'orderId'].some((key) => this.query(key))) return false; return this.storageGet('learn_mode') === 'newword' && this.parseArray(this.storageGet('choose_newword_learn')).length > 0; } get isReadOnlyWordView(): boolean { if (this.isHistoryWordView) return true; return !this.canStartPointReading && this.isStudyListPage && this.query('readonly') === '1'; } get isMatchingPage(): boolean { return this.meta.path === 'pages/stu/stu3'; } get isListeningPage(): boolean { return this.meta.path === 'pages/stu/stu4'; } get isSpellingPage(): boolean { return ['pages/stu/stu5', 'pages/stu/pl_stu5'].includes(this.meta.path); } get spellingStageSkipped(): boolean { return this.storageGet('skip_spelling_stage') === true; } get canSkipSpellingStage(): boolean { return this.isSpellingPage && (this.canStartPointReading || this.isNewWordSelfStudyStage); } get hasNextWordBatch(): boolean { const currentPage = Number(this.storageGet('_page_current_learn') ?? 1); const pages = this.parseArray(this.storageGet('_page_word_orilist')); return Number.isInteger(currentPage) && currentPage > 0 && Array.isArray(pages[currentPage]) && pages[currentPage].length > 0; } get stageActionLabel(): string { if (this.isMatchingPage) return '进入听音识意'; if (this.isListeningPage && !this.spellingStageSkipped) return '进入组合拼写'; if (this.hasNextWordBatch) return '学习下一组单词'; const mode = String(this.storageGet('learn_mode') ?? ''); if (mode === 'newword' || mode === 'history_word') return '完成学习并返回'; return this.canStartPointReading && !this.query('orderId') ? '保存并结束学习' : '查看学习结果'; } get canReturnToLearningEntry(): boolean { return isLearningStage(this.meta.path, this.route.snapshot.queryParamMap); } returnToLearningEntry(): void { const legacyOrderId = this.meta.path === 'pages/stu/pl_end' ? this.query('id') : ''; const url = legacyOrderId ? `/pages/shop/get_order?id=${encodeURIComponent(legacyOrderId)}` : learningReturnUrl(this.route.snapshot.queryParamMap, history.state?.learningReturnUrl); void this.router.navigateByUrl(url, { replaceUrl: true }); } get isReviewPage(): boolean { return ['pages/stu/stu6', 'pages/stu/pl_end'].includes(this.meta.path); } get isCalendarPage(): boolean { return this.meta.path === 'pages/stu/stu_calendar'; } get isAntiForgetPage(): boolean { return this.meta.path === 'pages/stu/stu7'; } get isRecordPage(): boolean { return this.meta.path === 'pages/stu/record'; } get isExamPage(): boolean { return this.meta.path === 'pages/his/exam'; } get isReportPage(): boolean { return this.meta.path === 'pages/stu/stu_ok'; } get selectionLimit(): number { if (this.meta.path === 'pages/stu/stu_abc') return 30; if (this.isNewWordSelectionPage) return 100; return this.words().length; } load(): void { this.selectionRequest?.unsubscribe();this.selectionPage.set(1);this.selectionTotal.set(0); this.resetPageState(); this.loading.set(true); this.error.set(''); this.recordWordless.set(false); if (this.isReportPage) { this.loading.set(false); return; } if (this.isNodePage) { this.loadNodes(); return; } if (this.isSelectionPage) { this.loadSelection(); return; } if (this.isCalendarPage) { this.loadCalendar(true); return; } if (this.isAntiForgetPage) { this.loadAntiForget(); return; } if (this.isRecordPage) { this.loadRecord(); return; } if (this.isExamPage) { this.loadExam(); return; } if (this.meta.path === 'pages/stu/pl_end') { this.loadPracticeEnd(); return; } if (this.meta.path === 'pages/stu/stu6') { this.loadReviewWords(); return; } this.loadStageWords(); } openNode(node: SourceItem): void { const id = node['NodeID'] ?? node['Nodeid'] ?? node['ID'] ?? node['id']; if (this.isCatalogPage) { void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } }); return; } if (this.isUnitListPage && !this.canStartPointReading) { void this.router.navigate(['/pages/stu/stu_abc'], { queryParams: { nids: id, pnid: this.query('nid'), readonly: 1 }, }); return; } this.toggleNode(node); } openWordPreviewPage(page: number): void { const target = Math.min(Math.max(1, page), this.wordPreviewPageCount()); if (target === this.wordPreviewPage() || this.wordPreviewLoading()) return; const previous = this.wordPreviewPage(); this.wordPreviewPage.set(target); if (this.isHistoryWordView) { const start = (target - 1) * this.wordPreviewPageSize; this.words.set(this.historyPreviewWords.slice(start, start + this.wordPreviewPageSize)); setTimeout(() => document.getElementById('readonly-word-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' })); return; } this.wordPreviewLoading.set(true); this.loadReadOnlyWords(true, previous); } selectCatalogCategory(index: number): void { if (index === this.catalogCategoryIndex()) return; this.catalogCategoryIndex.set(index); this.loadNodes(); } toggleNode(node: SourceItem): void { const id = this.nodeId(node); if (!id) return; this.selectedNodes.update((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } startNodeSelection(): void { if (!this.selectedNodes().size) { this.platform.toast('请选择学习单元', 'error'); return; } void this.router.navigate(['/pages/stu/stu_abc'], { queryParams: { nids: [...this.selectedNodes()].join(','), pnid: this.query('nid'), bindingId: this.query('bindingId'), orderId: this.query('orderId'), }, }); } nodeId(node: SourceItem): string { return String(node['NodeID'] ?? node['Nodeid'] ?? node['ID'] ?? node['id'] ?? ''); } nodeTitle(node: SourceItem): string { return String(node['NodeName'] ?? node['Title'] ?? node['name'] ?? '词库'); } nodeCount(node: SourceItem): string { return String(node['ItemCount'] ?? node['Count'] ?? node['count'] ?? ''); } nodeProgress(node: SourceItem): string { const total = this.nodeTotal(node); return total > 0 ? `已学 ${this.nodeLearned(node)}/${total} · 进度 ${this.nodeProgressPercent(node)}%` : ''; } nodeLearned(node: SourceItem): number { return Math.max(0, Number(node['yx_word'] ?? 0)); } nodeTotal(node: SourceItem): number { return Math.max(0, Number(node['total'] ?? 0)); } nodeProgressPercent(node: SourceItem): number { const total = this.nodeTotal(node); return total ? Math.min(100, Math.round((this.nodeLearned(node) / total) * 100)) : 0; } 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'); else this.clearSelectionAssessment(word); } selectionWordRevealed(word: Word): boolean { return this.selectionRevealed().has(word.id); } wordHistoricallyLearned(word: Word): boolean { return Number(word['w_learned'] ?? 0) > 0; } newWordLearned(word: Word): boolean { return Number(word['xxcs'] ?? 0) >= 2; } selectionWordCompleted(word: Word): boolean { 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) ? `已学${this.courseWordPass(word)}遍` : '本次已认识'; } revealSelectionWord(word: Word): void { this.selectionRevealed.update((current) => { const next = new Set(current); if (next.has(word.id)) next.delete(word.id); else next.add(word.id); return next; }); this.play(word); } 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'); } toggleAllWords(): void { if (this.isNewWordSelectionPage) { const visible = this.visibleNewWords(); const clearVisible = this.allVisibleNewWordsSelected(); const next = new Set(this.selected()); for (const word of visible) { if (clearVisible) next.delete(word.id); else if (next.size < this.selectionLimit) next.add(word.id); } if (!clearVisible && next.size === this.selectionLimit && visible.some((word) => !next.has(word.id))) { this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error'); } this.selected.set(next); return; } if (this.selected().size) { this.selected.set(new Set()); return; } this.selected.set(new Set(this.words().slice(0, this.selectionLimit).map((word) => word.id))); } startSelected(): void { 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(); if (!chosen.length && !newlyKnown.length) { this.platform.toast(this.isRefineSelectionPage ? '请先判断单词是否认识,或选择要学习的单词' : '请先选择要学习的单词', 'error'); return; } if (this.meta.path === 'pages/stu/stu_abc') { const student = this.session.student() ?? this.session.user(); if (!student?.userId) { this.platform.toast('课程信息错误', 'error'); return; } this.submitting.set(true); this.persistWordsAsLearned(newlyKnown, student).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => { if (newlyKnown.length) this.commitKnownWords(newlyKnown); if (chosen.length) { this.beginSelectedFlow(chosen, ''); return; } this.platform.toast('识词结果已保存', 'success'); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); return; } this.beginSelectedFlow(chosen, this.meta.path === 'pages/member/starshop' ? 'newword' : 'history_word'); } deleteSelectedNewWords(): void { const chosen = [...this.newWordSelectionCache.values()].filter((word) => this.selected().has(word.id)); if (!chosen.length) { this.platform.toast('请选择单词', 'error'); return; } const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user(); this.api.post('e_add_words', { uid: owner?.userId, wordsId: chosen.map((word) => word.wordId).join(','), ifnew: 0, kcid: this.query('kcid'), }).subscribe({ next: () => { this.platform.toast('已删除', 'success'); this.load(); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } play(word = this.current()): void { if (this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView)) return; if (!word?.audio) { this.platform.toast('该单词暂无音频', 'error'); return; } this.platform.play(this.wordAudioUrl(word.audio)); } toggleRecordAnswer(word: Word): void { this.recordRevealed.update((current) => { const next = new Set(current); if (next.has(word.id)) next.delete(word.id); else next.add(word.id); return next; }); this.seen.update((current) => new Set(current).add(word.id)); this.play(word); } repeatWord(word: Word, index: number): void { const count = this.repetitions()[word.id] ?? 0; if (this.studyStep() === 1 && index > 0) { const previous = this.words()[index - 1]; if ((this.repetitions()[previous.id] ?? 0) < 3) { this.platform.toast('前面还有没读完的单词', 'error'); return; } } this.play(word); if (count < 3) { this.repetitions.update((value) => ({ ...value, [word.id]: count + 1 })); } if (this.words().every((item) => (this.repetitions()[item.id] ?? 0) >= 3)) { this.finishStudyRound(); } } shuffleStudyWords(): void { this.words.set(shuffledStudy(this.words())); this.repetitions.set({}); } skipStudyList(): void { this.navigateStage('/pages/stu/stu3'); } chooseMatch(side: 'left' | 'right', word: Word): void { if (side === 'left') { this.leftChoice.set(word.id); this.play(word); } else { this.rightChoice.set(word.id); } const left = side === 'left' ? word.id : this.leftChoice(); const right = side === 'right' ? word.id : this.rightChoice(); if (!left || !right) return; if (left !== right) { this.platform.toast('答错了,请再试一次', 'error'); this.leftChoice.set(''); this.rightChoice.set(''); return; } this.platform.playEffect('success'); this.matchLeft.update((items) => items.filter((item) => item.id !== left)); this.matchRight.update((items) => items.filter((item) => item.id !== right)); this.leftChoice.set(''); this.rightChoice.set(''); if (this.matchLeft().length === 0) this.completed.set(true); } chooseMeaning(option: Word): void { if (this.choicesLoading() || this.choiceOptions().length !== 4 || this.completed() || !this.choiceOptions().some(w => w.id === option.id)) return; const word = this.current(); if (!word) return; if (option.id !== word.id) { this.platform.playEffect('error'); this.platform.toast('答错了,请重新听一遍', 'error'); this.play(word); return; } this.correct.update((value) => value + 1); this.platform.playEffect('success'); if (this.currentIndex() >= this.words().length - 1) { this.completed.set(true); return; } this.currentIndex.update((value) => value + 1); this.prepareChoiceOptions(); setTimeout(() => this.play(), 150); } spellLetter(index: number): void { if (this.spellingCorrect() || this.letters()[index]?.used) return; this.letters.update((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, used: true } : item)); const next = this.assembled() + this.letters()[index].value; this.assembled.set(next); const word = this.current(); if (!word || next.length < word.word.length) return; if (next.toLowerCase() === word.word.toLowerCase()) { this.spellingCorrect.set(true); this.correct.update((value) => value + 1); this.platform.playEffect('success'); this.platform.toast('拼写正确', 'success'); return; } this.platform.playEffect('error'); this.platform.toast('拼写错了,再试一次', 'error'); this.prepareSpelling(); } resetSpelling(): void { this.prepareSpelling(); } nextSpellingWord(): void { if (!this.spellingCorrect()) { this.platform.toast('请先完成当前单词', 'error'); return; } if (this.currentIndex() >= this.words().length - 1) { this.completed.set(true); return; } this.currentIndex.update((value) => value + 1); this.prepareSpelling(); this.play(); } skipPracticeStage(): void { if ((!this.isMatchingPage && !this.isListeningPage) || this.completed() || this.submitting()) return; this.completed.set(true); this.continueAfterStage(); } skipSpellingStage(): void { if (!this.canSkipSpellingStage || this.completed() || this.submitting()) return; this.storageSet('skip_spelling_stage', true); this.completed.set(true); this.continueAfterStage(); } continueAfterStage(): void { if (this.submitting() || !this.completed()) return; if (this.isMatchingPage) { this.completed.set(false); this.navigateStage('/pages/stu/stu4'); } else if (this.isListeningPage) { this.completed.set(false); if (this.spellingStageSkipped) this.finishSpellingFlow(); else this.navigateStage(this.canStartPointReading ? '/pages/stu/pl_stu5' : '/pages/stu/stu5'); } else if (this.isSpellingPage) { this.finishSpellingFlow(); } } reviewValue(word: Word): 0 | 1 | 2 { return this.reviewState()[word.id] ?? 0; } markReview(word: Word, state: 1 | 2): void { this.reviewState.update((value) => ({ ...value, [word.id]: state })); if (this.query('orderId')) this.storageSet('roundReview:' + this.storageGet('batchId'), this.reviewState()); } markAllKnown(): void { const allKnown = this.words().every((word) => this.reviewValue(word) === 1); const next: Record = {}; for (const word of this.words()) next[word.id] = allKnown ? 0 : 1; this.reviewState.set(next); if (this.query('orderId')) this.storageSet('roundReview:' + this.storageGet('batchId'), next); } reviewCount(state: 1 | 2): number { return this.words().filter((word) => this.reviewValue(word) === state).length; } submitReview(continueLearning = false): void { if (this.submitting() || !this.words().length) return; if (this.words().some((word) => this.reviewValue(word) === 0)) { this.platform.toast('请为每个单词选择掌握情况', 'error'); return; } if (this.meta.path === 'pages/stu/pl_end') { this.submitPracticeEnd(continueLearning); return; } this.saveLearningRecord(); } toggleSeen(word: Word): void { this.revealed.set(!this.revealed()); this.seen.update((current) => new Set(current).add(word.id)); this.play(word); this.updateAntiProgress(word); if (this.seen().size === this.words().length) { this.platform.toast(`已完成 ${this.words().length} 个单词的复习`, 'success'); } } joinAntiWords(): void { const chosen = this.words().filter((word) => this.selected().has(word.id)); if (!chosen.length) { this.platform.toast('请选择需要加入生词本的单词', 'error'); return; } const existing = this.parseArray(this.myNewWords()['scnr']); const combined = new Map(); for (const item of existing) combined.set(String(item['GeneralID'] ?? ''), item); for (const word of chosen) { combined.set(word.wordId, { GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word), gldy: word['gldy'] }); } const user = this.session.user(); const content: SourceItem = { ModelID: 55, nodeId: 255, inputer: user?.honeyName, status: 99, Hits: combined.size, title: user?.honeyName, }; let action = 'content_add'; if (this.myNewWords()['GeneralID']) { content['GeneralID'] = this.myNewWords()['GeneralID']; action = 'content_update'; } this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify({ UserID: user?.userId, scnr: JSON.stringify([...combined.values()]) }), }).subscribe({ next: () => this.platform.toast('添加成功', 'success'), error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } addRecordWords(): void { const chosen = this.words().filter((word) => this.selected().has(word.id)); if (!chosen.length) { this.platform.toast('请选择单词', 'error'); return; } const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user(); this.api.post('e_add_words', { uid: owner?.userId, wordsId: chosen.map((word) => word.wordId).join(','), ifnew: 1, kcid: this.query('kcid'), }).subscribe({ next: () => this.platform.toast('添加成功', 'success'), error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } endRecord(): void { if(this.submitting()||this.reviewCompletion())return; this.submitting.set(true); this.api.post<{amount:number;rewardStatus:string;ineligibleReason?:string;unchanged?:boolean}>('app_review_complete',{id:this.query('gid')}).pipe(finalize(()=>this.submitting.set(false))).subscribe({ next:({result})=>{this.reviewCache().clear();if(Number(this.session.groupId())===3)this.todayReviews.markCompleted(this.query('gid'));this.reviewCompletion.set(result);}, error:(e:ApiError)=>this.platform.toast(e.message,'error'), }); } get overdueReview():boolean{return Boolean(this.query('plannedDate')&&this.query('plannedDate'){ if(!this.isRecordPage||this.reviewCompletion())return true; if(this.submitting())return false; return this.seen().size||this.selected().size||this.recordRevealed().size?this.platform.confirm('本次复习尚未提交完成,确定离开?'):true; } returnToReview(today=false):void{ const r=today?{date:this.dateOnly()}:this.reviewReturn; void this.router.navigate(['/pages/stu/stu_calendar'],{queryParams:{date:r.date,studentId:r.studentId,scope:r.scope,page:r.page},state:{reviewScroll:r.scroll},replaceUrl:true}); } calendarTitle(item: SourceItem): string { const title = String(item['kc_title'] ?? item['kctitle'] ?? item['kcmc'] ?? '').trim(); return title ? title.split('@')[0] : '单词复习'; } calendarStudent(item: SourceItem): string { return String(item['honeyName'] ?? item['Title'] ?? '').split('@')[0]; } calendarDate(item: SourceItem): string { return antiForgetDate(item); } calendarFirstLearnedAt(item: SourceItem): string { const raw = String( item['learned_at'] ?? item['learnedAt'] ?? item['firstLearnedAt'] ?? item['learned_date'] ?? '', ).trim(); if (!raw) return '首次学习:时间未记录'; const compactDate = raw.match(/^(\d{4})(\d{2})(\d{2})$/); if (compactDate) return `首次学习:${compactDate[1]}-${compactDate[2]}-${compactDate[3]}(时间未记录)`; const dateOnly = raw.match(/^(\d{4}-\d{2}-\d{2})$/); if (dateOnly) return `首次学习:${dateOnly[1]}(时间未记录)`; const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw.replace(' ', 'T')}+08:00`; const value = new Date(normalized); if (Number.isNaN(value.getTime())) return `首次学习:${raw}`; const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(value); const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((itemPart) => itemPart.type === type)?.value ?? ''; return `首次学习:${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}`; } calendarReviewNumber(item: SourceItem): number { for (const key of ['fxcs', 'fxxh', 'reviewNo', 'reviewIndex', 'review_order']) { const value = Number(item[key]); if (Number.isFinite(value) && value > 0) return Math.round(value); } const recordId = String(item['xxjlid'] ?? '').trim(); if (!recordId) return 1; const tasks = this.calendarItems() .filter((candidate) => String(candidate['xxjlid'] ?? '').trim() === recordId) .sort((left, right) => { const dateOrder = antiForgetDate(left).localeCompare(antiForgetDate(right)); if (dateOrder) return dateOrder; return Number(left['GeneralID'] ?? 0) - Number(right['GeneralID'] ?? 0); }); const index = tasks.indexOf(item); return index >= 0 ? index + 1 : 1; } calendarReady(item: SourceItem): boolean { const due = String(item['kywsj'] ?? '').slice(0, 10); return Boolean(due && Date.parse(due) <= Date.parse(this.dateOnly()) && Number(item['fxzt'] ?? 0) !== 1); } calendarCompleted(item: SourceItem): boolean { return antiForgetCompleted(item); } selectMemoryDay(day: AntiForgetCalendarDay): void { if (day.date === this.memorySelectedDate() && day.inCurrentMonth) return; this.reviewScope.set('');this.memorySelectedDate.set(day.date); this.reviewListPage.set(1);this.loadCalendar(); if (!day.inCurrentMonth) { const [year, month] = day.date.split('-').map(Number); this.memoryMonth.set(new Date(year, month - 1, 1, 12)); } } shiftMemoryMonth(amount: number): void { const current = this.memoryMonth(); const next = new Date(current.getFullYear(), current.getMonth() + amount, 1, 12); this.memoryMonth.set(next); const monthKey = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}`; const firstTask = (this.reviewPageData()?.dates||[]).map(row=>row.date).filter((date) => date.startsWith(monthKey)).sort()[0]; this.memorySelectedDate.set(firstTask || `${monthKey}-01`); this.reviewListPage.set(1);this.loadCalendar(); } showCurrentMemoryMonth(): void { const today = this.dateOnly(); const [year, month] = today.split('-').map(Number); this.memoryMonth.set(new Date(year, month - 1, 1, 12)); this.reviewScope.set('');this.memorySelectedDate.set(today); this.reviewListPage.set(1);this.loadCalendar(); } selectMemoryStudent(event: Event): void { this.memoryStudentId.set((event.target as HTMLSelectElement).value);this.reviewListPage.set(1);this.loadCalendar(); } memoryTaskStatus(item: SourceItem): string { if (this.calendarCompleted(item)) return '已完成'; return this.calendarReady(item) ? (this.calendarDate(item)({qid:q.ID,qtype:String(q.QType??''),answer:'',result:2}))); this.examStarted.set(true); this.examSubmitted.set(false); this.currentIndex.set(0); this.startExamTimer(); } answerExam(option: ExamOption, optionIndex: number): void { if(this.submitting()||this.examSaveError()||this.examSubmitted())return; const question = this.currentQuestion(); if (!question) return; const options = question.QOption.map((item, index) => ({ ...item, checked: index === optionIndex })); this.questions.update((items) => items.map((item, index) => index === this.currentIndex() ? { ...item, QOption: options, checked: true } : item)); const result: 0 | 1 = this.cleanText(option.text) === this.cleanText(question.QContent) ? 1 : 0; this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: option.value, result }); this.advanceExam(); } skipExamQuestion(): void { if(this.submitting()||this.examSaveError()||this.examSubmitted())return; const question = this.currentQuestion(); if (!question) return; this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 }); this.advanceExam(); } submitExam(): void { if(this.submitting()||this.examSubmitted())return; this.examSaveError.set(''); if(this.examAnswerId!==null){this.submitting.set(true);this.saveExamLog(this.examAnswerId);return;} this.stopExamTimer(); const ask = this.askInfo(); const user = this.session.user(); const answers = this.answers(); const mu = { AskID: ask['ID'], Answer: JSON.stringify(answers), Remin: '', ZType: 0, Zstatus: 0, Source: '', UserID: user?.userId, CDate: this.examAttemptStamp, }; this.submitting.set(true); this.api.post('vote_add', { mu: JSON.stringify(mu) }).subscribe({ next: ({ result }) => {this.examAnswerId=result;this.saveExamLog(result);}, error: (error: ApiError) => { this.submitting.set(false);this.examSaveError.set(error.message); }, }); } restart(): void { if (this.isExamPage) { this.loadExam(); return; } this.load(); } leave(): void { this.clearLearningStorage(); void this.router.navigateByUrl('/pages/home/index', { replaceUrl: true }); } private loadNodes(): void { this.loading.set(true); this.error.set(''); this.nodes.set([]); this.selectedNodes.set(new Set()); const pid = this.isCatalogPage ? WORD_CATALOG_CATEGORIES[this.catalogCategoryIndex()].pid : this.query('nid') || this.query('pid'); const params: Record = { pid, cpage: 1, psize: 100 }; if (this.isUnitListPage) { const learner = this.canStartPointReading ? this.session.student() : this.session.user(); if (!learner?.userId) { this.loading.set(false); this.error.set('未找到当前学员,请返回学习记录重新选择学员'); return; } params['ifunit'] = 1; params['userId'] = learner.userId; params['bindingId'] = this.query('bindingId'); } this.api.get('node_list', params).pipe(finalize(() => this.loading.set(false))).subscribe({ next: (nodes) => { const rows = this.findList(nodes.result); if (this.isUnitListPage && !this.canStartPointReading && !rows.length && pid) { void this.router.navigate(['/pages/stu/stu_abc'], { queryParams: { nids: pid, readonly: 1 }, replaceUrl: true, }); return; } this.nodes.set(rows); }, error: (error: ApiError) => this.error.set(error.message || (this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试')), }); } loadMoreSelection():void{if(!this.selectionLoading()&&this.words().lengthp+1);this.loadSelection(true);}} selectNewWordBucket(bucket: 'pending' | 'completed'): void { if (this.activeSelectionBucket() === bucket) return; this.selectionRequest?.unsubscribe(); this.activeSelectionBucket.set(bucket); this.selectionPage.set(1); this.selectionTotal.set(this.newWordCounts()[bucket]); this.words.set([]); if (this.legacyNewWords) { this.showLegacyNewWordPage(); return; } this.loadSelection(); } goToNewWordPage(page: number): void { if (this.selectionLoading() || page < 1 || page > this.newWordPageCount() || page === this.selectionPage()) return; this.selectionPage.set(page); if (this.legacyNewWords) { this.showLegacyNewWordPage(); return; } this.loadSelection(); } private showLegacyNewWordPage(): void { const all = this.legacyNewWords ?? []; const pending = all.filter((word) => !this.newWordLearned(word)); const completed = all.filter((word) => this.newWordLearned(word)); const bucket = this.activeSelectionBucket() === 'completed' ? completed : pending; const start = (this.selectionPage() - 1) * this.newWordPageSize; this.newWordCounts.set({ pending: pending.length, completed: completed.length }); this.selectionTotal.set(bucket.length); this.words.set(bucket.slice(start, start + this.newWordPageSize)); for (const word of all) this.newWordSelectionCache.set(word.id, word); this.error.set(''); this.selectionLoading.set(false); this.loading.set(false); } private loadLegacyNewWords(uid: number | undefined, page = 1, collected: Word[] = []): void { const size = 500; this.selectionRequest = this.api.get('e_words_list', { uid, cpage: page, psize: size }).subscribe({ next: ({ result, page: pageInfo }) => { const incoming = this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word); const all = [...collected, ...incoming]; const total = Number(pageInfo?.itemCount ?? all.length); if (!Number.isFinite(total) || (!incoming.length && all.length < total)) { this.error.set('生词本数据未加载完整,请重试'); this.selectionLoading.set(false); this.loading.set(false); return; } if (all.length < total) { this.loadLegacyNewWords(uid, page + 1, all); return; } this.legacyNewWords = all; this.showLegacyNewWordPage(); }, error: (error: ApiError) => { this.error.set(error.message || '生词本加载失败,请重试'); this.selectionLoading.set(false); this.loading.set(false); }, }); } private loadSelection(more=false): void { const accept=(result:unknown,total:unknown)=>{const incoming=this.findList(result).map(item=>this.meta.path==='pages/member/starshop'?this.toNewWord(item):this.toWord(item)).filter(w=>w.word);this.words.set(more?[...this.words(),...incoming]:incoming);this.selectionTotal.set(Number(total)||this.words().length);}; 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('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') { this.api.post('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result, addon }) => { const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word); this.historyPreviewWords = words; this.words.set(words.slice(0, this.wordPreviewPageSize)); this.wordPreviewTotal.set(words.length); const sourceAddon = this.findList(addon)[0]; if (sourceAddon) { this.orderInfo.set(sourceAddon); if (sourceAddon['GeneralID']) this.api.get('app_lesson_study', {orderId: String(sourceAddon['GeneralID'])}).subscribe({next: ({result}) => this.historySources.set(lessonStudySources(result)), error: () => this.historySources.set([])}); } }, error: (error: ApiError) => this.error.set(error.message), }); return; } const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user(); this.selectionLoading.set(true); this.selectionRequest=this.api.get('e_words_list',{uid:owner?.userId,cpage:this.selectionPage(),psize:this.newWordPageSize,learningStatus:this.activeSelectionBucket()}).subscribe({ next: ({result,page}) => { const incoming = this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word); const pendingCount = Number(page?.['pendingCount']); const completedCount = Number(page?.['completedCount']); const total = Number(page?.itemCount ?? incoming.length); const validCounts = Number.isFinite(pendingCount) && Number.isFinite(completedCount) && pendingCount >= 0 && completedCount >= 0 && pendingCount + completedCount >= total && (incoming.length > 0 || total === 0 || this.selectionPage() > Math.ceil(total / this.newWordPageSize)); if (!validCounts) { this.loadLegacyNewWords(owner?.userId); return; } this.words.set(incoming); for (const word of incoming) this.newWordSelectionCache.set(word.id, word); const counts = {pending: pendingCount, completed: completedCount}; this.newWordCounts.set(counts); this.selectionTotal.set(total); this.error.set(''); this.selectionLoading.set(false); this.loading.set(false); }, error: (error: ApiError) => { failure(error); this.selectionLoading.set(false); this.loading.set(false); }, }); } private loadStageWords(): void { const nid = this.query('nid'); if (nid && this.isReadOnlyWordView) { this.loadReadOnlyWords(); return; } if (nid) { this.api.get('content_list', { nid, modelId: 52, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)), error: (error: ApiError) => this.error.set(error.message), }); return; } const stored = this.readLearningWords(); this.loading.set(false); if (!stored.length) { this.error.set('请先选择本次学习的单词'); return; } this.initializeStage(stored); } private loadReadOnlyWords(pageChange = false, previousPage = 1): void { const learner = this.session.student() ?? this.session.user(); this.api.get('e_ck_list', { checkwords: 2, nids: this.query('nid'), modelId: 52, cpage: this.wordPreviewPage(), psize: this.wordPreviewPageSize, uid: learner?.userId, }).pipe(finalize(() => { if (pageChange) this.wordPreviewLoading.set(false); else this.loading.set(false); })).subscribe({ next: ({ result, page }) => { const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word); this.words.set(words); this.wordPreviewTotal.set(Math.max(words.length, Number(page?.itemCount ?? 0))); if (pageChange) { setTimeout(() => document.getElementById('readonly-word-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' })); } }, error: (error: ApiError) => { if (pageChange) { this.wordPreviewPage.set(previousPage); this.platform.toast('该页单词加载失败,请重试', 'error'); return; } this.error.set(error.message); }, }); } private initializeStage(source: Word[]): void { if (!source.length) { this.error.set('本关暂无单词,请返回重新选择学习内容'); return; } const words = this.isStudyListPage ? source : this.shuffle(source); this.words.set(words); if (this.isStudyListPage) { this.studyStep.set(1); this.repetitions.set({}); } else if (this.isMatchingPage) { this.matchLeft.set(this.shuffle(words)); this.matchRight.set(this.shuffle(words)); } else if (this.isListeningPage) { this.prepareChoiceOptions(); setTimeout(() => this.play(), 120); } else if (this.isSpellingPage) { this.prepareSpelling(); setTimeout(() => this.play(), 120); } } private finishStudyRound(): void { if (this.studyStep() < 3) { const next = (this.studyStep() + 1) as 2 | 3; this.studyStep.set(next); this.words.set(shuffledStudy(this.words())); this.repetitions.set({}); this.platform.toast(next === 2 ? '进入乱序学习' : '进入中文挑战', 'success'); return; } this.navigateStage('/pages/stu/stu3'); } retryListeningChoices(): void { this.outsideChoicePool = null; this.prepareChoiceOptions(); } private prepareChoiceOptions(): void { this.choiceOptionsRequest?.unsubscribe(); const version = ++this.choiceOptionsVersion; const current = this.current(); if (!current) { this.choiceOptions.set([]); return; } const round = [...this.readAllLearningWords(), ...this.words()]; const apply = (pool: Word[]) => { if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return; this.choiceOptions.set(listeningChoices(current, round, pool, items => this.shuffle(items))); this.choicesLoading.set(false); }; if (this.outsideChoicePool !== null) { apply(this.outsideChoicePool); return; } this.choiceOptions.set([]); this.choicesLoading.set(true); this.choiceOptionsRequest = this.api.get('content_list', { modelId: 52, orders: 'NEWID()', cpage: 1, psize: 60, excludeWordIds: [...new Set(round.map(w => w.id))].join(',') }).subscribe({ next: ({ result }) => { if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return; this.outsideChoicePool = this.findList(result).map(item => this.toWord(item)); apply(this.outsideChoicePool); }, error: () => { apply([]); this.platform.toast('拓展选项暂未加载,本题使用已选词汇', 'info'); }, }); } private prepareSpelling(): void { const word = this.current(); this.assembled.set(''); this.spellingCorrect.set(false); this.letters.set(this.shuffle((word?.word ?? '').split('').map((value) => ({ value, used: false })))); } private finishSpellingFlow(): void { const currentPage = Number(this.storageGet('_page_current_learn') ?? 1); const mode = String(this.storageGet('learn_mode') ?? ''); if (mode === 'newword') { const learner = this.canStartPointReading ? this.session.student() : this.session.user(); this.submitting.set(true); this.persistWordsAsLearned(this.words(), learner).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => { if (this.hasNextWordBatch) { this.completed.set(false); this.storageSet('_page_current_learn', currentPage + 1); this.navigateStage('/pages/stu/stu_con'); return; } this.completed.set(false); this.clearLearningStorage(); void this.router.navigateByUrl('/pages/member/starshop', { replaceUrl: true }); }, error: (error: ApiError) => { this.completed.set(true); this.platform.toast(error.message || '学习结果保存失败,请重试', 'error'); }, }); return; } if (this.hasNextWordBatch) { this.completed.set(false); this.storageSet('_page_current_learn', currentPage + 1); this.navigateStage('/pages/stu/stu_con'); return; } if (mode === 'history_word') { this.completed.set(false); this.clearLearningStorage(); void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true }); } else if (this.meta.path === 'pages/stu/pl_stu5' || (this.isListeningPage && this.canStartPointReading && this.storageGet('skip_spelling_stage') === true)) { if (this.query('orderId')) { this.createPracticeLearningRecord(); } else { this.createStandalonePracticeLearningRecord(); } } else { this.navigateStage('/pages/stu/stu6'); } } private loadReviewWords(): void { const words = this.readAllLearningWords(); this.words.set(words); this.loading.set(false); if (!words.length) { this.error.set('请先选择本次学习的单词'); return; } const user = this.session.user(); this.api.get('content_list', { modelId: 56, psize: 2, myfield2: `UserId=${(this.canStartPointReading ? this.session.student() : user)?.userId}|dqrq=${this.compactDate()}`, }).subscribe({ next: ({ result }) => this.learnedRecord.set(this.findList(result)[0] ?? {}), error: (error:ApiError) => this.error.set(error.message), }); } private updateUnitRecord(): Observable { const nids = this.query('nids'); const bookId = this.query('pnid'); if (!nids || !bookId) return of(null); const wordIds: Record = {}; for (const nodeId of nids.split(',')) { wordIds[nodeId] = this.words().filter((word) => word.nodeId === nodeId).map((word) => word.wordId).join(','); } return this.api.post('unit_record_update', { nids, userId: (this.canStartPointReading ? this.session.student() : this.session.user())?.userId, bookId, wordIds: JSON.stringify(wordIds), }); } private saveLearningRecord(): void { const user = this.session.user(); const student = Number(user?.groupId) === 3 ? this.session.student() : null; const owner = student ?? user; const existing = this.learnedRecord(); const oldItems = this.parseArray(existing['xxqs']); const records = new Map(); for (const item of oldItems) records.set(String(item['GeneralID'] ?? ''), item); for (const word of this.words()) { records.set(word.wordId, { GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) }); } const xxqs = [...records.values()]; const learned = xxqs.length; const ygg = xxqs.filter((item) => Number(item['check']) === 1).length; const djq = xxqs.filter((item) => Number(item['check']) === 2).length; const content: SourceItem = { ModelID: 56, nodeId: 291, inputer: user?.honeyName, status: 99, Hits: learned, title: owner?.honeyName, }; let action = 'content_add_zt'; if (existing['GeneralID']) { content['GeneralID'] = existing['GeneralID']; content['UpDateTime'] = this.dateOnly(); action = 'content_update'; } const addon: SourceItem = { con: `完成了${learned}个单词的学习@${this.dateTime()}`, dqrq: this.compactDate(), pl: student ? user?.userId : 0, UserID: owner?.userId, learned, ygg, djq, xxqs: JSON.stringify(xxqs), }; if (!existing['xxqs']) addon['fxrl'] = ''; this.submitting.set(true); this.persistWordsAsLearned(this.words(), owner).pipe( switchMap(()=>this.updateUnitRecord()), switchMap(() => this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) })), finalize(() => this.submitting.set(false)), ).subscribe({ next: () => { this.platform.toast('提交成功', 'success'); void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') }, replaceUrl: true }); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private loadPracticeEnd(): void { const orderId = this.query('orderId') || this.query('id'); this.api.get('e_order_detail', {id: orderId}).pipe(switchMap(({result}) => { const order = this.findList(result)[0] ?? (result as SourceItem); const studentId = Number(order?.['szyh']); if (!studentId) return throwError(() => new ApiError('预约记录缺少学员信息')); this.orderInfo.set(order); if (this.session.student()?.userId !== studentId) this.session.setStudent({userId: studentId, groupId: 1, honeyName: String(order['HoneyName'] || '学员')}); return this.api.get('app_lesson_study', {orderId}); }), finalize(() => this.loading.set(false))).subscribe({ next: ({result}) => { this.lessonLearnedCount.set(Number(result.record?.learned || 0)); const batch = result.batches.find((b: any) => b.batchId === (this.query('batchId') || this.storageGet('batchId'))) || result.batches.find((b: any) => b.status === 'draft' && (!this.query('bindingId') || b.bindingId === this.query('bindingId'))); if (!batch) { this.error.set('未找到待确认批次,请返回课程查看已保存成果'); return; } this.storageSet('batchId', batch.batchId); this.storageSet('batchVersion', batch.version); this.orderInfo.update(order => ({...order, learningCourseId: batch.courseId || this.query('pnid') || this.query('kcid') || order['kcid']})); const words = batch.words.map((w: SourceItem) => this.toWord(w)); this.words.set(words); const local = (this.storageGet('roundReview:' + batch.batchId) || {}) as Record; this.reviewState.set(Object.fromEntries(words.map((w: Word) => [w.id, local[w.id] ?? Number(w['check']) ?? 0]))); }, error: (e: ApiError) => this.error.set(e.message), }); } private submitPracticeEnd(continueLearning = false): void { this.submitting.set(true); this.saveLessonBatch(this.query('mastery') ? 'mastery' : 'confirm', this.words()).pipe( switchMap(() => this.query('mastery') ? of(null) : this.persistWordsAsLearned(this.words(), this.session.student())), finalize(() => this.submitting.set(false)), ).subscribe({next: () => { this.clearLearningStorage(); this.platform.toast(`本轮已保存,本节累计 ${this.lessonLearnedCount()} 个词`, 'success'); void this.router.navigate(['/pages/shop/get_order'], {queryParams: {id: this.query('orderId') || this.query('id'), ...(continueLearning || this.query('mastery') ? {} : {feedback: '1'})}, replaceUrl: true}); }, error: (e: ApiError) => this.platform.toast(e.message, 'error')}); } private saveLessonBatch(mode: 'draft' | 'confirm' | 'mastery', words: Word[]): Observable { const batchId = String(this.storageGet('batchId') || crypto.randomUUID()); this.storageSet('batchId', batchId); const version = Number(this.storageGet('batchVersion') || 0); const xxqs = words.map(w => ({GeneralID: w.wordId, Title: w.word, check: mode !== 'draft' ? this.reviewValue(w) : 0})); return this.api.post('stu_record_update_v2', { orderId: this.query('orderId') || this.query('id'), uid: this.session.student()?.userId, batchId, saveMode: mode, expectedVersion: version, requestKey: this.draft().command('batch:' + batchId + ':' + version + ':' + mode + ':' + JSON.stringify(xxqs)), addon: JSON.stringify({bindingId: this.query('bindingId'), xxqs}), }).pipe(tap(({result}) => { this.storageSet('batchVersion', result.batchVersion); this.lessonLearnedCount.set(Number(result.learned || 0)); })); } private createPracticeLearningRecord(): void { this.submitting.set(true); this.saveLessonBatch('draft', this.readAllLearningWords()).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => this.navigateStage('/pages/stu/pl_end'), error: (e: ApiError) => this.platform.toast(e.message, 'error'), }); } private createStandalonePracticeLearningRecord(): void { const allWords = this.readAllLearningWords(); const user = this.session.user(); const student = this.session.student(); if (Number(user?.groupId) === 3 && !student?.userId) { this.platform.toast('请先选择学员', 'error'); return; } const owner = Number(user?.groupId) === 3 ? student : user; const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 })); const content: SourceItem = { ModelID: 56, nodeId: 291, inputer: user?.honeyName, status: 99, Hits: xxqs.length, title: owner?.honeyName, }; const addon: SourceItem = { con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`, dqrq: this.compactDate(), pl: Number(user?.groupId) === 3 ? user?.userId : 0, UserID: owner?.userId, learned: xxqs.length, ygg: 0, djq: 0, xxqs: JSON.stringify(xxqs), fxrl: '', createReviewPlan: false, bindingId: this.query('bindingId') || undefined, }; this.submitting.set(true); this.persistWordsAsLearned(allWords, owner).pipe( switchMap(() => this.api.post('content_add_zt', { content: JSON.stringify(content), addon: JSON.stringify(addon), })), finalize(() => this.submitting.set(false)), ).subscribe({ next: () => { this.clearLearningStorage(); this.platform.toast('学习记录已保存', 'success'); void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true }); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private loadRecord(): void { const user = this.session.user(); const learnerId = Number(user?.groupId) === 3 ? this.session.student()?.userId : user?.userId; const recordId = this.query('id'); const detailParams = { id: recordId, studentId: learnerId }; const hasWords = (result: unknown) => this.findList(result).some((item) => this.toWord(item).word); let originalRecord: SourceItem | undefined; this.api.post('e_record_detail', detailParams).pipe( switchMap((detail) => hasWords(detail.result) ? of(detail) : this.api.get('content_get', { id: recordId }).pipe( map(({ result }) => { originalRecord = this.findList(result)[0]; return { ...detail, result: this.parseArray(originalRecord?.['xxqs']) }; }), catchError(() => of(detail)), )), switchMap((detail) => hasWords(detail.result) ? of(detail) : this.api.post('app_learning_overview', { studentId: learnerId, page: 1, pageSize: 1, refresh: true, }).pipe( switchMap(() => this.api.post('e_record_detail', detailParams)), catchError(() => of(detail)), )), finalize(() => this.loading.set(false)), ).subscribe({ next: ({ result, addon }) => { const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word); if (!words.length) { const wordless = originalRecord && 'xxqs' in originalRecord && !this.parseArray(originalRecord['xxqs']).length && Number(originalRecord['learned'] ?? 0) === 0; this.recordWordless.set(Boolean(wordless)); const writingRecord = wordless && String(originalRecord?.['con'] ?? '').includes('写作'); this.error.set(wordless ? `${writingRecord ? '这条任务关联的是写作学习记录,原本没有单词' : '这条任务关联的原学习记录没有保存单词'},无法进行单词复习。任务进度不会被修改。` : '这条复习任务暂时没有读取到单词,请点击重新加载;任务进度不会被修改。'); return; } this.words.set(words); const key = `xiaoshu.review.order.${learnerId}.${this.query('id')}`; let previous: string[][] = []; try { const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]'); if (Array.isArray(stored)) previous = stored.filter((order): order is string[] => Array.isArray(order) && order.every((id) => typeof id === 'string')); } catch { /* A damaged or unavailable local cache should not block review. */ } try { const ordered = nextReviewWordOrder(words, previous, (word) => word.id); this.words.set(ordered.words); try { localStorage.setItem(key, JSON.stringify(ordered.history)); } catch { /* Review still works without local storage. */ } } catch { /* Keep the original words visible if ordering fails. */ } this.orderInfo.set(this.findList(addon)[0] ?? {}); }, error: (error: ApiError) => this.error.set(error.message), }); } turnReviewPage(delta:number):void{this.reviewListPage.update(p=>Math.max(1,Math.min(this.reviewPageData()?.page.pageCount||1,p+delta)));this.loadCalendar();} retryCalendar(): void { this.loadCalendar(false, true); } private reviewCache(): Map { const identity = `${this.session.token()}|${this.session.user()?.userId ?? ''}|${this.dateOnly()}|${this.api.dataRevision ?? 0}`; let cache = LearningPageComponent.reviewCacheByApi.get(this.api); if (!cache || cache.identity !== identity) { cache = { identity, pages: new Map() }; LearningPageComponent.reviewCacheByApi.set(this.api, cache); } return cache.pages; } private loadCalendar(initial = false, force = false): void { const requestId=++this.calendarRequestId; this.calendarRequest?.unsubscribe(); this.calendarError.set(''); const date = this.memorySelectedDate() || this.dateOnly(); const key = `${this.memoryStudentId()}|${date}|${this.reviewListPage()}|${this.reviewScope()}`; const cache = this.reviewCache(); const cached = force ? undefined : cache.get(key); if (cached) { this.reviewPageData.set(cached);this.calendarItems.set(cached.items);this.restoreReviewScroll(); if(!this.memorySelectedDate())this.memorySelectedDate.set(date); this.loading.set(false);this.calendarLoading.set(false); return; } if (initial) this.loading.set(true); else this.calendarLoading.set(true); this.calendarRequest = this.api.post('app_review_page',{uid:this.session.user()?.userId,studentId:this.memoryStudentId()||undefined,date,scope:this.reviewScope()||undefined,page:this.reviewListPage(),pageSize:20}).pipe(finalize(()=>{ if(requestId!==this.calendarRequestId)return; if(initial)this.loading.set(false);else this.calendarLoading.set(false); })).subscribe({next:({result})=>{ if(requestId!==this.calendarRequestId)return; cache.set(key,result); this.reviewPageData.set(result);this.calendarItems.set(result.items);this.restoreReviewScroll(); if(!this.memorySelectedDate())this.memorySelectedDate.set(this.dateOnly()); },error:(e:ApiError)=>{ if(requestId!==this.calendarRequestId)return; if(initial)this.error.set(e.message);else this.calendarError.set(e.message); }}); } private calendarRequestId=0; private restoredReviewScroll=false; private restoreReviewScroll():void{if(this.restoredReviewScroll)return;this.restoredReviewScroll=true;const top=Number(history.state?.reviewScroll);if(Number.isFinite(top)&&top>0)setTimeout(()=>window.scrollTo({top,behavior:'instant'}));} private loadAntiForget(): void { const user = this.session.user(); const date = this.query('date') || this.compactDate(); this.antiStartedAt = Date.now(); this.api.get('content_list', { nid: 76, modelId: 54, myfield: `fxrl=${date}`, myfield2: `UserId=${user?.userId}`, cpage: 1, psize: 30, orders: 'CreateTime=asc', }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result }) => { const words: Word[] = []; for (const record of this.findList(result)) { for (const item of this.parseArray(record['xxqs'])) words.push(this.toWord({ ...item, recordId: record['GeneralID'] })); } this.words.set(words.filter((word) => word.word)); }, error: (error: ApiError) => this.error.set(error.message), }); if (this.query('hid')) { this.api.get('content_get', { id: this.query('hid') }).subscribe({ next: ({ result }) => this.antiHistory.set(this.findList(result)[0] ?? {}), error: () => undefined, }); } this.api.get('content_list', { nid: 255, modelId: 55, cpage: 1, psize: 30, myfield2: `UserId=${user?.userId}`, orders: 'CreateTime=asc' }).subscribe({ next: ({ result }) => this.myNewWords.set(this.findList(result)[0] ?? {}), error: () => undefined, }); } private updateAntiProgress(word: Word): void { const historyId = this.query('hid'); if (!historyId) return; const seenWords = this.words().filter((item) => this.seen().has(item.id)); const previousIds = String(this.antiHistory()['xxjlid'] ?? '').split(',').filter(Boolean); const recordId = String(word['recordId'] ?? ''); if (recordId && !previousIds.includes(recordId)) previousIds.push(recordId); const scqs = seenWords.map((item) => ({ GeneralID: item.wordId, Title: item.word, gldy: item['gldy'] })); const wordIds = `,${scqs.map((item) => item.GeneralID).join(',')},`; this.api.post('content_update', { content: JSON.stringify({ GeneralID: historyId }), addon: JSON.stringify({ xxjlid: previousIds.join(','), scqs: JSON.stringify(scqs), yxx: scqs.length, scqs2: wordIds, times: Math.round((Date.now() - this.antiStartedAt) / 1000), num: this.words().length, }), }).subscribe({ error: () => undefined }); } loadExamHistory(more = false): void { if(more && this.examHistoryLoading())return; this.examHistoryRequest?.unsubscribe();this.examHistoryLoading.set(true);this.examHistoryError.set(''); const page=more?this.examHistoryPage+1:1; this.examHistoryRequest=this.api.get<{items:SourceItem[];hasMore:boolean}>('app_exam_history',{page}).pipe(finalize(()=>this.examHistoryLoading.set(false))).subscribe({ next:({result})=>{ if(!result || !Array.isArray(result.items)){this.examHistoryError.set('测试记录返回异常,请重试');return;} this.examHistoryPage=page;this.examHistoryMore.set(result.hasMore); this.examHistory.set(more?[...this.examHistory(),...result.items]:result.items); if(!more){this.previousExam.set(result.items.find(r=>r['resultAvailable'])||{});this.examCompareIds.set(result.items.filter(r=>r['resultAvailable']).slice(0,2).map(r=>String(r['GeneralID'])));} },error:(e:ApiError)=>this.examHistoryError.set(e.message), }); } toggleExamComparison(item:SourceItem):void { const id=String(item['GeneralID']);if(!item['resultAvailable'])return; this.examCompareIds.update(ids=>ids.includes(id)?ids.filter(x=>x!==id):[...ids.slice(-1),id]); } private loadExam(): void { this.stopExamTimer();this.examLoadRequest?.unsubscribe();this.examStarted.set(false);this.examSubmitted.set(false);this.examSaveError.set(''); this.loading.set(true);this.error.set('');this.questions.set([]);this.turnAskId(); const askId = Number(this.storageGet('askId') ?? 1); this.loadExamHistory(); this.examLoadRequest=forkJoin({ask:this.api.get('vote_ask',{id:askId}),questions:this.api.get('vote_question',{askid:askId})}).pipe(finalize(()=>this.loading.set(false))).subscribe({ next:({ask,questions})=>{this.askInfo.set(this.findList(ask.result)[0]??{});const items=this.shuffle(this.findList(questions.result).map(item=>this.toQuestion(item)));this.questions.set(items);this.answers.set(items.map(q=>({qid:q.ID,qtype:String(q.QType??''),answer:'',result:2})));}, error:(e:ApiError)=>this.error.set(e.message), }); } private turnAskId(): void { const current = Number(this.storageGet('askId') ?? 0); this.storageSet('askId', current === 1 ? 2 : 1); } examScoreValue(exam: SourceItem): number { return Number(exam['df'] ?? 0); } examAnalysis(exam: SourceItem): string { const score = this.examScoreValue(exam); if (score === 0) return '当前处于零基础阶段,可以从最常用的基础词汇开始建立词汇库。'; if (score <= 2) return '词汇量约为 100-200,处于英语入门或小学一、二年级水平。'; if (score <= 6) return '词汇量约为 200-400,相当于小学三、四年级水平。'; if (score <= 12) return '词汇量约为 400-600,相当于小学五、六年级水平。'; if (score <= 15) return '词汇量约为 600-900,相当于初一水平。'; if (score <= 18) return '词汇量约为 1000-1500,相当于初二水平。'; if (score <= 21) return '词汇量约为 1600-1800,相当于初三水平。'; if (score <= 33) return '词汇量约为 1800-2500,相当于高一水平。'; if (score <= 39) return '词汇量约为 2500-3000,相当于高二水平。'; if (score <= 42) return '词汇量约为 3000-3500,相当于高三一般水平。'; return '词汇量约为 3500-4500,达到高三较高或大学四级水平。'; } examAdvice(exam: SourceItem): string { const score = this.examScoreValue(exam); if (score <= 6) return '建议先系统学习自然拼读并背诵小学、初中基础词汇,打牢读、认、写基础。'; if (score <= 12) return '建议重点背诵初中词汇,并通过简单阅读和听力练习持续扩展词汇量。'; if (score <= 21) return '建议继续背诵初高中核心词汇,配合英文阅读、听力和写作练习巩固运用。'; if (score <= 39) return '建议学习高二、高三词汇,并通过时文外刊、国家地理和 TED 演讲提升理解与表达。'; return '建议拓展大学四、六级或雅思托福词汇,通过 BBC、VOA 和英文原著把新词用于真实语境。'; } private setExamAnswer(answer: ExamAnswer): void { this.answers.update((items) => items.map((item, index) => index === this.currentIndex() ? answer : item)); } private advanceExam(): void { if (this.currentIndex() >= this.questions().length - 1) { this.submitExam(); return; } this.currentIndex.update((value) => value + 1); this.startExamTimer(); } private startExamTimer(): void { this.stopExamTimer(); this.timer.set(10); this.examTimer = setInterval(() => { if (this.timer() <= 1) { this.skipExamQuestion(); } else { this.timer.update((value) => value - 1); } }, 1000); } private stopExamTimer(): void { if (this.examTimer) clearInterval(this.examTimer); this.examTimer = null; } private saveExamLog(answerId: string | number): void { const user = this.session.user(); const answers = this.answers(); const score = answers.filter((item) => item.result === 1).length; const previous = this.previousExam(); this.examScore.set(score); const content: SourceItem = { ModelID: 61, nodeId: 389, inputer: user?.honeyName, status: 99, Hits: 1, title: user?.honeyName }; const addon = { askid: this.currentQuestion()?.AskID ?? this.askInfo()['ID'], prev_score: previous['df'] ?? 0, totalScore: answers.length, wrong: answers.filter((item) => item.result === 0).length, dontKnow: answers.filter((item) => item.result === 2).length, answerid: answerId || 0, df: score, UserID: user?.userId, }; this.api.post('content_add', { requestKey:this.examLogKey,content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => { this.examStarted.set(false); this.examSubmitted.set(true); this.platform.toast('答题结果已保存', 'success');this.loadExamHistory(); }, error: (error: ApiError) => this.examSaveError.set(error.message), }); } private beginSelectedFlow(chosen: Word[], mode: '' | 'newword' | 'history_word'): void { if (this.query('orderId') && !mode) { this.submitting.set(true); this.api.get('app_lesson_study', {orderId: this.query('orderId')}).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: ({result}) => { const pending = result.batches.find((b: any) => b.status === 'draft' && (!this.query('bindingId') || b.bindingId === this.query('bindingId'))); if (pending) { this.storageSet('batchId', pending.batchId); this.storageSet('batchVersion', pending.version); this.platform.toast('还有待确认批次,请先确认掌握状态'); this.navigateStage('/pages/stu/pl_end'); return; } this.clearLearningStorage(); this.submitting.set(true); this.saveLessonBatch('draft', chosen).pipe(finalize(() => this.submitting.set(false))).subscribe({next: () => this.beginSelectedStages(chosen, mode, true),error: (e: ApiError) => this.platform.toast(e.message, 'error')}); }, error: (e: ApiError) => this.platform.toast(e.message, 'error'), }); return; } this.beginSelectedStages(chosen, mode); } private beginSelectedStages(chosen: Word[], mode: '' | 'newword' | 'history_word', preserveBatch = false): void { if (!preserveBatch) this.clearLearningStorage(); if (mode) this.storageSet('learn_mode', mode); const pages: Word[][] = []; for (let index = 0; index < chosen.length; index += 6) pages.push(chosen.slice(index, index + 6)); const storageKey = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn'; this.storageSet(storageKey, pages[0] ?? chosen); this.storageSet('_page_current_learn', 1); this.storageSet('_page_total_page', pages.length); this.storageSet('_page_word_orilist', pages); sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(pages[0] ?? chosen)); void this.router.navigate(['/pages/stu/stu_con'], { replaceUrl: true, state: { learningReturnUrl: this.router.url }, queryParams: { nids: this.query('nids') || this.query('nid'), pnid: this.query('pnid'), bindingId: this.query('bindingId'), orderId: this.query('orderId'), }, }); } private navigateStage(path: string): void { void this.router.navigate([path], { replaceUrl: true, state: { learningReturnUrl: history.state?.learningReturnUrl }, queryParams: { nid: this.query('nid'), nids: this.query('nids'), pnid: this.query('pnid'), bindingId: this.query('bindingId'), orderId: this.query('orderId') || this.query('id'), }, }); } private readLearningWords(): Word[] { const currentPage = Number(this.storageGet('_page_current_learn') ?? 1); const pages = this.parseArray(this.storageGet('_page_word_orilist')); if (pages.length) { const page = pages[currentPage - 1]; if (Number.isInteger(currentPage) && currentPage > 0 && Array.isArray(page)) { const mode = String(this.storageGet('learn_mode') ?? ''); const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn'; this.storageSet(key, page); sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(page)); return page.map((item) => this.toWord(item as SourceItem)); } return []; } const mode = String(this.storageGet('learn_mode') ?? ''); const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn'; const stored = this.parseArray(this.storageGet(key)); if (stored.length) return stored.map((item) => this.toWord(item)); return this.parseArray(sessionStorage.getItem(this.learningStorageKey('selectedWords'))).map((item) => this.toWord(item)); } private readAllLearningWords(): Word[] { const pages = this.parseArray(this.storageGet('_page_word_orilist')); if (pages.length && pages.every((page) => Array.isArray(page))) { return pages.flatMap((page) => (page as SourceItem[]).map((item) => this.toWord(item))); } return this.readLearningWords(); } private clearLearningStorage(): void { this.draft().clear(); } private resetPageState(): void { this.outsideChoicePool = null; this.choicesLoading.set(false); this.choiceOptionsVersion++; this.choiceOptionsRequest?.unsubscribe(); this.stopExamTimer(); this.nodes.set([]); this.selectedNodes.set(new Set()); this.words.set([]); this.historyPreviewWords = []; this.historySources.set([]); this.wordPreviewPage.set(1); this.wordPreviewTotal.set(0); this.wordPreviewLoading.set(false); this.calendarLoading.set(false); this.calendarError.set(''); this.selected.set(new Set()); this.newWordSelectionCache.clear(); this.newWordCounts.set({ pending: 0, completed: 0 }); this.legacyNewWords = null; 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); this.correct.set(0); this.unknown.set([]); this.studyStep.set(1); this.repetitions.set({}); this.matchLeft.set([]); this.matchRight.set([]); this.leftChoice.set(''); this.rightChoice.set(''); this.choiceOptions.set([]); this.reviewState.set({}); this.seen.set(new Set()); this.recordRevealed.set(new Set()); this.calendarItems.set([]); if(!this.isCalendarPage)this.memoryStudentId.set(''); this.reviewCompletion.set(null); this.examStarted.set(false); this.examSubmitted.set(false); } private toWord(item: SourceItem): Word { const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail']; const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {}; const source = { ...detail, ...item }; const id = String(item['GeneralID'] ?? detail['GeneralID'] ?? item['ID'] ?? item['id'] ?? item['gid'] ?? item['Title'] ?? detail['Title'] ?? ''); const wordId = String(detail['GeneralID'] ?? item['GeneralID'] ?? item['gldy'] ?? id); return { ...source, detail, id, wordId, nodeId: String(item['NodeID'] ?? detail['NodeID'] ?? item['nodeId'] ?? ''), word: String(item['Title'] ?? detail['Title'] ?? item['word'] ?? detail['word'] ?? item['Word'] ?? ''), meaning: String(item['sy'] ?? detail['sy'] ?? item['jssy'] ?? detail['jssy'] ?? item['Translate'] ?? detail['Translate'] ?? item['Meaning'] ?? ''), phonetic: String(item['yb'] ?? detail['yb'] ?? item['Phonetic'] ?? detail['Phonetic'] ?? ''), audio: String(item['yp'] ?? detail['yp'] ?? item['dy'] ?? detail['dy'] ?? ''), }; } private toNewWord(item: SourceItem): Word { const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail']; const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {}; const detailTitle = String(detail['Title'] ?? '').trim(); const storedTitle = String(item['Title'] ?? '').split('@')[0].trim(); return this.toWord({ ...item, Title: detailTitle || storedTitle }); } private toQuestion(item: SourceItem): ExamQuestion { let options: ExamOption[] = []; const raw = item['QOption']; if (Array.isArray(raw)) { options = raw as ExamOption[]; } else if (typeof raw === 'string') { try { options = JSON.parse(raw) as ExamOption[]; } catch { options = []; } } return { ...item, ID: String(item['ID'] ?? item['GeneralID'] ?? ''), AskID: String(item['AskID'] ?? ''), QType: String(item['QType'] ?? ''), QTitle: String(item['QTitle'] ?? ''), QContent: this.cleanText(String(item['QContent'] ?? '')), QOption: options, }; } private wordAudioUrl(path: string): string { if (/^(data:|https?:\/\/|\/)/i.test(path)) return this.api.assetUrl(path); return this.api.assetUrl(/^UploadFiles\//i.test(path) ? path : `UploadFiles/${path}`); } private setWordSelected(word: Word, shouldSelect: boolean): boolean { const current = this.selected(); if (shouldSelect && !current.has(word.id) && current.size >= this.selectionLimit) { this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error'); return false; } const next = new Set(current); if (shouldSelect) next.add(word.id); else next.delete(word.id); this.selected.set(next); return true; } private setSelectionAssessment(word: Word, assessment: 'known' | 'unknown'): void { this.selectionAssessment.update((current) => ({ ...current, [word.id]: assessment })); } private clearSelectionAssessment(word: Word): void { this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => id !== word.id)) as Record); } private persistWordsAsLearned(words: Word[], owner: AppUser | null | undefined): Observable { if (!words.length) return of(null); return this.api.post('e_add_words', { uid: owner?.userId, wordsId: words.map((word) => word.wordId).join(','), kcid: (this.meta.path === 'pages/stu/pl_end' ? this.orderInfo()['learningCourseId'] : '') || this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'], save: 1, requestKey:this.draft().command('learn:'+words.map(w=>w.wordId).sort().join(',')), }); } private commitKnownWords(words: Word[]): void { 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); } private findList(value: unknown): SourceItem[] { if (Array.isArray(value)) return value as SourceItem[]; if (value && typeof value === 'object') { for (const nested of Object.values(value)) { if (Array.isArray(nested)) return nested as SourceItem[]; if (nested && typeof nested === 'object') { for (const child of Object.values(nested)) if (Array.isArray(child)) return child as SourceItem[]; } } } return []; } private parseArray(value: unknown): SourceItem[] { if (Array.isArray(value)) return value as SourceItem[]; if (typeof value !== 'string' || !value) return []; try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed as SourceItem[] : []; } catch { return []; } } private shuffle(items: T[]): T[] { const copy = [...items]; for (let index = copy.length - 1; index > 0; index -= 1) { const target = Math.floor(Math.random() * (index + 1)); [copy[index], copy[target]] = [copy[target], copy[index]]; } return copy; } private draft():LearningDraft { const learner=this.canStartPointReading?this.session.student():this.session.user(); return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${learner?.userId}.${this.query('orderId') ? this.query('orderId') + '.' + (this.query('bindingId') || 'unbound') : this.query('bindingId') || 'practice'}`); } private learningStorageKey(key:string):string{return this.draft().key(key);} private storageGet(key:string):unknown{return this.draft().get(key);} private storageSet(key:string,value:unknown):void{this.draft().set(key,value);} private query(name: string): string { return this.route.snapshot.queryParamMap.get(name) ?? ''; } private cleanText(value: string): string { return value.replace(/<\/?[^>]*>/g, '').replace(/ /gi, '').trim(); } private dateOnly(): string { const date = new Date(); return `${date.getFullYear()}-${this.pad(date.getMonth() + 1)}-${this.pad(date.getDate())}`; } private compactDate(): string { return this.dateOnly().replaceAll('-', ''); } private dateTime(seconds = false): string { const date = new Date(); return `${this.dateOnly()} ${this.pad(date.getHours())}:${this.pad(date.getMinutes())}${seconds ? `:${this.pad(date.getSeconds())}` : ''}`; } private pad(value: number): string { return String(value).padStart(2, '0'); } }