Browse Source

feat(mobile): show student vocabulary progress in appointment unit selection

彭峰 1 tháng trước cách đây
mục cha
commit
1b83d89192

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

@@ -18,6 +18,10 @@ class LearningModulesApiService {
   lastLearningWordUpdate: Record<string, unknown> = {};
   lastMemoryParams: Record<string, unknown> = {};
   lastStandaloneLearningRecord: Record<string, unknown> = {};
+  unitRows: Record<string, unknown>[] = [
+    { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
+    { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
+  ];
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     if (action === 'e_order_detail') {
@@ -75,10 +79,7 @@ class LearningModulesApiService {
     }
     if (action === 'node_list' && Number(params['pid']) === 801) {
       this.lastNodeParams = params;
-      return this.result<T>([
-        { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
-        { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
-      ]);
+      return this.result<T>(this.unitRows);
     }
     if (action === 'e_ck_list') {
       this.lastWordParams = params;
@@ -700,6 +701,43 @@ describe('home learning modules', () => {
     expect(courseSelect.value).toBe('801');
     expect(courseSelect.selectedOptions[0]?.textContent).toContain('七年级上册');
     expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
+    const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
+    expect(unitButtons?.[0].textContent).toContain('已学 5%');
+    expect(unitButtons?.[0].textContent).toContain('1/20 词');
+    expect(unitButtons?.[1].textContent).toContain('已学 0%');
+    expect(unitButtons?.[1].textContent).toContain('0/18 词');
+    expect((unitButtons?.[0].querySelector('.unit-progress-track > span') as HTMLElement).style.width).toBe('5%');
+    unitButtons?.[0].click();
+    unitButtons?.[1].click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.unit-select[aria-pressed="true"]').length).toBe(2);
+    expect(unitButtons?.[0].textContent).toContain('已学 5%');
+  });
+
+  it('distinguishes completed, empty and unavailable appointment vocabulary progress', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    api.unitRows = [
+      { NodeID: 811, NodeName: '已学全册', yx_word: '20', total: '20' },
+      { NodeID: 812, NodeName: '空册', yx_word: 0, total: 0 },
+      { NodeID: 813, NodeName: '缺少统计' },
+      { NodeID: 814, NodeName: '异常统计', yx_word: 'invalid', total: 20 },
+    ];
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/shop/get_order?id=901');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+
+    const units = harness.routeNativeElement?.querySelectorAll('.unit-select');
+    expect(units?.[0].textContent).toContain('已学 100%');
+    expect(units?.[0].textContent).toContain('20/20 词');
+    expect(units?.[1].textContent).toContain('已学 0%');
+    expect(units?.[1].textContent).toContain('暂无单词');
+    for (const index of [2, 3]) {
+      expect(units?.[index].textContent).toContain('暂无进度数据');
+      expect(units?.[index].querySelector('.unit-progress-track')).toBeNull();
+    }
   });
 
   it('counts words marked as known as completed and persists them for the student', async () => {

+ 13 - 1
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -26,7 +26,19 @@
         <div class="unit-grid">
           @for (unit of units(); track $index) {
             <article [class.selected]="selectedUnits().has(unitId(unit))" [class.completed]="completedUnits().has(unitId(unit))">
-              <button type="button" class="unit-select" (click)="toggleUnit(unit)"><span class="check">@if (selectedUnits().has(unitId(unit))) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span><strong>{{ unitTitle(unit) }}</strong></button>
+              <button type="button" class="unit-select" [attr.aria-pressed]="selectedUnits().has(unitId(unit))" (click)="toggleUnit(unit)">
+                <span class="check">@if (selectedUnits().has(unitId(unit))) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
+                <span class="unit-copy">
+                  <strong>{{ unitTitle(unit) }}</strong>
+                  @if (unitProgress(unit); as progress) {
+                    <span class="unit-progress-summary"><b>已学 {{ progress.percent }}%</b><span>{{ progress.total > 0 ? progress.learned + '/' + progress.total + ' 词' : '暂无单词' }}</span></span>
+                    <span class="unit-progress-track" aria-hidden="true"><span [style.width.%]="progress.percent"></span></span>
+                  } @else {
+                    <span class="unit-progress-unavailable">暂无进度数据</span>
+                  }
+                  @if (completedUnits().has(unitId(unit))) { <span class="unit-completion-note">已标记学完</span> }
+                </span>
+              </button>
               <button type="button" class="mark-complete" title="标记已学完" aria-label="标记已学完" (click)="markUnitComplete(unit)"><lucide-icon [img]="icons.BookCheck" [size]="17" /></button>
             </article>
           } @empty { <div class="empty-state"><p>请选择课程后加载单元</p></div> }

+ 7 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss

@@ -25,6 +25,13 @@
 .unit-select .check { display: grid; width: 19px; height: 19px; flex: 0 0 19px; color: white; border: 1px solid var(--line); border-radius: 4px; place-items: center; }
 .selected .unit-select .check { background: var(--brand-strong); border-color: var(--brand-strong); }
 .unit-select strong { font-size: 13px; overflow-wrap: anywhere; }
+.unit-copy { display: grid; flex: 1; min-width: 0; gap: 6px; }
+.unit-progress-summary { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 3px 8px; font-size: 11px; }
+.unit-progress-summary b { color: var(--brand-strong); }
+.unit-progress-summary > span, .unit-progress-unavailable { color: var(--ink-soft); font-size: 11px; }
+.unit-progress-track { display: block; height: 4px; overflow: hidden; background: var(--line); border-radius: 4px; }
+.unit-progress-track > span { display: block; height: 100%; background: var(--brand-strong); border-radius: inherit; }
+.unit-completion-note { color: #8a5b12; font-size: 11px; }
 .mark-complete { position: absolute; top: 0; right: 0; display: grid; width: 34px; height: 100%; padding: 0; color: #8a5b12; background: #fff3d9; border: 0; place-items: center; }
 .report-panel h2 { display: flex; align-items: center; margin: 0 0 14px; font-size: 17px; gap: 7px; }
 .report-panel div { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--ink-soft); font-size: 12px; }

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -161,6 +161,20 @@ export class AppointmentPageComponent implements OnInit {
     return String(unit['NodeID'] ?? unit['ID'] ?? '');
   }
 
+  unitProgress(unit: SourceItem): { learned: number; total: number; percent: number } | null {
+    const rawTotal = unit['total'];
+    const rawLearned = unit['yx_word'];
+    if (rawTotal == null || rawTotal === '' || rawLearned == null || rawLearned === '') return null;
+    const total = Number(rawTotal);
+    const learned = Number(rawLearned);
+    if (!Number.isFinite(total) || !Number.isFinite(learned) || total < 0 || learned < 0) return null;
+    return {
+      learned: Math.min(learned, total),
+      total,
+      percent: total > 0 ? Math.min(100, Math.round(learned / total * 100)) : 0,
+    };
+  }
+
   startOrContinue(): void {
     if (!this.isWriting && !this.selectedUnits().size) {
       this.platform.toast('请选择单元', 'error');