Просмотр исходного кода

feat: search review coaches with matching admin styling

彭峰 2 дней назад
Родитель
Сommit
53be74498b

+ 4 - 0
docs/qa/lesson-records-2026-10-13/README.md

@@ -46,3 +46,7 @@
 ## 独立存在的同步阻塞
 
 Mac 23:00 LaunchAgent 已恢复并确认在调度。今晚全量同步因 `legacy_account_identity_conflict` 安全中止;本次定向补齐不能代表全量同步成功,未伪造检查点或完成状态。账号身份冲突仍需单独核对处理。
+
+## 陪练选择交互补充
+
+目标陪练改为白底、绿色高亮的页面内搜索列表,支持姓名片段和编号匹配、方向键/回车选择、Escape 收起及无匹配提示;范围改为单选项。修改搜索会清空旧目标与预览,未从结果中选定不能转交。后台测试 268 项通过,生产构建通过;真实后台浏览器检查搜索、键盘、空结果、白底样式与原有查询/导出流程通过。未提交真实转交,无后端变更。含真实姓名的截图保留在本机私有验收目录。

+ 10 - 2
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html

@@ -19,8 +19,16 @@
 </section>
 @if(transferring()){
 <section class="transfer-panel"><h3>转交未完成复习</h3><p>包含逾期、今天和未来的已有任务;后续新课仍由该课授课老师负责。</p>
-<div class="form-grid"><label>范围<select [(ngModel)]="scope" (ngModelChange)="resetPreview()" [disabled]="busy()"><option value="lesson" [disabled]="!d.appointmentId">本节课未完成复习</option><option value="student">该学员全部未完成复习</option></select></label>
-<label>目标陪练<select [(ngModel)]="coachId" (ngModelChange)="resetPreview()" [disabled]="busy()"><option [ngValue]="0">请选择陪练</option>@for(c of coaches();track c.userId){<option [ngValue]="c.userId">{{c.displayName}} · {{c.userId}}</option>}</select></label>
+<div class="form-grid"><fieldset class="transfer-scope"><legend>范围</legend><label><input type="radio" name="transferScope" value="lesson" [(ngModel)]="scope" (ngModelChange)="resetPreview()" [disabled]="busy()||!d.appointmentId">本节课未完成复习</label><label><input type="radio" name="transferScope" value="student" [(ngModel)]="scope" (ngModelChange)="resetPreview()" [disabled]="busy()">该学员全部未完成复习</label></fieldset>
+<div class="coach-picker" (focusout)="closeCoachesOnBlur($event)"><label for="transfer-coach-search">目标陪练</label><input id="transfer-coach-search" type="search" autocomplete="off" placeholder="输入老师姓名或编号搜索" role="combobox" aria-autocomplete="list" aria-controls="transfer-coach-results" [attr.aria-expanded]="coachPickerOpen()" [attr.aria-activedescendant]="coachPickerOpen()&&activeCoach()>=0?'transfer-coach-'+activeCoach():null" [ngModel]="coachQuery()" (ngModelChange)="searchCoach($event)" (focus)="openCoaches()" (click)="openCoaches()" (keydown)="coachKeydown($event)" [disabled]="busy()">
+@if(coachPickerOpen()){
+<div class="coach-results" id="transfer-coach-results" role="listbox" aria-label="可选陪练老师">
+@for(c of filteredCoaches();track c.userId;let i=$index){<button type="button" role="option" [id]="'transfer-coach-'+i" [attr.aria-selected]="coachId===c.userId" [class.keyboard-active]="activeCoach()===i" (mousedown)="$event.preventDefault()" (click)="selectCoach(c)"><span>{{c.displayName}}</span><small>编号 {{c.userId}}</small>@if(coachId===c.userId){<span class="coach-check" aria-hidden="true">✓</span>}</button>}
+</div>
+@if(!filteredCoaches().length){<p class="coach-empty" role="status">没有匹配的陪练,请换个姓名或编号</p>}
+}
+<small class="coach-hint">{{coachId?'已选定陪练,可修改搜索重新选择':'支持姓名片段或编号,请从结果中选择'}}</small>
+</div>
 <label class="wide">转交原因<textarea maxlength="1000" [(ngModel)]="reason" (ngModelChange)="resetPreview()" [disabled]="busy()" placeholder="例如:原老师离岗,由新老师接手复习"></textarea></label></div>
 <button class="ui-button secondary" (click)="checkTransfer()" [disabled]="busy()">预览受影响任务</button>
 @if(preview();as p){<div class="notice"><strong>将转交 {{p.count}} 次复习,涉及 {{p.lessonCount}} 节课</strong><p>{{p.dateFrom}} 至 {{p.dateTo}}</p>@if(p.unlinkedCount){<p>{{p.unlinkedCount}} 条任务缺少课次关联,但学员归属已核实。</p>}

+ 13 - 1
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.ts

@@ -1,5 +1,5 @@
 import {CommonModule} from '@angular/common';
-import {Component,ChangeDetectionStrategy,OnChanges,Input,Output,EventEmitter,inject,signal} from '@angular/core';
+import {Component,ChangeDetectionStrategy,OnChanges,Input,Output,EventEmitter,computed,inject,signal} from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {OperationsService} from '../operations.service';
 import {LessonHistoryDetail,TransferPreview} from './lesson-record.models';
@@ -8,11 +8,23 @@ export class LessonRecordDetailComponent implements OnChanges {
  @Input() lessonId='';@Input() appointmentId='';@Output() closed=new EventEmitter<void>();@Output() changed=new EventEmitter<void>();
  readonly data=signal<LessonHistoryDetail|null>(null);readonly busy=signal(false);readonly error=signal('');readonly message=signal('');
  readonly transferring=signal(false);readonly preview=signal<TransferPreview|null>(null);readonly coaches=signal<{userId:number;displayName:string}[]>([]);
+ readonly coachQuery=signal('');readonly coachPickerOpen=signal(false);readonly activeCoach=signal(-1);
+ readonly filteredCoaches=computed(()=>{const q=this.coachQuery().trim().toLocaleLowerCase();const selected=this.coaches().find(c=>c.userId===this.coachId);return this.coaches().filter(c=>!q||selected&&q===this.coachLabel(selected).toLocaleLowerCase()||q.split(/\s+/).every(part=>(c.displayName+' '+c.userId).toLocaleLowerCase().includes(part)));});
  scope='lesson';coachId=0;reason='';private key='';private serial=0;private readonly ops=inject(OperationsService);
  ngOnChanges():void{void this.load();}
  async load():Promise<void>{const serial=++this.serial;this.busy.set(true);this.error.set('');try{const d=await this.ops.request<LessonHistoryDetail>('ops/lessons/detail',{lessonId:this.lessonId,appointmentId:this.appointmentId});if(serial===this.serial)this.data.set(d);}catch(e){if(serial===this.serial)this.error.set(e instanceof Error?e.message:'详情加载失败');}finally{if(serial===this.serial)this.busy.set(false);}}
  async beginTransfer():Promise<void>{this.transferring.set(true);if(!this.data()?.appointmentId)this.scope='student';this.resetPreview();this.busy.set(true);this.error.set('');try{const rows=await this.ops.request<{items:{userId:number;displayName:string}[]}>('ops/lessons/transfer-coaches');this.coaches.set(rows.items);}catch(e){this.error.set(e instanceof Error?e.message:'陪练列表加载失败');}finally{this.busy.set(false);}}
  resetPreview():void{this.preview.set(null);this.key='';}
+ coachLabel(coach:{userId:number;displayName:string}):string{return `${coach.displayName} · ${coach.userId}`;}
+ searchCoach(value:string):void{this.coachId=0;this.coachQuery.set(value);this.coachPickerOpen.set(true);this.activeCoach.set(-1);this.resetPreview();}
+ openCoaches():void{this.coachPickerOpen.set(true);this.activeCoach.set(-1);}
+ selectCoach(coach:{userId:number;displayName:string}):void{this.coachId=coach.userId;this.coachQuery.set(this.coachLabel(coach));this.coachPickerOpen.set(false);this.activeCoach.set(-1);this.resetPreview();}
+ closeCoachesOnBlur(event:FocusEvent):void{if(!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node|null))this.coachPickerOpen.set(false);}
+ coachKeydown(event:KeyboardEvent):void{
+  if(event.key==='Escape'){event.preventDefault();event.stopPropagation();this.coachPickerOpen.set(false);return;}
+  if(event.key==='ArrowDown'||event.key==='ArrowUp'){event.preventDefault();this.coachPickerOpen.set(true);const n=this.filteredCoaches().length;this.activeCoach.set(n?(this.activeCoach()<0?(event.key==='ArrowDown'?0:n-1):(this.activeCoach()+ (event.key==='ArrowDown'?1:-1)+n)%n):-1);setTimeout(()=>document.getElementById('transfer-coach-'+this.activeCoach())?.scrollIntoView({block:'nearest'}));}
+  if(event.key==='Enter'&&this.coachPickerOpen()){event.preventDefault();const row=this.filteredCoaches()[this.activeCoach()];if(row)this.selectCoach(row);}
+ }
  params():Record<string,unknown>{return{studentId:this.data()?.studentId,appointmentId:this.data()?.appointmentId,scope:this.scope,coachId:this.coachId};}
  async checkTransfer():Promise<void>{if(!this.coachId||this.reason.trim().length<2){this.error.set('请选择目标陪练,并填写至少两个字的转交原因');return;}this.busy.set(true);this.error.set('');this.preview.set(null);try{this.preview.set(await this.ops.request<TransferPreview>('ops/lessons/transfer-preview',this.params()));this.key=crypto.randomUUID();}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.busy.set(false);}}
  async saveTransfer():Promise<void>{const p=this.preview();if(!p?.count||this.busy())return;this.busy.set(true);this.error.set('');try{const r=await this.ops.request<{count:number}>('ops/lessons/transfer',{...this.params(),version:p.version,reason:this.reason.trim(),idempotencyKey:this.key});this.message.set(`已转交 ${r.count} 次未完成复习`);this.transferring.set(false);this.resetPreview();await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'保存失败,可用同一请求重试');}finally{this.busy.set(false);}}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.scss


+ 12 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -26,6 +26,18 @@ describe('上课记录与复习转交',()=>{
   c.coachId=11;c.reason='老师交接';c.preview.set({version:'old',count:2} as never);ops.request.and.rejectWith(new Error('任务变化'));
   await c.checkTransfer();expect(c.preview()).toBeNull();expect(c.error()).toBe('任务变化');
  });
+ it('陪练支持姓名片段和编号搜索,修改搜索后清除旧目标和旧预览',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordDetailComponent());c.coaches.set([{userId:69,displayName:'钱老师'},{userId:1665,displayName:'张老师'}]);
+  c.searchCoach('钱');expect(c.filteredCoaches().map(x=>x.userId)).toEqual([69]);c.selectCoach(c.filteredCoaches()[0]);expect(c.coachId).toBe(69);
+  c.preview.set({count:10,version:'old'} as never);c.searchCoach('665');expect(c.coachId).toBe(0);expect(c.preview()).toBeNull();expect(c.filteredCoaches().map(x=>x.userId)).toEqual([1665]);
+  c.reason='老师交接';await c.checkTransfer();expect(ops.request).not.toHaveBeenCalled();
+  c.searchCoach('不存在');expect(c.filteredCoaches()).toEqual([]);
+ });
+ it('键盘可选择搜索结果,Escape 仅收起陪练列表',()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordDetailComponent());c.coaches.set([{userId:69,displayName:'钱老师'}]);c.searchCoach('钱');
+  c.coachKeydown(new KeyboardEvent('keydown',{key:'ArrowDown'}));c.coachKeydown(new KeyboardEvent('keydown',{key:'Enter'}));expect(c.coachId).toBe(69);expect(c.coachPickerOpen()).toBeFalse();
+  c.openCoaches();c.coachKeydown(new KeyboardEvent('keydown',{key:'Escape'}));expect(c.coachPickerOpen()).toBeFalse();expect(c.coachId).toBe(69);
+ });
  it('保存失败保留同一幂等请求,重复点击仅提交一次;成功后回读',async()=>{
   const c=TestBed.runInInjectionContext(()=>new LessonRecordDetailComponent());c.data.set({studentId:20,appointmentId:'1000'} as never);c.coachId=11;c.reason='老师交接';
   ops.request.and.resolveTo({version:'v1',count:2});await c.checkTransfer();ops.request.and.rejectWith(new Error('断网'));

+ 4 - 2
scripts/check-lesson-records-browser.mjs

@@ -15,8 +15,10 @@ export async function checkLessonRecordsBrowser({auth,output,studentId=1857,memb
  await page.screenshot({path:output+'/history-desktop.png',fullPage:true});
  const downloadPromise=page.waitForEvent('download');await page.getByRole('button',{name:'导出 Excel',exact:true}).click();const download=await downloadPromise;assert.match(download.suggestedFilename(),/上课记录.*xlsx/);await download.saveAs(output+'/lesson-records.xlsx');const book=XLSX.read(readFileSync(output+'/lesson-records.xlsx'));assert.equal(XLSX.utils.sheet_to_json(book.Sheets[book.SheetNames[0]]).length,total);checks.push('真实筛选结果导出 Excel,行数与课次一致');
  await page.getByRole('button',{name:'查看反馈',exact:true}).first().click();await page.getByRole('dialog',{name:'上课记录详情'}).waitFor();await page.getByRole('heading',{name:'老师点评',exact:true}).waitFor({timeout:60000}).catch(async e=>{await page.screenshot({path:output+'/detail-error.png',fullPage:true});throw Error((await page.getByRole('dialog').innerText())+' '+e.message);});
- await page.getByRole('button',{name:'更换抗遗忘陪练'}).click();await page.locator('.transfer-panel select').last().locator('option').nth(1).waitFor({timeout:60000,state:'attached'});
- assert.ok(await page.locator('.transfer-panel select').last().locator('option').count()>1);checks.push('完整反馈详情与有效陪练选择;未提交真实转交');
+ await page.getByRole('button',{name:'更换抗遗忘陪练'}).click();const picker=page.getByRole('combobox',{name:'目标陪练'});await picker.waitFor();await picker.click();await page.locator('#transfer-coach-results').getByRole('option').first().waitFor({timeout:60000});
+ const firstOption=page.locator('#transfer-coach-results').getByRole('option').first(),teacherName=await firstOption.locator('span').first().innerText();await picker.fill(teacherName.slice(0,1));await page.locator('#transfer-coach-results').getByRole('option').first().waitFor();assert.ok((await page.locator('#transfer-coach-results').getByRole('option').allTextContents()).every(t=>t.includes(teacherName.slice(0,1))));
+ await picker.press('ArrowDown');await picker.press('Enter');assert.equal(await picker.getAttribute('aria-expanded'),'false');await picker.fill('没有这个老师XYZ');await page.getByText('没有匹配的陪练,请换个姓名或编号').waitFor();await picker.fill(teacherName.slice(0,1));await page.locator('#transfer-coach-results').getByRole('option').first().waitFor();
+ assert.equal(await page.locator('#transfer-coach-results').getByRole('option').first().evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(255, 255, 255)');await page.locator('.transfer-panel').screenshot({path:output+'/coach-search.png'});checks.push('陪练姓名模糊搜索、键盘选择、无结果提示及白底列表;未提交真实转交');
  await page.screenshot({path:output+'/feedback-desktop.png',fullPage:true});await page.getByRole('button',{name:'关闭',exact:true}).click();
  await page.setViewportSize({width:390,height:844});await page.screenshot({path:output+'/history-mobile-width.png',fullPage:true});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth+1));checks.push('窄屏无整页横向溢出');
  if(memberObjectId){await page.setViewportSize({width:1440,height:1050});await page.goto('http://127.0.0.1:4300/admin/members/'+memberObjectId);await page.getByRole('button',{name:'上课记录',exact:true}).click();await page.locator('app-lesson-records .data-table tbody tr').first().waitFor();assert.equal(await page.locator('nav.pagination').count(),0);checks.push('会员详情复用同一列表且不重复分页');}

Некоторые файлы не были показаны из-за большого количества измененных файлов