Procházet zdrojové kódy

feat(admin): add coach account management

彭峰 před 2 měsíci
rodič
revize
348d1ec601

+ 19 - 2
scripts/deploy-admin-functions.mjs

@@ -2778,7 +2778,7 @@ async function coachRows(context,input) {
   const lessonMap=new Map();
   for(const row of lessonFacts){const uid=text(row.uid);if(!uid)continue;const lessonAt=firstDateIso(row.appointmentEnd,row.kzsj,row.appointmentStart,row.plpjsj),type=number(row.kclx),score=number(row.pf),entry=lessonMap.get(uid)||{month_count:0,month_hours:0,estimated_pay:0,score_total:0,score_count:0,average_score:0,last_at:''};if(lessonAt&&lessonAt.slice(0,7)===month){entry.month_count++;entry.month_hours+=durationHours(type);entry.estimated_pay+=type===1?20:[2,3].includes(type)?40:0;}if(score>0){entry.score_total+=score;entry.score_count++;entry.average_score=entry.score_total/entry.score_count;}if(lessonAt&&lessonAt>entry.last_at)entry.last_at=lessonAt;lessonMap.set(uid,entry);}
   const availability=new Set((await availabilityRows(context,input)).map((row)=>row.coachId)),apptMap=new Map(appts.map((row)=>[text(row.uid),row]));
-  let normalized=users.map((row)=>{const legacy=legacyOf(row),uid=number(row.legacyUserId||legacy.UserID),storeLegacy=legacyOf({legacyUserData:row.storeLegacy}),a=apptMap.get(String(uid))||{},l=lessonMap.get(String(uid))||{},mobile=mobileOf(row);return{objectId:text(row.objectId),userId:uid,displayName:displayName(row),mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),storeName:text(row.storeRealName||row.storeNickname||storeLegacy.HoneyName||row.storeUsername)||'未绑定',status:row.isDisabled===true?'disabled':'active',statusLabel:row.isDisabled===true?'已停用':'正常',todayAppointments:number(a.today_count),pendingAppointments:number(a.pending_count),monthLessons:number(l.month_count),monthHours:number(l.month_hours),estimatedPay:number(l.estimated_pay),averageScore:number(l.average_score),lastLessonAt:dateIso(l.last_at),availabilityConfigured:availability.has(uid),updatedAt:dateIso(row.updatedAt)}});
+  let normalized=users.map((row)=>{const legacy=legacyOf(row),uid=number(row.legacyUserId||legacy.UserID),storeLegacy=legacyOf({legacyUserData:row.storeLegacy}),a=apptMap.get(String(uid))||{},l=lessonMap.get(String(uid))||{},mobile=mobileOf(row);return{objectId:text(row.objectId),userId:uid,username:text(row.username),displayName:displayName(row),mobile:roleOf(context)==='ops-manager'?mobile:'',mobileMasked:maskMobile(mobile),parentUserId:number(legacy.ParentUserID),storeName:text(row.storeRealName||row.storeNickname||storeLegacy.HoneyName||row.storeUsername)||'未绑定',status:row.isDisabled===true?'disabled':'active',statusLabel:row.isDisabled===true?'已停用':'正常',todayAppointments:number(a.today_count),pendingAppointments:number(a.pending_count),monthLessons:number(l.month_count),monthHours:number(l.month_hours),estimatedPay:number(l.estimated_pay),averageScore:number(l.average_score),lastLessonAt:dateIso(l.last_at),availabilityConfigured:availability.has(uid),updatedAt:dateIso(row.updatedAt)}});
   const paging=pageInput(input),status=text(paging.filters.status||'all');if(status!=='all')normalized=normalized.filter((row)=>row.status===status);return pageRows(searchRows(normalized,input),input);
 }
 async function scheduleData(context,input) { const from=text(input.dateFrom)||new Date().toISOString().slice(0,10),to=text(input.dateTo)||from,coachId=number((input.filters||{}).coachId); const coachPage=await coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}}),all=await allAppointmentRows(context,input),appointments=all.filter((row)=>row.startsAt.slice(0,10)>=from&&row.startsAt.slice(0,10)<=to&&(!coachId||row.coachId===coachId)),unassigned=appointments.filter((row)=>!row.coachId&&!row.cancelled); return {dateFrom:from,dateTo:to,refreshedAt:new Date().toISOString(),coaches:coachPage.items,appointments,unassigned}; }
@@ -2858,10 +2858,24 @@ async function ensureCompletionLedger(context,input,pair) {
   }
 }
 async function completeAppointmentSafe(context,input) { const pair=await appointmentPair(context,input,text(input.targetId)),result=await completeAppointment(context,input);await ensureCompletionLedger(context,input,pair);return result; }
+async function coachStore(context,input,parentUserId) { if(!parentUserId)return null;const companyId=companyIdOf(context,input),row=await Psql.oneOrNone('SELECT "objectId","username","realName","nickname","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\')=$2 AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\'2\' AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,String(parentUserId)]);if(!row)fail(404,'所属门店不存在');return row; }
+function coachPayload(payload,creating=false) { const username=text(payload.username),password=text(payload.password),displayName=text(payload.displayName),mobile=text(payload.mobile),parentUserId=number(payload.parentUserId);if(username.length<3||username.length>64)fail(400,'登录账号长度应为 3 至 64 位');if(!displayName||displayName.length>100)fail(400,'老师姓名为必填项且不能超过 100 位');if(mobile&&!/^1\d{10}$/.test(mobile))fail(400,'手机号格式不正确');if((creating||password)&&(password.length<8||password.length>72))fail(400,'密码长度应为 8 至 72 位');if(parentUserId<0||!Number.isInteger(parentUserId))fail(400,'所属门店无效');return{username,password,displayName,mobile,parentUserId,disabled:payload.disabled===true}; }
+async function createCoach(context,input,payload) {
+  requireRole(context,['ops-manager']);const reason=reasonOf(input),data=coachPayload(payload,true),companyId=companyIdOf(context,input);if(!companyId)fail(400,'新增陪练必须指定帐套');const company=await new Parse.Query('Company').get(companyId,{useMasterKey:true});await coachStore(context,input,data.parentUserId);const duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,data.username]);if(duplicate)fail(409,'此登录账号已被使用');
+  const fields=await fieldsOf('_User'),created=new Parse.User();created.setUsername(data.username);created.setPassword(data.password);created.set('company',company);if(fields.type)created.set('type','user');if(fields.isDisabled)created.set('isDisabled',data.disabled);if(fields.isDeleted)created.set('isDeleted',false);if(fields.legacyGroupId)created.set('legacyGroupId',3);if(fields.nickname)created.set('nickname',data.displayName);if(fields.realName)created.set('realName',data.displayName);if(fields.mobile)created.set('mobile',data.mobile);if(fields.isAdmin)created.set('isAdmin',false);if(fields.roles)created.set('roles',[]);try{await created.signUp(null,{useMasterKey:true});}catch(error){if(number(error&&error.code)===202)fail(409,'此登录账号已被使用');fail(422,'陪练账号创建失败:'+text(error&&error.message));}
+  try{const legacy={UserID:0,UserName:data.username,HoneyName:data.displayName,GroupID:3,ParentUserID:data.parentUserId,VIP:0,RegTime:new Date().toISOString(),Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,boffExp:0,State:data.disabled?0:1,OpsCreatedAt:new Date().toISOString()},rows=await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("legacyUserId"),0)+1 AS id FROM "_User",lock_row WHERE "company"=$1 AND COALESCE("legacyUserId",0)>0) UPDATE "_User" SET "legacyUserId"=next_id.id,"legacyGroupId"=3,"legacyUserData"=jsonb_set($3::jsonb,\'{UserID}\',to_jsonb(next_id.id),true),"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[companyId,created.id,JSON.stringify(legacy)]),userId=number(rows[0]&&rows[0].id);if(!userId)throw new Error('无法分配陪练业务 ID');await audit(context,'create-coach','_User',created.id,reason,null,{userId,username:data.username,displayName:data.displayName,parentUserId:data.parentUserId,disabled:data.disabled});return{objectId:created.id,userId};}catch(error){await created.destroy({useMasterKey:true}).catch(()=>undefined);if(error&&error.status)throw error;fail(422,'陪练业务资料初始化失败:'+text(error&&error.message));}
+}
+async function scopedCoachObject(context,input) { const objectId=text(input.targetId);if(!objectId)fail(400,'缺少陪练记录');const target=await new Parse.Query('_User').get(objectId,{useMasterKey:true}),companyId=companyIdOf(context,input),targetCompanyId=pointerId(target.get('company'));if(companyId&&targetCompanyId!==companyId)fail(404,'陪练不存在或不属于当前帐套');return target; }
+async function updateCoach(context,input,payload) {
+  requireRole(context,['ops-manager']);const reason=reasonOf(input),data=coachPayload(payload,false),target=await scopedCoachObject(context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})};if(number(target.get('legacyGroupId')||legacy.GroupID)!==3)fail(400,'目标账号不是陪练老师');await coachStore(context,input,data.parentUserId);const companyId=pointerId(target.get('company')||context.company),duplicate=await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "username"=$2 AND "objectId"<>$3 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[companyId,data.username,target.id]);if(duplicate)fail(409,'此登录账号已被使用');const before={username:text(target.get('username')),displayName:displayName(target.toJSON()),mobile:mobileOf(target.toJSON()),parentUserId:number(legacy.ParentUserID),disabled:target.get('isDisabled')===true};target.set('username',data.username);target.set('nickname',data.displayName);target.set('realName',data.displayName);target.set('mobile',data.mobile);target.set('isDisabled',data.disabled);if(data.password)target.set('password',data.password);legacy.UserName=data.username;legacy.HoneyName=data.displayName;legacy.GroupID=3;legacy.ParentUserID=data.parentUserId;legacy.State=data.disabled?0:1;target.set('legacyGroupId',3);target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});if(data.password||data.disabled){const query=new Parse.Query('_Session');query.equalTo('user',target);query.limit(1000);const sessions=await query.find({useMasterKey:true});if(sessions.length)await Parse.Object.destroyAll(sessions,{useMasterKey:true});}await audit(context,'update-coach','_User',target.id,reason,before,{username:data.username,displayName:data.displayName,mobile:data.mobile,parentUserId:data.parentUserId,disabled:data.disabled});return{objectId:target.id};
+}
+async function deleteCoach(context,input) {
+  requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedCoachObject(context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})},coachId=number(target.get('legacyUserId')||legacy.UserID);if(number(target.get('legacyGroupId')||legacy.GroupID)!==3)fail(400,'目标账号不是陪练老师');const today=businessDate(),stored=await allAppointmentRows(context,input),live=await liveAppointmentRows({dateFrom:today}),active=stored.concat(live.allItems).filter((row)=>row.coachId===coachId&&!row.cancelled&&row.statusCode<30&&businessDate(row.startsAt)>=today);if(active.length)fail(409,'该老师还有 '+active.length+' 节未完成排课,请先改派或取消后再删除');const before={displayName:displayName(target.toJSON()),coachId,isDeleted:target.get('isDeleted')===true,isDisabled:target.get('isDisabled')===true};target.set('isDisabled',true);target.set('isDeleted',true);legacy.State=0;legacy.OpsDeletedAt=new Date().toISOString();target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});const query=new Parse.Query('_Session');query.equalTo('user',target);query.limit(1000);const sessions=await query.find({useMasterKey:true});if(sessions.length)await Parse.Object.destroyAll(sessions,{useMasterKey:true});await audit(context,'delete-coach','_User',target.id,reason,before,{isDeleted:true,isDisabled:true});return{objectId:target.id,deleted:true};
+}
 function businessDate(value=new Date()) { return new Date(value).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}); }
 async function appointmentCountsForBusinessDate(context,input,date=businessDate()) { const appointments=await allAppointmentRows(context,input),active=appointments.filter((row)=>!row.cancelled&&businessDate(row.startsAt)===date),byCoach=new Map();for(const row of active)if(row.coachId)byCoach.set(row.coachId,(byCoach.get(row.coachId)||0)+1);return{active,byCoach}; }
 function appointmentHistory(rows) { const byCoach=new Map(),dates=[];for(const row of rows){const date=row.startsAt?businessDate(row.startsAt):'';if(/^\d{4}-\d{2}-\d{2}$/.test(date))dates.push(date);if(!row.coachId)continue;const current=byCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:''};current.totalAppointments+=1;if(row.startsAt>current.lastAppointmentAt)current.lastAppointmentAt=row.startsAt;byCoach.set(row.coachId,current);}dates.sort();return{byCoach,totalAppointments:rows.length,dataDateFrom:dates[0]||'',dataDateTo:dates[dates.length-1]||''};}
-async function professionalCoachRows(context,input) { const page=await coachRows(context,input),all=await allAppointmentRows(context,input),counts=await appointmentCountsForBusinessDate(context,input),history=appointmentHistory(all);return{...page,items:page.items.map((row)=>({...row,todayAppointments:counts.byCoach.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:''})}))}; }
+async function professionalCoachRows(context,input) { const page=await coachRows(context,input),all=await allAppointmentRows(context,input),counts=await appointmentCountsForBusinessDate(context,input),history=appointmentHistory(all),companyId=companyIdOf(context,input),storeRows=await Psql.query('SELECT "objectId","username","realName","nickname","legacyUserId","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\')=\'2\' AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) ORDER BY COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"username")',[companyId]);return{...page,canManage:roleOf(context)==='ops-manager',stores:storeRows.map((row)=>({objectId:text(row.objectId),userId:number(row.legacyUserId||legacyOf(row).UserID),displayName:displayName(row)})).filter((row)=>row.userId),items:page.items.map((row)=>({...row,todayAppointments:counts.byCoach.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:''})}))}; }
 async function professionalCoachDetail(context,input,objectId) { const detail=await coachDetailData(context,input,objectId),all=await allAppointmentRows(context,input),counts=await appointmentCountsForBusinessDate(context,input),history=appointmentHistory(all),coachHistory=history.byCoach.get(detail.coach.userId)||{totalAppointments:0,lastAppointmentAt:''};return{...detail,coach:{...detail.coach,todayAppointments:counts.byCoach.get(detail.coach.userId)||0,...coachHistory},appointments:all.filter((row)=>row.coachId===detail.coach.userId).slice(0,500)}; }
 async function professionalScheduleDataLocal(context,input) {
   const data=await professionalScheduleData(context,input),stored=await allAppointmentRows(context,input),live=await liveAppointmentRows(input),coachId=number((input.filters||{}).coachId),memberMap=new Map(data.members.map((row)=>[row.userId,row])),baseCoachMap=new Map(data.coaches.map((row)=>[row.userId,row]));
@@ -2879,6 +2893,9 @@ async function handler(request,response) {
     if(operation==='ops/dashboard/summary') return response.json({success:true,data:await professionalDashboard(context,input)});
     if(operation==='ops/coaches/list') return response.json({success:true,data:await professionalCoachRows(context,input)});
     if(operation==='ops/coaches/detail') return response.json({success:true,data:await professionalCoachDetail(context,input,text(input.objectId))});
+    if(operation==='ops/coaches/create') return response.json({success:true,data:await createCoach(context,input,payload)});
+    if(operation==='ops/coaches/update') return response.json({success:true,data:await updateCoach(context,input,payload)});
+    if(operation==='ops/coaches/delete') return response.json({success:true,data:await deleteCoach(context,input)});
     if(operation==='ops/schedule/calendar') return response.json({success:true,data:await professionalScheduleDataLocal(context,input)});
     if(operation==='ops/coach-availability/list') return response.json({success:true,data:pageRows(await availabilityRows(context,input,number((input.filters||{}).coachId)),input)});
     if(operation==='ops/coach-availability/save') { requireRole(context,['ops-manager','ops-staff']);const reason=reasonOf(input),coachId=number(payload.coachId),ruleType=text(payload.ruleType),start=text(payload.startTime),end=text(payload.endTime);if(!coachId||!['weekly','date'].includes(ruleType)||!/^\d{2}:\d{2}$/.test(start)||!/^\d{2}:\d{2}$/.test(end)||start>=end)fail(400,'陪练、规则类型和有效时段必填');if(!(await userByLegacyId(context,input,coachId,3)))fail(404,'陪练不存在');if(ruleType==='date'&&!/^\d{4}-\d{2}-\d{2}$/.test(text(payload.date)))fail(400,'指定日期无效');const company=companyPointer(context,input),rule=new Parse.Object('CoachAvailability');rule.set('company',company);rule.set('sourceKey','cloud:ops-availability:'+companyIdOf(context,input)+':'+coachId+':'+randomObjectId());rule.set('coachId',coachId);rule.set('ruleType',ruleType);rule.set('weekday',number(payload.weekday));rule.set('date',text(payload.date));rule.set('startTime',start);rule.set('endTime',end);rule.set('available',payload.available!==false);rule.set('reason',reason);await rule.save(null,{useMasterKey:true});await audit(context,'save-coach-availability','CoachAvailability',rule.id,reason,null,safe(rule));return response.json({success:true,data:{objectId:rule.id}}); }

+ 13 - 0
src/app/admin/admin.models.ts

@@ -175,9 +175,11 @@ export interface OperationsUserDetail extends OperationsUser {
 export interface CoachSummary extends Record<string, unknown> {
   objectId: string;
   userId: number;
+  username: string;
   displayName: string;
   mobile: string;
   mobileMasked: string;
+  parentUserId: number;
   storeName: string;
   status: 'active' | 'disabled';
   statusLabel: string;
@@ -194,6 +196,17 @@ export interface CoachSummary extends Record<string, unknown> {
   updatedAt: string;
 }
 
+export interface CoachStoreOption extends Record<string, unknown> {
+  objectId: string;
+  userId: number;
+  displayName: string;
+}
+
+export interface CoachPage extends OperationsPage<CoachSummary> {
+  canManage: boolean;
+  stores: CoachStoreOption[];
+}
+
 export interface ScheduleAppointment extends Record<string, unknown> {
   targetId: string;
   generalId: number;

+ 3 - 3
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachSummary, OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -24,8 +24,8 @@ export class OperationsService {
     return this.functions.operations<OperationsUserDetail>('ops/users/detail', { objectId });
   }
 
-  coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<CoachSummary>> {
-    return this.functions.operations<OperationsPage<CoachSummary>>('ops/coaches/list', { page, pageSize, search, filters });
+  coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<CoachPage> {
+    return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters });
   }
 
   coachDetail(objectId: string): Promise<CoachDetailData> {

+ 24 - 5
src/app/admin/pages/operations-coaches.component.html

@@ -1,18 +1,37 @@
 <header class="page-header">
   <div><p>COACH OPERATIONS</p><h1>陪练老师</h1><span>查看老师排课、完成课次、工时、评分与预估工资</span></div>
-  <div class="header-actions"><button type="button" (click)="load(page()?.page||1)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button></div>
+  <div class="header-actions"><button class="ui-button secondary" type="button" (click)="load(page()?.page||1)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button>@if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增陪练</button>}</div>
 </header>
 <section class="toolbar">
   <label class="grow"><span>搜索老师</span><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="姓名、账号、手机号或所属门店"/></label>
   <label><span>账号状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="all">全部</option><option value="active">正常</option><option value="disabled">已停用</option></select></label>
-  <button class="primary" type="button" (click)="load(1)"><lucide-icon [img]="icons.Search" [size]="17"/>查询</button><button type="button" (click)="reset()">重置</button>
+  <button class="ui-button primary" type="button" (click)="load(1)"><lucide-icon [img]="icons.Search" [size]="17"/>查询</button><button class="ui-button secondary" type="button" (click)="reset()">重置</button>
 </section>
-@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+@if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 <section class="content-card">
   <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位</span></header>
-  <div class="table-scroll"><table class="data-table"><thead><tr><th>老师</th><th>所属门店</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>待完成</th><th>本月课次</th><th>本月工时</th><th>预估工资</th><th>平均评分</th><th>最近上课</th><th>状态</th><th></th></tr></thead><tbody>
-    @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd'):'暂无'}}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured){<small class="exception">未设置可用时间</small>}</td><td><a [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情"><lucide-icon [img]="icons.Eye" [size]="17"/></a></td></tr>}
+  <div class="table-scroll"><table class="data-table"><thead><tr><th>老师</th><th>所属门店</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>待完成</th><th>本月课次</th><th>本月工时</th><th>预估工资</th><th>平均评分</th><th>最近上课</th><th>状态</th><th>操作</th></tr></thead><tbody>
+    @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username}} · ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>{{row.lastAppointmentAt?(row.lastAppointmentAt|date:'yyyy-MM-dd'):'暂无'}}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions"><a class="icon-action" [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情" title="查看"><lucide-icon [img]="icons.Eye" [size]="16"/></a>@if(page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑陪练" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除陪练" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}</div></td></tr>}
     @empty{<tr><td colspan="13"><div class="empty">没有符合条件的陪练老师</div></td></tr>}
   </tbody></table></div>
   <footer class="pagination"><span>第 {{page()?.page||1}} / {{pageCount()}} 页</span><div><button type="button" [disabled]="(page()?.page||1)<=1" (click)="load((page()?.page||1)-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><button type="button" [disabled]="(page()?.page||1)>=pageCount()" (click)="load((page()?.page||1)+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></div></footer>
 </section>
+
+@if(drawerOpen()){
+  <div class="backdrop" (click)="closeDrawer()"></div>
+  <aside class="drawer coach-editor" role="dialog" aria-modal="true" [attr.aria-label]="editing()?'编辑陪练老师':'新增陪练老师'">
+    <header><div><p>COACH ACCOUNT</p><h2>{{editing()?'编辑陪练老师':'新增陪练老师'}}</h2><span>{{editing()?'修改账号资料、门店归属和状态':'创建可用于排课的陪练账号'}}</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="19"/></button></header>
+    @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+    <div class="form-grid coach-form">
+      <label><span>登录账号 <em>*</em></span><input autocomplete="username" [ngModel]="username()" (ngModelChange)="username.set($event)" placeholder="至少 3 位,例如 coach_wang"/></label>
+      <label><span>{{editing()?'重置密码':'初始密码'}} <em [class.optional]="editing()">{{editing()?'选填':'*'}}</em></span><input type="password" autocomplete="new-password" [ngModel]="password()" (ngModelChange)="password.set($event)" [placeholder]="editing()?'留空则不修改密码':'至少 8 位'"/></label>
+      <label><span>老师姓名 <em>*</em></span><input [ngModel]="displayName()" (ngModelChange)="displayName.set($event)" placeholder="用于排课和工资单显示"/></label>
+      <label><span>手机号码</span><input inputmode="numeric" maxlength="11" [ngModel]="mobile()" (ngModelChange)="mobile.set($event)" placeholder="11 位手机号码"/></label>
+      <label><span>所属门店</span><select [ngModel]="parentUserId()" (ngModelChange)="parentUserId.set(+$event)"><option [ngValue]="0">暂不绑定门店</option>@for(store of page()?.stores||[];track store.userId){<option [ngValue]="store.userId">{{store.displayName}}</option>}</select></label>
+      <label><span>账号状态</span><select [ngModel]="disabled()" (ngModelChange)="disabled.set($event)"><option [ngValue]="false">正常使用</option><option [ngValue]="true">停用账号</option></select></label>
+      <label class="wide"><span>操作原因 <em>*</em></span><textarea rows="3" [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="用于安全审计,例如:新员工入职、资料更正或离职"></textarea></label>
+    </div>
+    @if(deleteArmed()){<p class="delete-warning">删除后老师将不能登录,历史排课和工资记录会保留。有未完成排课时系统会阻止删除。</p>}
+    <footer>@if(editing()){<button class="ui-button danger push-left" type="button" (click)="remove()" [disabled]="saving()"><lucide-icon [img]="icons.Trash2" [size]="16"/>{{deleteArmed()?'确认删除老师':'删除老师'}}</button>}<button class="ui-button secondary" type="button" (click)="closeDrawer()">取消</button><button class="ui-button primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'保存资料'}}</button></footer>
+  </aside>
+}

+ 89 - 0
src/app/admin/pages/operations-coaches.component.scss

@@ -0,0 +1,89 @@
+:host {
+  --control-height: 42px;
+}
+
+.data-table {
+  min-width: 1460px;
+}
+
+.row-actions {
+  flex-wrap: nowrap;
+  min-width: 120px;
+}
+
+.coach-editor {
+  width: min(640px, 100%);
+}
+
+.coach-editor header h2 {
+  margin: 5px 0 7px;
+}
+
+.coach-editor header p {
+  margin: 0;
+  color: #258b68;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: .16em;
+}
+
+.coach-editor header span {
+  color: #788882;
+  font-size: 13px;
+}
+
+.coach-form label > span {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.coach-form em {
+  color: #bd4545;
+  font-style: normal;
+}
+
+.coach-form em.optional {
+  color: #8a9792;
+  font-size: 11px;
+  font-weight: 500;
+}
+
+.coach-form textarea {
+  min-height: 92px;
+  resize: vertical;
+}
+
+.delete-warning {
+  margin: 18px 0 0;
+  padding: 12px 14px;
+  border: 1px solid #f1c9c5;
+  border-radius: 12px;
+  color: #a23f3f;
+  background: #fff4f2;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.push-left {
+  margin-right: auto;
+}
+
+@media (max-width: 700px) {
+  .coach-editor {
+    padding: 20px 16px;
+  }
+
+  .coach-editor footer {
+    flex-wrap: wrap;
+  }
+
+  .coach-editor footer .ui-button {
+    flex: 1 1 auto;
+  }
+
+  .coach-editor footer .push-left {
+    flex-basis: 100%;
+    margin-right: 0;
+  }
+}

+ 11 - 4
src/app/admin/pages/operations-coaches.component.ts

@@ -2,13 +2,20 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterLink } from '@angular/router';
-import { ChevronLeft, ChevronRight, Eye, GraduationCap, LucideAngularModule, RefreshCw, Search } from 'lucide-angular';
-import { CoachSummary, OperationsPage } from '../admin.models';
+import { ChevronLeft, ChevronRight, Eye, GraduationCap, LucideAngularModule, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-angular';
+import { CoachPage, CoachSummary } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
-@Component({selector:'app-operations-coaches',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-coaches',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrls:['./operations-professional.scss','./operations-coaches.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachesComponent implements OnInit{
- readonly page=signal<OperationsPage<CoachSummary>|null>(null);readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly error=signal('');readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,RefreshCw,Search};private readonly operations=inject(OperationsService);
+ readonly page=signal<CoachPage|null>(null);readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
+ readonly username=signal('');readonly password=signal('');readonly displayName=signal('');readonly mobile=signal('');readonly parentUserId=signal(0);readonly disabled=signal(false);readonly reason=signal('');
+ readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,Pencil,Plus,RefreshCw,Search,Trash2,X};private readonly operations=inject(OperationsService);
  ngOnInit():void{void this.load(1);}async load(page=1):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()}));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);}}
  reset():void{this.query.set('');this.status.set('all');void this.load(1);}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
+ openCreate():void{this.editing.set(null);this.username.set('');this.password.set('');this.displayName.set('');this.mobile.set('');this.parentUserId.set(0);this.disabled.set(false);this.reason.set('新增陪练老师');this.deleteArmed.set(false);this.error.set('');this.drawerOpen.set(true);}
+ openEdit(row:CoachSummary,deleteMode=false):void{this.editing.set(row);this.username.set(row.username);this.password.set('');this.displayName.set(row.displayName);this.mobile.set(row.mobile||'');this.parentUserId.set(row.parentUserId||0);this.disabled.set(row.status==='disabled');this.reason.set('');this.deleteArmed.set(deleteMode);this.error.set('');this.drawerOpen.set(true);}
+ closeDrawer():void{this.drawerOpen.set(false);this.deleteArmed.set(false);}
+ async save():Promise<void>{const editing=this.editing();if(!this.username().trim()||!this.displayName().trim()||!this.reason().trim()){this.error.set('登录账号、老师姓名和操作原因必填');return;}if(!editing&&!this.password()){this.error.set('新增老师必须设置初始密码');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate(editing?'ops/coaches/update':'ops/coaches/create',editing?.objectId||'',this.reason(),{username:this.username(),password:this.password(),displayName:this.displayName(),mobile:this.mobile(),parentUserId:this.parentUserId(),disabled:this.disabled()},editing?.updatedAt||'');this.message.set(editing?'陪练资料已更新':'陪练老师已新增');this.closeDrawer();await this.load(editing?this.page()?.page||1:1);}catch(error){this.error.set(error instanceof Error?error.message:'陪练资料保存失败');}finally{this.saving.set(false);}}
+ async remove():Promise<void>{const editing=this.editing();if(!editing)return;if(!this.reason().trim()){this.error.set('删除前必须填写操作原因');return;}if(!this.deleteArmed()){this.deleteArmed.set(true);return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/coaches/delete',editing.objectId,this.reason(),{},editing.updatedAt);this.message.set('陪练老师已删除;历史排课和工资记录仍保留');this.closeDrawer();await this.load(1);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
 }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/app/admin/pages/operations-professional.scss


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů