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 } 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, Observable, of, Subscription, switchMap, throwError } 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 { isLearningStage, learningReturnUrl } from '../../core/learning-navigation'; 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; } 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], 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 wordPreviewPage = signal(1); readonly wordPreviewTotal = signal(0); readonly wordPreviewLoading = signal(false); readonly selected = signal>(new Set()); readonly selectionRevealed = signal>(new Set()); readonly selectionAssessment = signal>({}); readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending'); readonly currentIndex = signal(0); readonly revealed = signal(false); readonly completed = signal(false); 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 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 memoryStudentId = signal(''); readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12)); readonly memorySelectedDate = signal(''); readonly orderInfo = signal({}); readonly learnedRecord = 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 completedSelectionWords = computed(() => this.words().filter((word) => this.selectionWordCompleted(word))); readonly pendingSelectionWords = computed(() => this.words().filter((word) => !this.selectionWordCompleted(word))); readonly newlyKnownWords = computed(() => 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(() => 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 platform = inject(PlatformService); private readonly wordPreviewPageSize = 30; private examTimer: ReturnType | null = null; private antiStartedAt = Date.now(); private calendarRequest?: Subscription; ngOnInit(): void { if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !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.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe(); 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 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 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 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) return '进入组合拼写'; if (this.hasNextWordBatch) return '学习下一组单词'; const mode = String(this.storageGet('learn_mode') ?? ''); if (mode === 'newword' || mode === 'history_word') return '完成学习并返回'; return this.meta.path === 'pages/stu/pl_stu5' && !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 { return this.meta.path === 'pages/stu/stu_abc' ? 30 : 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(''); 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); 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 { 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; } selectionWordCompleted(word: Word): boolean { return this.wordHistoricallyLearned(word) || this.selectionAssessment()[word.id] === 'known'; } selectionCompletionLabel(word: Word): string { return this.wordHistoricallyLearned(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 { 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.pendingSelectionWords().filter((word) => this.selected().has(word.id)); const newlyKnown = this.newlyKnownWords(); if (!chosen.length && !newlyKnown.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.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.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(); } skipSpellingWord(): void { if (this.meta.path !== 'pages/stu/pl_stu5') return; if (this.currentIndex() >= this.words().length - 1) { this.completed.set(true); return; } this.currentIndex.update((value) => value + 1); this.prepareSpelling(); setTimeout(() => this.play(), 120); } 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); 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.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(); 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())return; 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 { if (day.date === this.memorySelectedDate() && day.inCurrentMonth) return; 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.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) ? '待复习' : '未到期'; } isWritingRecord(): boolean { return String(this.orderInfo()['if_writing'] ?? '') === '1'; } openCalendar(item: SourceItem): void { if (!this.calendarReady(item) && !this.calendarCompleted(item)) { this.platform.toast('还未到复习时间'); return; } const studentId = Number(item['yhid'] ?? 0); if (!studentId) { this.platform.toast('复习任务缺少关联学员', 'error'); return; } this.session.setStudent({ userId: studentId, honeyName: this.calendarStudent(item) || '关联学员', groupId: 1, }); void this.router.navigate(['/pages/stu/record'], { queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'] }, }); } 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 { if(this.submitting())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.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', { 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);}} 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'),modelId:52,cpage:this.selectionPage(),psize:100,uid:student?.userId}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure});return; } 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.words.set(words); this.wordPreviewTotal.set(words.length); 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.selectionLoading.set(true); this.selectionRequest=this.api.get('e_words_list',{uid:owner?.userId,cpage:this.selectionPage(),psize:100}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure}); } 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(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 currentPage = Number(this.storageGet('_page_current_learn') ?? 1); if (this.hasNextWordBatch) { this.completed.set(false); this.storageSet('_page_current_learn', currentPage + 1); this.navigateStage('/pages/stu/stu_con'); return; } const mode = String(this.storageGet('learn_mode') ?? ''); if (mode === 'newword') { this.completed.set(false); this.clearLearningStorage(); void this.router.navigateByUrl('/pages/member/starshop', { replaceUrl: true }); } else 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') { 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'); const coachId = this.session.user()?.userId; if (!orderId || !coachId) { this.loading.set(false); this.error.set('缺少预约或老师信息,请返回课程重新进入'); return; } 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'] ?? order?.['yhid'] ?? 0); 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'] ?? order['honeyName'] ?? '学员') }); } // Restrict the read to this appointment, its student and the signed-in coach. return this.api.get('content_list', { modelId: 56, psize: 1, myfield2: `UserId=${studentId}|dsid=${orderId}|pl=${coachId}`, }); }), switchMap(({ result }) => { const record = this.findList(result)[0] ?? {}; this.learnedRecord.set(record); if (!record['GeneralID']) return throwError(() => new ApiError('未找到本次陪练学习记录,请返回课程重试')); return this.api.get('e_record_detail', { id: record['GeneralID'] }); }), finalize(() => this.loading.set(false)), ).subscribe({ next: ({ result }) => { const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word); this.words.set(words); this.reviewState.set(Object.fromEntries(words.map((word) => [word.id, Number(word['check']) === 1 ? 1 : Number(word['check']) === 2 ? 2 : 0]))); }, error: (error: ApiError) => 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: () => { this.clearLearningStorage(); void this.router.navigate(['/pages/shop/get_order'], { queryParams: { id: this.query('orderId') || this.query('id'), feedback: '1' }, replaceUrl: true, }); }, 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.persistWordsAsLearned(allWords, owner).pipe( switchMap(() => this.api.post('stu_record_update_v2', { orderId: this.query('orderId'), uid: owner?.userId, inputer: user?.honeyName, addon: JSON.stringify(addon), })), finalize(() => this.submitting.set(false)), ).subscribe({ next: () => this.navigateStage('/pages/stu/pl_end'), error: (error: ApiError) => this.platform.toast(error.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 { 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), }); } 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(); } private loadCalendar(initial = false): void { const requestId=++this.calendarRequestId; this.calendarRequest?.unsubscribe(); this.calendarError.set(''); 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:this.memorySelectedDate()||this.dateOnly(),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; this.reviewPageData.set(result);this.calendarItems.set(result.items); 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 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(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.stopExamTimer(); this.nodes.set([]); this.selectedNodes.set(new Set()); this.words.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.selectionRevealed.set(new Set()); this.selectionAssessment.set({}); this.activeSelectionBucket.set('pending'); 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.memoryStudentId.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 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.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)); this.words.update((current) => current.map((word) => ids.has(word.id) ? { ...word, w_learned: Math.max(1, Number(word['w_learned'] ?? 0)) } : word)); this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record); } 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('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'); } }