فهرست منبع

fix(admin): surface historical coach schedules

彭峰 1 ماه پیش
والد
کامیت
3f74726f78

+ 7 - 5
scripts/deploy-admin-functions.mjs

@@ -2740,7 +2740,7 @@ async function findAddon(className, company, id) { const fields=await fieldsOf(c
 function durationMinutes(type) { return number(type)===2||number(type)===3?60:30; }
 function durationHours(type) { return durationMinutes(type)/60; }
 function appointmentDate(value,date,time) { const raw=text(value); if(raw) { const normalized=raw.includes('T')?raw:raw.replace(' ','T')+(raw.includes('+')||raw.endsWith('Z')?'':'+08:00'); const parsed=new Date(normalized); if(!Number.isNaN(parsed.getTime())) return parsed; } const fallback=new Date(text(date)+'T'+(text(time)||'00:00')+':00+08:00'); return fallback; }
-function appointmentProjection(row) { const start=appointmentDate(row.yysj,row.bxrq,row.sdsd),type=number(row.dslx),end=new Date(start.getTime()+durationMinutes(type)*60000),student=legacyOf({legacyUserData:row.studentLegacy}),coach=legacyOf({legacyUserData:row.coachLegacy}),cancelled=number(row.commonStatus)===-2; return { targetId:text(row.targetId),generalId:number(row.generalId),studentObjectId:text(row.studentObjectId),studentId:number(row.szyh),studentName:text(row.studentRealName||row.studentNickname||student.HoneyName||row.studentUsername)||'用户 '+number(row.szyh),coachObjectId:text(row.coachObjectId),coachId:number(row.pl||row.fxpl),coachName:text(row.coachRealName||row.coachNickname||coach.HoneyName||row.coachUsername)||'未分配',courseId:number(row.kcid),courseName:text(row.courseTitle)||'课程 '+number(row.kcid),classType:type,classTypeLabel:classType(type),deliveryMethod:text(row.jffs)||'未设置',startsAt:dateIso(start),endsAt:dateIso(end),statusCode:number(row.dszt),statusLabel:cancelled?'已取消':statusLabel(row.dszt),cancelled,cancellationReason:text(row.cancellationReason),availabilityWarning:row.availabilityConfigured===false,updatedAt:dateIso(row.commonUpdatedAt||row.updatedAt) }; }
+function appointmentProjection(row) { const start=appointmentDate(row.yysj,row.bxrq,row.sdsd),type=number(row.dslx),end=new Date(start.getTime()+durationMinutes(type)*60000),student=legacyOf({legacyUserData:row.studentLegacy}),coach=legacyOf({legacyUserData:row.coachLegacy}),cancelled=number(row.commonStatus)===-2,coachId=number(row.pl||row.fxpl),coachObjectId=text(row.coachObjectId),startsAt=Number.isNaN(start.getTime())?'':start.toISOString(),endsAt=Number.isNaN(end.getTime())?'':end.toISOString(); return { targetId:text(row.targetId),generalId:number(row.generalId),studentObjectId:text(row.studentObjectId),studentId:number(row.szyh),studentName:text(row.studentRealName||row.studentNickname||student.HoneyName||row.studentUsername)||'用户 '+number(row.szyh),coachObjectId,coachId,coachName:text(row.coachRealName||row.coachNickname||coach.HoneyName||row.coachUsername||row.plxm)||(coachId?'历史陪练 '+coachId:'未分配'),courseId:number(row.kcid),courseName:text(row.courseTitle)||'课程 '+number(row.kcid),classType:type,classTypeLabel:classType(type),deliveryMethod:text(row.jffs)||'未设置',startsAt,endsAt,statusCode:number(row.dszt),statusLabel:cancelled?'已取消':statusLabel(row.dszt),cancelled,cancellationReason:text(row.cancellationReason),coachAccountMissing:Boolean(coachId&&!coachObjectId),availabilityWarning:row.availabilityConfigured===false,updatedAt:dateIso(row.commonUpdatedAt||row.updatedAt) }; }
 async function allAppointmentRows(context,input) { const companyId=companyIdOf(context,input); const raw=await Psql.query('SELECT c."objectId" AS "targetId",c."generalId",c."status" AS "commonStatus",c."updatedAt" AS "commonUpdatedAt",a.*,s."objectId" AS "studentObjectId",s."realName" AS "studentRealName",s."nickname" AS "studentNickname",s."username" AS "studentUsername",s."legacyUserData" AS "studentLegacy",p."objectId" AS "coachObjectId",p."realName" AS "coachRealName",p."nickname" AS "coachNickname",p."username" AS "coachUsername",p."legacyUserData" AS "coachLegacy",n."nodeName" AS "courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) LEFT JOIN "_User" s ON s."company"=c."company" AND COALESCE(CAST(s."legacyUserId" AS text),s."legacyUserData"->>\'UserID\')=CAST(a."szyh" AS text) LEFT JOIN "_User" p ON p."company"=c."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),CAST(a."fxpl" AS text)) LEFT JOIN "Node" n ON n."company"=c."company" AND CAST(n."nodeId" AS text)=CAST(a."kcid" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' ORDER BY c."updatedAt" DESC LIMIT 10000',[companyId]); return raw.map(appointmentProjection); }
 async function newClassObjects(className,context,input,limit=1000) { try { const query=new Parse.Query(className); const company=companyPointer(context,input); if(company) query.equalTo('company',company); query.limit(limit); query.descending('createdAt'); return await query.find({useMasterKey:true}); } catch(error) { if(error.code===100||error.code===103||/class/i.test(text(error.message))) return []; throw error; } }
 async function availabilityRows(context,input,coachId=0) { const rows=await newClassObjects('CoachAvailability',context,input,1000); return rows.filter((row)=>!coachId||number(row.get('coachId'))===coachId).map((row)=>({objectId:row.id,coachId:number(row.get('coachId')),ruleType:text(row.get('ruleType'))||'weekly',weekday:number(row.get('weekday')),date:text(row.get('date')),startTime:text(row.get('startTime')),endTime:text(row.get('endTime')),available:row.get('available')!==false,reason:text(row.get('reason')),updatedAt:dateIso(row.updatedAt)})); }
@@ -2827,9 +2827,10 @@ 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; }
 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}; }
-async function professionalCoachRows(context,input) { const page=await coachRows(context,input),counts=await appointmentCountsForBusinessDate(context,input);return{...page,items:page.items.map((row)=>({...row,todayAppointments:counts.byCoach.get(row.userId)||0}))}; }
-async function professionalCoachDetail(context,input,objectId) { const detail=await coachDetailData(context,input,objectId),counts=await appointmentCountsForBusinessDate(context,input);return{...detail,coach:{...detail.coach,todayAppointments:counts.byCoach.get(detail.coach.userId)||0}}; }
-async function professionalScheduleDataLocal(context,input) { const data=await professionalScheduleData(context,input),counts=await appointmentCountsForBusinessDate(context,input);return{...data,coaches:data.coaches.map((row)=>({...row,todayAppointments:counts.byCoach.get(row.userId)||0}))}; }
+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 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),all=await allAppointmentRows(context,input),counts=await appointmentCountsForBusinessDate(context,input),history=appointmentHistory(all),coachId=number((input.filters||{}).coachId),appointments=all.filter((row)=>{const date=row.startsAt?businessDate(row.startsAt):'';return date>=data.dateFrom&&date<=data.dateTo&&(!coachId||row.coachId===coachId);}),unassigned=appointments.filter((row)=>!row.coachId||row.coachAccountMissing);return{...data,totalAppointments:history.totalAppointments,dataDateFrom:history.dataDateFrom,dataDateTo:history.dataDateTo,appointments,unassigned,coaches:data.coaches.map((row)=>({...row,todayAppointments:counts.byCoach.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:''})}))}; }
 async function professionalDashboard(context,input) { const data=await dashboard(context,input),date=businessDate(),counts=await appointmentCountsForBusinessDate(context,input),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:counts.active.length}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:counts.active.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts}; }
 async function handler(request,response) {
   try {
@@ -2881,6 +2882,7 @@ async function handler(request,response) {
 
 const definitions = [
   { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] },
   { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
@@ -2927,7 +2929,7 @@ async function upsert(definition) {
     respType: 'json',
     respJson: { success: true, data: {} },
     enabled: true,
-    version: '1.2.3',
+    version: '1.2.4',
   };
   const objectId = existing.results?.[0]?.objectId;
   if (objectId) {

+ 16 - 11
scripts/smoke-operations-functions.mjs

@@ -37,44 +37,49 @@ try {
   const token = login.sessionToken;
   if (!token) throw new Error('临时运营管理员未返回会话');
 
-  const dashboard = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/dashboard/summary' })).data;
+  const ops = (params, expectedStatus = 200) => call('xiaoshu/ops/gateway-v3', token, params, expectedStatus);
+  const dashboard = (await ops({ operation: 'ops/dashboard/summary' })).data;
   if (dashboard.identity?.operationsRole !== 'ops-manager' || !Array.isArray(dashboard.metrics) || dashboard.metrics.length < 4) throw new Error('运营工作台返回结构无效');
 
-  const users = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 20, search: '' })).data;
+  const users = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '' })).data;
   if (!Array.isArray(users.items) || !users.items.length || users.items.some((item) => 'legacyUserData' in item || 'legacyPasswordHash' in item || 'ACL' in item)) throw new Error('用户业务投影为空或泄漏技术字段');
   if (users.items.some((item) => item.username === username) || users.items.some((item) => !item.learningStatus || !item.learningStatusLabel) || !users.refreshedAt) throw new Error('管理员过滤、学习状态或刷新时间无效');
   const learningCounts = {};
   for (const learningStatus of ['trial', 'studying', 'completed', 'not_started', 'not_applicable']) {
-    const result = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 1, search: '', filters: { learningStatus } })).data;
+    const result = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 1, search: '', filters: { learningStatus } })).data;
     learningCounts[learningStatus] = result.total;
   }
   if (Object.values(learningCounts).reduce((sum, value) => sum + Number(value || 0), 0) !== users.total) throw new Error(`学习状态分类数量与会员总数不一致:${JSON.stringify(learningCounts)}`);
-  const named = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 20, search: '万宇昊' })).data;
+  const named = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '万宇昊' })).data;
   if (!named.items?.some((item) => item.displayName === '万宇昊')) throw new Error('用户显示名称兜底未找到“万宇昊”');
-  const legacyAdmin = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 20, search: 'admin' })).data;
+  const legacyAdmin = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: 'admin' })).data;
   if (legacyAdmin.items?.some((item) => String(item.username || '').toLowerCase() === 'admin')) throw new Error('Manager 表中的历史管理员仍混入会员列表');
 
-  const coaches = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '' })).data;
+  const coaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '' })).data;
   if (!Array.isArray(coaches.items) || coaches.items.some((item) => !item.userId || !item.displayName)) throw new Error('陪练老师业务投影无效');
   const today = new Date().toISOString().slice(0, 10);
-  const schedule = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/schedule/calendar', dateFrom: today, dateTo: today, filters: {} })).data;
+  const schedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: today, dateTo: today, filters: {} })).data;
   if (!Array.isArray(schedule.appointments) || !Array.isArray(schedule.coaches) || !Array.isArray(schedule.members) || !Array.isArray(schedule.courses) || !schedule.refreshedAt) throw new Error('排课中心日历或业务选项结构无效');
-  const rates = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/pay-rates/list', page: 1, pageSize: 100 })).data;
+  if (!schedule.totalAppointments || !schedule.dataDateFrom || !schedule.dataDateTo) throw new Error(`历史排课总量或数据日期范围缺失:${JSON.stringify({ keys: Object.keys(schedule), totalAppointments: schedule.totalAppointments, dataDateFrom: schedule.dataDateFrom, dataDateTo: schedule.dataDateTo })}`);
+  const latestSchedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: schedule.dataDateTo, dateTo: schedule.dataDateTo, filters: {} })).data;
+  if (!latestSchedule.appointments?.length || latestSchedule.totalAppointments !== schedule.totalAppointments) throw new Error('最近历史排课未正确投影到排课中心');
+  if (!coaches.items?.some((item) => item.totalAppointments > 0 && item.lastAppointmentAt)) throw new Error('陪练列表缺少历史排课汇总');
+  const rates = (await ops({ operation: 'ops/pay-rates/list', page: 1, pageSize: 100 })).data;
   for (const expected of [{ type: 1, amount: 20, hours: .5 }, { type: 2, amount: 40, hours: 1 }, { type: 3, amount: 40, hours: 1 }]) {
     if (!rates.items?.some((item) => item.classType === expected.type && item.amount === expected.amount && item.durationHours === expected.hours)) throw new Error(`原系统课酬规则缺失:课型 ${expected.type}`);
   }
   if (rates.items?.some((item) => item.classType === 4 && item.isDefault)) throw new Error('课型4不应存在默认工资规则');
-  const payrolls = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
+  const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
   if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
 
   for (const operation of ['ops/relations/list', 'ops/course-bindings/list', 'ops/appointments/list', 'ops/lessons/list', 'ops/learning-records/list', 'ops/finance/list', 'ops/money-logs/list']) {
-    const page = (await call('xiaoshu/ops/gateway', token, { operation, page: 1, pageSize: 5, search: '' })).data;
+    const page = (await ops({ operation, page: 1, pageSize: 5, search: '' })).data;
     if (!Array.isArray(page.items) || typeof page.total !== 'number') throw new Error(`${operation} 未返回统一分页结构`);
   }
 
   const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
   if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
-  console.log(`Operations smoke passed: users=${users.total}, coaches=${coaches.total}, appointments=${schedule.appointments.length}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
+  console.log(`Operations smoke passed: users=${users.total}, coaches=${coaches.total}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
 } finally {
   if (userId) {
     const sessions = await request(`${PARSE_URL}/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }))}&limit=1000`, {}, true).catch(() => ({ results: [] }));

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

@@ -183,6 +183,8 @@ export interface CoachSummary extends Record<string, unknown> {
   statusLabel: string;
   todayAppointments: number;
   pendingAppointments: number;
+  totalAppointments: number;
+  lastAppointmentAt: string;
   monthLessons: number;
   monthHours: number;
   estimatedPay: number;
@@ -212,6 +214,7 @@ export interface ScheduleAppointment extends Record<string, unknown> {
   statusLabel: string;
   cancelled: boolean;
   cancellationReason: string;
+  coachAccountMissing: boolean;
   availabilityWarning: boolean;
   updatedAt: string;
 }
@@ -220,6 +223,9 @@ export interface ScheduleCalendarData {
   dateFrom: string;
   dateTo: string;
   refreshedAt: string;
+  totalAppointments: number;
+  dataDateFrom: string;
+  dataDateTo: string;
   coaches: CoachSummary[];
   appointments: ScheduleAppointment[];
   unassigned: ScheduleAppointment[];

+ 1 - 1
src/app/admin/cloud-functions.service.spec.ts

@@ -34,7 +34,7 @@ describe('CloudFunctionsService', () => {
 
   it('calls the isolated operations gateway', async () => {
     const pending = service.operations('ops/users/list', { page: 1, pageSize: 20 });
-    const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway`);
+    const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
     expect(request.request.body).toEqual({ token: 'r:admin-session', params: { operation: 'ops/users/list', page: 1, pageSize: 20 } });
     request.flush({ success: true, data: { page: 1, pageSize: 20, total: 0, items: [] } });
     await expectAsync(pending).toBeResolvedTo({ page: 1, pageSize: 20, total: 0, items: [] });

+ 1 - 1
src/app/admin/cloud-functions.service.ts

@@ -42,7 +42,7 @@ export class CloudFunctionsService {
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     try {
       const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
-        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway`,
+        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`,
         { token, params: { operation, ...params } },
       ));
       if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败');

+ 17 - 3
src/app/admin/pages/operations-coaches.component.html

@@ -1,4 +1,18 @@
-<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></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></section>
+<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>
+</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>
+</section>
 @if(error()){<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></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>{{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>}@empty{<tr><td colspan="11"><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>
+<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>}
+    @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>

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

@@ -16,6 +16,7 @@
   <label><span>日期</span><input type="date" [ngModel]="date()" (ngModelChange)="date.set($event);load()" /></label>
   <button type="button" aria-label="下一时间段" (click)="move(view()==='week'?7:1)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
   <label class="grow"><span>陪练筛选</span><select [ngModel]="coachFilter()" (ngModelChange)="coachFilter.set(+$event);load()"><option [ngValue]="0">全部陪练</option>@for(coach of data()?.coaches||[];track coach.userId){<option [ngValue]="coach.userId">{{coach.displayName}}</option>}</select></label>
+  @if(data()?.dataDateTo){<button type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
 </section>
 
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
@@ -25,20 +26,22 @@
   <article class="summary-card"><span>当前范围课程</span><strong>{{activeAppointments()}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
   <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
   <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
-  <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>服务端实时数据</small></article>
+  <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
+  <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>服务端实时读取</small></article>
 </section>
 
 <section class="content-card">
   <header><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of data()?.appointments||[];track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}</td><td><span class="badge" [class.muted]="item.cancelled">{{item.statusLabel}}</span></td><td><div class="row-actions"><button type="button" (click)="openEdit(item)" [disabled]="item.statusCode>0||item.cancelled">修改</button>@if(item.cancelled){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@else{<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button><button type="button" (click)="action(item,'ops/appointments/complete')">完成</button>}</div></td></tr>}
-      @empty{<tr><td colspan="7"><div class="empty">当前范围暂无排课</div></td></tr>}
+      @for(item of data()?.appointments||[];track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}</td><td><span class="badge" [class.muted]="item.cancelled">{{item.statusLabel}}</span></td><td><div class="row-actions"><button type="button" (click)="openEdit(item)" [disabled]="item.statusCode>0||item.cancelled">修改</button>@if(item.cancelled){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@else{<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button><button type="button" (click)="action(item,'ops/appointments/complete')">完成</button>}</div></td></tr>}
+      @empty{<tr><td colspan="7"><div class="empty">当前范围暂无排课;历史数据范围为 {{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</div></td></tr>}
     </tbody></table></div>
   } @else {
     <div class="table-scroll"><div class="calendar">
       @for(coach of data()?.coaches||[];track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>今日 {{coach.todayAppointments}} 节</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of data()?.appointments||[];track item.targetId){@if(item.coachId===coach.userId){<button type="button" class="appointment" [class.cancelled]="item.cancelled" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{item.statusLabel}}</small></button>}}</div></div>}
       @empty{<div class="empty">暂无陪练资料</div>}
+      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of data()?.unassigned||[];track item.targetId){<button type="button" class="appointment" [class.cancelled]="item.cancelled" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{item.statusLabel}}</small></button>}</div></div>}
     </div></div>
   }
 </section>

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

@@ -13,11 +13,13 @@ export class OperationsScheduleComponent implements OnInit {
   readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,Plus,RefreshCw,Search,X}; private readonly operations=inject(OperationsService);
   readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0); readonly unassignedCount=computed(()=>this.data()?.unassigned.length||0);
+  private initialRange=true;
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
   ngOnInit():void{void this.load();}
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}
-  async load():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();this.data.set(await this.operations.schedule(range.from,range.to,this.coachFilter()));}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}
+  async load():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range();this.data.set(await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter()));this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}
   move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
+  showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
   openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.coachId.set(0);this.preview.set(null);this.drawerOpen.set(true);}
   openEdit(item:ScheduleAppointment):void{this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.coachId.set(item.coachId);this.classType.set(item.classType);this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
   payload():Record<string,unknown>{return{studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),deliveryMethod:this.deliveryMethod(),date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}