Jelajahi Sumber

fix(ops): load live scheduling data

彭峰 2 bulan lalu
induk
melakukan
8a8f1f299c

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

@@ -2741,8 +2741,30 @@ 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,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) }; }
+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(),source=text(row.source)||'parse'; 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:source==='legacy-live'?false:Boolean(coachId&&!coachObjectId),availabilityWarning:row.availabilityConfigured===false,source,sourceLabel:source==='legacy-live'?'实时业务系统':'迁移数据库',readOnly:source==='legacy-live',updatedAt:dateIso(row.commonUpdatedAt||row.updatedAt||row.CreateTime) }; }
 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); }
+function parseLegacyPayload(value) { let parsed=value; if(typeof parsed==='string'){try{parsed=JSON.parse(parsed);}catch(_){return[];}} return Array.isArray(parsed)?parsed:[]; }
+async function legacyScheduleConfig() { const config=await Parse.Config.get({useMasterKey:true}); return { url:text(config.get('legacyScheduleApiUrl')),apiId:text(config.get('legacyScheduleApiId')),apiKey:text(config.get('legacyScheduleApiKey')) }; }
+async function legacyScheduleRequest(config,params) {
+  const query=Object.entries({...params,apiId:config.apiId,apiKey:config.apiKey,action:'content_list'}).map(([key,value])=>encodeURIComponent(key)+'='+encodeURIComponent(String(value))).join('&'),url=config.url+(config.url.includes('?')?'&':'?')+query;
+  let payload;
+  if(typeof fetch==='function'){const response=await fetch(url,{method:'GET',headers:{accept:'application/json'}});if(!response.ok)throw new Error('实时排课接口返回 HTTP '+response.status);payload=await response.json();}
+  else if(Parse.Cloud&&typeof Parse.Cloud.httpRequest==='function'){const response=await Parse.Cloud.httpRequest({url,method:'GET',headers:{accept:'application/json'}});payload=typeof response.data==='object'?response.data:JSON.parse(text(response.text));}
+  else throw new Error('云函数运行环境不支持外部请求');
+  if(number(payload&&payload.retcode)===-1)throw new Error(text(payload.retmsg)||'实时排课接口拒绝请求');
+  return {items:parseLegacyPayload(payload&&payload.result),page:payload&&payload.page&&typeof payload.page==='object'?payload.page:{}};
+}
+async function liveAppointmentRows(input) {
+  const from=text(input.dateFrom||new Date().toISOString().slice(0,10)),config=await legacyScheduleConfig();
+  if(!config.url||!config.apiId||!config.apiKey)return{available:false,message:'实时排课数据源尚未配置',items:[],total:0,allItems:[]};
+  try {
+    const overall=await legacyScheduleRequest(config,{nid:29,dateFilter:'2000-01-01',psize:1,cpage:1}),raw=[],pageSize=1000;let total=number(overall.page.itemCount),pageCount=1;
+    for(let page=1;page<=Math.min(20,pageCount);page++){const result=await legacyScheduleRequest(config,{nid:29,dateFilter:from,psize:pageSize,cpage:page});raw.push(...result.items);total=Math.max(total,number(result.page.itemCount),raw.length);pageCount=Math.max(1,number(result.page.pageCount,Math.ceil(total/pageSize)));if(result.items.length<pageSize&&page>=pageCount)break;}
+    const seen=new Set(),allItems=[];
+    for(const row of raw){const generalId=number(row.GeneralID||row.generalId),key=generalId?'general:'+generalId:[row.yysj,row.szyh,row.pl,row.kcid].map(text).join('|');if(seen.has(key))continue;seen.add(key);allItems.push(appointmentProjection({...row,targetId:'legacy-live:'+generalId,generalId,studentRealName:row.Title,courseTitle:row.Subtitle,commonStatus:99,commonUpdatedAt:row.CreateTime,source:'legacy-live'}));}
+    return{available:true,message:'',items:allItems,total,allItems};
+  } catch(error) { return{available:false,message:'实时排课数据暂时读取失败,当前显示迁移数据库中的备用数据',items:[],total:0,allItems:[]}; }
+}
 async function newClassObjects(className,context,input,limit=1000) { try { const fields=await fieldsOf(className).catch(()=>({})); if(!Object.keys(fields).length)return[]; const query=new Parse.Query(className); const company=companyPointer(context,input); if(company&&fields.company) query.equalTo('company',company); query.limit(limit); query.descending(fields.createdAt?'createdAt':'objectId'); return await query.find({useMasterKey:true}); } catch(error) { if(error.code===100||error.code===103||/class|schema|column .* does not exist/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)})); }
 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}]; }
@@ -2841,7 +2863,15 @@ async function appointmentCountsForBusinessDate(context,input,date=businessDate(
 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 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 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 handler(request,response) {
   try {

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

@@ -216,6 +216,9 @@ export interface ScheduleAppointment extends Record<string, unknown> {
   cancellationReason: string;
   coachAccountMissing: boolean;
   availabilityWarning: boolean;
+  source: 'parse' | 'legacy-live';
+  sourceLabel: string;
+  readOnly: boolean;
   updatedAt: string;
 }
 
@@ -226,6 +229,9 @@ export interface ScheduleCalendarData {
   totalAppointments: number;
   dataDateFrom: string;
   dataDateTo: string;
+  liveSourceAvailable: boolean;
+  liveSourceMessage: string;
+  liveSourceCount: number;
   coaches: CoachSummary[];
   appointments: ScheduleAppointment[];
   unassigned: ScheduleAppointment[];

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

@@ -21,6 +21,7 @@
 
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+@if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
 
 <section class="summary-grid">
   <article class="summary-card"><span>当前范围课程</span><strong>{{activeAppointments()}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
@@ -45,12 +46,12 @@
   </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}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</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>}
+      @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}}@if(item.readOnly){<small class="live-source">实时数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions">@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{<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" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}}</div></div>}
+      @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" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}@if(item.readOnly){ · 实时}</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" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}</div></div>}
     </div></div>

+ 10 - 0
src/app/admin/pages/operations-schedule.component.scss

@@ -164,6 +164,16 @@ textarea:focus-visible {
   text-align: left;
 }
 
+.live-source,
+.read-only-label {
+  display: block;
+  margin-top: 4px;
+  color: var(--schedule-green);
+  font-size: 11px;
+  font-style: normal;
+  white-space: nowrap;
+}
+
 .schedule-content-header {
   flex-wrap: wrap;
   gap: 12px 20px;

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

@@ -64,7 +64,7 @@ export class OperationsScheduleComponent implements OnInit {
   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.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);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.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);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);}
+  openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);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);}
   closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
   openSearch(field:SearchField):void{this.activeSearch.set(field);}
   updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.openSearch('member');}
@@ -78,5 +78,5 @@ export class OperationsScheduleComponent implements OnInit {
   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()};}
   async preflight():Promise<void>{this.error.set('');if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能排课;请先补齐该会员的迁移标识');return;}try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
   async save():Promise<void>{if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing();await this.operations.mutate(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),this.payload(),editing?.updatedAt||'');this.message.set(editing?'排课已更新':'排课已创建');this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
-  async action(item:ScheduleAppointment,operation:string):Promise<void>{const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
+  async action(item:ScheduleAppointment,operation:string):Promise<void>{if(item.readOnly){this.message.set('实时业务数据暂不支持从新后台修改,请在原业务端完成操作。');return;}const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
 }