Browse Source

fix(mobile): show student names in anchored schedule filter

彭峰 3 weeks ago
parent
commit
16893345fa

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

@@ -5,6 +5,7 @@ import { By } from '@angular/platform-browser';
 import { provideRouter, Router } from '@angular/router';
 import { RouterTestingHarness } from '@angular/router/testing';
 import { Observable, of, Subject, throwError } from 'rxjs';
+import { CourseCenterPageComponent } from '../features/course-center/course-center-page.component';
 import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
 import { LearningPageComponent } from '../features/learning/learning-page.component';
 import { routes } from '../app.routes';
@@ -291,6 +292,30 @@ describe('home learning modules', () => {
     });
   });
 
+  it('shows named schedule students below the trigger and closes after selection or Escape', async () => {
+    user.set({userId:300,userName:'coach',honeyName:'张老师',groupId:3});
+    const harness=await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/home/shop');
+    const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+    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')!;
+    expect(trigger.textContent).toContain('全部学员(25)');
+    trigger.click();harness.fixture.detectChanges();
+    const menu=harness.routeNativeElement!.querySelector<HTMLElement>('.student-filter-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();
+    expect(component.selectedStudent()).toBe('101');
+    expect(trigger.textContent).toContain('花花(9)');
+    expect(component.studentMenuOpen()).toBeFalse();
+    trigger.click();harness.fixture.detectChanges();
+    document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));harness.fixture.detectChanges();
+    expect(component.studentMenuOpen()).toBeFalse();
+  });
+
   it('renders source-shaped data for all four home entries', async () => {
     const harness = await RouterTestingHarness.create();
 

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

@@ -50,15 +50,21 @@
               <button type="button" [class.active]="scheduleRange() === 'all'" (click)="showAllSchedules()">全部</button>
             </nav>
             @if (isCoach && studentOptions().length) {
-              <label class="student-filter">
-                <span>学员</span>
-                <select [ngModel]="selectedStudent()" (ngModelChange)="setStudentFilter($event)">
-                  <option value="all">全部学员({{ listTotal() }})</option>
-                  @for (student of studentOptions(); track student.id) {
-                    <option [value]="student.id">{{ student.name }}({{ student.count }})</option>
-                  }
-                </select>
-              </label>
+              <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>
             }
           </div>
 

+ 9 - 4
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss

@@ -24,9 +24,14 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .range-tabs button { min-height: 40px; padding: 6px 4px; color: var(--ink-soft); background: transparent; border: 0; border-radius: 8px; 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::after { position: absolute; right: 18px; bottom: 20px; width: 9px; height: 9px; border: solid var(--ink); border-width: 0 2px 2px 0; border-radius: 1px; content: ''; pointer-events: none; transform: rotate(45deg); }
-.student-filter select { width: 100%; height: 48px; padding: 0 42px 0 11px; color: var(--ink); appearance: none; background: white; border: 1px solid var(--line); border-radius: 10px; font-size: 13px; outline: 0; }
+.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; }
@@ -92,7 +97,7 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }
 @media (max-width: 420px) {
-  .schedule-toolbar { grid-template-columns: 1fr; }
+  .schedule-toolbar, .schedule-toolbar:has(.student-filter) { grid-template-columns: 1fr; }
   .schedule-list article { grid-template-columns: 54px minmax(0, 1fr) auto auto auto; gap: 7px; }
   .schedule-list article:not(:has(.report-button)) { grid-template-columns: 54px minmax(0, 1fr) auto auto; }
   .status { width: fit-content; }

+ 24 - 2
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, 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';
@@ -67,13 +67,33 @@ 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()));
   readonly now = signal(Date.now());
   readonly loading = signal(false);
   readonly error = signal('');
-  readonly studentOptions = computed(() => this.serverStudents());
+  readonly studentOptions = computed(() => this.serverStudents().map(student => ({...student, id: String(student.id), name: String(student.name || '').trim() || `学员 ${student.id}`})));
   readonly historyMonthOptions = computed(() => this.buildHistoryMonthOptions());
   readonly historyFilterActive = computed(() => this.historyMonth() !== 'all' || Boolean(this.historyStudentQuery().trim()));
   readonly scopedSchedules = computed(() => this.schedules().filter((item) => this.matchesStudent(item)));
@@ -233,6 +253,8 @@ 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();

+ 5 - 0
scripts/cloud/mobile-teaching.js

@@ -53,3 +53,8 @@ async function mobileAtomicWrite(input,current,operations,scope='') {
   const row=await Psql.one('SELECT xs_mobile_teaching_write($1,$2,$3,md5($4),$5::jsonb) AS result',[DEFAULT_COMPANY_ID,scope||String(ownLegacyId(current)),String(current.id)+':'+key,JSON.stringify(semantic),JSON.stringify(operations)]);
   return row.result;
 }
+
+// Empty profile fields must not hide the student's legacy display name.
+function companionStudentNameSql() {
+  return `COALESCE(NULLIF(BTRIM(u."realName"),''),NULLIF(BTRIM(u."nickname"),''),NULLIF(BTRIM(u."legacyUserData"->>'HoneyName'),''),NULLIF(BTRIM(SPLIT_PART(REPLACE(c."title",'(','('),'(',1)),''),NULLIF(BTRIM(u."username"),''),'学员 '||a."szyh")`;
+}

+ 1 - 1
scripts/deploy-admin-functions.mjs

@@ -2724,7 +2724,7 @@ async function appCompanionOverview(input,current) {
   const join=' FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=a."company" AND c."modelId"=54 AND c."itemId"=a."id"';
   const summaryWhere=clauses.join(' AND '),summaryArgs=[...args];
   const dates=await Psql.query('SELECT '+date+' AS date,COUNT(*) FILTER(WHERE a."dszt"::numeric>=30 AND COALESCE(c."status",99)<>-2)::int finished,COUNT(*) FILTER(WHERE a."dszt"::numeric<30 AND COALESCE(c."status",99)<>-2)::int planned'+join+' WHERE '+summaryWhere+' GROUP BY '+date,summaryArgs);
-  const studentOptions=coach?await Psql.query('SELECT a."szyh" AS id,MAX(COALESCE(u."realName",u."nickname",u."username",a."szyh")) AS name,COUNT(*)::int count'+join+' LEFT JOIN "_User" u ON u."company"=a."company" AND u."legacyUserId"::text=a."szyh" WHERE a."company"=$1 AND a."pl"=$2 AND COALESCE(c."status",99)<>-2 GROUP BY a."szyh" ORDER BY name',[DEFAULT_COMPANY_ID,String(uid)]):[];
+  const studentOptions=coach?await Psql.query('SELECT a."szyh" AS id,MAX('+companionStudentNameSql()+') AS name,COUNT(*)::int count'+join+' LEFT JOIN "_User" u ON u."company"=a."company" AND u."legacyUserId"::text=a."szyh" WHERE a."company"=$1 AND a."pl"=$2 AND COALESCE(c."status",99)<>-2 GROUP BY a."szyh" ORDER BY name',[DEFAULT_COMPANY_ID,String(uid)]):[];
   if(input.dateStart)add(date+'>=?',String(input.dateStart));
   if(input.dateEnd)add(date+'<=?',String(input.dateEnd));
   if(input.filter==='finished')clauses.push('a."dszt"::numeric>=30 AND COALESCE(c."status",99)<>-2');

+ 12 - 0
scripts/tests/companion-student-names.test.mjs

@@ -0,0 +1,12 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {runInNewContext} from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+test('排课学员姓名跳过空白字段,兼容旧昵称、预约标题和缺失资料',async()=>{
+ const expression=runInNewContext(readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8')+';companionStudentNameSql()');
+ const db=new PGlite();try{
+  const rows=[['小徐','','{}','其他(60分钟练习)','login','小徐'],['',' ','{"HoneyName":"花花"}','其他(60分钟练习)','login','花花'],['','','{}','张雅茜(60分钟练习)','login','张雅茜'],[null,null,'{}','',null,'学员 213']];
+  for(const [realName,nickname,legacy,title,username,expected]of rows){const result=await db.query(`SELECT ${expression} AS name FROM (SELECT $1::text "realName",$2::text nickname,$3::jsonb "legacyUserData",$5::text username) u CROSS JOIN (SELECT $4::text title) c CROSS JOIN (SELECT '213'::text szyh) a`,[realName,nickname,legacy,title,username]);assert.equal(result.rows[0].name,expected);}
+ }finally{await db.close();}
+});