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