فهرست منبع

feat(admin): unify standard and searchable dropdown menus

彭峰 1 ماه پیش
والد
کامیت
51bbd3915e
26فایلهای تغییر یافته به همراه338 افزوده شده و 223 حذف شده
  1. 12 0
      projects/shared/ui/_dropdown.scss
  2. 188 0
      projects/shared/ui/dropdown.directive.ts
  3. 2 1
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts
  4. 2 1
      projects/xiaoshu-admin/src/app/components/member-enrollment.component.ts
  5. 2 1
      projects/xiaoshu-admin/src/app/pages/admin-migration.component.ts
  6. 2 1
      projects/xiaoshu-admin/src/app/pages/admin-resource.component.ts
  7. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts
  8. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts
  9. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts
  10. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  11. 6 6
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  12. 5 0
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss
  13. 3 1
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  14. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-special-training.component.ts
  15. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-stores.component.ts
  16. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts
  17. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  18. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts
  19. 2 1
      projects/xiaoshu-admin/src/app/pages/operator-accounts.component.ts
  20. 2 1
      projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts
  21. 51 0
      projects/xiaoshu-admin/src/app/shared/anchored-menu.directive.ts
  22. 36 0
      projects/xiaoshu-admin/src/app/shared/dropdown.directive.spec.ts
  23. 1 0
      projects/xiaoshu-admin/src/app/shared/dropdown.directive.ts
  24. 4 0
      projects/xiaoshu-admin/src/styles.scss
  25. 1 188
      projects/xiaoshu-mobile/src/app/shared/dropdown.directive.ts
  26. 1 13
      projects/xiaoshu-mobile/src/styles.scss

+ 12 - 0
projects/shared/ui/_dropdown.scss

@@ -0,0 +1,12 @@
+/* Shared app 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); }

+ 188 - 0
projects/shared/ui/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 = `xs-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);
+  }
+}

+ 2 - 1
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -1,10 +1,11 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { Component, Input, Output, EventEmitter, OnChanges, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { OperationsService } from '../operations.service';
 import { CreditAccounts, CreditTransaction } from '../member-enrollment.models';
 import { TeachingCreditAccount } from '../admin.models';
-@Component({selector:'app-member-credit-account',standalone:true,imports:[CommonModule,FormsModule],template:`
+@Component({selector:'app-member-credit-account',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],template:`
 <section class="panel credit-panel"><header class="section-header"><h3>{{store?'门店课时账户':'课时充值与划拨'}}</h3><button type="button" (click)="load()" [disabled]="saving()||loading()">刷新账户</button></header>
 @if(error()){<p role="alert" class="error">{{error()}}</p>}@if(message()){<p role="status">{{message()}}</p>}
 @if(loading()){<p role="status">正在刷新课时账户…</p>}

+ 2 - 1
projects/xiaoshu-admin/src/app/components/member-enrollment.component.ts

@@ -1,9 +1,10 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { Component, Input, Output, EventEmitter, OnChanges, inject, signal, computed } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { OperationsService } from '../operations.service';
 import { EnrollmentOptions } from '../member-enrollment.models';
-@Component({selector:'app-member-enrollment',standalone:true,imports:[CommonModule,FormsModule],template:`
+@Component({selector:'app-member-enrollment',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],template:`
 <section class="panel enrollment-panel"><h3>按分类开课</h3><p>先选择课程分类,再选择内容。同一内容可分别用于不同分类;开课不扣课时。</p>
 @if(error()){<p class="error" role="alert">{{error()}}</p>}@if(message()){<p role="status">{{message()}}</p>}
 @if(loading()){<p role="status">正在加载分类内容…</p>}@if(error()){<button type="button" (click)="load()">重试加载</button>}

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/admin-migration.component.ts

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

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/admin-resource.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -27,7 +28,7 @@ interface PlatformMemberPage {
 @Component({
   selector: 'app-admin-resource',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './admin-resource.component.html',
   styleUrl: './admin-resource.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, HostListener, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -10,7 +11,7 @@ import { ScheduleStatusTone, scheduleStatusLabel, scheduleStatusTone } from './o
 type MonthSection = 'schedule' | 'lessons' | 'payroll';
 const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).slice(0, 7);
 
-@Component({selector:'app-operations-coach-detail',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrls:['./operations-professional.scss','./operations-coach-detail.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-coach-detail',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrls:['./operations-professional.scss','./operations-coach-detail.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachDetailComponent implements OnInit{
  readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly sectionLoading=signal<MonthSection|null>(null);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'lessons'|'reviews'|'availability'|'payroll'>('schedule');readonly currentMonth=CURRENT_MONTH;readonly summaryMonth=signal(CURRENT_MONTH);readonly scheduleMonth=signal(CURRENT_MONTH);readonly lessonsMonth=signal(CURRENT_MONTH);readonly payrollMonth=signal(CURRENT_MONTH);readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly activeMonthPicker=signal<MonthSection|null>(null);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly upcomingCount=computed(()=>this.data()?.appointments.filter((row)=>!row.cancelled&&row.statusCode<30&&new Date(row.endsAt).getTime()>=Date.now()).length||0);readonly icons={ArrowLeft,Building2,CalendarDays,ChevronDown,ChevronLeft,ChevronRight,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
  ngOnInit():void{void this.load();}

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -6,7 +7,7 @@ import { ChevronLeft, ChevronRight, Eye, GraduationCap, LoaderCircle, LucideAngu
 import { CoachPage, CoachSummary } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
-@Component({selector:'app-operations-coaches',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrls:['./operations-professional.scss','./operations-coaches.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-coaches',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrls:['./operations-professional.scss','./operations-coaches.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachesComponent implements OnInit{
  readonly page=signal<CoachPage|null>(null);readonly queryInput=signal('');readonly statusInput=signal('all');readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly querying=signal(false);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
  readonly username=signal('');readonly password=signal('');readonly displayName=signal('');readonly mobile=signal('');readonly parentUserId=signal(0);readonly disabled=signal(false);readonly reason=signal('');

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnInit, ViewChild, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -18,7 +19,7 @@ interface CalendarDay { day: number; date: string; }
 @Component({
   selector: 'app-operations-learning-reports',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-learning-reports.component.html',
   styleUrls: ['./operations-professional.scss', './operations-learning-reports.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { ActivatedRoute } from '@angular/router';
 import { ReviewRewardsComponent } from './review-rewards.component';
 import { ReviewRewardFilters } from '../admin.models';
@@ -19,7 +20,7 @@ export function teachingRateMatrix(rules:TeachingRule[],categories:TeachingCours
 
 interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string}
 
-@Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-payroll',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsPayrollComponent implements OnInit{
  readonly adjustmentCoaches=signal<Array<{coachId:number;coachName:string}>>([]);
  readonly writingRows=signal<WritingMappingRow[]>([]);

+ 6 - 6
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -93,11 +93,11 @@
         <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
         <span class="search-control" [class.selected]="selectedMemberObjectId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
-          <input type="search" autocomplete="off" placeholder="输入学员姓名或手机号码" [ngModel]="memberSearch()" (ngModelChange)="updateMemberSearch($event)" (focus)="openSearch('member')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='member'" aria-controls="member-search-results" />
+          <input type="search" autocomplete="off" placeholder="输入学员姓名或手机号码" [ngModel]="memberSearch()" (ngModelChange)="updateMemberSearch($event)" (focus)="openSearch('member')" (click)="openSearch('member')" #memberSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='member'" aria-controls="member-search-results" />
           @if(selectedMemberObjectId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
         </span>
         @if(activeSearch()==='member'){
-          <span class="search-results" id="member-search-results" role="listbox">
+          <span class="search-results" [xsAnchoredMenu]="memberSearchInput" (xsDismiss)="activeSearch.set(null)" id="member-search-results" role="listbox">
             @for(member of filteredMembers();track member.objectId){
               <button type="button" class="search-option" role="option" [class.read-only]="member.readOnly" [attr.aria-disabled]="member.readOnly" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}@if(member.readOnly){ · 同步中,仅查看}</small></span><em>{{member.learningStatusLabel}}</em></button>
             } @empty {<span class="search-empty">没有匹配的学员,请检查姓名或手机号</span>}
@@ -108,11 +108,11 @@
         <span>选择课程 <small>仅显示当前学员已绑定课程,支持模糊匹配</small></span>
         <span class="search-control" [class.selected]="courseId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
-          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="studentId()?'输入课程名称关键词':'请先选择学员'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
+          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="studentId()?'输入课程名称关键词':'请先选择学员'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" (click)="openSearch('course')" #courseSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
           @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
         </span>
         @if(activeSearch()==='course'){
-          <span class="search-results" id="course-search-results" role="listbox">
+          <span class="search-results" [xsAnchoredMenu]="courseSearchInput" (xsDismiss)="activeSearch.set(null)" id="course-search-results" role="listbox">
             @for(course of filteredCourses();track course.bindingId||course.courseId){
               <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId && bindingId()===(course.bindingId||'')" (click)="chooseCourse(course)"><span><strong>{{course.categoryName}} · {{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?((course.stageName ? course.stageName+' · ' : '')+course.categoryName):'待处理课程分类'}}</small></span></button>
             } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
@@ -127,11 +127,11 @@
         <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
         <span class="search-control" [class.selected]="coachId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
-          <input type="search" autocomplete="off" placeholder="输入陪练老师姓名或手机号码,可暂不分配" [ngModel]="coachSearch()" (ngModelChange)="updateCoachSearch($event)" (focus)="openSearch('coach')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='coach'" aria-controls="coach-search-results" />
+          <input type="search" autocomplete="off" placeholder="输入陪练老师姓名或手机号码,可暂不分配" [ngModel]="coachSearch()" (ngModelChange)="updateCoachSearch($event)" (focus)="openSearch('coach')" (click)="openSearch('coach')" #coachSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='coach'" aria-controls="coach-search-results" />
           @if(coachId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
         </span>
         @if(activeSearch()==='coach'){
-          <span class="search-results" id="coach-search-results" role="listbox">
+          <span class="search-results" [xsAnchoredMenu]="coachSearchInput" (xsDismiss)="activeSearch.set(null)" id="coach-search-results" role="listbox">
             <button type="button" class="search-option muted-option" role="option" [attr.aria-selected]="!coachId()" (click)="clearCoach()"><span><strong>暂不分配</strong><small>保存为待分配预约</small></span></button>
             @for(coach of filteredCoaches();track coach.userId){
               <button type="button" class="search-option" [class.busy-option]="coachBusyAtSelectedTime(coach.userId)" role="option" [attr.aria-selected]="coachId()===coach.userId" (click)="chooseCoach(coach)"><span><strong>{{coach.displayName}}</strong><small>{{coach.mobileMasked||'未登记手机号'}} · ID {{coach.userId}}</small></span><em>{{coachCandidateHint(coach)}}</em></button>

+ 5 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss

@@ -1161,3 +1161,8 @@ textarea:focus-visible {
 @media (max-width: 720px) {
   .feedback-facts,.feedback-sections { grid-template-columns:1fr;padding-inline:16px; }
 }
+
+.search-control::after { content: ''; position: absolute; right: 20px; top: 17px; width: 8px; height: 8px; border: solid var(--ink-soft); border-width: 0 2px 2px 0; transform: rotate(45deg); pointer-events: none; }
+.search-control .selected-mark { right: 42px; }
+.search-control input { padding-right: 70px; }
+.xs-anchored-menu { border-radius: 12px; padding: 5px; box-shadow: 0 10px 28px rgb(20 53 45 / 18%); overscroll-behavior: contain; }

+ 3 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -1,3 +1,5 @@
+import { AnchoredMenuDirective } from '../shared/anchored-menu.directive';
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { ActivatedRoute } from '@angular/router';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
@@ -115,7 +117,7 @@ export function scheduleCoachBusy(
   });
 }
 
-@Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
+@Component({ selector:'app-operations-schedule', standalone:true, imports: [AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit {
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -7,7 +8,7 @@ import { OperationsService } from '../operations.service';
 
 type SpecialDraft = Pick<SpecialTrainingItem,'targetId'|'title'|'synopsis'|'coverUrl'|'tags'|'contentHtml'|'videoUrl'|'author'|'source'|'orderId'|'status'|'updatedAt'>;
 
-@Component({selector:'app-operations-special-training',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-special-training.component.html',styleUrls:['./operations-professional.scss','./operations-special-training.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-special-training',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-special-training.component.html',styleUrls:['./operations-professional.scss','./operations-special-training.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsSpecialTrainingComponent implements OnInit{
   readonly icons={BookOpen,Eye,Image,LayoutGrid,List,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,Upload,Video,X};
   readonly page=signal<SpecialTrainingPage|null>(null);readonly query=signal('');readonly status=signal('');readonly view=signal<'cards'|'list'>('cards');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-stores.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -10,7 +11,7 @@ import { OperationsService } from '../operations.service';
 @Component({
   selector: 'app-operations-stores',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-stores.component.html',
   styleUrls: ['./operations-professional.scss', './operations-stores.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
 import { MemberEnrollmentComponent } from '../components/member-enrollment.component';
@@ -13,7 +14,7 @@ type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'reports'
 
 @Component({
   selector: 'app-operations-user-detail', standalone: true,
-  imports: [OperationsDateFilterComponent, MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [DropdownDirective, OperationsDateFilterComponent, MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-user-detail.component.html', styleUrl: './operations-user-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -11,7 +12,7 @@ import { OperationsService } from '../operations.service';
 
 @Component({
   selector: 'app-operations-users', standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, OperationsDateFilterComponent],
+  imports: [DropdownDirective, CommonModule, FormsModule, RouterLink, LucideAngularModule, OperationsDateFilterComponent],
   templateUrl: './operations-users.component.html', styleUrl: './operations-users.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -11,7 +12,7 @@ type WordDraft = Pick<VocabularyWord, 'targetId'|'word'|'meaning'|'phonetic'|'ex
 
 @Component({
   selector: 'app-operations-vocabulary', standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './operations-vocabulary.component.html',
   styleUrls: ['./operations-professional.scss', './operations-vocabulary.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operator-accounts.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import {CommonModule} from '@angular/common';
 import {Component,inject,OnInit,signal} from '@angular/core';
 import {FormsModule} from '@angular/forms';
@@ -9,7 +10,7 @@ export interface OperatorAccount {objectId:string;username:string;displayName:st
 interface AccountPage {items:OperatorAccount[];total:number;page:number;pageSize:number;roles:{key:string;label:string;description:string}[];currentId:string;}
 interface AuditEntry {objectId:string;actorName:string;action:string;reason:string;before:Record<string,unknown>;after:Record<string,unknown>;createdAt:string;}
 type Action='create'|'save'|'status'|'reset-password'|'revoke-sessions';
-@Component({selector:'app-operator-accounts',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operator-accounts.component.html',styleUrl:'./operator-accounts.component.scss'})
+@Component({selector:'app-operator-accounts',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operator-accounts.component.html',styleUrl:'./operator-accounts.component.scss'})
 export class OperatorAccountsComponent implements OnInit {
  private api=inject(CloudFunctionsService);readonly icons={Plus,RefreshCw,ShieldCheck,X};
  data=signal<AccountPage|null>(null);loading=signal(false);saving=signal(false);error=signal('');message=signal('');formError=signal('');

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts

@@ -1,3 +1,4 @@
+import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, Input, OnChanges, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -5,7 +6,7 @@ import { ReviewRewardFilters, ReviewRewardPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { utils, writeFile } from 'xlsx';
 
-@Component({selector:'app-review-rewards',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-review-rewards',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class ReviewRewardsComponent implements OnChanges {
   @Input({required:true}) filters!: ReviewRewardFilters;
   readonly data=signal<ReviewRewardPage|null>(null);

+ 51 - 0
projects/xiaoshu-admin/src/app/shared/anchored-menu.directive.ts

@@ -0,0 +1,51 @@
+import { AfterViewInit, Directive, ElementRef, EventEmitter, inject, Input, OnDestroy, Output } from '@angular/core';
+
+/** Portals a searchable list below its input without losing Angular's option bindings. */
+@Directive({selector:'[xsAnchoredMenu]',standalone:true})
+export class AnchoredMenuDirective implements AfterViewInit, OnDestroy {
+  @Input({required:true}) xsAnchoredMenu!: HTMLInputElement;
+  @Output() xsDismiss = new EventEmitter<void>();
+  private readonly menu=inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
+  private readonly placeholder=document.createComment('anchored search menu');
+  private readonly cleanup:Array<()=>void>=[];
+
+  ngAfterViewInit():void {
+    const menu=this.menu, input=this.xsAnchoredMenu;
+    // Keep the page's palette after moving outside a drawer's clipping container.
+    const style=getComputedStyle(menu);
+    for(let i=0;i<style.length;i++){const key=style[i];if(key.startsWith('--'))menu.style.setProperty(key,style.getPropertyValue(key));}
+    menu.before(this.placeholder);document.body.append(menu);menu.classList.add('xs-anchored-menu');
+    if(window.innerHeight-input.getBoundingClientRect().bottom<150) input.scrollIntoView({block:'center',behavior:'instant'});
+    this.position();
+    const outside=(event:MouseEvent)=>{if(!menu.contains(event.target as Node)&&!input.parentElement?.contains(event.target as Node))this.xsDismiss.emit();};
+    const keydown=(event:KeyboardEvent)=>{
+      if(!menu.contains(event.target as Node)&&event.target!==input)return;
+      if(event.key==='Escape'){event.preventDefault();event.stopImmediatePropagation();input.focus({preventScroll:true});this.xsDismiss.emit();return;}
+      if(event.key==='Tab'){this.xsDismiss.emit();return;}
+      if(!['ArrowDown','ArrowUp','Home','End'].includes(event.key))return;
+      // Home/End in the search input still edit the search text.
+      if(event.target===input&&['Home','End'].includes(event.key))return;
+      const options=Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled):not([aria-disabled="true"])'));
+      if(!options.length)return;
+      event.preventDefault();let index=options.indexOf(document.activeElement as HTMLButtonElement);
+      if(event.key==='Home')index=0;else if(event.key==='End')index=options.length-1;
+      else if(event.key==='ArrowDown')index=(index+1)%options.length;
+      else index=index<0?options.length-1:(index-1+options.length)%options.length;
+      options[index].focus({preventScroll:true});options[index].scrollIntoView({block:'nearest'});
+    };
+    const scroll=(event:Event)=>{if(!menu.contains(event.target as Node))this.position();};
+    const resize=()=>this.position();
+    document.addEventListener('click',outside,true);document.addEventListener('keydown',keydown,true);document.addEventListener('scroll',scroll,true);window.addEventListener('resize',resize);
+    this.cleanup.push(()=>document.removeEventListener('click',outside,true),()=>document.removeEventListener('keydown',keydown,true),()=>document.removeEventListener('scroll',scroll,true),()=>window.removeEventListener('resize',resize));
+  }
+  private position():void {
+    const rect=(this.xsAnchoredMenu.closest('.search-control') || this.xsAnchoredMenu).getBoundingClientRect();
+    if(rect.bottom<0||rect.top>window.innerHeight){this.xsDismiss.emit();return;}
+    const width=Math.min(rect.width,window.innerWidth-24);
+    Object.assign(this.menu.style,{position:'fixed',zIndex:'20000',top:`${rect.bottom+6}px`,left:`${Math.max(12,Math.min(rect.left,window.innerWidth-width-12))}px`,right:'auto',width:`${width}px`,maxHeight:`${Math.max(44,Math.min(320,window.innerHeight-rect.bottom-18))}px`});
+  }
+  ngOnDestroy():void {
+    this.cleanup.forEach(dispose=>dispose());
+    this.menu.remove();this.placeholder.remove();
+  }
+}

+ 36 - 0
projects/xiaoshu-admin/src/app/shared/dropdown.directive.spec.ts

@@ -0,0 +1,36 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { DropdownDirective } from './dropdown.directive';
+import { AnchoredMenuDirective } from './anchored-menu.directive';
+
+@Component({standalone:true,imports:[ReactiveFormsModule,DropdownDirective],template:`<label>老师<select [formControl]="teacher"><option [ngValue]="null">全部老师</option>@for(person of people;track person.id){<option [ngValue]="person">{{person.name}}</option>}</select></label><label>工资标准<select [formControl]="rate"><option [ngValue]="0">零元</option><option [ngValue]="40">40 元</option></select></label>`})
+class SelectHost {people=[{id:1,name:'张老师'},{id:2,name:'李老师'}];teacher=new FormControl<{id:number;name:string}|null>(null);rate=new FormControl(0);}
+@Component({standalone:true,imports:[FormsModule,AnchoredMenuDirective],template:`<section style="height:60px;overflow:hidden"><label><span>搜索学员</span><span><input #anchor [(ngModel)]="query" (focus)="open=true" /></span>@if(open){<span role="listbox" [xsAnchoredMenu]="anchor" (xsDismiss)="open=false">@for(item of items;track item){@if(!query||item.includes(query)){<button role="option" (click)="selected=item;open=false">{{item}}</button>}}</span>}</label></section>`})
+class SearchHost {open=false;query='';selected='';items=['张同学','李同学'];}
+
+describe('后台统一下拉',()=>{
+ it('保留响应式表单中的对象、null、零元和禁用状态',async()=>{
+  TestBed.configureTestingModule({imports:[SelectHost]});const fixture=TestBed.createComponent(SelectHost);fixture.detectChanges();await fixture.whenStable();
+  const triggers=fixture.nativeElement.querySelectorAll('.xs-dropdown-trigger');triggers[0].click();
+  (document.querySelectorAll('.xs-dropdown-menu button')[2] as HTMLButtonElement).click();
+  expect(fixture.componentInstance.teacher.value).toBe(fixture.componentInstance.people[1]);
+  triggers[0].click();(document.querySelector('.xs-dropdown-menu button') as HTMLButtonElement).click();expect(fixture.componentInstance.teacher.value).toBeNull();
+  expect(fixture.componentInstance.rate.value).toBe(0);fixture.componentInstance.rate.disable();fixture.detectChanges();await fixture.whenStable();expect(triggers[1].disabled).toBeTrue();
+ });
+ it('搜索菜单不被弹窗裁切,筛选和选择仍更新 Angular 状态',async()=>{
+  TestBed.configureTestingModule({imports:[SearchHost]});const fixture=TestBed.createComponent(SearchHost);fixture.detectChanges();await fixture.whenStable();
+  const input=fixture.nativeElement.querySelector('input') as HTMLInputElement;input.focus();fixture.detectChanges();
+  const menu=document.querySelector<HTMLElement>('.xs-anchored-menu')!;expect(menu.parentElement).toBe(document.body);expect(menu.getBoundingClientRect().top).toBeGreaterThan(input.getBoundingClientRect().bottom);
+  input.value='李';input.dispatchEvent(new Event('input'));fixture.detectChanges();await fixture.whenStable();expect(menu.querySelectorAll('button').length).toBe(1);
+  menu.querySelector('button')!.click();fixture.detectChanges();expect(fixture.componentInstance.selected).toBe('李同学');expect(document.querySelector('.xs-anchored-menu')).toBeNull();
+ });
+ it('搜索菜单支持方向键、Esc、点击外部及销毁时清理',async()=>{
+  TestBed.configureTestingModule({imports:[SearchHost]});const fixture=TestBed.createComponent(SearchHost);fixture.detectChanges();await fixture.whenStable();
+  const input=fixture.nativeElement.querySelector('input') as HTMLInputElement;input.focus();fixture.detectChanges();
+  input.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}));expect(document.activeElement?.textContent).toBe('张同学');
+  document.activeElement!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));fixture.detectChanges();expect(fixture.componentInstance.open).toBeFalse();
+  fixture.componentInstance.open=true;fixture.detectChanges();document.body.click();fixture.detectChanges();expect(fixture.componentInstance.open).toBeFalse();
+  fixture.componentInstance.open=true;fixture.detectChanges();fixture.destroy();expect(document.querySelector('.xs-anchored-menu')).toBeNull();
+ });
+});

+ 1 - 0
projects/xiaoshu-admin/src/app/shared/dropdown.directive.ts

@@ -0,0 +1 @@
+export { DropdownDirective } from '../../../../shared/ui/dropdown.directive';

+ 4 - 0
projects/xiaoshu-admin/src/styles.scss

@@ -1,3 +1,4 @@
+@use '../../shared/ui/dropdown';
 :root {
   --brand: #2f9b82;
   --brand-strong: #1f7b68;
@@ -786,3 +787,6 @@ app-learning-page .word-assessment .unknown-button.active { color: #4d3100; back
 @media (max-width: 360px) {
   :root { --page-gutter: 10px; }
 }
+
+/* Compact selectors in technical resource toolbars keep their horizontal layout. */
+body .bulk-toolbar > button.xs-dropdown-trigger { flex: 0 0 auto; width: auto; min-width: 130px; min-height: 38px; padding-block: 8px; font-size: 12px; }

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

@@ -1,188 +1 @@
-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);
-  }
-}
+export { DropdownDirective } from '../../../../shared/ui/dropdown.directive';

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

@@ -1,3 +1,4 @@
+@use '../../shared/ui/dropdown';
 :root {
   --brand: #2f9b82;
   --brand-strong: #1f7b68;
@@ -853,16 +854,3 @@ 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); }