Przeglądaj źródła

fix: reveal review transfer panel and coach options on open

彭峰 7 godzin temu
rodzic
commit
3cf87a35be

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

@@ -50,3 +50,7 @@ Mac 23:00 LaunchAgent 已恢复并确认在调度。今晚全量同步因 `legac
 ## 陪练选择交互补充
 
 目标陪练改为白底、绿色高亮的页面内搜索列表,支持姓名片段和编号匹配、方向键/回车选择、Escape 收起及无匹配提示;范围改为单选项。修改搜索会清空旧目标与预览,未从结果中选定不能转交。后台测试 268 项通过,生产构建通过;真实后台浏览器检查搜索、键盘、空结果、白底样式与原有查询/导出流程通过。未提交真实转交,无后端变更。含真实姓名的截图保留在本机私有验收目录。
+
+## 2026-10-14 展开区域自动定位
+
+更换按钮在内容渲染后平滑滚动详情抽屉,让转交区域底部进入视野;展开或输入搜索时同样定位老师选择区域。只滚动详情抽屉,尊重系统减少动画设置,关闭组件后取消待执行定位。候选列表高度适配视口并独立滚动。真实浏览器在 1440×720 和 390×640 下检查面板底部、搜索框顶部和列表底部的实际坐标通过;268 项后台测试与生产构建通过。无云函数或业务数据改动。

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

@@ -1,4 +1,4 @@
-<div class="backdrop" (click)="close()"></div><aside class="drawer lesson-drawer" role="dialog" aria-modal="true" aria-label="上课记录详情" (keydown.escape)="close()">
+<div class="backdrop" (click)="close()"></div><aside #detailDrawer class="drawer lesson-drawer" role="dialog" aria-modal="true" aria-label="上课记录详情" (keydown.escape)="close()">
 <header><div><h2>上课记录详情</h2><p class="muted">课堂反馈与抗遗忘安排</p></div><button class="ui-button secondary" [disabled]="busy()" (click)="close()">关闭</button></header>
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}@if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(busy()){<p role="status">正在处理…</p>}

+ 18 - 5
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.ts

@@ -1,10 +1,12 @@
 import {CommonModule} from '@angular/common';
-import {Component,ChangeDetectionStrategy,OnChanges,Input,Output,EventEmitter,computed,inject,signal} from '@angular/core';
+import {Component,ChangeDetectionStrategy,OnChanges,OnDestroy,Input,Output,EventEmitter,computed,inject,signal,ViewChild,ElementRef,Injector,afterNextRender,AfterRenderRef} from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {OperationsService} from '../operations.service';
 import {LessonHistoryDetail,TransferPreview} from './lesson-record.models';
 @Component({selector:'app-lesson-record-detail',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./lesson-record-detail.component.html',styleUrls:['./operations-professional.scss','./lesson-records.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
-export class LessonRecordDetailComponent implements OnChanges {
+export class LessonRecordDetailComponent implements OnChanges,OnDestroy {
+ @ViewChild('detailDrawer') private drawer?:ElementRef<HTMLElement>;
+ private readonly injector=inject(Injector);private pendingReveal?:AfterRenderRef;
  @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}[]>([]);
@@ -12,12 +14,23 @@ export class LessonRecordDetailComponent implements OnChanges {
  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();}
+ ngOnDestroy():void{this.pendingReveal?.destroy();}
+ private revealAfterRender(selector:string):void{
+  this.pendingReveal?.destroy();
+  this.pendingReveal=afterNextRender(()=>{
+   const drawer=this.drawer?.nativeElement,target=drawer?.querySelector<HTMLElement>(selector);if(!drawer||!target)return;
+   const bounds=drawer.getBoundingClientRect(),rect=target.getBoundingClientRect(),viewport=window.visualViewport;
+   const top=Math.max(bounds.top,viewport?.offsetTop||0)+16,bottom=Math.min(bounds.bottom,viewport?viewport.offsetTop+viewport.height:window.innerHeight)-16;
+   const delta=rect.bottom>bottom?rect.bottom-bottom:rect.top<top?rect.top-top:0;
+   if(delta)drawer.scrollTo({top:drawer.scrollTop+delta,behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});
+  },{injector:this.injector});
+ }
  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);}}
+ async beginTransfer():Promise<void>{this.transferring.set(true);this.coachPickerOpen.set(false);this.revealAfterRender('.transfer-panel');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);}
+ searchCoach(value:string):void{this.coachId=0;this.coachQuery.set(value);this.coachPickerOpen.set(true);this.activeCoach.set(-1);this.resetPreview();this.revealAfterRender('.coach-picker');}
+ openCoaches():void{this.coachPickerOpen.set(true);this.activeCoach.set(-1);this.revealAfterRender('.coach-picker');}
  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{

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.scss

@@ -2,3 +2,5 @@
 
 /* Keep coach search and scope controls in the admin palette, independent of OS menus. */
 .transfer-scope{border:0;padding:0;margin:0;min-width:0}.transfer-scope legend,.coach-picker>label{font-size:13px;font-weight:600;color:#5d7168;margin-bottom:8px}.transfer-scope label{display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:10px;cursor:pointer}.transfer-scope input{width:17px;min-height:17px;margin:0;accent-color:#197958}.coach-picker{min-width:0}.coach-picker>label{display:block}.coach-picker input{color-scheme:light;background:#fff}.coach-results{margin-top:8px;max-height:240px;overflow:auto;background:#fff;border:1px solid #ccdcd4;border-radius:10px;box-shadow:0 6px 18px #234d3510}.coach-results:empty{display:none}.transfer-panel .coach-results button{display:flex;align-items:center;gap:10px;width:100%;margin:0;padding:12px 14px;border:0;border-bottom:1px solid #eef3f0;background:#fff;color:#233d35;text-align:left;font:inherit;border-radius:0}.coach-results button:last-child{border-bottom:0}.transfer-panel .coach-results button:hover,.transfer-panel .coach-results button.keyboard-active,.transfer-panel .coach-results button[aria-selected=true]{background:#e6f3ed;color:#146c50}.coach-results small{font-size:12px;color:#697e75;margin-left:auto;white-space:nowrap}.coach-check{color:#197958;font-weight:700}.coach-hint{display:block;font-size:12px;color:#697e75;line-height:1.6;margin-top:7px}.coach-empty{padding:12px;margin:8px 0 0;background:#fff;border:1px solid #dfe8e4;border-radius:8px;font-size:13px;color:#697e75}
+
+.lesson-drawer{overscroll-behavior:contain}.coach-results{max-height:min(240px,35dvh);overscroll-behavior:contain}

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

@@ -15,9 +15,18 @@ 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();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 picker=page.getByRole('combobox',{name:'目标陪练'});
+ for(const viewport of [{width:1440,height:720},{width:390,height:640}]){
+  await page.setViewportSize(viewport);await page.getByRole('button',{name:'更换抗遗忘陪练'}).click();
+  await page.waitForFunction(()=>{const r=document.querySelector('.transfer-panel')?.getBoundingClientRect();return r&&r.bottom<=innerHeight-8&&r.bottom>0;},null,{timeout:5000});
+  await picker.click();await page.locator('#transfer-coach-results').getByRole('option').first().waitFor({timeout:60000});
+  await page.waitForFunction(()=>{const input=document.querySelector('#transfer-coach-search')?.getBoundingClientRect(),list=document.querySelector('#transfer-coach-results')?.getBoundingClientRect();return input&&list&&input.top>=8&&list.bottom<=innerHeight-8&&list.height>0;},null,{timeout:5000});
+  await page.screenshot({path:output+'/coach-scroll-'+viewport.width+'.png'});await picker.press('Escape');await page.locator('.transfer-panel').getByRole('button',{name:'取消',exact:true}).click();
+ }
+ checks.push('桌面和窄屏:打开转交自动露出底部,展开老师列表后搜索框和列表完整可见');
+ await page.setViewportSize({width:1440,height:1050});await page.getByRole('button',{name:'更换抗遗忘陪练'}).click();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();
+ await picker.press('ArrowDown');await picker.press('Enter');await page.waitForFunction(()=>document.querySelector('#transfer-coach-search')?.getAttribute('aria-expanded')==='false',null,{timeout:3000});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('窄屏无整页横向溢出');