import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router } 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, of, switchMap } from 'rxjs'; import { GAME_AUDIO } from '../../core/app.constants'; 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 { PlatformService } from '../../core/platform.service'; import { SessionService } from '../../core/session.service'; 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; } 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: [CommonModule, LucideAngularModule], templateUrl: './learning-page.component.html', styleUrl: './learning-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class LearningPageComponent implements OnInit, OnDestroy { 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 selected = signal>(new Set()); readonly selectionRevealed = signal>(new Set()); readonly selectionAssessment = signal>({}); readonly currentIndex = signal(0); readonly revealed = signal(false); readonly completed = signal(false); readonly correct = signal(0); readonly unknown = signal([]); readonly loading = signal(false); readonly submitting = signal(false); readonly error = signal(''); readonly studyStep = signal<1 | 2 | 3>(1); readonly repetitions = signal>({}); readonly matchLeft = signal([]); readonly matchRight = signal([]); readonly leftChoice = signal(''); readonly rightChoice = signal(''); 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 memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12)); readonly memorySelectedDate = signal(''); readonly orderInfo = signal({}); readonly learnedRecord = signal({}); readonly plRecord = signal({}); readonly antiHistory = signal({}); readonly myNewWords = signal({}); readonly examStarted = signal(false); readonly examSubmitted = signal(false); readonly askInfo = signal({}); 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 currentQuestion = computed(() => this.questions()[this.currentIndex()] ?? null); readonly memoryView = computed(() => buildAntiForgetView(this.calendarItems())); readonly memorySummary = computed(() => this.memoryView().summary); readonly memoryCalendar = computed(() => buildAntiForgetCalendar( this.calendarItems(), this.memoryMonth(), this.memorySelectedDate(), )); readonly memorySelectedTasks = computed(() => antiForgetTasksOnDate(this.calendarItems(), 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 memoryCompletedCount = computed(() => this.calendarItems().filter(antiForgetCompleted).length); readonly memoryDueCount = computed(() => this.calendarItems().filter((item) => !antiForgetCompleted(item) && antiForgetDate(item) <= this.dateOnly()).length); readonly memoryProgress = computed(() => this.calendarItems().length ? Math.round((this.memoryCompletedCount() / this.calendarItems().length) * 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 platform = inject(PlatformService); private examTimer: ReturnType | null = null; private antiStartedAt = Date.now(); ngOnInit(): void { if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.canStartPointReading) { 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; } this.load(); } ngOnDestroy(): void { this.stopExamTimer(); 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 isRefineSelectionPage(): boolean { return this.meta.path === 'pages/stu/stu_abc'; } get isStudyListPage(): boolean { return this.meta.path === 'pages/stu/stu_con'; } get canStartPointReading(): boolean { return canStartTeacherLedLearning(this.session.groupId()); } get isReadOnlyWordView(): boolean { if (this.canStartPointReading) return false; return this.meta.path === 'pages/his/his_words' || (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 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 { return this.meta.path === 'pages/stu/stu_abc' ? 30 : this.words().length; } load(): void { this.resetPageState(); this.loading.set(true); this.error.set(''); 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(); 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_con'], { queryParams: { nid: id, pnid: this.query('nid'), readonly: 1 }, }); return; } this.toggleNode(node); } 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'), 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 { const shouldSelect = !this.selected().has(word.id); if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return; this.setSelectionAssessment(word, shouldSelect ? 'unknown' : 'known'); } selectionWordRevealed(word: Word): boolean { return this.selectionRevealed().has(word.id); } 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 { this.setWordSelected(word, false); this.setSelectionAssessment(word, 'known'); } markWordUnknown(word: Word): void { if (this.setWordSelected(word, true)) this.setSelectionAssessment(word, 'unknown'); } toggleAllWords(): void { 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 { const chosen = this.words().filter((word) => this.selected().has(word.id)); if (!chosen.length) { this.platform.toast('请至少选择一个单词', '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.api.post('e_add_words', { uid: student.userId, wordsId: chosen.map((word) => word.wordId).join(','), kcid: this.query('pnid'), save: 1, }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => this.beginSelectedFlow(chosen, ''), 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.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: 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 (!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(this.shuffle(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.play(this.api.assetUrl(GAME_AUDIO.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 { const word = this.current(); if (!word) return; if (option.id !== word.id) { this.platform.play(this.api.assetUrl(GAME_AUDIO.error)); this.platform.toast('答错了,请重新听一遍', 'error'); this.play(word); return; } this.correct.update((value) => value + 1); this.platform.play(this.api.assetUrl(GAME_AUDIO.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.play(this.api.assetUrl(GAME_AUDIO.success)); this.platform.toast('拼写正确', 'success'); return; } this.platform.play(this.api.assetUrl(GAME_AUDIO.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(); } continueAfterStage(): void { this.completed.set(false); if (this.isMatchingPage) { this.navigateStage('/pages/stu/stu4'); } else if (this.isListeningPage) { const path = Number(this.session.user()?.groupId) === 3 ? '/pages/stu/pl_stu5' : '/pages/stu/stu5'; this.navigateStage(path); } 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 })); } 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); } reviewCount(state: 1 | 2): number { return this.words().filter((word) => this.reviewValue(word) === state).length; } submitReview(): void { if (this.words().some((word) => this.reviewValue(word) === 0)) { this.platform.toast('请为每个单词选择掌握情况', 'error'); return; } if (this.meta.path === 'pages/stu/pl_end') { this.submitPracticeEnd(); return; } this.updateUnitRecord(); 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 { const now = this.dateTime(); this.submitting.set(true); this.api.post('content_update', { content: JSON.stringify({ ModelID: 60, nodeId: 388, status: 99, GeneralID: this.query('gid'), UpDateTime: now }), addon: JSON.stringify({ fxzt: 1, wcsj: now }), }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => void this.router.navigateByUrl('/pages/stu/stu_calendar'), error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } 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 { this.memorySelectedDate.set(day.date); 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.calendarItems().map(antiForgetDate).filter((date) => date.startsWith(monthKey)).sort()[0]; this.memorySelectedDate.set(firstTask || `${monthKey}-01`); } showCurrentMemoryMonth(): void { const today = this.dateOnly(); const [year, month] = today.split('-').map(Number); this.memoryMonth.set(new Date(year, month - 1, 1, 12)); this.memorySelectedDate.set(today); } memoryTaskStatus(item: SourceItem): string { if (this.calendarCompleted(item)) return '已完成'; return this.calendarReady(item) ? '待复习' : '未到期'; } isWritingRecord(): boolean { return String(this.orderInfo()['if_writing'] ?? '') === '1'; } openCalendar(item: SourceItem): void { if (!this.calendarReady(item) && !this.calendarCompleted(item)) { this.platform.toast('还未到复习时间'); return; } this.api.post('user_get', { uid: item['yhid'] }).subscribe({ next: ({ result }) => { const student = (Array.isArray(result) ? result[0] : result) as AppUser; this.session.setStudent(student); void this.router.navigate(['/pages/stu/record'], { queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'] }, }); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } startExam(): void { if (!this.questions().length) return; this.examStarted.set(true); this.examSubmitted.set(false); this.currentIndex.set(0); this.startExamTimer(); } answerExam(option: ExamOption, optionIndex: number): void { 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 { const question = this.currentQuestion(); if (!question) return; this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 }); this.advanceExam(); } submitExam(): void { 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.dateTime(true), }; this.submitting.set(true); this.api.post('vote_add', { mu: JSON.stringify(mu) }).subscribe({ next: ({ result }) => this.saveExamLog(result), error: (error: ApiError) => { this.submitting.set(false); this.platform.toast(error.message, 'error'); }, }); } restart(): void { if (this.isExamPage) { this.loadExam(); return; } this.load(); } leave(): void { this.clearLearningStorage(); void this.router.navigateByUrl('/pages/home/index'); } 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) { params['ifunit'] = 1; params['userId'] = this.session.user()?.userId; params['bindingId'] = this.query('bindingId'); } this.api.get('node_list', params).pipe( switchMap(({ result }) => { const nodes = this.findList(result); if (!this.isUnitListPage || !nodes.length) return of(nodes); const learner = this.session.student() ?? this.session.user(); const nodeIds = nodes.map((node) => this.nodeId(node)).filter(Boolean); if (!learner?.userId || !nodeIds.length) return of(nodes); return this.api.get('e_ck_list', { checkwords: 2, nids: nodeIds.join(','), modelId: 52, cpage: 1, psize: 10000, uid: learner.userId, }).pipe( map(({ result: words }) => this.mergeUnitProgress(nodes, this.findList(words))), catchError(() => of(nodes)), ); }), finalize(() => this.loading.set(false)), ).subscribe({ next: (nodes) => this.nodes.set(nodes), error: () => this.error.set(this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试'), }); } private mergeUnitProgress(nodes: SourceItem[], words: SourceItem[]): SourceItem[] { const totals = new Map(); const learned = new Map(); for (const word of words) { const nodeId = String(word['NodeID'] ?? word['nodeId'] ?? ''); if (!nodeId) continue; totals.set(nodeId, (totals.get(nodeId) ?? 0) + 1); if (Number(word['w_learned'] ?? 0) > 0) learned.set(nodeId, (learned.get(nodeId) ?? 0) + 1); } return nodes.map((node) => { const nodeId = this.nodeId(node); return { ...node, total: Math.max(Number(node['total'] ?? 0), totals.get(nodeId) ?? 0), yx_word: Math.max(Number(node['yx_word'] ?? 0), learned.get(nodeId) ?? 0), }; }); } private loadSelection(): void { if (this.meta.path === 'pages/stu/stu_abc') { const student = this.session.student() ?? this.session.user(); this.api.get('e_ck_list', { checkwords: 2, nids: this.query('nids') || this.query('nid'), modelId: 52, cpage: 1, psize: 300, uid: student?.userId, }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result }) => this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)), error: (error: ApiError) => this.error.set(error.message), }); return; } if (this.meta.path === 'pages/his/his_words') { this.api.get('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result, addon }) => { this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)); const sourceAddon = this.findList(addon)[0]; if (sourceAddon) this.orderInfo.set(sourceAddon); }, error: (error: ApiError) => this.error.set(error.message), }); return; } const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user(); this.api.get('e_words_list', { uid: owner?.userId, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result }) => this.words.set(this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word)), error: (error: ApiError) => this.error.set(error.message), }); } private loadStageWords(): void { const nid = this.query('nid'); if (nid && this.isReadOnlyWordView) { const learner = this.session.student() ?? this.session.user(); this.api.get('e_ck_list', { checkwords: 2, nids: nid, modelId: 52, cpage: 1, psize: 500, uid: learner?.userId, }).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; } 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 initializeStage(source: Word[]): void { 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(next === 2 ? [...this.words()].reverse() : this.shuffle(this.words())); this.repetitions.set({}); this.platform.toast(next === 2 ? '进入乱序学习' : '进入中文挑战', 'success'); return; } this.navigateStage('/pages/stu/stu3'); } private prepareChoiceOptions(): void { const current = this.current(); if (!current) { this.choiceOptions.set([]); return; } const distractors = this.shuffle(this.words().filter((word) => word.id !== current.id)).slice(0, 3); this.choiceOptions.set(this.shuffle([current, ...distractors])); if (distractors.length < 3) { this.api.get('content_list', { modelId: 52, orders: 'NEWID()', cpage: 1, psize: 8 }).subscribe({ next: ({ result }) => { const extras = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.id !== current.id && !distractors.some((item) => item.id === word.id)); this.choiceOptions.set(this.shuffle([current, ...distractors, ...extras]).slice(0, 4)); }, error: () => undefined, }); } } 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 totalPages = Number(this.storageGet('_page_total_page') ?? 0); const currentPage = Number(this.storageGet('_page_current_learn') ?? 0); if (totalPages > currentPage) { this.storageSet('_page_current_learn', currentPage + 1); this.navigateStage('/pages/stu/stu_con'); return; } const mode = String(this.storageGet('learn_mode') ?? ''); if (mode === 'newword') { void this.router.navigateByUrl('/pages/member/starshop'); } else if (mode === 'history_word') { void this.router.navigateByUrl('/pages/his/his'); } else if (this.meta.path === 'pages/stu/pl_stu5') { this.createPracticeLearningRecord(); } 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; } this.updateUnitRecord(); const user = this.session.user(); this.api.get('content_list', { modelId: 56, psize: 2, myfield2: `UserId=${user?.userId}|dqrq=${this.compactDate()}`, }).subscribe({ next: ({ result }) => this.learnedRecord.set(this.findList(result)[0] ?? {}), error: () => undefined, }); } private updateUnitRecord(): void { const nids = this.query('nids'); const bookId = this.query('pnid'); if (!nids || !bookId) return; const wordIds: Record = {}; for (const nodeId of nids.split(',')) { wordIds[nodeId] = this.words().filter((word) => word.nodeId === nodeId).map((word) => word.wordId).join(','); } this.api.post('unit_record_update', { nids, userId: this.session.user()?.userId, bookId, wordIds: JSON.stringify(wordIds), }).subscribe({ error: () => undefined }); } 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.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => { this.platform.toast('提交成功', 'success'); void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') } }); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private loadPracticeEnd(): void { const orderId = this.query('orderId') || this.query('id'); const student = this.session.student(); const user = this.session.user(); this.api.get('e_order_detail', { id: orderId }).subscribe({ next: ({ result }) => this.orderInfo.set(this.findList(result)[0] ?? {}), error: () => undefined, }); this.api.get('content_list', { modelId: 59, psize: 2, myfield2: `yyds=${orderId}|jsmz=${user?.userId}` }).subscribe({ next: ({ result }) => this.plRecord.set(this.findList(result)[0] ?? {}), error: () => undefined, }); this.api.get('content_list', { modelId: 56, psize: 1, myfield2: `UserId=${student?.userId}|dsid=${orderId}` }).subscribe({ next: ({ result }) => { const record = this.findList(result)[0] ?? {}; this.learnedRecord.set(record); if (!record['GeneralID']) { this.loading.set(false); this.error.set('未找到本次陪练学习记录'); return; } this.api.get('e_record_detail', { id: record['GeneralID'] }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result: detail }) => this.words.set(this.findList(detail).map((item) => this.toWord(item)).filter((word) => word.word)), error: (error: ApiError) => this.error.set(error.message), }); }, error: (error: ApiError) => { this.loading.set(false); this.error.set(error.message); }, }); } private submitPracticeEnd(): void { const user = this.session.user(); const student = this.session.student(); const owner = Number(user?.groupId) === 3 ? student : user; const xxqs = this.words().map((word) => ({ GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) })); const addon = { learned: xxqs.length, ygg: xxqs.filter((item) => item.check === 1).length, djq: xxqs.filter((item) => item.check === 2).length, xxqs: JSON.stringify(xxqs), }; this.submitting.set(true); this.api.post('stu_record_update_v2', { orderId: this.query('orderId') || this.query('id'), uid: owner?.userId, inputer: owner?.honeyName, addon: JSON.stringify(addon), }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => { void this.platform.confirm('即将结束本次学习,确认继续?').then((confirmed) => { if (confirmed) this.endPracticeOrder(); }); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private endPracticeOrder(): void { const orderId = this.query('orderId') || this.query('id'); const content = { GeneralID: orderId, UpDateTime: this.dateTime() }; this.submitting.set(true); this.api.post('e_order_update_v2', { status: 11, uid: this.session.user()?.userId, content: JSON.stringify(content) }).subscribe({ next: () => this.savePracticeSession(), error: (error: ApiError) => { this.submitting.set(false); this.platform.toast(error.message, 'error'); }, }); } private savePracticeSession(): void { const student = this.session.student(); const user = this.session.user(); const order = this.orderInfo(); const content: SourceItem = { ModelID: 59, nodeId: 296, inputer: user?.honeyName, status: 99, Hits: 0, title: student?.honeyName, }; let action = 'content_add'; if (this.plRecord()['GeneralID']) { content['GeneralID'] = this.plRecord()['GeneralID']; action = 'content_update'; } const addon = { jsmz: user?.userId, kcid: order['kcid'], xymz: student?.userId, kclx: order['dslx_t'], yyds: this.query('orderId') || this.query('id'), jffs: order['jffs'], kcmc: order['kcmc'], szmdid: order['szmd'], }; this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => void this.router.navigate(['/pages/shop/get_order'], { queryParams: { id: this.query('orderId') || this.query('id') } }), error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private createPracticeLearningRecord(): void { const allWords = this.readAllLearningWords(); const user = this.session.user(); const student = this.session.student(); const owner = Number(user?.groupId) === 3 ? student : user; const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 })); const addon = { con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`, szmdid: this.orderInfo()['szmd'], dqrq: this.compactDate(), dsid: this.query('orderId'), pl: Number(user?.groupId) === 3 ? user?.userId : 0, UserID: owner?.userId, learned: xxqs.length, ygg: 0, djq: 0, xxqs: JSON.stringify(xxqs), }; this.submitting.set(true); this.api.post('stu_record_update_v2', { orderId: this.query('orderId'), uid: owner?.userId, inputer: user?.honeyName, addon: JSON.stringify(addon), }).pipe(finalize(() => this.submitting.set(false))).subscribe({ next: () => this.navigateStage('/pages/stu/pl_end'), error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private loadRecord(): void { this.api.post('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result, addon }) => { this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)); this.orderInfo.set(this.findList(addon)[0] ?? {}); }, error: (error: ApiError) => this.error.set(error.message), }); } private loadCalendar(): void { const uid = this.session.user()?.userId; this.api.post('e_get_21list', { uid, cpage: 1, psize: 500 }) .pipe(finalize(() => this.loading.set(false))) .subscribe({ next: ({ result }) => { const items = mergeAntiForgetItems(this.findList(result), []); this.calendarItems.set(items); const selectedDate = nearestAntiForgetDate(items); const [year, month] = selectedDate.split('-').map(Number); this.memorySelectedDate.set(selectedDate); this.memoryMonth.set(new Date(year, month - 1, 1, 12)); }, error: () => this.error.set('抗遗忘数据暂时无法加载,请稍后重试'), }); this.api.post('e_get_21list_tj', { uid }).subscribe({ error: () => undefined }); } 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 }); } private loadExam(): void { this.turnAskId(); const askId = Number(this.storageGet('askId') ?? 1); const user = this.session.user(); this.api.get('vote_ask', { id: askId }).subscribe({ next: ({ result }) => this.askInfo.set(this.findList(result)[0] ?? {}), error: () => undefined, }); this.api.get('content_list', { nid: 389, modelId: 61, cpage: 1, psize: 2, myfield2: `UserID=${user?.userId}`, orders: 'CreateTime=desc' }).subscribe({ next: ({ result }) => this.previousExam.set(this.findList(result)[0] ?? {}), error: () => undefined, }); this.api.get('vote_question', { askid: askId }).pipe(finalize(() => this.loading.set(false))).subscribe({ next: ({ result }) => { const questions = this.shuffle(this.findList(result).map((item) => this.toQuestion(item))); this.questions.set(questions); this.answers.set(questions.map((question) => ({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 }))); }, error: (error: ApiError) => this.error.set(error.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, }; let action = 'content_add'; if (previous['GeneralID']) { content['GeneralID'] = previous['GeneralID']; action = 'content_update'; } this.api.post(action, { 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'); }, error: (error: ApiError) => this.platform.toast(error.message, 'error'), }); } private beginSelectedFlow(chosen: Word[], mode: '' | 'newword' | 'history_word'): void { 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('xiaoshu.selectedWords', JSON.stringify(pages[0] ?? chosen)); void this.router.navigate(['/pages/stu/stu_con'], { queryParams: { nids: this.query('nids') || this.query('nid'), pnid: this.query('pnid'), orderId: this.query('orderId'), }, }); } private navigateStage(path: string): void { void this.router.navigate([path], { queryParams: { nid: this.query('nid'), nids: this.query('nids'), pnid: this.query('pnid'), orderId: this.query('orderId') || this.query('id'), }, }); } private readLearningWords(): Word[] { const totalPages = Number(this.storageGet('_page_total_page') ?? 0); const currentPage = Number(this.storageGet('_page_current_learn') ?? 1); if (totalPages > 0) { const pages = this.parseArray(this.storageGet('_page_word_orilist')); const page = pages[currentPage - 1]; if (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('xiaoshu.selectedWords', JSON.stringify(page)); return page.map((item) => this.toWord(item as SourceItem)); } } 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('xiaoshu.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 { for (const key of ['learn_mode', 'choose_word_learn', 'choose_newword_learn', 'choose_hisword_learn', '_page_word_orilist', '_page_total_page', '_page_current_learn']) { localStorage.removeItem(`xiaoshu.learning.${key}`); } sessionStorage.removeItem('xiaoshu.selectedWords'); } private resetPageState(): void { this.stopExamTimer(); this.nodes.set([]); this.selectedNodes.set(new Set()); this.words.set([]); this.selected.set(new Set()); this.selectionRevealed.set(new Set()); this.selectionAssessment.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([]); 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/${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 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 storageGet(key: string): unknown { const raw = localStorage.getItem(`xiaoshu.learning.${key}`); if (raw === null) return null; try { return JSON.parse(raw) as unknown; } catch { return raw; } } private storageSet(key: string, value: unknown): void { localStorage.setItem(`xiaoshu.learning.${key}`, JSON.stringify(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'); } }