learning-page.component.ts 112 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625
  1. import { ListeningPoolService } from '../../core/listening-pool.service';
  2. import { WordAudioService } from '../../core/word-audio.service';
  3. import { lessonDateLabel } from '../appointment/lesson-display';
  4. import { TeacherTodayReviewService } from '../../core/teacher-today-review.service';
  5. import { lessonStudySources } from '../appointment/lesson-study-sources';
  6. import { DropdownDirective } from '../../shared/dropdown.directive';
  7. import { LearningDraft } from '../../core/learning-draft';
  8. import { ReviewPage, reviewCalendar } from '../../core/review-page';
  9. import { CommonModule } from '@angular/common';
  10. import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
  11. import { ActivatedRoute, Router, RouterLink } from '@angular/router';
  12. import {
  13. Activity,
  14. BookOpen,
  15. BrainCircuit,
  16. Check,
  17. CheckCheck,
  18. ChevronLeft,
  19. ChevronRight,
  20. CircleHelp,
  21. Clock3,
  22. Flag,
  23. Headphones,
  24. Library,
  25. LucideAngularModule,
  26. Play,
  27. RefreshCw,
  28. RotateCcw,
  29. Star,
  30. Trash2,
  31. Volume2,
  32. X,
  33. } from 'lucide-angular';
  34. import { catchError, concatMap, finalize, forkJoin, from, map, Observable, of, Subscription, switchMap, tap, throwError, toArray } from 'rxjs';
  35. import { listeningChoices, shuffledStudy } from '../../core/learning-random';
  36. import { ApiError, ApiService } from '../../core/api.service';
  37. import {
  38. AntiForgetCalendarDay,
  39. antiForgetCompleted,
  40. antiForgetDate,
  41. antiForgetTasksOnDate,
  42. buildAntiForgetCalendar,
  43. buildAntiForgetView,
  44. mergeAntiForgetItems,
  45. nearestAntiForgetDate,
  46. } from '../../core/anti-forget';
  47. import { AppUser, PageMeta } from '../../core/models';
  48. import { canStartTeacherLedLearning, isTeacherLedLearningPath } from '../../core/learning-access';
  49. import { isLearningStage, learningReturnUrl } from '../../core/learning-navigation';
  50. import { PlatformService } from '../../core/platform.service';
  51. import { SessionService } from '../../core/session.service';
  52. import { nextReviewWordOrder } from '../../core/review-word-order';
  53. type SourceItem = Record<string, unknown>;
  54. interface WordbookStudent {
  55. userId: number;
  56. honeyName: string;
  57. }
  58. interface LessonBatchResult {
  59. batchId: string;
  60. batchVersion: number;
  61. learned?: number;
  62. resumed?: boolean;
  63. }
  64. interface Word extends SourceItem {
  65. id: string;
  66. wordId: string;
  67. nodeId: string;
  68. word: string;
  69. meaning: string;
  70. phonetic: string;
  71. audio: string;
  72. }
  73. interface LetterChoice {
  74. value: string;
  75. used: boolean;
  76. }
  77. interface ExamOption {
  78. text: string;
  79. value: string | number;
  80. checked?: boolean;
  81. }
  82. interface ExamQuestion extends SourceItem {
  83. ID: string | number;
  84. AskID?: string | number;
  85. QType?: string | number;
  86. QContent: string;
  87. QTitle?: string;
  88. QOption: ExamOption[];
  89. }
  90. interface ExamAnswer {
  91. qid: string | number;
  92. qtype: string | number;
  93. answer: string | number;
  94. result: 0 | 1 | 2;
  95. }
  96. interface MemoryStudentOption {
  97. id: string;
  98. name: string;
  99. taskCount: number;
  100. }
  101. const WORD_CATALOG_CATEGORIES = [
  102. { label: '小学', pid: 8 },
  103. { label: '初中', pid: 9 },
  104. { label: '高中', pid: 10 },
  105. { label: '大学+', pid: 11 },
  106. { label: '出国', pid: 12 },
  107. ] as const;
  108. @Component({
  109. selector: 'app-learning-page',
  110. standalone: true,
  111. imports: [DropdownDirective, CommonModule, LucideAngularModule, RouterLink],
  112. templateUrl: './learning-page.component.html',
  113. styleUrl: './learning-page.component.scss',
  114. changeDetection: ChangeDetectionStrategy.OnPush,
  115. })
  116. export class LearningPageComponent implements OnInit, OnDestroy {
  117. private static readonly reviewCacheByApi = new WeakMap<ApiService, { identity: string; pages: Map<string, ReviewPage> }>();
  118. readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
  119. readonly nodes = signal<SourceItem[]>([]);
  120. readonly catalogCategories = WORD_CATALOG_CATEGORIES;
  121. readonly catalogCategoryIndex = signal(0);
  122. readonly selectedNodes = signal<Set<string>>(new Set());
  123. readonly words = signal<Word[]>([]);
  124. readonly wordPreviewPage = signal(1);
  125. readonly wordPreviewTotal = signal(0);
  126. readonly wordPreviewLoading = signal(false);
  127. readonly historyWordRevealed = signal<Set<string>>(new Set());
  128. readonly historyAudioWordId = signal('');
  129. private historyPreviewWords: Word[] = [];
  130. readonly selected = signal<Set<string>>(new Set());
  131. readonly selectionRevealed = signal<Set<string>>(new Set());
  132. readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
  133. readonly bulkSelection = signal<Set<string> | null>(null);
  134. readonly knownSaveRetry = signal(false);
  135. private knownSaveContinuation: { continueLearning: boolean; openKnownPass: boolean } | null = null;
  136. private courseSelectionNeedsRebase = false;
  137. readonly bulkCandidates = computed(() => this.visibleCourseWords().filter(word => this.canRelearnCourseWord(word) && this.selectionAssessment()[word.id] !== 'known'));
  138. readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending');
  139. readonly coursePassTabs = ['未学习', '已学一遍', '已学第二遍', '已学第三遍'];
  140. readonly coursePass = signal(0);
  141. readonly coursePassCounts = signal<number[] | null>(null);
  142. readonly bookPassCounts = signal<number[] | null>(null);
  143. private readonly courseSelectionCache = signal<Word[]>([]);
  144. readonly visibleCourseWords = computed(() => {
  145. const candidates = [...this.words(), ...this.newlyKnownWords()];
  146. return [...new Map(candidates.map(word => [word.id, word])).values()]
  147. .filter(word => this.courseWordPass(word) === this.coursePass());
  148. });
  149. readonly firstPassProgress = computed(() => {
  150. const counts = this.bookPassCounts();
  151. const total = counts?.reduce((sum, n) => sum + n, 0) || 0;
  152. return total && counts ? Math.round((total - counts[0]) / total * 100) : 0;
  153. });
  154. readonly newWordCounts = signal({ pending: 0, completed: 0 });
  155. readonly wordbookStudents = signal<WordbookStudent[]>([]);
  156. readonly wordbookStudentId = signal(0);
  157. readonly wordbookStudentsLoading = signal(false);
  158. readonly wordbookStudentsError = signal('');
  159. readonly wordbookStudentsHaveMore = signal(false);
  160. private wordbookStudentsPage = 0;
  161. private wordbookStudentsRequest?: Subscription;
  162. private readonly newWordPageSize = 30;
  163. private readonly newWordSelectionCache = new Map<string, Word>();
  164. private legacyNewWords: Word[] | null = null;
  165. readonly currentIndex = signal(0);
  166. readonly revealed = signal(false);
  167. readonly completed = signal(false);
  168. readonly lessonLearnedCount = signal(0);
  169. readonly historySources = signal<Array<{label: string; count: number}>>([]);
  170. readonly correct = signal(0);
  171. readonly unknown = signal<Word[]>([]);
  172. readonly loading = signal(false);
  173. readonly calendarLoading = signal(false);
  174. readonly calendarError = signal('');
  175. readonly submitting = signal(false);
  176. readonly selectionLocked = computed(() => this.submitting() || this.knownSaveRetry());
  177. readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription;
  178. readonly error = signal('');
  179. readonly recordWordless = signal(false);
  180. readonly studyStep = signal<1 | 2 | 3>(1);
  181. readonly repetitions = signal<Record<string, number>>({});
  182. readonly matchLeft = signal<Word[]>([]);
  183. readonly matchRight = signal<Word[]>([]);
  184. readonly leftChoice = signal('');
  185. readonly rightChoice = signal('');
  186. readonly choicesLoading = signal(false);
  187. readonly choiceOptions = signal<Word[]>([]);
  188. readonly letters = signal<LetterChoice[]>([]);
  189. readonly assembled = signal('');
  190. readonly spellingCorrect = signal(false);
  191. readonly reviewState = signal<Record<string, 0 | 1 | 2>>({});
  192. readonly seen = signal<Set<string>>(new Set());
  193. readonly recordRevealed = signal<Set<string>>(new Set());
  194. readonly calendarItems = signal<SourceItem[]>([]);
  195. readonly memoryStudentId = signal('');
  196. readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12));
  197. readonly memorySelectedDate = signal('');
  198. readonly reviewScope=signal('');
  199. readonly reviewCompletion=signal<{amount:number;rewardStatus:string;ineligibleReason?:string;unchanged?:boolean}|null>(null);
  200. private reviewReturn: {date?:string;studentId?:string;page?:number;scope?:string;scroll?:number}={};
  201. readonly orderInfo = signal<SourceItem>({});
  202. readonly learnedRecord = signal<SourceItem>({});
  203. readonly antiHistory = signal<SourceItem>({});
  204. readonly myNewWords = signal<SourceItem>({});
  205. readonly examStarted = signal(false);
  206. readonly examSubmitted = signal(false);
  207. readonly askInfo = signal<SourceItem>({});
  208. readonly examHistory = signal<SourceItem[]>([]);
  209. readonly examHistoryLoading = signal(false);
  210. readonly examHistoryError = signal('');
  211. readonly examHistoryMore = signal(false);
  212. readonly examCompareIds = signal<string[]>([]);
  213. readonly comparedExams = computed(() => this.examHistory().filter(r => this.examCompareIds().includes(String(r['GeneralID']))));
  214. readonly examSaveError = signal('');
  215. readonly examDateLabel = lessonDateLabel;
  216. private examHistoryPage = 0;
  217. private examHistoryRequest?: Subscription;
  218. private examLoadRequest?: Subscription;
  219. private examAttemptStamp = '';
  220. private examLogKey = '';
  221. private examAnswerId: string | number | null = null;
  222. readonly previousExam = signal<SourceItem>({});
  223. readonly questions = signal<ExamQuestion[]>([]);
  224. readonly answers = signal<ExamAnswer[]>([]);
  225. readonly timer = signal(10);
  226. readonly examScore = signal(0);
  227. readonly icons = {
  228. Activity,
  229. BookOpen,
  230. BrainCircuit,
  231. Check,
  232. CheckCheck,
  233. ChevronLeft,
  234. ChevronRight,
  235. CircleHelp,
  236. Clock3,
  237. Flag,
  238. Headphones,
  239. Library,
  240. Play,
  241. RefreshCw,
  242. RotateCcw,
  243. Star,
  244. Trash2,
  245. Volume2,
  246. X,
  247. };
  248. readonly antiForgetChart = {
  249. forgettingPath: 'M 26 12 C 38 60 82 88 304 95',
  250. 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',
  251. continuationPath: 'M 276 12 C 284 15 294 16 304 16',
  252. reviewEvents: [
  253. { day: 2, x: 40, y: 61, path: 'M 26 12 C 30 30 34 49 40 61' },
  254. { day: 4, x: 68, y: 40, path: 'M 40 12 C 47 22 57 35 68 40' },
  255. { day: 7, x: 110, y: 27, path: 'M 68 12 C 79 17 94 24 110 27' },
  256. { day: 12, x: 179, y: 19, path: 'M 110 12 C 128 15 155 19 179 19' },
  257. { day: 19, x: 276, y: 17, path: 'M 179 12 C 205 15 244 17 276 17' },
  258. ],
  259. days: Array.from({ length: 21 }, (_, index) => ({ day: index + 1, x: 26 + index * 13.9 })),
  260. };
  261. readonly current = computed(() => this.words()[this.currentIndex()] ?? null);
  262. readonly completedSelectionWords = computed(() => this.words().filter((word) => this.selectionWordCompleted(word)));
  263. readonly pendingSelectionWords = computed(() => this.words().filter((word) => !this.selectionWordCompleted(word)));
  264. readonly learnedNewWords = computed(() => this.words().filter((word) => this.newWordLearned(word)));
  265. readonly unlearnedNewWords = computed(() => this.words().filter((word) => !this.newWordLearned(word)));
  266. readonly visibleNewWords = computed(() => this.activeSelectionBucket() === 'completed' ? this.learnedNewWords() : this.unlearnedNewWords());
  267. readonly newWordPageCount = computed(() => Math.max(1, Math.ceil(this.selectionTotal() / this.newWordPageSize)));
  268. readonly allVisibleNewWordsSelected = computed(() => this.visibleNewWords().length > 0 && this.visibleNewWords().every((word) => this.selected().has(word.id)));
  269. readonly newlyKnownWords = computed(() => (this.isRefineSelectionPage ? this.courseSelectionCache() : this.words()).filter((word) => this.selectionAssessment()[word.id] === 'known'));
  270. readonly selectionActionLabel = computed(() => {
  271. if (this.knownSaveRetry()) return '重试保存';
  272. const hasLearningWords = this.selected().size > 0;
  273. const hasKnownWords = this.newlyKnownWords().length > 0;
  274. if (hasLearningWords && hasKnownWords) return '保存并开始学习';
  275. if (hasKnownWords) return '保存识词结果';
  276. return '开始学习';
  277. });
  278. readonly currentQuestion = computed(() => this.questions()[this.currentIndex()] ?? null);
  279. readonly wordPreviewPageCount = computed(() => Math.max(1, Math.ceil(this.wordPreviewTotal() / this.wordPreviewPageSize)));
  280. readonly wordPreviewPages = computed(() => {
  281. const pageCount = this.wordPreviewPageCount();
  282. if (pageCount <= 3) return Array.from({ length: pageCount }, (_, index) => index + 1);
  283. const start = Math.min(Math.max(1, this.wordPreviewPage() - 1), pageCount - 2);
  284. return Array.from({ length: 3 }, (_, index) => start + index);
  285. });
  286. readonly wordPreviewRangeStart = computed(() => this.wordPreviewTotal()
  287. ? ((this.wordPreviewPage() - 1) * this.wordPreviewPageSize) + 1
  288. : 0);
  289. readonly wordPreviewRangeEnd = computed(() => Math.min(this.wordPreviewPage() * this.wordPreviewPageSize, this.wordPreviewTotal()));
  290. readonly memoryStudents = computed(() => {
  291. if(this.reviewPageData()?.students)return this.reviewPageData()!.students!.map(s=>({id:String(s.id),name:s.name,taskCount:s.taskCount}));
  292. const students = new Map<string, MemoryStudentOption>();
  293. for (const item of this.calendarItems()) {
  294. const id = String(item['yhid'] ?? '').trim();
  295. if (!id) continue;
  296. const current = students.get(id);
  297. if (current) current.taskCount += 1;
  298. else students.set(id, { id, name: this.calendarStudent(item) || `学员 ${id}`, taskCount: 1 });
  299. }
  300. return [...students.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
  301. });
  302. readonly memorySelectedStudentName = computed(() => this.memoryStudents().find((student) => student.id === this.memoryStudentId())?.name || '全部学员');
  303. readonly reviewPageData=signal<ReviewPage|null>(null); readonly reviewListPage=signal(1);
  304. readonly memoryFilteredItems = computed(() => {
  305. const studentId = this.memoryStudentId();
  306. return studentId ? this.calendarItems().filter((item) => String(item['yhid'] ?? '') === studentId) : this.calendarItems();
  307. });
  308. readonly memoryView = computed(() => buildAntiForgetView(this.memoryFilteredItems()));
  309. readonly memorySummary = computed(() => this.memoryView().summary);
  310. readonly memoryCalendar=computed(()=>reviewCalendar(this.reviewPageData()?.dates||[],this.memoryMonth(),this.memorySelectedDate()));
  311. readonly memorySelectedTasks = computed(() => this.reviewScope() === 'overdue' ? this.memoryFilteredItems() : antiForgetTasksOnDate(this.memoryFilteredItems(), this.memorySelectedDate()));
  312. readonly memorySelectedLabel = computed(() => {
  313. const value = this.memorySelectedDate();
  314. if (!value) return '';
  315. const [year, month, day] = value.split('-').map(Number);
  316. return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(year, month - 1, day, 12));
  317. });
  318. readonly memoryTotalCount = computed(() => this.reviewPageData()?.summary.total||0);
  319. readonly memoryCompletedCount = computed(() => this.reviewPageData()?.summary.completed||0);
  320. readonly memoryDueCount = computed(() => this.reviewPageData()?.summary.due||0);
  321. readonly memoryProgress=computed(()=>{const s=this.reviewPageData()?.summary;return s?.total?Math.round(s.completed/s.total*100):0;});
  322. readonly progress = computed(() => {
  323. const total = this.isMatchingPage ? this.words().length : this.words().length || this.questions().length;
  324. if (!total) return 0;
  325. if (this.isMatchingPage) return Math.round(((total - this.matchLeft().length) / total) * 100);
  326. return Math.round(((this.currentIndex() + (this.completed() ? 1 : 0)) / total) * 100);
  327. });
  328. private readonly route = inject(ActivatedRoute);
  329. private readonly router = inject(Router);
  330. private readonly api = inject(ApiService);
  331. private readonly session = inject(SessionService);
  332. private readonly todayReviews = inject(TeacherTodayReviewService);
  333. private readonly platform = inject(PlatformService);
  334. private readonly listeningPool = inject(ListeningPoolService);
  335. private readonly wordAudio = inject(WordAudioService);
  336. readonly audioPreparing = signal(false);
  337. private audioRequest = 0;
  338. private readonly wordPreviewPageSize = 30;
  339. private examTimer: ReturnType<typeof setInterval> | null = null;
  340. private antiStartedAt = Date.now();
  341. private calendarRequest?: Subscription;
  342. private choiceOptionsRequest?: Subscription;
  343. private choiceOptionsVersion = 0;
  344. private outsideChoicePool: Word[] | null = null;
  345. ngOnInit(): void {
  346. if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading && !this.isNewWordSelfStudyStage) {
  347. this.platform.toast('点读学习由陪练老师在训练时发起', 'error');
  348. void this.router.navigateByUrl('/pages/home/cart');
  349. return;
  350. }
  351. if (this.isCatalogPage && Number(this.session.user()?.groupId) === 1) {
  352. void this.router.navigateByUrl('/pages/home/cart');
  353. return;
  354. }
  355. if(this.isCalendarPage){
  356. this.reviewScope.set(this.query('scope')==='overdue'?'overdue':'');
  357. this.memorySelectedDate.set(this.query('date')||this.dateOnly());
  358. this.memoryStudentId.set(this.query('studentId')||'');
  359. this.reviewListPage.set(Math.max(1,Number(this.query('page'))||1));
  360. const [y,m]=this.memorySelectedDate().split('-').map(Number);this.memoryMonth.set(new Date(y,m-1,1,12));
  361. }
  362. if(this.isRecordPage)this.reviewReturn=history.state?.reviewReturn||{date:this.query('date')||this.dateOnly()};
  363. if (this.isCoachWordbookSelection) this.loadWordbookStudents();
  364. else this.load();
  365. }
  366. ngOnDestroy(): void {
  367. this.audioRequest++;
  368. this.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe();
  369. this.wordbookStudentsRequest?.unsubscribe();
  370. this.choiceOptionsVersion++;
  371. this.choiceOptionsRequest?.unsubscribe();
  372. this.stopExamTimer();
  373. this.examHistoryRequest?.unsubscribe();this.examLoadRequest?.unsubscribe();
  374. this.platform.stopAudio();
  375. }
  376. get isNodePage(): boolean {
  377. return ['pages/stu/index', 'pages/stu/stu_list'].includes(this.meta.path);
  378. }
  379. get isCatalogPage(): boolean {
  380. return this.meta.path === 'pages/stu/index';
  381. }
  382. get isUnitListPage(): boolean {
  383. return this.meta.path === 'pages/stu/stu_list';
  384. }
  385. get isSelectionPage(): boolean {
  386. return ['pages/stu/stu_abc', 'pages/his/his_words', 'pages/member/starshop'].includes(this.meta.path);
  387. }
  388. get isNewWordSelectionPage(): boolean {
  389. return this.meta.path === 'pages/member/starshop';
  390. }
  391. get isCoachWordbookSelection(): boolean {
  392. return this.isNewWordSelectionPage && this.canStartPointReading;
  393. }
  394. private get coachWordbookStudentId(): number {
  395. if (!this.canStartPointReading) return 0;
  396. if (this.isCoachWordbookSelection) return this.wordbookStudentId();
  397. if (!this.isStudyListPage && !this.isMatchingPage && !this.isListeningPage && !this.isSpellingPage) return 0;
  398. const id = Number(this.query('wordbookStudentId'));
  399. return Number.isSafeInteger(id) && id > 0 ? id : 0;
  400. }
  401. loadWordbookStudents(more = false): void {
  402. if (!this.isCoachWordbookSelection || this.wordbookStudentsLoading()) return;
  403. const requestedPage = more ? this.wordbookStudentsPage + 1 : 1;
  404. const preferred = Number(this.query('wordbookStudentId')) || 0;
  405. this.wordbookStudentsLoading.set(true);
  406. this.wordbookStudentsError.set('');
  407. this.wordbookStudentsRequest = this.api.get<WordbookStudent[]>('e_user_list', {
  408. scope: 'wordbook', cpage: requestedPage, psize: 50, selectedStudentId: preferred,
  409. }).pipe(finalize(() => this.wordbookStudentsLoading.set(false))).subscribe({
  410. next: ({result, page}) => {
  411. this.wordbookStudentsPage = requestedPage;
  412. const students = more ? [...this.wordbookStudents(), ...result] : result;
  413. this.wordbookStudents.set([...new Map(students.map(student => [student.userId, student])).values()]);
  414. this.wordbookStudentsHaveMore.set(requestedPage < Number(page?.pageCount ?? 0));
  415. if (!more) {
  416. const selected = students.find(student => student.userId === preferred) ?? students[0];
  417. if (selected) this.selectWordbookStudent(selected.userId);
  418. }
  419. },
  420. error: (error: ApiError) => this.wordbookStudentsError.set(error.message || '学员名单加载失败,请重试'),
  421. });
  422. }
  423. selectWordbookStudent(id: number): void {
  424. if (this.wordbookStudentId() === id || !this.wordbookStudents().some(student => student.userId === id)) return;
  425. this.wordbookStudentId.set(id);
  426. this.load();
  427. }
  428. get isHistoryWordView(): boolean {
  429. return this.meta.path === 'pages/his/his_words';
  430. }
  431. get isRefineSelectionPage(): boolean {
  432. return this.meta.path === 'pages/stu/stu_abc';
  433. }
  434. get isStudyListPage(): boolean {
  435. return this.meta.path === 'pages/stu/stu_con';
  436. }
  437. get isReadOnlySelectionPage(): boolean {
  438. return !this.canStartPointReading && this.isRefineSelectionPage && this.query('readonly') === '1';
  439. }
  440. get canStartPointReading(): boolean {
  441. return canStartTeacherLedLearning(this.session.groupId());
  442. }
  443. get isNewWordSelfStudyStage(): boolean {
  444. if (Number(this.session.groupId()) !== 1
  445. || !['pages/stu/stu_con', 'pages/stu/stu3', 'pages/stu/stu4', 'pages/stu/stu5'].includes(this.meta.path)
  446. || ['nid', 'nids', 'bindingId', 'orderId'].some((key) => this.query(key))) return false;
  447. return this.storageGet('learn_mode') === 'newword'
  448. && this.parseArray(this.storageGet('choose_newword_learn')).length > 0;
  449. }
  450. get isReadOnlyWordView(): boolean {
  451. if (this.isHistoryWordView) return true;
  452. return !this.canStartPointReading && this.isStudyListPage && this.query('readonly') === '1';
  453. }
  454. get isMatchingPage(): boolean {
  455. return this.meta.path === 'pages/stu/stu3';
  456. }
  457. get isListeningPage(): boolean {
  458. return this.meta.path === 'pages/stu/stu4';
  459. }
  460. get isSpellingPage(): boolean {
  461. return ['pages/stu/stu5', 'pages/stu/pl_stu5'].includes(this.meta.path);
  462. }
  463. get spellingStageSkipped(): boolean {
  464. return this.storageGet('skip_spelling_stage') === true;
  465. }
  466. get canSkipSpellingStage(): boolean {
  467. return this.isSpellingPage && (this.canStartPointReading || this.isNewWordSelfStudyStage);
  468. }
  469. get hasNextWordBatch(): boolean {
  470. const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
  471. const pages = this.parseArray(this.storageGet('_page_word_orilist'));
  472. return Number.isInteger(currentPage) && currentPage > 0
  473. && Array.isArray(pages[currentPage]) && pages[currentPage].length > 0;
  474. }
  475. get stageActionLabel(): string {
  476. if (this.isMatchingPage) return '进入听音识意';
  477. if (this.isListeningPage && !this.spellingStageSkipped) return '进入组合拼写';
  478. if (this.hasNextWordBatch) return '学习下一组单词';
  479. const mode = String(this.storageGet('learn_mode') ?? '');
  480. if (mode === 'newword' || mode === 'history_word') return '完成学习并返回';
  481. return this.canStartPointReading && !this.query('orderId') ? '保存并结束学习' : '查看学习结果';
  482. }
  483. get canReturnToLearningEntry(): boolean {
  484. return isLearningStage(this.meta.path, this.route.snapshot.queryParamMap);
  485. }
  486. returnToLearningEntry(): void {
  487. const legacyOrderId = this.meta.path === 'pages/stu/pl_end' ? this.query('id') : '';
  488. const url = legacyOrderId ? `/pages/shop/get_order?id=${encodeURIComponent(legacyOrderId)}`
  489. : learningReturnUrl(this.route.snapshot.queryParamMap, history.state?.learningReturnUrl);
  490. void this.router.navigateByUrl(url, { replaceUrl: true });
  491. }
  492. get isReviewPage(): boolean {
  493. return ['pages/stu/stu6', 'pages/stu/pl_end'].includes(this.meta.path);
  494. }
  495. get isCalendarPage(): boolean {
  496. return this.meta.path === 'pages/stu/stu_calendar';
  497. }
  498. get isAntiForgetPage(): boolean {
  499. return this.meta.path === 'pages/stu/stu7';
  500. }
  501. get isRecordPage(): boolean {
  502. return this.meta.path === 'pages/stu/record';
  503. }
  504. get isExamPage(): boolean {
  505. return this.meta.path === 'pages/his/exam';
  506. }
  507. get isReportPage(): boolean {
  508. return this.meta.path === 'pages/stu/stu_ok';
  509. }
  510. get selectionLimit(): number {
  511. if (this.meta.path === 'pages/stu/stu_abc') return 30;
  512. if (this.isNewWordSelectionPage) return 100;
  513. return this.words().length;
  514. }
  515. load(): void {
  516. this.selectionRequest?.unsubscribe();this.selectionPage.set(1);this.selectionTotal.set(0);
  517. this.resetPageState();
  518. this.loading.set(true);
  519. this.error.set('');
  520. this.recordWordless.set(false);
  521. if (this.isReportPage) {
  522. this.loading.set(false);
  523. return;
  524. }
  525. if (this.isNodePage) {
  526. this.loadNodes();
  527. return;
  528. }
  529. if (this.isSelectionPage) {
  530. this.loadSelection();
  531. return;
  532. }
  533. if (this.isCalendarPage) {
  534. this.loadCalendar(true);
  535. return;
  536. }
  537. if (this.isAntiForgetPage) {
  538. this.loadAntiForget();
  539. return;
  540. }
  541. if (this.isRecordPage) {
  542. this.loadRecord();
  543. return;
  544. }
  545. if (this.isExamPage) {
  546. this.loadExam();
  547. return;
  548. }
  549. if (this.meta.path === 'pages/stu/pl_end') {
  550. this.loadPracticeEnd();
  551. return;
  552. }
  553. if (this.meta.path === 'pages/stu/stu6') {
  554. this.loadReviewWords();
  555. return;
  556. }
  557. this.loadStageWords();
  558. }
  559. openNode(node: SourceItem): void {
  560. const id = node['NodeID'] ?? node['Nodeid'] ?? node['ID'] ?? node['id'];
  561. if (this.isCatalogPage) {
  562. void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } });
  563. return;
  564. }
  565. if (this.isUnitListPage && !this.canStartPointReading) {
  566. void this.router.navigate(['/pages/stu/stu_abc'], {
  567. queryParams: { nids: id, pnid: this.query('nid'), readonly: 1 },
  568. });
  569. return;
  570. }
  571. this.toggleNode(node);
  572. }
  573. openWordPreviewPage(page: number): void {
  574. const target = Math.min(Math.max(1, page), this.wordPreviewPageCount());
  575. if (target === this.wordPreviewPage() || this.wordPreviewLoading()) return;
  576. const previous = this.wordPreviewPage();
  577. this.wordPreviewPage.set(target);
  578. if (this.isHistoryWordView) {
  579. this.audioRequest++;
  580. this.platform.stopAudio();
  581. this.audioPreparing.set(false);
  582. this.historyAudioWordId.set('');
  583. const start = (target - 1) * this.wordPreviewPageSize;
  584. this.words.set(this.historyPreviewWords.slice(start, start + this.wordPreviewPageSize));
  585. setTimeout(() => document.getElementById('readonly-word-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
  586. return;
  587. }
  588. this.wordPreviewLoading.set(true);
  589. this.loadReadOnlyWords(true, previous);
  590. }
  591. selectCatalogCategory(index: number): void {
  592. if (index === this.catalogCategoryIndex()) return;
  593. this.catalogCategoryIndex.set(index);
  594. this.loadNodes();
  595. }
  596. toggleNode(node: SourceItem): void {
  597. const id = this.nodeId(node);
  598. if (!id) return;
  599. this.selectedNodes.update((current) => {
  600. const next = new Set(current);
  601. if (next.has(id)) next.delete(id); else next.add(id);
  602. return next;
  603. });
  604. }
  605. startNodeSelection(): void {
  606. if (!this.selectedNodes().size) {
  607. this.platform.toast('请选择学习单元', 'error');
  608. return;
  609. }
  610. void this.router.navigate(['/pages/stu/stu_abc'], {
  611. queryParams: {
  612. nids: [...this.selectedNodes()].join(','),
  613. pnid: this.query('nid'),
  614. bindingId: this.query('bindingId'),
  615. orderId: this.query('orderId'),
  616. },
  617. });
  618. }
  619. nodeId(node: SourceItem): string {
  620. return String(node['NodeID'] ?? node['Nodeid'] ?? node['ID'] ?? node['id'] ?? '');
  621. }
  622. nodeTitle(node: SourceItem): string {
  623. return String(node['NodeName'] ?? node['Title'] ?? node['name'] ?? '词库');
  624. }
  625. nodeCount(node: SourceItem): string {
  626. return String(node['ItemCount'] ?? node['Count'] ?? node['count'] ?? '');
  627. }
  628. nodeProgress(node: SourceItem): string {
  629. const total = this.nodeTotal(node);
  630. return total > 0 ? `已学 ${this.nodeLearned(node)}/${total} · 进度 ${this.nodeProgressPercent(node)}%` : '';
  631. }
  632. nodeLearned(node: SourceItem): number { return Math.max(0, Number(node['yx_word'] ?? 0)); }
  633. nodeTotal(node: SourceItem): number { return Math.max(0, Number(node['total'] ?? 0)); }
  634. nodeProgressPercent(node: SourceItem): number {
  635. const total = this.nodeTotal(node);
  636. return total ? Math.min(100, Math.round((this.nodeLearned(node) / total) * 100)) : 0;
  637. }
  638. toggleWord(word: Word): void {
  639. if (this.selectionLocked()) return;
  640. const bulk = this.bulkSelection();
  641. if (this.isRefineSelectionPage && bulk !== null) {
  642. const next = new Set(bulk);
  643. if (next.has(word.id)) next.delete(word.id); else next.add(word.id);
  644. this.bulkSelection.set(next);
  645. return;
  646. }
  647. if (this.isRefineSelectionPage && !this.canRelearnCourseWord(word)) return;
  648. const shouldSelect = !this.selected().has(word.id);
  649. if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return;
  650. if (shouldSelect) this.setSelectionAssessment(word, 'unknown');
  651. else this.clearSelectionAssessment(word);
  652. }
  653. selectionWordRevealed(word: Word): boolean {
  654. return this.selectionRevealed().has(word.id);
  655. }
  656. wordHistoricallyLearned(word: Word): boolean {
  657. return Number(word['w_learned'] ?? 0) > 0;
  658. }
  659. newWordLearned(word: Word): boolean {
  660. return Number(word['xxcs'] ?? 0) >= 2;
  661. }
  662. selectionWordCompleted(word: Word): boolean {
  663. return this.wordHistoricallyLearned(word) || this.selectionAssessment()[word.id] === 'known';
  664. }
  665. courseWordPass(word: Word): number {
  666. return Math.min(3, this.savedCoursePass(word) + (this.selectionAssessment()[word.id] === 'known' ? 1 : 0));
  667. }
  668. private savedCoursePass(word: Word): number {
  669. return Math.min(3, Math.max(0, Math.floor(Number(word['w_learned']) || 0)));
  670. }
  671. coursePassCount(pass: number): number {
  672. const counts = this.coursePassCounts();
  673. if (!counts) return 0;
  674. const adjustment = this.newlyKnownWords().reduce((total, word) => {
  675. const before = this.savedCoursePass(word), after = Math.min(3, before + 1);
  676. return total + Number(after === pass) - Number(before === pass);
  677. }, 0);
  678. return Math.max(0, counts[pass] + adjustment);
  679. }
  680. canRelearnCourseWord(word: Word): boolean {
  681. return !this.isReadOnlySelectionPage && this.courseWordPass(word) < 3;
  682. }
  683. get coursePassHint(): string {
  684. const pass = this.coursePass();
  685. if (pass === 0) return this.isReadOnlySelectionPage ? '这些单词尚未完成第一遍学习,点击可查看释义。' : '可直接选择“认识”或“不认识”,也可全选本页后批量判断。';
  686. if (pass === 3) return '这些单词已完成三遍学习,可查看和听读音。';
  687. return this.isReadOnlySelectionPage ? `这些单词已学${pass}遍,可由老师选词继续学习。` : `选择单词开始第${pass === 1 ? '二' : '三'}遍学习,也可全选本页批量判断。`;
  688. }
  689. selectCoursePass(pass: number): void {
  690. if (this.selectionLocked() || pass === this.coursePass() || pass < 0 || pass > 3) return;
  691. this.bulkSelection.set(null);
  692. this.courseSelectionNeedsRebase = false;
  693. this.selectionRequest?.unsubscribe();
  694. this.coursePass.set(pass);
  695. this.selectionPage.set(1);
  696. this.words.set([]);
  697. this.selectionTotal.set(0);
  698. this.loadSelection();
  699. }
  700. selectionCompletionLabel(word: Word): string {
  701. return this.wordHistoricallyLearned(word) ? `已学${this.courseWordPass(word)}遍` : '本次已认识';
  702. }
  703. revealSelectionWord(word: Word): void {
  704. this.selectionRevealed.update((current) => {
  705. const next = new Set(current);
  706. if (next.has(word.id)) next.delete(word.id);
  707. else next.add(word.id);
  708. return next;
  709. });
  710. this.play(word);
  711. }
  712. markWordKnown(word: Word): void {
  713. if (this.selectionLocked() || !this.canRelearnCourseWord(word) || this.selectionAssessment()[word.id] === 'known') return;
  714. this.setWordSelected(word, false);
  715. this.setSelectionAssessment(word, 'known');
  716. this.bulkSelection.update(current => current === null ? null : new Set([...current].filter(id => id !== word.id)));
  717. }
  718. markWordUnknown(word: Word): void {
  719. if (this.selectionLocked() || this.isReadOnlySelectionPage || this.savedCoursePass(word) >= 3) return;
  720. if (this.setWordSelected(word, true)) {
  721. this.setSelectionAssessment(word, 'unknown');
  722. this.bulkSelection.update(current => current === null ? null : new Set([...current].filter(id => id !== word.id)));
  723. }
  724. }
  725. courseWordChecked(word: Word): boolean {
  726. return (this.bulkSelection() ?? this.selected()).has(word.id);
  727. }
  728. toggleBulkSelection(): void {
  729. if (this.selectionLocked() || this.selectionLoading() || this.isReadOnlySelectionPage || this.coursePass() >= 3) return;
  730. this.bulkSelection.set(this.bulkSelection() === null ? new Set(this.bulkCandidates().map(word => word.id)) : null);
  731. }
  732. applyBulkAssessment(assessment: 'known' | 'unknown'): void {
  733. if (this.selectionLocked() || this.isReadOnlySelectionPage || this.coursePass() >= 3) return;
  734. const checked = this.bulkSelection();
  735. if (!checked?.size) return;
  736. const candidates = this.bulkCandidates();
  737. const chosen = candidates.filter(word => checked.has(word.id));
  738. if (!chosen.length) return;
  739. if (assessment === 'unknown') {
  740. const combined = new Set([...this.selected(), ...chosen.map(word => word.id)]);
  741. if (combined.size > this.selectionLimit) {
  742. this.platform.toast(`本轮最多学习 ${this.selectionLimit} 个单词,请减少勾选后重试`, 'error');
  743. return;
  744. }
  745. this.bulkSelection.set(null);
  746. for (const word of chosen) this.markWordUnknown(word);
  747. this.startSelected();
  748. return;
  749. }
  750. const remaining = candidates.filter(word => !checked.has(word.id));
  751. this.bulkSelection.set(null);
  752. for (const word of chosen) this.markWordKnown(word);
  753. const canContinue = new Set([...this.selected(), ...remaining.map(word => word.id)]).size <= this.selectionLimit;
  754. if (canContinue) for (const word of remaining) this.markWordUnknown(word);
  755. this.startSelected(canContinue, canContinue && this.selected().size === 0);
  756. }
  757. toggleAllWords(): void {
  758. if (this.isNewWordSelectionPage) {
  759. const visible = this.visibleNewWords();
  760. const clearVisible = this.allVisibleNewWordsSelected();
  761. const next = new Set(this.selected());
  762. for (const word of visible) {
  763. if (clearVisible) next.delete(word.id);
  764. else if (next.size < this.selectionLimit) next.add(word.id);
  765. }
  766. if (!clearVisible && next.size === this.selectionLimit && visible.some((word) => !next.has(word.id))) {
  767. this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error');
  768. }
  769. this.selected.set(next);
  770. return;
  771. }
  772. if (this.selected().size) {
  773. this.selected.set(new Set());
  774. return;
  775. }
  776. this.selected.set(new Set(this.words().slice(0, this.selectionLimit).map((word) => word.id)));
  777. }
  778. startSelected(continueLearning = true, openKnownPass = false): void {
  779. if (this.submitting() || this.selectionLoading() || this.isReadOnlySelectionPage) return;
  780. if (this.isCoachWordbookSelection && !this.wordbookStudentId()) return;
  781. if (this.knownSaveRetry() && this.knownSaveContinuation) {
  782. ({ continueLearning, openKnownPass } = this.knownSaveContinuation);
  783. }
  784. const available = this.isNewWordSelectionPage ? [...this.newWordSelectionCache.values()] : this.isRefineSelectionPage ? this.courseSelectionCache().filter(word => this.canRelearnCourseWord(word) && this.selectionAssessment()[word.id] !== 'known') : this.words();
  785. const chosen = available
  786. .filter((word) => this.selected().has(word.id));
  787. const newlyKnown = this.newlyKnownWords();
  788. if (!chosen.length && !newlyKnown.length) {
  789. this.platform.toast(this.isRefineSelectionPage ? '请先判断单词是否认识,或选择要学习的单词' : '请先选择要学习的单词', 'error');
  790. return;
  791. }
  792. if (this.meta.path === 'pages/stu/stu_abc') {
  793. const student = this.session.student() ?? this.session.user();
  794. if (!student?.userId) {
  795. this.platform.toast('课程信息错误', 'error');
  796. return;
  797. }
  798. this.submitting.set(true);
  799. this.knownSaveContinuation = { continueLearning, openKnownPass };
  800. this.persistWordsAsLearned(newlyKnown, student, batch => this.commitKnownWords(batch)).subscribe({
  801. next: () => {
  802. this.knownSaveRetry.set(false);
  803. this.knownSaveContinuation = null;
  804. this.submitting.set(false);
  805. if (continueLearning && chosen.length) {
  806. this.beginSelectedFlow(chosen, '');
  807. return;
  808. }
  809. if (openKnownPass) this.selectCoursePass(Math.min(3, this.coursePass() + 1));
  810. this.platform.toast('识词结果已保存', 'success');
  811. },
  812. error: (error: ApiError) => {
  813. this.knownSaveRetry.set(true);
  814. this.submitting.set(false);
  815. this.platform.toast(`${error.message};已保存的批次会保留,请点击“重试保存”完成核对`, 'error');
  816. },
  817. });
  818. return;
  819. }
  820. this.beginSelectedFlow(chosen, this.meta.path === 'pages/member/starshop' ? 'newword' : 'history_word');
  821. }
  822. deleteSelectedNewWords(): void {
  823. if (this.isCoachWordbookSelection) return;
  824. const chosen = [...this.newWordSelectionCache.values()].filter((word) => this.selected().has(word.id));
  825. if (!chosen.length) {
  826. this.platform.toast('请选择单词', 'error');
  827. return;
  828. }
  829. const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
  830. this.api.post('e_add_words', {
  831. uid: owner?.userId,
  832. wordsId: chosen.map((word) => word.wordId).join(','),
  833. ifnew: 0,
  834. kcid: this.query('kcid'),
  835. }).subscribe({
  836. next: () => {
  837. this.platform.toast('已删除', 'success');
  838. this.load();
  839. },
  840. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  841. });
  842. }
  843. async play(word = this.current()): Promise<void> {
  844. if (this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView) || !word) return;
  845. this.prepareWordAudio(word);
  846. const request = ++this.audioRequest, focusedWord = this.current(), step = this.studyStep();
  847. const active = () => request === this.audioRequest && this.current() === focusedWord && this.studyStep() === step;
  848. this.audioPreparing.set(false);
  849. if (this.isHistoryWordView) this.historyAudioWordId.set(word.id);
  850. const playAudio = async (url: string, silentFailure = false) => {
  851. const status = await (this.isHistoryWordView
  852. ? this.platform.play(url, silentFailure, {
  853. onEnded: () => {
  854. if (!active()) return;
  855. this.historyWordRevealed.update(current => new Set(current).add(word.id));
  856. this.historyAudioWordId.set('');
  857. },
  858. onError: () => {
  859. if (!active()) return;
  860. this.historyAudioWordId.set('');
  861. this.platform.toast('音频播放中断,请再点一次单词重试', 'error');
  862. },
  863. })
  864. : this.platform.play(url, silentFailure));
  865. if (status !== 'played' && active()) this.historyAudioWordId.set('');
  866. return status;
  867. };
  868. const original = word.audio?.trim();
  869. if (original) {
  870. const status = await playAudio(this.wordAudioUrl(original), true);
  871. if (status !== 'failed' || !active()) return;
  872. } else this.platform.stopAudio();
  873. this.audioPreparing.set(true);
  874. if (this.isHistoryWordView) this.historyAudioWordId.set(word.id);
  875. try {
  876. const url = await this.wordAudio.ensure(word.wordId, !!original);
  877. if (!active()) return;
  878. word.audio = url;
  879. if (original && this.wordAudioUrl(original) === this.wordAudioUrl(url)) {
  880. this.historyAudioWordId.set('');
  881. this.platform.toast('音频暂时无法播放,请检查网络后重试', 'error');
  882. return;
  883. }
  884. await playAudio(this.wordAudioUrl(url));
  885. } catch (error) {
  886. if (active()) {
  887. this.historyAudioWordId.set('');
  888. this.platform.toast(error instanceof Error ? error.message : '读音暂时不可用,请再点喇叭重试', 'error');
  889. }
  890. } finally {
  891. if (request === this.audioRequest) this.audioPreparing.set(false);
  892. }
  893. }
  894. toggleRecordAnswer(word: Word): void {
  895. this.recordRevealed.update((current) => {
  896. const next = new Set(current);
  897. if (next.has(word.id)) next.delete(word.id);
  898. else next.add(word.id);
  899. return next;
  900. });
  901. this.seen.update((current) => new Set(current).add(word.id));
  902. this.play(word);
  903. }
  904. repeatWord(word: Word, index: number): void {
  905. const count = this.repetitions()[word.id] ?? 0;
  906. if (this.studyStep() === 1 && index > 0) {
  907. const previous = this.words()[index - 1];
  908. if ((this.repetitions()[previous.id] ?? 0) < 3) {
  909. this.platform.toast('前面还有没读完的单词', 'error');
  910. return;
  911. }
  912. }
  913. this.play(word);
  914. if (count < 3) {
  915. this.repetitions.update((value) => ({ ...value, [word.id]: count + 1 }));
  916. }
  917. if (this.words().every((item) => (this.repetitions()[item.id] ?? 0) >= 3)) {
  918. this.finishStudyRound();
  919. }
  920. }
  921. shuffleStudyWords(): void {
  922. this.words.set(shuffledStudy(this.words()));
  923. this.repetitions.set({});
  924. }
  925. skipStudyList(): void {
  926. this.navigateStage('/pages/stu/stu3');
  927. }
  928. chooseMatch(side: 'left' | 'right', word: Word): void {
  929. if (side === 'left') {
  930. this.leftChoice.set(word.id);
  931. this.play(word);
  932. } else {
  933. this.rightChoice.set(word.id);
  934. }
  935. const left = side === 'left' ? word.id : this.leftChoice();
  936. const right = side === 'right' ? word.id : this.rightChoice();
  937. if (!left || !right) return;
  938. if (left !== right) {
  939. this.platform.toast('答错了,请再试一次', 'error');
  940. this.leftChoice.set('');
  941. this.rightChoice.set('');
  942. return;
  943. }
  944. this.platform.playEffect('success');
  945. this.matchLeft.update((items) => items.filter((item) => item.id !== left));
  946. this.matchRight.update((items) => items.filter((item) => item.id !== right));
  947. this.leftChoice.set('');
  948. this.rightChoice.set('');
  949. if (this.matchLeft().length === 0) this.completed.set(true);
  950. }
  951. chooseMeaning(option: Word): void {
  952. if (this.choicesLoading() || this.choiceOptions().length !== 4 || this.completed() || !this.choiceOptions().some(w => w.id === option.id)) return;
  953. const word = this.current();
  954. if (!word) return;
  955. if (option.id !== word.id) {
  956. this.platform.playEffect('error');
  957. this.platform.toast('答错了,请重新听一遍', 'error');
  958. this.play(word);
  959. return;
  960. }
  961. this.correct.update((value) => value + 1);
  962. this.platform.playEffect('success');
  963. if (this.currentIndex() >= this.words().length - 1) {
  964. this.completed.set(true);
  965. return;
  966. }
  967. this.currentIndex.update((value) => value + 1);
  968. this.prepareChoiceOptions();
  969. setTimeout(() => this.play(), 150);
  970. }
  971. spellLetter(index: number): void {
  972. if (this.spellingCorrect() || this.letters()[index]?.used) return;
  973. this.letters.update((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, used: true } : item));
  974. const next = this.assembled() + this.letters()[index].value;
  975. this.assembled.set(next);
  976. const word = this.current();
  977. if (!word || next.length < word.word.length) return;
  978. if (next.toLowerCase() === word.word.toLowerCase()) {
  979. this.spellingCorrect.set(true);
  980. this.correct.update((value) => value + 1);
  981. this.platform.playEffect('success');
  982. this.platform.toast('拼写正确', 'success');
  983. return;
  984. }
  985. this.platform.playEffect('error');
  986. this.platform.toast('拼写错了,再试一次', 'error');
  987. this.prepareSpelling();
  988. }
  989. resetSpelling(): void {
  990. this.prepareSpelling();
  991. }
  992. nextSpellingWord(): void {
  993. if (!this.spellingCorrect()) {
  994. this.platform.toast('请先完成当前单词', 'error');
  995. return;
  996. }
  997. if (this.currentIndex() >= this.words().length - 1) {
  998. this.completed.set(true);
  999. return;
  1000. }
  1001. this.currentIndex.update((value) => value + 1);
  1002. this.prepareSpelling();
  1003. this.play();
  1004. }
  1005. skipPracticeStage(): void {
  1006. if ((!this.isMatchingPage && !this.isListeningPage) || this.completed() || this.submitting()) return;
  1007. this.completed.set(true);
  1008. this.continueAfterStage();
  1009. }
  1010. skipSpellingStage(): void {
  1011. if (!this.canSkipSpellingStage || this.completed() || this.submitting()) return;
  1012. this.storageSet('skip_spelling_stage', true);
  1013. this.completed.set(true);
  1014. this.continueAfterStage();
  1015. }
  1016. continueAfterStage(): void {
  1017. if (this.submitting() || !this.completed()) return;
  1018. if (this.isMatchingPage) {
  1019. this.completed.set(false);
  1020. this.navigateStage('/pages/stu/stu4');
  1021. } else if (this.isListeningPage) {
  1022. this.completed.set(false);
  1023. if (this.spellingStageSkipped) this.finishSpellingFlow();
  1024. else this.navigateStage(this.canStartPointReading ? '/pages/stu/pl_stu5' : '/pages/stu/stu5');
  1025. } else if (this.isSpellingPage) {
  1026. this.finishSpellingFlow();
  1027. }
  1028. }
  1029. reviewValue(word: Word): 0 | 1 | 2 {
  1030. return this.reviewState()[word.id] ?? 0;
  1031. }
  1032. markReview(word: Word, state: 1 | 2): void {
  1033. this.reviewState.update((value) => ({ ...value, [word.id]: state }));
  1034. if (this.query('orderId')) this.storageSet('roundReview:' + this.storageGet('batchId'), this.reviewState());
  1035. }
  1036. markAllKnown(): void {
  1037. const allKnown = this.words().every((word) => this.reviewValue(word) === 1);
  1038. const next: Record<string, 0 | 1 | 2> = {};
  1039. for (const word of this.words()) next[word.id] = allKnown ? 0 : 1;
  1040. this.reviewState.set(next);
  1041. if (this.query('orderId')) this.storageSet('roundReview:' + this.storageGet('batchId'), next);
  1042. }
  1043. reviewCount(state: 1 | 2): number {
  1044. return this.words().filter((word) => this.reviewValue(word) === state).length;
  1045. }
  1046. submitReview(continueLearning = false): void {
  1047. if (this.submitting() || !this.words().length) return;
  1048. if (this.words().some((word) => this.reviewValue(word) === 0)) {
  1049. this.platform.toast('请为每个单词选择掌握情况', 'error');
  1050. return;
  1051. }
  1052. if (this.meta.path === 'pages/stu/pl_end') {
  1053. this.submitPracticeEnd(continueLearning);
  1054. return;
  1055. }
  1056. this.saveLearningRecord();
  1057. }
  1058. toggleSeen(word: Word): void {
  1059. this.revealed.set(!this.revealed());
  1060. this.seen.update((current) => new Set(current).add(word.id));
  1061. this.play(word);
  1062. this.updateAntiProgress(word);
  1063. if (this.seen().size === this.words().length) {
  1064. this.platform.toast(`已完成 ${this.words().length} 个单词的复习`, 'success');
  1065. }
  1066. }
  1067. joinAntiWords(): void {
  1068. const chosen = this.words().filter((word) => this.selected().has(word.id));
  1069. if (!chosen.length) {
  1070. this.platform.toast('请选择需要加入生词本的单词', 'error');
  1071. return;
  1072. }
  1073. const existing = this.parseArray(this.myNewWords()['scnr']);
  1074. const combined = new Map<string, SourceItem>();
  1075. for (const item of existing) combined.set(String(item['GeneralID'] ?? ''), item);
  1076. for (const word of chosen) {
  1077. combined.set(word.wordId, { GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word), gldy: word['gldy'] });
  1078. }
  1079. const user = this.session.user();
  1080. const content: SourceItem = {
  1081. ModelID: 55,
  1082. nodeId: 255,
  1083. inputer: user?.honeyName,
  1084. status: 99,
  1085. Hits: combined.size,
  1086. title: user?.honeyName,
  1087. };
  1088. let action = 'content_add';
  1089. if (this.myNewWords()['GeneralID']) {
  1090. content['GeneralID'] = this.myNewWords()['GeneralID'];
  1091. action = 'content_update';
  1092. }
  1093. this.api.post(action, {
  1094. content: JSON.stringify(content),
  1095. addon: JSON.stringify({ UserID: user?.userId, scnr: JSON.stringify([...combined.values()]) }),
  1096. }).subscribe({
  1097. next: () => this.platform.toast('添加成功', 'success'),
  1098. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1099. });
  1100. }
  1101. addRecordWords(): void {
  1102. const chosen = this.words().filter((word) => this.selected().has(word.id));
  1103. if (!chosen.length) {
  1104. this.platform.toast('请选择单词', 'error');
  1105. return;
  1106. }
  1107. const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
  1108. this.api.post('e_add_words', {
  1109. uid: owner?.userId,
  1110. wordsId: chosen.map((word) => word.wordId).join(','),
  1111. ifnew: 1,
  1112. kcid: this.query('kcid'),
  1113. }).subscribe({
  1114. next: () => this.platform.toast('添加成功', 'success'),
  1115. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1116. });
  1117. }
  1118. endRecord(): void {
  1119. if(this.submitting()||this.reviewCompletion())return;
  1120. this.submitting.set(true);
  1121. 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({
  1122. next:({result})=>{this.reviewCache().clear();if(Number(this.session.groupId())===3)this.todayReviews.markCompleted(this.query('gid'));this.reviewCompletion.set(result);},
  1123. error:(e:ApiError)=>this.platform.toast(e.message,'error'),
  1124. });
  1125. }
  1126. get overdueReview():boolean{return Boolean(this.query('plannedDate')&&this.query('plannedDate')<this.dateOnly());}
  1127. canDeactivate():boolean|Promise<boolean>{
  1128. if(!this.isRecordPage||this.reviewCompletion())return true;
  1129. if(this.submitting())return false;
  1130. return this.seen().size||this.selected().size||this.recordRevealed().size?this.platform.confirm('本次复习尚未提交完成,确定离开?'):true;
  1131. }
  1132. returnToReview(today=false):void{
  1133. const r=today?{date:this.dateOnly()}:this.reviewReturn;
  1134. 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});
  1135. }
  1136. calendarTitle(item: SourceItem): string {
  1137. const title = String(item['kc_title'] ?? item['kctitle'] ?? item['kcmc'] ?? '').trim();
  1138. return title ? title.split('@')[0] : '单词复习';
  1139. }
  1140. calendarStudent(item: SourceItem): string {
  1141. return String(item['honeyName'] ?? item['Title'] ?? '').split('@')[0];
  1142. }
  1143. calendarDate(item: SourceItem): string {
  1144. return antiForgetDate(item);
  1145. }
  1146. calendarFirstLearnedAt(item: SourceItem): string {
  1147. const raw = String(
  1148. item['learned_at']
  1149. ?? item['learnedAt']
  1150. ?? item['firstLearnedAt']
  1151. ?? item['learned_date']
  1152. ?? '',
  1153. ).trim();
  1154. if (!raw) return '首次学习:时间未记录';
  1155. const compactDate = raw.match(/^(\d{4})(\d{2})(\d{2})$/);
  1156. if (compactDate) return `首次学习:${compactDate[1]}-${compactDate[2]}-${compactDate[3]}(时间未记录)`;
  1157. const dateOnly = raw.match(/^(\d{4}-\d{2}-\d{2})$/);
  1158. if (dateOnly) return `首次学习:${dateOnly[1]}(时间未记录)`;
  1159. const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)
  1160. ? raw
  1161. : `${raw.replace(' ', 'T')}+08:00`;
  1162. const value = new Date(normalized);
  1163. if (Number.isNaN(value.getTime())) return `首次学习:${raw}`;
  1164. const parts = new Intl.DateTimeFormat('zh-CN', {
  1165. timeZone: 'Asia/Shanghai',
  1166. year: 'numeric',
  1167. month: '2-digit',
  1168. day: '2-digit',
  1169. hour: '2-digit',
  1170. minute: '2-digit',
  1171. hourCycle: 'h23',
  1172. }).formatToParts(value);
  1173. const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((itemPart) => itemPart.type === type)?.value ?? '';
  1174. return `首次学习:${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}`;
  1175. }
  1176. calendarReviewNumber(item: SourceItem): number {
  1177. for (const key of ['fxcs', 'fxxh', 'reviewNo', 'reviewIndex', 'review_order']) {
  1178. const value = Number(item[key]);
  1179. if (Number.isFinite(value) && value > 0) return Math.round(value);
  1180. }
  1181. const recordId = String(item['xxjlid'] ?? '').trim();
  1182. if (!recordId) return 1;
  1183. const tasks = this.calendarItems()
  1184. .filter((candidate) => String(candidate['xxjlid'] ?? '').trim() === recordId)
  1185. .sort((left, right) => {
  1186. const dateOrder = antiForgetDate(left).localeCompare(antiForgetDate(right));
  1187. if (dateOrder) return dateOrder;
  1188. return Number(left['GeneralID'] ?? 0) - Number(right['GeneralID'] ?? 0);
  1189. });
  1190. const index = tasks.indexOf(item);
  1191. return index >= 0 ? index + 1 : 1;
  1192. }
  1193. calendarReady(item: SourceItem): boolean {
  1194. const due = antiForgetDate(item);
  1195. return Boolean(due && Date.parse(due) <= Date.parse(this.dateOnly()) && Number(item['fxzt'] ?? 0) !== 1);
  1196. }
  1197. calendarCompleted(item: SourceItem): boolean {
  1198. return antiForgetCompleted(item);
  1199. }
  1200. selectMemoryDay(day: AntiForgetCalendarDay): void {
  1201. if (day.date === this.memorySelectedDate() && day.inCurrentMonth) return;
  1202. this.reviewScope.set('');this.memorySelectedDate.set(day.date);
  1203. this.reviewListPage.set(1);this.loadCalendar();
  1204. if (!day.inCurrentMonth) {
  1205. const [year, month] = day.date.split('-').map(Number);
  1206. this.memoryMonth.set(new Date(year, month - 1, 1, 12));
  1207. }
  1208. }
  1209. shiftMemoryMonth(amount: number): void {
  1210. const current = this.memoryMonth();
  1211. const next = new Date(current.getFullYear(), current.getMonth() + amount, 1, 12);
  1212. this.memoryMonth.set(next);
  1213. const monthKey = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}`;
  1214. const firstTask = (this.reviewPageData()?.dates||[]).map(row=>row.date).filter((date) => date.startsWith(monthKey)).sort()[0];
  1215. this.memorySelectedDate.set(firstTask || `${monthKey}-01`);
  1216. this.reviewListPage.set(1);this.loadCalendar();
  1217. }
  1218. showCurrentMemoryMonth(): void {
  1219. const today = this.dateOnly();
  1220. const [year, month] = today.split('-').map(Number);
  1221. this.memoryMonth.set(new Date(year, month - 1, 1, 12));
  1222. this.reviewScope.set('');this.memorySelectedDate.set(today);
  1223. this.reviewListPage.set(1);this.loadCalendar();
  1224. }
  1225. selectMemoryStudent(event: Event): void {
  1226. this.memoryStudentId.set((event.target as HTMLSelectElement).value);this.reviewListPage.set(1);this.loadCalendar();
  1227. }
  1228. memoryTaskStatus(item: SourceItem): string {
  1229. if (this.calendarCompleted(item)) return '已完成';
  1230. if (!this.calendarReady(item)) return '未到期';
  1231. const overdue = this.calendarDate(item) < this.dateOnly();
  1232. if (!this.canStartPointReading) return overdue ? '逾期补做' : '待复习';
  1233. return overdue ? '逾期补做 · 不计薪' : '待复习 · 当天完成计薪';
  1234. }
  1235. isWritingRecord(): boolean {
  1236. return String(this.orderInfo()['if_writing'] ?? '') === '1';
  1237. }
  1238. openCalendar(item: SourceItem): void {
  1239. if (!this.calendarReady(item) && !this.calendarCompleted(item)) {
  1240. this.platform.toast('还未到复习时间');
  1241. return;
  1242. }
  1243. const studentId = Number(item['yhid'] ?? 0);
  1244. if (!studentId) {
  1245. this.platform.toast('复习任务缺少关联学员', 'error');
  1246. return;
  1247. }
  1248. this.session.setStudent({
  1249. userId: studentId,
  1250. honeyName: this.calendarStudent(item) || '关联学员',
  1251. groupId: 1,
  1252. });
  1253. void this.router.navigate(['/pages/stu/record'], {
  1254. queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'], plannedDate:this.calendarDate(item),date:this.memorySelectedDate() },
  1255. state:{reviewReturn:{date:this.memorySelectedDate(),studentId:this.memoryStudentId(),page:this.reviewListPage(),scope:this.reviewScope(),scroll:window.scrollY}},
  1256. });
  1257. }
  1258. startExam(): void {
  1259. if (!this.questions().length || this.loading() || this.submitting()) return;
  1260. this.examAttemptStamp=new Date().toISOString();this.examLogKey=crypto.randomUUID();this.examAnswerId=null;this.examSaveError.set('');
  1261. this.answers.set(this.questions().map(q=>({qid:q.ID,qtype:String(q.QType??''),answer:'',result:2})));
  1262. this.examStarted.set(true);
  1263. this.examSubmitted.set(false);
  1264. this.currentIndex.set(0);
  1265. this.startExamTimer();
  1266. }
  1267. answerExam(option: ExamOption, optionIndex: number): void {
  1268. if(this.submitting()||this.examSaveError()||this.examSubmitted())return;
  1269. const question = this.currentQuestion();
  1270. if (!question) return;
  1271. const options = question.QOption.map((item, index) => ({ ...item, checked: index === optionIndex }));
  1272. this.questions.update((items) => items.map((item, index) => index === this.currentIndex() ? { ...item, QOption: options, checked: true } : item));
  1273. const result: 0 | 1 = this.cleanText(option.text) === this.cleanText(question.QContent) ? 1 : 0;
  1274. this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: option.value, result });
  1275. this.advanceExam();
  1276. }
  1277. skipExamQuestion(): void {
  1278. if(this.submitting()||this.examSaveError()||this.examSubmitted())return;
  1279. const question = this.currentQuestion();
  1280. if (!question) return;
  1281. this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 });
  1282. this.advanceExam();
  1283. }
  1284. submitExam(): void {
  1285. if(this.submitting()||this.examSubmitted())return;
  1286. this.examSaveError.set('');
  1287. if(this.examAnswerId!==null){this.submitting.set(true);this.saveExamLog(this.examAnswerId);return;}
  1288. this.stopExamTimer();
  1289. const ask = this.askInfo();
  1290. const user = this.session.user();
  1291. const answers = this.answers();
  1292. const mu = {
  1293. AskID: ask['ID'],
  1294. Answer: JSON.stringify(answers),
  1295. Remin: '',
  1296. ZType: 0,
  1297. Zstatus: 0,
  1298. Source: '',
  1299. UserID: user?.userId,
  1300. CDate: this.examAttemptStamp,
  1301. };
  1302. this.submitting.set(true);
  1303. this.api.post<string | number>('vote_add', { mu: JSON.stringify(mu) }).subscribe({
  1304. next: ({ result }) => {this.examAnswerId=result;this.saveExamLog(result);},
  1305. error: (error: ApiError) => {
  1306. this.submitting.set(false);this.examSaveError.set(error.message);
  1307. },
  1308. });
  1309. }
  1310. restart(): void {
  1311. if (this.isExamPage) {
  1312. this.loadExam();
  1313. return;
  1314. }
  1315. this.load();
  1316. }
  1317. leave(): void {
  1318. this.clearLearningStorage();
  1319. void this.router.navigateByUrl('/pages/home/index', { replaceUrl: true });
  1320. }
  1321. private loadNodes(): void {
  1322. this.loading.set(true);
  1323. this.error.set('');
  1324. this.nodes.set([]);
  1325. this.selectedNodes.set(new Set());
  1326. const pid = this.isCatalogPage
  1327. ? WORD_CATALOG_CATEGORIES[this.catalogCategoryIndex()].pid
  1328. : this.query('nid') || this.query('pid');
  1329. const params: Record<string, unknown> = { pid, cpage: 1, psize: 100 };
  1330. if (this.isUnitListPage) {
  1331. const learner = this.canStartPointReading ? this.session.student() : this.session.user();
  1332. if (!learner?.userId) {
  1333. this.loading.set(false);
  1334. this.error.set('未找到当前学员,请返回学习记录重新选择学员');
  1335. return;
  1336. }
  1337. params['ifunit'] = 1;
  1338. params['userId'] = learner.userId;
  1339. params['bindingId'] = this.query('bindingId');
  1340. }
  1341. this.api.get<unknown>('node_list', params).pipe(finalize(() => this.loading.set(false))).subscribe({
  1342. next: (nodes) => {
  1343. const rows = this.findList(nodes.result);
  1344. if (this.isUnitListPage && !this.canStartPointReading && !rows.length && pid) {
  1345. void this.router.navigate(['/pages/stu/stu_abc'], {
  1346. queryParams: { nids: pid, readonly: 1 },
  1347. replaceUrl: true,
  1348. });
  1349. return;
  1350. }
  1351. this.nodes.set(rows);
  1352. },
  1353. error: (error: ApiError) => this.error.set(error.message
  1354. || (this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试')),
  1355. });
  1356. }
  1357. loadMoreSelection(): void {
  1358. if (this.selectionLocked() || this.selectionLoading() || this.words().length >= this.selectionTotal()) return;
  1359. this.bulkSelection.set(null);
  1360. if (this.isRefineSelectionPage && this.courseSelectionNeedsRebase) {
  1361. const previousPage = this.selectionPage();
  1362. this.selectionPage.set(1);
  1363. this.loadSelection(false, previousPage);
  1364. return;
  1365. }
  1366. this.selectionPage.update(page => page + 1);
  1367. this.loadSelection(true);
  1368. }
  1369. selectNewWordBucket(bucket: 'pending' | 'completed'): void {
  1370. if (this.activeSelectionBucket() === bucket) return;
  1371. this.selectionRequest?.unsubscribe();
  1372. this.activeSelectionBucket.set(bucket);
  1373. this.selectionPage.set(1);
  1374. this.selectionTotal.set(this.newWordCounts()[bucket]);
  1375. this.words.set([]);
  1376. if (this.legacyNewWords) { this.showLegacyNewWordPage(); return; }
  1377. this.loadSelection();
  1378. }
  1379. goToNewWordPage(page: number): void {
  1380. if (this.selectionLoading() || page < 1 || page > this.newWordPageCount() || page === this.selectionPage()) return;
  1381. this.selectionPage.set(page);
  1382. if (this.legacyNewWords) { this.showLegacyNewWordPage(); return; }
  1383. this.loadSelection();
  1384. }
  1385. private showLegacyNewWordPage(): void {
  1386. const all = this.legacyNewWords ?? [];
  1387. const pending = all.filter((word) => !this.newWordLearned(word));
  1388. const completed = all.filter((word) => this.newWordLearned(word));
  1389. const bucket = this.activeSelectionBucket() === 'completed' ? completed : pending;
  1390. const start = (this.selectionPage() - 1) * this.newWordPageSize;
  1391. this.newWordCounts.set({ pending: pending.length, completed: completed.length });
  1392. this.selectionTotal.set(bucket.length);
  1393. this.words.set(bucket.slice(start, start + this.newWordPageSize));
  1394. for (const word of all) this.newWordSelectionCache.set(word.id, word);
  1395. this.error.set('');
  1396. this.selectionLoading.set(false);
  1397. this.loading.set(false);
  1398. }
  1399. private loadLegacyNewWords(uid: number | undefined, page = 1, collected: Word[] = []): void {
  1400. const size = 500;
  1401. this.selectionRequest = this.api.get<unknown>('e_words_list', { uid, cpage: page, psize: size }).subscribe({
  1402. next: ({ result, page: pageInfo }) => {
  1403. const incoming = this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word);
  1404. const all = [...collected, ...incoming];
  1405. const total = Number(pageInfo?.itemCount ?? all.length);
  1406. if (!Number.isFinite(total) || (!incoming.length && all.length < total)) {
  1407. this.error.set('生词本数据未加载完整,请重试');
  1408. this.selectionLoading.set(false);
  1409. this.loading.set(false);
  1410. return;
  1411. }
  1412. if (all.length < total) {
  1413. this.loadLegacyNewWords(uid, page + 1, all);
  1414. return;
  1415. }
  1416. this.legacyNewWords = all;
  1417. this.showLegacyNewWordPage();
  1418. },
  1419. error: (error: ApiError) => {
  1420. this.error.set(error.message || '生词本加载失败,请重试');
  1421. this.selectionLoading.set(false);
  1422. this.loading.set(false);
  1423. },
  1424. });
  1425. }
  1426. private loadSelection(more=false, rebaseFromPage?: number): void {
  1427. 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);};
  1428. const failure=(error:ApiError)=>{if(rebaseFromPage!==undefined){this.selectionPage.set(rebaseFromPage);this.platform.toast(error.message,'error');}else if(more){this.selectionPage.update(p=>p-1);this.platform.toast(error.message,'error');}else this.error.set(error.message);};
  1429. if (this.meta.path === 'pages/stu/stu_abc') {
  1430. const student = this.session.student() ?? this.session.user();this.selectionLoading.set(true);
  1431. 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})=>{
  1432. accept(result,page?.itemCount);
  1433. if (rebaseFromPage !== undefined) this.courseSelectionNeedsRebase = false;
  1434. const merged=new Map(this.courseSelectionCache().map(word=>[word.id,word]));
  1435. for(const word of this.words()) merged.set(word.id,word);
  1436. this.courseSelectionCache.set([...merged.values()]);
  1437. const counts=page?.['passCounts'],bookCounts=page?.['bookPassCounts'];
  1438. if(Array.isArray(counts)&&counts.length===4&&Array.isArray(bookCounts)&&bookCounts.length===4){
  1439. this.coursePassCounts.set(counts.map(Number));this.bookPassCounts.set(bookCounts.map(Number));
  1440. }
  1441. this.error.set('');
  1442. },error:failure});return;
  1443. }
  1444. if (this.meta.path === 'pages/his/his_words') {
  1445. this.api.post<unknown>('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1446. next: ({ result, addon }) => {
  1447. const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
  1448. this.historyPreviewWords = words;
  1449. this.words.set(words.slice(0, this.wordPreviewPageSize));
  1450. this.wordPreviewTotal.set(words.length);
  1451. const sourceAddon = this.findList(addon)[0];
  1452. if (sourceAddon) {
  1453. this.orderInfo.set(sourceAddon);
  1454. 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([])});
  1455. }
  1456. },
  1457. error: (error: ApiError) => this.error.set(error.message),
  1458. });
  1459. return;
  1460. }
  1461. const uid = this.isCoachWordbookSelection ? this.wordbookStudentId() : this.session.user()?.userId;
  1462. this.selectionLoading.set(true);
  1463. this.selectionRequest=this.api.get<unknown>('e_words_list',{uid,cpage:this.selectionPage(),psize:this.newWordPageSize,learningStatus:this.activeSelectionBucket()}).subscribe({
  1464. next: ({result,page}) => {
  1465. const incoming = this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word);
  1466. const pendingCount = Number(page?.['pendingCount']);
  1467. const completedCount = Number(page?.['completedCount']);
  1468. const total = Number(page?.itemCount ?? incoming.length);
  1469. const validCounts = Number.isFinite(pendingCount) && Number.isFinite(completedCount)
  1470. && pendingCount >= 0 && completedCount >= 0 && pendingCount + completedCount >= total
  1471. && (incoming.length > 0 || total === 0 || this.selectionPage() > Math.ceil(total / this.newWordPageSize));
  1472. if (!validCounts) {
  1473. this.loadLegacyNewWords(uid);
  1474. return;
  1475. }
  1476. this.words.set(incoming);
  1477. for (const word of incoming) this.newWordSelectionCache.set(word.id, word);
  1478. const counts = {pending: pendingCount, completed: completedCount};
  1479. this.newWordCounts.set(counts);
  1480. this.selectionTotal.set(total);
  1481. this.error.set('');
  1482. this.selectionLoading.set(false);
  1483. this.loading.set(false);
  1484. },
  1485. error: (error: ApiError) => {
  1486. failure(error);
  1487. this.selectionLoading.set(false);
  1488. this.loading.set(false);
  1489. },
  1490. });
  1491. }
  1492. private loadStageWords(): void {
  1493. const nid = this.coachWordbookStudentId ? '' : this.query('nid');
  1494. if (nid && this.isReadOnlyWordView) {
  1495. this.loadReadOnlyWords();
  1496. return;
  1497. }
  1498. if (nid) {
  1499. this.api.get<unknown>('content_list', { nid, modelId: 52, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1500. next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
  1501. error: (error: ApiError) => this.error.set(error.message),
  1502. });
  1503. return;
  1504. }
  1505. const stored = this.readLearningWords();
  1506. this.loading.set(false);
  1507. if (!stored.length) {
  1508. this.error.set('请先选择本次学习的单词');
  1509. return;
  1510. }
  1511. this.initializeStage(stored);
  1512. }
  1513. private loadReadOnlyWords(pageChange = false, previousPage = 1): void {
  1514. const learner = this.session.student() ?? this.session.user();
  1515. this.api.get<unknown>('e_ck_list', {
  1516. checkwords: 2,
  1517. nids: this.query('nid'),
  1518. modelId: 52,
  1519. cpage: this.wordPreviewPage(),
  1520. psize: this.wordPreviewPageSize,
  1521. uid: learner?.userId,
  1522. }).pipe(finalize(() => {
  1523. if (pageChange) this.wordPreviewLoading.set(false);
  1524. else this.loading.set(false);
  1525. })).subscribe({
  1526. next: ({ result, page }) => {
  1527. const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
  1528. this.words.set(words);
  1529. this.wordPreviewTotal.set(Math.max(words.length, Number(page?.itemCount ?? 0)));
  1530. if (pageChange) {
  1531. setTimeout(() => document.getElementById('readonly-word-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
  1532. }
  1533. },
  1534. error: (error: ApiError) => {
  1535. if (pageChange) {
  1536. this.wordPreviewPage.set(previousPage);
  1537. this.platform.toast('该页单词加载失败,请重试', 'error');
  1538. return;
  1539. }
  1540. this.error.set(error.message);
  1541. },
  1542. });
  1543. }
  1544. private initializeStage(source: Word[]): void {
  1545. if (!source.length) {
  1546. this.error.set('本关暂无单词,请返回重新选择学习内容');
  1547. return;
  1548. }
  1549. const words = this.isStudyListPage ? source : this.shuffle(source);
  1550. this.words.set(words);
  1551. this.prepareWordAudio(words[0]);
  1552. if (this.isStudyListPage) {
  1553. this.studyStep.set(1);
  1554. this.repetitions.set({});
  1555. } else if (this.isMatchingPage) {
  1556. this.matchLeft.set(this.shuffle(words));
  1557. this.matchRight.set(this.shuffle(words));
  1558. // Load public distractors while the matching activity is already usable.
  1559. const round = [...this.readAllLearningWords(), ...words];
  1560. this.listeningPool.load(round.map(word => word.id)).subscribe({ error: () => undefined });
  1561. } else if (this.isListeningPage) {
  1562. this.prepareChoiceOptions();
  1563. setTimeout(() => this.play(), 120);
  1564. } else if (this.isSpellingPage) {
  1565. this.prepareSpelling();
  1566. setTimeout(() => this.play(), 120);
  1567. }
  1568. }
  1569. private finishStudyRound(): void {
  1570. if (this.studyStep() < 3) {
  1571. const next = (this.studyStep() + 1) as 2 | 3;
  1572. this.studyStep.set(next);
  1573. this.words.set(shuffledStudy(this.words()));
  1574. this.repetitions.set({});
  1575. this.platform.toast(next === 2 ? '进入乱序学习' : '进入中文挑战', 'success');
  1576. return;
  1577. }
  1578. this.navigateStage('/pages/stu/stu3');
  1579. }
  1580. private prepareWordAudio(word: Word | undefined): void {
  1581. if (!word || this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView)) return;
  1582. const words = this.words(), index = words.findIndex(item => item.id === word.id);
  1583. const nearby = [word, ...words.slice(Math.max(0, index + 1), Math.max(0, index + 1) + 2)];
  1584. this.platform.preloadAudio(nearby.filter(item => item.audio?.trim()).map(item => this.wordAudioUrl(item.audio)));
  1585. }
  1586. retryListeningChoices(): void { this.outsideChoicePool = null; this.prepareChoiceOptions(true); }
  1587. private prepareChoiceOptions(refresh = false): void {
  1588. this.choiceOptionsRequest?.unsubscribe();
  1589. const version = ++this.choiceOptionsVersion;
  1590. const current = this.current();
  1591. if (!current) {
  1592. this.choiceOptions.set([]);
  1593. return;
  1594. }
  1595. const round = [...this.readAllLearningWords(), ...this.words()];
  1596. const apply = (pool: Word[]) => {
  1597. if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return;
  1598. this.choiceOptions.set(listeningChoices(current, round, pool, items => this.shuffle(items)));
  1599. this.choicesLoading.set(false);
  1600. };
  1601. if (this.outsideChoicePool !== null) { apply(this.outsideChoicePool); return; }
  1602. this.choiceOptions.set([]);
  1603. this.choicesLoading.set(true);
  1604. this.choiceOptionsRequest = this.listeningPool.load(round.map(word => word.id), refresh).subscribe({
  1605. next: ({ result }) => {
  1606. if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return;
  1607. this.outsideChoicePool = this.findList(result).map(item => this.toWord(item));
  1608. apply(this.outsideChoicePool);
  1609. },
  1610. error: () => {
  1611. apply([]);
  1612. this.platform.toast('拓展选项暂未加载,本题使用已选词汇', 'info');
  1613. },
  1614. });
  1615. }
  1616. private prepareSpelling(): void {
  1617. const word = this.current();
  1618. this.assembled.set('');
  1619. this.spellingCorrect.set(false);
  1620. this.letters.set(this.shuffle((word?.word ?? '').split('').map((value) => ({ value, used: false }))));
  1621. }
  1622. private finishSpellingFlow(): void {
  1623. const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
  1624. const mode = String(this.storageGet('learn_mode') ?? '');
  1625. if (mode === 'newword') {
  1626. const studentId = this.coachWordbookStudentId;
  1627. const learner = studentId ? {userId: studentId} : this.canStartPointReading ? this.session.student() : this.session.user();
  1628. this.submitting.set(true);
  1629. this.persistWordsAsLearned(this.words(), learner).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1630. next: () => {
  1631. if (this.hasNextWordBatch) {
  1632. this.completed.set(false);
  1633. this.storageSet('_page_current_learn', currentPage + 1);
  1634. this.navigateStage('/pages/stu/stu_con');
  1635. return;
  1636. }
  1637. this.completed.set(false);
  1638. this.clearLearningStorage();
  1639. const queryParams = studentId ? {wordbookStudentId: studentId} : {};
  1640. void this.router.navigate(['/pages/member/starshop'], { queryParams, replaceUrl: true });
  1641. },
  1642. error: (error: ApiError) => {
  1643. this.completed.set(true);
  1644. this.platform.toast(error.message || '学习结果保存失败,请重试', 'error');
  1645. },
  1646. });
  1647. return;
  1648. }
  1649. if (this.hasNextWordBatch) {
  1650. this.completed.set(false);
  1651. this.storageSet('_page_current_learn', currentPage + 1);
  1652. this.navigateStage('/pages/stu/stu_con');
  1653. return;
  1654. }
  1655. if (mode === 'history_word') {
  1656. this.completed.set(false);
  1657. this.clearLearningStorage();
  1658. void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true });
  1659. } else if (this.meta.path === 'pages/stu/pl_stu5' || (this.isListeningPage && this.canStartPointReading && this.storageGet('skip_spelling_stage') === true)) {
  1660. if (this.query('orderId')) {
  1661. this.createPracticeLearningRecord();
  1662. } else {
  1663. this.createStandalonePracticeLearningRecord();
  1664. }
  1665. } else {
  1666. this.navigateStage('/pages/stu/stu6');
  1667. }
  1668. }
  1669. private loadReviewWords(): void {
  1670. const words = this.readAllLearningWords();
  1671. this.words.set(words);
  1672. this.loading.set(false);
  1673. if (!words.length) {
  1674. this.error.set('请先选择本次学习的单词');
  1675. return;
  1676. }
  1677. const user = this.session.user();
  1678. this.api.get<unknown>('content_list', {
  1679. modelId: 56,
  1680. psize: 2,
  1681. myfield2: `UserId=${(this.canStartPointReading ? this.session.student() : user)?.userId}|dqrq=${this.compactDate()}`,
  1682. }).subscribe({
  1683. next: ({ result }) => this.learnedRecord.set(this.findList(result)[0] ?? {}),
  1684. error: (error:ApiError) => this.error.set(error.message),
  1685. });
  1686. }
  1687. private updateUnitRecord(): Observable<unknown> {
  1688. const nids = this.query('nids');
  1689. const bookId = this.query('pnid');
  1690. if (!nids || !bookId) return of(null);
  1691. const wordIds: Record<string, string> = {};
  1692. for (const nodeId of nids.split(',')) {
  1693. wordIds[nodeId] = this.words().filter((word) => word.nodeId === nodeId).map((word) => word.wordId).join(',');
  1694. }
  1695. return this.api.post('unit_record_update', {
  1696. nids,
  1697. userId: (this.canStartPointReading ? this.session.student() : this.session.user())?.userId,
  1698. bookId,
  1699. wordIds: JSON.stringify(wordIds),
  1700. });
  1701. }
  1702. private saveLearningRecord(): void {
  1703. const user = this.session.user();
  1704. const student = Number(user?.groupId) === 3 ? this.session.student() : null;
  1705. const owner = student ?? user;
  1706. const existing = this.learnedRecord();
  1707. const oldItems = this.parseArray(existing['xxqs']);
  1708. const records = new Map<string, SourceItem>();
  1709. for (const item of oldItems) records.set(String(item['GeneralID'] ?? ''), item);
  1710. for (const word of this.words()) {
  1711. records.set(word.wordId, { GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) });
  1712. }
  1713. const xxqs = [...records.values()];
  1714. const learned = xxqs.length;
  1715. const ygg = xxqs.filter((item) => Number(item['check']) === 1).length;
  1716. const djq = xxqs.filter((item) => Number(item['check']) === 2).length;
  1717. const content: SourceItem = {
  1718. ModelID: 56,
  1719. nodeId: 291,
  1720. inputer: user?.honeyName,
  1721. status: 99,
  1722. Hits: learned,
  1723. title: owner?.honeyName,
  1724. };
  1725. let action = 'content_add_zt';
  1726. if (existing['GeneralID']) {
  1727. content['GeneralID'] = existing['GeneralID'];
  1728. content['UpDateTime'] = this.dateOnly();
  1729. action = 'content_update';
  1730. }
  1731. const addon: SourceItem = {
  1732. con: `完成了${learned}个单词的学习@${this.dateTime()}`,
  1733. dqrq: this.compactDate(),
  1734. pl: student ? user?.userId : 0,
  1735. UserID: owner?.userId,
  1736. learned,
  1737. ygg,
  1738. djq,
  1739. xxqs: JSON.stringify(xxqs),
  1740. };
  1741. if (!existing['xxqs']) addon['fxrl'] = '';
  1742. this.submitting.set(true);
  1743. this.persistWordsAsLearned(this.words(), owner).pipe(
  1744. switchMap(()=>this.updateUnitRecord()),
  1745. switchMap(() => this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) })),
  1746. finalize(() => this.submitting.set(false)),
  1747. ).subscribe({
  1748. next: () => {
  1749. this.platform.toast('提交成功', 'success');
  1750. void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') }, replaceUrl: true });
  1751. },
  1752. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1753. });
  1754. }
  1755. private loadPracticeEnd(): void {
  1756. const orderId = this.query('orderId') || this.query('id');
  1757. this.api.get<unknown>('e_order_detail', {id: orderId}).pipe(switchMap(({result}) => {
  1758. const order = this.findList(result)[0] ?? (result as SourceItem);
  1759. const studentId = Number(order?.['szyh']);
  1760. if (!studentId) return throwError(() => new ApiError('预约记录缺少学员信息'));
  1761. this.orderInfo.set(order);
  1762. if (this.session.student()?.userId !== studentId) this.session.setStudent({userId: studentId, groupId: 1, honeyName: String(order['HoneyName'] || '学员')});
  1763. return this.api.get<any>('app_lesson_study', {orderId});
  1764. }), finalize(() => this.loading.set(false))).subscribe({
  1765. next: ({result}) => {
  1766. this.lessonLearnedCount.set(Number(result.record?.learned || 0));
  1767. const batch = result.batches.find((b: any) => b.batchId === (this.query('batchId') || this.storageGet('batchId')))
  1768. || result.batches.find((b: any) => b.status === 'draft' && (!this.query('bindingId') || b.bindingId === this.query('bindingId')));
  1769. if (!batch) { this.error.set('未找到待确认批次,请返回课程查看已保存成果'); return; }
  1770. this.storageSet('batchId', batch.batchId); this.storageSet('batchVersion', batch.version);
  1771. this.orderInfo.update(order => ({...order, learningCourseId: batch.courseId || this.query('pnid') || this.query('kcid') || order['kcid']}));
  1772. const words = batch.words.map((w: SourceItem) => this.toWord(w));
  1773. this.words.set(words);
  1774. const local = (this.storageGet('roundReview:' + batch.batchId) || {}) as Record<string, 0 | 1 | 2>;
  1775. this.reviewState.set(Object.fromEntries(words.map((w: Word) => [w.id, local[w.id] ?? Number(w['check']) ?? 0])));
  1776. }, error: (e: ApiError) => this.error.set(e.message),
  1777. });
  1778. }
  1779. private submitPracticeEnd(continueLearning = false): void {
  1780. this.submitting.set(true);
  1781. this.saveLessonBatch(this.query('mastery') ? 'mastery' : 'confirm', this.words()).pipe(
  1782. switchMap(() => this.query('mastery') ? of(null) : this.persistWordsAsLearned(this.words(), this.session.student())),
  1783. finalize(() => this.submitting.set(false)),
  1784. ).subscribe({next: () => {
  1785. this.clearLearningStorage();
  1786. this.platform.toast(`本轮已保存,本节累计 ${this.lessonLearnedCount()} 个词`, 'success');
  1787. void this.router.navigate(['/pages/shop/get_order'], {queryParams: {id: this.query('orderId') || this.query('id'), ...(continueLearning || this.query('mastery') ? {} : {feedback: '1'})}, replaceUrl: true});
  1788. }, error: (e: ApiError) => this.platform.toast(e.message, 'error')});
  1789. }
  1790. private saveLessonBatch(mode: 'draft' | 'open-draft' | 'confirm' | 'mastery', words: Word[]): Observable<LessonBatchResult> {
  1791. const opening = mode === 'open-draft';
  1792. const batchId = opening ? crypto.randomUUID() : String(this.storageGet('batchId') || crypto.randomUUID());
  1793. const version = opening ? 0 : Number(this.storageGet('batchVersion') || 0);
  1794. if (!opening) this.storageSet('batchId', batchId);
  1795. const xxqs = words.map(w => ({GeneralID: w.wordId, Title: w.word, check: mode === 'confirm' || mode === 'mastery' ? this.reviewValue(w) : 0}));
  1796. return this.api.post<LessonBatchResult>('stu_record_update_v2', {
  1797. orderId: this.query('orderId') || this.query('id'), uid: this.session.student()?.userId,
  1798. batchId, saveMode: mode, expectedVersion: version,
  1799. requestKey: this.draft().command('batch:' + batchId + ':' + version + ':' + mode + ':' + JSON.stringify(xxqs)),
  1800. addon: JSON.stringify({bindingId: this.query('bindingId'), xxqs}),
  1801. }).pipe(map(({result}) => {
  1802. if (opening && !result.resumed) this.clearLearningStorage();
  1803. this.storageSet('batchId', result.batchId || batchId);
  1804. this.storageSet('batchVersion', result.batchVersion);
  1805. if (result.learned !== undefined) this.lessonLearnedCount.set(Number(result.learned));
  1806. return result;
  1807. }));
  1808. }
  1809. private createPracticeLearningRecord(): void {
  1810. this.submitting.set(true);
  1811. this.saveLessonBatch('draft', this.readAllLearningWords()).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1812. next: () => this.navigateStage('/pages/stu/pl_end'),
  1813. error: (e: ApiError) => this.platform.toast(e.message, 'error'),
  1814. });
  1815. }
  1816. private createStandalonePracticeLearningRecord(): void {
  1817. const allWords = this.readAllLearningWords();
  1818. const user = this.session.user();
  1819. const student = this.session.student();
  1820. if (Number(user?.groupId) === 3 && !student?.userId) {
  1821. this.platform.toast('请先选择学员', 'error');
  1822. return;
  1823. }
  1824. const owner = Number(user?.groupId) === 3 ? student : user;
  1825. const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 }));
  1826. const content: SourceItem = {
  1827. ModelID: 56,
  1828. nodeId: 291,
  1829. inputer: user?.honeyName,
  1830. status: 99,
  1831. Hits: xxqs.length,
  1832. title: owner?.honeyName,
  1833. };
  1834. const addon: SourceItem = {
  1835. con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`,
  1836. dqrq: this.compactDate(),
  1837. pl: Number(user?.groupId) === 3 ? user?.userId : 0,
  1838. UserID: owner?.userId,
  1839. learned: xxqs.length,
  1840. ygg: 0,
  1841. djq: 0,
  1842. xxqs: JSON.stringify(xxqs),
  1843. fxrl: '',
  1844. createReviewPlan: false,
  1845. bindingId: this.query('bindingId') || undefined,
  1846. };
  1847. this.submitting.set(true);
  1848. this.persistWordsAsLearned(allWords, owner).pipe(
  1849. switchMap(() => this.api.post('content_add_zt', {
  1850. content: JSON.stringify(content),
  1851. addon: JSON.stringify(addon),
  1852. })),
  1853. finalize(() => this.submitting.set(false)),
  1854. ).subscribe({
  1855. next: () => {
  1856. this.clearLearningStorage();
  1857. this.platform.toast('学习记录已保存', 'success');
  1858. void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true });
  1859. },
  1860. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1861. });
  1862. }
  1863. private loadRecord(): void {
  1864. const user = this.session.user();
  1865. const learnerId = Number(user?.groupId) === 3 ? this.session.student()?.userId : user?.userId;
  1866. const recordId = this.query('id');
  1867. const detailParams = { id: recordId, studentId: learnerId };
  1868. const hasWords = (result: unknown) => this.findList(result).some((item) => this.toWord(item).word);
  1869. const notesReview = (detail: {addon?: unknown}) => this.findList(detail.addon)[0]?.['reviewContentMode'] === 'notes';
  1870. let originalRecord: SourceItem | undefined;
  1871. this.api.post<unknown>('e_record_detail', detailParams).pipe(
  1872. switchMap((detail) => hasWords(detail.result) || notesReview(detail) ? of(detail) : this.api.get<unknown>('content_get', { id: recordId }).pipe(
  1873. map(({ result }) => {
  1874. originalRecord = this.findList(result)[0];
  1875. return { ...detail, result: this.parseArray(originalRecord?.['xxqs']) };
  1876. }),
  1877. catchError(() => of(detail)),
  1878. )),
  1879. switchMap((detail) => hasWords(detail.result) || notesReview(detail) ? of(detail) : this.api.post<unknown>('app_learning_overview', {
  1880. studentId: learnerId, page: 1, pageSize: 1, refresh: true,
  1881. }).pipe(
  1882. switchMap(() => this.api.post<unknown>('e_record_detail', detailParams)),
  1883. catchError(() => of(detail)),
  1884. )),
  1885. finalize(() => this.loading.set(false)),
  1886. ).subscribe({
  1887. next: ({ result, addon }) => {
  1888. const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
  1889. if (!words.length) {
  1890. if (notesReview({addon})) {
  1891. this.orderInfo.set(this.findList(addon)[0] ?? {});
  1892. this.recordWordless.set(true);
  1893. this.error.set('');
  1894. return;
  1895. }
  1896. const wordless = originalRecord && 'xxqs' in originalRecord
  1897. && !this.parseArray(originalRecord['xxqs']).length
  1898. && Number(originalRecord['learned'] ?? 0) === 0;
  1899. this.recordWordless.set(Boolean(wordless));
  1900. const writingRecord = wordless && String(originalRecord?.['con'] ?? '').includes('写作');
  1901. this.error.set(wordless
  1902. ? `${writingRecord ? '这条任务关联的是写作学习记录,原本没有单词' : '这条任务关联的原学习记录没有保存单词'},无法进行单词复习。任务进度不会被修改。`
  1903. : '这条复习任务暂时没有读取到单词,请点击重新加载;任务进度不会被修改。');
  1904. return;
  1905. }
  1906. this.words.set(words);
  1907. const key = `xiaoshu.review.order.${learnerId}.${this.query('id')}`;
  1908. let previous: string[][] = [];
  1909. try {
  1910. const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
  1911. if (Array.isArray(stored)) previous = stored.filter((order): order is string[] =>
  1912. Array.isArray(order) && order.every((id) => typeof id === 'string'));
  1913. } catch { /* A damaged or unavailable local cache should not block review. */ }
  1914. try {
  1915. const ordered = nextReviewWordOrder(words, previous, (word) => word.id);
  1916. this.words.set(ordered.words);
  1917. try { localStorage.setItem(key, JSON.stringify(ordered.history)); } catch { /* Review still works without local storage. */ }
  1918. } catch { /* Keep the original words visible if ordering fails. */ }
  1919. this.orderInfo.set(this.findList(addon)[0] ?? {});
  1920. },
  1921. error: (error: ApiError) => this.error.set(error.message),
  1922. });
  1923. }
  1924. turnReviewPage(delta:number):void{this.reviewListPage.update(p=>Math.max(1,Math.min(this.reviewPageData()?.page.pageCount||1,p+delta)));this.loadCalendar();}
  1925. retryCalendar(): void { this.loadCalendar(false, true); }
  1926. private reviewCache(): Map<string, ReviewPage> {
  1927. const identity = `${this.session.token()}|${this.session.user()?.userId ?? ''}|${this.dateOnly()}|${this.api.dataRevision ?? 0}`;
  1928. let cache = LearningPageComponent.reviewCacheByApi.get(this.api);
  1929. if (!cache || cache.identity !== identity) {
  1930. cache = { identity, pages: new Map() };
  1931. LearningPageComponent.reviewCacheByApi.set(this.api, cache);
  1932. }
  1933. return cache.pages;
  1934. }
  1935. private loadCalendar(initial = false, force = false): void {
  1936. const requestId=++this.calendarRequestId;
  1937. this.calendarRequest?.unsubscribe();
  1938. this.calendarError.set('');
  1939. const date = this.memorySelectedDate() || this.dateOnly();
  1940. const key = `${this.memoryStudentId()}|${date}|${this.reviewListPage()}|${this.reviewScope()}`;
  1941. const cache = this.reviewCache();
  1942. const cached = force ? undefined : cache.get(key);
  1943. if (cached) {
  1944. this.reviewPageData.set(cached);this.calendarItems.set(cached.items);this.restoreReviewScroll();
  1945. if(!this.memorySelectedDate())this.memorySelectedDate.set(date);
  1946. this.loading.set(false);this.calendarLoading.set(false);
  1947. return;
  1948. }
  1949. if (initial) this.loading.set(true); else this.calendarLoading.set(true);
  1950. 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(()=>{
  1951. if(requestId!==this.calendarRequestId)return;
  1952. if(initial)this.loading.set(false);else this.calendarLoading.set(false);
  1953. })).subscribe({next:({result})=>{
  1954. if(requestId!==this.calendarRequestId)return;
  1955. cache.set(key,result);
  1956. this.reviewPageData.set(result);this.calendarItems.set(result.items);this.restoreReviewScroll();
  1957. if(!this.memorySelectedDate())this.memorySelectedDate.set(this.dateOnly());
  1958. },error:(e:ApiError)=>{
  1959. if(requestId!==this.calendarRequestId)return;
  1960. if(initial)this.error.set(e.message);else this.calendarError.set(e.message);
  1961. }});
  1962. }
  1963. private calendarRequestId=0;
  1964. private restoredReviewScroll=false;
  1965. 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'}));}
  1966. private loadAntiForget(): void {
  1967. const user = this.session.user();
  1968. const date = this.query('date') || this.compactDate();
  1969. this.antiStartedAt = Date.now();
  1970. this.api.get<unknown>('content_list', {
  1971. nid: 76,
  1972. modelId: 54,
  1973. myfield: `fxrl=${date}`,
  1974. myfield2: `UserId=${user?.userId}`,
  1975. cpage: 1,
  1976. psize: 30,
  1977. orders: 'CreateTime=asc',
  1978. }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1979. next: ({ result }) => {
  1980. const words: Word[] = [];
  1981. for (const record of this.findList(result)) {
  1982. for (const item of this.parseArray(record['xxqs'])) words.push(this.toWord({ ...item, recordId: record['GeneralID'] }));
  1983. }
  1984. this.words.set(words.filter((word) => word.word));
  1985. },
  1986. error: (error: ApiError) => this.error.set(error.message),
  1987. });
  1988. if (this.query('hid')) {
  1989. this.api.get<unknown>('content_get', { id: this.query('hid') }).subscribe({
  1990. next: ({ result }) => this.antiHistory.set(this.findList(result)[0] ?? {}),
  1991. error: () => undefined,
  1992. });
  1993. }
  1994. this.api.get<unknown>('content_list', { nid: 255, modelId: 55, cpage: 1, psize: 30, myfield2: `UserId=${user?.userId}`, orders: 'CreateTime=asc' }).subscribe({
  1995. next: ({ result }) => this.myNewWords.set(this.findList(result)[0] ?? {}),
  1996. error: () => undefined,
  1997. });
  1998. }
  1999. private updateAntiProgress(word: Word): void {
  2000. const historyId = this.query('hid');
  2001. if (!historyId) return;
  2002. const seenWords = this.words().filter((item) => this.seen().has(item.id));
  2003. const previousIds = String(this.antiHistory()['xxjlid'] ?? '').split(',').filter(Boolean);
  2004. const recordId = String(word['recordId'] ?? '');
  2005. if (recordId && !previousIds.includes(recordId)) previousIds.push(recordId);
  2006. const scqs = seenWords.map((item) => ({ GeneralID: item.wordId, Title: item.word, gldy: item['gldy'] }));
  2007. const wordIds = `,${scqs.map((item) => item.GeneralID).join(',')},`;
  2008. this.api.post('content_update', {
  2009. content: JSON.stringify({ GeneralID: historyId }),
  2010. addon: JSON.stringify({
  2011. xxjlid: previousIds.join(','),
  2012. scqs: JSON.stringify(scqs),
  2013. yxx: scqs.length,
  2014. scqs2: wordIds,
  2015. times: Math.round((Date.now() - this.antiStartedAt) / 1000),
  2016. num: this.words().length,
  2017. }),
  2018. }).subscribe({ error: () => undefined });
  2019. }
  2020. loadExamHistory(more = false): void {
  2021. if(more && this.examHistoryLoading())return;
  2022. this.examHistoryRequest?.unsubscribe();this.examHistoryLoading.set(true);this.examHistoryError.set('');
  2023. const page=more?this.examHistoryPage+1:1;
  2024. this.examHistoryRequest=this.api.get<{items:SourceItem[];hasMore:boolean}>('app_exam_history',{page}).pipe(finalize(()=>this.examHistoryLoading.set(false))).subscribe({
  2025. next:({result})=>{
  2026. if(!result || !Array.isArray(result.items)){this.examHistoryError.set('测试记录返回异常,请重试');return;}
  2027. this.examHistoryPage=page;this.examHistoryMore.set(result.hasMore);
  2028. this.examHistory.set(more?[...this.examHistory(),...result.items]:result.items);
  2029. 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'])));}
  2030. },error:(e:ApiError)=>this.examHistoryError.set(e.message),
  2031. });
  2032. }
  2033. toggleExamComparison(item:SourceItem):void {
  2034. const id=String(item['GeneralID']);if(!item['resultAvailable'])return;
  2035. this.examCompareIds.update(ids=>ids.includes(id)?ids.filter(x=>x!==id):[...ids.slice(-1),id]);
  2036. }
  2037. private loadExam(): void {
  2038. this.stopExamTimer();this.examLoadRequest?.unsubscribe();this.examStarted.set(false);this.examSubmitted.set(false);this.examSaveError.set('');
  2039. this.loading.set(true);this.error.set('');this.questions.set([]);this.turnAskId();
  2040. const askId = Number(this.storageGet('askId') ?? 1);
  2041. this.loadExamHistory();
  2042. 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({
  2043. 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})));},
  2044. error:(e:ApiError)=>this.error.set(e.message),
  2045. });
  2046. }
  2047. private turnAskId(): void {
  2048. const current = Number(this.storageGet('askId') ?? 0);
  2049. this.storageSet('askId', current === 1 ? 2 : 1);
  2050. }
  2051. examScoreValue(exam: SourceItem): number {
  2052. return Number(exam['df'] ?? 0);
  2053. }
  2054. examAnalysis(exam: SourceItem): string {
  2055. const score = this.examScoreValue(exam);
  2056. if (score === 0) return '当前处于零基础阶段,可以从最常用的基础词汇开始建立词汇库。';
  2057. if (score <= 2) return '词汇量约为 100-200,处于英语入门或小学一、二年级水平。';
  2058. if (score <= 6) return '词汇量约为 200-400,相当于小学三、四年级水平。';
  2059. if (score <= 12) return '词汇量约为 400-600,相当于小学五、六年级水平。';
  2060. if (score <= 15) return '词汇量约为 600-900,相当于初一水平。';
  2061. if (score <= 18) return '词汇量约为 1000-1500,相当于初二水平。';
  2062. if (score <= 21) return '词汇量约为 1600-1800,相当于初三水平。';
  2063. if (score <= 33) return '词汇量约为 1800-2500,相当于高一水平。';
  2064. if (score <= 39) return '词汇量约为 2500-3000,相当于高二水平。';
  2065. if (score <= 42) return '词汇量约为 3000-3500,相当于高三一般水平。';
  2066. return '词汇量约为 3500-4500,达到高三较高或大学四级水平。';
  2067. }
  2068. examAdvice(exam: SourceItem): string {
  2069. const score = this.examScoreValue(exam);
  2070. if (score <= 6) return '建议先系统学习自然拼读并背诵小学、初中基础词汇,打牢读、认、写基础。';
  2071. if (score <= 12) return '建议重点背诵初中词汇,并通过简单阅读和听力练习持续扩展词汇量。';
  2072. if (score <= 21) return '建议继续背诵初高中核心词汇,配合英文阅读、听力和写作练习巩固运用。';
  2073. if (score <= 39) return '建议学习高二、高三词汇,并通过时文外刊、国家地理和 TED 演讲提升理解与表达。';
  2074. return '建议拓展大学四、六级或雅思托福词汇,通过 BBC、VOA 和英文原著把新词用于真实语境。';
  2075. }
  2076. private setExamAnswer(answer: ExamAnswer): void {
  2077. this.answers.update((items) => items.map((item, index) => index === this.currentIndex() ? answer : item));
  2078. }
  2079. private advanceExam(): void {
  2080. if (this.currentIndex() >= this.questions().length - 1) {
  2081. this.submitExam();
  2082. return;
  2083. }
  2084. this.currentIndex.update((value) => value + 1);
  2085. this.startExamTimer();
  2086. }
  2087. private startExamTimer(): void {
  2088. this.stopExamTimer();
  2089. this.timer.set(10);
  2090. this.examTimer = setInterval(() => {
  2091. if (this.timer() <= 1) {
  2092. this.skipExamQuestion();
  2093. } else {
  2094. this.timer.update((value) => value - 1);
  2095. }
  2096. }, 1000);
  2097. }
  2098. private stopExamTimer(): void {
  2099. if (this.examTimer) clearInterval(this.examTimer);
  2100. this.examTimer = null;
  2101. }
  2102. private saveExamLog(answerId: string | number): void {
  2103. const user = this.session.user();
  2104. const answers = this.answers();
  2105. const score = answers.filter((item) => item.result === 1).length;
  2106. const previous = this.previousExam();
  2107. this.examScore.set(score);
  2108. const content: SourceItem = { ModelID: 61, nodeId: 389, inputer: user?.honeyName, status: 99, Hits: 1, title: user?.honeyName };
  2109. const addon = {
  2110. askid: this.currentQuestion()?.AskID ?? this.askInfo()['ID'],
  2111. prev_score: previous['df'] ?? 0,
  2112. totalScore: answers.length,
  2113. wrong: answers.filter((item) => item.result === 0).length,
  2114. dontKnow: answers.filter((item) => item.result === 2).length,
  2115. answerid: answerId || 0,
  2116. df: score,
  2117. UserID: user?.userId,
  2118. };
  2119. this.api.post('content_add', { requestKey:this.examLogKey,content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  2120. next: () => {
  2121. this.examStarted.set(false);
  2122. this.examSubmitted.set(true);
  2123. this.platform.toast('答题结果已保存', 'success');this.loadExamHistory();
  2124. },
  2125. error: (error: ApiError) => this.examSaveError.set(error.message),
  2126. });
  2127. }
  2128. private beginSelectedFlow(chosen: Word[], mode: '' | 'newword' | 'history_word'): void {
  2129. if (this.query('orderId') && !mode) {
  2130. this.submitting.set(true);
  2131. this.saveLessonBatch('open-draft', chosen).pipe(finalize(() => this.submitting.set(false))).subscribe({
  2132. next: result => {
  2133. if (result.resumed) {
  2134. this.platform.toast('还有待确认批次,请先确认掌握状态');
  2135. this.navigateStage('/pages/stu/pl_end');
  2136. return;
  2137. }
  2138. this.beginSelectedStages(chosen, mode, true);
  2139. },
  2140. error: (e: ApiError) => this.platform.toast(e.message, 'error'),
  2141. });
  2142. return;
  2143. }
  2144. this.beginSelectedStages(chosen, mode);
  2145. }
  2146. private beginSelectedStages(chosen: Word[], mode: '' | 'newword' | 'history_word', preserveBatch = false): void {
  2147. if (!preserveBatch) this.clearLearningStorage();
  2148. if (mode) this.storageSet('learn_mode', mode);
  2149. const pages: Word[][] = [];
  2150. for (let index = 0; index < chosen.length; index += 6) pages.push(chosen.slice(index, index + 6));
  2151. const storageKey = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
  2152. this.storageSet(storageKey, pages[0] ?? chosen);
  2153. this.storageSet('_page_current_learn', 1);
  2154. this.storageSet('_page_total_page', pages.length);
  2155. this.storageSet('_page_word_orilist', pages);
  2156. sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(pages[0] ?? chosen));
  2157. const wordbookStudentId = mode === 'newword' ? this.coachWordbookStudentId : 0;
  2158. void this.router.navigate(['/pages/stu/stu_con'], {
  2159. replaceUrl: true,
  2160. state: { learningReturnUrl: this.router.url },
  2161. queryParams: wordbookStudentId ? {wordbookStudentId} : {
  2162. nids: this.query('nids') || this.query('nid'),
  2163. pnid: this.query('pnid'),
  2164. bindingId: this.query('bindingId'),
  2165. orderId: this.query('orderId'),
  2166. },
  2167. });
  2168. }
  2169. private navigateStage(path: string): void {
  2170. const wordbookStudentId = this.coachWordbookStudentId;
  2171. void this.router.navigate([path], {
  2172. replaceUrl: true,
  2173. state: { learningReturnUrl: history.state?.learningReturnUrl },
  2174. queryParams: wordbookStudentId ? {wordbookStudentId} : {
  2175. nid: this.query('nid'),
  2176. nids: this.query('nids'),
  2177. pnid: this.query('pnid'),
  2178. bindingId: this.query('bindingId'),
  2179. orderId: this.query('orderId') || this.query('id'),
  2180. },
  2181. });
  2182. }
  2183. private readLearningWords(): Word[] {
  2184. const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
  2185. const pages = this.parseArray(this.storageGet('_page_word_orilist'));
  2186. if (pages.length) {
  2187. const page = pages[currentPage - 1];
  2188. if (Number.isInteger(currentPage) && currentPage > 0 && Array.isArray(page)) {
  2189. const mode = String(this.storageGet('learn_mode') ?? '');
  2190. const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
  2191. this.storageSet(key, page);
  2192. sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(page));
  2193. return page.map((item) => this.toWord(item as SourceItem));
  2194. }
  2195. return [];
  2196. }
  2197. const mode = String(this.storageGet('learn_mode') ?? '');
  2198. const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
  2199. const stored = this.parseArray(this.storageGet(key));
  2200. if (stored.length) return stored.map((item) => this.toWord(item));
  2201. return this.parseArray(sessionStorage.getItem(this.learningStorageKey('selectedWords'))).map((item) => this.toWord(item));
  2202. }
  2203. private readAllLearningWords(): Word[] {
  2204. const pages = this.parseArray(this.storageGet('_page_word_orilist'));
  2205. if (pages.length && pages.every((page) => Array.isArray(page))) {
  2206. return pages.flatMap((page) => (page as SourceItem[]).map((item) => this.toWord(item)));
  2207. }
  2208. return this.readLearningWords();
  2209. }
  2210. private clearLearningStorage(): void { this.draft().clear(); }
  2211. private resetPageState(): void {
  2212. this.audioRequest++;
  2213. this.platform.stopAudio();
  2214. this.audioPreparing.set(false);
  2215. this.outsideChoicePool = null;
  2216. this.choicesLoading.set(false);
  2217. this.choiceOptionsVersion++;
  2218. this.choiceOptionsRequest?.unsubscribe();
  2219. this.stopExamTimer();
  2220. this.nodes.set([]);
  2221. this.selectedNodes.set(new Set());
  2222. this.words.set([]);
  2223. this.historyPreviewWords = [];
  2224. this.historyWordRevealed.set(new Set());
  2225. this.historyAudioWordId.set('');
  2226. this.historySources.set([]);
  2227. this.wordPreviewPage.set(1);
  2228. this.wordPreviewTotal.set(0);
  2229. this.wordPreviewLoading.set(false);
  2230. this.calendarLoading.set(false);
  2231. this.calendarError.set('');
  2232. this.selected.set(new Set());
  2233. this.newWordSelectionCache.clear();
  2234. this.newWordCounts.set({ pending: 0, completed: 0 });
  2235. this.legacyNewWords = null;
  2236. this.selectionRevealed.set(new Set());
  2237. this.selectionAssessment.set({});
  2238. this.bulkSelection.set(null);
  2239. this.knownSaveRetry.set(false);
  2240. this.knownSaveContinuation = null;
  2241. this.courseSelectionNeedsRebase = false;
  2242. this.activeSelectionBucket.set('pending');
  2243. this.coursePass.set(0);
  2244. this.coursePassCounts.set(null);
  2245. this.bookPassCounts.set(null);
  2246. this.courseSelectionCache.set([]);
  2247. this.currentIndex.set(0);
  2248. this.revealed.set(false);
  2249. this.completed.set(false);
  2250. this.correct.set(0);
  2251. this.unknown.set([]);
  2252. this.studyStep.set(1);
  2253. this.repetitions.set({});
  2254. this.matchLeft.set([]);
  2255. this.matchRight.set([]);
  2256. this.leftChoice.set('');
  2257. this.rightChoice.set('');
  2258. this.choiceOptions.set([]);
  2259. this.reviewState.set({});
  2260. this.seen.set(new Set());
  2261. this.recordRevealed.set(new Set());
  2262. this.calendarItems.set([]);
  2263. if(!this.isCalendarPage)this.memoryStudentId.set('');
  2264. this.reviewCompletion.set(null);
  2265. this.examStarted.set(false);
  2266. this.examSubmitted.set(false);
  2267. }
  2268. private toWord(item: SourceItem): Word {
  2269. const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail'];
  2270. const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {};
  2271. const source = { ...detail, ...item };
  2272. const id = String(item['GeneralID'] ?? detail['GeneralID'] ?? item['ID'] ?? item['id'] ?? item['gid'] ?? item['Title'] ?? detail['Title'] ?? '');
  2273. const wordId = String(detail['GeneralID'] ?? item['GeneralID'] ?? item['gldy'] ?? id);
  2274. return {
  2275. ...source,
  2276. detail,
  2277. id,
  2278. wordId,
  2279. nodeId: String(item['NodeID'] ?? detail['NodeID'] ?? item['nodeId'] ?? ''),
  2280. word: String(item['Title'] ?? detail['Title'] ?? item['word'] ?? detail['word'] ?? item['Word'] ?? ''),
  2281. meaning: String(item['sy'] ?? detail['sy'] ?? item['jssy'] ?? detail['jssy'] ?? item['Translate'] ?? detail['Translate'] ?? item['Meaning'] ?? ''),
  2282. phonetic: [item['yb'], detail['yb'], item['Phonetic'], detail['Phonetic'], item['phonetic'], detail['phonetic']].map(value => String(value ?? '').trim()).find(Boolean) || '',
  2283. audio: [item['yp'], detail['yp'], item['dy'], detail['dy']].map(value => String(value ?? '').trim()).find(Boolean) || '',
  2284. };
  2285. }
  2286. private toNewWord(item: SourceItem): Word {
  2287. const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail'];
  2288. const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {};
  2289. const detailTitle = String(detail['Title'] ?? '').trim();
  2290. const storedTitle = String(item['Title'] ?? '').split('@')[0].trim();
  2291. // The envelope identifies a practice record; selection and study need the vocabulary ID.
  2292. // Normalize GeneralID too so stored words keep the same identity in subsequent stages.
  2293. return this.toWord({
  2294. ...item,
  2295. GeneralID: detail['GeneralID'] ?? item['scid'] ?? item['GeneralID'],
  2296. Title: detailTitle || storedTitle,
  2297. });
  2298. }
  2299. private toQuestion(item: SourceItem): ExamQuestion {
  2300. let options: ExamOption[] = [];
  2301. const raw = item['QOption'];
  2302. if (Array.isArray(raw)) {
  2303. options = raw as ExamOption[];
  2304. } else if (typeof raw === 'string') {
  2305. try { options = JSON.parse(raw) as ExamOption[]; } catch { options = []; }
  2306. }
  2307. return {
  2308. ...item,
  2309. ID: String(item['ID'] ?? item['GeneralID'] ?? ''),
  2310. AskID: String(item['AskID'] ?? ''),
  2311. QType: String(item['QType'] ?? ''),
  2312. QTitle: String(item['QTitle'] ?? ''),
  2313. QContent: this.cleanText(String(item['QContent'] ?? '')),
  2314. QOption: options,
  2315. };
  2316. }
  2317. private wordAudioUrl(path: string): string {
  2318. if (/^(data:|https?:\/\/|\/)/i.test(path)) return this.api.assetUrl(path);
  2319. return this.api.assetUrl(/^UploadFiles\//i.test(path) ? path : `UploadFiles/${path}`);
  2320. }
  2321. private setWordSelected(word: Word, shouldSelect: boolean): boolean {
  2322. const current = this.selected();
  2323. if (shouldSelect && !current.has(word.id) && current.size >= this.selectionLimit) {
  2324. this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error');
  2325. return false;
  2326. }
  2327. const next = new Set(current);
  2328. if (shouldSelect) next.add(word.id);
  2329. else next.delete(word.id);
  2330. this.selected.set(next);
  2331. return true;
  2332. }
  2333. private setSelectionAssessment(word: Word, assessment: 'known' | 'unknown'): void {
  2334. this.selectionAssessment.update((current) => ({ ...current, [word.id]: assessment }));
  2335. }
  2336. private clearSelectionAssessment(word: Word): void {
  2337. this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => id !== word.id)) as Record<string, 'known' | 'unknown'>);
  2338. }
  2339. private persistWordsAsLearned(words: Word[], owner: Pick<AppUser, 'userId'> | null | undefined, onBatchSaved?: (batch: Word[]) => void): Observable<unknown> {
  2340. if (!words.length) return of(null);
  2341. const batches: Word[][] = [];
  2342. for (let index = 0; index < words.length; index += 100) batches.push(words.slice(index, index + 100));
  2343. return from(batches).pipe(concatMap(batch => this.api.post('e_add_words', {
  2344. uid: owner?.userId,
  2345. wordsId: batch.map((word) => word.wordId).join(','),
  2346. kcid: (this.meta.path === 'pages/stu/pl_end' ? this.orderInfo()['learningCourseId'] : '') || this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
  2347. save: 1,
  2348. ...(this.coachWordbookStudentId ? {scope: 'wordbook'} : {}),
  2349. requestKey:this.draft().command('learn:'+batch.map(w=>w.wordId+'@'+this.savedCoursePass(w)).sort().join(',')),
  2350. }).pipe(tap(() => onBatchSaved?.(batch)))), toArray());
  2351. }
  2352. private commitKnownWords(words: Word[]): void {
  2353. if (this.isRefineSelectionPage) this.courseSelectionNeedsRebase = true;
  2354. const nextPass = new Map(words.map(word => [word.id, Math.min(3, this.savedCoursePass(word) + 1)]));
  2355. const ids = new Set(nextPass.keys());
  2356. const update = (current: Word[]) => current.map(word => ids.has(word.id) ? {...word, w_learned: nextPass.get(word.id)!} : word);
  2357. this.words.update(update);
  2358. this.courseSelectionCache.update(update);
  2359. const changes = [0,0,0,0];
  2360. for (const word of words) { changes[this.savedCoursePass(word)]--; changes[nextPass.get(word.id)!]++; }
  2361. const advance = (counts: number[] | null) => counts ? counts.map((count, pass) => Math.max(0, count + changes[pass])) : null;
  2362. this.coursePassCounts.update(advance);
  2363. this.bookPassCounts.update(advance);
  2364. this.selectionAssessment.update(current => Object.fromEntries(Object.entries(current).filter(([id]) => !ids.has(id))) as Record<string, 'known' | 'unknown'>);
  2365. }
  2366. private findList(value: unknown): SourceItem[] {
  2367. if (Array.isArray(value)) return value as SourceItem[];
  2368. if (value && typeof value === 'object') {
  2369. for (const nested of Object.values(value)) {
  2370. if (Array.isArray(nested)) return nested as SourceItem[];
  2371. if (nested && typeof nested === 'object') {
  2372. for (const child of Object.values(nested)) if (Array.isArray(child)) return child as SourceItem[];
  2373. }
  2374. }
  2375. }
  2376. return [];
  2377. }
  2378. private parseArray(value: unknown): SourceItem[] {
  2379. if (Array.isArray(value)) return value as SourceItem[];
  2380. if (typeof value !== 'string' || !value) return [];
  2381. try {
  2382. const parsed = JSON.parse(value) as unknown;
  2383. return Array.isArray(parsed) ? parsed as SourceItem[] : [];
  2384. } catch {
  2385. return [];
  2386. }
  2387. }
  2388. private shuffle<T>(items: T[]): T[] {
  2389. const copy = [...items];
  2390. for (let index = copy.length - 1; index > 0; index -= 1) {
  2391. const target = Math.floor(Math.random() * (index + 1));
  2392. [copy[index], copy[target]] = [copy[target], copy[index]];
  2393. }
  2394. return copy;
  2395. }
  2396. private draft():LearningDraft {
  2397. const wordbookStudentId = this.coachWordbookStudentId;
  2398. if (wordbookStudentId) return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${wordbookStudentId}.wordbook`);
  2399. const learner=this.canStartPointReading?this.session.student():this.session.user();
  2400. 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'}`);
  2401. }
  2402. private learningStorageKey(key:string):string{return this.draft().key(key);}
  2403. private storageGet(key:string):unknown{return this.draft().get(key);}
  2404. private storageSet(key:string,value:unknown):void{this.draft().set(key,value);}
  2405. private query(name: string): string {
  2406. return this.route.snapshot.queryParamMap.get(name) ?? '';
  2407. }
  2408. private cleanText(value: string): string {
  2409. return value.replace(/<\/?[^>]*>/g, '').replace(/&nbsp;/gi, '').trim();
  2410. }
  2411. private dateOnly(): string {
  2412. const date = new Date();
  2413. return `${date.getFullYear()}-${this.pad(date.getMonth() + 1)}-${this.pad(date.getDate())}`;
  2414. }
  2415. private compactDate(): string {
  2416. return this.dateOnly().replaceAll('-', '');
  2417. }
  2418. private dateTime(seconds = false): string {
  2419. const date = new Date();
  2420. return `${this.dateOnly()} ${this.pad(date.getHours())}:${this.pad(date.getMinutes())}${seconds ? `:${this.pad(date.getSeconds())}` : ''}`;
  2421. }
  2422. private pad(value: number): string {
  2423. return String(value).padStart(2, '0');
  2424. }
  2425. }