Browse Source

feat(mobile): show student and generation date on reading articles

彭峰 1 week ago
parent
commit
6d00f6a1c2

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts

@@ -8,6 +8,7 @@ describe('reading article reuse',()=>{
  it('queries the complete normalized word set, source study and reading configuration',async()=>{const{s,cloud}=setup();cloud.query.and.resolveTo([{objectId:'existing',content:'apple river'}]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('existing');const where=cloud.query.calls.first().args[1];expect(where['createOptions.params.wordListKey']).toBe('apple|river');expect(where['createOptions.params.sourceStudyId']).toBe('201');expect(where['createOptions.params.level']).toBe('初一');expect(where['createOptions.params.type']).toBe('记叙文');expect(where['createOptions.params.theme']).toBe('校园');expect(where['createOptions.params.wordList']).toBeUndefined();expect(cloud.create).not.toHaveBeenCalled();});
  it('does not reuse an article that only overlaps part of the requested vocabulary',async()=>{const{s,cloud}=setup();cloud.query.and.callFake(async(_n:string,w:Record<string,unknown>)=>w['createOptions.params.wordList']?[{objectId:'old-apple-tree'}]:[]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('new');expect(cloud.create).toHaveBeenCalled();});
  it('regenerates on explicit request without querying a reusable article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,renew:true},()=>{});expect(article.objectId).toBe('new');expect(cloud.query).not.toHaveBeenCalled();});
+ it('saves the learner name with each newly generated article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,name:' 林同学 ',renew:true},()=>{});expect(article.createOptions.params.studentName).toBe('林同学');expect(cloud.create).toHaveBeenCalledWith('SurveyItem',jasmine.objectContaining({createOptions:jasmine.objectContaining({params:jasmine.objectContaining({studentName:'林同学'})})}));});
 });
 
 describe('reading question persistence', () => {

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -65,6 +65,7 @@ export class EnglishReadingService {
 
   async createArticle(params: ReadingRouteParams, onProgress: (progress: ReadingProgress) => void): Promise<ReadingArticle> {
     const createParams = {
+      studentName: String(params.name || '').trim(),
       sourceStudyId:params.sourceStudyId||'',
       wordListKey: [...new Set(params.wordList.map(w=>w.trim().toLowerCase()))].sort().join('|'),
       wordList: params.wordList,

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.html

@@ -9,7 +9,7 @@
     <div class="page-state error-state"><lucide-icon [img]="icons.XCircle" [size]="30" /><strong>加载阅读练习失败</strong><p>{{ error() }}</p><button type="button" class="secondary-button" (click)="load()">重新加载</button></div>
   } @else {
     <article class="article-section">
-      <div class="meta-row">@if (article()?.createOptions?.params?.type) { <span>{{ article()?.createOptions?.params?.type }}</span> } @if (article()?.createOptions?.params?.theme) { <span>{{ article()?.createOptions?.params?.theme }}</span> } @if (questions().length) { <span>{{ questions().length }} 道题</span> }</div>
+      <div class="meta-row">@if (article(); as currentArticle) { @if (studentName(currentArticle)) { <span>{{ studentName(currentArticle) }}</span> } @if (currentArticle.createdAt) { <span>{{ currentArticle.createdAt | date:'yyyy-MM-dd' }}</span> } } @if (article()?.createOptions?.params?.type) { <span>{{ article()?.createOptions?.params?.type }}</span> } @if (article()?.createOptions?.params?.theme) { <span>{{ article()?.createOptions?.params?.theme }}</span> } @if (questions().length) { <span>{{ questions().length }} 道题</span> }</div>
       @if (article()?.title) { <h2>{{ article()?.title }}</h2> } @else { <div class="skeleton title-skeleton"></div> }
       @if (article()?.content) { <div class="article-content" [innerHTML]="articleHtml"></div> } @else { <div class="text-skeleton"><i></i><i></i><i></i><i></i></div> }
     </article>
@@ -45,6 +45,6 @@
   }
 </section>
 
-@if (historyOpen()) { <div class="dialog-backdrop" (click)="closeHistory()"><section class="history-dialog" role="dialog" aria-modal="true" (click)="$event.stopPropagation()"><header><div><h2>阅读历史</h2><p>相同生词最多生成三篇文章</p></div><button type="button" (click)="closeHistory()" aria-label="关闭" title="关闭"><lucide-icon [img]="icons.X" [size]="20" /></button></header><div class="history-list">@if (historyLoading()) { <div class="dialog-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24" />正在加载</div> } @else if (!historyItems().length) { <div class="dialog-state"><lucide-icon [img]="icons.BookOpen" [size]="28" />暂无阅读历史</div> } @else { @for (item of historyItems(); track item.article.objectId) { <button type="button" class="history-item" (click)="selectHistory(item)"><div><strong>{{ item.article.title }}</strong><p>{{ item.article.createOptions.params.level }} · {{ item.article.createOptions.params.type }}</p></div><span>{{ item.attemptCount ? item.bestScore + '分' : '未练习' }}</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> } @if (historyHasMore()) { <button type="button" class="load-more" [disabled]="historyLoadingMore()" (click)="loadMoreHistory()">{{ historyLoadingMore() ? '加载中...' : '加载更多' }}</button> } @else { <p class="no-more">已经显示全部历史记录</p> } }</div></section></div> }
+@if (historyOpen()) { <div class="dialog-backdrop" (click)="closeHistory()"><section class="history-dialog" role="dialog" aria-modal="true" (click)="$event.stopPropagation()"><header><div><h2>阅读历史</h2><p>相同生词最多生成三篇文章</p></div><button type="button" (click)="closeHistory()" aria-label="关闭" title="关闭"><lucide-icon [img]="icons.X" [size]="20" /></button></header><div class="history-list">@if (historyLoading()) { <div class="dialog-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24" />正在加载</div> } @else if (!historyItems().length) { <div class="dialog-state"><lucide-icon [img]="icons.BookOpen" [size]="28" />暂无阅读历史</div> } @else { @for (item of historyItems(); track item.article.objectId) { <button type="button" class="history-item" (click)="selectHistory(item)"><div><strong>{{ item.article.title }}</strong><p>@if (studentName(item.article)) { {{ studentName(item.article) }} · }@if (item.article.createdAt) { {{ item.article.createdAt | date:'yyyy-MM-dd' }} · }{{ item.article.createOptions.params.level }} · {{ item.article.createOptions.params.type }}</p></div><span>{{ item.attemptCount ? item.bestScore + '分' : '未练习' }}</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> } @if (historyHasMore()) { <button type="button" class="load-more" [disabled]="historyLoadingMore()" (click)="loadMoreHistory()">{{ historyLoadingMore() ? '加载中...' : '加载更多' }}</button> } @else { <p class="no-more">已经显示全部历史记录</p> } }</div></section></div> }
 
 @if (dialog(); as popup) { <div class="result-backdrop"><section class="result-dialog" [class]="'result-dialog ' + popup.tone" role="dialog" aria-modal="true"><div class="result-icon">@if (popup.tone === 'success') { <lucide-icon [img]="icons.CheckCircle2" [size]="38" /> } @else { <lucide-icon [img]="icons.XCircle" [size]="38" /> }</div><h2>{{ popup.title }}</h2><p>{{ popup.message }}</p><button type="button" class="primary-button" (click)="handleDialog()">{{ popup.buttonText }}</button></section></div> }

+ 5 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts

@@ -7,7 +7,7 @@ import { SessionService } from '../../core/session.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { ReadingArticle, ReadingHistoryEntry, ReadingProgress, ReadingQuestion, ReadingRouteParams } from './reading.models';
-import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, shuffle } from './reading.utils';
+import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, readingStudentName, shuffle } from './reading.utils';
 
 interface ReadingDialog {
   title: string;
@@ -87,6 +87,10 @@ export class ReadingExerciseComponent implements OnInit {
     return article ? highlightWords(article.content, article.createOptions.params.wordList) : '';
   }
 
+  studentName(article: ReadingArticle): string {
+    return readingStudentName(article, this.cloud.user?.nickname || this.params?.name || String(this.readingLearner()?.honeyName || ''));
+  }
+
   private readingLearner() {
     const id=Number(this.route.snapshot.queryParamMap.get('studentId'));
     return this.session.groupId()===3 && id ? {userId:id,groupId:1,honeyName:this.session.student()?.userId===id?this.session.student()?.honeyName:''} : this.session.user();

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.html

@@ -5,7 +5,7 @@
   @else if (error()) { <div class="pdf-state">{{ error() }}<button type="button" class="primary-button" (click)="back()">返回练习页</button></div> }
   @else {
     <main #pdfContent class="pdf-document">
-      <section class="reading-section"><h1>{{ article()?.title }}</h1><div class="document-meta"><span>{{ article()?.createOptions?.params?.level }}</span><span>{{ article()?.createOptions?.params?.type }}</span><span>{{ article()?.createOptions?.params?.theme }}</span><span>{{ questions().length }} 道题</span></div><div class="reading-content" [innerHTML]="articleHtml"></div></section>
+      <section class="reading-section"><h1>{{ article()?.title }}</h1><div class="document-meta">@if (article(); as currentArticle) { @if (studentName(currentArticle)) { <span>学生:{{ studentName(currentArticle) }}</span> } @if (currentArticle.createdAt) { <span>生成日期:{{ currentArticle.createdAt | date:'yyyy-MM-dd' }}</span> } }<span>{{ article()?.createOptions?.params?.level }}</span><span>{{ article()?.createOptions?.params?.type }}</span><span>{{ article()?.createOptions?.params?.theme }}</span><span>{{ questions().length }} 道题</span></div><div class="reading-content" [innerHTML]="articleHtml"></div></section>
       <section class="question-section"><h2>Questions</h2>@for (question of questions(); track question.objectId; let index = $index) { <article class="question-item"><h3>{{ index + 1 }}. {{ question.title }}</h3>@for (option of question.options; track option.value; let optionIndex = $index) { <p><b>{{ optionLabels[optionIndex] }}.</b> {{ option.value }}</p> }</article> }</section>
       <section class="answer-section"><h2>Answers and Explanations</h2>@for (question of questions(); track question.objectId; let index = $index) { <article class="answer-item"><h3>Question {{ index + 1 }}: {{ question.title }}</h3>@for (option of question.options; track option.value; let optionIndex = $index) { <div [class.correct-answer]="option.check"><p><b>{{ optionLabels[optionIndex] }}.</b> {{ option.value }} @if (option.check) { <strong>正确答案</strong> }</p>@if (option.answer) { <small>{{ option.answer }}</small> }</div> }@if (question.answer) { <footer>{{ question.answer }}</footer> }</article> }</section>
     </main>

+ 19 - 7
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.spec.ts

@@ -1,4 +1,4 @@
-import { TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
@@ -9,7 +9,7 @@ import { ReadingPdfComponent } from './reading-pdf.component';
 
 describe('ReadingPdfComponent', () => {
   const article: ReadingArticle = {
-    objectId: 'article-1', type: 'reading', title: 'A story', content: 'Apple grows by the river.',
+    objectId: 'article-1', type: 'reading', title: 'A story', content: 'Apple grows by the river.', createdAt:'2026-10-04T04:00:00.000Z',
     createOptions: { tpl: 'article', params: { wordList: ['apple'], count: 120, stage: '小学', level: '三年级', type: '记叙文', theme: '校园' } },
   };
   const questions: ReadingQuestion[] = Array.from({ length: 5 }, (_, index) => ({
@@ -27,7 +27,7 @@ describe('ReadingPdfComponent', () => {
     await TestBed.configureTestingModule({
       imports: [ReadingPdfComponent],
       providers: [
-        { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ sid: article.objectId }), queryParamMap: convertToParamMap({}) } } },
+        { provide: ActivatedRoute, useValue: { snapshot: { paramMap: convertToParamMap({ sid: article.objectId }), queryParamMap: convertToParamMap({ name:'林同学' }) } } },
         { provide: Router, useValue: jasmine.createSpyObj<Router>('Router', ['navigate', 'navigateByUrl']) },
         { provide: SessionService, useValue: { groupId: () => 1, user: () => ({ userId: 7 }), student: () => null } },
         { provide: PlatformService, useValue: jasmine.createSpyObj<PlatformService>('PlatformService', ['toast']) },
@@ -38,20 +38,32 @@ describe('ReadingPdfComponent', () => {
         { provide: EnglishReadingService, useValue: reading },
       ] },
     }).compileComponents();
-    const component = TestBed.createComponent(ReadingPdfComponent).componentInstance;
+    const fixture: ComponentFixture<ReadingPdfComponent> = TestBed.createComponent(ReadingPdfComponent);
+    const component = fixture.componentInstance;
     await component.ngOnInit();
-    return component;
+    return { component, fixture };
   }
 
   it('refuses direct PDF access while the cloud has only one question', async () => {
-    const component = await setup(questions.slice(0, 1));
+    const { component } = await setup(questions.slice(0, 1));
     expect(component.questions()).toEqual([]);
     expect(component.error()).toContain('题目尚未完整保存');
   });
 
   it('loads five saved questions without requiring an online answer log', async () => {
-    const component = await setup(questions);
+    const { component } = await setup(questions);
     expect(component.error()).toBe('');
     expect(component.questions().length).toBe(5);
   });
+
+  it('includes the learner name and creation date before grade in printed content', async () => {
+    const { fixture } = await setup(questions);
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+    const metadata = (fixture.nativeElement as HTMLElement).querySelector('.document-meta');
+    expect(metadata?.textContent).toContain('学生:林同学');
+    expect(metadata?.textContent).toContain('生成日期:2026-10-04');
+    expect(metadata?.textContent?.indexOf('林同学')).toBeLessThan(metadata?.textContent?.indexOf('三年级') ?? 0);
+  });
 });

+ 5 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -7,7 +7,7 @@ import { SessionService } from '../../core/session.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingArticle, ReadingQuestion, ReadingRouteParams } from './reading.models';
-import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl } from './reading.utils';
+import { highlightWords, isCompleteReadingQuestionSet, normalizeReadingParams, normalizeReadingReturnUrl, readingStudentName } from './reading.utils';
 
 @Component({
   selector: 'app-reading-pdf',
@@ -43,6 +43,10 @@ export class ReadingPdfComponent implements OnInit {
     return article ? highlightWords(article.content, article.createOptions.params.wordList) : '';
   }
 
+  studentName(article: ReadingArticle): string {
+    return readingStudentName(article, this.cloud.user?.nickname || this.params?.name || String(this.readingLearner()?.honeyName || ''));
+  }
+
   private readingLearner() {
     const id=Number(this.route.snapshot.queryParamMap.get('studentId'));
     return this.session.groupId()===3 && id ? {userId:id,groupId:1,honeyName:this.session.student()?.userId===id?this.session.student()?.honeyName:''} : this.session.user();

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.html

@@ -34,7 +34,7 @@
       <div class="history-list">
         @if (historyLoading()) { <div class="dialog-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24" />正在加载</div> }
         @else if (!historyItems().length) { <div class="dialog-state"><lucide-icon [img]="icons.BookOpen" [size]="28" />暂无阅读历史</div> }
-        @else { @for (item of historyItems(); track item.article.objectId) { <button type="button" class="history-item" (click)="selectHistory(item)"><div><strong>{{ item.article.title }}</strong><p>{{ item.article.createOptions.params.level }} · {{ item.article.createOptions.params.type }} · {{ item.article.createOptions.params.theme }}</p><small>{{ item.article.createOptions.params.wordList.join(', ') }}</small></div><span>@if (item.attemptCount) { <b>{{ item.bestScore }}分</b><small><lucide-icon [img]="icons.Clock3" [size]="13" />{{ item.attemptCount }}次</small> } @else { <small>未练习</small> }</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> } @if (historyHasMore()) { <button type="button" class="load-more" [disabled]="historyLoadingMore()" (click)="loadMoreHistory()">{{ historyLoadingMore() ? '加载中...' : '加载更多' }}</button> } @else { <p class="no-more">已经显示全部历史记录</p> } }
+        @else { @for (item of historyItems(); track item.article.objectId) { <button type="button" class="history-item" (click)="selectHistory(item)"><div><strong>{{ item.article.title }}</strong><p>@if (studentName(item.article)) { {{ studentName(item.article) }} · }@if (item.article.createdAt) { {{ item.article.createdAt | date:'yyyy-MM-dd' }} · }{{ item.article.createOptions.params.level }} · {{ item.article.createOptions.params.type }} · {{ item.article.createOptions.params.theme }}</p><small>{{ item.article.createOptions.params.wordList.join(', ') }}</small></div><span>@if (item.attemptCount) { <b>{{ item.bestScore }}分</b><small><lucide-icon [img]="icons.Clock3" [size]="13" />{{ item.attemptCount }}次</small> } @else { <small>未练习</small> }</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> } @if (historyHasMore()) { <button type="button" class="load-more" [disabled]="historyLoadingMore()" (click)="loadMoreHistory()">{{ historyLoadingMore() ? '加载中...' : '加载更多' }}</button> } @else { <p class="no-more">已经显示全部历史记录</p> } }
       </div>
     </section>
   </div>

+ 6 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.ts

@@ -8,8 +8,8 @@ import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingCloudService } from './reading-cloud.service';
-import { ReadingHistoryEntry, ReadingRouteParams } from './reading.models';
-import { normalizeReadingParams, normalizeReadingReturnUrl, READING_LEVELS, READING_THEMES, READING_TYPES, splitWords } from './reading.utils';
+import { ReadingArticle, ReadingHistoryEntry, ReadingRouteParams } from './reading.models';
+import { normalizeReadingParams, normalizeReadingReturnUrl, readingStudentName, READING_LEVELS, READING_THEMES, READING_TYPES, splitWords } from './reading.utils';
 
 @Component({
   selector: 'app-reading-setup',
@@ -56,6 +56,10 @@ export class ReadingSetupComponent implements OnInit, OnDestroy {
 
   get vocabularyCount(): number { return splitWords(this.vocabulary).length; }
 
+  studentName(article: ReadingArticle): string {
+    return readingStudentName(article, this.cloud.user?.nickname || this.nickname || this.routeParams?.name || '');
+  }
+
   private readingLearner() {
     const id=Number(this.route.snapshot.queryParamMap.get('studentId'));
     return this.session.groupId()===3 && id ? {userId:id,groupId:1,honeyName:this.session.student()?.userId===id?this.session.student()?.honeyName:''} : this.session.user();

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts

@@ -20,6 +20,7 @@ export interface ReadingPointer {
 }
 
 export interface ReadingCreateParams {
+  studentName?: string;
   wordList: string[];
   count: number;
   stage: string;

+ 9 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.spec.ts

@@ -1,5 +1,5 @@
-import { ReadingQuestion } from './reading.models';
-import { highlightWords, normalizeReadingParams, normalizeReadingQuestions, normalizeReadingReturnUrl, readingStage, readingWordLimit, splitWords } from './reading.utils';
+import { ReadingArticle, ReadingQuestion } from './reading.models';
+import { highlightWords, normalizeReadingParams, normalizeReadingQuestions, normalizeReadingReturnUrl, readingStage, readingStudentName, readingWordLimit, splitWords } from './reading.utils';
 
 describe('reading utilities', () => {
   it('normalizes packed UniApp params and common word separators', () => {
@@ -28,6 +28,13 @@ describe('reading utilities', () => {
     expect(readingWordLimit('六年级', 3)).toBe(130);
   });
 
+  it('uses the article name snapshot and falls back to the learner profile for old articles', () => {
+    const article: ReadingArticle = { objectId:'article-1', type:'reading', title:'A story', content:'text', createOptions:{ tpl:'article', params:{ wordList:[], count:120, stage:'小学', level:'三年级', type:'记叙文', theme:'校园' } } };
+    expect(readingStudentName(article, '当前姓名')).toBe('当前姓名');
+    article.createOptions.params.studentName = '生成时姓名';
+    expect(readingStudentName(article, '当前姓名')).toBe('生成时姓名');
+  });
+
   it('escapes plain article text before highlighting vocabulary', () => {
     const html = highlightWords('<script>alert(1)</script> apple\n\nApple pie', ['apple']);
     expect(html).not.toContain('<script>');

+ 5 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts

@@ -1,4 +1,4 @@
-import { ReadingOption, ReadingQuestion, ReadingRouteParams } from './reading.models';
+import { ReadingArticle, ReadingOption, ReadingQuestion, ReadingRouteParams } from './reading.models';
 
 export const READING_LEVELS = [
   '一年级', '二年级', '三年级', '四年级', '五年级', '六年级',
@@ -15,6 +15,10 @@ export const READING_THEMES = [
 
 export const READING_QUESTION_COUNT = 5;
 
+export function readingStudentName(article: ReadingArticle, currentName: string): string {
+  return article.createOptions.params.studentName?.trim() || currentName.trim();
+}
+
 export function normalizeReadingReturnUrl(value: unknown, fallback = '/pages/his/his'): string {
   const url = String(value ?? '').trim();
   return url.startsWith('/') && !/^\/[\\/]/.test(url) ? url : fallback;