Selaa lähdekoodia

Restore missing words in anti-forget review details

彭峰 2 viikkoa sitten
vanhempi
commit
93391963d6

+ 13 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -543,6 +543,19 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.memory-date-tasks')?.textContent).toContain('9月12日复习');
   });
 
+  it('shows a retryable error instead of an empty review when the detail has no words', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
+      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : post(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=900&gid=102&kcid=801');
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('没有读取到单词');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error button')?.textContent).toContain('重新加载');
+    expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
+  });
+
   it('renders source-shaped data for all four home entries', async () => {
     const harness = await RouterTestingHarness.create();
 

+ 13 - 6
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1554,11 +1554,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private loadRecord(): void {
-    this.api.post<unknown>('e_record_detail', { id: this.query('id') }).pipe(finalize(() => this.loading.set(false))).subscribe({
+    const user = this.session.user();
+    const learnerId = Number(user?.groupId) === 3 ? this.session.student()?.userId : user?.userId;
+    this.api.post<unknown>('e_record_detail', { id: this.query('id'), studentId: learnerId }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result, addon }) => {
         const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
-        const user = this.session.user();
-        const learnerId = Number(user?.groupId) === 3 ? this.session.student()?.userId : user?.userId;
+        if (!words.length) {
+          this.error.set('这条复习任务暂时没有读取到单词,请点击重新加载;任务进度不会被修改。');
+          return;
+        }
+        this.words.set(words);
         const key = `xiaoshu.review.order.${learnerId}.${this.query('id')}`;
         let previous: string[][] = [];
         try {
@@ -1566,9 +1571,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
           if (Array.isArray(stored)) previous = stored.filter((order): order is string[] =>
             Array.isArray(order) && order.every((id) => typeof id === 'string'));
         } catch { /* A damaged or unavailable local cache should not block review. */ }
-        const ordered = nextReviewWordOrder(words, previous, (word) => word.id);
-        this.words.set(ordered.words);
-        try { localStorage.setItem(key, JSON.stringify(ordered.history)); } catch { /* Review still works without local storage. */ }
+        try {
+          const ordered = nextReviewWordOrder(words, previous, (word) => word.id);
+          this.words.set(ordered.words);
+          try { localStorage.setItem(key, JSON.stringify(ordered.history)); } catch { /* Review still works without local storage. */ }
+        } catch { /* Keep the original words visible if ordering fails. */ }
         this.orderInfo.set(this.findList(addon)[0] ?? {});
       },
       error: (error: ApiError) => this.error.set(error.message),

+ 22 - 1
scripts/deploy-admin-functions.mjs

@@ -2559,9 +2559,30 @@ async function orderDetailRow(inputId, current, authorize = true) {
   let source = rows[0]; if (!source && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const direct = await Psql.query('SELECT a.*, n."nodeName" AS "__courseTitle" FROM "CourseAppointment" a LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE a."company" = $1 AND a."objectId" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (direct[0]) source = { ...direct[0], generalId: id, GeneralID: id, title: direct[0].title || '课程预约' }; }
   if (!source) return null; const addon = source.__addon || {}; const courseTitle = source.__courseTitle || source.subtitle || ''; delete source.__addon; delete source.__courseTitle; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.cancelled=number(source.status)===-2; row.kcmc = row.kcmc || courseTitle; row.yykcmc = row.yykcmc || courseTitle; row.HoneyName = row.HoneyName || String(row.Title || '').split('(')[0]; if (authorize) await authorizeBusinessRow(current, row, ['szyh'], ['pl']); return row;
 }
+async function legacyStudyRecordWords(id, ownerId) {
+  try {
+    const config = await appLegacyConfig();
+    if (!config.url || !config.apiId || !config.apiKey) return [];
+    const params = new URLSearchParams({ action:'content_get', id:String(id), apiId:config.apiId, apiKey:config.apiKey });
+    const url = config.url + (config.url.includes('?') ? '&' : '?') + params.toString();
+    const response = await fetch(url, { headers:{ accept:'application/json' }, signal:typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined });
+    if (!response.ok) return [];
+    const payload = await response.json();
+    let result = payload && payload.result;
+    if (typeof result === 'string') result = JSON.parse(result);
+    const record = Array.isArray(result) ? result[0] : Array.isArray(result && result.items) ? result.items[0] : result;
+    if (!record || number(firstValue(record.UserID,record.userId)) !== number(ownerId)) return [];
+    return parseArray(record.xxqs).filter((item) => item && typeof item === 'object');
+  } catch (_) { return []; }
+}
 async function recordDetailData(input, current) {
   const id = String(input.id || input.gid || ''); if (!id) fail(400, '缺少学习记录 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(d) AS "__addon" FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'56\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
-  if (!rows.length) return null; const source = rows[0]; const recordAddon = source.__addon || {}; delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record); const storedWords = parseArray(recordAddon.xxqs); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
+  if (!rows.length) {
+    const targetId = await appLearningTarget(current,input.studentId);
+    const words = await legacyStudyRecordWords(id,targetId);
+    return words.length ? { words:words.map((item) => ({ ...item, Title:item.Title || item.title || item.word || '' })), addon:[] } : null;
+  }
+  const source = rows[0]; const recordAddon = source.__addon || {}; delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeLearningRecordAccess(current, record); let storedWords = parseArray(recordAddon.xxqs); if (!storedWords.length) storedWords = await legacyStudyRecordWords(id,number(record.userId ?? record.UserID)); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
   if (ids.length) wordRows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]);
   const details = new Map(wordRows.map((row) => { const normalized = normalizeWordRow(row); return [String(normalized.GeneralID || normalized.generalId), normalized]; })); const words = storedWords.map((item) => { const raw = item && typeof item === 'object' ? safe(item) : {}; const wordId = String(raw.GeneralID ?? raw.generalId ?? raw.id ?? ''); const detail = details.get(wordId) || legacyAliases({ generalId: wordId, title: raw.Title || raw.title || '' }, 'CommonModel'); return { ...detail, ...raw, GeneralID: number(wordId, wordId), Title: raw.Title || raw.title || detail.Title || detail.title || '', detail: [detail] }; });
   const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };