learning-page.component.ts 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813
  1. import { CommonModule } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
  3. import { ActivatedRoute, Router } from '@angular/router';
  4. import {
  5. Activity,
  6. BookOpen,
  7. BrainCircuit,
  8. Check,
  9. CheckCheck,
  10. ChevronLeft,
  11. ChevronRight,
  12. CircleHelp,
  13. Clock3,
  14. Flag,
  15. Headphones,
  16. Library,
  17. LucideAngularModule,
  18. Play,
  19. RefreshCw,
  20. RotateCcw,
  21. Star,
  22. Trash2,
  23. Volume2,
  24. X,
  25. } from 'lucide-angular';
  26. import { catchError, finalize, forkJoin, map, of, switchMap } from 'rxjs';
  27. import { GAME_AUDIO } from '../../core/app.constants';
  28. import { ApiError, ApiService } from '../../core/api.service';
  29. import {
  30. AntiForgetCalendarDay,
  31. antiForgetCompleted,
  32. antiForgetDate,
  33. antiForgetTasksOnDate,
  34. buildAntiForgetCalendar,
  35. buildAntiForgetView,
  36. mergeAntiForgetItems,
  37. nearestAntiForgetDate,
  38. } from '../../core/anti-forget';
  39. import { AppUser, PageMeta } from '../../core/models';
  40. import { canStartTeacherLedLearning, isTeacherLedLearningPath } from '../../core/learning-access';
  41. import { PlatformService } from '../../core/platform.service';
  42. import { SessionService } from '../../core/session.service';
  43. type SourceItem = Record<string, unknown>;
  44. interface Word extends SourceItem {
  45. id: string;
  46. wordId: string;
  47. nodeId: string;
  48. word: string;
  49. meaning: string;
  50. phonetic: string;
  51. audio: string;
  52. }
  53. interface LetterChoice {
  54. value: string;
  55. used: boolean;
  56. }
  57. interface ExamOption {
  58. text: string;
  59. value: string | number;
  60. checked?: boolean;
  61. }
  62. interface ExamQuestion extends SourceItem {
  63. ID: string | number;
  64. AskID?: string | number;
  65. QType?: string | number;
  66. QContent: string;
  67. QTitle?: string;
  68. QOption: ExamOption[];
  69. }
  70. interface ExamAnswer {
  71. qid: string | number;
  72. qtype: string | number;
  73. answer: string | number;
  74. result: 0 | 1 | 2;
  75. }
  76. const WORD_CATALOG_CATEGORIES = [
  77. { label: '小学', pid: 8 },
  78. { label: '初中', pid: 9 },
  79. { label: '高中', pid: 10 },
  80. { label: '大学+', pid: 11 },
  81. { label: '出国', pid: 12 },
  82. ] as const;
  83. @Component({
  84. selector: 'app-learning-page',
  85. standalone: true,
  86. imports: [CommonModule, LucideAngularModule],
  87. templateUrl: './learning-page.component.html',
  88. styleUrl: './learning-page.component.scss',
  89. changeDetection: ChangeDetectionStrategy.OnPush,
  90. })
  91. export class LearningPageComponent implements OnInit, OnDestroy {
  92. readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
  93. readonly nodes = signal<SourceItem[]>([]);
  94. readonly catalogCategories = WORD_CATALOG_CATEGORIES;
  95. readonly catalogCategoryIndex = signal(0);
  96. readonly selectedNodes = signal<Set<string>>(new Set());
  97. readonly words = signal<Word[]>([]);
  98. readonly selected = signal<Set<string>>(new Set());
  99. readonly selectionRevealed = signal<Set<string>>(new Set());
  100. readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
  101. readonly currentIndex = signal(0);
  102. readonly revealed = signal(false);
  103. readonly completed = signal(false);
  104. readonly correct = signal(0);
  105. readonly unknown = signal<Word[]>([]);
  106. readonly loading = signal(false);
  107. readonly submitting = signal(false);
  108. readonly error = signal('');
  109. readonly studyStep = signal<1 | 2 | 3>(1);
  110. readonly repetitions = signal<Record<string, number>>({});
  111. readonly matchLeft = signal<Word[]>([]);
  112. readonly matchRight = signal<Word[]>([]);
  113. readonly leftChoice = signal('');
  114. readonly rightChoice = signal('');
  115. readonly choiceOptions = signal<Word[]>([]);
  116. readonly letters = signal<LetterChoice[]>([]);
  117. readonly assembled = signal('');
  118. readonly spellingCorrect = signal(false);
  119. readonly reviewState = signal<Record<string, 0 | 1 | 2>>({});
  120. readonly seen = signal<Set<string>>(new Set());
  121. readonly recordRevealed = signal<Set<string>>(new Set());
  122. readonly calendarItems = signal<SourceItem[]>([]);
  123. readonly memoryMonth = signal(new Date(new Date().getFullYear(), new Date().getMonth(), 1, 12));
  124. readonly memorySelectedDate = signal('');
  125. readonly orderInfo = signal<SourceItem>({});
  126. readonly learnedRecord = signal<SourceItem>({});
  127. readonly plRecord = signal<SourceItem>({});
  128. readonly antiHistory = signal<SourceItem>({});
  129. readonly myNewWords = signal<SourceItem>({});
  130. readonly examStarted = signal(false);
  131. readonly examSubmitted = signal(false);
  132. readonly askInfo = signal<SourceItem>({});
  133. readonly previousExam = signal<SourceItem>({});
  134. readonly questions = signal<ExamQuestion[]>([]);
  135. readonly answers = signal<ExamAnswer[]>([]);
  136. readonly timer = signal(10);
  137. readonly examScore = signal(0);
  138. readonly icons = {
  139. Activity,
  140. BookOpen,
  141. BrainCircuit,
  142. Check,
  143. CheckCheck,
  144. ChevronLeft,
  145. ChevronRight,
  146. CircleHelp,
  147. Clock3,
  148. Flag,
  149. Headphones,
  150. Library,
  151. Play,
  152. RefreshCw,
  153. RotateCcw,
  154. Star,
  155. Trash2,
  156. Volume2,
  157. X,
  158. };
  159. readonly antiForgetChart = {
  160. forgettingPath: 'M 26 12 C 38 60 82 88 304 95',
  161. 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',
  162. continuationPath: 'M 276 12 C 284 15 294 16 304 16',
  163. reviewEvents: [
  164. { day: 2, x: 40, y: 61, path: 'M 26 12 C 30 30 34 49 40 61' },
  165. { day: 4, x: 68, y: 40, path: 'M 40 12 C 47 22 57 35 68 40' },
  166. { day: 7, x: 110, y: 27, path: 'M 68 12 C 79 17 94 24 110 27' },
  167. { day: 12, x: 179, y: 19, path: 'M 110 12 C 128 15 155 19 179 19' },
  168. { day: 19, x: 276, y: 17, path: 'M 179 12 C 205 15 244 17 276 17' },
  169. ],
  170. days: Array.from({ length: 21 }, (_, index) => ({ day: index + 1, x: 26 + index * 13.9 })),
  171. };
  172. readonly current = computed(() => this.words()[this.currentIndex()] ?? null);
  173. readonly currentQuestion = computed(() => this.questions()[this.currentIndex()] ?? null);
  174. readonly memoryView = computed(() => buildAntiForgetView(this.calendarItems()));
  175. readonly memorySummary = computed(() => this.memoryView().summary);
  176. readonly memoryCalendar = computed(() => buildAntiForgetCalendar(
  177. this.calendarItems(),
  178. this.memoryMonth(),
  179. this.memorySelectedDate(),
  180. ));
  181. readonly memorySelectedTasks = computed(() => antiForgetTasksOnDate(this.calendarItems(), this.memorySelectedDate()));
  182. readonly memorySelectedLabel = computed(() => {
  183. const value = this.memorySelectedDate();
  184. if (!value) return '';
  185. const [year, month, day] = value.split('-').map(Number);
  186. return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(year, month - 1, day, 12));
  187. });
  188. readonly memoryCompletedCount = computed(() => this.calendarItems().filter(antiForgetCompleted).length);
  189. readonly memoryDueCount = computed(() => this.calendarItems().filter((item) => !antiForgetCompleted(item) && antiForgetDate(item) <= this.dateOnly()).length);
  190. readonly memoryProgress = computed(() => this.calendarItems().length
  191. ? Math.round((this.memoryCompletedCount() / this.calendarItems().length) * 100)
  192. : 0);
  193. readonly progress = computed(() => {
  194. const total = this.isMatchingPage ? this.words().length : this.words().length || this.questions().length;
  195. if (!total) return 0;
  196. if (this.isMatchingPage) return Math.round(((total - this.matchLeft().length) / total) * 100);
  197. return Math.round(((this.currentIndex() + (this.completed() ? 1 : 0)) / total) * 100);
  198. });
  199. private readonly route = inject(ActivatedRoute);
  200. private readonly router = inject(Router);
  201. private readonly api = inject(ApiService);
  202. private readonly session = inject(SessionService);
  203. private readonly platform = inject(PlatformService);
  204. private examTimer: ReturnType<typeof setInterval> | null = null;
  205. private antiStartedAt = Date.now();
  206. ngOnInit(): void {
  207. if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.canStartPointReading) {
  208. this.platform.toast('点读学习由陪练老师在上课时发起', 'error');
  209. void this.router.navigateByUrl('/pages/home/cart');
  210. return;
  211. }
  212. if (this.isCatalogPage && Number(this.session.user()?.groupId) === 1) {
  213. void this.router.navigateByUrl('/pages/home/cart');
  214. return;
  215. }
  216. this.load();
  217. }
  218. ngOnDestroy(): void {
  219. this.stopExamTimer();
  220. this.platform.stopAudio();
  221. }
  222. get isNodePage(): boolean {
  223. return ['pages/stu/index', 'pages/stu/stu_list'].includes(this.meta.path);
  224. }
  225. get isCatalogPage(): boolean {
  226. return this.meta.path === 'pages/stu/index';
  227. }
  228. get isUnitListPage(): boolean {
  229. return this.meta.path === 'pages/stu/stu_list';
  230. }
  231. get isSelectionPage(): boolean {
  232. return ['pages/stu/stu_abc', 'pages/his/his_words', 'pages/member/starshop'].includes(this.meta.path);
  233. }
  234. get isRefineSelectionPage(): boolean {
  235. return this.meta.path === 'pages/stu/stu_abc';
  236. }
  237. get isStudyListPage(): boolean {
  238. return this.meta.path === 'pages/stu/stu_con';
  239. }
  240. get canStartPointReading(): boolean {
  241. return canStartTeacherLedLearning(this.session.groupId());
  242. }
  243. get isReadOnlyWordView(): boolean {
  244. if (this.canStartPointReading) return false;
  245. return this.meta.path === 'pages/his/his_words'
  246. || (this.isStudyListPage && this.query('readonly') === '1');
  247. }
  248. get isMatchingPage(): boolean {
  249. return this.meta.path === 'pages/stu/stu3';
  250. }
  251. get isListeningPage(): boolean {
  252. return this.meta.path === 'pages/stu/stu4';
  253. }
  254. get isSpellingPage(): boolean {
  255. return ['pages/stu/stu5', 'pages/stu/pl_stu5'].includes(this.meta.path);
  256. }
  257. get isReviewPage(): boolean {
  258. return ['pages/stu/stu6', 'pages/stu/pl_end'].includes(this.meta.path);
  259. }
  260. get isCalendarPage(): boolean {
  261. return this.meta.path === 'pages/stu/stu_calendar';
  262. }
  263. get isAntiForgetPage(): boolean {
  264. return this.meta.path === 'pages/stu/stu7';
  265. }
  266. get isRecordPage(): boolean {
  267. return this.meta.path === 'pages/stu/record';
  268. }
  269. get isExamPage(): boolean {
  270. return this.meta.path === 'pages/his/exam';
  271. }
  272. get isReportPage(): boolean {
  273. return this.meta.path === 'pages/stu/stu_ok';
  274. }
  275. get selectionLimit(): number {
  276. return this.meta.path === 'pages/stu/stu_abc' ? 30 : this.words().length;
  277. }
  278. load(): void {
  279. this.resetPageState();
  280. this.loading.set(true);
  281. this.error.set('');
  282. if (this.isReportPage) {
  283. this.loading.set(false);
  284. return;
  285. }
  286. if (this.isNodePage) {
  287. this.loadNodes();
  288. return;
  289. }
  290. if (this.isSelectionPage) {
  291. this.loadSelection();
  292. return;
  293. }
  294. if (this.isCalendarPage) {
  295. this.loadCalendar();
  296. return;
  297. }
  298. if (this.isAntiForgetPage) {
  299. this.loadAntiForget();
  300. return;
  301. }
  302. if (this.isRecordPage) {
  303. this.loadRecord();
  304. return;
  305. }
  306. if (this.isExamPage) {
  307. this.loadExam();
  308. return;
  309. }
  310. if (this.meta.path === 'pages/stu/pl_end') {
  311. this.loadPracticeEnd();
  312. return;
  313. }
  314. if (this.meta.path === 'pages/stu/stu6') {
  315. this.loadReviewWords();
  316. return;
  317. }
  318. this.loadStageWords();
  319. }
  320. openNode(node: SourceItem): void {
  321. const id = node['NodeID'] ?? node['Nodeid'] ?? node['ID'] ?? node['id'];
  322. if (this.isCatalogPage) {
  323. void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } });
  324. return;
  325. }
  326. if (this.isUnitListPage && !this.canStartPointReading) {
  327. void this.router.navigate(['/pages/stu/stu_con'], {
  328. queryParams: { nid: id, pnid: this.query('nid'), readonly: 1 },
  329. });
  330. return;
  331. }
  332. this.toggleNode(node);
  333. }
  334. selectCatalogCategory(index: number): void {
  335. if (index === this.catalogCategoryIndex()) return;
  336. this.catalogCategoryIndex.set(index);
  337. this.loadNodes();
  338. }
  339. toggleNode(node: SourceItem): void {
  340. const id = this.nodeId(node);
  341. if (!id) return;
  342. this.selectedNodes.update((current) => {
  343. const next = new Set(current);
  344. if (next.has(id)) next.delete(id); else next.add(id);
  345. return next;
  346. });
  347. }
  348. startNodeSelection(): void {
  349. if (!this.selectedNodes().size) {
  350. this.platform.toast('请选择学习单元', 'error');
  351. return;
  352. }
  353. void this.router.navigate(['/pages/stu/stu_abc'], {
  354. queryParams: {
  355. nids: [...this.selectedNodes()].join(','),
  356. pnid: this.query('nid'),
  357. orderId: this.query('orderId'),
  358. },
  359. });
  360. }
  361. nodeId(node: SourceItem): string {
  362. return String(node['NodeID'] ?? node['Nodeid'] ?? node['ID'] ?? node['id'] ?? '');
  363. }
  364. nodeTitle(node: SourceItem): string {
  365. return String(node['NodeName'] ?? node['Title'] ?? node['name'] ?? '词库');
  366. }
  367. nodeCount(node: SourceItem): string {
  368. return String(node['ItemCount'] ?? node['Count'] ?? node['count'] ?? '');
  369. }
  370. nodeProgress(node: SourceItem): string {
  371. const total = this.nodeTotal(node);
  372. return total > 0 ? `已学 ${this.nodeLearned(node)}/${total} · 进度 ${this.nodeProgressPercent(node)}%` : '';
  373. }
  374. nodeLearned(node: SourceItem): number { return Math.max(0, Number(node['yx_word'] ?? 0)); }
  375. nodeTotal(node: SourceItem): number { return Math.max(0, Number(node['total'] ?? 0)); }
  376. nodeProgressPercent(node: SourceItem): number {
  377. const total = this.nodeTotal(node);
  378. return total ? Math.min(100, Math.round((this.nodeLearned(node) / total) * 100)) : 0;
  379. }
  380. toggleWord(word: Word): void {
  381. const shouldSelect = !this.selected().has(word.id);
  382. if (!this.setWordSelected(word, shouldSelect) || !this.isRefineSelectionPage) return;
  383. this.setSelectionAssessment(word, shouldSelect ? 'unknown' : 'known');
  384. }
  385. selectionWordRevealed(word: Word): boolean {
  386. return this.selectionRevealed().has(word.id);
  387. }
  388. revealSelectionWord(word: Word): void {
  389. this.selectionRevealed.update((current) => {
  390. const next = new Set(current);
  391. if (next.has(word.id)) next.delete(word.id);
  392. else next.add(word.id);
  393. return next;
  394. });
  395. this.play(word);
  396. }
  397. markWordKnown(word: Word): void {
  398. this.setWordSelected(word, false);
  399. this.setSelectionAssessment(word, 'known');
  400. }
  401. markWordUnknown(word: Word): void {
  402. if (this.setWordSelected(word, true)) this.setSelectionAssessment(word, 'unknown');
  403. }
  404. toggleAllWords(): void {
  405. if (this.selected().size) {
  406. this.selected.set(new Set());
  407. return;
  408. }
  409. this.selected.set(new Set(this.words().slice(0, this.selectionLimit).map((word) => word.id)));
  410. }
  411. startSelected(): void {
  412. const chosen = this.words().filter((word) => this.selected().has(word.id));
  413. if (!chosen.length) {
  414. this.platform.toast('请至少选择一个单词', 'error');
  415. return;
  416. }
  417. if (this.meta.path === 'pages/stu/stu_abc') {
  418. const student = this.session.student() ?? this.session.user();
  419. if (!student?.userId) {
  420. this.platform.toast('课程信息错误', 'error');
  421. return;
  422. }
  423. this.submitting.set(true);
  424. this.api.post('e_add_words', {
  425. uid: student.userId,
  426. wordsId: chosen.map((word) => word.wordId).join(','),
  427. kcid: this.query('pnid'),
  428. save: 1,
  429. }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  430. next: () => this.beginSelectedFlow(chosen, ''),
  431. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  432. });
  433. return;
  434. }
  435. this.beginSelectedFlow(chosen, this.meta.path === 'pages/member/starshop' ? 'newword' : 'history_word');
  436. }
  437. deleteSelectedNewWords(): void {
  438. const chosen = this.words().filter((word) => this.selected().has(word.id));
  439. if (!chosen.length) {
  440. this.platform.toast('请选择单词', 'error');
  441. return;
  442. }
  443. const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
  444. this.api.post('e_add_words', {
  445. uid: owner?.userId,
  446. wordsId: chosen.map((word) => word.wordId).join(','),
  447. ifnew: 0,
  448. kcid: this.query('kcid'),
  449. }).subscribe({
  450. next: () => {
  451. this.platform.toast('已删除', 'success');
  452. this.load();
  453. },
  454. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  455. });
  456. }
  457. play(word = this.current()): void {
  458. if (!word?.audio) {
  459. this.platform.toast('该单词暂无音频', 'error');
  460. return;
  461. }
  462. this.platform.play(this.wordAudioUrl(word.audio));
  463. }
  464. toggleRecordAnswer(word: Word): void {
  465. this.recordRevealed.update((current) => {
  466. const next = new Set(current);
  467. if (next.has(word.id)) next.delete(word.id);
  468. else next.add(word.id);
  469. return next;
  470. });
  471. this.seen.update((current) => new Set(current).add(word.id));
  472. this.play(word);
  473. }
  474. repeatWord(word: Word, index: number): void {
  475. const count = this.repetitions()[word.id] ?? 0;
  476. if (this.studyStep() === 1 && index > 0) {
  477. const previous = this.words()[index - 1];
  478. if ((this.repetitions()[previous.id] ?? 0) < 3) {
  479. this.platform.toast('前面还有没读完的单词', 'error');
  480. return;
  481. }
  482. }
  483. this.play(word);
  484. if (count < 3) {
  485. this.repetitions.update((value) => ({ ...value, [word.id]: count + 1 }));
  486. }
  487. if (this.words().every((item) => (this.repetitions()[item.id] ?? 0) >= 3)) {
  488. this.finishStudyRound();
  489. }
  490. }
  491. shuffleStudyWords(): void {
  492. this.words.set(this.shuffle(this.words()));
  493. this.repetitions.set({});
  494. }
  495. skipStudyList(): void {
  496. this.navigateStage('/pages/stu/stu3');
  497. }
  498. chooseMatch(side: 'left' | 'right', word: Word): void {
  499. if (side === 'left') {
  500. this.leftChoice.set(word.id);
  501. this.play(word);
  502. } else {
  503. this.rightChoice.set(word.id);
  504. }
  505. const left = side === 'left' ? word.id : this.leftChoice();
  506. const right = side === 'right' ? word.id : this.rightChoice();
  507. if (!left || !right) return;
  508. if (left !== right) {
  509. this.platform.toast('答错了,请再试一次', 'error');
  510. this.leftChoice.set('');
  511. this.rightChoice.set('');
  512. return;
  513. }
  514. this.platform.play(this.api.assetUrl(GAME_AUDIO.success));
  515. this.matchLeft.update((items) => items.filter((item) => item.id !== left));
  516. this.matchRight.update((items) => items.filter((item) => item.id !== right));
  517. this.leftChoice.set('');
  518. this.rightChoice.set('');
  519. if (this.matchLeft().length === 0) this.completed.set(true);
  520. }
  521. chooseMeaning(option: Word): void {
  522. const word = this.current();
  523. if (!word) return;
  524. if (option.id !== word.id) {
  525. this.platform.play(this.api.assetUrl(GAME_AUDIO.error));
  526. this.platform.toast('答错了,请重新听一遍', 'error');
  527. this.play(word);
  528. return;
  529. }
  530. this.correct.update((value) => value + 1);
  531. this.platform.play(this.api.assetUrl(GAME_AUDIO.success));
  532. if (this.currentIndex() >= this.words().length - 1) {
  533. this.completed.set(true);
  534. return;
  535. }
  536. this.currentIndex.update((value) => value + 1);
  537. this.prepareChoiceOptions();
  538. setTimeout(() => this.play(), 150);
  539. }
  540. spellLetter(index: number): void {
  541. if (this.spellingCorrect() || this.letters()[index]?.used) return;
  542. this.letters.update((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, used: true } : item));
  543. const next = this.assembled() + this.letters()[index].value;
  544. this.assembled.set(next);
  545. const word = this.current();
  546. if (!word || next.length < word.word.length) return;
  547. if (next.toLowerCase() === word.word.toLowerCase()) {
  548. this.spellingCorrect.set(true);
  549. this.correct.update((value) => value + 1);
  550. this.platform.play(this.api.assetUrl(GAME_AUDIO.success));
  551. this.platform.toast('拼写正确', 'success');
  552. return;
  553. }
  554. this.platform.play(this.api.assetUrl(GAME_AUDIO.error));
  555. this.platform.toast('拼写错了,再试一次', 'error');
  556. this.prepareSpelling();
  557. }
  558. resetSpelling(): void {
  559. this.prepareSpelling();
  560. }
  561. nextSpellingWord(): void {
  562. if (!this.spellingCorrect()) {
  563. this.platform.toast('请先完成当前单词', 'error');
  564. return;
  565. }
  566. if (this.currentIndex() >= this.words().length - 1) {
  567. this.completed.set(true);
  568. return;
  569. }
  570. this.currentIndex.update((value) => value + 1);
  571. this.prepareSpelling();
  572. this.play();
  573. }
  574. continueAfterStage(): void {
  575. this.completed.set(false);
  576. if (this.isMatchingPage) {
  577. this.navigateStage('/pages/stu/stu4');
  578. } else if (this.isListeningPage) {
  579. const path = Number(this.session.user()?.groupId) === 3 ? '/pages/stu/pl_stu5' : '/pages/stu/stu5';
  580. this.navigateStage(path);
  581. } else if (this.isSpellingPage) {
  582. this.finishSpellingFlow();
  583. }
  584. }
  585. reviewValue(word: Word): 0 | 1 | 2 {
  586. return this.reviewState()[word.id] ?? 0;
  587. }
  588. markReview(word: Word, state: 1 | 2): void {
  589. this.reviewState.update((value) => ({ ...value, [word.id]: state }));
  590. }
  591. markAllKnown(): void {
  592. const allKnown = this.words().every((word) => this.reviewValue(word) === 1);
  593. const next: Record<string, 0 | 1 | 2> = {};
  594. for (const word of this.words()) next[word.id] = allKnown ? 0 : 1;
  595. this.reviewState.set(next);
  596. }
  597. reviewCount(state: 1 | 2): number {
  598. return this.words().filter((word) => this.reviewValue(word) === state).length;
  599. }
  600. submitReview(): void {
  601. if (this.words().some((word) => this.reviewValue(word) === 0)) {
  602. this.platform.toast('请为每个单词选择掌握情况', 'error');
  603. return;
  604. }
  605. if (this.meta.path === 'pages/stu/pl_end') {
  606. this.submitPracticeEnd();
  607. return;
  608. }
  609. this.updateUnitRecord();
  610. this.saveLearningRecord();
  611. }
  612. toggleSeen(word: Word): void {
  613. this.revealed.set(!this.revealed());
  614. this.seen.update((current) => new Set(current).add(word.id));
  615. this.play(word);
  616. this.updateAntiProgress(word);
  617. if (this.seen().size === this.words().length) {
  618. this.platform.toast(`已完成 ${this.words().length} 个单词的复习`, 'success');
  619. }
  620. }
  621. joinAntiWords(): void {
  622. const chosen = this.words().filter((word) => this.selected().has(word.id));
  623. if (!chosen.length) {
  624. this.platform.toast('请选择需要加入生词本的单词', 'error');
  625. return;
  626. }
  627. const existing = this.parseArray(this.myNewWords()['scnr']);
  628. const combined = new Map<string, SourceItem>();
  629. for (const item of existing) combined.set(String(item['GeneralID'] ?? ''), item);
  630. for (const word of chosen) {
  631. combined.set(word.wordId, { GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word), gldy: word['gldy'] });
  632. }
  633. const user = this.session.user();
  634. const content: SourceItem = {
  635. ModelID: 55,
  636. nodeId: 255,
  637. inputer: user?.honeyName,
  638. status: 99,
  639. Hits: combined.size,
  640. title: user?.honeyName,
  641. };
  642. let action = 'content_add';
  643. if (this.myNewWords()['GeneralID']) {
  644. content['GeneralID'] = this.myNewWords()['GeneralID'];
  645. action = 'content_update';
  646. }
  647. this.api.post(action, {
  648. content: JSON.stringify(content),
  649. addon: JSON.stringify({ UserID: user?.userId, scnr: JSON.stringify([...combined.values()]) }),
  650. }).subscribe({
  651. next: () => this.platform.toast('添加成功', 'success'),
  652. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  653. });
  654. }
  655. addRecordWords(): void {
  656. const chosen = this.words().filter((word) => this.selected().has(word.id));
  657. if (!chosen.length) {
  658. this.platform.toast('请选择单词', 'error');
  659. return;
  660. }
  661. const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
  662. this.api.post('e_add_words', {
  663. uid: owner?.userId,
  664. wordsId: chosen.map((word) => word.wordId).join(','),
  665. ifnew: 1,
  666. kcid: this.query('kcid'),
  667. }).subscribe({
  668. next: () => this.platform.toast('添加成功', 'success'),
  669. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  670. });
  671. }
  672. endRecord(): void {
  673. const now = this.dateTime();
  674. this.submitting.set(true);
  675. this.api.post('content_update', {
  676. content: JSON.stringify({ ModelID: 60, nodeId: 388, status: 99, GeneralID: this.query('gid'), UpDateTime: now }),
  677. addon: JSON.stringify({ fxzt: 1, wcsj: now }),
  678. }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  679. next: () => void this.router.navigateByUrl('/pages/stu/stu_calendar'),
  680. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  681. });
  682. }
  683. calendarTitle(item: SourceItem): string {
  684. const title = String(item['kc_title'] ?? item['kctitle'] ?? item['kcmc'] ?? '').trim();
  685. return title ? title.split('@')[0] : '单词复习';
  686. }
  687. calendarStudent(item: SourceItem): string {
  688. return String(item['honeyName'] ?? item['Title'] ?? '').split('@')[0];
  689. }
  690. calendarDate(item: SourceItem): string {
  691. return antiForgetDate(item);
  692. }
  693. calendarFirstLearnedAt(item: SourceItem): string {
  694. const raw = String(
  695. item['learned_at']
  696. ?? item['learnedAt']
  697. ?? item['firstLearnedAt']
  698. ?? item['learned_date']
  699. ?? '',
  700. ).trim();
  701. if (!raw) return '首次学习:时间未记录';
  702. const compactDate = raw.match(/^(\d{4})(\d{2})(\d{2})$/);
  703. if (compactDate) return `首次学习:${compactDate[1]}-${compactDate[2]}-${compactDate[3]}(时间未记录)`;
  704. const dateOnly = raw.match(/^(\d{4}-\d{2}-\d{2})$/);
  705. if (dateOnly) return `首次学习:${dateOnly[1]}(时间未记录)`;
  706. const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)
  707. ? raw
  708. : `${raw.replace(' ', 'T')}+08:00`;
  709. const value = new Date(normalized);
  710. if (Number.isNaN(value.getTime())) return `首次学习:${raw}`;
  711. const parts = new Intl.DateTimeFormat('zh-CN', {
  712. timeZone: 'Asia/Shanghai',
  713. year: 'numeric',
  714. month: '2-digit',
  715. day: '2-digit',
  716. hour: '2-digit',
  717. minute: '2-digit',
  718. hourCycle: 'h23',
  719. }).formatToParts(value);
  720. const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((itemPart) => itemPart.type === type)?.value ?? '';
  721. return `首次学习:${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}`;
  722. }
  723. calendarReviewNumber(item: SourceItem): number {
  724. for (const key of ['fxcs', 'fxxh', 'reviewNo', 'reviewIndex', 'review_order']) {
  725. const value = Number(item[key]);
  726. if (Number.isFinite(value) && value > 0) return Math.round(value);
  727. }
  728. const recordId = String(item['xxjlid'] ?? '').trim();
  729. if (!recordId) return 1;
  730. const tasks = this.calendarItems()
  731. .filter((candidate) => String(candidate['xxjlid'] ?? '').trim() === recordId)
  732. .sort((left, right) => {
  733. const dateOrder = antiForgetDate(left).localeCompare(antiForgetDate(right));
  734. if (dateOrder) return dateOrder;
  735. return Number(left['GeneralID'] ?? 0) - Number(right['GeneralID'] ?? 0);
  736. });
  737. const index = tasks.indexOf(item);
  738. return index >= 0 ? index + 1 : 1;
  739. }
  740. calendarReady(item: SourceItem): boolean {
  741. const due = String(item['kywsj'] ?? '').slice(0, 10);
  742. return Boolean(due && Date.parse(due) <= Date.parse(this.dateOnly()) && Number(item['fxzt'] ?? 0) !== 1);
  743. }
  744. calendarCompleted(item: SourceItem): boolean {
  745. return antiForgetCompleted(item);
  746. }
  747. selectMemoryDay(day: AntiForgetCalendarDay): void {
  748. this.memorySelectedDate.set(day.date);
  749. if (!day.inCurrentMonth) {
  750. const [year, month] = day.date.split('-').map(Number);
  751. this.memoryMonth.set(new Date(year, month - 1, 1, 12));
  752. }
  753. }
  754. shiftMemoryMonth(amount: number): void {
  755. const current = this.memoryMonth();
  756. const next = new Date(current.getFullYear(), current.getMonth() + amount, 1, 12);
  757. this.memoryMonth.set(next);
  758. const monthKey = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}`;
  759. const firstTask = this.calendarItems().map(antiForgetDate).filter((date) => date.startsWith(monthKey)).sort()[0];
  760. this.memorySelectedDate.set(firstTask || `${monthKey}-01`);
  761. }
  762. showCurrentMemoryMonth(): void {
  763. const today = this.dateOnly();
  764. const [year, month] = today.split('-').map(Number);
  765. this.memoryMonth.set(new Date(year, month - 1, 1, 12));
  766. this.memorySelectedDate.set(today);
  767. }
  768. memoryTaskStatus(item: SourceItem): string {
  769. if (this.calendarCompleted(item)) return '已完成';
  770. return this.calendarReady(item) ? '待复习' : '未到期';
  771. }
  772. isWritingRecord(): boolean {
  773. return String(this.orderInfo()['if_writing'] ?? '') === '1';
  774. }
  775. openCalendar(item: SourceItem): void {
  776. if (!this.calendarReady(item) && !this.calendarCompleted(item)) {
  777. this.platform.toast('还未到复习时间');
  778. return;
  779. }
  780. this.api.post<AppUser>('user_get', { uid: item['yhid'] }).subscribe({
  781. next: ({ result }) => {
  782. const student = (Array.isArray(result) ? result[0] : result) as AppUser;
  783. this.session.setStudent(student);
  784. void this.router.navigate(['/pages/stu/record'], {
  785. queryParams: { id: item['xxjlid'], gid: item['GeneralID'], kcid: item['kcid'] },
  786. });
  787. },
  788. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  789. });
  790. }
  791. startExam(): void {
  792. if (!this.questions().length) return;
  793. this.examStarted.set(true);
  794. this.examSubmitted.set(false);
  795. this.currentIndex.set(0);
  796. this.startExamTimer();
  797. }
  798. answerExam(option: ExamOption, optionIndex: number): void {
  799. const question = this.currentQuestion();
  800. if (!question) return;
  801. const options = question.QOption.map((item, index) => ({ ...item, checked: index === optionIndex }));
  802. this.questions.update((items) => items.map((item, index) => index === this.currentIndex() ? { ...item, QOption: options, checked: true } : item));
  803. const result: 0 | 1 = this.cleanText(option.text) === this.cleanText(question.QContent) ? 1 : 0;
  804. this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: option.value, result });
  805. this.advanceExam();
  806. }
  807. skipExamQuestion(): void {
  808. const question = this.currentQuestion();
  809. if (!question) return;
  810. this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 });
  811. this.advanceExam();
  812. }
  813. submitExam(): void {
  814. this.stopExamTimer();
  815. const ask = this.askInfo();
  816. const user = this.session.user();
  817. const answers = this.answers();
  818. const mu = {
  819. AskID: ask['ID'],
  820. Answer: JSON.stringify(answers),
  821. Remin: '',
  822. ZType: 0,
  823. Zstatus: 0,
  824. Source: '',
  825. UserID: user?.userId,
  826. CDate: this.dateTime(true),
  827. };
  828. this.submitting.set(true);
  829. this.api.post<string | number>('vote_add', { mu: JSON.stringify(mu) }).subscribe({
  830. next: ({ result }) => this.saveExamLog(result),
  831. error: (error: ApiError) => {
  832. this.submitting.set(false);
  833. this.platform.toast(error.message, 'error');
  834. },
  835. });
  836. }
  837. restart(): void {
  838. if (this.isExamPage) {
  839. this.loadExam();
  840. return;
  841. }
  842. this.load();
  843. }
  844. leave(): void {
  845. this.clearLearningStorage();
  846. void this.router.navigateByUrl('/pages/home/index');
  847. }
  848. private loadNodes(): void {
  849. this.loading.set(true);
  850. this.error.set('');
  851. this.nodes.set([]);
  852. this.selectedNodes.set(new Set());
  853. const pid = this.isCatalogPage
  854. ? WORD_CATALOG_CATEGORIES[this.catalogCategoryIndex()].pid
  855. : this.query('nid') || this.query('pid');
  856. const params: Record<string, unknown> = { pid, cpage: 1, psize: 100 };
  857. if (this.isUnitListPage) {
  858. params['ifunit'] = 1;
  859. params['userId'] = this.session.user()?.userId;
  860. params['bindingId'] = this.query('bindingId');
  861. }
  862. this.api.get<unknown>('node_list', params).pipe(
  863. switchMap(({ result }) => {
  864. const nodes = this.findList(result);
  865. if (!this.isUnitListPage || !nodes.length) return of(nodes);
  866. const learner = this.session.student() ?? this.session.user();
  867. const nodeIds = nodes.map((node) => this.nodeId(node)).filter(Boolean);
  868. if (!learner?.userId || !nodeIds.length) return of(nodes);
  869. return this.api.get<unknown>('e_ck_list', {
  870. checkwords: 2,
  871. nids: nodeIds.join(','),
  872. modelId: 52,
  873. cpage: 1,
  874. psize: 10000,
  875. uid: learner.userId,
  876. }).pipe(
  877. map(({ result: words }) => this.mergeUnitProgress(nodes, this.findList(words))),
  878. catchError(() => of(nodes)),
  879. );
  880. }),
  881. finalize(() => this.loading.set(false)),
  882. ).subscribe({
  883. next: (nodes) => {
  884. if (this.isUnitListPage && !this.canStartPointReading && !nodes.length && pid) {
  885. void this.router.navigate(['/pages/stu/stu_con'], {
  886. queryParams: { nid: pid, readonly: 1 },
  887. replaceUrl: true,
  888. });
  889. return;
  890. }
  891. this.nodes.set(nodes);
  892. },
  893. error: () => this.error.set(this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试'),
  894. });
  895. }
  896. private mergeUnitProgress(nodes: SourceItem[], words: SourceItem[]): SourceItem[] {
  897. const totals = new Map<string, number>();
  898. const learned = new Map<string, number>();
  899. for (const word of words) {
  900. const nodeId = String(word['NodeID'] ?? word['nodeId'] ?? '');
  901. if (!nodeId) continue;
  902. totals.set(nodeId, (totals.get(nodeId) ?? 0) + 1);
  903. if (Number(word['w_learned'] ?? 0) > 0) learned.set(nodeId, (learned.get(nodeId) ?? 0) + 1);
  904. }
  905. return nodes.map((node) => {
  906. const nodeId = this.nodeId(node);
  907. return {
  908. ...node,
  909. total: Math.max(Number(node['total'] ?? 0), totals.get(nodeId) ?? 0),
  910. yx_word: Math.max(Number(node['yx_word'] ?? 0), learned.get(nodeId) ?? 0),
  911. };
  912. });
  913. }
  914. private loadSelection(): void {
  915. if (this.meta.path === 'pages/stu/stu_abc') {
  916. const student = this.session.student() ?? this.session.user();
  917. this.api.get<unknown>('e_ck_list', {
  918. checkwords: 2,
  919. nids: this.query('nids') || this.query('nid'),
  920. modelId: 52,
  921. cpage: 1,
  922. psize: 300,
  923. uid: student?.userId,
  924. }).pipe(finalize(() => this.loading.set(false))).subscribe({
  925. next: ({ result }) => this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
  926. error: (error: ApiError) => this.error.set(error.message),
  927. });
  928. return;
  929. }
  930. if (this.meta.path === 'pages/his/his_words') {
  931. this.api.get<unknown>('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({
  932. next: ({ result, addon }) => {
  933. this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word));
  934. const sourceAddon = this.findList(addon)[0];
  935. if (sourceAddon) this.orderInfo.set(sourceAddon);
  936. },
  937. error: (error: ApiError) => this.error.set(error.message),
  938. });
  939. return;
  940. }
  941. const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
  942. this.api.get<unknown>('e_words_list', { uid: owner?.userId, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({
  943. next: ({ result }) => this.words.set(this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word)),
  944. error: (error: ApiError) => this.error.set(error.message),
  945. });
  946. }
  947. private loadStageWords(): void {
  948. const nid = this.query('nid');
  949. if (nid && this.isReadOnlyWordView) {
  950. const learner = this.session.student() ?? this.session.user();
  951. this.api.get<unknown>('e_ck_list', {
  952. checkwords: 2,
  953. nids: nid,
  954. modelId: 52,
  955. cpage: 1,
  956. psize: 500,
  957. uid: learner?.userId,
  958. }).pipe(finalize(() => this.loading.set(false))).subscribe({
  959. next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
  960. error: (error: ApiError) => this.error.set(error.message),
  961. });
  962. return;
  963. }
  964. if (nid) {
  965. this.api.get<unknown>('content_list', { nid, modelId: 52, cpage: 1, psize: 300 }).pipe(finalize(() => this.loading.set(false))).subscribe({
  966. next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
  967. error: (error: ApiError) => this.error.set(error.message),
  968. });
  969. return;
  970. }
  971. const stored = this.readLearningWords();
  972. this.loading.set(false);
  973. if (!stored.length) {
  974. this.error.set('请先选择本次学习的单词');
  975. return;
  976. }
  977. this.initializeStage(stored);
  978. }
  979. private initializeStage(source: Word[]): void {
  980. const words = this.isStudyListPage ? source : this.shuffle(source);
  981. this.words.set(words);
  982. if (this.isStudyListPage) {
  983. this.studyStep.set(1);
  984. this.repetitions.set({});
  985. } else if (this.isMatchingPage) {
  986. this.matchLeft.set(this.shuffle(words));
  987. this.matchRight.set(this.shuffle(words));
  988. } else if (this.isListeningPage) {
  989. this.prepareChoiceOptions();
  990. setTimeout(() => this.play(), 120);
  991. } else if (this.isSpellingPage) {
  992. this.prepareSpelling();
  993. setTimeout(() => this.play(), 120);
  994. }
  995. }
  996. private finishStudyRound(): void {
  997. if (this.studyStep() < 3) {
  998. const next = (this.studyStep() + 1) as 2 | 3;
  999. this.studyStep.set(next);
  1000. this.words.set(next === 2 ? [...this.words()].reverse() : this.shuffle(this.words()));
  1001. this.repetitions.set({});
  1002. this.platform.toast(next === 2 ? '进入乱序学习' : '进入中文挑战', 'success');
  1003. return;
  1004. }
  1005. this.navigateStage('/pages/stu/stu3');
  1006. }
  1007. private prepareChoiceOptions(): void {
  1008. const current = this.current();
  1009. if (!current) {
  1010. this.choiceOptions.set([]);
  1011. return;
  1012. }
  1013. const distractors = this.shuffle(this.words().filter((word) => word.id !== current.id)).slice(0, 3);
  1014. this.choiceOptions.set(this.shuffle([current, ...distractors]));
  1015. if (distractors.length < 3) {
  1016. this.api.get<unknown>('content_list', { modelId: 52, orders: 'NEWID()', cpage: 1, psize: 8 }).subscribe({
  1017. next: ({ result }) => {
  1018. const extras = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.id !== current.id && !distractors.some((item) => item.id === word.id));
  1019. this.choiceOptions.set(this.shuffle([current, ...distractors, ...extras]).slice(0, 4));
  1020. },
  1021. error: () => undefined,
  1022. });
  1023. }
  1024. }
  1025. private prepareSpelling(): void {
  1026. const word = this.current();
  1027. this.assembled.set('');
  1028. this.spellingCorrect.set(false);
  1029. this.letters.set(this.shuffle((word?.word ?? '').split('').map((value) => ({ value, used: false }))));
  1030. }
  1031. private finishSpellingFlow(): void {
  1032. const totalPages = Number(this.storageGet('_page_total_page') ?? 0);
  1033. const currentPage = Number(this.storageGet('_page_current_learn') ?? 0);
  1034. if (totalPages > currentPage) {
  1035. this.storageSet('_page_current_learn', currentPage + 1);
  1036. this.navigateStage('/pages/stu/stu_con');
  1037. return;
  1038. }
  1039. const mode = String(this.storageGet('learn_mode') ?? '');
  1040. if (mode === 'newword') {
  1041. void this.router.navigateByUrl('/pages/member/starshop');
  1042. } else if (mode === 'history_word') {
  1043. void this.router.navigateByUrl('/pages/his/his');
  1044. } else if (this.meta.path === 'pages/stu/pl_stu5') {
  1045. this.createPracticeLearningRecord();
  1046. } else {
  1047. this.navigateStage('/pages/stu/stu6');
  1048. }
  1049. }
  1050. private loadReviewWords(): void {
  1051. const words = this.readAllLearningWords();
  1052. this.words.set(words);
  1053. this.loading.set(false);
  1054. if (!words.length) {
  1055. this.error.set('请先选择本次学习的单词');
  1056. return;
  1057. }
  1058. this.updateUnitRecord();
  1059. const user = this.session.user();
  1060. this.api.get<unknown>('content_list', {
  1061. modelId: 56,
  1062. psize: 2,
  1063. myfield2: `UserId=${user?.userId}|dqrq=${this.compactDate()}`,
  1064. }).subscribe({
  1065. next: ({ result }) => this.learnedRecord.set(this.findList(result)[0] ?? {}),
  1066. error: () => undefined,
  1067. });
  1068. }
  1069. private updateUnitRecord(): void {
  1070. const nids = this.query('nids');
  1071. const bookId = this.query('pnid');
  1072. if (!nids || !bookId) return;
  1073. const wordIds: Record<string, string> = {};
  1074. for (const nodeId of nids.split(',')) {
  1075. wordIds[nodeId] = this.words().filter((word) => word.nodeId === nodeId).map((word) => word.wordId).join(',');
  1076. }
  1077. this.api.post('unit_record_update', {
  1078. nids,
  1079. userId: this.session.user()?.userId,
  1080. bookId,
  1081. wordIds: JSON.stringify(wordIds),
  1082. }).subscribe({ error: () => undefined });
  1083. }
  1084. private saveLearningRecord(): void {
  1085. const user = this.session.user();
  1086. const student = Number(user?.groupId) === 3 ? this.session.student() : null;
  1087. const owner = student ?? user;
  1088. const existing = this.learnedRecord();
  1089. const oldItems = this.parseArray(existing['xxqs']);
  1090. const records = new Map<string, SourceItem>();
  1091. for (const item of oldItems) records.set(String(item['GeneralID'] ?? ''), item);
  1092. for (const word of this.words()) {
  1093. records.set(word.wordId, { GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) });
  1094. }
  1095. const xxqs = [...records.values()];
  1096. const learned = xxqs.length;
  1097. const ygg = xxqs.filter((item) => Number(item['check']) === 1).length;
  1098. const djq = xxqs.filter((item) => Number(item['check']) === 2).length;
  1099. const content: SourceItem = {
  1100. ModelID: 56,
  1101. nodeId: 291,
  1102. inputer: user?.honeyName,
  1103. status: 99,
  1104. Hits: learned,
  1105. title: owner?.honeyName,
  1106. };
  1107. let action = 'content_add_zt';
  1108. if (existing['GeneralID']) {
  1109. content['GeneralID'] = existing['GeneralID'];
  1110. content['UpDateTime'] = this.dateOnly();
  1111. action = 'content_update';
  1112. }
  1113. const addon: SourceItem = {
  1114. con: `完成了${learned}个单词的学习@${this.dateTime()}`,
  1115. dqrq: this.compactDate(),
  1116. pl: student ? user?.userId : 0,
  1117. UserID: owner?.userId,
  1118. learned,
  1119. ygg,
  1120. djq,
  1121. xxqs: JSON.stringify(xxqs),
  1122. };
  1123. if (!existing['xxqs']) addon['fxrl'] = '';
  1124. this.submitting.set(true);
  1125. this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1126. next: () => {
  1127. this.platform.toast('提交成功', 'success');
  1128. void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') } });
  1129. },
  1130. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1131. });
  1132. }
  1133. private loadPracticeEnd(): void {
  1134. const orderId = this.query('orderId') || this.query('id');
  1135. const student = this.session.student();
  1136. const user = this.session.user();
  1137. this.api.get<unknown>('e_order_detail', { id: orderId }).subscribe({
  1138. next: ({ result }) => this.orderInfo.set(this.findList(result)[0] ?? {}),
  1139. error: () => undefined,
  1140. });
  1141. this.api.get<unknown>('content_list', { modelId: 59, psize: 2, myfield2: `yyds=${orderId}|jsmz=${user?.userId}` }).subscribe({
  1142. next: ({ result }) => this.plRecord.set(this.findList(result)[0] ?? {}),
  1143. error: () => undefined,
  1144. });
  1145. this.api.get<unknown>('content_list', { modelId: 56, psize: 1, myfield2: `UserId=${student?.userId}|dsid=${orderId}` }).subscribe({
  1146. next: ({ result }) => {
  1147. const record = this.findList(result)[0] ?? {};
  1148. this.learnedRecord.set(record);
  1149. if (!record['GeneralID']) {
  1150. this.loading.set(false);
  1151. this.error.set('未找到本次陪练学习记录');
  1152. return;
  1153. }
  1154. this.api.get<unknown>('e_record_detail', { id: record['GeneralID'] }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1155. next: ({ result: detail }) => this.words.set(this.findList(detail).map((item) => this.toWord(item)).filter((word) => word.word)),
  1156. error: (error: ApiError) => this.error.set(error.message),
  1157. });
  1158. },
  1159. error: (error: ApiError) => {
  1160. this.loading.set(false);
  1161. this.error.set(error.message);
  1162. },
  1163. });
  1164. }
  1165. private submitPracticeEnd(): void {
  1166. const user = this.session.user();
  1167. const student = this.session.student();
  1168. const owner = Number(user?.groupId) === 3 ? student : user;
  1169. const xxqs = this.words().map((word) => ({ GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) }));
  1170. const addon = {
  1171. learned: xxqs.length,
  1172. ygg: xxqs.filter((item) => item.check === 1).length,
  1173. djq: xxqs.filter((item) => item.check === 2).length,
  1174. xxqs: JSON.stringify(xxqs),
  1175. };
  1176. this.submitting.set(true);
  1177. this.api.post('stu_record_update_v2', {
  1178. orderId: this.query('orderId') || this.query('id'),
  1179. uid: owner?.userId,
  1180. inputer: owner?.honeyName,
  1181. addon: JSON.stringify(addon),
  1182. }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1183. next: () => {
  1184. void this.platform.confirm('即将结束本次学习,确认继续?').then((confirmed) => {
  1185. if (confirmed) this.endPracticeOrder();
  1186. });
  1187. },
  1188. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1189. });
  1190. }
  1191. private endPracticeOrder(): void {
  1192. const orderId = this.query('orderId') || this.query('id');
  1193. const content = { GeneralID: orderId, UpDateTime: this.dateTime() };
  1194. this.submitting.set(true);
  1195. this.api.post('e_order_update_v2', { status: 11, uid: this.session.user()?.userId, content: JSON.stringify(content) }).subscribe({
  1196. next: () => this.savePracticeSession(),
  1197. error: (error: ApiError) => {
  1198. this.submitting.set(false);
  1199. this.platform.toast(error.message, 'error');
  1200. },
  1201. });
  1202. }
  1203. private savePracticeSession(): void {
  1204. const student = this.session.student();
  1205. const user = this.session.user();
  1206. const order = this.orderInfo();
  1207. const content: SourceItem = {
  1208. ModelID: 59,
  1209. nodeId: 296,
  1210. inputer: user?.honeyName,
  1211. status: 99,
  1212. Hits: 0,
  1213. title: student?.honeyName,
  1214. };
  1215. let action = 'content_add';
  1216. if (this.plRecord()['GeneralID']) {
  1217. content['GeneralID'] = this.plRecord()['GeneralID'];
  1218. action = 'content_update';
  1219. }
  1220. const addon = {
  1221. jsmz: user?.userId,
  1222. kcid: order['kcid'],
  1223. xymz: student?.userId,
  1224. kclx: order['dslx_t'],
  1225. yyds: this.query('orderId') || this.query('id'),
  1226. jffs: order['jffs'],
  1227. kcmc: order['kcmc'],
  1228. szmdid: order['szmd'],
  1229. };
  1230. this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1231. next: () => void this.router.navigate(['/pages/shop/get_order'], { queryParams: { id: this.query('orderId') || this.query('id') } }),
  1232. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1233. });
  1234. }
  1235. private createPracticeLearningRecord(): void {
  1236. const allWords = this.readAllLearningWords();
  1237. const user = this.session.user();
  1238. const student = this.session.student();
  1239. const owner = Number(user?.groupId) === 3 ? student : user;
  1240. const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 }));
  1241. const addon = {
  1242. con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`,
  1243. szmdid: this.orderInfo()['szmd'],
  1244. dqrq: this.compactDate(),
  1245. dsid: this.query('orderId'),
  1246. pl: Number(user?.groupId) === 3 ? user?.userId : 0,
  1247. UserID: owner?.userId,
  1248. learned: xxqs.length,
  1249. ygg: 0,
  1250. djq: 0,
  1251. xxqs: JSON.stringify(xxqs),
  1252. };
  1253. this.submitting.set(true);
  1254. this.api.post('stu_record_update_v2', {
  1255. orderId: this.query('orderId'),
  1256. uid: owner?.userId,
  1257. inputer: user?.honeyName,
  1258. addon: JSON.stringify(addon),
  1259. }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1260. next: () => this.navigateStage('/pages/stu/pl_end'),
  1261. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1262. });
  1263. }
  1264. private loadRecord(): void {
  1265. this.api.post<unknown>('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1266. next: ({ result, addon }) => {
  1267. this.words.set(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word));
  1268. this.orderInfo.set(this.findList(addon)[0] ?? {});
  1269. },
  1270. error: (error: ApiError) => this.error.set(error.message),
  1271. });
  1272. }
  1273. private loadCalendar(): void {
  1274. const uid = this.session.user()?.userId;
  1275. this.api.post<unknown>('e_get_21list', { uid, cpage: 1, psize: 500 })
  1276. .pipe(finalize(() => this.loading.set(false)))
  1277. .subscribe({
  1278. next: ({ result }) => {
  1279. const items = mergeAntiForgetItems(this.findList(result), []);
  1280. this.calendarItems.set(items);
  1281. const selectedDate = nearestAntiForgetDate(items);
  1282. const [year, month] = selectedDate.split('-').map(Number);
  1283. this.memorySelectedDate.set(selectedDate);
  1284. this.memoryMonth.set(new Date(year, month - 1, 1, 12));
  1285. },
  1286. error: () => this.error.set('抗遗忘数据暂时无法加载,请稍后重试'),
  1287. });
  1288. this.api.post('e_get_21list_tj', { uid }).subscribe({ error: () => undefined });
  1289. }
  1290. private loadAntiForget(): void {
  1291. const user = this.session.user();
  1292. const date = this.query('date') || this.compactDate();
  1293. this.antiStartedAt = Date.now();
  1294. this.api.get<unknown>('content_list', {
  1295. nid: 76,
  1296. modelId: 54,
  1297. myfield: `fxrl=${date}`,
  1298. myfield2: `UserId=${user?.userId}`,
  1299. cpage: 1,
  1300. psize: 30,
  1301. orders: 'CreateTime=asc',
  1302. }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1303. next: ({ result }) => {
  1304. const words: Word[] = [];
  1305. for (const record of this.findList(result)) {
  1306. for (const item of this.parseArray(record['xxqs'])) words.push(this.toWord({ ...item, recordId: record['GeneralID'] }));
  1307. }
  1308. this.words.set(words.filter((word) => word.word));
  1309. },
  1310. error: (error: ApiError) => this.error.set(error.message),
  1311. });
  1312. if (this.query('hid')) {
  1313. this.api.get<unknown>('content_get', { id: this.query('hid') }).subscribe({
  1314. next: ({ result }) => this.antiHistory.set(this.findList(result)[0] ?? {}),
  1315. error: () => undefined,
  1316. });
  1317. }
  1318. this.api.get<unknown>('content_list', { nid: 255, modelId: 55, cpage: 1, psize: 30, myfield2: `UserId=${user?.userId}`, orders: 'CreateTime=asc' }).subscribe({
  1319. next: ({ result }) => this.myNewWords.set(this.findList(result)[0] ?? {}),
  1320. error: () => undefined,
  1321. });
  1322. }
  1323. private updateAntiProgress(word: Word): void {
  1324. const historyId = this.query('hid');
  1325. if (!historyId) return;
  1326. const seenWords = this.words().filter((item) => this.seen().has(item.id));
  1327. const previousIds = String(this.antiHistory()['xxjlid'] ?? '').split(',').filter(Boolean);
  1328. const recordId = String(word['recordId'] ?? '');
  1329. if (recordId && !previousIds.includes(recordId)) previousIds.push(recordId);
  1330. const scqs = seenWords.map((item) => ({ GeneralID: item.wordId, Title: item.word, gldy: item['gldy'] }));
  1331. const wordIds = `,${scqs.map((item) => item.GeneralID).join(',')},`;
  1332. this.api.post('content_update', {
  1333. content: JSON.stringify({ GeneralID: historyId }),
  1334. addon: JSON.stringify({
  1335. xxjlid: previousIds.join(','),
  1336. scqs: JSON.stringify(scqs),
  1337. yxx: scqs.length,
  1338. scqs2: wordIds,
  1339. times: Math.round((Date.now() - this.antiStartedAt) / 1000),
  1340. num: this.words().length,
  1341. }),
  1342. }).subscribe({ error: () => undefined });
  1343. }
  1344. private loadExam(): void {
  1345. this.turnAskId();
  1346. const askId = Number(this.storageGet('askId') ?? 1);
  1347. const user = this.session.user();
  1348. this.api.get<unknown>('vote_ask', { id: askId }).subscribe({
  1349. next: ({ result }) => this.askInfo.set(this.findList(result)[0] ?? {}),
  1350. error: () => undefined,
  1351. });
  1352. this.api.get<unknown>('content_list', { nid: 389, modelId: 61, cpage: 1, psize: 2, myfield2: `UserID=${user?.userId}`, orders: 'CreateTime=desc' }).subscribe({
  1353. next: ({ result }) => this.previousExam.set(this.findList(result)[0] ?? {}),
  1354. error: () => undefined,
  1355. });
  1356. this.api.get<unknown>('vote_question', { askid: askId }).pipe(finalize(() => this.loading.set(false))).subscribe({
  1357. next: ({ result }) => {
  1358. const questions = this.shuffle(this.findList(result).map((item) => this.toQuestion(item)));
  1359. this.questions.set(questions);
  1360. this.answers.set(questions.map((question) => ({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 })));
  1361. },
  1362. error: (error: ApiError) => this.error.set(error.message),
  1363. });
  1364. }
  1365. private turnAskId(): void {
  1366. const current = Number(this.storageGet('askId') ?? 0);
  1367. this.storageSet('askId', current === 1 ? 2 : 1);
  1368. }
  1369. examScoreValue(exam: SourceItem): number {
  1370. return Number(exam['df'] ?? 0);
  1371. }
  1372. examAnalysis(exam: SourceItem): string {
  1373. const score = this.examScoreValue(exam);
  1374. if (score === 0) return '当前处于零基础阶段,可以从最常用的基础词汇开始建立词汇库。';
  1375. if (score <= 2) return '词汇量约为 100-200,处于英语入门或小学一、二年级水平。';
  1376. if (score <= 6) return '词汇量约为 200-400,相当于小学三、四年级水平。';
  1377. if (score <= 12) return '词汇量约为 400-600,相当于小学五、六年级水平。';
  1378. if (score <= 15) return '词汇量约为 600-900,相当于初一水平。';
  1379. if (score <= 18) return '词汇量约为 1000-1500,相当于初二水平。';
  1380. if (score <= 21) return '词汇量约为 1600-1800,相当于初三水平。';
  1381. if (score <= 33) return '词汇量约为 1800-2500,相当于高一水平。';
  1382. if (score <= 39) return '词汇量约为 2500-3000,相当于高二水平。';
  1383. if (score <= 42) return '词汇量约为 3000-3500,相当于高三一般水平。';
  1384. return '词汇量约为 3500-4500,达到高三较高或大学四级水平。';
  1385. }
  1386. examAdvice(exam: SourceItem): string {
  1387. const score = this.examScoreValue(exam);
  1388. if (score <= 6) return '建议先系统学习自然拼读并背诵小学、初中基础词汇,打牢读、认、写基础。';
  1389. if (score <= 12) return '建议重点背诵初中词汇,并通过简单阅读和听力练习持续扩展词汇量。';
  1390. if (score <= 21) return '建议继续背诵初高中核心词汇,配合英文阅读、听力和写作练习巩固运用。';
  1391. if (score <= 39) return '建议学习高二、高三词汇,并通过时文外刊、国家地理和 TED 演讲提升理解与表达。';
  1392. return '建议拓展大学四、六级或雅思托福词汇,通过 BBC、VOA 和英文原著把新词用于真实语境。';
  1393. }
  1394. private setExamAnswer(answer: ExamAnswer): void {
  1395. this.answers.update((items) => items.map((item, index) => index === this.currentIndex() ? answer : item));
  1396. }
  1397. private advanceExam(): void {
  1398. if (this.currentIndex() >= this.questions().length - 1) {
  1399. this.submitExam();
  1400. return;
  1401. }
  1402. this.currentIndex.update((value) => value + 1);
  1403. this.startExamTimer();
  1404. }
  1405. private startExamTimer(): void {
  1406. this.stopExamTimer();
  1407. this.timer.set(10);
  1408. this.examTimer = setInterval(() => {
  1409. if (this.timer() <= 1) {
  1410. this.skipExamQuestion();
  1411. } else {
  1412. this.timer.update((value) => value - 1);
  1413. }
  1414. }, 1000);
  1415. }
  1416. private stopExamTimer(): void {
  1417. if (this.examTimer) clearInterval(this.examTimer);
  1418. this.examTimer = null;
  1419. }
  1420. private saveExamLog(answerId: string | number): void {
  1421. const user = this.session.user();
  1422. const answers = this.answers();
  1423. const score = answers.filter((item) => item.result === 1).length;
  1424. const previous = this.previousExam();
  1425. this.examScore.set(score);
  1426. const content: SourceItem = { ModelID: 61, nodeId: 389, inputer: user?.honeyName, status: 99, Hits: 1, title: user?.honeyName };
  1427. const addon = {
  1428. askid: this.currentQuestion()?.AskID ?? this.askInfo()['ID'],
  1429. prev_score: previous['df'] ?? 0,
  1430. totalScore: answers.length,
  1431. wrong: answers.filter((item) => item.result === 0).length,
  1432. dontKnow: answers.filter((item) => item.result === 2).length,
  1433. answerid: answerId || 0,
  1434. df: score,
  1435. UserID: user?.userId,
  1436. };
  1437. let action = 'content_add';
  1438. if (previous['GeneralID']) {
  1439. content['GeneralID'] = previous['GeneralID'];
  1440. action = 'content_update';
  1441. }
  1442. this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
  1443. next: () => {
  1444. this.examStarted.set(false);
  1445. this.examSubmitted.set(true);
  1446. this.platform.toast('答题结果已保存', 'success');
  1447. },
  1448. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  1449. });
  1450. }
  1451. private beginSelectedFlow(chosen: Word[], mode: '' | 'newword' | 'history_word'): void {
  1452. this.clearLearningStorage();
  1453. if (mode) this.storageSet('learn_mode', mode);
  1454. const pages: Word[][] = [];
  1455. for (let index = 0; index < chosen.length; index += 6) pages.push(chosen.slice(index, index + 6));
  1456. const storageKey = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
  1457. this.storageSet(storageKey, pages[0] ?? chosen);
  1458. this.storageSet('_page_current_learn', 1);
  1459. this.storageSet('_page_total_page', pages.length);
  1460. this.storageSet('_page_word_orilist', pages);
  1461. sessionStorage.setItem('xiaoshu.selectedWords', JSON.stringify(pages[0] ?? chosen));
  1462. void this.router.navigate(['/pages/stu/stu_con'], {
  1463. queryParams: {
  1464. nids: this.query('nids') || this.query('nid'),
  1465. pnid: this.query('pnid'),
  1466. orderId: this.query('orderId'),
  1467. },
  1468. });
  1469. }
  1470. private navigateStage(path: string): void {
  1471. void this.router.navigate([path], {
  1472. queryParams: {
  1473. nid: this.query('nid'),
  1474. nids: this.query('nids'),
  1475. pnid: this.query('pnid'),
  1476. orderId: this.query('orderId') || this.query('id'),
  1477. },
  1478. });
  1479. }
  1480. private readLearningWords(): Word[] {
  1481. const totalPages = Number(this.storageGet('_page_total_page') ?? 0);
  1482. const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
  1483. if (totalPages > 0) {
  1484. const pages = this.parseArray(this.storageGet('_page_word_orilist'));
  1485. const page = pages[currentPage - 1];
  1486. if (Array.isArray(page)) {
  1487. const mode = String(this.storageGet('learn_mode') ?? '');
  1488. const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
  1489. this.storageSet(key, page);
  1490. sessionStorage.setItem('xiaoshu.selectedWords', JSON.stringify(page));
  1491. return page.map((item) => this.toWord(item as SourceItem));
  1492. }
  1493. }
  1494. const mode = String(this.storageGet('learn_mode') ?? '');
  1495. const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
  1496. const stored = this.parseArray(this.storageGet(key));
  1497. if (stored.length) return stored.map((item) => this.toWord(item));
  1498. return this.parseArray(sessionStorage.getItem('xiaoshu.selectedWords')).map((item) => this.toWord(item));
  1499. }
  1500. private readAllLearningWords(): Word[] {
  1501. const pages = this.parseArray(this.storageGet('_page_word_orilist'));
  1502. if (pages.length && pages.every((page) => Array.isArray(page))) {
  1503. return pages.flatMap((page) => (page as SourceItem[]).map((item) => this.toWord(item)));
  1504. }
  1505. return this.readLearningWords();
  1506. }
  1507. private clearLearningStorage(): void {
  1508. for (const key of ['learn_mode', 'choose_word_learn', 'choose_newword_learn', 'choose_hisword_learn', '_page_word_orilist', '_page_total_page', '_page_current_learn']) {
  1509. localStorage.removeItem(`xiaoshu.learning.${key}`);
  1510. }
  1511. sessionStorage.removeItem('xiaoshu.selectedWords');
  1512. }
  1513. private resetPageState(): void {
  1514. this.stopExamTimer();
  1515. this.nodes.set([]);
  1516. this.selectedNodes.set(new Set());
  1517. this.words.set([]);
  1518. this.selected.set(new Set());
  1519. this.selectionRevealed.set(new Set());
  1520. this.selectionAssessment.set({});
  1521. this.currentIndex.set(0);
  1522. this.revealed.set(false);
  1523. this.completed.set(false);
  1524. this.correct.set(0);
  1525. this.unknown.set([]);
  1526. this.studyStep.set(1);
  1527. this.repetitions.set({});
  1528. this.matchLeft.set([]);
  1529. this.matchRight.set([]);
  1530. this.leftChoice.set('');
  1531. this.rightChoice.set('');
  1532. this.choiceOptions.set([]);
  1533. this.reviewState.set({});
  1534. this.seen.set(new Set());
  1535. this.recordRevealed.set(new Set());
  1536. this.calendarItems.set([]);
  1537. this.examStarted.set(false);
  1538. this.examSubmitted.set(false);
  1539. }
  1540. private toWord(item: SourceItem): Word {
  1541. const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail'];
  1542. const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {};
  1543. const source = { ...detail, ...item };
  1544. const id = String(item['GeneralID'] ?? detail['GeneralID'] ?? item['ID'] ?? item['id'] ?? item['gid'] ?? item['Title'] ?? detail['Title'] ?? '');
  1545. const wordId = String(detail['GeneralID'] ?? item['GeneralID'] ?? item['gldy'] ?? id);
  1546. return {
  1547. ...source,
  1548. detail,
  1549. id,
  1550. wordId,
  1551. nodeId: String(item['NodeID'] ?? detail['NodeID'] ?? item['nodeId'] ?? ''),
  1552. word: String(item['Title'] ?? detail['Title'] ?? item['word'] ?? detail['word'] ?? item['Word'] ?? ''),
  1553. meaning: String(item['sy'] ?? detail['sy'] ?? item['jssy'] ?? detail['jssy'] ?? item['Translate'] ?? detail['Translate'] ?? item['Meaning'] ?? ''),
  1554. phonetic: String(item['yb'] ?? detail['yb'] ?? item['Phonetic'] ?? detail['Phonetic'] ?? ''),
  1555. audio: String(item['yp'] ?? detail['yp'] ?? item['dy'] ?? detail['dy'] ?? ''),
  1556. };
  1557. }
  1558. private toNewWord(item: SourceItem): Word {
  1559. const detailValue = Array.isArray(item['detail']) ? item['detail'][0] : item['detail'];
  1560. const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {};
  1561. const detailTitle = String(detail['Title'] ?? '').trim();
  1562. const storedTitle = String(item['Title'] ?? '').split('@')[0].trim();
  1563. return this.toWord({ ...item, Title: detailTitle || storedTitle });
  1564. }
  1565. private toQuestion(item: SourceItem): ExamQuestion {
  1566. let options: ExamOption[] = [];
  1567. const raw = item['QOption'];
  1568. if (Array.isArray(raw)) {
  1569. options = raw as ExamOption[];
  1570. } else if (typeof raw === 'string') {
  1571. try { options = JSON.parse(raw) as ExamOption[]; } catch { options = []; }
  1572. }
  1573. return {
  1574. ...item,
  1575. ID: String(item['ID'] ?? item['GeneralID'] ?? ''),
  1576. AskID: String(item['AskID'] ?? ''),
  1577. QType: String(item['QType'] ?? ''),
  1578. QTitle: String(item['QTitle'] ?? ''),
  1579. QContent: this.cleanText(String(item['QContent'] ?? '')),
  1580. QOption: options,
  1581. };
  1582. }
  1583. private wordAudioUrl(path: string): string {
  1584. if (/^(data:|https?:\/\/|\/)/i.test(path)) return this.api.assetUrl(path);
  1585. return this.api.assetUrl(`UploadFiles/${path}`);
  1586. }
  1587. private setWordSelected(word: Word, shouldSelect: boolean): boolean {
  1588. const current = this.selected();
  1589. if (shouldSelect && !current.has(word.id) && current.size >= this.selectionLimit) {
  1590. this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error');
  1591. return false;
  1592. }
  1593. const next = new Set(current);
  1594. if (shouldSelect) next.add(word.id);
  1595. else next.delete(word.id);
  1596. this.selected.set(next);
  1597. return true;
  1598. }
  1599. private setSelectionAssessment(word: Word, assessment: 'known' | 'unknown'): void {
  1600. this.selectionAssessment.update((current) => ({ ...current, [word.id]: assessment }));
  1601. }
  1602. private findList(value: unknown): SourceItem[] {
  1603. if (Array.isArray(value)) return value as SourceItem[];
  1604. if (value && typeof value === 'object') {
  1605. for (const nested of Object.values(value)) {
  1606. if (Array.isArray(nested)) return nested as SourceItem[];
  1607. if (nested && typeof nested === 'object') {
  1608. for (const child of Object.values(nested)) if (Array.isArray(child)) return child as SourceItem[];
  1609. }
  1610. }
  1611. }
  1612. return [];
  1613. }
  1614. private parseArray(value: unknown): SourceItem[] {
  1615. if (Array.isArray(value)) return value as SourceItem[];
  1616. if (typeof value !== 'string' || !value) return [];
  1617. try {
  1618. const parsed = JSON.parse(value) as unknown;
  1619. return Array.isArray(parsed) ? parsed as SourceItem[] : [];
  1620. } catch {
  1621. return [];
  1622. }
  1623. }
  1624. private shuffle<T>(items: T[]): T[] {
  1625. const copy = [...items];
  1626. for (let index = copy.length - 1; index > 0; index -= 1) {
  1627. const target = Math.floor(Math.random() * (index + 1));
  1628. [copy[index], copy[target]] = [copy[target], copy[index]];
  1629. }
  1630. return copy;
  1631. }
  1632. private storageGet(key: string): unknown {
  1633. const raw = localStorage.getItem(`xiaoshu.learning.${key}`);
  1634. if (raw === null) return null;
  1635. try { return JSON.parse(raw) as unknown; } catch { return raw; }
  1636. }
  1637. private storageSet(key: string, value: unknown): void {
  1638. localStorage.setItem(`xiaoshu.learning.${key}`, JSON.stringify(value));
  1639. }
  1640. private query(name: string): string {
  1641. return this.route.snapshot.queryParamMap.get(name) ?? '';
  1642. }
  1643. private cleanText(value: string): string {
  1644. return value.replace(/<\/?[^>]*>/g, '').replace(/&nbsp;/gi, '').trim();
  1645. }
  1646. private dateOnly(): string {
  1647. const date = new Date();
  1648. return `${date.getFullYear()}-${this.pad(date.getMonth() + 1)}-${this.pad(date.getDate())}`;
  1649. }
  1650. private compactDate(): string {
  1651. return this.dateOnly().replaceAll('-', '');
  1652. }
  1653. private dateTime(seconds = false): string {
  1654. const date = new Date();
  1655. return `${this.dateOnly()} ${this.pad(date.getHours())}:${this.pad(date.getMinutes())}${seconds ? `:${this.pad(date.getSeconds())}` : ''}`;
  1656. }
  1657. private pad(value: number): string {
  1658. return String(value).padStart(2, '0');
  1659. }
  1660. }