Sfoglia il codice sorgente

fix: unify student read-only vocabulary access

彭峰 3 settimane fa
parent
commit
601bdc7231

+ 13 - 0
design-qa.md

@@ -49,6 +49,19 @@ final result: passed
 
 ---
 
+# Design QA — 学生只读单词列表
+
+- source screenshot: `/var/folders/yx/cq4gbk4x6w702x_slllw80mw0000gn/T/TemporaryItems/NSIRD_screencaptureui_1C51Xy/截屏2026-09-04 18.04.48.png`
+- implementation screenshot: `/Users/mac/Desktop/编程项目/xiaoshu-angular/docs/design-qa/student-readonly-word-list-after.jpg`
+- viewport: 564 CSS px wide mobile view(源图为 2× 像素密度;验收图为 1×)
+- tested state: 学习记录 `GeneralID=853921`,30 个单词,学生账号只读模式
+- visual check: 内容已收进独立白色卡片;左右各保留 14px 页面间距;顶部品牌色、圆角、边框、阴影和列表分隔线与移动端现有卡片规范一致;长释义不会越界。
+- interaction check: 学习记录和伴学中心均可进入只读词库;页面不出现选词、播放、跟读或开始学习按钮;无下级单元的词库会直接打开只读单词列表。
+
+final result: passed
+
+---
+
 # Design QA:运营工作台指标与趋势合并
 
 ## Evidence

BIN
docs/design-qa/student-readonly-word-list-after.jpg


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

@@ -214,6 +214,12 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('n. 苹果');
     expect(harness.routeNativeElement?.querySelector('.repeat-list')).toBeNull();
 
+    await harness.navigateByUrl('/pages/stu/stu_list?nid=999&bindingId=799');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_con?nid=999&readonly=1');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读');
+
     await harness.navigateByUrl('/pages/stu/stu_calendar');
     expect(harness.fixture.nativeElement.querySelector('.tabbar')).toBeNull();
     expect(harness.routeNativeElement?.querySelectorAll('.memory-date').length).toBe(42);
@@ -336,11 +342,12 @@ describe('home learning modules', () => {
     const learningRecordCard = harness.routeNativeElement?.querySelector('.learning-record-card') as HTMLElement;
     expect(currentLearningCard.textContent).toContain('我的学习内容');
     expect(currentLearningCard.querySelectorAll('.current-learning-list button').length).toBe(2);
+    expect((currentLearningCard.querySelectorAll('.current-learning-list button')[1] as HTMLButtonElement).disabled).toBeFalse();
     expect(currentLearningCard.compareDocumentPosition(learningRecordCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
     (currentLearningCard.querySelector('.current-learning-list button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
-    expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801');
+    expect(TestBed.inject(Router).url).toBe('/pages/stu/stu_list?nid=801&bindingId=701');
     expect(harness.routeNativeElement?.querySelectorAll('.node-list button').length).toBe(2);
 
     await harness.navigateByUrl('/pages/his/his');
@@ -351,6 +358,7 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     expect(TestBed.inject(Router).url).toBe('/pages/his/his_words?id=201');
     expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('学生只读');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-card')).not.toBeNull();
     expect(harness.routeNativeElement?.querySelector('.selection-header')).toBeNull();
     await harness.navigateByUrl('/pages/his/his');
     (harness.routeNativeElement?.querySelector('.date-trigger') as HTMLButtonElement).click();

+ 17 - 15
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -56,21 +56,23 @@
       </div>
     }
   } @else if (isReadOnlyWordView) {
-    <header class="readonly-word-header">
-      <div><strong>单词列表</strong><span>共 {{ words().length }} 个单词</span></div>
-      <em>学生只读</em>
-    </header>
-    <div class="readonly-word-list">
-      @for (word of words(); track word.id) {
-        <article>
-          <div><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</div>
-          <p>{{ word.meaning || '暂无释义' }}</p>
-        </article>
-      } @empty {
-        <div class="empty-state"><p>该单元暂无单词</p></div>
-      }
-    </div>
-    <p class="readonly-word-note">点读、跟读和学习记录由陪练老师在上课时操作</p>
+    <section class="readonly-word-card" aria-labelledby="readonly-word-title">
+      <header class="readonly-word-header">
+        <div><strong id="readonly-word-title">单词列表</strong><span>共 {{ words().length }} 个单词</span></div>
+        <em>学生只读</em>
+      </header>
+      <div class="readonly-word-list">
+        @for (word of words(); track word.id) {
+          <article>
+            <div><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</div>
+            <p>{{ word.meaning || '暂无释义' }}</p>
+          </article>
+        } @empty {
+          <div class="empty-state"><p>该单元暂无单词</p></div>
+        }
+      </div>
+      <p class="readonly-word-note">学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作</p>
+    </section>
   } @else if (isSelectionPage) {
     <header class="selection-header">
       <div><strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong><span>{{ isRefineSelectionPage ? '先回忆中文,再点击英文核对' : '选择本次要学习的单词' }}</span></div>

+ 10 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -978,7 +978,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       }),
       finalize(() => this.loading.set(false)),
     ).subscribe({
-      next: (nodes) => this.nodes.set(nodes),
+      next: (nodes) => {
+        if (this.isUnitListPage && !this.canStartPointReading && !nodes.length && pid) {
+          void this.router.navigate(['/pages/stu/stu_con'], {
+            queryParams: { nid: pid, readonly: 1 },
+            replaceUrl: true,
+          });
+          return;
+        }
+        this.nodes.set(nodes);
+      },
       error: () => this.error.set(this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试'),
     });
   }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -29,7 +29,7 @@
         <header><div><span id="current-learning-title">正在学习</span><strong>{{ canStartPointReading ? '继续选词学习' : '我的学习内容' }}</strong></div><span>{{ courses().length }} 项内容</span></header>
         <div class="current-learning-list">
           @for (course of visibleCourses(); track $index) {
-            <button type="button" [class.readonly]="!canStartPointReading" [disabled]="!courseId(course)" (click)="openCourse(course)"><i><lucide-icon [img]="icons.BookOpen" [size]="19" /></i><span><strong>{{ courseTitle(course) }}</strong><small>{{ !canStartPointReading ? '点击查看单元与单词列表' : courseId(course) ? '选择本次学习单元' : '暂未绑定可学习词库' }}</small></span><lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></button>
+            <button type="button" [class.readonly]="!canStartPointReading" [disabled]="!canOpenCourse(course)" (click)="openCourse(course)"><i><lucide-icon [img]="icons.BookOpen" [size]="19" /></i><span><strong>{{ courseTitle(course) }}</strong><small>{{ courseHint(course) }}</small></span><lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></button>
           }
         </div>
         @if (courses().length > 3) { <button type="button" class="current-learning-toggle" [class.expanded]="coursesExpanded()" [attr.aria-expanded]="coursesExpanded()" (click)="toggleCoursesExpanded()"><span>{{ coursesExpanded() ? '收起学习内容' : '展开剩余 ' + hiddenCourseCount() + ' 项' }}</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> }

+ 45 - 2
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -210,6 +210,14 @@ export class StudyRecordsPageComponent implements OnInit {
     return String(item['kcid'] ?? item['kcId'] ?? item['NodeID'] ?? item['nodeId'] ?? '');
   }
 
+  courseBindingId(item: Item): string {
+    return String(item['id'] ?? item['bindingId'] ?? item['bindingObjectId'] ?? '');
+  }
+
+  canOpenCourse(item: Item): boolean {
+    return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
+  }
+
   courseTitle(item: Item): string {
     const title = String(item['Title2'] ?? item['Title'] ?? '课程内容');
     if (item['Title2'] || !title.includes('@')) return title;
@@ -218,9 +226,22 @@ export class StudyRecordsPageComponent implements OnInit {
     return `【${first}】${parts.at(-1) ?? ''}`;
   }
 
+  courseHint(item: Item): string {
+    if (!this.canOpenCourse(item)) return '暂未分配可查看的学习内容';
+    if (!this.canStartPointReading) return '点击查看老师分配的单元与单词列表';
+    return '选择本次学习单元';
+  }
+
   openCourse(item: Item): void {
-    const id = this.courseId(item);
-    if (id) void this.router.navigate(['/pages/stu/stu_list'], { queryParams: { nid: id } });
+    if (!this.canOpenCourse(item)) return;
+    const courseId = this.courseId(item);
+    const bindingId = this.courseBindingId(item);
+    void this.router.navigate(['/pages/stu/stu_list'], {
+      queryParams: {
+        nid: courseId || null,
+        bindingId: bindingId || null,
+      },
+    });
   }
 
   openSchedulePlan(item: Item): void {
@@ -247,6 +268,28 @@ export class StudyRecordsPageComponent implements OnInit {
     return canStartTeacherLedLearning(this.session.groupId());
   }
 
+  private assignedUnits(item: Item): Array<{ id: string; name: string }> {
+    const raw = item['syjd'] ?? item['assignedUnits'];
+    let source: unknown = raw;
+    if (typeof raw === 'string' && raw.trim()) {
+      try { source = JSON.parse(raw); } catch { return []; }
+    }
+    const entries = Array.isArray(source)
+      ? source.map((value, index) => [String(index), value] as const)
+      : source && typeof source === 'object'
+        ? Object.entries(source)
+        : [];
+    return entries
+      .map(([key, value]) => {
+        const text = typeof value === 'object' && value
+          ? String((value as Item)['name'] ?? (value as Item)['title'] ?? (value as Item)['NodeName'] ?? '')
+          : String(value ?? '');
+        const id = String((typeof value === 'object' && value ? (value as Item)['id'] ?? (value as Item)['nodeId'] : '') || (/^\d+$/.test(key) ? key : text.split('@')[0]) || '').trim();
+        return { id, name: text };
+      })
+      .filter((unit) => /^\d+$/.test(unit.id));
+  }
+
   openReading(item: Item, event: Event): void {
     event.stopPropagation();
     this.navigateToReading(item);

+ 26 - 0
projects/xiaoshu-mobile/src/styles.scss

@@ -59,6 +59,32 @@ select { font: inherit; }
 button,
 a { -webkit-tap-highlight-color: transparent; }
 
+/* Shared student read-only vocabulary card used from study history and companion center. */
+.readonly-word-card {
+  margin: 14px 14px 20px;
+  overflow: hidden;
+  background: white;
+  border: 1px solid var(--line);
+  border-radius: var(--radius-lg);
+  box-shadow: var(--shadow-sm);
+}
+.readonly-word-header { display: flex; min-height: 106px; align-items: center; justify-content: space-between; padding: 20px 18px; color: white; background: var(--brand-strong); gap: 14px; }
+.readonly-word-header > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
+.readonly-word-header strong { font-size: 22px; line-height: 1.25; }
+.readonly-word-header span { color: rgb(255 255 255 / 78%); font-size: 13px; }
+.readonly-word-header em { flex: 0 0 auto; padding: 7px 12px; color: var(--brand-deep); background: var(--brand-pale); border-radius: 999px; font-size: 12px; font-style: normal; font-weight: 750; white-space: nowrap; }
+.readonly-word-list article { display: grid; min-height: 88px; grid-template-columns: minmax(112px, .72fr) minmax(0, 1.28fr); align-items: center; padding: 16px 18px; border-bottom: 1px solid var(--line); gap: 18px; }
+.readonly-word-list article:last-child { border-bottom: 0; }
+.readonly-word-list article > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+.readonly-word-list strong { overflow-wrap: anywhere; color: var(--ink); font-size: 18px; line-height: 1.3; }
+.readonly-word-list small { color: var(--ink-soft); font-size: 12px; }
+.readonly-word-list p { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-size: 15px; line-height: 1.6; }
+.readonly-word-note { margin: 0; padding: 13px 18px; color: var(--ink-soft); background: var(--surface-muted); font-size: 12px; line-height: 1.6; }
+@media (max-width: 360px) {
+  .readonly-word-card { margin-inline: 10px; }
+  .readonly-word-list article { grid-template-columns: minmax(92px, .7fr) minmax(0, 1.3fr); padding-inline: 14px; gap: 12px; }
+}
+
 button { cursor: pointer; }
 
 a,