learning-page.component.ts 99 KB

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