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

fix(admin): unify member detail UI and correct business records

彭峰 4 недель назад
Родитель
Сommit
0d07b7ed88

+ 40 - 0
docs/qa/member-detail-ui-audit-2026-09-09.md

@@ -0,0 +1,40 @@
+# 会员详情 UI 与核心功能检查(2026-09-09)
+
+检查范围:概览、课程、预约、上课记录、学习报表、课时流水、操作审计,以及从详情进入排课的表单。根据用户截图与已登录页面逐项检查,使用真实页面只读交互;充值、开课、排课、完课等写入使用独立测试账号。
+
+## 逐项结果
+
+| 步骤 | 状态 | 修复与验证 |
+| --- | --- | --- |
+| 1. 概览与资料 | 已修复,页面验证通过 | 顶部操作统一按钮样式;原因栏移入文档流,不再盖住账号安全区;门店搜索不再自动解绑;未结束预约排除回收站记录,现场样本从 13 修正为 9。 |
+| 2. 课程与开课 | 已修复,页面与接口验证通过 | 六个分类可选,语法写作固定对应学阶;切换分类立即清空旧选项,加载失败不能提交旧内容;确认操作、数量表格与表单统一样式。历史待确认分类仍须运营核对。 |
+| 3. 预约与排课入口 | 已修复,页面与接口验证通过 | 预约列表沿用绑定分类、时长及取消状态;详情入口带入当前学员,选择开课记录后匹配分类时长;去掉可选学阶缺失时的 undefined;使用共享日历。 |
+| 4. 上课记录 | 已修复,页面验证通过 | 显示真实教师姓名及开课分类,历史缺失教师明确回退;教师、时间、课型不逐字换行;长评价与表格滚动保持可读。 |
+| 5. 学习报表 | 已修复,页面验证通过 | 日期区使用共享日历;开始和结束日期合法性校验;按精确学员身份筛选;实测日期筛选从 4 条变为 1 条,重置可恢复;支持每页 20 条。 |
+| 6. 课时与流水 | 已修复,页面与事务验证通过 | 同一处展示余额、预留和可用;刷新同时更新账户和历史流水;加载与提交禁用冲突操作;整数课时、有限数值及两位小数校验;双边划拨与关联原单退回已实现。 |
+| 7. 操作审计 | 页面检查通过 | 备注、操作原因、保存按钮及审计空状态统一样式;独立测试业务产生相应审计。未对真实学员提交备注或安全操作。 |
+
+## UI 规范
+
+- 正文、按钮、表单输入、数据表与日历日期统一 14px;字段标签 13px,辅助说明 12px。
+- 页标题 28px(窄屏 24px)、资料卡姓名 22px、板块标题 18px、数值 26px。
+- 主按钮使用一致绿色;次按钮、危险操作、禁用、焦点状态使用统一规则。
+- 桌面、768px 和 390px 宽度已检查;390px 页面宽度为 390px,无整页横向溢出;长表格与页签局部滚动。
+- 截图保存在本机 `tmp/member-detail-ui-audit/`,含修改前后和日历展开状态;含业务资料,不提交 Git。
+
+## 本地验证
+
+- `npm run test:admin:ci`:178 项通过。
+- `npm run test:member-enrollment`:26 项通过,包括真实 PostgreSQL 兼容引擎测试取消预约统计、隔离、事务及并发约束。
+- `node scripts/deploy-admin-functions.mjs --validate`:11 个云函数定义通过。
+- `npm run build:admin`:通过;其他页面原有样式预算与 CommonJS 警告保留。
+- `git diff --check`:通过。
+
+## 线上验证与发布边界
+
+- 更新前备份两个运营网关,随后更新 `xiaoshu/ops/gateway-v3` 和 `xiaoshu/ops/gateway`。
+- `scripts/smoke-member-enrollment.mjs`:线上 25 项检查通过,包含创建学员、总部入账、双边划拨、零余额拒绝、六分类、跨分类开课、移动端分类读取、共享词汇进度、完课幂等、取消与恢复、充值/完课并发、最后一课并发争抢、退款/预留互斥、流水与审计对账。
+- 结果为 `ONLINE_SMOKE_PASSED` 和 `FIXTURES_CLEANED`;真实学员资料、余额与预约未写入。
+- 已实现业务流程不代表历史迁移全部完成:线上仍有 1298 条历史绑定待确认分类,本次不自动猜测或修改。
+- 管理端 UI 已在本机 4300 服务生效,并生成生产构建;本次没有已确认的独立前端生产托管地址,因此不将本机页面称为公网前端发布。
+- 本次不声称完成全站无障碍认证或真实学员操作验收。

+ 25 - 0
projects/xiaoshu-admin/src/app/components/_member-controls.scss

@@ -0,0 +1,25 @@
+@mixin controls {
+  button, .action-link {
+    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+    min-height: 40px; padding: 9px 14px; border: 1px solid var(--line, #dce5e2);
+    border-radius: 9px; background: #fff; color: #344944;
+    font: inherit; font-size: 14px; font-weight: 600; line-height: 20px;
+    text-decoration: none; cursor: pointer;
+  }
+  button:hover:not(:disabled), .action-link:hover { border-color: #80ad9f; background: #f1f8f5; }
+  button:disabled { opacity: .55; cursor: not-allowed; }
+  button.primary, .action-link.primary { border-color: #19765c; background: #19765c; color: #fff; }
+  button.primary:hover:not(:disabled), .action-link.primary:hover { background: #125e49; }
+  button.danger { border-color: #edc3c7; background: #fff5f5; color: #a33f48; }
+  input:not([type=checkbox]), select, textarea {
+    width: 100%; min-height: 40px; padding: 9px 12px; border: 1px solid #d9e3e0;
+    border-radius: 9px; background: #fff; color: #2d413b; box-sizing: border-box;
+    font: inherit; font-size: 14px; line-height: 20px;
+  }
+  label { display: grid; gap: 7px; min-width: 0; color: #526960; font-size: 13px; line-height: 20px; }
+  input:disabled, select:disabled, textarea:disabled { color: #697b74; background: #f4f7f6; }
+  input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible, a:focus-visible {
+    outline: 2px solid #23876a; outline-offset: 3px;
+  }
+  small { font-size: 12px; line-height: 1.6; }
+}

+ 11 - 10
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -5,31 +5,32 @@ 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:`
-<section class="panel"><h2>{{store?'门店课时账户':'课时充值与划拨'}}</h2>
+<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>}
 @if(data();as value){
 <p>{{store?'总部入账后,可向所属学员划拨课时。':'充值来源:'+(value.storeName||'尚未关联门店')}}</p>
 <div class="accounts">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span></article>}</div>
-@if(value.canPost){<form (ngSubmit)="submit()">
+@if(value.canPost){<form (ngSubmit)="submit()"><fieldset [disabled]="saving()||loading()">
 @if(!store){<label>业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='' "><option value="add">门店划拨充值</option><option value="deduct">退回原门店</option></select></label>}
 @if(direction==='deduct'&&!store){<label>原划拨单<select name="source" [(ngModel)]="sourceId" (ngModelChange)="selectSource($event)"><option value="">请选择原划拨记录</option>@for(t of refundable();track t.objectId){<option [value]="t.objectId">{{t.createdAt|date:'MM-dd HH:mm'}} · {{label(t.account)}} · 尚未退回 {{t.unreturned}}</option>}</select></label>}
 <label>课时类型<select name="account" [(ngModel)]="account" [disabled]="direction==='deduct'&&!store">@for(a of value.accounts;track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label>
-<label>数量<input name="amount" type="number" min="0.01" [step]="account==='UserPoint'?0.01:1" [(ngModel)]="amount" required /></label>
+<label>数量<input name="amount" type="number" [min]="account==='UserPoint'?0.01:1" [step]="account==='UserPoint'?0.01:1" [(ngModel)]="amount" required /></label>
 <label>付款或记账方式<select name="method" [(ngModel)]="method" required><option value="">请选择</option><option>微信</option><option>支付宝</option><option>转账</option><option>线下收款</option><option>运营记账</option></select></label>
-<label class="wide">操作原因<input name="reason" [(ngModel)]="reason" minlength="2" maxlength="500" required /></label>
-<button type="submit" [disabled]="saving()||(!store&&!value.storeId&&direction==='add')">{{saving()?'处理中…':store?'确认总部入账':direction==='add'?'确认门店划拨':'确认退回原门店'}}</button></form>}
+<label class="wide">充值 / 退回原因<input name="reason" [(ngModel)]="reason" minlength="2" maxlength="500" placeholder="填写本次课时业务原因" required /></label>
+<button class="primary" type="submit" [disabled]="saving()||(!store&&!value.storeId&&direction==='add')">{{saving()?'处理中…':store?'确认总部入账':direction==='add'?'确认门店划拨':'确认退回原门店'}}</button></fieldset></form>}
 <div class="scroll"><table><thead><tr><th>时间</th><th>业务</th><th>项目</th><th>数量</th><th>门店</th><th>学员</th><th>原因</th></tr></thead><tbody>@for(t of value.transactions;track t.objectId){<tr><td>{{t.createdAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{t.kind==='fund'?'总部入账':t.kind==='transfer'?'门店划拨':'退回门店'}}</td><td>{{label(t.account)}}</td><td>{{t.amount}}</td><td>{{t.storeId}}</td><td>{{t.studentId||'—'}}</td><td>{{t.reason}}</td></tr>}@empty{<tr><td colspan="7">暂无新业务流水;历史记录保留在课时流水中。</td></tr>}</tbody></table></div>
-}<button type="button" (click)="load()" [disabled]="saving()">刷新账户</button></section>`,styleUrls:['./member-enrollment.scss']})
+}</section>`,styleUrls:['./member-enrollment.scss']})
 export class MemberCreditAccountComponent implements OnChanges {
- @Input({required:true}) objectId='';@Input() store=false;@Output() changed=new EventEmitter<void>();
- private operations=inject(OperationsService);data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);
+ @Input({required:true}) objectId='';@Input() store=false;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();
+ private operations=inject(OperationsService);loading=signal(false);private serial=0;data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);
  account:TeachingCreditAccount='Purse';amount=0;direction='add';sourceId='';method='';reason='';private fingerprint='';private key='';
  ngOnChanges(){void this.load();}
- async load(){if(!this.objectId)return;try{this.data.set(await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId}));}catch(e){this.error.set(e instanceof Error?e.message:'账户加载失败');}}
+ async load(){if(!this.objectId)return;const serial=++this.serial;this.loading.set(true);this.error.set('');try{const result=await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId});if(serial===this.serial)this.data.set(result);}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'账户加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
  label(account:string){return this.data()?.accounts.find(a=>a.account===account)?.label||account;}
  refundable():CreditTransaction[]{return(this.data()?.transactions||[]).filter(t=>t.kind==='transfer'&&Number(t.unreturned)>0);}
  selectSource(id:string){const t=this.refundable().find(t=>t.objectId===id);if(t)this.account=t.account;}
- async submit(){if(this.saving())return;this.error.set('');this.message.set('');if(this.amount<=0||!this.method||this.reason.trim().length<2||(!this.store&&this.direction==='deduct'&&!this.sourceId)){this.error.set('请填写有效数量、方式、操作原因;退回必须选择原划拨单');return;}
+ async submit(){if(this.saving()||this.loading())return;this.error.set('');this.message.set('');if(!Number.isFinite(this.amount)||this.amount<=0||(this.account!=='UserPoint'&&!Number.isInteger(this.amount))||Math.abs(this.amount*100-Math.round(this.amount*100))>0.000001||!this.method||this.reason.trim().length<2||(!this.store&&this.direction==='deduct'&&!this.sourceId)){this.error.set('请填写有效数量、方式、操作原因;退回必须选择原划拨单');return;}
  const payload={account:this.account,amount:this.amount,direction:this.direction,sourceTransactionId:this.sourceId,payMethod:this.method},fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
  this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,'',this.key);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.message.set('课时业务已完成,余额和流水已更新');await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'课时业务失败');}finally{this.saving.set(false);}}
 }

+ 8 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts

@@ -34,4 +34,12 @@ describe('会员开课与课时操作', () => {
   const fixture=TestBed.createComponent(MemberCreditAccountComponent),c=fixture.componentInstance;c.objectId='student';c.direction='deduct';c.amount=1;c.method='运营记账';c.reason='退回余量';
   await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('原划拨单');
  });
+ it('分类加载失败后不能使用旧分类内容提交开课',async()=>{
+  const c=TestBed.createComponent(MemberEnrollmentComponent).componentInstance;c.studentId=20;await c.load();c.toggle(100);operations.request.and.rejectWith(new Error('加载失败'));c.changeCategory('word_self_study');await c.load();c.selected.set([100]);await c.submit();expect(c.options()).toBeNull();expect(operations.mutate).not.toHaveBeenCalled();
+ });
+ it('课时类型校验拒绝小数节数和非有限数量',async()=>{
+  const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='student';c.method='运营记账';c.reason='课时充值';
+  for(const amount of [0.5,NaN,Infinity]){c.amount=amount;await c.submit();}expect(operations.mutate).not.toHaveBeenCalled();
+ });
+
 });

+ 8 - 7
projects/xiaoshu-admin/src/app/components/member-enrollment.component.ts

@@ -4,21 +4,22 @@ 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:`
-<section class="panel"><h2>按分类开课</h2><p>先选择课程分类,再选择内容。同一内容可分别用于不同分类;开课不扣课时。</p>
+<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>}
-@if(options();as value){<div class="form-grid"><label>课程分类<select [ngModel]="category" (ngModelChange)="changeCategory($event)" [disabled]="saving()">@for(c of value.categories;track c.key){<option [value]="c.key">{{c.name}}</option>}</select></label><label>搜索课程<input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="课程名称或 ID" /></label><label>学阶<select [ngModel]="stage()" (ngModelChange)="stage.set($event)"><option value="">全部</option><option value="primary">小学</option><option value="middle">初中</option><option value="high">高中</option><option value="other">其他</option></select></label></div>
+@if(options();as value){<div class="form-grid"><label>课程分类<select [ngModel]="category" (ngModelChange)="changeCategory($event)" [disabled]="saving()">@for(c of value.categories;track c.key){<option [value]="c.key">{{c.name}}</option>}</select></label><label>搜索课程<input [disabled]="saving()" [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="课程名称或 ID" /></label><label>学阶<select [disabled]="saving() || !!categoryStage()" [ngModel]="stage()" (ngModelChange)="stage.set($event)"><option value="">全部</option><option value="primary">小学</option><option value="middle">初中</option><option value="high">高中</option><option value="other">其他</option></select></label></div>
 @if(value.reason){<p class="error">{{value.reason}}</p>}
 <div class="rules">@for(r of value.rules;track r.objectId){<span>{{r.durationMinutes}}分钟 · {{r.deliveryLabel}}:@for(c of r.creditCosts;track c.account){ {{c.label}} {{c.amount}} }({{r.affordable?'课时足够':'课时不足'}})</span>}</div>
 <div class="scroll"><table><thead><tr><th>选择</th><th>课程内容</th><th>学阶</th><th>单词数量</th><th>状态</th></tr></thead><tbody>@for(c of filtered();track c.courseId){<tr><td><input type="checkbox" [attr.aria-label]="'选择'+c.courseName" [checked]="selected().includes(c.courseId)" (change)="toggle(c.courseId)" [disabled]="saving()||loading()||!c.eligible||c.alreadyOpened" /></td><td>{{c.courseName}}<small>ID {{c.courseId}}</small></td><td>{{c.stageName}}</td><td>{{c.wordCount}}</td><td>{{c.alreadyOpened?'该分类已开课':c.reason||'可开课'}}</td></tr>}@empty{<tr><td colspan="5">该分类下暂无匹配内容,请检查课程目录和学阶。</td></tr>}</tbody></table></div>
-<label>操作原因<input [(ngModel)]="reason" maxlength="500" /></label><button type="button" (click)="submit()" [disabled]="saving()||loading()||!value.eligible||!selected().length">{{saving()?'正在开课…':'确认开课('+selected().length+'项)'}}</button>
+<div class="form-actions"><label>开课原因<input [(ngModel)]="reason" maxlength="500" [disabled]="saving()" /></label><button class="primary" type="button" (click)="submit()" [disabled]="saving()||loading()||!value.eligible||!selected().length">{{saving()?'正在开课…':'确认开课('+selected().length+'项)'}}</button></div>
 }</section>`,styleUrls:['./member-enrollment.scss']})
 export class MemberEnrollmentComponent implements OnChanges{
- @Input({required:true}) studentId=0;@Output() changed=new EventEmitter<void>();private operations=inject(OperationsService);
+ @Input({required:true}) studentId=0;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();private operations=inject(OperationsService);
  options=signal<EnrollmentOptions|null>(null);selected=signal<number[]>([]);search=signal('');stage=signal('');error=signal('');message=signal('');saving=signal(false);loading=signal(false);category='word';reason='为会员开课';private serial=0;private key='';private fingerprint='';
+ categoryStage(){return ({primary_writing:'primary',middle_writing:'middle',high_writing:'high'} as Record<string,string>)[this.category]||'';}
  filtered=computed(()=>(this.options()?.items||[]).filter(c=>(!this.stage()||c.stageKey===this.stage())&&`${c.courseName} ${c.courseId}`.toLowerCase().includes(this.search().trim().toLowerCase())));
- ngOnChanges(){void this.load();}changeCategory(value:string){this.category=value;this.selected.set([]);this.stage.set('');this.message.set('');void this.load();}
+ ngOnChanges(){void this.load();}changeCategory(value:string){this.category=value;this.options.set(null);this.selected.set([]);this.stage.set(this.categoryStage());this.message.set('');void this.load();}
  toggle(id:number){this.selected.update(ids=>ids.includes(id)?ids.filter(x=>x!==id):[...ids,id]);}
- async load(){if(!this.studentId)return;const serial=++this.serial;this.loading.set(true);try{const result=await this.operations.request<EnrollmentOptions>('ops/course-bindings/options',{studentId:this.studentId,categoryKey:this.category});if(serial===this.serial){this.options.set(result);this.error.set('');}}catch(e){if(serial===this.serial)this.error.set(e instanceof Error?e.message:'开课内容加载失败');}finally{if(serial===this.serial)this.loading.set(false);}}
- async submit(){if(this.saving()||this.loading()||!this.selected().length)return;if(this.reason.trim().length<2){this.error.set('请填写操作原因');return;}const payload={studentId:this.studentId,categoryKey:this.category,courseIds:[...this.selected()].sort((a,b)=>a-b)},f=JSON.stringify([payload,this.reason]);if(f!==this.fingerprint){this.fingerprint=f;this.key=crypto.randomUUID();}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/course-bindings/save','',this.reason,payload,'',this.key);this.selected.set([]);this.fingerprint='';this.message.set('开课成功,可前往排课');await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'开课失败');}finally{this.saving.set(false);}}
+ async load(){if(!this.studentId)return;const serial=++this.serial;this.loading.set(true);try{const result=await this.operations.request<EnrollmentOptions>('ops/course-bindings/options',{studentId:this.studentId,categoryKey:this.category});if(serial===this.serial){this.options.set(result);this.error.set('');}}catch(e){if(serial===this.serial){this.options.set(null);this.error.set(e instanceof Error?e.message:'开课内容加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
+ async submit(){if(this.saving()||this.loading()||!this.selected().length||!this.options()?.eligible||this.options()?.categoryKey!==this.category)return;if(this.reason.trim().length<2){this.error.set('请填写操作原因');return;}const payload={studentId:this.studentId,categoryKey:this.category,courseIds:[...this.selected()].sort((a,b)=>a-b)},f=JSON.stringify([payload,this.reason]);if(f!==this.fingerprint){this.fingerprint=f;this.key=crypto.randomUUID();}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/course-bindings/save','',this.reason,payload,'',this.key);this.selected.set([]);this.fingerprint='';this.message.set('开课成功,可前往排课');await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'开课失败');}finally{this.saving.set(false);}}
 }

+ 34 - 1
projects/xiaoshu-admin/src/app/components/member-enrollment.scss

@@ -1 +1,34 @@
-:host{display:block;margin:16px 0;color:#213547}.panel{background:white;border:1px solid #dfe7ee;border-radius:14px;padding:20px}h2{margin:0 0 12px;font-size:20px}p{line-height:1.7;color:#586b7c}.error{color:#b3261e}.accounts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:18px 0}article{background:#f3f7fa;border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:8px}article b{font-size:26px;color:#186895}article span,small{font-size:12px;color:#647587}form,.form-grid{display:flex;flex-wrap:wrap;gap:14px;align-items:end}label{display:flex;flex-direction:column;gap:7px;margin:8px 0;font-size:14px}input,select,button{font:inherit;padding:10px;border:1px solid #cad6e0;border-radius:7px;max-width:100%;box-sizing:border-box}button{cursor:pointer;background:#166a96;color:white;margin:10px 0}button:disabled{opacity:.5;cursor:default}.wide{flex:1;min-width:200px}.scroll{overflow:auto;max-height:420px;margin:15px 0}table{border-collapse:collapse;width:100%;font-size:14px}td,th{padding:12px;text-align:left;border-bottom:1px solid #e1e8ee;white-space:nowrap}th{background:#f3f7fa;position:sticky;top:0}small{display:block}.rules{display:flex;flex-wrap:wrap;gap:8px;color:#526879;font-size:12px;margin:14px 0}.rules span{padding:6px;background:#f1f5f8;border-radius:6px}@media(max-width:700px){.accounts{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:14px}form label,.form-grid label{width:100%}}
+@use 'member-controls' as member;
+:host { display:block; margin:16px 0; color:#263733; font-size:14px; line-height:1.5; }
+@include member.controls;
+.panel { background:#fff; border:1px solid #dfe7e4; border-radius:12px; padding:20px; }
+.enrollment-panel { background:#fbfdfc; }
+h3 { margin:0; font-size:18px; line-height:1.4; }
+p { margin:12px 0 18px; color:#63776d; font-size:14px; line-height:1.7; }
+.error { color:#a33f48; }
+p[role=status] { padding:10px 12px; background:#eaf6ef; border-radius:8px; color:#216b51; }
+.section-header { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:12px; }
+.accounts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; margin:20px 0; }
+article { background:#f2f8f5; border-radius:10px; padding:16px; display:flex; flex-direction:column; gap:8px; }
+article strong { font-size:14px; font-weight:600; }
+article b { font-size:26px; line-height:1.2; color:#17674f; font-variant-numeric:tabular-nums; }
+article span,small { color:#657c6e; font-size:12px; }
+fieldset,.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; align-items:end; }
+fieldset { min-width:0; margin:0; padding:18px 0; border:0; border-top:1px solid #e1ebe5; }
+.wide { grid-column:span 2; }
+.form-actions { display:flex; flex-wrap:wrap; gap:14px; align-items:end; padding-top:8px; }
+.form-actions label { flex:1 1 240px; }
+.scroll { overflow:auto; max-height:420px; margin:20px 0; border:1px solid #dfe8e2; border-radius:10px; }
+table { border-collapse:separate; border-spacing:0; width:100%; font-size:14px; }
+td,th { padding:13px 14px; text-align:left; border-bottom:1px solid #e6eee8; line-height:1.6; }
+th { background:#f2f7f4; position:sticky; top:0; color:#52695d; font-weight:600; white-space:nowrap; z-index:1; }
+td:first-child { white-space:nowrap; }
+tr:last-child td { border-bottom:0; }
+td[colspan] { padding:24px; text-align:center; color:#647a6d; }
+small { display:block; }
+input[type=checkbox] { width:18px; height:18px; accent-color:#19765c; cursor:pointer; }
+input[type=checkbox]:disabled { cursor:not-allowed; }
+.rules { display:flex; flex-wrap:wrap; gap:8px; color:#516b5e; font-size:12px; margin:18px 0; }
+.rules span { padding:8px 10px; background:#edf5f0; border:1px solid #dce9e1; border-radius:8px; }
+@media(max-width:900px) { .accounts{grid-template-columns:repeat(2,minmax(0,1fr))}fieldset{grid-template-columns:repeat(2,minmax(0,1fr))} }
+@media(max-width:650px) { .panel{padding:16px}.form-grid,fieldset{grid-template-columns:1fr}.wide{grid-column:auto}.accounts{gap:8px}article{padding:12px}.form-actions button{width:100%} }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
projects/xiaoshu-admin/src/app/components/operations-date-filter.component.ts


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

@@ -114,12 +114,12 @@
         @if(activeSearch()==='course'){
           <span class="search-results" 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.categoryName):'待处理课程分类'}}</small></span></button>
+              <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>}
           </span>
         }
       </label>
-      <label class="type-step">课程标准<select [ngModel]="durationMinutes()" (ngModelChange)="updateDuration(+$event)" [disabled]="!selectedCourse()?.mappingConfirmed">@for(duration of selectedCourse()?.supportedDurations||[30,60];track duration){<option [ngValue]="duration">{{selectedCourse()?.stageName||'数据库大类缺失'}} · {{selectedCourse()?.categoryName||'待映射'}} · {{duration}}分钟</option>}</select>@if(selectedCourse()&&!selectedCourse()?.mappingConfirmed){<small class="exception">该课程尚未完成分类映射,或数据库课程大类缺失,不能排课</small>}</label>
+      <label class="type-step">课程标准<select [ngModel]="durationMinutes()" (ngModelChange)="updateDuration(+$event)" [disabled]="!selectedCourse()?.mappingConfirmed">@for(duration of selectedCourse()?.supportedDurations||[30,60];track duration){<option [ngValue]="duration">{{selectedCourse()?.categoryName||'请选择已开课程'}} · {{duration}}分钟</option>}</select>@if(selectedCourse()&&!selectedCourse()?.mappingConfirmed){<small class="exception">该课程尚未完成分类映射,或数据库课程大类缺失,不能排课</small>}</label>
       <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
       <app-operations-date-filter class="date-step" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
       <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>

+ 16 - 15
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html

@@ -1,42 +1,43 @@
-<header class="page-header"><div><a routerLink="/admin/members"><lucide-icon [img]="icons.ArrowLeft" [size]="17" />返回用户管理</a><p>MEMBER PROFILE</p><h1>{{ user()?.displayName || '会员详情' }}</h1><span>围绕会员资料、课程、排课、上课和课时记录完成运营</span></div><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
+<header class="page-header"><div><a routerLink="/admin/members"><lucide-icon [img]="icons.ArrowLeft" [size]="17" />返回会员管理</a><p>MEMBER PROFILE</p><h1>{{ user()?.displayName || '会员详情' }}</h1><span>围绕会员资料、课程、排课、上课和课时记录完成运营</span></div><button type="button" (click)="refreshBusiness()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
 
 @if (loading() && !user()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在加载会员资料…</div> }
 @else if (error() && !user()) { <div class="state error" role="alert">{{ error() }}</div> }
 @else { @if (user(); as detail) {
-<nav class="quick-actions"><button type="button" (click)="selectTab('balances')">充值课时</button><button type="button" (click)="selectTab('courses')">按分类开课</button><a routerLink="/admin/schedule" [queryParams]="{studentId:detail.userId}">为该学员排课</a></nav>
+<nav class="quick-actions" aria-label="会员常用操作"><button type="button" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />{{ detail.permissions.canManage ? '充值课时' : '查看课时' }}</button><button type="button" (click)="selectTab('courses')"><lucide-icon [img]="icons.BookOpen" [size]="17" />{{ detail.permissions.canEdit ? '按分类开课' : '查看课程' }}</button><a class="action-link primary" routerLink="/admin/schedule" [queryParams]="{studentId:detail.userId}"><lucide-icon [img]="icons.CalendarDays" [size]="17" />为该学员排课</a></nav>
   <section class="profile-summary"><div class="avatar"><lucide-icon [img]="icons.UserRound" [size]="28" /></div><div><h2>{{ detail.displayName }}</h2><p>会员 ID {{ detail.userId || '—' }} · {{ detail.username }}</p></div><div class="profile-facts"><span>{{ detail.learningStatusLabel }}</span><span>{{ detail.parentDisplayName || '未绑定代理' }}</span><span>{{ detail.registeredAt | date:'yyyy-MM-dd' }} 注册</span></div><span class="status" [class.disabled]="detail.status === 'disabled'">{{ detail.statusLabel }}</span></section>
   @if (detail.writeReady === false) { <p class="notice" role="status">{{ detail.readOnlyReason }}</p> }
   @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
 
   <nav class="tabs" aria-label="会员详情分类">
-    <button type="button" [class.active]="tab()==='overview'" (click)="selectTab('overview')"><lucide-icon [img]="icons.UserRound" [size]="17" />概览</button>
-    <button type="button" [class.active]="tab()==='courses'" (click)="selectTab('courses')"><lucide-icon [img]="icons.BookOpen" [size]="17" />课程</button>
-    <button type="button" [class.active]="tab()==='appointments'" (click)="selectTab('appointments')"><lucide-icon [img]="icons.CalendarDays" [size]="17" />预约</button>
-    <button type="button" [class.active]="tab()==='lessons'" (click)="selectTab('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="17" />上课记录</button>
-    <button type="button" [class.active]="tab()==='reports'" (click)="selectTab('reports')"><lucide-icon [img]="icons.FileChartColumn" [size]="17" />学习报表</button>
-    <button type="button" [class.active]="tab()==='balances'" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />课时流水</button>
-    <button type="button" [class.active]="tab()==='audit'" (click)="selectTab('audit')"><lucide-icon [img]="icons.FileClock" [size]="17" />操作审计</button>
+    <button type="button" [class.active]="tab()==='overview'" [attr.aria-pressed]="tab()==='overview'" (click)="selectTab('overview')"><lucide-icon [img]="icons.UserRound" [size]="17" />概览</button>
+    <button type="button" [class.active]="tab()==='courses'" [attr.aria-pressed]="tab()==='courses'" (click)="selectTab('courses')"><lucide-icon [img]="icons.BookOpen" [size]="17" />课程</button>
+    <button type="button" [class.active]="tab()==='appointments'" [attr.aria-pressed]="tab()==='appointments'" (click)="selectTab('appointments')"><lucide-icon [img]="icons.CalendarDays" [size]="17" />预约</button>
+    <button type="button" [class.active]="tab()==='lessons'" [attr.aria-pressed]="tab()==='lessons'" (click)="selectTab('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="17" />上课记录</button>
+    <button type="button" [class.active]="tab()==='reports'" [attr.aria-pressed]="tab()==='reports'" (click)="selectTab('reports')"><lucide-icon [img]="icons.FileChartColumn" [size]="17" />学习报表</button>
+    <button type="button" [class.active]="tab()==='balances'" [attr.aria-pressed]="tab()==='balances'" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />课时流水</button>
+    <button type="button" [class.active]="tab()==='audit'" [attr.aria-pressed]="tab()==='audit'" (click)="selectTab('audit')"><lucide-icon [img]="icons.FileClock" [size]="17" />操作审计</button>
   </nav>
 
   @if (sectionLoading()) { <div class="section-loading"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="22" />正在读取业务记录…</div> }
+  @if ((tab()==='overview'||tab()==='audit') && (detail.permissions.canEdit||detail.permissions.canManage)) { <section class="reason-bar"><label><span>本次操作原因 <b aria-hidden="true">*</b></span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" minlength="2" maxlength="500" placeholder="保存资料、变更账号或添加备注前填写,至少 2 个字" /></label><small>操作原因随本次修改记入审计日志</small></section> }
   @switch (tab()) {
     @case ('overview') {
       <div class="detail-grid">
         <section class="panel profile-editor"><header><div><h2>基本资料</h2><span>只展示运营需要的业务字段</span></div>@if (detail.permissions.canEdit) { <button type="button" class="primary" (click)="saveProfile()" [disabled]="saving()"><lucide-icon [img]="icons.Save" [size]="16" />保存资料</button> }</header><div class="form-grid"><label><span>会员姓名</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editName()" (ngModelChange)="editName.set($event)" /></label><label><span>昵称</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editNickname()" (ngModelChange)="editNickname.set($event)" /></label><label><span>手机号</span><div class="mobile-control"><input [disabled]="!detail.permissions.canEdit || !revealMobile()" [ngModel]="revealMobile() ? editMobile() : detail.mobileMasked" (ngModelChange)="editMobile.set($event)" />@if (!revealMobile() && detail.permissions.canRevealMobile) { <button type="button" (click)="reveal()"><lucide-icon [img]="icons.Eye" [size]="15" />查看</button> }</div></label><label><span>邮箱</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editEmail()" (ngModelChange)="editEmail.set($event)" /></label><label><span>性别</span><select [disabled]="!detail.permissions.canEdit" [ngModel]="editGender()" (ngModelChange)="editGender.set($event)"><option value="">未填写</option><option value="男">男</option><option value="女">女</option></select></label><label><span>年级</span><input [disabled]="!detail.permissions.canEdit" [ngModel]="editGrade()" (ngModelChange)="editGrade.set($event)" /></label></div><dl class="account-facts"><div><dt>注册时间</dt><dd>{{ detail.registeredAt | date:'yyyy-MM-dd HH:mm' }}</dd></div><div><dt>最后登录</dt><dd>{{ detail.lastLoginAt ? (detail.lastLoginAt | date:'yyyy-MM-dd HH:mm') : '—' }}</dd></div><div><dt>登录次数</dt><dd>{{ detail.loginCount }}</dd></div></dl></section>
         <section class="panel"><header><div><h2>学习与课时</h2><span>{{ detail.learningStatusReason }}</span></div><span class="learning-status" [attr.data-status]="detail.learningStatus">{{ detail.learningStatusLabel }}</span></header><div class="metric-grid"><article><strong>{{ detail.periods30 }}</strong><span>30 分钟课时</span></article><article><strong>{{ detail.periods60 }}</strong><span>60 分钟课时</span></article><article><strong>{{ detail.trialPeriods }}</strong><span>体验课时</span></article><article><strong>{{ detail.balances.userPoint }}</strong><span>陪练时长</span></article><article><strong>{{ detail.activeCourseCount }}</strong><span>有效课程</span></article><article><strong>{{ detail.studyRecordCount }}</strong><span>学习记录</span></article></div><div class="risk-list">@for (risk of detail.riskFlags || []; track risk) { <span>{{ risk }}</span> } @empty { <small>当前没有运营风险提示</small> }</div></section>
-        <section class="panel"><header><div><h2>所属门店/代理</h2><span>通过名称搜索选择,不填写数字 ID</span></div></header><label><span>搜索门店或代理</span><div class="search-control"><input [disabled]="!detail.permissions.canEdit" [ngModel]="agentSearch()" (ngModelChange)="agentSearch.set($event);parentUserId.set(0)" placeholder="输入名称或代理 ID" /></div></label>@if (detail.permissions.canEdit) { <div class="option-list"><button type="button" [class.selected]="!parentUserId()" (click)="clearAgent()">暂不绑定</button>@for (agent of filteredAgents(); track agent.userId) { <button type="button" [class.selected]="parentUserId()===agent.userId" (click)="selectAgent(agent)"><strong>{{ agent.displayName }}</strong><span>ID {{ agent.userId }}</span></button> }</div> }</section>
+        <section class="panel"><header><div><h2>所属门店/代理</h2><span>选择后点击“保存资料”生效</span></div></header><label><span>搜索门店或代理</span><div class="search-control"><input [disabled]="!detail.permissions.canEdit" [ngModel]="agentSearch()" (ngModelChange)="agentSearch.set($event)" placeholder="输入门店或代理名称" /></div></label>@if (detail.permissions.canEdit) { <div class="option-list"><button type="button" [class.selected]="!parentUserId()" (click)="clearAgent()">暂不绑定</button>@for (agent of filteredAgents(); track agent.userId) { <button type="button" [class.selected]="parentUserId()===agent.userId" (click)="selectAgent(agent)"><strong>{{ agent.displayName }}</strong><span>{{ parentUserId()===agent.userId ? '已选择' : '选择门店' }}</span></button> }</div> }</section>
         <section class="panel risk-panel"><header><div><h2>账号安全</h2><span>高风险操作仅运营经理可执行</span></div></header>@if (detail.permissions.canManage) { <div class="security-actions"><button type="button" class="danger" (click)="changeStatus()" [disabled]="saving()"><lucide-icon [img]="icons.ShieldAlert" [size]="17" />{{ detail.status === 'disabled' ? '恢复账号' : '停用账号' }}</button>@if (detail.permissions.canResetPassword) { <label><span>新临时密码</span><input type="password" autocomplete="new-password" [ngModel]="resetPassword()" (ngModelChange)="resetPassword.set($event)" placeholder="至少 8 位" /></label><button type="button" (click)="resetMemberPassword()" [disabled]="saving()">重置密码并退出旧会话</button> }</div> } @else { <p class="muted">当前账号没有高风险操作权限。</p> }</section>
       </div>
     }
     @case ('courses') {
-      <section class="panel"><header><div><h2>已开课程</h2><span>查看学习进度,取消绑定进入回收站并可恢复</span></div></header>@if (detail.permissions.canEdit) { <app-member-enrollment [studentId]="detail.userId" (changed)="refreshBusiness()" /> }<div class="record-table"><table><thead><tr><th>分类与课程</th><th>已学/内容数量</th><th>进度</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().courses; track row['targetId']) { <tr><td>{{ row['categoryName'] || '待确认分类' }} · {{ row['courseName'] || '未命名课程' }}</td><td>{{ row['learnedCount'] || 0 }} / {{ row['openedCount'] || 0 }}</td><td>{{ row['progressStage'] || '未记录' }}</td><td>{{ row['enrollmentStatus'] === 'pending' ? '分类待确认' : row['statusLabel'] }}</td><td>@if (detail.permissions.canManage) { <button type="button" (click)="toggleCourse(row)">{{ $any(row['status']) === -2 ? '恢复' : '移入回收站' }}</button> }</td></tr> } @empty { <tr><td colspan="5">暂无课程绑定</td></tr> }</tbody></table></div></section>
+      <section class="panel"><header><div><h2>已开课程</h2><span>查看学习进度,取消绑定进入回收站并可恢复</span></div></header>@if (detail.permissions.canEdit) { <app-member-enrollment [studentId]="detail.userId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /> }@if(detail.permissions.canManage){<label class="course-reason"><span>课程回收 / 恢复原因</span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="操作下方已开课程前填写,至少 2 个字" maxlength="500" /></label>}<div class="record-table"><table><thead><tr><th>分类与课程</th><th>已学/内容数量</th><th>进度</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().courses; track row['targetId']) { <tr><td>{{ row['categoryName'] || '待确认分类' }} · {{ row['courseName'] || '未命名课程' }}</td><td>{{ row['learnedCount'] || 0 }} / {{ row['openedCount'] || 0 }}</td><td>{{ row['progressStage'] || '未记录' }}</td><td>{{ row['enrollmentStatus'] === 'pending' ? '分类待确认' : row['statusLabel'] }}</td><td>@if (detail.permissions.canManage) { <button type="button" (click)="toggleCourse(row)" [disabled]="saving() || sectionLoading()">{{ $any(row['status']) === -2 ? '恢复' : '移入回收站' }}</button> }</td></tr> } @empty { <tr><td colspan="5">暂无课程绑定</td></tr> }</tbody></table></div></section>
     }
     @case ('appointments') { <section class="panel"><header><div><h2>预约记录</h2><span>查看课程、陪练、预约时间和业务状态</span></div></header><div class="record-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>预约时间</th><th>课型</th><th>状态</th></tr></thead><tbody>@for (row of related().appointments; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] || '待分配' }}</td><td>{{ $any(row['appointmentAt'] || row['startsAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['statusLabel'] }}</td></tr> } @empty { <tr><td colspan="5">暂无预约记录</td></tr> }</tbody></table></div></section> }
-    @case ('lessons') { <section class="panel"><header><div><h2>上课记录</h2><span>仅展示真实上课事实、老师和评价</span></div></header><div class="record-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>上课时间</th><th>课型</th><th>评分</th><th>评价</th></tr></thead><tbody>@for (row of related().lessons; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ $any(row['lessonAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['score'] || '—' }}</td><td>{{ row['comment'] || '—' }}</td></tr> } @empty { <tr><td colspan="6">暂无上课记录</td></tr> }</tbody></table></div></section> }
-    @case ('reports') { <section class="panel"><header><div><h2>单词学习报表</h2><span>查看该会员每次学习的掌握情况和复习计划</span></div><a class="primary detail-link" routerLink="/admin/learning-reports" [queryParams]="{ studentObjectId: detail.objectId }">进入报表中心</a></header><div class="record-table"><table><thead><tr><th>学习日期</th><th>课程</th><th>陪练老师</th><th>学习结果</th><th>掌握率</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().reports; track row['targetId']) { <tr><td>{{ row['studyDate'] || '日期缺失' }}</td><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ row['learnedCount'] }} 个 · 掌握 {{ row['masteredCount'] }} · 待复习 {{ row['reviewDueCount'] }}</td><td>{{ row['masteryRate'] }}%</td><td>{{ row['statusLabel'] }}</td><td><a [routerLink]="['/admin/learning-reports']" [queryParams]="{ studentObjectId: detail.objectId, report: row['targetId'] }">查看报表</a></td></tr> } @empty { <tr><td colspan="7">暂无学习报表</td></tr> }</tbody></table></div></section> }
-    @case ('balances') { <section class="panel"><header><div><h2>课时与流水</h2><span>门店划拨与退回原门店均记录操作原因</span></div></header><div class="metric-grid balances"><article><strong>{{ detail.periods30 }}</strong><span>30 分钟</span></article><article><strong>{{ detail.periods60 }}</strong><span>60 分钟</span></article><article><strong>{{ detail.trialPeriods }}</strong><span>体验课</span></article><article><strong>{{ detail.balances.userPoint }}</strong><span>陪练时长</span></article></div><app-member-credit-account [objectId]="detail.objectId" (changed)="refreshBusiness()" /><div class="record-table"><table><thead><tr><th>项目</th><th>变化</th><th>调整前</th><th>余额</th><th>时间</th><th>说明</th></tr></thead><tbody>@for (row of related().balances; track row['targetId']) { <tr><td>{{ row['moneyTypeLabel'] }}</td><td>{{ row['score'] }}</td><td>{{ row['scoreBefore'] }}</td><td>{{ row['balanceAfter'] }}</td><td>{{ $any(row['happenedAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['detail'] }}</td></tr> } @empty { <tr><td colspan="6">暂无课时流水</td></tr> }</tbody></table></div></section> }
+    @case ('lessons') { <section class="panel"><header><div><h2>上课记录</h2><span>仅展示真实上课事实、老师和评价</span></div></header><div class="record-table lessons-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>上课时间</th><th>课型</th><th>评分</th><th>评价</th></tr></thead><tbody>@for (row of related().lessons; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ $any(row['lessonAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['score'] || '—' }}</td><td>{{ row['comment'] || '—' }}</td></tr> } @empty { <tr><td colspan="6">暂无上课记录</td></tr> }</tbody></table></div></section> }
+    @case ('reports') { <section class="panel"><header><div><h2>单词学习报表</h2><span>查看该会员每次学习的掌握情况和复习计划</span></div><a class="primary action-link" routerLink="/admin/learning-reports" [queryParams]="{ studentObjectId: detail.objectId }">进入报表中心</a></header><div class="date-filters"><app-operations-date-filter label="开始日期" [value]="reportFrom()" (valueChange)="reportFrom.set($event)" /><app-operations-date-filter label="结束日期" [value]="reportTo()" (valueChange)="reportTo.set($event)" /><button type="button" class="primary" (click)="filterReports()" [disabled]="sectionLoading()">查询</button><button type="button" (click)="filterReports(true)" [disabled]="sectionLoading()">重置</button></div><div class="record-table"><table><thead><tr><th>学习日期</th><th>课程</th><th>陪练老师</th><th>学习结果</th><th>掌握率</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().reports; track row['targetId']) { <tr><td>{{ row['studyDate'] || '日期缺失' }}</td><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ row['learnedCount'] }} 个 · 掌握 {{ row['masteredCount'] }} · 待复习 {{ row['reviewDueCount'] }}</td><td>{{ row['masteryRate'] }}%</td><td>{{ row['statusLabel'] }}</td><td><a class="action-link compact" [routerLink]="['/admin/learning-reports']" [queryParams]="{ studentObjectId: detail.objectId, report: row['targetId'] }">查看报表</a></td></tr> } @empty { <tr><td colspan="7">暂无学习报表</td></tr> }</tbody></table></div></section> }
+    @case ('balances') { <section class="panel"><header><div><h2>课时与流水</h2><span>门店划拨与退回原门店均记录操作原因</span></div></header><app-member-credit-account [objectId]="detail.objectId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /><h3 class="record-heading">历史课时明细</h3><div class="record-table"><table><thead><tr><th>项目</th><th>变化</th><th>调整前</th><th>余额</th><th>时间</th><th>说明</th></tr></thead><tbody>@for (row of related().balances; track row['targetId']) { <tr><td>{{ row['moneyTypeLabel'] }}</td><td>{{ row['score'] }}</td><td>{{ row['scoreBefore'] }}</td><td>{{ row['balanceAfter'] }}</td><td>{{ $any(row['happenedAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['detail'] }}</td></tr> } @empty { <tr><td colspan="6">暂无课时流水</td></tr> }</tbody></table></div></section> }
     @case ('audit') { <div class="audit-grid"><section class="panel"><header><div><h2>运营跟进备注</h2><span>记录后续联系和服务事项</span></div></header>@if (detail.permissions.canEdit) { <label><span>新增备注</span><textarea [ngModel]="noteContent()" (ngModelChange)="noteContent.set($event)" placeholder="填写需要跟进的事项或沟通结果"></textarea></label><button class="primary" type="button" (click)="saveNote()" [disabled]="saving()"><lucide-icon [img]="icons.MessageSquarePlus" [size]="16" />保存备注</button> }<div class="timeline">@for (note of notes(); track note['objectId']) { <article><strong>{{ note['operatorName'] || '运营人员' }}</strong><time>{{ $any(note['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ note['content'] }}</p></article> } @empty { <p class="muted">暂无跟进备注</p> }</div></section><section class="panel"><header><div><h2>操作审计</h2><span>账号、资料、关系、课程和课时修改记录</span></div></header><div class="timeline audit">@for (item of related().audit; track item['objectId']) { <article><strong>{{ item['operatorName'] || '系统' }}</strong><time>{{ $any(item['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ item['summary'] || '会员业务操作' }}</p></article> } @empty { <p class="muted">暂无操作审计记录</p> }</div></section></div> }
   }
 
-  @if (detail.permissions.canEdit || detail.permissions.canManage) { <section class="reason-bar"><label><span>本次操作原因 *</span><input [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="保存资料、绑定课程、调整课时等操作前必须填写" /></label><small>操作原因会与修改前后数据一起写入审计日志</small></section> }
+  @if(tab()!=='overview'&&tab()!=='audit') {<nav class="pagination" aria-label="会员业务记录分页"><span>共 {{ recordTotal() }} 条 · 每页 {{ pageSize }} 条</span><button type="button" (click)="changePage(recordPage()-1)" [disabled]="sectionLoading()||recordPage()<=1">上一页</button><strong>{{ recordPage() }} / {{ pageCount() }}</strong><button type="button" (click)="changePage(recordPage()+1)" [disabled]="sectionLoading()||recordPage()>=pageCount()">下一页</button></nav>}
 } }

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


+ 38 - 0
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.spec.ts

@@ -0,0 +1,38 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { OperationsUserDetailComponent } from './operations-user-detail.component';
+import { OperationsService } from '../operations.service';
+import { OperationsUserDetail } from '../admin.models';
+
+describe('会员详情业务交互', () => {
+  let operations: jasmine.SpyObj<OperationsService>;
+  const detail={objectId:'member-1',userId:1845,displayName:'测试会员',nickname:'测试会员',mobileMasked:'138****0000',parentUserId:73,parentDisplayName:'测试门店',agents:[{userId:73,displayName:'测试门店'}],permissions:{canEdit:true,canManage:true},balances:{userPoint:1},status:'active'} as unknown as OperationsUserDetail;
+  beforeEach(()=>{
+    operations=jasmine.createSpyObj('OperationsService',['userDetail','request','mutate','memberRows','learningReports']);
+    operations.userDetail.and.resolveTo(detail);operations.request.and.resolveTo({items:[],total:41});operations.learningReports.and.resolveTo({items:[],total:0} as never);
+    TestBed.configureTestingModule({imports:[OperationsUserDetailComponent],providers:[provideRouter([]),{provide:ActivatedRoute,useValue:{snapshot:{paramMap:convertToParamMap({objectId:'member-1'}),queryParamMap:convertToParamMap({})}}},{provide:OperationsService,useValue:operations}]});
+  });
+  it('搜索门店不会清空已保存的门店归属,显式取消才解绑', async()=>{
+    const fixture=TestBed.createComponent(OperationsUserDetailComponent),c=fixture.componentInstance;fixture.detectChanges();await fixture.whenStable();fixture.detectChanges();
+    const input=fixture.nativeElement.querySelector('input[placeholder="输入门店或代理名称"]') as HTMLInputElement;input.value='另一家店';input.dispatchEvent(new Event('input'));fixture.detectChanges();
+    expect(c.parentUserId()).toBe(73);expect(operations.mutate).not.toHaveBeenCalled();c.clearAgent();expect(c.parentUserId()).toBe(0);
+  });
+  it('翻页保留精确学员身份,支持超过一页的历史记录',async()=>{
+    const c=TestBed.createComponent(OperationsUserDetailComponent).componentInstance;await c.load();await c.selectTab('appointments');await c.changePage(2);
+    expect(c.pageCount()).toBe(3);expect(operations.request.calls.mostRecent().args).toEqual(['ops/appointments/list',{page:2,pageSize:20,studentId:1845}]);
+  });
+  it('刷新同时更新详情、当前记录和嵌入账户组件',async()=>{
+    const c=TestBed.createComponent(OperationsUserDetailComponent).componentInstance;await c.load();await c.selectTab('balances');operations.request.calls.reset();await c.refreshBusiness();
+    expect(c.refreshVersion()).toBe(1);expect(operations.request).toHaveBeenCalledWith('ops/money-logs/list',{page:1,pageSize:20,studentId:1845});
+  });
+  it('快速切换页签时旧响应不能结束新页签加载或覆盖记录',async()=>{
+    const c=TestBed.createComponent(OperationsUserDetailComponent).componentInstance;await c.load();let first!:(v:unknown)=>void,second!:(v:unknown)=>void;
+    operations.request.and.returnValues(new Promise(resolve=>first=resolve),new Promise(resolve=>second=resolve));
+    const a=c.selectTab('courses'),b=c.selectTab('appointments');first({items:[{targetId:'old'}],total:1});await a;expect(c.sectionLoading()).toBeTrue();expect(c.related().courses).toEqual([]);
+    second({items:[{targetId:'current'}],total:1});await b;expect(c.related().appointments[0]['targetId']).toBe('current');expect(c.sectionLoading()).toBeFalse();
+  });
+  it('学习日期筛选保留学员范围,并阻止倒置日期',async()=>{
+    const c=TestBed.createComponent(OperationsUserDetailComponent).componentInstance;await c.load();await c.selectTab('reports');operations.learningReports.calls.reset();c.reportFrom.set('2026-09-10');c.reportTo.set('2026-09-01');await c.filterReports();expect(operations.learningReports).not.toHaveBeenCalled();
+    c.reportTo.set('2026-09-20');await c.filterReports();expect(operations.learningReports).toHaveBeenCalledWith(1,20,'',{studentObjectId:'member-1',dateFrom:'2026-09-10',dateTo:'2026-09-20'},false);
+  });
+});

+ 25 - 11
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -1,3 +1,4 @@
+import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { MemberCreditAccountComponent } from '../components/member-credit-account.component';
 import { MemberEnrollmentComponent } from '../components/member-enrollment.component';
 import { CommonModule } from '@angular/common';
@@ -12,7 +13,7 @@ type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'reports'
 
 @Component({
   selector: 'app-operations-user-detail', standalone: true,
-  imports: [MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [OperationsDateFilterComponent, MemberCreditAccountComponent, MemberEnrollmentComponent, CommonModule, FormsModule, RouterLink, LucideAngularModule],
   templateUrl: './operations-user-detail.component.html', styleUrl: './operations-user-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
@@ -20,32 +21,45 @@ export class OperationsUserDetailComponent implements OnInit {
   readonly user = signal<OperationsUserDetail | null>(null); readonly tab = signal<MemberTab>('overview'); readonly related = signal<Record<MemberTab, Record<string, unknown>[]>>({ overview: [], courses: [], appointments: [], lessons: [], reports: [], balances: [], audit: [] });
   readonly notes = signal<Record<string, unknown>[]>([]); readonly loading = signal(true); readonly sectionLoading = signal(false); readonly saving = signal(false); readonly error = signal(''); readonly message = signal(''); readonly reason = signal('');
   readonly editName = signal(''); readonly editNickname = signal(''); readonly editMobile = signal(''); readonly editEmail = signal(''); readonly editGender = signal(''); readonly editGrade = signal(''); readonly parentUserId = signal(0); readonly agentSearch = signal('');
+  readonly refreshVersion = signal(0); readonly recordPage = signal(1); readonly recordTotal = signal(0); readonly pageSize = 20; readonly reportFrom = signal(''); readonly reportTo = signal(''); private sectionSerial = 0;
+  readonly pageCount = computed(() => Math.max(1, Math.ceil(this.recordTotal() / this.pageSize)));
   readonly resetPassword = signal(''); readonly noteContent = signal(''); readonly revealMobile = signal(false);
   readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
   private readonly route = inject(ActivatedRoute); private readonly operations = inject(OperationsService); private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
   readonly filteredAgents = computed(() => { const keyword = this.agentSearch().trim().toLowerCase(); return (this.user()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
 
   ngOnInit(): void { void this.load().then(() => { const tab=this.route.snapshot.queryParamMap.get('tab') as MemberTab; if(['courses','balances'].includes(tab))void this.selectTab(tab); }); }
-  async refreshBusiness(): Promise<void>{await this.load();await this.loadSection();}
+  async refreshBusiness(): Promise<void>{await this.load();this.refreshVersion.update(v=>v+1);await this.loadSection();}
   async load(): Promise<void> {
     this.loading.set(true); this.error.set('');
     try { const user = await this.operations.userDetail(this.objectId); this.user.set(user); this.editName.set(user.displayName); this.editNickname.set(user.nickname || ''); this.editMobile.set(user.mobile || user.mobileMasked); this.editEmail.set(user.email || ''); this.editGender.set(user.gender || ''); this.editGrade.set(user.grade || ''); this.parentUserId.set(user.parentUserId); this.agentSearch.set(user.parentDisplayName || ''); this.revealMobile.set(Boolean(user.mobile)); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '会员详情加载失败'); }
     finally { this.loading.set(false); }
   }
-  async selectTab(tab: MemberTab): Promise<void> { this.tab.set(tab); if (tab !== 'overview') await this.loadSection(tab); }
+  async selectTab(tab: MemberTab): Promise<void> { ++this.sectionSerial; this.tab.set(tab); this.recordPage.set(1); this.recordTotal.set(0); this.sectionLoading.set(false); this.error.set(''); if (tab !== 'overview') await this.loadSection(tab); }
+  async changePage(page: number): Promise<void> { if(this.sectionLoading() || page<1 || page>this.pageCount()) return; this.recordPage.set(page); await this.loadSection(); }
+  async filterReports(clear=false): Promise<void> { if(clear){this.reportFrom.set('');this.reportTo.set('');} if(this.reportFrom()&&this.reportTo()&&this.reportFrom()>this.reportTo()){this.error.set('开始日期不能晚于结束日期');return;}this.recordPage.set(1);await this.loadSection('reports'); }
   private kindFor(tab: MemberTab): OperationsListKind | null { return ({ courses: 'course-bindings', appointments: 'appointments', lessons: 'lessons', balances: 'money-logs' } as Partial<Record<MemberTab, OperationsListKind>>)[tab] || null; }
   async loadSection(tab = this.tab()): Promise<void> {
-    const user = this.user(); if (!user || tab === 'overview') return; this.sectionLoading.set(true); this.error.set('');
+    const user = this.user(); if (!user || tab === 'overview') return;
+    const serial=++this.sectionSerial; this.sectionLoading.set(true); this.error.set('');
     try {
-      if (tab === 'audit') { const [notes, audit] = await Promise.all([this.operations.memberRows('ops/users/notes', user.objectId), this.operations.memberRows('ops/users/audit', user.objectId)]); this.notes.set(notes.items); this.related.update(value => ({ ...value, audit: audit.items })); }
-      else if (tab === 'reports') { const page = await this.operations.learningReports(1, 100, '', { studentObjectId: user.objectId }, false); this.related.update(value => ({ ...value, reports: page.items })); }
-      else { const kind = this.kindFor(tab); if (kind) { const page = await this.operations.request<{items:Record<string,unknown>[]}>(`ops/${kind}/list`, {page:1,pageSize:100,studentId:user.userId}); this.related.update(value => ({ ...value, [tab]: page.items })); } }
-    } catch (error) { this.error.set(error instanceof Error ? error.message : '业务记录加载失败'); }
-    finally { this.sectionLoading.set(false); }
+      let items: Record<string,unknown>[] = [], total=0;
+      if (tab === 'audit') {
+        const [notes, audit] = await Promise.all([this.operations.memberRows('ops/users/notes', user.objectId), this.operations.memberRows('ops/users/audit', user.objectId)]);
+        if(serial!==this.sectionSerial)return; this.notes.set(notes.items); items=audit.items; total=items.length;
+      } else if (tab === 'reports') {
+        const page=await this.operations.learningReports(this.recordPage(), this.pageSize, '', {studentObjectId:user.objectId,dateFrom:this.reportFrom(),dateTo:this.reportTo()}, false);
+        items=page.items; total=page.total;
+      } else {
+        const kind=this.kindFor(tab); if(kind){const page=await this.operations.request<{items:Record<string,unknown>[];total:number}>(`ops/${kind}/list`, {page:this.recordPage(),pageSize:this.pageSize,studentId:user.userId});items=page.items;total=page.total;}
+      }
+      if(serial===this.sectionSerial){this.related.update(value=>({...value,[tab]:items}));this.recordTotal.set(total);}
+    } catch (error) { if(serial===this.sectionSerial)this.error.set(error instanceof Error ? error.message : '业务记录加载失败'); }
+    finally { if(serial===this.sectionSerial)this.sectionLoading.set(false); }
   }
   async run(operation: string, payload: Record<string, unknown>, success: string, targetId = this.user()?.objectId || '', expectedUpdatedAt = this.user()?.updatedAt || ''): Promise<boolean> {
-    if (!this.reason().trim()) { this.error.set('请输入操作原因'); return false; } this.saving.set(true); this.error.set(''); this.message.set('');
+    if(this.saving())return false; if (this.reason().trim().length < 2) { this.error.set('请输入至少 2 个字的操作原因'); return false; } this.saving.set(true); this.error.set(''); this.message.set('');
     try { await this.operations.mutate(operation, targetId, this.reason(), payload, expectedUpdatedAt); this.message.set(success); this.reason.set(''); return true; }
     catch (error) { this.error.set(error instanceof Error ? error.message : '操作失败'); return false; }
     finally { this.saving.set(false); }
@@ -56,6 +70,6 @@ export class OperationsUserDetailComponent implements OnInit {
   async changeStatus(): Promise<void> { const user = this.user(); if (user && await this.run('ops/users/change-status', { disabled: user.status !== 'disabled' }, user.status === 'disabled' ? '账号已恢复' : '账号已停用')) await this.load(); }
   async resetMemberPassword(): Promise<void> { if (this.resetPassword().length < 8) { this.error.set('临时密码至少 8 位'); return; } if (await this.run('ops/users/reset-password', { password: this.resetPassword() }, '临时密码已更新,旧会话已退出')) { this.resetPassword.set(''); await this.load(); } }
   async saveNote(): Promise<void> { if (!this.noteContent().trim()) { this.error.set('请输入跟进备注'); return; } if (await this.run('ops/users/save-note', { content: this.noteContent() }, '跟进备注已保存')) { this.noteContent.set(''); await this.loadSection('audit'); } }
-  async toggleCourse(row: Record<string, unknown>): Promise<void> { const recycled = Number(row['status']) === -2; if (await this.run(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['targetId'] || ''), String(row['updatedAt'] || ''))) await this.loadSection('courses'); }
+  async toggleCourse(row: Record<string, unknown>): Promise<void> { const recycled = Number(row['status']) === -2; if (await this.run(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['targetId'] || ''), String(row['updatedAt'] || ''))) await this.refreshBusiness(); }
   async reveal(): Promise<void> { const user = this.user(); if (!user) return; this.error.set(''); try { const result = await this.operations.mutate<{ mobile: string }>('ops/users/reveal-mobile', user.objectId, '运营核对会员联系方式'); this.user.set({ ...user, mobile: result.mobile }); this.editMobile.set(result.mobile); this.revealMobile.set(true); } catch (error) { this.error.set(error instanceof Error ? error.message : '手机号查看失败'); } }
 }

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
scripts/deploy-admin-functions.mjs


+ 15 - 0
scripts/tests/member-enrollment-gateway.test.mjs

@@ -16,3 +16,18 @@ test('批量排课跨规则日期分别保存快照',async()=>{const c=setup();c
 test('新后台列表按学员精确查询,筛选发生在全站记录上限之前',async()=>{const c=setup();const calls=[];c.Psql={query:async(sql,args)=>{calls.push([sql,args]);return[];}};for(const kind of ['course-bindings','appointments','lessons','learning-records'])await c.businessRows(kind,{}, {studentId:20});await c.moneyRows({}, {studentId:20});for(const [sql,args] of calls){assert.equal(args[1],'20');assert.ok(sql.indexOf('$2')<sql.indexOf('LIMIT 5000'));}});
 test('旧同步不能覆盖账号、开课和预约,跨分类进度查询不得随机选择第一条',async()=>{const c=setup();await assert.rejects(c.applyUserSync({}, {},{}),/禁止旧数据覆盖/);for(const kind of ['users','course-bindings','appointments','lessons'])await assert.rejects(c.applyProjectionRecord({},'upsert',{}, {},kind),/禁止旧数据覆盖/);});
 test('用户端通用内容接口不能绕过开课、预留及完课事务',async()=>{const c={};vm.createContext(c);vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c);for(const modelId of [54,58,59])await assert.rejects(c.createContentPair({content:JSON.stringify({modelId}),addon:'{}'},{}),/正式完课流程/);});
+
+test('会员预约详情沿用开课分类并识别回收站取消状态',async()=>{const c=setup();c.Psql={query:async()=>[{targetId:'a',generalId:1,commonStatus:-2,szyh:'20',pl:'30',dslx:'2',dszt:'0',yysj:'2026-09-09 10:00',courseCategoryKey:'word_self_study',courseCategoryName:'单词自学课',bindingId:'b',durationMinutes:60}]};const page=await c.businessRows('appointments',{}, {studentId:20,page:1,pageSize:20});assert.equal(page.items[0].bindingId,'b');assert.equal(page.items[0].classTypeLabel,'单词自学课·60分钟');assert.equal(page.items[0].statusLabel,'已取消');});
+test('会员上课记录使用教师姓名和开课分类,不将编号当姓名',async()=>{const c=setup();c.Psql={query:async()=>[{objectId:'l',xymz:'20',jsmz:'30',coachRealName:'测试老师',kclx:'2',courseCategoryName:'单词自学课',bindingId:'b'}]};const page=await c.businessRows('lessons',{}, {studentId:20});assert.equal(page.items[0].coachName,'测试老师');assert.equal(page.items[0].classTypeLabel,'单词自学课');});
+
+test('会员概览未结束数、体验记录与下次预约排除已取消,保留总历史数',async()=>{
+ const {PGlite}=await import('@electric-sql/pglite');const db=new PGlite();
+ try{
+  await db.exec(`CREATE TABLE "CourseAppointment"("company" text,"id" int,"szyh" text,"dszt" text,"dslx" text,"yysj" text,"bxrq" text);
+   CREATE TABLE "CommonModel"("company" text,"modelId" int,"itemId" int,"status" int);
+   INSERT INTO "CourseAppointment" VALUES ('co',1,'20','0','3','2099-01-01',null),('co',2,'20','0','2','2099-02-01',null),('co',3,'20','30','2','2026-01-01',null),('other',4,'20','0','3','2099-01-01',null);
+   INSERT INTO "CommonModel" VALUES ('co',54,1,-2),('other',54,2,-2);`);
+  const c=setup();c.Psql={query:async(sql,args)=>(await db.query(sql,args)).rows};const [row]=await c.memberAppointmentStats('co');
+  assert.equal(row.appointment_count,3);assert.equal(row.unfinished,1);assert.equal(row.trial_count,0);assert.equal(row.next_at,'2099-02-01');assert.equal(row.abnormal,0);
+ }finally{await db.close();}
+});

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