| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813 |
- 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<string, unknown>;
- 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<SourceItem[]>([]);
- readonly catalogCategories = WORD_CATALOG_CATEGORIES;
- readonly catalogCategoryIndex = signal(0);
- readonly selectedNodes = signal<Set<string>>(new Set());
- readonly words = signal<Word[]>([]);
- readonly selected = signal<Set<string>>(new Set());
- readonly selectionRevealed = signal<Set<string>>(new Set());
- readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
- readonly currentIndex = signal(0);
- readonly revealed = signal(false);
- readonly completed = signal(false);
- readonly correct = signal(0);
- readonly unknown = signal<Word[]>([]);
- readonly loading = signal(false);
- readonly submitting = signal(false);
- readonly error = signal('');
- readonly studyStep = signal<1 | 2 | 3>(1);
- readonly repetitions = signal<Record<string, number>>({});
- readonly matchLeft = signal<Word[]>([]);
- readonly matchRight = signal<Word[]>([]);
- readonly leftChoice = signal('');
- readonly rightChoice = signal('');
- readonly choiceOptions = signal<Word[]>([]);
- readonly letters = signal<LetterChoice[]>([]);
- readonly assembled = signal('');
- readonly spellingCorrect = signal(false);
- readonly reviewState = signal<Record<string, 0 | 1 | 2>>({});
- readonly seen = signal<Set<string>>(new Set());
- readonly recordRevealed = signal<Set<string>>(new Set());
- readonly calendarItems = signal<SourceItem[]>([]);
- readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12));
- readonly memorySelectedDate = signal('');
- readonly orderInfo = signal<SourceItem>({});
- readonly learnedRecord = signal<SourceItem>({});
- readonly plRecord = signal<SourceItem>({});
- readonly antiHistory = signal<SourceItem>({});
- readonly myNewWords = signal<SourceItem>({});
- readonly examStarted = signal(false);
- readonly examSubmitted = signal(false);
- readonly askInfo = signal<SourceItem>({});
- readonly previousExam = signal<SourceItem>({});
- readonly questions = signal<ExamQuestion[]>([]);
- readonly answers = signal<ExamAnswer[]>([]);
- 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<typeof setInterval> | 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<string, 0 | 1 | 2> = {};
- 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<string, SourceItem>();
- 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<AppUser>('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<string | number>('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<string, unknown> = { 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<unknown>('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<unknown>('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) => {
- if (this.isUnitListPage && !this.canStartPointReading && !nodes.length && pid) {
- void this.router.navigate(['/pages/stu/stu_con'], {
- queryParams: { nid: pid, readonly: 1 },
- replaceUrl: true,
- });
- return;
- }
- this.nodes.set(nodes);
- },
- error: () => this.error.set(this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试'),
- });
- }
- private mergeUnitProgress(nodes: SourceItem[], words: SourceItem[]): SourceItem[] {
- const totals = new Map<string, number>();
- const learned = new Map<string, number>();
- 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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<string, string> = {};
- 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<string, SourceItem>();
- 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<unknown>('e_order_detail', { id: orderId }).subscribe({
- next: ({ result }) => this.orderInfo.set(this.findList(result)[0] ?? {}),
- error: () => undefined,
- });
- this.api.get<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('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<unknown>('content_get', { id: this.query('hid') }).subscribe({
- next: ({ result }) => this.antiHistory.set(this.findList(result)[0] ?? {}),
- error: () => undefined,
- });
- }
- this.api.get<unknown>('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<unknown>('vote_ask', { id: askId }).subscribe({
- next: ({ result }) => this.askInfo.set(this.findList(result)[0] ?? {}),
- error: () => undefined,
- });
- this.api.get<unknown>('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<unknown>('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<T>(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');
- }
- }
|