Przeglądaj źródła

feat(mobile): unify dropdown menus across student and teacher pages

彭峰 1 miesiąc temu
rodzic
commit
12e28ace3e
18 zmienionych plików z 291 dodań i 62 usunięć
  1. 6 4
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  2. 2 1
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  3. 2 1
      projects/xiaoshu-mobile/src/app/features/checkout/checkout-page.component.ts
  4. 6 15
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  5. 0 8
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss
  6. 3 23
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts
  7. 2 1
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.ts
  8. 2 1
      projects/xiaoshu-mobile/src/app/features/form/form-page.component.ts
  9. 2 1
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  10. 2 1
      projects/xiaoshu-mobile/src/app/features/order/order-page.component.ts
  11. 2 1
      projects/xiaoshu-mobile/src/app/features/resource/resource-page.component.ts
  12. 0 1
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss
  13. 3 2
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts
  14. 2 1
      projects/xiaoshu-mobile/src/app/features/team/team-page.component.ts
  15. 55 0
      projects/xiaoshu-mobile/src/app/shared/dropdown.directive.spec.ts
  16. 188 0
      projects/xiaoshu-mobile/src/app/shared/dropdown.directive.ts
  17. 13 0
      projects/xiaoshu-mobile/src/styles.scss
  18. 1 1
      scripts/cloud/mobile-teaching.js

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

@@ -300,20 +300,22 @@ describe('home learning modules', () => {
     component.serverStudents.set([{id:'100',name:'张雅茜',count:15},{id:'101',name:'花花',count:9},{id:'102',name:' ',count:1}]);
     spyOn(component,'load').and.stub();
     harness.fixture.detectChanges();
-    const trigger=harness.routeNativeElement!.querySelector<HTMLButtonElement>('.student-filter-trigger')!;
+    await new Promise(resolve => setTimeout(resolve, 0));
+    const trigger=harness.routeNativeElement!.querySelector<HTMLButtonElement>('.student-filter .xs-dropdown-trigger')!;
     expect(trigger.textContent).toContain('全部学员(25)');
     trigger.click();harness.fixture.detectChanges();
-    const menu=harness.routeNativeElement!.querySelector<HTMLElement>('.student-filter-menu')!;
+    const menu=document.querySelector<HTMLElement>('.xs-dropdown-menu')!;
     expect(menu.textContent).toContain('张雅茜(15)');
     expect(menu.textContent).toContain('学员 102(1)');
     expect(menu.getBoundingClientRect().top).toBeGreaterThan(trigger.getBoundingClientRect().bottom);
     menu.querySelectorAll('button')[2].click();harness.fixture.detectChanges();
+    await new Promise(resolve => setTimeout(resolve, 0));
     expect(component.selectedStudent()).toBe('101');
     expect(trigger.textContent).toContain('花花(9)');
-    expect(component.studentMenuOpen()).toBeFalse();
+    expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
     trigger.click();harness.fixture.detectChanges();
     document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));harness.fixture.detectChanges();
-    expect(component.studentMenuOpen()).toBeFalse();
+    expect(document.querySelector('.xs-dropdown-menu')).toBeNull();
   });
 
   it('renders source-shaped data for all four home entries', async () => {

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -14,7 +15,7 @@ type SourceItem = Record<string, unknown>;
 @Component({
   selector: 'app-appointment-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './appointment-page.component.html',
   styleUrl: './appointment-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/checkout/checkout-page.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -39,7 +40,7 @@ interface CheckoutInvoice {
 @Component({
   selector: 'app-checkout-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './checkout-page.component.html',
   styleUrl: './checkout-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 6 - 15
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -50,21 +50,12 @@
               <button type="button" [class.active]="scheduleRange() === 'all'" (click)="showAllSchedules()">全部</button>
             </nav>
             @if (isCoach && studentOptions().length) {
-              <div class="student-filter">
-                <span id="schedule-student-label">学员</span>
-                <button type="button" class="student-filter-trigger" aria-labelledby="schedule-student-label schedule-student-value"
-                  [attr.aria-expanded]="studentMenuOpen()" aria-controls="schedule-student-options" (click)="studentMenuOpen.set(!studentMenuOpen())">
-                  <span id="schedule-student-value">{{ studentFilterLabel() }}</span><i aria-hidden="true"></i>
-                </button>
-                @if (studentMenuOpen()) {
-                  <div id="schedule-student-options" class="student-filter-menu" role="group" aria-label="筛选学员">
-                    <button type="button" [attr.aria-pressed]="selectedStudent() === 'all'" (click)="setStudentFilter('all')">全部学员({{ allStudentCount() }})</button>
-                    @for (student of studentOptions(); track student.id) {
-                      <button type="button" [attr.aria-pressed]="selectedStudent() === student.id" (click)="setStudentFilter(student.id)">{{ student.name }}({{ student.count }})</button>
-                    }
-                  </div>
-                }
-              </div>
+              <label class="student-filter"><span>学员</span>
+                <select aria-label="筛选排课学员" [ngModel]="selectedStudent()" (ngModelChange)="setStudentFilter($event)">
+                  <option value="all">全部学员({{ allStudentCount() }})</option>
+                  @for (student of studentOptions(); track student.id) { <option [value]="student.id">{{ student.name }}({{ student.count }})</option> }
+                </select>
+              </label>
             }
           </div>
 

+ 0 - 8
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss

@@ -25,13 +25,6 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .range-tabs button.active { color: white; background: var(--brand-strong); box-shadow: 0 4px 12px rgb(20 53 45 / 16%); font-weight: 700; }
 .student-filter { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 4px; }
 .student-filter > span { color: var(--ink-soft); font-size: 11px; line-height: 13px; }
-.student-filter-trigger { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 48px; padding: 10px 18px 10px 12px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: 10px; font: inherit; font-size: 13px; text-align: left; }
-.student-filter-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.student-filter-trigger i { flex: 0 0 8px; height: 8px; margin: -4px 2px 0 0; border: solid var(--ink-soft); border-width: 0 2px 2px 0; transform: rotate(45deg); }
-.student-filter-menu { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; max-height: min(320px, 50vh); overflow-y: auto; padding: 5px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 10px 28px rgb(20 53 45 / 18%); }
-.student-filter-menu button { display: block; width: 100%; min-height: 44px; padding: 10px; border: 0; border-radius: 7px; color: var(--ink); background: transparent; font: inherit; font-size: 13px; text-align: left; overflow-wrap: anywhere; }
-.student-filter-menu button[aria-pressed="true"], .student-filter-menu button:hover { color: var(--brand-strong); background: var(--brand-pale); }
-.student-filter button:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
 .history-filter-panel { padding: 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--brand-pale) 42%, white), white); border-bottom: 1px solid var(--line); }
 .history-filter-grid { display: grid; grid-template-columns: minmax(140px, .85fr) minmax(170px, 1.15fr); gap: 10px; }
 .history-filter-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
@@ -42,7 +35,6 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .history-filter-control select:focus, .history-filter-control input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-pale) 76%, transparent); }
 .history-filter-control input::placeholder { color: color-mix(in srgb, var(--ink-soft) 72%, white); }
 .history-filter-control input::-webkit-search-cancel-button { display: none; }
-.month-control::after { position: absolute; right: 15px; width: 8px; height: 8px; border: solid var(--ink-soft); border-width: 0 2px 2px 0; border-radius: 1px; content: ''; pointer-events: none; transform: translateY(-2px) rotate(45deg); }
 .month-control select { padding-right: 34px; }
 .history-filter-panel footer { display: flex; min-height: 25px; align-items: end; justify-content: space-between; margin-top: 8px; color: var(--ink-soft); font-size: 11px; gap: 8px; }
 .history-filter-panel footer strong { color: var(--brand-strong); font-size: 13px; }

+ 3 - 23
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -1,5 +1,6 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
@@ -47,7 +48,7 @@ interface CompanionOverview {
 @Component({
   selector: 'app-course-center-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './course-center-page.component.html',
   styleUrl: './course-center-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -67,26 +68,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly scheduleRange = signal<ScheduleRange>('day');
   readonly selectedDate = signal(this.today());
   readonly selectedStudent = signal('all');
-  readonly studentMenuOpen = signal(false);
-  private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
   readonly allStudentCount = computed(() => this.studentOptions().reduce((sum, student) => sum + student.count, 0));
-  readonly studentFilterLabel = computed(() => {
-    const student = this.studentOptions().find(item => item.id === this.selectedStudent());
-    return student ? `${student.name}(${student.count})` : `全部学员(${this.allStudentCount()})`;
-  });
-
-  @HostListener('document:click', ['$event'])
-  closeStudentMenuOutside(event: MouseEvent): void {
-    if (!(event.target instanceof Node) || !this.host.nativeElement.querySelector('.student-filter')?.contains(event.target)) this.studentMenuOpen.set(false);
-  }
-
-  @HostListener('document:keydown.escape')
-  closeStudentMenu(): void {
-    if (!this.studentMenuOpen()) return;
-    this.studentMenuOpen.set(false);
-    this.host.nativeElement.querySelector<HTMLButtonElement>('.student-filter-trigger')?.focus();
-  }
-
   readonly historyMonth = signal('all');
   readonly historyStudentQuery = signal('');
   readonly calendarMonth = signal(this.firstOfMonth(new Date()));
@@ -253,8 +235,6 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   setStudentFilter(value: string): void {
-    this.studentMenuOpen.set(false);
-    this.host.nativeElement.querySelector<HTMLButtonElement>('.student-filter-trigger')?.focus();
     this.selectedStudent.set(value || 'all');
     this.load();
     this.syncScheduleState();

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

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -14,7 +15,7 @@ import { normalizeReadingParams, READING_LEVELS, READING_THEMES, READING_TYPES,
   selector: 'app-reading-setup',
   standalone: true,
   providers: [ReadingCloudService, EnglishReadingService],
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './reading-setup.component.html',
   styleUrl: './reading-setup.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/form/form-page.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -13,7 +14,7 @@ import { SessionService } from '../../core/session.service';
 @Component({
   selector: 'app-form-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './form-page.component.html',
   styleUrl: './form-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

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

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { LearningDraft } from '../../core/learning-draft';
 import { ReviewPage, reviewCalendar } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
@@ -100,7 +101,7 @@ const WORD_CATALOG_CATEGORIES = [
 @Component({
   selector: 'app-learning-page',
   standalone: true,
-  imports: [CommonModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, LucideAngularModule],
   templateUrl: './learning-page.component.html',
   styleUrl: './learning-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/order/order-page.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -15,7 +16,7 @@ type OrderRecord = Record<string, unknown>;
 @Component({
   selector: 'app-order-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './order-page.component.html',
   styleUrl: './order-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/resource/resource-page.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -15,7 +16,7 @@ type DataItem = Record<string, unknown>;
 @Component({
   selector: 'app-resource-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './resource-page.component.html',
   styleUrl: './resource-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

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

@@ -9,7 +9,6 @@
 .student-filter { position: relative; }
 .student-filter select { padding-right: 44px; appearance: none; }
 .student-filter select:disabled { cursor: wait; opacity: .68; }
-.student-filter::after { content: ""; position: absolute; right: 19px; bottom: 19px; width: 8px; height: 8px; border: solid var(--ink); border-width: 0 2px 2px 0; transform: rotate(45deg); pointer-events: none; }
 .date-filter { position: relative; }
 .date-picker-field { position: relative; }
 .date-trigger { display: flex; width: 100%; height: 48px; align-items: center; justify-content: space-between; padding: 0 14px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius-md); }

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

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { ReviewPage } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, OnDestroy, signal } from '@angular/core';
@@ -38,7 +39,7 @@ interface LearningOverview {
 @Component({
   selector: 'app-study-records-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './study-records-page.component.html',
   styleUrl: './study-records-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -429,7 +430,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   }
 
   studentName(student: Item): string {
-    const name = String(student['honeyname'] ?? student['HoneyName'] ?? student['honeyName'] ?? student['UserName'] ?? student['username'] ?? '').trim();
+    const name = [student['honeyname'], student['HoneyName'], student['honeyName'], student['realName'], student['nickname'], student['UserName'], student['username']].map(value => String(value ?? '').trim()).find(Boolean);
     if (name) return name;
     const mobile = String(student['Mobile'] ?? student['mobile'] ?? '').trim();
     return mobile || `学员 ${this.studentValue(student)}`;

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/team/team-page.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -15,7 +16,7 @@ type DialogMode = 'upgrade' | 'supply' | '';
 @Component({
   selector: 'app-team-page',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './team-page.component.html',
   styleUrl: './team-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 55 - 0
projects/xiaoshu-mobile/src/app/shared/dropdown.directive.spec.ts

@@ -0,0 +1,55 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { DropdownDirective } from './dropdown.directive';
+
+@Component({standalone:true,imports:[FormsModule,DropdownDirective],template:`
+<form (change)="changes=changes+1"><label>学员<select name="student" [(ngModel)]="value" [disabled]="disabled"><option [ngValue]="0">全部学员</option>@for(item of items;track item.id){<option [ngValue]="item.id">{{item.name}}</option>}</select></label></form>
+<label>类型<select [(ngModel)]="type"><option value="reading">阅读</option><option value="writing" disabled>写作</option><option value="words">单词</option></select></label>`})
+class HostComponent {value=0;disabled=false;changes=0;type='reading';items=[{id:11,name:'张雅茜'},{id:12,name:'花花'}];}
+
+describe('shared anchored dropdown',()=>{
+ beforeEach(()=>TestBed.configureTestingModule({imports:[HostComponent]}));
+ const menu=()=>document.querySelector<HTMLElement>('.xs-dropdown-menu');
+ it('preserves numeric ngValue and emits exactly one bubbling form change',async()=>{
+  const fixture=TestBed.createComponent(HostComponent);fixture.detectChanges();await fixture.whenStable();
+  const trigger=fixture.nativeElement.querySelector('.xs-dropdown-trigger') as HTMLButtonElement;
+  trigger.click();const options=menu()!.querySelectorAll<HTMLButtonElement>('button');
+  expect(options[1].textContent).toBe('张雅茜');options[1].click();fixture.detectChanges();await fixture.whenStable();
+  expect(fixture.componentInstance.value).toBe(11);expect(fixture.componentInstance.changes).toBe(1);
+  expect(trigger.textContent).toContain('张雅茜');expect(menu()).toBeNull();
+ });
+ it('updates asynchronous labels and programmatic selection, honors disabled and cleans up on destroy',async()=>{
+  const fixture=TestBed.createComponent(HostComponent);fixture.detectChanges();await fixture.whenStable();
+  fixture.componentInstance.items=[{id:11,name:'更新后的姓名'}];fixture.componentInstance.value=11;
+  fixture.detectChanges();await fixture.whenStable();
+  const trigger=fixture.nativeElement.querySelector('.xs-dropdown-trigger') as HTMLButtonElement;
+  expect(trigger.textContent).toContain('更新后的姓名');fixture.componentInstance.disabled=true;fixture.detectChanges();await fixture.whenStable();
+  expect(trigger.disabled).toBeTrue();trigger.click();expect(menu()).toBeNull();
+  fixture.componentInstance.disabled=false;fixture.detectChanges();await fixture.whenStable();trigger.click();expect(menu()).not.toBeNull();fixture.destroy();expect(menu()).toBeNull();
+ });
+ it('supports keyboard selection, skips disabled options and dismisses on Escape/outside click',async()=>{
+  const fixture=TestBed.createComponent(HostComponent);fixture.detectChanges();await fixture.whenStable();
+  const trigger=fixture.nativeElement.querySelectorAll('.xs-dropdown-trigger')[1] as HTMLButtonElement;
+  trigger.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}));
+  menu()!.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}));
+  expect(document.activeElement?.textContent).toBe('单词');(document.activeElement as HTMLButtonElement).click();
+  expect(fixture.componentInstance.type).toBe('words');trigger.click();document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));expect(menu()).toBeNull();
+  trigger.click();document.body.click();expect(menu()).toBeNull();
+ });
+ it('renders outside a clipped dialog and retains form values when dismissed',async()=>{
+  const fixture=TestBed.createComponent(HostComponent);fixture.detectChanges();await fixture.whenStable();
+  Object.assign(fixture.nativeElement.style,{overflow:'hidden',height:'50px'});
+  const trigger=fixture.nativeElement.querySelector('.xs-dropdown-trigger') as HTMLButtonElement;trigger.click();
+  expect(menu()?.parentElement).toBe(document.body);
+  expect(menu()!.getBoundingClientRect().top).toBeGreaterThan(trigger.getBoundingClientRect().bottom);
+  const outside=document.createElement('button');fixture.nativeElement.append(outside);outside.addEventListener('click',event=>event.stopPropagation());outside.click();
+  expect(menu()).toBeNull();expect(fixture.componentInstance.value).toBe(0);expect(fixture.componentInstance.changes).toBe(0);
+ });
+ it('keeps only one picker open across fields and closes when options change',async()=>{
+  const fixture=TestBed.createComponent(HostComponent);fixture.detectChanges();await fixture.whenStable();
+  const triggers=fixture.nativeElement.querySelectorAll('.xs-dropdown-trigger');triggers[0].click();triggers[1].click();
+  expect(document.querySelectorAll('.xs-dropdown-menu').length).toBe(1);expect(triggers[0].getAttribute('aria-expanded')).toBe('false');
+  fixture.componentInstance.type='words';fixture.detectChanges();await fixture.whenStable();expect(menu()).toBeNull();
+ });
+});

+ 188 - 0
projects/xiaoshu-mobile/src/app/shared/dropdown.directive.ts

@@ -0,0 +1,188 @@
+import { AfterViewChecked, AfterViewInit, Directive, ElementRef, inject, NgZone, OnDestroy, Renderer2 } from '@angular/core';
+
+/** Keeps native Angular select bindings while providing one consistent, anchored picker. */
+@Directive({selector: 'select', standalone: true})
+export class DropdownDirective implements AfterViewInit, AfterViewChecked, OnDestroy {
+  private static nextId = 0;
+  private static active?: DropdownDirective;
+  private readonly select = inject<ElementRef<HTMLSelectElement>>(ElementRef).nativeElement;
+  private readonly renderer = inject(Renderer2);
+  private readonly zone = inject(NgZone);
+  private readonly id = `mobile-dropdown-${++DropdownDirective.nextId}`;
+  private trigger?: HTMLButtonElement;
+  private label?: HTMLSpanElement;
+  private menu?: HTMLDivElement;
+  private observer?: MutationObserver;
+  private readonly cleanup: Array<() => void> = [];
+  private menuCleanup: Array<() => void> = [];
+  private pending = false;
+  private destroyed = false;
+  private signature = '';
+  private search = '';
+  private searchedAt = 0;
+  private originalTabIndex: string | null = null;
+  private originalAriaHidden: string | null = null;
+
+  ngAfterViewInit(): void {
+    const select = this.select;
+    // Current app controls are all single-select; don't silently change multi-select semantics.
+    if (select.multiple || select.size > 1) return;
+    const trigger = this.trigger = document.createElement('button');
+    trigger.type = 'button';
+    trigger.className = 'xs-dropdown-trigger';
+    trigger.setAttribute('role', 'combobox');
+    trigger.setAttribute('aria-haspopup', 'listbox');
+    trigger.setAttribute('aria-expanded', 'false');
+    this.label = document.createElement('span');
+    const arrow = document.createElement('i');
+    arrow.setAttribute('aria-hidden', 'true');
+    trigger.append(this.label, arrow);
+    select.insertAdjacentElement('afterend', trigger);
+    this.originalTabIndex = select.getAttribute('tabindex');
+    this.originalAriaHidden = select.getAttribute('aria-hidden');
+    select.classList.add('xs-dropdown-native');
+    select.tabIndex = -1;
+    select.setAttribute('aria-hidden', 'true');
+    this.listen(trigger, 'click', (event: Event) => { event.preventDefault(); this.menu ? this.close() : this.open(); });
+    this.listen(trigger, 'keydown', (event: KeyboardEvent) => {
+      if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); this.open(event.key); }
+      else if (event.key === 'Escape') this.close();
+    });
+    this.listen(select, 'focus', () => trigger.focus());
+    this.listen(select, 'change', () => this.sync());
+    this.listen(trigger, 'blur', () => select.dispatchEvent(new Event('blur')));
+    if (select.form) this.listen(select.form, 'reset', () => this.scheduleSync());
+    const outsideClick = (event: MouseEvent) => {
+      const target = event.target as Node;
+      if (this.menu && !trigger.contains(target) && !this.menu.contains(target) && target !== select) this.close(false);
+    };
+    const escape = (event: KeyboardEvent) => {
+      if (this.menu && event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); this.close(); }
+    };
+    // Capture so modal containers that stop bubbling cannot trap the open menu.
+    document.addEventListener('click', outsideClick, true);
+    document.addEventListener('keydown', escape, true);
+    this.cleanup.push(() => document.removeEventListener('click', outsideClick, true), () => document.removeEventListener('keydown', escape, true));
+    this.listen(window, 'resize', () => this.positionMenu());
+    const onScroll = (event: Event) => { if (this.menu && !this.menu.contains(event.target as Node)) this.positionMenu(); };
+    document.addEventListener('scroll', onScroll, true);
+    this.cleanup.push(() => document.removeEventListener('scroll', onScroll, true));
+    this.observer = new MutationObserver(() => this.scheduleSync());
+    this.observer.observe(select, {subtree:true, childList:true, characterData:true, attributes:true, attributeFilter:['disabled','label','value','selected','aria-label','aria-required','required']});
+    this.sync();
+  }
+
+  ngAfterViewChecked(): void { this.scheduleSync(); }
+  private scheduleSync(): void {
+    if (this.pending || this.destroyed || !this.trigger) return;
+    this.pending = true;
+    this.zone.runOutsideAngular(() => queueMicrotask(() => { this.pending = false; if (!this.destroyed) this.sync(); }));
+  }
+
+  private optionLabel(option: HTMLOptionElement): string {
+    return option.label.trim() || option.textContent?.trim() || '未命名选项';
+  }
+  private accessibleLabel(): string {
+    const explicit = this.select.getAttribute('aria-label');
+    if (explicit) return explicit;
+    const label = this.select.labels?.[0];
+    if (!label) return '选择选项';
+    const copy = label.cloneNode(true) as HTMLElement;
+    copy.querySelectorAll('select,button,input,textarea,svg').forEach(element => element.remove());
+    return copy.textContent?.trim() || '选择选项';
+  }
+  private sync(): void {
+    if (!this.trigger || !this.label) return;
+    this.label.textContent = this.select.selectedOptions[0] ? this.optionLabel(this.select.selectedOptions[0]) : '请选择';
+    this.trigger.disabled = this.select.disabled;
+    this.trigger.setAttribute('aria-label', this.accessibleLabel());
+    this.trigger.setAttribute('aria-required', String(this.select.required || this.select.getAttribute('aria-required') === 'true'));
+    const signature = JSON.stringify(Array.from(this.select.options, option => [option.value, this.optionLabel(option), option.disabled, option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled, option.selected]));
+    if (this.menu && (signature !== this.signature || this.select.disabled)) this.close(false);
+    this.signature = signature;
+  }
+
+  private open(key?: string): void {
+    this.sync();
+    if (!this.trigger || this.trigger.disabled || this.menu) return;
+    DropdownDirective.active?.close(false);
+    // Make room below the control even when it is near the bottom of a dialog or viewport.
+    if (window.innerHeight - this.trigger.getBoundingClientRect().bottom < 150) this.trigger.scrollIntoView({block:'center', behavior:'instant'});
+    const menu = this.menu = document.createElement('div');
+    menu.className = 'xs-dropdown-menu';
+    menu.id = this.id;
+    menu.setAttribute('role', 'listbox');
+    menu.setAttribute('aria-label', this.accessibleLabel());
+    for (const option of Array.from(this.select.options)) {
+      if (option.hidden) continue;
+      const button = document.createElement('button');
+      button.type = 'button';
+      button.setAttribute('role', 'option');
+      button.setAttribute('aria-selected', String(option.selected));
+      button.textContent = this.optionLabel(option);
+      button.disabled = option.disabled || (option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled);
+      button.tabIndex = -1;
+      menu.append(button);
+      this.menuCleanup.push(this.renderer.listen(button, 'click', () => {
+        if (this.select.disabled || button.disabled) return;
+        this.select.selectedIndex = option.index;
+        this.close();
+        this.select.dispatchEvent(new Event('change', {bubbles:true}));
+        this.sync();
+      }));
+    }
+    if (!menu.children.length) { const empty=document.createElement('p');empty.textContent='暂无可选项';menu.append(empty); }
+    // A viewport overlay escapes scroll cards and modal overflow clipping.
+    document.body.append(menu);
+    this.positionMenu();
+    this.trigger.setAttribute('aria-expanded', 'true');
+    this.trigger.setAttribute('aria-controls', this.id);
+    DropdownDirective.active = this;
+    this.menuCleanup.push(this.renderer.listen(menu, 'keydown', (event: KeyboardEvent) => this.menuKeydown(event)));
+    const enabled = this.enabledOptions();
+    const selected = enabled.find(button => button.getAttribute('aria-selected') === 'true');
+    const target = key==='Home' ? enabled[0] : key==='End' ? enabled.at(-1) : selected || (key==='ArrowUp' ? enabled.at(-1) : enabled[0]);
+    target?.focus({preventScroll:true});
+    if (target) menu.scrollTop = Math.max(0, target.offsetTop - menu.clientHeight/2);
+  }
+  private positionMenu(): void {
+    if (!this.menu || !this.trigger) return;
+    const rect = this.trigger.getBoundingClientRect();
+    if (rect.bottom < 0 || rect.top > window.innerHeight) { this.close(false); return; }
+    const width = Math.min(rect.width, window.innerWidth - 24);
+    Object.assign(this.menu.style, {left:`${Math.max(12, Math.min(rect.left, window.innerWidth-width-12))}px`, top:`${rect.bottom+6}px`, width:`${width}px`, maxHeight:`${Math.max(44, Math.min(320,window.innerHeight-rect.bottom-18))}px`});
+  }
+  private enabledOptions(): HTMLButtonElement[] { return Array.from(this.menu?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || []); }
+  private menuKeydown(event: KeyboardEvent): void {
+    if (event.key === 'Tab') { this.close(); return; }
+    const options = this.enabledOptions();
+    if (!options.length) return;
+    let index = options.indexOf(document.activeElement as HTMLButtonElement);
+    if (event.key === 'ArrowDown') index = (index+1)%options.length;
+    else if (event.key === 'ArrowUp') index = (index-1+options.length)%options.length;
+    else if (event.key === 'Home') index = 0;
+    else if (event.key === 'End') index = options.length-1;
+    else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && event.key !== ' ') {
+      this.search = Date.now()-this.searchedAt > 800 ? event.key : this.search+event.key;
+      this.searchedAt = Date.now();
+      index = options.findIndex(option => option.textContent?.toLocaleLowerCase().startsWith(this.search.toLocaleLowerCase()));
+    } else return;
+    event.preventDefault();
+    if (index>=0) { options[index].focus({preventScroll:true}); options[index].scrollIntoView({block:'nearest'}); }
+  }
+  private close(restoreFocus = true): void {
+    if (!this.menu) return;
+    this.menuCleanup.forEach(dispose => dispose());this.menuCleanup=[];
+    this.menu.remove(); this.menu=undefined;
+    this.trigger?.setAttribute('aria-expanded','false');
+    this.trigger?.removeAttribute('aria-controls');
+    if (DropdownDirective.active===this) DropdownDirective.active=undefined;
+    if (restoreFocus) this.trigger?.focus({preventScroll:true});
+  }
+  private listen(target: unknown, event: string, callback: (event: any) => void): void { this.cleanup.push(this.renderer.listen(target, event, callback)); }
+  ngOnDestroy(): void {
+    this.destroyed=true;this.close(false);this.observer?.disconnect();this.cleanup.forEach(dispose=>dispose());this.trigger?.remove();
+    this.select.classList.remove('xs-dropdown-native');
+    for(const [name,value] of [['tabindex',this.originalTabIndex],['aria-hidden',this.originalAriaHidden]] as const) value===null?this.select.removeAttribute(name):this.select.setAttribute(name,value);
+  }
+}

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

@@ -853,3 +853,16 @@ app-learning-page .word-column-empty { display: flex; min-height: 150px; padding
 }
 
 .mobile-pagination{display:flex;align-items:center;justify-content:center;gap:16px;padding:20px;flex-wrap:wrap}.mobile-pagination button{border:1px solid #dce6e2;border-radius:10px;background:#fff;color:#18745d;padding:10px 16px;font:inherit;cursor:pointer}.mobile-pagination button:disabled{opacity:.45;cursor:default}
+
+/* Shared student/teacher dropdowns; native selects remain the form value source. */
+select.xs-dropdown-native { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; opacity: 0 !important; overflow: hidden !important; pointer-events: none !important; clip-path: inset(50%); }
+body button.xs-dropdown-trigger { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 48px; padding: 11px 20px 11px 12px; margin: 0; border: 1px solid var(--line); border-radius: 10px; color: var(--ink); background: var(--surface, white); font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
+body button.xs-dropdown-trigger > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; font: inherit; }
+body button.xs-dropdown-trigger > i { flex: 0 0 8px; width: 8px; height: 8px; margin: -4px 2px 0 0; border: solid var(--ink-soft); border-width: 0 2px 2px 0; transform: rotate(45deg); }
+body button.xs-dropdown-trigger:disabled { cursor: not-allowed; opacity: .55; }
+body button.xs-dropdown-trigger:focus-visible, .xs-dropdown-menu button:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+body .xs-dropdown-menu { position: fixed; z-index: 20000; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; padding: 5px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface, white); box-shadow: 0 10px 28px rgb(20 53 45 / 18%); }
+body .xs-dropdown-menu button { display: block; width: 100%; min-height: 44px; padding: 10px; border: 0; border-radius: 7px; color: var(--ink); background: transparent; font: inherit; font-size: 14px; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
+body .xs-dropdown-menu button[aria-selected="true"], body .xs-dropdown-menu button:hover:not(:disabled) { color: var(--brand-strong); background: var(--brand-pale); }
+body .xs-dropdown-menu button:disabled { opacity: .45; cursor: not-allowed; }
+.xs-dropdown-menu p { margin: 10px; color: var(--ink-soft); }

+ 1 - 1
scripts/cloud/mobile-teaching.js

@@ -24,7 +24,7 @@ async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
   const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
-  const students=coach?await Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(u."realName"),MAX(u."nickname"),MAX(u."username"),m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',args):[];
+  const students=coach?await Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',args):[];
   if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId));
   const baseWhere=clauses.join(' AND '),baseArgs=[...args];
   const dateSql='LEFT(COALESCE(NULLIF(m."kywrq",\'\'),m."kywsj"),10)';