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

fix(admin): sync coach and dashboard live data

彭峰 2 месяцев назад
Родитель
Сommit
db83b69be9

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

@@ -2770,7 +2770,7 @@ async function availabilityRows(context,input,coachId=0) { const rows=await newC
 function defaultPayRates() { return [{objectId:'default-1',classType:1,classTypeLabel:classType(1),amount:20,durationHours:.5,effectiveFrom:'1970-01-01',active:true,isDefault:true},{objectId:'default-2',classType:2,classTypeLabel:classType(2),amount:40,durationHours:1,effectiveFrom:'1970-01-01',active:true,isDefault:true},{objectId:'default-3',classType:3,classTypeLabel:classType(3),amount:40,durationHours:1,effectiveFrom:'1970-01-01',active:true,isDefault:true}]; }
 async function payRateRows(context,input) { const stored=(await newClassObjects('CoachPayRate',context,input,1000)).map((row)=>({objectId:row.id,classType:number(row.get('classType')),classTypeLabel:classType(row.get('classType')),amount:number(row.get('amount')),durationHours:number(row.get('durationHours'),durationHours(row.get('classType'))),effectiveFrom:text(row.get('effectiveFrom')),active:row.get('active')!==false,isDefault:false,updatedAt:dateIso(row.updatedAt)})); return defaultPayRates().concat(stored).sort((a,b)=>String(b.effectiveFrom).localeCompare(String(a.effectiveFrom))); }
 function rateFor(rates,type,date) { return rates.filter((row)=>row.active&&row.classType===number(type)&&row.effectiveFrom<=date.slice(0,10)).sort((a,b)=>b.effectiveFrom.localeCompare(a.effectiveFrom))[0]||null; }
-async function coachRows(context,input) {
+async function coachRows(context,input,paginate=true) {
   const companyId=companyIdOf(context,input),today=new Date().toISOString().slice(0,10),month=today.slice(0,7);
   const users=await Psql.query('SELECT u.*,p."realName" AS "storeRealName",p."nickname" AS "storeNickname",p."username" AS "storeUsername",p."legacyUserData" AS "storeLegacy" FROM "_User" u LEFT JOIN "_User" p ON p."company"=u."company" AND COALESCE(CAST(p."legacyUserId" AS text),p."legacyUserData"->>\'UserID\')=COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') WHERE ($1::text IS NULL OR u."company"=$1) AND COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\')=\'3\' AND (u."isDeleted" IS NULL OR u."isDeleted"=FALSE) ORDER BY u."createdAt" DESC',[companyId]);
   const appts=await Psql.query('SELECT CAST("pl" AS text) AS uid,COUNT(*) FILTER (WHERE COALESCE("yysj","bxrq") LIKE $2||\'%\')::int AS today_count,COUNT(*) FILTER (WHERE CAST(COALESCE("dszt",\'0\') AS text) IN (\'0\',\'10\'))::int AS pending_count FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) GROUP BY CAST("pl" AS text)',[companyId,today]);
@@ -2779,7 +2779,7 @@ async function coachRows(context,input) {
   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,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);
+  const paging=pageInput(input),status=text(paging.filters.status||'all');if(status!=='all')normalized=normalized.filter((row)=>row.status===status);const filtered=searchRows(normalized,input);return paginate?pageRows(filtered,input):filtered;
 }
 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}; }
 function recurrenceCandidates(payload) { const count=Math.min(60,Math.max(1,number(payload.occurrences,1))),base=appointmentDate('',payload.date,payload.startTime),type=number(payload.classType),mode=text(payload.recurrence)||'once',items=[]; for(let index=0;index<count;index++){const start=new Date(base); if(mode==='daily')start.setDate(start.getDate()+index);else if(mode==='alternate')start.setDate(start.getDate()+index*2);else if(mode==='weekly')start.setDate(start.getDate()+index*7);else if(mode==='same_day_30')start.setMinutes(start.getMinutes()+index*30);else if(mode==='same_day_60')start.setMinutes(start.getMinutes()+index*60);else if(index>0)break; items.push({index,start,end:new Date(start.getTime()+durationMinutes(type)*60000)});} return items; }
@@ -2875,18 +2875,27 @@ async function deleteCoach(context,input) {
 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),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:''})}))}; }
+function mergeAppointments(stored,liveRows) { const merged=new Map();for(const row of stored)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);for(const row of liveRows)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);return Array.from(merged.values()); }
+async function appointmentSnapshot(context,input,dateFrom=businessDate()) { const stored=await allAppointmentRows(context,input),live=await liveAppointmentRows({dateFrom}),appointments=mergeAppointments(stored,live.allItems);return{stored,live,appointments}; }
+async function professionalCoachRows(context,input,providedSnapshot=null) {
+  const local=await coachRows(context,{...input,page:1,pageSize:100,search:'',filters:{...(input.filters||{}),status:'all'}},false),snapshot=providedSnapshot||await appointmentSnapshot(context,input),history=appointmentHistory(snapshot.appointments),today=businessDate(),todayCounts=new Map(),pendingCounts=new Map();
+  for(const row of snapshot.appointments){if(!row.coachId||row.cancelled)continue;if(businessDate(row.startsAt)===today)todayCounts.set(row.coachId,(todayCounts.get(row.coachId)||0)+1);if(row.statusCode<30)pendingCounts.set(row.coachId,(pendingCounts.get(row.coachId)||0)+1);}
+  const rows=local.map((row)=>({...row,todayAppointments:todayCounts.get(row.userId)||0,pendingAppointments:pendingCounts.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:''}),source:'hybrid',sourceLabel:snapshot.live.available?'新系统账号 · 实时排课':'新系统账号',readOnly:false})),knownCoachIds=new Set(rows.map((row)=>row.userId));
+  for(const appointment of snapshot.live.allItems){if(!appointment.coachId||knownCoachIds.has(appointment.coachId))continue;knownCoachIds.add(appointment.coachId);const coachHistory=history.byCoach.get(appointment.coachId)||{totalAppointments:0,lastAppointmentAt:''};rows.push({objectId:'legacy-live-coach:'+appointment.coachId,userId:appointment.coachId,username:'',displayName:appointment.coachName,mobile:'',mobileMasked:'',parentUserId:0,storeName:'实时业务系统',status:'active',statusLabel:'实时账号',todayAppointments:todayCounts.get(appointment.coachId)||0,pendingAppointments:pendingCounts.get(appointment.coachId)||0,...coachHistory,monthLessons:0,monthHours:0,estimatedPay:0,averageScore:0,lastLessonAt:'',availabilityConfigured:false,updatedAt:appointment.updatedAt||'',source:'legacy-live',sourceLabel:'实时业务系统',readOnly:true});}
+  const paging=pageInput(input),status=text(paging.filters.status||'all'),filtered=searchRows(rows.filter((row)=>status==='all'||row.status===status),input).sort((a,b)=>a.displayName.localeCompare(b.displayName,'zh-CN')),page=pageRows(filtered,input),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),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message,liveSourceCount:snapshot.live.allItems.length,dataDateFrom:history.dataDateFrom,dataDateTo:history.dataDateTo};
+}
 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]));
   const hydratedLive=live.allItems.map((row)=>{const member=memberMap.get(row.studentId),coach=baseCoachMap.get(row.coachId);return{...row,studentObjectId:member?member.objectId:'',studentName:member?member.displayName:row.studentName,coachObjectId:coach?coach.objectId:'',coachName:coach?coach.displayName:row.coachName};});
-  const merged=new Map();for(const row of stored)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);for(const row of hydratedLive)merged.set(row.generalId?'general:'+row.generalId:'target:'+row.targetId,row);const all=Array.from(merged.values()),history=appointmentHistory(all),appointments=all.filter((row)=>{const date=row.startsAt?businessDate(row.startsAt):'';return date>=data.dateFrom&&date<=data.dateTo&&(!coachId||row.coachId===coachId);}).sort((a,b)=>a.startsAt.localeCompare(b.startsAt)),rangeCounts=new Map();for(const row of appointments)if(row.coachId)rangeCounts.set(row.coachId,(rangeCounts.get(row.coachId)||0)+1);
+  const all=mergeAppointments(stored,hydratedLive),history=appointmentHistory(all),appointments=all.filter((row)=>{const date=row.startsAt?businessDate(row.startsAt):'';return date>=data.dateFrom&&date<=data.dateTo&&(!coachId||row.coachId===coachId);}).sort((a,b)=>a.startsAt.localeCompare(b.startsAt)),rangeCounts=new Map();for(const row of appointments)if(row.coachId)rangeCounts.set(row.coachId,(rangeCounts.get(row.coachId)||0)+1);
   const coaches=data.coaches.map((row)=>({...row,todayAppointments:rangeCounts.get(row.userId)||0,...(history.byCoach.get(row.userId)||{totalAppointments:0,lastAppointmentAt:''})})),knownCoachIds=new Set(coaches.map((row)=>row.userId));
   for(const row of hydratedLive){if(!row.coachId||knownCoachIds.has(row.coachId))continue;knownCoachIds.add(row.coachId);const coachHistory=history.byCoach.get(row.coachId)||{totalAppointments:0,lastAppointmentAt:''};coaches.push({objectId:'legacy-live-coach:'+row.coachId,userId:row.coachId,displayName:row.coachName,mobile:'',mobileMasked:'',storeName:'实时业务系统',status:'active',statusLabel:'实时账号',todayAppointments:rangeCounts.get(row.coachId)||0,pendingAppointments:0,...coachHistory,monthLessons:0,monthHours:0,estimatedPay:0,averageScore:0,lastLessonAt:'',availabilityConfigured:false,updatedAt:''});
   }
   const unassigned=appointments.filter((row)=>!row.coachId);return{...data,refreshedAt:new Date().toISOString(),totalAppointments:Math.max(history.totalAppointments,live.total),dataDateFrom:history.dataDateFrom,dataDateTo:history.dataDateTo,appointments,unassigned,coaches:coaches.sort((a,b)=>a.displayName.localeCompare(b.displayName,'zh-CN')),liveSourceAvailable:live.available,liveSourceMessage:live.message,liveSourceCount:hydratedLive.length};
 }
-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 professionalDashboard(context,input) { const data=await dashboard(context,input),date=businessDate(),snapshot=await appointmentSnapshot(context,input,date),todayRows=snapshot.appointments.filter((row)=>!row.cancelled&&businessDate(row.startsAt)===date),coachPage=await professionalCoachRows(context,{...input,page:1,pageSize:100,search:'',filters:{status:'active'}},snapshot),estimatedPay=coachPage.items.reduce((sum,row)=>sum+number(row.estimatedPay),0),metrics=data.metrics.map((row)=>row.key==='today'?{...row,value:todayRows.length}:row.key==='coaches'?{...row,value:coachPage.total,hint:snapshot.live.available?'新系统与实时业务账号去重统计':row.hint}:row.key==='pay'?{...row,value:estimatedPay}:row),alerts=data.alerts.map((row)=>row.key==='unassigned'?{...row,value:todayRows.filter((item)=>!item.coachId).length}:row);return{...data,date,metrics,alerts,refreshedAt:new Date().toISOString(),liveSourceAvailable:snapshot.live.available,liveSourceMessage:snapshot.live.message}; }
 async function handler(request,response) {
   try {
     const input=inputOf(request); const operation=text(input.operation); const context=await auth(request); const payload=input.payload&&typeof input.payload==='object'?input.payload:{};

+ 5 - 1
scripts/smoke-operations-functions.mjs

@@ -57,6 +57,7 @@ try {
 
   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 activeCoaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 100, search: '', filters: { status: 'active' } })).data;
   const today = new Date().toISOString().slice(0, 10);
   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('排课中心日历或业务选项结构无效');
@@ -64,6 +65,9 @@ try {
   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 dashboardCoachMetric = dashboard.metrics.find((item) => item.key === 'coaches');
+  if (dashboardCoachMetric?.value !== activeCoaches.total) throw new Error(`工作台陪练人数与陪练页不一致:dashboard=${dashboardCoachMetric?.value}, coaches=${activeCoaches.total}`);
+  if (coaches.liveSourceAvailable !== schedule.liveSourceAvailable || !coaches.liveSourceCount) 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}`);
@@ -79,7 +83,7 @@ try {
 
   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.totalAppointments}, latest=${latestSchedule.appointments.length}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
+  console.log(`Operations smoke passed: users=${users.total}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, 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: [] }));

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

@@ -122,6 +122,9 @@ export interface OperationsDashboardData {
   date: string;
   metrics: OperationsDashboardMetric[];
   alerts: { key: string; label: string; value: number; route: string }[];
+  refreshedAt?: string;
+  liveSourceAvailable?: boolean;
+  liveSourceMessage?: string;
 }
 
 export interface OperationsUser extends Record<string, unknown> {
@@ -194,6 +197,9 @@ export interface CoachSummary extends Record<string, unknown> {
   lastLessonAt: string;
   availabilityConfigured: boolean;
   updatedAt: string;
+  source?: 'hybrid' | 'legacy-live';
+  sourceLabel?: string;
+  readOnly?: boolean;
 }
 
 export interface CoachStoreOption extends Record<string, unknown> {
@@ -205,6 +211,11 @@ export interface CoachStoreOption extends Record<string, unknown> {
 export interface CoachPage extends OperationsPage<CoachSummary> {
   canManage: boolean;
   stores: CoachStoreOption[];
+  liveSourceAvailable?: boolean;
+  liveSourceMessage?: string;
+  liveSourceCount?: number;
+  dataDateFrom?: string;
+  dataDateTo?: string;
 }
 
 export interface ScheduleAppointment extends Record<string, unknown> {

+ 2 - 2
src/app/admin/pages/admin-dashboard.component.html

@@ -10,8 +10,8 @@
 } @else {
   @if (data(); as dashboard) {
   <section class="welcome">
-    <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }}</p></div>
-    <a routerLink="/admin/appointments">查看今日排课<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
+    <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }} · {{dashboard.liveSourceAvailable?'实时排课已同步':'当前使用迁移库备用数据'}}</p></div>
+    <a routerLink="/admin/schedule">查看今日排课<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
   </section>
 
   <section class="metric-grid" aria-label="核心运营指标">

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

@@ -8,10 +8,11 @@
   <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(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
+@if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}
 <section class="content-card">
-  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位</span></header>
+  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · {{page()?.liveSourceAvailable?'已同步实时排课':'迁移库备用数据'}}</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>{{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>}
+    @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username?row.username+' · ':''}}ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small><small class="source-note">{{row.sourceLabel||'新系统账号'}}</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&&!row.readOnly){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions">@if(!row.readOnly){<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>}}@else{<span class="sync-only">实时只读</span>}</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>

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

@@ -54,6 +54,35 @@
   resize: vertical;
 }
 
+.notice.warning {
+  border: 1px solid #f0d6a4;
+  color: #8c5a12;
+  background: #fff8e9;
+}
+
+.data-table .source-note {
+  width: fit-content;
+  margin-top: 5px;
+  padding: 2px 7px;
+  border-radius: 999px;
+  color: #287158;
+  background: #edf7f3;
+  font-size: 10px;
+  font-weight: 750;
+}
+
+.sync-only {
+  display: inline-flex;
+  align-items: center;
+  min-height: 32px;
+  padding: 0 9px;
+  border-radius: 9px;
+  color: #6b7974;
+  background: #f1f4f3;
+  font-size: 11px;
+  white-space: nowrap;
+}
+
 .delete-warning {
   margin: 18px 0 0;
   padding: 12px 14px;