import { inject, Injectable } from '@angular/core'; import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, TeachingStandardsData, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models'; import { CloudFunctionsService } from './cloud-functions.service'; import { AdminSessionService } from './admin-session.service'; export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs'; @Injectable({ providedIn: 'root' }) export class OperationsService { private readonly functions = inject(CloudFunctionsService); private readonly sessions = inject(AdminSessionService); readonly dataChanges$ = this.functions.dataChanges$; private dashboardCache: { token: string; data: OperationsDashboardData; savedAt: number } | null = null; private dashboardInFlight: { token: string; promise: Promise } | null = null; private dashboardRevision = 0; constructor() { this.dataChanges$?.subscribe(() => { this.dashboardRevision++; this.dashboardCache = null; this.dashboardInFlight = null; }); } dashboardSnapshot(): { data: OperationsDashboardData; ageMs: number } | null { const cached = this.dashboardCache; const token = this.sessions.session()?.sessionToken; const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }); return cached && token && cached.token === token && cached.data.date === today ? { data: cached.data, ageMs: Date.now() - cached.savedAt } : null; } dashboard(): Promise { const token = this.sessions.session()?.sessionToken || ''; if (this.dashboardInFlight?.token === token) return this.dashboardInFlight.promise; const revision = this.dashboardRevision; const promise = this.functions.operations('ops/dashboard/summary').then((data) => { if (token && this.sessions.session()?.sessionToken === token && revision === this.dashboardRevision) { this.dashboardCache = { token, data, savedAt: Date.now() }; } return data; }).finally(() => { if (this.dashboardInFlight?.promise === promise) this.dashboardInFlight = null; }); this.dashboardInFlight = { token, promise }; return promise; } syncStatus(probe = true): Promise { return this.functions.operations('ops/sync/status', { probe }); } runSync(dataset: string, full = false): Promise { return this.functions.operations('ops/sync/run', { dataset, full, pageSize: 500, maxPages: 5 }); } retrySyncFailures(dataset = ''): Promise { return this.functions.operations('ops/sync/retry', { dataset }); } scheduleTrend(period: ScheduleTrendPeriod): Promise { return this.functions.operations('ops/dashboard/schedule-trend', { period }); } request(operation: string, params: Record = {}): Promise { return this.functions.operations(operation, params); } users(page: number, pageSize: number, search: string, filters: Record = {}, sort: MemberListSort = { field: 'registeredAt', direction: 'desc' }, refresh = false): Promise { return this.functions.operations('ops/users/list', { page, pageSize, search, filters, sort, refresh }); } userDetail(objectId: string): Promise { return this.functions.operations('ops/users/detail', { objectId }); } memberRows(operation: 'ops/users/notes' | 'ops/users/audit', objectId: string): Promise<{ items: Record[]; refreshedAt: string }> { return this.functions.operations<{ items: Record[]; refreshedAt: string }>(operation, { objectId }); } exportMembers(search: string, filters: Record, sort: MemberListSort): Promise<{ fileName: string; items: Record[] }> { return this.functions.operations<{ fileName: string; items: Record[] }>('ops/users/export', { search, filters, sort }); } previewMemberImport(fileName: string, rows: Record[], reason: string, idempotencyKey: string): Promise { return this.functions.operations('ops/users/import-preview', { reason, idempotencyKey, payload: { fileName, rows } }); } commitMemberImport(batchId: string, fileName: string, rows: Record[], reason: string, idempotencyKey: string): Promise { return this.functions.operations('ops/users/import-commit', { targetId: batchId, reason, idempotencyKey, payload: { batchId, fileName, rows } }); } coaches(page: number, pageSize: number, search = '', filters: Record = {}, refresh = false): Promise { return this.functions.operations('ops/coaches/list', { page, pageSize, search, filters, refresh }); } async stores(page: number, pageSize: number, search = '', filters: Record = {}): Promise { const result = await this.functions.operations('ops/stores/list', { page, pageSize, search, filters }); let countFailed = false; // Older gateways lack this field. Use the existing member query's total, not its page length. const items = await Promise.all(result.items.map(async (store) => { if (store.activeStudyingMemberCount != null) return store; try { if (!(store.userId > 0)) throw new Error('门店编号缺失'); const members = await this.users(1, 1, '', { parentUserId: store.userId, learningStatus: 'studying', status: 'active' }); if (!Number.isInteger(members.total) || members.total < 0) throw new Error('人数统计无效'); return { ...store, activeStudyingMemberCount: members.total }; } catch { countFailed = true; return store; } })); return { ...result, items, activeStudyingMemberCountError: countFailed ? '部分门店的在学正常学员人数加载失败,请点击刷新重试。' : '' }; } storeDetail(objectId: string, month: string, refresh = false): Promise { return this.functions.operations('ops/stores/detail', { objectId, month, refresh }); } coachDetail(objectId: string, month = '', refresh = false): Promise { return this.functions.operations('ops/coaches/detail', { objectId, month, refresh }); } learningReports(page: number, pageSize: number, search = '', filters: Record = {}, refresh = false): Promise { return this.functions.operations('ops/learning-reports/list', { page, pageSize, search, filters, sort: { field: 'studyDate', direction: 'desc' }, refresh }); } learningReportDetail(targetId: string): Promise { return this.functions.operations('ops/learning-reports/detail', { targetId }); } learningReportExport(targetId: string): Promise { return this.functions.operations('ops/learning-reports/export', { targetId }); } schedule(dateFrom: string, dateTo: string, coachId = 0): Promise { return this.functions.operations('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId } }); } availability(coachId: number): Promise> { return this.functions.operations>('ops/coach-availability/list', { page: 1, pageSize: 100, filters: { coachId } }); } payRates(): Promise> { return this.functions.operations>('ops/pay-rates/list', { page: 1, pageSize: 100 }); } teachingStandards(): Promise { return this.functions.operations('ops/teaching-rules/list'); } reviewRewards(filters: import('./admin.models').ReviewRewardFilters, page = 1, status = '', pageSize = 20): Promise { return this.functions.operations('ops/payroll/review-rewards', { ...filters, page, pageSize, status }); } confirmLegacyReviewRewards(rewardIds: string[], reason: string): Promise<{ confirmed: number; alreadyConfirmed: number }> { return this.mutate('ops/payroll/review-rewards/confirm', '', reason, { rewardIds }); } payrolls(page = 1, pageSize = 20, month = ''): Promise> { return this.functions.operations>('ops/payroll/list', { page, pageSize, month }); } payrollPreview(month: string): Promise { return this.functions.operations('ops/payroll/preview', { month }); } payrollOverview(month: string, storeId = 0, search = '', refresh = false): Promise { return this.functions.operations('ops/payroll/overview', { month, storeId, search, refresh }); } payrollDetail(targetId: string): Promise { return this.functions.operations('ops/payroll/detail', { targetId }); } payrollExport(month: string, targetId = '', storeId = 0, search = '', coachId = 0): Promise { return this.functions.operations('ops/payroll/export', { month, targetId, storeId, search, coachId }); } vocabularyTree(): Promise { return this.functions.operations('ops/vocabulary/tree'); } vocabulary(page: number, pageSize: number, search = '', filters: Record = {}): Promise> { return this.functions.operations>('ops/vocabulary/list', { page, pageSize, search, filters }); } specialTraining(page = 1, pageSize = 50, search = '', filters: Record = {}, refresh = false): Promise { return this.functions.operations('ops/special-training/list', { page, pageSize, search, filters, refresh }); } previewVocabularyImport(fileName: string, rows: Record[], reason: string): Promise { return this.functions.operations('ops/vocabulary/import-preview', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName, rows } }); } commitVocabularyImport(batchId: string, reason: string): Promise> { return this.functions.operations>('ops/vocabulary/import-commit', { targetId: batchId, reason, idempotencyKey: crypto.randomUUID() }); } async uploadCover(file: File, reason: string, width: number, height: number): Promise { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('无法读取封面文件')); reader.onerror = () => reject(new Error('无法读取封面文件')); reader.readAsDataURL(file); }); const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); const result = await this.functions.operations<{ publicUrl: string }>('ops/media/upload-inline', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName: file.name, contentType: file.type, size: file.size, kind: 'image', base64, width, height }, }); return result.publicUrl; } async uploadMedia(file: File, kind: 'image' | 'audio' | 'video', reason: string): Promise { const ticket = await this.functions.operations('ops/media/presign', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName: file.name, contentType: file.type, size: file.size, kind } }); const response = await fetch(ticket.uploadUrl, { method: ticket.method, headers: ticket.headers, body: file }); if (!response.ok) throw new Error(`媒体上传失败(${response.status})`); const completed = await this.functions.operations<{ publicUrl: string }>('ops/media/complete', { targetId: ticket.ticketId, reason, idempotencyKey: crypto.randomUUID() }); return completed.publicUrl; } list(kind: OperationsListKind, page: number, pageSize: number, search: string, filters: Record = {}): Promise { const operation = kind === 'relations' ? 'ops/relations/list' : kind === 'course-bindings' ? 'ops/course-bindings/list' : kind === 'appointments' ? 'ops/appointments/list' : kind === 'lessons' ? 'ops/lessons/list' : kind === 'learning-records' ? 'ops/learning-records/list' : kind === 'finance' ? 'ops/finance/list' : 'ops/money-logs/list'; return this.functions.operations(operation, { page, pageSize, search, filters }); } mutate(operation: string, targetId: string, reason: string, payload: Record = {}, expectedUpdatedAt = '', idempotencyKey = ''): Promise { return this.functions.operations(operation, { targetId, reason, payload, expectedUpdatedAt, idempotencyKey: idempotencyKey || crypto.randomUUID() }); } }