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

feat(admin): professionalize operations scheduling and payroll

彭峰 1 месяц назад
Родитель
Сommit
c685326dfb

Разница между файлами не показана из-за своего большого размера
+ 9 - 8
scripts/deploy-admin-functions.mjs


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

@@ -51,6 +51,21 @@ try {
   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;
   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;
+  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;
+  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;
+  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;
+  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;
+  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;
@@ -59,7 +74,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}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
+  console.log(`Operations smoke passed: users=${users.total}, coaches=${coaches.total}, appointments=${schedule.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: [] }));

+ 5 - 13
src/app/admin/admin.constants.ts

@@ -14,19 +14,11 @@ export interface AdminNavigationGroup { label: string; items: readonly AdminNavi
 
 export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
   { label: '运营总览', items: [{ label: '工作台', icon: 'layout-dashboard', route: '/admin/dashboard' }] },
-  { label: '用户运营', items: [
-    { label: '用户列表', icon: 'users', route: '/admin/users' },
-    { label: '陪练与代理关系', icon: 'network', route: '/admin/relations' },
-  ] },
-  { label: '教学运营', items: [
-    { label: '用户课程绑定', icon: 'book-open', route: '/admin/course-bindings' },
-    { label: '预约排课', icon: 'calendar-days', route: '/admin/appointments' },
-    { label: '上课记录', icon: 'graduation-cap', route: '/admin/lessons' },
-    { label: '每日学习记录', icon: 'chart-no-axes-combined', route: '/admin/learning-records' },
-  ] },
-  { label: '财务与课时', items: [
-    { label: '用户课时与余额', icon: 'wallet-cards', route: '/admin/finance' },
-    { label: '资金积分流水', icon: 'receipt-text', route: '/admin/money-logs' },
+  { label: '核心运营', items: [
+    { label: '会员管理', icon: 'users', route: '/admin/members' },
+    { label: '排课中心', icon: 'calendar-days', route: '/admin/schedule' },
+    { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
+    { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
   ] },
   { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
 ];

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

@@ -152,6 +152,10 @@ export interface OperationsUser extends Record<string, unknown> {
   statusLabel: string;
   canRevealMobile: boolean;
   updatedAt: string;
+  nickname?: string;
+  lastLessonAt?: string;
+  nextAppointmentAt?: string;
+  riskFlags?: string[];
 }
 
 export interface OperationsUserDetail extends OperationsUser {
@@ -167,3 +171,135 @@ export interface OperationsUserDetail extends OperationsUser {
   relation: { parentUserId: number; parentDisplayName: string };
   permissions: { canManage: boolean; canAdjustBalance: boolean; canRevealMobile: boolean };
 }
+
+export interface CoachSummary extends Record<string, unknown> {
+  objectId: string;
+  userId: number;
+  displayName: string;
+  mobile: string;
+  mobileMasked: string;
+  storeName: string;
+  status: 'active' | 'disabled';
+  statusLabel: string;
+  todayAppointments: number;
+  pendingAppointments: number;
+  monthLessons: number;
+  monthHours: number;
+  estimatedPay: number;
+  averageScore: number;
+  lastLessonAt: string;
+  availabilityConfigured: boolean;
+  updatedAt: string;
+}
+
+export interface ScheduleAppointment extends Record<string, unknown> {
+  targetId: string;
+  generalId: number;
+  studentObjectId: string;
+  studentId: number;
+  studentName: string;
+  coachObjectId: string;
+  coachId: number;
+  coachName: string;
+  courseId: number;
+  courseName: string;
+  classType: number;
+  classTypeLabel: string;
+  deliveryMethod: string;
+  startsAt: string;
+  endsAt: string;
+  statusCode: number;
+  statusLabel: string;
+  cancelled: boolean;
+  cancellationReason: string;
+  availabilityWarning: boolean;
+  updatedAt: string;
+}
+
+export interface ScheduleCalendarData {
+  dateFrom: string;
+  dateTo: string;
+  refreshedAt: string;
+  coaches: CoachSummary[];
+  appointments: ScheduleAppointment[];
+  unassigned: ScheduleAppointment[];
+  members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string }>;
+  courses: Array<{ courseId: number; courseName: string }>;
+}
+
+export interface CoachAvailability extends Record<string, unknown> {
+  objectId: string;
+  coachId: number;
+  ruleType: 'weekly' | 'date';
+  weekday: number;
+  date: string;
+  startTime: string;
+  endTime: string;
+  available: boolean;
+  reason: string;
+  updatedAt: string;
+}
+
+export interface CoachDetailData {
+  coach: CoachSummary;
+  availability: CoachAvailability[];
+  appointments: ScheduleAppointment[];
+  lessons: PayrollLine[];
+  payrolls: PayrollSummary[];
+  reviewTaskCount: number;
+}
+
+export interface PayRate extends Record<string, unknown> {
+  objectId: string;
+  classType: number;
+  classTypeLabel: string;
+  amount: number;
+  durationHours: number;
+  effectiveFrom: string;
+  active: boolean;
+  isDefault?: boolean;
+  updatedAt: string;
+}
+
+export type PayrollStatus = 'draft' | 'reviewed' | 'paid';
+
+export interface PayrollSummary extends Record<string, unknown> {
+  objectId: string;
+  month: string;
+  status: PayrollStatus;
+  statusLabel: string;
+  coachCount: number;
+  lessonCount: number;
+  totalHours: number;
+  baseAmount: number;
+  adjustmentAmount: number;
+  payableAmount: number;
+  exceptionCount: number;
+  reviewedAt: string;
+  paidAt: string;
+  paymentMethod: string;
+  paymentReference: string;
+  updatedAt: string;
+}
+
+export interface PayrollLine extends Record<string, unknown> {
+  objectId: string;
+  kind: 'lesson' | 'adjustment';
+  coachId: number;
+  coachName: string;
+  lessonId: string;
+  lessonAt: string;
+  classType: number;
+  classTypeLabel: string;
+  durationHours: number;
+  rateAmount: number;
+  amount: number;
+  description: string;
+  exception: string;
+  excluded?: boolean;
+}
+
+export interface PayrollDetail extends PayrollSummary {
+  lines: PayrollLine[];
+  exceptions: PayrollLine[];
+}

+ 9 - 9
src/app/admin/admin.routes.ts

@@ -4,15 +4,15 @@ import { adminGuard, superAdminGuard } from './admin.guard';
 const operationsChildren: Routes = [
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
   { path: 'dashboard', loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
-  { path: 'users', loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
-  { path: 'users/:objectId', loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
-  { path: 'relations', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'relations' } },
-  { path: 'course-bindings', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'course-bindings' } },
-  { path: 'appointments', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'appointments' } },
-  { path: 'lessons', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'lessons' } },
-  { path: 'learning-records', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'learning-records' } },
-  { path: 'finance', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'finance' } },
-  { path: 'money-logs', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'money-logs' } },
+  { path: 'users', pathMatch: 'full', redirectTo: 'members' },
+  { path: 'users/:objectId', redirectTo: 'members/:objectId' },
+  { path: 'members', loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
+  { path: 'members/:objectId', loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
+  { path: 'schedule', loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
+  { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
+  { path: 'coaches', loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
+  { path: 'coaches/:objectId', loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
+  { path: 'payroll', loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
   { path: 'change-password', loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
   { path: 'forbidden', loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
 ];

+ 14 - 0
src/app/admin/operations.service.spec.ts

@@ -25,4 +25,18 @@ describe('OperationsService', () => {
     expect(functions.operations.calls.argsFor(0)[0]).toBe('ops/course-bindings/list');
     expect(functions.operations.calls.argsFor(1)[0]).toBe('ops/appointments/list');
   });
+
+  it('loads the professional schedule projection with a coach filter', async () => {
+    functions.operations.and.resolveTo({ dateFrom: '2026-08-20', dateTo: '2026-08-26', refreshedAt: '', coaches: [], appointments: [], unassigned: [], members: [], courses: [] });
+    await service.schedule('2026-08-20', '2026-08-26', 88);
+    expect(functions.operations).toHaveBeenCalledWith('ops/schedule/calendar', { dateFrom: '2026-08-20', dateTo: '2026-08-26', filters: { coachId: 88 } });
+  });
+
+  it('adds concurrency and idempotency metadata to every mutation', async () => {
+    functions.operations.and.resolveTo({ success: true });
+    await service.mutate('ops/appointments/update', 'appointment-1', '运营改期', { coachId: 3 }, '2026-08-20T08:00:00.000Z', 'stable-key');
+    expect(functions.operations).toHaveBeenCalledWith('ops/appointments/update', {
+      targetId: 'appointment-1', reason: '运营改期', payload: { coachId: 3 }, expectedUpdatedAt: '2026-08-20T08:00:00.000Z', idempotencyKey: 'stable-key'
+    });
+  });
 });

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

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachSummary, OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -12,6 +12,10 @@ export class OperationsService {
     return this.functions.operations<OperationsDashboardData>('ops/dashboard/summary');
   }
 
+  request<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
+    return this.functions.operations<T>(operation, params);
+  }
+
   users(page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}, refresh = false): Promise<OperationsPage<OperationsUser>> {
     return this.functions.operations<OperationsPage<OperationsUser>>('ops/users/list', { page, pageSize, search, filters, refresh });
   }
@@ -20,6 +24,38 @@ export class OperationsService {
     return this.functions.operations<OperationsUserDetail>('ops/users/detail', { objectId });
   }
 
+  coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<CoachSummary>> {
+    return this.functions.operations<OperationsPage<CoachSummary>>('ops/coaches/list', { page, pageSize, search, filters });
+  }
+
+  coachDetail(objectId: string): Promise<CoachDetailData> {
+    return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId });
+  }
+
+  schedule(dateFrom: string, dateTo: string, coachId = 0): Promise<ScheduleCalendarData> {
+    return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId } });
+  }
+
+  availability(coachId: number): Promise<OperationsPage<CoachAvailability>> {
+    return this.functions.operations<OperationsPage<CoachAvailability>>('ops/coach-availability/list', { page: 1, pageSize: 100, filters: { coachId } });
+  }
+
+  payRates(): Promise<OperationsPage<PayRate>> {
+    return this.functions.operations<OperationsPage<PayRate>>('ops/pay-rates/list', { page: 1, pageSize: 100 });
+  }
+
+  payrolls(page = 1, pageSize = 20): Promise<OperationsPage<PayrollSummary>> {
+    return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize });
+  }
+
+  payrollPreview(month: string): Promise<PayrollDetail> {
+    return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
+  }
+
+  payrollDetail(targetId: string): Promise<PayrollDetail> {
+    return this.functions.operations<PayrollDetail>('ops/payroll/detail', { targetId });
+  }
+
   list(kind: OperationsListKind, page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}): Promise<OperationsPage> {
     const operation = kind === 'relations' ? 'ops/relations/list'
       : kind === 'course-bindings' ? 'ops/course-bindings/list'
@@ -31,7 +67,7 @@ export class OperationsService {
     return this.functions.operations<OperationsPage>(operation, { page, pageSize, search, filters });
   }
 
-  mutate<T>(operation: string, targetId: string, reason: string, payload: Record<string, unknown> = {}, expectedUpdatedAt = ''): Promise<T> {
-    return this.functions.operations<T>(operation, { targetId, reason, payload, expectedUpdatedAt });
+  mutate<T>(operation: string, targetId: string, reason: string, payload: Record<string, unknown> = {}, expectedUpdatedAt = '', idempotencyKey = ''): Promise<T> {
+    return this.functions.operations<T>(operation, { targetId, reason, payload, expectedUpdatedAt, idempotencyKey: idempotencyKey || crypto.randomUUID() });
   }
 }

+ 30 - 0
src/app/admin/pages/admin-dashboard.component.spec.ts

@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AdminSessionService } from '../admin-session.service';
+import { OperationsService } from '../operations.service';
+import { AdminDashboardComponent } from './admin-dashboard.component';
+
+describe('AdminDashboardComponent', () => {
+  let fixture: ComponentFixture<AdminDashboardComponent>;
+  let operations: jasmine.SpyObj<OperationsService>;
+  let sessions: jasmine.SpyObj<AdminSessionService>;
+
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['dashboard']);
+    sessions = jasmine.createSpyObj<AdminSessionService>('AdminSessionService', ['applyIdentity']);
+    operations.dashboard.and.resolveTo({
+      identity: { objectId: 'admin-1', username: 'ops', displayName: '运营经理', roles: ['admin', 'ops-manager'], isSuperAdmin: false, operationsRole: 'ops-manager' },
+      date: '2026-08-20', metrics: [], alerts: []
+    });
+    await TestBed.configureTestingModule({ imports: [AdminDashboardComponent], providers: [
+      { provide: OperationsService, useValue: operations },
+      { provide: AdminSessionService, useValue: sessions }
+    ] }).compileComponents();
+    fixture = TestBed.createComponent(AdminDashboardComponent);
+  });
+
+  it('refreshes the stored operations role from the cloud identity', async () => {
+    fixture.detectChanges();
+    await fixture.whenStable();
+    expect(sessions.applyIdentity).toHaveBeenCalledWith(jasmine.objectContaining({ operationsRole: 'ops-manager' }));
+  });
+});

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

@@ -4,6 +4,7 @@ import { RouterLink } from '@angular/router';
 import { ArrowRight, CircleAlert, LoaderCircle, LucideAngularModule, RefreshCw } from 'lucide-angular';
 import { OperationsDashboardData } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { AdminSessionService } from '../admin-session.service';
 
 @Component({
   selector: 'app-admin-dashboard',
@@ -19,10 +20,11 @@ export class AdminDashboardComponent implements OnInit {
   readonly error = signal('');
   readonly icons = { ArrowRight, CircleAlert, LoaderCircle, RefreshCw };
   private readonly operations = inject(OperationsService);
+  private readonly sessions = inject(AdminSessionService);
   ngOnInit(): void { void this.load(); }
   async load(): Promise<void> {
     this.loading.set(true); this.error.set('');
-    try { this.data.set(await this.operations.dashboard()); }
+    try { const data = await this.operations.dashboard(); this.data.set(data); this.sessions.applyIdentity(data.identity); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '运营工作台加载失败'); }
     finally { this.loading.set(false); }
   }

Разница между файлами не показана из-за своего большого размера
+ 2 - 0
src/app/admin/pages/operations-coach-detail.component.html


+ 15 - 0
src/app/admin/pages/operations-coach-detail.component.ts

@@ -0,0 +1,15 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { ArrowLeft, LucideAngularModule, Plus, RefreshCw, Trash2 } from 'lucide-angular';
+import { CoachDetailData } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+@Component({selector:'app-operations-coach-detail',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+export class OperationsCoachDetailComponent implements OnInit{
+ readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'availability'|'lessons'|'payroll'>('schedule');readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly icons={ArrowLeft,Plus,RefreshCw,Trash2};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
+ ngOnInit():void{void this.load();}async load():Promise<void>{this.loading.set(true);this.error.set('');try{this.data.set(await this.operations.coachDetail(this.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{this.loading.set(false);}}
+ async saveAvailability():Promise<void>{const coach=this.data()?.coach;if(!coach)return;this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/save','',this.ruleReason(),{coachId:coach.userId,ruleType:this.ruleType(),weekday:this.weekday(),date:this.ruleDate(),startTime:this.startTime(),endTime:this.endTime(),available:this.available()});this.message.set('可用时间已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'可用时间保存失败');}finally{this.saving.set(false);}}
+ async removeAvailability(objectId:string,updatedAt:string):Promise<void>{this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/delete',objectId,'删除错误的可用时间规则',{},updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
+}

+ 4 - 0
src/app/admin/pages/operations-coaches.component.html

@@ -0,0 +1,4 @@
+<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>

+ 14 - 0
src/app/admin/pages/operations-coaches.component.ts

@@ -0,0 +1,14 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterLink } from '@angular/router';
+import { ChevronLeft, ChevronRight, Eye, GraduationCap, LucideAngularModule, RefreshCw, Search } from 'lucide-angular';
+import { CoachSummary, OperationsPage } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+@Component({selector:'app-operations-coaches',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+export class OperationsCoachesComponent implements OnInit{
+ readonly page=signal<OperationsPage<CoachSummary>|null>(null);readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly error=signal('');readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,RefreshCw,Search};private readonly operations=inject(OperationsService);
+ ngOnInit():void{void this.load(1);}async load(page=1):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()}));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);}}
+ reset():void{this.query.set('');this.status.set('all');void this.load(1);}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
+}

+ 71 - 0
src/app/admin/pages/operations-payroll.component.html

@@ -0,0 +1,71 @@
+<header class="page-header">
+  <div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按有效上课记录生成月度工资,完成复核、锁定和发放登记</span></div>
+  <div class="header-actions"><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新</button></div>
+</header>
+
+@if(message()){<p class="notice" role="status">{{message()}}</p>}
+@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+
+<nav class="tabs" aria-label="工资结算功能">
+  <button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />月度工资</button>
+  <button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />计价规则</button>
+</nav>
+
+@if(tab()==='rates'){
+  <section class="content-card">
+    <header><strong>按生效日期管理课酬</strong><span>历史工资保存单价快照,不受后续改价影响</span></header>
+    @if(isManager()){
+      <div class="panel"><div class="form-grid">
+        <label>课程类型<select [ngModel]="rateType()" (ngModelChange)="rateType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
+        <label>每节课酬<input type="number" min="0" step="0.01" [ngModel]="rateAmount()" (ngModelChange)="rateAmount.set(+$event)" /></label>
+        <label>计薪工时<input type="number" min="0" step="0.5" [ngModel]="rateHours()" (ngModelChange)="rateHours.set(+$event)" /></label>
+        <label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label>
+        <button class="primary" type="button" (click)="saveRate()" [disabled]="saving()">保存规则</button>
+      </div></div>
+    }
+    <div class="table-scroll"><table class="data-table"><thead><tr><th>课型</th><th>每节课酬</th><th>计薪工时</th><th>生效日期</th><th>状态</th></tr></thead><tbody>
+      @for(rate of rates();track rate.objectId){<tr><td>{{rate.classTypeLabel}}</td><td class="money">¥{{rate.amount|number:'1.2-2'}}</td><td>{{rate.durationHours}}h</td><td>@if(rate.isDefault){系统默认}@else { {{rate.effectiveFrom|date:'yyyy-MM-dd'}} }</td><td><span class="badge">{{rate.active?'生效':'停用'}}</span>@if(rate.isDefault){<small>原系统默认规则</small>}</td></tr>}
+      @empty{<tr><td colspan="5"><div class="empty">暂无计价规则</div></td></tr>}
+    </tbody></table></div>
+  </section>
+} @else {
+  <section class="toolbar">
+    <label><span>工资月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label>
+    <button type="button" (click)="preview()">预览工资</button>
+    @if(isManager()){<button class="primary" type="button" (click)="create()" [disabled]="saving()"><lucide-icon [img]="icons.Plus" [size]="17" />生成草稿</button>}
+  </section>
+  <section class="content-card">
+    <header><strong>工资批次</strong><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></tr></thead><tbody>
+      @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}
+      @empty{<tr><td colspan="10"><div class="empty">尚未生成工资批次,可先选择月份预览</div></td></tr>}
+    </tbody></table></div>
+  </section>
+}
+
+@if(detail();as batch){
+  <div class="backdrop" (click)="detail.set(null)"></div>
+  <aside class="drawer" role="dialog" aria-modal="true" aria-label="工资单详情">
+    <header><div><p>PAYROLL {{batch.month}}</p><h2>{{batch.statusLabel}} · 应发 ¥{{batch.payableAmount|number:'1.2-2'}}</h2><span>{{batch.lessonCount}} 节 / {{batch.totalHours|number:'1.1-1'}} 小时 / {{batch.coachCount}} 位陪练</span></div><button type="button" aria-label="关闭" (click)="detail.set(null)">×</button></header>
+    @if(batch.exceptionCount){<p class="notice error">存在 {{batch.exceptionCount}} 条异常。可先维护计价规则后重新计算,或填写原因排除异常,处理完才能审核。</p>}
+    <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></tr></thead><tbody>
+      @for(line of batch.lines;track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}</td><td>{{line.lessonAt|date:'MM-dd HH:mm'}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}}</td><td>@if(batch.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button type="button" (click)="excludeLine(line.objectId)">排除异常</button>}</td></tr>}
+      @empty{<tr><td colspan="8"><div class="empty">暂无可计薪记录</div></td></tr>}
+    </tbody></table></div>
+    @if(batch.status==='draft'&&isManager()){
+      <section class="panel"><h3>新增调整项</h3><div class="form-grid"><label>陪练ID<input type="number" min="1" [ngModel]="adjustmentCoachId()" (ngModelChange)="adjustmentCoachId.set(+$event)" /></label><label>调整金额<input type="number" step="0.01" [ngModel]="adjustmentAmount()" (ngModelChange)="adjustmentAmount.set(+$event)" /></label><label class="wide">调整原因<input [ngModel]="adjustmentReason()" (ngModelChange)="adjustmentReason.set($event)" /></label><button type="button" (click)="addAdjustment()">添加调整</button></div></section>
+    }
+    <footer>
+      <button type="button" (click)="exportCsv()"><lucide-icon [img]="icons.Download" [size]="16" />导出CSV</button>
+      @if(isManager()&&batch.objectId){
+        @if(batch.status==='draft'){
+          <button type="button" (click)="refreshDraft()" [disabled]="saving()">按最新规则重算</button>
+          <button class="primary" type="button" (click)="workflow('ops/payroll/review')" [disabled]="batch.exceptionCount>0||saving()">审核并锁定</button>
+        } @else if(batch.status==='reviewed'){
+          <button type="button" (click)="workflow('ops/payroll/reopen')">重新打开</button>
+          <button class="primary" type="button" (click)="workflow('ops/payroll/mark-paid')">标记已发放</button>
+        }
+      }
+    </footer>
+  </aside>
+}

+ 26 - 0
src/app/admin/pages/operations-payroll.component.ts

@@ -0,0 +1,26 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Banknote, Download, Eye, LucideAngularModule, Plus, RefreshCw, Settings2 } from 'lucide-angular';
+import { OperationsPage, PayRate, PayrollDetail, PayrollSummary } from '../admin.models';
+import { AdminSessionService } from '../admin-session.service';
+import { OperationsService } from '../operations.service';
+
+const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
+
+@Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-payroll.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+export class OperationsPayrollComponent implements OnInit{
+ readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'batches'|'rates'>('batches');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly icons={Banknote,Download,Eye,Plus,RefreshCw,Settings2};private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
+ ngOnInit():void{void this.load();}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
+ async load():Promise<void>{this.loading.set(true);this.error.set('');try{const [page,rates]=await Promise.all([this.operations.payrolls(),this.operations.payRates()]);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}}
+ async preview():Promise<void>{this.loading.set(true);this.error.set('');try{this.detail.set(await this.operations.payrollPreview(this.month()));}catch(error){this.error.set(error instanceof Error?error.message:'工资预览失败');}finally{this.loading.set(false);}}
+ async open(row:PayrollSummary):Promise<void>{this.loading.set(true);try{this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}
+ async create():Promise<void>{this.saving.set(true);try{const result=await this.operations.mutate<PayrollSummary>('ops/payroll/create','',`生成 ${this.month()} 月工资草稿`,{month:this.month()});await this.load();await this.open(result);this.message.set('工资草稿已生成');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
+ async saveRate():Promise<void>{this.saving.set(true);try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
+ async addAdjustment():Promise<void>{const batch=this.detail();if(!batch||!this.adjustmentCoachId()||!this.adjustmentAmount()||!this.adjustmentReason().trim())return;this.saving.set(true);try{await this.operations.mutate('ops/payroll/add-adjustment',batch.objectId,this.adjustmentReason(),{coachId:this.adjustmentCoachId(),amount:this.adjustmentAmount(),description:this.adjustmentReason()},batch.updatedAt);await this.open({...batch});this.adjustmentAmount.set(0);this.adjustmentReason.set('');}catch(error){this.error.set(error instanceof Error?error.message:'调整项保存失败');}finally{this.saving.set(false);}}
+ async refreshDraft():Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.operations.mutate<PayrollDetail>('ops/payroll/refresh',batch.objectId,'根据最新计价规则重新计算工资草稿',{},batch.updatedAt));this.message.set('工资草稿已按最新计价规则重新计算');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'工资草稿重新计算失败');}finally{this.saving.set(false);}}
+ async excludeLine(objectId:string):Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;const reason=globalThis.prompt('请输入排除该异常记录的原因');if(!reason?.trim())return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/exclude-line',objectId,reason,{},'');await this.open({...batch});this.message.set('异常记录已排除并保留审计原因');}catch(error){this.error.set(error instanceof Error?error.message:'异常记录排除失败');}finally{this.saving.set(false);}}
+ async workflow(operation:string):Promise<void>{const batch=this.detail();if(!batch)return;let payload:Record<string,unknown>={};let reason=operation.endsWith('review')?'复核工资明细并锁定':operation.endsWith('reopen')?'发现差异,重新打开工资草稿':'确认工资已线下发放';if(operation.endsWith('mark-paid')){const method=globalThis.prompt('请输入发放方式,例如:银行转账');if(!method)return;const reference=globalThis.prompt('请输入发放凭证编号');if(!reference)return;payload={paymentMethod:method,paymentReference:reference};}this.saving.set(true);try{await this.operations.mutate(operation,batch.objectId,reason,payload,batch.updatedAt);await this.load();await this.open({...batch});}catch(error){this.error.set(error instanceof Error?error.message:'工资流程操作失败');}finally{this.saving.set(false);}}
+ exportCsv():void{const batch=this.detail();if(!batch)return;const rows=[['老师','日期','课型','工时','单价','金额','说明'],...batch.lines.map(line=>[line.coachName,line.lessonAt,line.classTypeLabel,String(line.durationHours),String(line.rateAmount),String(line.amount),line.description])];const csv='\ufeff'+rows.map(row=>row.map(cell=>`"${String(cell).replaceAll('"','""')}"`).join(',')).join('\n');const url=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));const anchor=document.createElement('a');anchor.href=url;anchor.download=`小树陪练工资_${batch.month}.csv`;anchor.click();URL.revokeObjectURL(url);}
+ rateLabel(type:number):string{return({1:'30分钟',2:'60分钟',3:'体验课',4:'陪练'})[type]||`课型${type}`;}
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/operations-professional.scss


+ 68 - 0
src/app/admin/pages/operations-schedule.component.html

@@ -0,0 +1,68 @@
+<header class="page-header">
+  <div><p>SCHEDULING CENTER</p><h1>排课中心</h1><span>按陪练或时间快速排课,并在提交前发现冲突</span></div>
+  <div class="header-actions">
+    <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新</button>
+    <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17" />新增排课</button>
+  </div>
+</header>
+
+<section class="toolbar">
+  <div class="segmented">
+    <button type="button" [class.active]="view()==='day'" (click)="view.set('day');load()">日</button>
+    <button type="button" [class.active]="view()==='week'" (click)="view.set('week');load()">周</button>
+    <button type="button" [class.active]="view()==='list'" (click)="view.set('list');load()">列表</button>
+  </div>
+  <button type="button" aria-label="上一时间段" (click)="move(view()==='week'?-7:-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button>
+  <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>
+</section>
+
+@if(message()){<p class="notice" role="status">{{message()}}</p>}
+@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
+
+<section class="summary-grid">
+  <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>
+</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>}
+    </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>}
+    </div></div>
+  }
+</section>
+
+@if(drawerOpen()){
+  <div class="backdrop" (click)="drawerOpen.set(false)"></div>
+  <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
+    <header><div><p>SCHEDULE FORM</p><h2>{{editing()?'修改排课':'新增排课'}}</h2></div><button type="button" aria-label="关闭" (click)="drawerOpen.set(false)">×</button></header>
+    <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="mode.set('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="mode.set('time')">时间优先</button></div>
+    <div class="form-grid">
+      <label>会员<select [ngModel]="studentId()" (ngModelChange)="studentId.set(+$event)"><option [ngValue]="0">请选择会员</option>@for(member of data()?.members||[];track member.objectId){<option [ngValue]="member.userId">{{member.displayName}} · {{member.mobileMasked}} · {{member.learningStatusLabel}}</option>}</select></label>
+      <label>课程<select [ngModel]="courseId()" (ngModelChange)="courseId.set(+$event)"><option [ngValue]="0">请选择课程</option>@for(course of data()?.courses||[];track course.courseId){<option [ngValue]="course.courseId">{{course.courseName}}</option>}</select></label>
+      <label>课程类型<select [ngModel]="classType()" (ngModelChange)="classType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
+      <label>交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event)"><option>线上</option><option>线下</option></select></label>
+      <label>日期<input type="date" [ngModel]="date()" (ngModelChange)="date.set($event)" /></label>
+      <label>开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="startTime.set($event)" /></label>
+      <label>陪练<select [ngModel]="coachId()" (ngModelChange)="coachId.set(+$event)"><option [ngValue]="0">暂不分配</option>@for(coach of data()?.coaches||[];track coach.userId){<option [ngValue]="coach.userId">{{coach.displayName}}</option>}</select></label>
+      @if(!editing()){
+        <label>重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event)"><option value="once">单次</option><option value="daily">每天</option><option value="alternate">隔天</option><option value="weekly">每周</option><option value="same_day_30">同日间隔30分钟</option><option value="same_day_60">同日间隔60分钟</option></select></label>
+        <label>排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event)" /></label>
+      }
+      <label class="wide">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
+    </div>
+    @if(preview();as result){<p class="notice" [class.error]="previewConflictCount()>0">预检完成:可排 {{result['availableCount']||0}} 节,冲突 {{result['conflictCount']||0}} 节。{{result['warnings']||''}}</p>}
+    <footer><button type="button" (click)="preflight()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'确认保存'}}</button></footer>
+  </aside>
+}

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

@@ -0,0 +1,27 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LucideAngularModule, Plus, RefreshCw, Search, X } from 'lucide-angular';
+import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
+
+@Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule], templateUrl:'./operations-schedule.component.html', styleUrl:'./operations-professional.scss', changeDetection:ChangeDetectionStrategy.OnPush })
+export class OperationsScheduleComponent implements OnInit {
+  readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
+  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);
+  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);}}
+  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();}
+  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()};}
+  async preflight():Promise<void>{this.error.set('');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.studentId()||!this.courseId()||!this.reason().trim()){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);}}
+}

+ 1 - 1
src/app/admin/pages/operations-user-detail.component.html

@@ -1,4 +1,4 @@
-<header class="page-header"><div><a routerLink="/admin/users"><lucide-icon [img]="icons.ArrowLeft" [size]="17" />返回用户列表</a><p>USER PROFILE</p><h1>{{ user()?.displayName || '用户详情' }}</h1><span>只展示运营需要的资料、关系、课时和账号状态</span></div><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
+<header class="page-header"><div><a routerLink="/admin/members"><lucide-icon [img]="icons.ArrowLeft" [size]="17" />返回会员管理</a><p>MEMBER PROFILE</p><h1>{{ user()?.displayName || '会员详情' }}</h1><span>集中查看会员资料、学习状态、关系、课程、课时和账号状态</span></div><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
 
 @if (loading() && !user()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在加载用户资料…</div> }
 @else if (error() && !user()) { <div class="state error" role="alert">{{ error() }}</div> }

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

@@ -1,5 +1,5 @@
 <header class="page-header">
-  <div><p>USER OPERATIONS</p><h1>用户列表</h1><span>按真实姓名、昵称、手机号、用户 ID 或所属代理查找</span></div>
+  <div><p>MEMBER OPERATIONS</p><h1>会员管理</h1><span>掌握会员身份、学习进度、剩余课时与下一步运营动作</span></div>
   <button class="refresh-button" type="button" (click)="refresh()" [disabled]="loading()"><lucide-icon [class.spin]="loading()" [img]="icons.RefreshCw" [size]="17" />{{ loading() ? '正在刷新…' : '刷新数据' }}</button>
 </header>
 
@@ -11,20 +11,20 @@
 </section>
 
 <section class="table-card">
-  <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>会员数据</strong><span>{{ page()?.total || 0 }} 人(已排除管理员)</span></div><small>最后刷新:{{ lastRefreshedAt() | date:'HH:mm:ss' }} · 手机号按权限显示</small></header>
+  <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>正式会员</strong><span>{{ page()?.total || 0 }} 人(已排除管理员、门店和陪练)</span></div><small>最后刷新:{{ lastRefreshedAt() | date:'HH:mm:ss' }} · 手机号按权限显示</small></header>
   @if (loading() && !page()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载用户…</div> }
   @else if (error()) { <div class="state error" role="alert">{{ error() }}</div> }
   @else {
     <div class="table-scroll"><table>
-      <thead><tr><th>用户</th><th>手机号</th><th>会员组</th><th>学习状态</th><th>所属代理</th><th>30 分钟</th><th>60 分钟</th><th>注册时间</th><th>账号状态</th><th><span class="sr-only">操作</span></th></tr></thead>
+      <thead><tr><th>会员</th><th>手机号</th><th>学习状态</th><th>风险提示</th><th>所属门店/代理</th><th>剩余课时</th><th>最近上课</th><th>下次预约</th><th>账号状态</th><th><span class="sr-only">操作</span></th></tr></thead>
       <tbody>
         @for (row of page()?.items || []; track row.objectId) {
           <tr>
             <td><div class="user-cell"><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.username }}</span></div></td>
-            <td>{{ row.mobile || row.mobileMasked || '—' }}</td><td>{{ row.groupName || ('用户组 ' + row.groupId) }}</td><td><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span><small class="status-reason">{{ row.learningStatusReason }}</small></td><td>{{ row.parentDisplayName || '未绑定' }}</td>
-            <td>{{ row.periods30 | number }}</td><td>{{ row.periods60 | number }}</td><td>{{ row.registeredAt | date:'yyyy-MM-dd' }}</td>
+            <td>{{ row.mobile || row.mobileMasked || '—' }}</td><td><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span><small class="status-reason">{{ row.learningStatusReason }}</small></td><td><div class="risk-list">@for (risk of row.riskFlags || []; track risk) { <span>{{ risk }}</span> } @empty { <small>状态正常</small> }</div></td><td>{{ row.parentDisplayName || '未绑定' }}</td>
+            <td><strong>{{ row.periods30 | number }}</strong> × 30分<br/><strong>{{ row.periods60 | number }}</strong> × 60分</td><td>{{ row.lastLessonAt ? (row.lastLessonAt | date:'MM-dd HH:mm') : '暂无' }}</td><td>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</td>
             <td><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></td>
-            <td><a [routerLink]="['/admin/users', row.objectId]" aria-label="查看用户详情"><lucide-icon [img]="icons.Eye" [size]="17" /></a></td>
+            <td><a [routerLink]="['/admin/members', row.objectId]" aria-label="查看会员详情"><lucide-icon [img]="icons.Eye" [size]="17" /></a></td>
           </tr>
         } @empty { <tr><td colspan="10"><div class="state">没有符合条件的用户</div></td></tr> }
       </tbody>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/operations-users.component.scss


Некоторые файлы не были показаны из-за большого количества измененных файлов