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

feat: simplify enrollment selection and show newest courses first

彭峰 8 часов назад
Родитель
Сommit
00806bb7ac

+ 19 - 0
docs/qa/member-enrollment-picker-2026-10-15.md

@@ -0,0 +1,19 @@
+# 会员开课选课体验(2026-10-15)
+
+- 课程目录改为新建时间倒序,同时间以课程编号倒序稳定排序;新系统 `cloud:` 课程使用创建时间,不使用修改时间。
+- 历史数据同时存在 `legacy:node:*`、`legacy-sync:*`、`[["NodeID",...]]` 等来源,部分创建时间是迁移时间。历史课程统一以原课程编号倒序作为新增顺序的近似依据,不展示迁移时间为上传时间。
+- 保留搜索、学阶和课程分类;点击课程整行或复选框均可选择。已开课程、不可开课程及保存期间均不可误选。
+- 列表上方显示已选课程名称,可逐项取消或清空;筛选不会隐藏已选摘要。开课原因和确认按钮移至列表上方,无需滚过课程列表查找。
+- 刷新目录时去掉已经开通或不再符合条件的选择。开课校验、课时规则和幂等提交不变。
+
+## 验证及发布
+
+- 管理端构建通过;9 项会员开课与课时前端测试通过。
+- 新增目录排序数据库测试通过:创建日期与课程名称/编号冲突、历史迁移时间、其他租户、已删除单词与章节计数。
+- 11 个云函数定义语法校验通过。
+- 两个运营网关只替换 `enrollmentCatalog` 函数的排序规则,备份实际线上代码并在发布前检查漂移,发布后核对代码哈希。
+- 使用学员 1858 的真实只读开课选项验收,发布前后 65 项内容及资格逐项一致。前五项为 464、462、454、449、447。
+- 浏览器使用本地后台连接真实后端,检查整行选择、复选框只切换一次、搜索后已选摘要保留及取消、1440/390 宽度。验收拦截开课写请求,没有实际给测试学员开通课程。
+- 生产后端排序已发布;前端交互为本地构建/开发后台验证,未在本任务部署前端托管资源。
+
+私有发布备份、接口回读及浏览器截图位于 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-enrollment-picker/`。回滚时从原始 `baseline.json` 恢复本次函数,先检查其他线上功能是否已更新,避免整包覆盖。源码仅提交本次文件。

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

@@ -50,3 +50,19 @@ describe('会员充值预览保护',()=>{
  it('必须先预览,预览后修改数量不能提交旧确认',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';await c.submit();expect(operations.mutate).not.toHaveBeenCalled();operations.request.and.resolveTo({version:'server-v1',lines:[{account:'Purse',delta:40},{account:'UserPoint',delta:20}]});await c.check();c.amount=20;await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('预览');});
  it('服务端拒绝预览时保留输入并不执行记账',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';operations.request.and.rejectWith(new Error('门店库存不足'));await c.check();expect(c.amount).toBe(40);expect(c.reason).toBe('购买课时');expect(c.preview()).toBeNull();expect(c.error()).toContain('库存不足');expect(operations.mutate).not.toHaveBeenCalled();});
 });
+
+describe('开课课程选择',()=>{
+ it('点整行或复选框只切换一次,筛选后仍可查看并取消已选课程',async()=>{
+  const ops=jasmine.createSpyObj('OperationsService',['request','mutate']);
+  const item={courseId:464,courseName:'体育单招1700词',stageKey:'high',stageName:'高中',wordCount:1700,active:true,alreadyOpened:false,eligible:true,reason:''};
+  ops.request.and.resolveTo({categories:[{key:'word',name:'单词课'}],categoryKey:'word',eligible:true,reason:'',rules:[],items:[item,{...item,courseId:400,courseName:'已开课程',alreadyOpened:true}]});
+  TestBed.configureTestingModule({imports:[MemberEnrollmentComponent],providers:[{provide:OperationsService,useValue:ops}]});
+  const fixture=TestBed.createComponent(MemberEnrollmentComponent),c=fixture.componentInstance;c.studentId=20;await c.load();fixture.detectChanges();
+  fixture.nativeElement.querySelector('tbody tr td:nth-child(2)').click();fixture.detectChanges();expect(c.selected()).toEqual([464]);
+  fixture.nativeElement.querySelector('tbody input').click();fixture.detectChanges();expect(c.selected()).toEqual([]);
+  c.toggle(400);expect(c.selected()).toEqual([]);c.toggle(464);c.search.set('不存在');fixture.detectChanges();
+  expect(fixture.nativeElement.querySelector('.selected-courses').textContent).toContain('体育单招');
+  fixture.nativeElement.querySelector('.selected-courses button').click();fixture.detectChanges();expect(c.selected()).toEqual([]);
+  c.search.set('');c.toggle(464);c.saving.set(true);c.toggle(464);c.clearSelection();expect(c.selected()).toEqual([464]);
+ });
+});

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

@@ -11,8 +11,11 @@ import { EnrollmentOptions } from '../member-enrollment.models';
 @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>
+<div class="selection-bar"><span>最新课程在前 · 点击课程行可选择</span><span aria-live="polite">已选 {{selected().length}} 项</span><button type="button" (click)="clearSelection()" [disabled]="saving()||!selected().length">清空选择</button></div>
+@if(selectedCourses().length){<div class="selected-courses" aria-label="已选课程">@for(c of selectedCourses();track c.courseId){<button type="button" (click)="toggle(c.courseId)" [disabled]="saving()||loading()" [attr.aria-label]="'取消选择'+c.courseName">{{c.courseName}} <span aria-hidden="true">×</span></button>}</div>}
 <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>
+<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 [class.selectable]="c.eligible&&!c.alreadyOpened&&!saving()&&!loading()" [class.selected]="selected().includes(c.courseId)" (click)="toggle(c.courseId)"><td><input type="checkbox" [attr.aria-label]="'选择'+c.courseName" [checked]="selected().includes(c.courseId)" (click)="$event.stopPropagation()" (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>
+
 }</section>`,styleUrls:['./member-enrollment.scss']})
 export class MemberEnrollmentComponent implements OnChanges{
  @Input({required:true}) studentId=0;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();private operations=inject(OperationsService);
@@ -20,7 +23,9 @@ export class MemberEnrollmentComponent implements OnChanges{
  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.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.options.set(null);this.error.set(e instanceof Error?e.message:'开课内容加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
+ selectedCourses=computed(()=>(this.options()?.items||[]).filter(c=>this.selected().includes(c.courseId)));
+ clearSelection(){if(!this.saving())this.selected.set([]);}
+ toggle(id:number){const course=this.options()?.items.find(c=>c.courseId===id);if(this.saving()||this.loading()||!course?.eligible||course.alreadyOpened)return;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.selected.update(ids=>ids.filter(id=>result.items.some(c=>c.courseId===id&&c.eligible&&!c.alreadyOpened)));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);}}
 }

+ 16 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.scss

@@ -96,3 +96,19 @@ input[type=checkbox]:disabled { cursor:not-allowed; }
     > app-operations-date-filter, > .account-actions { grid-column:1 / -1; }
   }
 }
+
+// Enrollment controls remain visible above the independently scrolling catalog.
+.enrollment-panel {
+ .selection-bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px 16px; margin:14px 0; color:#52695d; }
+ .selection-bar > span:first-child { flex:1; }
+ .selection-bar button { padding:6px 12px; }
+ .selected-courses { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px; }
+ .selected-courses button { display:inline-flex; align-items:center; gap:12px; padding:8px 12px; background:#e8f5ef; color:#17674f; border-color:#aad1c2; text-align:left; }
+ .selected-courses button span { font-size:20px; line-height:1; }
+ tr.selectable { cursor:pointer; }
+ tr.selectable:hover td { background:#f0f8f4; }
+ tr.selected td { background:#e8f5ef; }
+ tr:focus-within td { box-shadow:inset 0 -2px #19765c; }
+ td:first-child { width:64px; }
+ .form-actions { border-top:1px solid #e1ebe5; padding-top:12px; }
+}

+ 1 - 1
scripts/cloud/member-enrollment.js

@@ -27,7 +27,7 @@ async function enrollmentCatalog(context,input) {
     SELECT n.* FROM "Node" n WHERE n."company"=$1 AND (n."parentId" IN (8,9,10,11,12) OR EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p."company"=n."company" AND p."courseId"=n."nodeId"))) ,
     descendants AS (SELECT "nodeId" AS root,"nodeId",ARRAY["nodeId"] AS visited FROM roots UNION ALL SELECT d.root,n."nodeId",d.visited||n."nodeId" FROM descendants d JOIN "Node" n ON n."company"=$1 AND n."parentId"=d."nodeId" WHERE NOT n."nodeId"=ANY(d.visited) AND COALESCE(n."zstatus",99)=99),
     counts AS (SELECT d.root,COUNT(DISTINCT c."objectId") AS total FROM descendants d JOIN "CommonModel" c ON c."company"=$1 AND c."nodeId"=d."nodeId" AND c."modelId"=52 AND COALESCE(c."status",99)=99 GROUP BY d.root)
-    SELECT n."nodeId",n."nodeName",n."parentId",n."zstatus",p."nodeName" AS parent,c.total FROM roots n LEFT JOIN "Node" p ON p."company"=$1 AND p."nodeId"=n."parentId" LEFT JOIN counts c ON c.root=n."nodeId" ORDER BY n."nodeName"`,[co]);
+    SELECT n."nodeId",n."nodeName",n."parentId",n."zstatus",p."nodeName" AS parent,c.total FROM roots n LEFT JOIN "Node" p ON p."company"=$1 AND p."nodeId"=n."parentId" LEFT JOIN counts c ON c.root=n."nodeId" ORDER BY CASE WHEN n."sourceKey" LIKE 'cloud:%' THEN n."createdAt" END DESC NULLS LAST,n."nodeId" DESC`,[co]);
   return rows.map(row=>({courseId:number(row.nodeId),courseName:text(row.nodeName),stageKey:courseStageFromDatabase([row.nodeName,row.parent]).key,stageName:courseStageFromDatabase([row.nodeName,row.parent]).name,wordCount:number(row.total),active:number(row.zstatus,99)===99}));
 }
 async function enrollmentOptions(context,input) {

+ 18 - 0
scripts/tests/enrollment-catalog-order.test.mjs

@@ -0,0 +1,18 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';import {PGlite} from '@electric-sql/pglite';
+test('enrollment catalog uses creation order, legacy IDs as migration fallback and preserves tenant/content scope',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`CREATE TABLE "Node"(company text,"nodeId" numeric,"nodeName" text,"parentId" numeric,zstatus numeric,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text);
+ CREATE TABLE "CourseTeachingProfile"(company text,"courseId" numeric);
+ CREATE TABLE "CommonModel"(company text,"objectId" text,"nodeId" numeric,"modelId" numeric,status numeric);
+ INSERT INTO "Node" VALUES ('a',10,'高中',0,99,null,null,'legacy:node:10'),
+ ('a',600,'Z 最新课程',10,99,'2026-10-15','2026-10-15','cloud:600'),
+ ('a',700,'A 较早课程',10,99,'2026-10-14','2026-10-16','cloud:700'),
+ ('a',464,'历史最新',10,99,'2026-10-08','2026-10-08','legacy:node:464'),
+ ('a',13,'历史较早',10,99,'2026-10-09','2026-10-09', '[["NodeID",13]]' ),
+ ('b',999,'别的租户',10,99,'2026-10-16','2026-10-16','cloud:999'),
+ ('a',601,'章节',600,99,'2026-10-15','2026-10-15','cloud:601');
+ INSERT INTO "CommonModel" VALUES ('a','w1',601,52,99),('a','w2',601,52,-2),('b','w3',601,52,99);`);
+ const ctx=vm.createContext({Psql:{query:async(sql,args)=>(await db.query(sql,args)).rows},companyIdOf:()=> 'a',number:(v,f=0)=>v==null?f:Number(v),text:v=>String(v??''),courseStageFromDatabase:()=>({key:'high',name:'高中'})});vm.runInContext(readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8'),ctx);
+ const rows=await ctx.enrollmentCatalog({},{});assert.deepEqual(Array.from(rows,r=>r.courseId),[600,700,464,13]);assert.equal(rows[0].wordCount,1);
+ }finally{await db.close();}
+});