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

fix(teaching): restore same-day lesson start with legacy servers

彭峰 1 день назад
Родитель
Сommit
ecf7098c2b

+ 10 - 0
docs/features/lesson-workflow-2026-10-11.md

@@ -52,3 +52,13 @@ npm run start:admin   # http://localhost:4300/admin/schedule
 6. 手机体验、前端实际部署版本、三端浏览器验收和账务对账分别记录。满足这些条件后才可称为全流程上线完成。
 
 复跑本地检查:`npm run test:lesson-workflow`、`npm run test:member-enrollment`、`npm run test:payroll-integrity`、`npm run test:mobile:ci`、`npm run cloud:validate`、`npm run build`。
+
+## 同日开课问题复核与修正(2026-10-11 晚)
+
+在已登录的本地教师端复现:当天 14:00、16:00、21:00 的课都被按计划结束时刻标成“计划未完成”;详情能打开,但旧接口缺少 `allowedActions` 时,前端把原有开课动作一并禁用了。
+
+- 课表按北京时间的预约日期判断过期,当天始终保留“未开始/已开始”。点击直接打开详情,避免等待学员资料请求;详情负责绑定当前预约学员。
+- 成功取得编号一致的服务端详情后,旧接口仅兼容该课次老师的 `start/continue`:未开始课程限预约当天,已开始课程允许跨午夜继续。开课仍通过原接口再次校验,不能从缓存推导写权限;新接口明确返回的权限不被覆盖。
+- 详情显示当天可提前或延后的规则;未选单元时保留明确错误提示。新结课和评价功能仍需后续发布,不因兼容开课而放宽。
+- 本地浏览器已确认三节课程状态修正,以及 21:00 课程可显示开课按钮、未选单元时正确提示。未对真实课次提交开始、结课或扣费操作。
+- 移动端全量 181 项通过;其中流程专项 19 项覆盖旧接口兼容、同日早晚、跨午夜、非当天、其他老师、取消、编号不符、后台刷新失败和服务器拒绝。开课写入及课堂跳转使用隔离接口替身验证,真实三端写入验收仍未执行。

+ 10 - 0
projects/xiaoshu-mobile/src/app/core/appointment-time.ts

@@ -0,0 +1,10 @@
+/** Appointment dates are business dates in Beijing, regardless of the device timezone. */
+export function beijingDate(at = Date.now()): string {
+  return new Date(at + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
+}
+
+export function appointmentDate(order: Record<string, unknown>): string {
+  const raw = String(order['bxrq'] || order['yydate'] || order['yysj'] || '');
+  const match = raw.match(/^(\d{4})[-/]?(\d{2})[-/]?(\d{2})(?:$|[ T])/);
+  return match ? `${match[1]}-${match[2]}-${match[3]}` : '';
+}

+ 51 - 0
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -9,6 +9,7 @@ import { CourseCenterPageComponent } from '../features/course-center/course-cent
 import { ApiService, ApiError } from './api.service';
 import { SessionService } from './session.service';
 import { PlatformService } from './platform.service';
+import { appointmentDate, beijingDate } from './appointment-time';
 
 @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
 
@@ -63,6 +64,56 @@ describe('appointment workflow screens',()=>{
   (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
   harness.fixture.detectChanges();expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);expect(component.status()).toBe(10);
  });
+ for (const time of ['00:01', '23:59']) {
+  it(`starts a same-day lesson scheduled at ${time} without new server capabilities`,async()=>{
+   order={...order,bxrq:beijingDate(),sdsd:time};delete order['allowedActions'];
+   const {harness,component}=await screen();
+   expect(component.can('start')).toBeTrue();expect(component.can('complete')).toBeFalse();
+   expect(harness.routeNativeElement?.textContent).toContain('可提前开始');
+   component.toggleUnit({NodeID:811});component.startOrContinue();await harness.fixture.whenStable();
+   expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);
+   expect(TestBed.inject(Router).url).toContain('/pages/stu/stu_abc');
+   expect(student()).toEqual(jasmine.objectContaining({userId:20}));
+  });
+ }
+ it('keeps all of today’s unfinished lessons out of the overdue filter and opens details immediately',async()=>{
+  order={...order,bxrq:beijingDate(),sdsd:'00:00'};
+  const {harness}=await screen('/pages/home/shop');
+  const center=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+  expect(center.statusText(order)).toBe('未开始');
+  expect(center.statusText({...order,bxrq:'2000-01-01'})).toBe('计划未完成');
+  expect(center.statusText({...order,dszt:10})).toBe('已开始');
+  center.open(order);await harness.fixture.whenStable();
+  expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
+  expect(calls.some(c=>c.action==='user_get')).toBeFalse();
+ });
+ it('allows an already started legacy lesson to continue across midnight',async()=>{
+  order={...order,dszt:10,bxrq:'2000-01-01'};delete order['allowedActions'];
+  const {component}=await screen();expect(component.can('continue')).toBeTrue();expect(component.can('start')).toBeFalse();
+ });
+ for (const changes of [{bxrq:'2000-01-01'}, {bxrq:'2099-10-11'}, {pl:31}, {cancelled:true}, {GeneralID:902}]) {
+  it(`does not infer start permission for an ineligible legacy detail ${JSON.stringify(changes)}`,async()=>{
+   order={...order,bxrq:beijingDate(),...changes};delete order['allowedActions'];
+   const {component}=await screen();component.startOrContinue();
+   expect(component.can('start')).toBeFalse();expect(calls.some(c=>c.action==='e_order_update_v2')).toBeFalse();
+  });
+ }
+ it('does not override an explicit server denial on today’s lesson',async()=>{
+  order={...order,bxrq:beijingDate(),allowedActions:[],blockedReason:'课时不足'};
+  const {component}=await screen();expect(component.can('start')).toBeFalse();
+ });
+ it('disables legacy start again when a background detail refresh fails',async()=>{
+  order={...order,bxrq:beijingDate()};delete order['allowedActions'];
+  const {component}=await screen();expect(component.can('start')).toBeTrue();
+  failDetail=true;component.load(true);expect(component.can('start')).toBeFalse();
+ });
+ it('uses Beijing midnight boundaries and accepts the legacy compact business date',()=>{
+  expect(beijingDate(Date.parse('2026-10-10T15:59:59Z'))).toBe('2026-10-10');
+  expect(beijingDate(Date.parse('2026-10-10T16:00:00Z'))).toBe('2026-10-11');
+  expect(beijingDate(Date.parse('2026-10-11T15:59:59Z'))).toBe('2026-10-11');
+  expect(beijingDate(Date.parse('2026-10-11T16:00:00Z'))).toBe('2026-10-12');
+  expect(appointmentDate({bxrq:'20261011'})).toBe('2026-10-11');
+ });
  it('shows a persistent reason when vocabulary has not been selected, then enters the bound classroom',async()=>{
   const {harness,component}=await screen();component.startOrContinue();harness.fixture.detectChanges();
   expect(harness.routeNativeElement?.querySelector('[role="alert"]')?.textContent).toContain('选择');

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -11,6 +11,7 @@
     @if (detailWarning()) { <p class="detail-warning" role="alert">{{ detailWarning() }}</p> }
     @if(actionError()){<p class="detail-warning" role="alert">{{actionError()}}</p>}
     @if(order()['blockedReason']){<p class="detail-warning">{{order()['blockedReason']}}</p>}
+    @if (can('start')) { <p>预约当天(北京时间)均可上课,可提前开始,也可在计划时间之后开始。</p> }
     <button type="button" class="secondary-button" (click)="load()" [disabled]="submitting()">刷新详情</button>
 
     <dl class="appointment-info">

+ 22 - 2
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -10,6 +10,7 @@ import { ApiError, ApiService } from '../../core/api.service';
 import { PageMeta } from '../../core/models';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
+import { appointmentDate, beijingDate } from '../../core/appointment-time';
 
 type SourceItem = Record<string, unknown>;
 
@@ -101,8 +102,11 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     this.loadRequest = this.api.get<unknown>('e_order_detail', { id: this.orderId }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
         const detail = this.findList(result)[0] ?? this.asRecord(result);
-        this.detailVerified.set(Array.isArray(detail['allowedActions']));
-        if (!this.detailVerified()) this.detailWarning.set('课程服务尚未更新,请联系管理员完成发布后刷新。当前课程仅供查看。');
+        this.detailVerified.set(String(detail['GeneralID'] ?? '') === this.orderId);
+        if (!this.detailVerified()) this.detailWarning.set('未能核实预约详情,请刷新后重试。当前课程仅供查看。');
+        // Older servers already support starting lessons. Missing new workflow metadata
+        // must not disable that endpoint; never derive write access from the cached row.
+        if (!Array.isArray(detail['allowedActions'])) Object.assign(detail, this.legacyStartActions(detail));
         if (background) { this.order.set({...this.order(),...detail}); return; }
         this.applyOrder({ ...fallback, ...detail });
       },
@@ -120,6 +124,22 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     });
   }
 
+  private legacyStartActions(detail: SourceItem): SourceItem {
+    const result: SourceItem = { allowedActions: [], blockedReason: '' };
+    if (!this.detailVerified() || this.isStudentView || detail['cancelled']) return result;
+    const teacherId = Number(detail['pl']);
+    if (!teacherId || teacherId !== this.session.user()?.userId) {
+      return { ...result, blockedReason: '仅本节课的老师可以开始或继续课程' };
+    }
+    const status = Number(detail['dszt'] ?? detail['status']);
+    if (status === 10) return { ...result, allowedActions: ['continue'] };
+    if (status !== 0) return result;
+    const date = appointmentDate(detail);
+    if (!date) return { ...result, blockedReason: '预约日期不完整,请联系管理员核对排课' };
+    if (date !== beijingDate()) return { ...result, blockedReason: '课程仅在预约当天(北京时间)可开始,其他日期请联系管理员改期' };
+    return { ...result, allowedActions: ['start'] };
+  }
+
   field(label: string): string {
     const aliases: Record<string, string[]> = {
       student: ['HoneyName', 'honeyName', 'honeyname', 'xymz_t'],

+ 6 - 24
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -1,4 +1,5 @@
 import { DropdownDirective } from '../../shared/dropdown.directive';
+import { beijingDate } from '../../core/appointment-time';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, NgZone, OnDestroy, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
@@ -70,13 +71,13 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly schedules = signal<Item[]>([]);
   readonly scheduleFilter = signal<ScheduleFilter>('all');
   readonly scheduleRange = signal<ScheduleRange>('day');
+  readonly now = signal(Date.now());
   readonly selectedDate = signal(this.today());
   readonly selectedStudent = signal('all');
   readonly allStudentCount = computed(() => this.studentOptions().reduce((sum, student) => sum + student.count, 0));
   readonly historyMonth = signal('all');
   readonly historyStudentQuery = signal('');
   readonly calendarMonth = signal(this.firstOfMonth(new Date()));
-  readonly now = signal(Date.now());
   readonly loading = signal(false);
   readonly liveChecking = signal(false);
   readonly refreshedAt = signal('');
@@ -360,7 +361,9 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       void this.router.navigate(['/pages/shop/stu_get_order'], { queryParams: { id: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
       return;
     }
-    this.loadStudentAndNavigate(item, '/pages/shop/get_order', 'id');
+    // The verified detail loads the appointment's student; a profile request must not delay opening it.
+    this.storeAppointmentSnapshot(item);
+    void this.router.navigate(['/pages/shop/get_order'], { queryParams: { id: this.scheduleId(item) }, state: { appointmentSnapshot: item } });
   }
 
   clearScheduleFilters(): void {
@@ -533,8 +536,6 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
 
   private isOverdueUnfinished(item: Item): boolean {
     if (!this.isPendingRaw(item)) return false;
-    const end = this.lessonEnd(item);
-    if (end) return end.getTime() < this.now();
     const date = this.itemDate(item);
     return Boolean(date && date < this.today());
   }
@@ -553,24 +554,6 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return new Date(year, month - 1, day, time.hour, time.minute);
   }
 
-  private lessonEnd(item: Item): Date | null {
-    const start = this.lessonStart(item);
-    if (!start) return null;
-    return new Date(start.getTime() + this.lessonMinutes(item) * 60_000);
-  }
-
-  private lessonMinutes(item: Item): number {
-    if (Number(item['durationMinutes'])>0) return Number(item['durationMinutes']);
-    const text = `${item['Title'] ?? ''} ${item['Title2'] ?? ''} ${item['Subtitle'] ?? ''}`;
-    const matched = text.match(/(\d{1,3})\s*分钟/);
-    if (matched) return Number(matched[1]);
-    const type = Number(item['dslx'] ?? 0);
-    if (type === 1) return 30;
-    if (type === 2) return 60;
-    if (type === 3) return 60;
-    return 0;
-  }
-
   private timeParts(value: unknown): { hour: number; minute: number } | null {
     const matched = String(value ?? '').match(/(?:^|\s)(\d{1,2}):(\d{2})/);
     if (!matched) return null;
@@ -643,8 +626,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   }
 
   today(): string {
-    const now = new Date();
-    return this.dateKey(now);
+    return beijingDate(this.now());
   }
 
   tomorrow(): string {