Bladeren bron

feat: add coach-led student wordbook practice

彭峰 2 uur geleden
bovenliggende
commit
ed017795f4

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

@@ -856,6 +856,80 @@ describe('home learning modules', () => {
     expect(router.url).toBe('/pages/home/cart');
   });
 
+  it('lets a teacher open a student wordbook, switch learners and save practice for the selected learner', async () => {
+    user.set({userId:300, userName:'coach', honeyName:'老师', groupId:3});
+    session.groupId.set(3);
+    selectedStudent.set({userId:888, honeyName:'正在排课的学员', groupId:1});
+    spyOn(TestBed.inject(PlatformService), 'play').and.resolveTo('played');
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    const pendingFirstLearner = new Subject<ApiEnvelope<any>>();
+    const words = (uid: number, completed = false) => ({
+      retcode:1, result:[{GeneralID:uid, xxcs:completed ? 2 : 1,
+        detail:[{GeneralID:uid+700, Title:uid===100 ? 'apple' : 'banana', sy:uid===100 ? '苹果' : '香蕉', yp:'/UploadFiles/test.mp3'}]}],
+      page:{itemCount:1, pendingCount:completed ? 0 : 1, completedCount:completed ? 1 : 0},
+    });
+    const read = spyOn(api, 'get').and.callFake((action, params): Observable<ApiEnvelope<any>> => {
+      if (action === 'e_user_list') return of({retcode:1, result:[{userId:100, honeyName:'学员甲'}, {userId:101, honeyName:'学员乙'}]});
+      if (action !== 'e_words_list') return get(action, params);
+      const uid = Number(params?.['uid']);
+      const completed = params?.['learningStatus'] === 'completed';
+      if (uid === 100 && completed) return pendingFirstLearner;
+      const saved = uid === 101 && api.lastLearningWordUpdate['save'] === 1;
+      if (saved !== completed) return of({retcode:1, result:[], page:{itemCount:0, pendingCount:saved ? 0 : 1, completedCount:saved ? 1 : 0}});
+      return of(words(uid, saved));
+    });
+    const harness = await RouterTestingHarness.create();
+    const router = TestBed.inject(Router);
+    const page = () => harness.routeNativeElement!;
+    const stage = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    const settled = async () => { await harness.fixture.whenStable(); harness.fixture.detectChanges(); };
+    await harness.navigateByUrl('/pages/home/index');
+    (page().querySelector('.coach-wordbook-entry') as HTMLAnchorElement).click();
+    await settled();
+    expect(router.url).toBe('/pages/member/starshop');
+    expect(read).toHaveBeenCalledWith('e_user_list', jasmine.objectContaining({scope:'wordbook'}));
+    expect(page().querySelector('.word-select-list')?.textContent).toContain('apple');
+    expect(page().querySelector('.danger-button')).toBeNull();
+    (page().querySelector('.word-select-list button') as HTMLButtonElement).click();
+    (page().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
+    const picker = page().querySelector<HTMLSelectElement>('#wordbook-student')!;
+    picker.value = '101';
+    picker.dispatchEvent(new Event('change'));
+    pendingFirstLearner.next(words(100, true));
+    await settled();
+    expect(page().querySelector('.word-select-list')?.textContent).toContain('banana');
+    expect(page().querySelector('.word-select-list')?.textContent).not.toContain('apple');
+    expect(stage().selected().size).toBe(0);
+    expect(selectedStudent()?.userId).toBe(888);
+    (page().querySelector('.word-select-list button') as HTMLButtonElement).click();
+    await settled();
+    (page().querySelector('.selection-header .primary-button') as HTMLButtonElement).click();
+    await settled();
+    expect(router.url).toBe('/pages/stu/stu_con?wordbookStudentId=101');
+    expect(stage().words().map(word=>word.word)).toEqual(['banana']);
+    stage().skipStudyList();
+    await settled();
+    expect(router.url).toBe('/pages/stu/stu3?wordbookStudentId=101');
+    stage().chooseMatch('left', stage().words()[0]);
+    stage().chooseMatch('right', stage().words()[0]);
+    stage().continueAfterStage();
+    await settled();
+    stage().chooseMeaning(stage().words()[0]);
+    stage().continueAfterStage();
+    await settled();
+    expect(router.url).toBe('/pages/stu/pl_stu5?wordbookStudentId=101');
+    stage().skipSpellingStage();
+    await settled();
+    expect(api.lastLearningWordUpdate).toEqual(jasmine.objectContaining({uid:101, scope:'wordbook', wordsId:'801', save:1}));
+    expect(router.url).toBe('/pages/member/starshop?wordbookStudentId=101');
+    expect(page().querySelector<HTMLSelectElement>('#wordbook-student')?.value).toBe('101');
+    expect(selectedStudent()?.userId).toBe(888);
+    (page().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
+    await settled();
+    expect(page().querySelector('.completed-column')?.textContent).toContain('banana');
+  }, 15000);
+
   it('separates unlearned and learned wordbook words while keeping selections across tabs', async () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const get = api.get.bind(api);
@@ -1737,7 +1811,7 @@ describe('home learning modules', () => {
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     const coachLinks = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-quick-grid > a') ?? []).map((link) => link.getAttribute('href'));
-    expect(coachLinks).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/ke_fuxi']);
+    expect(coachLinks).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/ke_fuxi', '/pages/member/starshop']);
     expect(harness.routeNativeElement?.querySelector('.home-header .header-action')).toBeNull();
     const coachAccountTab = (Array.from(harness.fixture.nativeElement.querySelectorAll('.tabbar a')) as HTMLAnchorElement[])
       .find((link) => link.getAttribute('href') === '/pages/home/ucenter');

+ 2 - 0
projects/xiaoshu-mobile/src/app/core/learning-navigation.ts

@@ -16,6 +16,8 @@ export function isLearningStage(path: string, params: ParamMap): boolean {
 export function learningReturnUrl(params: ParamMap, savedUrl?: unknown): string {
   const orderId = params.get('orderId');
   if (orderId) return `/pages/shop/get_order?id=${encodeURIComponent(orderId)}`;
+  const wordbookStudentId = params.get('wordbookStudentId');
+  if (wordbookStudentId && /^[1-9][0-9]*$/.test(wordbookStudentId)) return `/pages/member/starshop?wordbookStudentId=${wordbookStudentId}`;
   if (typeof savedUrl === 'string' && RETURN_PATHS.has(savedUrl.split('?')[0])) return savedUrl;
   const nids = params.get('nids') || params.get('nid');
   if (!nids) return '/pages/home/cart';

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -112,6 +112,7 @@
       <a routerLink="/pages/member/ke_xiao"><span class="quick-icon review"><lucide-icon [img]="icons.Check" [size]="25" /></span><span><strong>陪练记录</strong><small>核对已完成课程</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
       <a routerLink="/pages/his/his"><span class="quick-icon history"><lucide-icon [img]="icons.BookOpen" [size]="25" /></span><span><strong>学员记录</strong><small>按学员查看学习成果</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
       <a routerLink="/pages/member/ke_fuxi"><span class="quick-icon exam"><lucide-icon [img]="icons.BarChart3" [size]="25" /></span><span><strong>抗遗忘费用</strong><small>查看抗遗忘复习费用</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a class="coach-wordbook-entry" routerLink="/pages/member/starshop"><span class="quick-icon learn"><lucide-icon [img]="icons.BookMarked" [size]="25" /></span><span><strong>生词本</strong><small>选学员,带练生词</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
     </section>
   } @else {
     <section class="quick-grid" aria-label="学习入口">

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss

@@ -104,6 +104,7 @@
 .quick-grid a { display: grid; min-width: 0; min-height: 104px; grid-template-columns: 48px minmax(0, 1fr) 20px; align-items: center; padding: 14px 12px; color: var(--ink); text-decoration: none; gap: 10px; }
 .quick-grid a:nth-child(odd) { border-right: 1px solid var(--line); }
 .quick-grid a:nth-child(-n + 2) { border-bottom: 1px solid var(--line); }
+.coach-quick-grid a.coach-wordbook-entry { grid-column: 1 / -1; min-height: 76px; border-top: 1px solid var(--line); border-right: 0; }
 .quick-grid a > span:nth-child(2) { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
 .quick-grid strong { font-size: 15px; }
 .quick-grid small { overflow: hidden; color: var(--ink-soft); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }

+ 28 - 4
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -1,6 +1,30 @@
 <section class="learning-page">
+  @if (isCoachWordbookSelection) {
+    <div class="section field wordbook-student-picker">
+      <label for="wordbook-student">学员</label>
+      <select id="wordbook-student" [value]="wordbookStudentId()" [disabled]="!wordbookStudents().length" (change)="selectWordbookStudent(+$any($event.target).value)">
+        <option value="0" disabled>{{ wordbookStudentsLoading() ? '正在加载学员…' : '请选择学员' }}</option>
+        @for (student of wordbookStudents(); track student.userId) {
+          <option [value]="student.userId" [selected]="student.userId === wordbookStudentId()">{{ student.honeyName || '学员 ' + student.userId }}</option>
+        }
+      </select>
+      @if (wordbookStudentsHaveMore()) {
+        <button type="button" class="secondary-button" [disabled]="wordbookStudentsLoading()" (click)="loadWordbookStudents(true)">加载更多学员</button>
+      }
+      @if (wordbookStudentsError()) {
+        <p class="muted" role="alert">{{ wordbookStudentsError() }}</p>
+        @if (!wordbookStudents().length) {
+          <button type="button" class="secondary-button" (click)="loadWordbookStudents()">重新加载学员</button>
+        }
+      }
+    </div>
+  }
   @if (audioPreparing()) { <p class="readonly-word-note" role="status" aria-live="polite">正在准备读音,首次生成后会自动保存…</p> }
-  @if (loading()) {
+  @if (isCoachWordbookSelection && !wordbookStudentId()) {
+    @if (!wordbookStudentsLoading() && !wordbookStudentsError()) {
+      <div class="empty-state"><p>当前暂无可带练的学员</p></div>
+    }
+  } @else if (loading()) {
     <div class="empty-state"><p>正在加载学习内容...</p></div>
   } @else if (error()) {
     <div class="empty-state error">
@@ -119,8 +143,8 @@
       <div class="header-actions">
         @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" [disabled]="isNewWordSelectionPage && !visibleNewWords().length" (click)="toggleAllWords()">{{ isNewWordSelectionPage ? (allVisibleNewWordsSelected() ? '取消本栏' : '全选本栏') : (selected().size ? '取消全选' : '全选') }}</button> }
         @if (!isReadOnlySelectionPage) {
-          <button type="button" class="primary-button" [disabled]="submitting() || selectionLoading()" (click)="startSelected()">
-            <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : '开始学习' }}
+          <button type="button" class="primary-button" [disabled]="submitting() || selectionLoading() || (isCoachWordbookSelection && !selected().size)" (click)="startSelected()">
+            <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : isCoachWordbookSelection ? '开始带练' : '开始学习' }}
           </button>
         }
       </div>
@@ -215,7 +239,7 @@
         }
       </div>
     }
-    @if (meta.path === 'pages/member/starshop') {
+    @if (isNewWordSelectionPage && !isCoachWordbookSelection) {
       <div class="sticky-actions single">
         <button type="button" class="danger-button" (click)="deleteSelectedNewWords()"><lucide-icon [img]="icons.Trash2" [size]="18" />删除所选</button>
       </div>

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

@@ -53,6 +53,11 @@ import { nextReviewWordOrder } from '../../core/review-word-order';
 
 type SourceItem = Record<string, unknown>;
 
+interface WordbookStudent {
+  userId: number;
+  honeyName: string;
+}
+
 interface LessonBatchResult {
   batchId: string;
   batchVersion: number;
@@ -158,6 +163,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return total && counts ? Math.round((total - counts[0]) / total * 100) : 0;
   });
   readonly newWordCounts = signal({ pending: 0, completed: 0 });
+  readonly wordbookStudents = signal<WordbookStudent[]>([]);
+  readonly wordbookStudentId = signal(0);
+  readonly wordbookStudentsLoading = signal(false);
+  readonly wordbookStudentsError = signal('');
+  readonly wordbookStudentsHaveMore = signal(false);
+  private wordbookStudentsPage = 0;
+  private wordbookStudentsRequest?: Subscription;
   private readonly newWordPageSize = 30;
   private readonly newWordSelectionCache = new Map<string, Word>();
   private legacyNewWords: Word[] | null = null;
@@ -365,12 +377,14 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       const [y,m]=this.memorySelectedDate().split('-').map(Number);this.memoryMonth.set(new Date(y,m-1,1,12));
     }
     if(this.isRecordPage)this.reviewReturn=history.state?.reviewReturn||{date:this.query('date')||this.dateOnly()};
-    this.load();
+    if (this.isCoachWordbookSelection) this.loadWordbookStudents();
+    else this.load();
   }
 
   ngOnDestroy(): void {
     this.audioRequest++;
     this.calendarRequestId++;this.calendarRequest?.unsubscribe();this.selectionRequest?.unsubscribe();
+    this.wordbookStudentsRequest?.unsubscribe();
     this.choiceOptionsVersion++;
     this.choiceOptionsRequest?.unsubscribe();
     this.stopExamTimer();
@@ -398,6 +412,47 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return this.meta.path === 'pages/member/starshop';
   }
 
+  get isCoachWordbookSelection(): boolean {
+    return this.isNewWordSelectionPage && this.canStartPointReading;
+  }
+
+  private get coachWordbookStudentId(): number {
+    if (!this.canStartPointReading) return 0;
+    if (this.isCoachWordbookSelection) return this.wordbookStudentId();
+    if (!this.isStudyListPage && !this.isMatchingPage && !this.isListeningPage && !this.isSpellingPage) return 0;
+    const id = Number(this.query('wordbookStudentId'));
+    return Number.isSafeInteger(id) && id > 0 ? id : 0;
+  }
+
+  loadWordbookStudents(more = false): void {
+    if (!this.isCoachWordbookSelection || this.wordbookStudentsLoading()) return;
+    const requestedPage = more ? this.wordbookStudentsPage + 1 : 1;
+    const preferred = Number(this.query('wordbookStudentId')) || 0;
+    this.wordbookStudentsLoading.set(true);
+    this.wordbookStudentsError.set('');
+    this.wordbookStudentsRequest = this.api.get<WordbookStudent[]>('e_user_list', {
+      scope: 'wordbook', cpage: requestedPage, psize: 50, selectedStudentId: preferred,
+    }).pipe(finalize(() => this.wordbookStudentsLoading.set(false))).subscribe({
+      next: ({result, page}) => {
+        this.wordbookStudentsPage = requestedPage;
+        const students = more ? [...this.wordbookStudents(), ...result] : result;
+        this.wordbookStudents.set([...new Map(students.map(student => [student.userId, student])).values()]);
+        this.wordbookStudentsHaveMore.set(requestedPage < Number(page?.pageCount ?? 0));
+        if (!more) {
+          const selected = students.find(student => student.userId === preferred) ?? students[0];
+          if (selected) this.selectWordbookStudent(selected.userId);
+        }
+      },
+      error: (error: ApiError) => this.wordbookStudentsError.set(error.message || '学员名单加载失败,请重试'),
+    });
+  }
+
+  selectWordbookStudent(id: number): void {
+    if (this.wordbookStudentId() === id || !this.wordbookStudents().some(student => student.userId === id)) return;
+    this.wordbookStudentId.set(id);
+    this.load();
+  }
+
   get isHistoryWordView(): boolean {
     return this.meta.path === 'pages/his/his_words';
   }
@@ -804,6 +859,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   startSelected(continueLearning = true, openKnownPass = false): void {
     if (this.submitting() || this.selectionLoading() || this.isReadOnlySelectionPage) return;
+    if (this.isCoachWordbookSelection && !this.wordbookStudentId()) return;
     if (this.knownSaveRetry() && this.knownSaveContinuation) {
       ({ continueLearning, openKnownPass } = this.knownSaveContinuation);
     }
@@ -849,6 +905,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   deleteSelectedNewWords(): void {
+    if (this.isCoachWordbookSelection) return;
     const chosen = [...this.newWordSelectionCache.values()].filter((word) => this.selected().has(word.id));
     if (!chosen.length) {
       this.platform.toast('请选择单词', 'error');
@@ -1542,9 +1599,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       return;
     }
 
-    const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
+    const uid = this.isCoachWordbookSelection ? this.wordbookStudentId() : this.session.user()?.userId;
     this.selectionLoading.set(true);
-    this.selectionRequest=this.api.get<unknown>('e_words_list',{uid:owner?.userId,cpage:this.selectionPage(),psize:this.newWordPageSize,learningStatus:this.activeSelectionBucket()}).subscribe({
+    this.selectionRequest=this.api.get<unknown>('e_words_list',{uid,cpage:this.selectionPage(),psize:this.newWordPageSize,learningStatus:this.activeSelectionBucket()}).subscribe({
       next: ({result,page}) => {
         const incoming = this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word);
         const pendingCount = Number(page?.['pendingCount']);
@@ -1554,7 +1611,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
           && pendingCount >= 0 && completedCount >= 0 && pendingCount + completedCount >= total
           && (incoming.length > 0 || total === 0 || this.selectionPage() > Math.ceil(total / this.newWordPageSize));
         if (!validCounts) {
-          this.loadLegacyNewWords(owner?.userId);
+          this.loadLegacyNewWords(uid);
           return;
         }
         this.words.set(incoming);
@@ -1575,7 +1632,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private loadStageWords(): void {
-    const nid = this.query('nid');
+    const nid = this.coachWordbookStudentId ? '' : this.query('nid');
     if (nid && this.isReadOnlyWordView) {
       this.loadReadOnlyWords();
       return;
@@ -1716,7 +1773,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
     const mode = String(this.storageGet('learn_mode') ?? '');
     if (mode === 'newword') {
-      const learner = this.canStartPointReading ? this.session.student() : this.session.user();
+      const studentId = this.coachWordbookStudentId;
+      const learner = studentId ? {userId: studentId} : this.canStartPointReading ? this.session.student() : this.session.user();
       this.submitting.set(true);
       this.persistWordsAsLearned(this.words(), learner).pipe(finalize(() => this.submitting.set(false))).subscribe({
         next: () => {
@@ -1728,7 +1786,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
           }
           this.completed.set(false);
           this.clearLearningStorage();
-          void this.router.navigateByUrl('/pages/member/starshop', { replaceUrl: true });
+          const queryParams = studentId ? {wordbookStudentId: studentId} : {};
+          void this.router.navigate(['/pages/member/starshop'], { queryParams, replaceUrl: true });
         },
         error: (error: ApiError) => {
           this.completed.set(true);
@@ -2271,10 +2330,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.storageSet('_page_total_page', pages.length);
     this.storageSet('_page_word_orilist', pages);
     sessionStorage.setItem(this.learningStorageKey('selectedWords'), JSON.stringify(pages[0] ?? chosen));
+    const wordbookStudentId = mode === 'newword' ? this.coachWordbookStudentId : 0;
     void this.router.navigate(['/pages/stu/stu_con'], {
       replaceUrl: true,
       state: { learningReturnUrl: this.router.url },
-      queryParams: {
+      queryParams: wordbookStudentId ? {wordbookStudentId} : {
         nids: this.query('nids') || this.query('nid'),
         pnid: this.query('pnid'),
         bindingId: this.query('bindingId'),
@@ -2284,10 +2344,11 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private navigateStage(path: string): void {
+    const wordbookStudentId = this.coachWordbookStudentId;
     void this.router.navigate([path], {
       replaceUrl: true,
       state: { learningReturnUrl: history.state?.learningReturnUrl },
-      queryParams: {
+      queryParams: wordbookStudentId ? {wordbookStudentId} : {
         nid: this.query('nid'),
         nids: this.query('nids'),
         pnid: this.query('pnid'),
@@ -2464,7 +2525,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectionAssessment.update((current) => Object.fromEntries(Object.entries(current).filter(([id]) => id !== word.id)) as Record<string, 'known' | 'unknown'>);
   }
 
-  private persistWordsAsLearned(words: Word[], owner: AppUser | null | undefined, onBatchSaved?: (batch: Word[]) => void): Observable<unknown> {
+  private persistWordsAsLearned(words: Word[], owner: Pick<AppUser, 'userId'> | null | undefined, onBatchSaved?: (batch: Word[]) => void): Observable<unknown> {
     if (!words.length) return of(null);
     const batches: Word[][] = [];
     for (let index = 0; index < words.length; index += 100) batches.push(words.slice(index, index + 100));
@@ -2473,6 +2534,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       wordsId: batch.map((word) => word.wordId).join(','),
       kcid: (this.meta.path === 'pages/stu/pl_end' ? this.orderInfo()['learningCourseId'] : '') || this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
       save: 1,
+      ...(this.coachWordbookStudentId ? {scope: 'wordbook'} : {}),
       requestKey:this.draft().command('learn:'+batch.map(w=>w.wordId+'@'+this.savedCoursePass(w)).sort().join(',')),
     }).pipe(tap(() => onBatchSaved?.(batch)))), toArray());
   }
@@ -2526,6 +2588,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private draft():LearningDraft {
+    const wordbookStudentId = this.coachWordbookStudentId;
+    if (wordbookStudentId) return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${wordbookStudentId}.wordbook`);
     const learner=this.canStartPointReading?this.session.student():this.session.user();
     return new LearningDraft(`xiaoshu.learning.${this.session.user()?.userId}.${learner?.userId}.${this.query('orderId') ? this.query('orderId') + '.' + (this.query('bindingId') || 'unbound') : this.query('bindingId') || 'practice'}`);
   }

+ 41 - 7
scripts/deploy-admin-functions.mjs

@@ -2560,10 +2560,8 @@ async function teamUserPage(input, current) {
 async function teamStats(input,current) {
   const parentId = await authorizeTeamUser(current,input.uid); const rows = await Psql.query('SELECT COALESCE("legacyUserData"->>\'VIP\',\'0\') AS vip,COUNT(*)::int AS total FROM "_User" WHERE "company"=$1 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("legacyUserData"->>\'ParentUserID\',\'0\')=$2 GROUP BY COALESCE("legacyUserData"->>\'VIP\',\'0\')', [DEFAULT_COMPANY_ID,String(parentId)]); const result = { childs:0,v0:0,v1:0,v2:0,v3:0,v4:0,v5:0 }; for (const row of rows) { const total = number(row.total); result.childs += total; const key = 'v' + Math.max(0,Math.min(5,number(row.vip))); result[key] += total; } return result;
 }
-async function coachStudentPage(input,current) {
-  const coachId = ownLegacyId(current);
-  if (!coachId) fail(400,'陪练账号缺少旧系统用户 ID');
-  const paging = pageInput(input), scheduledOnly = input.scope === 'current-scheduled';
+function coachStudentSource(coachId, scope) {
+  const scheduledOnly = scope === 'current-scheduled' || scope === 'wordbook';
   const values = [DEFAULT_COMPANY_ID,String(coachId)];
   let base = "FROM \"CommonModel\" c JOIN \"CourseAppointment\" a ON a.\"company\"=$1 AND a.\"id\"=c.\"itemId\"";
   if (scheduledOnly) base += " JOIN \"_User\" eligible ON eligible.\"company\"=$1 AND CAST(eligible.\"legacyUserId\" AS text)=CAST(a.\"szyh\" AS text)";
@@ -2572,8 +2570,28 @@ async function coachStudentPage(input,current) {
     values.push(appOverviewToday().replace(/-/g,''));
     base += " AND eligible.\"isDisabled\" IS NOT TRUE AND eligible.\"isDeleted\" IS NOT TRUE\n      AND COALESCE(eligible.\"legacyGroupId\"::text,eligible.\"legacyUserData\"->>'GroupID','0')='1'\n      AND COALESCE(to_jsonb(a)->>'isDeleted','false') NOT IN ('true','1')\n      AND COALESCE(to_jsonb(c)->>'isDeleted','false') NOT IN ('true','1')";
     // Keep students through their assigned review dates, rather than retaining all past students forever.
-    base += " AND ((COALESCE(NULLIF(a.\"dszt\"::text,''),'0') IN ('0','10','20') AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(a.\"bxrq\",''),a.\"yysj\",''),'[^0-9]','','g'),8) >= $3) OR a.\"szyh\" IN (SELECT CAST(m.\"yhid\" AS text) FROM \"MemoryPracticeRecord\" m JOIN \"CommonModel\" review ON review.\"company\"=$1 AND review.\"modelId\"=60 AND review.\"itemId\"=m.\"id\" AND review.\"status\"=99 WHERE m.\"company\"=$1 AND m.\"plid\"=$2::numeric AND COALESCE(NULLIF(m.\"fxzt\"::text,''),'0')='0' AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(m.\"kywrq\",''),m.\"kywsj\",''),'[^0-9]','','g'),8) >= $3 AND COALESCE(to_jsonb(m)->>'isDeleted','false') NOT IN ('true','1') AND COALESCE(to_jsonb(m)->>'rewardDuplicate','false') NOT IN ('true','1') AND COALESCE(to_jsonb(review)->>'isDeleted','false') NOT IN ('true','1')))";
+    const activeStatuses = scope === 'wordbook' ? "'0','10','11','20'" : "'0','10','20'";
+    base += " AND ((COALESCE(NULLIF(a.\"dszt\"::text,''),'0') IN ("+activeStatuses+") AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(a.\"bxrq\",''),a.\"yysj\",''),'[^0-9]','','g'),8) >= $3) OR a.\"szyh\" IN (SELECT CAST(m.\"yhid\" AS text) FROM \"MemoryPracticeRecord\" m JOIN \"CommonModel\" review ON review.\"company\"=$1 AND review.\"modelId\"=60 AND review.\"itemId\"=m.\"id\" AND review.\"status\"=99 WHERE m.\"company\"=$1 AND m.\"plid\"=$2::numeric AND COALESCE(NULLIF(m.\"fxzt\"::text,''),'0')='0' AND LEFT(REGEXP_REPLACE(COALESCE(NULLIF(m.\"kywrq\",''),m.\"kywsj\",''),'[^0-9]','','g'),8) >= $3 AND COALESCE(to_jsonb(m)->>'isDeleted','false') NOT IN ('true','1') AND COALESCE(to_jsonb(m)->>'rewardDuplicate','false') NOT IN ('true','1') AND COALESCE(to_jsonb(review)->>'isDeleted','false') NOT IN ('true','1')))";
   }
+  return { base, values, scheduledOnly };
+}
+function isWordbookCoach(current) {
+  return number(current.get('legacyGroupId') || current.get('groupId')) === 3;
+}
+async function coachWordbookTarget(current, requestedId, db = Psql) {
+  if (!isWordbookCoach(current)) fail(403,'仅陪练老师可以带学生学习生词本');
+  const uid = number(requestedId), coachId = ownLegacyId(current);
+  if (!Number.isSafeInteger(uid) || uid <= 0 || !coachId) fail(400,'请选择学员');
+  const { base, values } = coachStudentSource(coachId, 'wordbook');
+  const linked = await db.oneOrNone('SELECT 1 ' + base + ' AND a."szyh"=$4 LIMIT 1', values.concat(String(uid)));
+  if (!linked) fail(403,'该学员不在你的生词本带练名单中');
+  return uid;
+}
+async function coachStudentPage(input,current) {
+  const coachId = ownLegacyId(current), wordbook = input.scope === 'wordbook';
+  if (!coachId) fail(400,'陪练账号缺少旧系统用户 ID');
+  if (wordbook && !isWordbookCoach(current)) fail(403,'仅陪练老师可以查看带练学员名单');
+  const paging = pageInput(input), {base, values, scheduledOnly} = coachStudentSource(coachId, input.scope);
   const countRow = await Psql.one('SELECT COUNT(DISTINCT CAST(a."szyh" AS text))::int AS total ' + base,values);
   // Keep an eligible restored selection on the first page, even with many students.
   const preferred = scheduledOnly ? String(number(input.selectedStudentId)) : '';
@@ -2582,7 +2600,7 @@ async function coachStudentPage(input,current) {
   const rows = await Psql.query('WITH students AS (SELECT CAST(a."szyh" AS text) AS uid,MAX(CAST(a."id" AS text)) AS appointment_id,MAX(c."updatedAt") AS updated_at,(CAST(a."szyh" AS text)=' + preferredParam + ') AS preferred ' + base + ' GROUP BY CAST(a."szyh" AS text) ORDER BY preferred DESC,updated_at DESC,uid LIMIT ' + limitParam + ' OFFSET ' + offsetParam + ') SELECT ' + userSelect('u') + ',students.uid AS "__studentId",students.appointment_id AS "__appointmentId" FROM students LEFT JOIN "_User" u ON u."company"=$1 AND CAST(u."legacyUserId" AS text)=students.uid ORDER BY students.preferred DESC,students.updated_at DESC,students.uid',rowValues);
   const normalized = rows.map((row) => { const value = legacyUserRow(row); const studentId = number(row.__studentId || value.UserID); return { ...value, userId:studentId, UserID:studentId, szyh:studentId, ID:number(row.__appointmentId), honeyname:value.HoneyName, honeyName:value.HoneyName }; });
   const total = number(countRow.total);
-  return { rows:normalized,page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size } };
+  return { rows:wordbook ? normalized.map(({userId, honeyName}) => ({userId, honeyName})) : normalized,page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size } };
 }
 const POINT_HISTORY_CLASSES = { 1:'UserExpDomP',2:'UserSIcon',3:'UserExpHis',4:'UserUserPoint',5:'UserDummyPoint',6:'UserCredit' };
 function legacyPointRow(source) { const row = safe(source); row.ExpHisID = firstValue(row.expHisId,row.objectId); row.UserID = number(row.userId); row.HisTime = firstValue(row.hisTime,row.createdAt); row.Score = number(row.score); row.Detail = String(firstValue(row.detail,row.remark,'') || ''); row.Remark = String(row.remark || ''); row.score_before = number(row.scoreBefore); return row; }
@@ -2652,7 +2670,7 @@ async function recordDetailData(input, current) {
   return { words, addon: order ? [{...order, ...(notesReview ? {reviewContentMode:'notes'} : {})}] : [], record };
 }
 async function newWordPage(input, current) {
-  const uid = await authorizeRequestedUser(current, input.uid);
+  const uid = await (isWordbookCoach(current) ? coachWordbookTarget(current, input.uid) : authorizeRequestedUser(current, input.uid));
   const paging = largePageInput(input);
   const status = String(input.learningStatus ?? '').trim();
   if (status && status !== 'pending' && status !== 'completed') fail(400, '生词学习状态无效');
@@ -2691,6 +2709,7 @@ async function courseWordPage(input, current) {
   }),page:{itemCount:total,pageCount:Math.ceil(total/paging.size),pageIndex:paging.index,pageSize:paging.size,passCounts,bookPassCounts,firstPassProgress:bookTotal?Math.round((bookTotal-bookPassCounts[0])/bookTotal*100):0}};
 }
 async function updateLearningWords(input, current) {
+  if (input.scope === 'wordbook') return saveCoachWordbookWords(input, current);
   const uid = await authorizeRequestedUser(current, input.uid); const wordIds = [...new Set(String(input.wordsId || input.wordIds || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'))]; if (!wordIds.length) fail(400, '请选择单词'); if (wordIds.length > 100) fail(400, '单次最多操作 100 个单词'); const saveLearning = number(input.save) === 1; const hasNewWordFlag = input.ifnew !== undefined && input.ifnew !== ''; const newWordFlag = hasNewWordFlag ? (number(input.ifnew) === 1 ? '1' : '0') : null; if (!saveLearning && !hasNewWordFlag) fail(400, '缺少学习或生词操作类型'); const kcid = number(input.kcid);
   const validRows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, wordIds]); const validIds = new Set(validRows.map((row) => String(row.id))); const invalid = wordIds.filter((id) => !validIds.has(id)); if (invalid.length) fail(400, '包含不存在的词库 ID: ' + invalid.join(','));
   const existingRows = await Psql.query('SELECT DISTINCT ON (pr.scid) pr."objectId", CAST(pr.scid AS text) AS "scid" FROM "PracticeRecord" pr WHERE pr.company = $1 AND CAST(pr.yhid AS text) = $2 AND CAST(pr.scid AS text) = ANY($3::text[]) ORDER BY pr.scid, COALESCE(pr.xxcs,0) DESC, pr."createdAt", pr."objectId"', [DEFAULT_COMPANY_ID, String(uid), wordIds]); const existing = await Promise.all(existingRows.map((row) => new Parse.Query('PracticeRecord').get(row.objectId, { useMasterKey: true }))); const byWord = new Map(existing.map((item) => [String(item.get('scid')), item])); const changed = []; let created = 0;
@@ -2698,6 +2717,21 @@ async function updateLearningWords(input, current) {
   const operations=changed.map(item=>{const data=mobileObjectData(item);if(saveLearning)data.xxcs=1;return{table:'PracticeRecord',data,match:{company:DEFAULT_COMPANY_ID,yhid:uid,scid:number(item.get('scid'))},increment:saveLearning?['xxcs']:[]};});
   const saved=await mobileAtomicWrite(input,current,operations,String(uid));return{affected:saved.length,created,learnedIncremented:saveLearning?saved.length:0,newWordState:newWordFlag===null?undefined:number(newWordFlag),objectIds:saved.map(item=>item.objectId)};
 }
+async function saveCoachWordbookWords(input, current) {
+  if (!isWordbookCoach(current)) fail(403,'仅陪练老师可以带学生学习生词本');
+  if (number(input.save) !== 1 || input.ifnew != null) fail(400,'生词本带练仅支持保存学习进度');
+  const wordIds = [...new Set(String(input.wordsId || '').split(',').map(id => id.trim()).filter(Boolean))];
+  if (!wordIds.length || wordIds.length > 100 || wordIds.some(id => !/^[1-9][0-9]*$/.test(id))) fail(400,'请选择 1 到 100 个有效生词');
+  return Psql.tx(async db => {
+    const uid = await coachWordbookTarget(current, input.uid, db);
+    await db.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [DEFAULT_COMPANY_ID + ':' + uid]);
+    const rows = await db.query("WITH candidates AS MATERIALIZED (SELECT p.\"objectId\",p.scid,p.xxcs,p.\"createdAt\" FROM \"PracticeRecord\" p WHERE p.company=$1 AND p.yhid::text=$2 AND p.scid::text=ANY($3::text[]) AND LOWER(p.jrscb::text) IN ('1','true') AND EXISTS (SELECT 1 FROM \"CommonModel\" c WHERE c.company=$1 AND c.\"modelId\"=52 AND c.\"generalId\"::text=p.scid::text) FOR UPDATE) SELECT DISTINCT ON (scid) \"objectId\" FROM candidates ORDER BY scid,COALESCE(xxcs,0) DESC,\"createdAt\",\"objectId\"", [DEFAULT_COMPANY_ID, String(uid), wordIds]);
+    if (rows.length !== wordIds.length) fail(409,'有单词已不在该学员的生词本中,请返回重新选词');
+    const operations = rows.map(row => ({table:'PracticeRecord', data:{xxcs:1}, match:{company:DEFAULT_COMPANY_ID, objectId:row.objectId}, increment:['xxcs']}));
+    const saved = await mobileAtomicWrite(input, current, operations, String(uid), db);
+    return {affected:saved.length, created:0, learnedIncremented:saved.length, objectIds:saved.map(row => row.objectId)};
+  });
+}
 function parseObject(value, label) { if (value && typeof value === 'object' && !Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) return {}; try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) {} fail(400, label + '格式错误'); }
 function cleanText(value, max) { return String(value == null ? '' : value).trim().slice(0, max); }
 async function validateStudyWords(raw) { const words = parseArray(raw); if (words.length > 500) fail(400, '单次学习记录最多 500 个单词'); const normalized = words.map((item) => ({ GeneralID: number(item && (item.GeneralID ?? item.generalId ?? item.id)), Title: cleanText(item && (item.Title ?? item.title), 200), check: number(item && item.check) })).filter((item) => item.GeneralID); if (!normalized.length && words.length) fail(400, '学习记录中的单词 ID 无效'); if (normalized.length) { const ids = [...new Set(normalized.map((item) => String(item.GeneralID)))]; const rows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]); if (new Set(rows.map((row) => String(row.id))).size !== ids.length) fail(400, '学习记录包含不存在的词库 ID'); } return normalized; }

+ 25 - 1
scripts/tests/coach-student-page.test.mjs

@@ -10,10 +10,11 @@ const gateway = vm.createContext({});
 vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace(/const DEFAULT_COMPANY_ID = '[^']+';/, "const DEFAULT_COMPANY_ID = 'co';"), gateway);
 gateway.Psql = {
   one: async (sql, args) => (await db.query(sql, args)).rows[0],
+  oneOrNone: async (sql, args) => (await db.query(sql, args)).rows[0] ?? null,
   query: async (sql, args) => (await db.query(sql, args)).rows,
 };
 gateway.appOverviewToday = () => '2026-10-30';
-const coach = { get: key => key === 'legacyUserId' ? 300 : undefined };
+const coach = { get: key => ({legacyUserId:300, legacyGroupId:3})[key] };
 const page = (input = {}) => gateway.coachStudentPage({ scope: 'current-scheduled', ...input }, coach);
 const ids = result => Array.from(result.rows, row => row.UserID);
 
@@ -50,6 +51,29 @@ async function review(id, student, { date = '2026-11-01', time = '', status = '0
     [company, id, commonStatus, '2026-10-30T00:00:00Z', commonDeleted]);
 }
 
+test('生词本名单、读写授权使用同一归属,排除取消、过期和其他老师的课', async () => {
+  for (let id = 1; id <= 6; id++) await user(id, {disabled: id === 6});
+  await appointment(1, 1, {status:'11'});
+  await appointment(2, 2, {date:'2026-10-29', status:'30'});
+  await review(2, 2);
+  await appointment(3, 3, {commonStatus:-2});
+  await appointment(4, 4, {teacher:'301'});
+  await appointment(5, 5, {date:'2026-10-29', status:'30'});
+  await appointment(6, 6);
+  const roster = await page({scope:'wordbook', selectedStudentId:2});
+  assert.deepEqual(Array.from(roster.rows, row => row.userId), [2, 1]);
+  assert.deepEqual(Object.keys(roster.rows[0]), ['userId', 'honeyName']);
+  assert.equal(await gateway.coachWordbookTarget(coach, 1), 1);
+  assert.equal(await gateway.coachWordbookTarget(coach, 2), 2);
+  for (const id of [3, 4, 5, 6]) {
+    await assert.rejects(gateway.coachWordbookTarget(coach, id), /不在你的生词本带练名单/);
+    await assert.rejects(gateway.newWordPage({uid:id}, coach), /不在你的生词本带练名单/);
+  }
+  const student = { get: key => ({legacyUserId:1, legacyGroupId:1})[key] };
+  await assert.rejects(gateway.coachStudentPage({scope:'wordbook'}, student), /仅陪练老师/);
+  await assert.rejects(gateway.coachWordbookTarget(student, 1), /仅陪练老师/);
+});
+
 test('没有未结束复习时仅列出正常账号且仍有今天或未来排课的学员,先筛选再分页', async () => {
   for (let id = 1; id <= 13; id++) await user(id, { disabled: id === 2, deleted: id === 3, group: id === 4 ? 3 : 1 });
   await appointment(1, 1);

+ 26 - 1
scripts/tests/learning-word-duplicates.test.mjs

@@ -18,7 +18,8 @@ before(async()=>{
  INSERT INTO "PracticeRecord" VALUES('old','legacy:old','co','2025-01-01','2025-01-01',1001,20,101,'80',1,'0'),('new','legacy:new','co','2026-01-01','2026-01-01',1002,20,101,'80',2,'1'),('normal','legacy:normal','co','2026-01-01','2026-01-01',1003,20,102,'80',1,'0');`);
  await db.exec(read('../sql/mobile-teaching.sql'));
  const app=read('../deploy-admin-functions.mjs').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");
- ctx=vm.createContext({Psql:{query:q,one},Parse:{Object:Row,Query:class{constructor(name){this.name=name;}async get(id){return new Row(this.name,await one('SELECT * FROM "PracticeRecord" WHERE "objectId"=$1',[id]));}}}});
+ const tx=callback=>db.transaction(t=>callback({query:async(sql,args)=>(await t.query(sql,args)).rows,one:async(sql,args)=>(await t.query(sql,args)).rows[0]}));
+ ctx=vm.createContext({Psql:{query:q,one,tx},Parse:{Object:Row,Query:class{constructor(name){this.name=name;}async get(id){return new Row(this.name,await one('SELECT * FROM "PracticeRecord" WHERE "objectId"=$1',[id]));}}}});
  vm.runInContext(app+read('../cloud/mobile-teaching.js'),ctx);ctx.authorizeRequestedUser=async()=>20;
 });
 after(()=>db.close());
@@ -58,3 +59,27 @@ test('与线上相同的同时间、同次数重复记录按 objectId 稳定选
   assert.deepEqual(rows.map(r=>[r.objectId,r.sourceKey,Number(r.xxcs),r.jrscb]),[['a-tie','legacy:tie-a',2,'1'],['z-tie','legacy:tie-z',1,'0']]);
  }finally{await db.exec('ROLLBACK');}
 });
+test('老师带练只增加所选学员现有生词的学习次数,整批失败可重试且不会重复增加',async()=>{
+ const verifyTarget=ctx.coachWordbookTarget;
+ ctx.coachWordbookTarget=async(_current,uid)=>{
+  if(uid!==20)throw Error('不在你的生词本带练名单中');
+  return uid;
+ };
+ try {
+  const scope={scope:'wordbook'};
+  await assert.rejects(save('guided-unauthorized','101',{...scope,uid:99}),/不在你的生词本带练名单/);
+  await assert.rejects(save('guided-unmarked','101,102',scope),/已不在/);
+  await assert.rejects(save('guided-no-delete','101',{...scope,ifnew:0}),/仅支持保存/);
+  assert.equal((await one('SELECT xxcs FROM "PracticeRecord" WHERE "objectId"=\'new\'')).xxcs,'2');
+  const first=await save('guided-practice-retry','101',scope);
+  assert.equal(first.created,0);
+  assert.deepEqual(Array.from(first.objectIds),['new']);
+  await save('guided-practice-retry','101',scope);
+  const rows=await q('SELECT "objectId",xxcs,jrscb FROM "PracticeRecord" ORDER BY "objectId"');
+  assert.deepEqual(rows.map(r=>[r.objectId,Number(r.xxcs),r.jrscb]),[['new',3,'1'],['normal',1,'0'],['old',1,'0']]);
+  assert.equal((await one('SELECT count(*)::int n FROM "MobileTeachingCommand"')).n,1);
+ } finally {
+  ctx.coachWordbookTarget=verifyTarget;
+  await db.exec(`UPDATE "PracticeRecord" SET xxcs=2 WHERE "objectId"='new'; DELETE FROM "MobileTeachingCommand";`);
+ }
+});

+ 1 - 1
scripts/tests/wordbook-gateway.test.mjs

@@ -42,7 +42,7 @@ async function setup(t) {
     if (uid !== 42) throw new Error('无权访问该学员');
     return uid;
   };
-  return { read: input => context.newWordPage({ uid: 42, ...input }, {}), queryCount: () => queries };
+  return { read: input => context.newWordPage({ uid: 42, ...input }, { get: () => 1 }), queryCount: () => queries };
 }
 
 test('生词本未学分页保留全量状态计数,并隔离其他学员和帐套', async t => {