| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- 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<OperationsDashboardData> } | 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<OperationsDashboardData> {
- const token = this.sessions.session()?.sessionToken || '';
- if (this.dashboardInFlight?.token === token) return this.dashboardInFlight.promise;
- const revision = this.dashboardRevision;
- const promise = this.functions.operations<OperationsDashboardData>('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<LegacySyncStatus> {
- return this.functions.operations<LegacySyncStatus>('ops/sync/status', { probe });
- }
- runSync(dataset: string, full = false): Promise<LegacySyncRunResult> {
- return this.functions.operations<LegacySyncRunResult>('ops/sync/run', { dataset, full, pageSize: 500, maxPages: 5 });
- }
- retrySyncFailures(dataset = ''): Promise<LegacySyncRunResult> {
- return this.functions.operations<LegacySyncRunResult>('ops/sync/retry', { dataset });
- }
- scheduleTrend(period: ScheduleTrendPeriod): Promise<ScheduleTrendData> {
- return this.functions.operations<ScheduleTrendData>('ops/dashboard/schedule-trend', { period });
- }
- 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> = {}, sort: MemberListSort = { field: 'registeredAt', direction: 'desc' }, refresh = false): Promise<MemberPage> {
- return this.functions.operations<MemberPage>('ops/users/list', { page, pageSize, search, filters, sort, refresh });
- }
- userDetail(objectId: string): Promise<OperationsUserDetail> {
- return this.functions.operations<OperationsUserDetail>('ops/users/detail', { objectId });
- }
- memberRows(operation: 'ops/users/notes' | 'ops/users/audit', objectId: string): Promise<{ items: Record<string, unknown>[]; refreshedAt: string }> {
- return this.functions.operations<{ items: Record<string, unknown>[]; refreshedAt: string }>(operation, { objectId });
- }
- exportMembers(search: string, filters: Record<string, unknown>, sort: MemberListSort): Promise<{ fileName: string; items: Record<string, unknown>[] }> {
- return this.functions.operations<{ fileName: string; items: Record<string, unknown>[] }>('ops/users/export', { search, filters, sort });
- }
- previewMemberImport(fileName: string, rows: Record<string, unknown>[], reason: string, idempotencyKey: string): Promise<MemberImportPreview> {
- return this.functions.operations<MemberImportPreview>('ops/users/import-preview', { reason, idempotencyKey, payload: { fileName, rows } });
- }
- commitMemberImport(batchId: string, fileName: string, rows: Record<string, unknown>[], reason: string, idempotencyKey: string): Promise<MemberImportResult> {
- return this.functions.operations<MemberImportResult>('ops/users/import-commit', { targetId: batchId, reason, idempotencyKey, payload: { batchId, fileName, rows } });
- }
- coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<CoachPage> {
- return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters, refresh });
- }
- async stores(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<StorePage> {
- const result = await this.functions.operations<StorePage>('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<StoreDetailData> {
- return this.functions.operations<StoreDetailData>('ops/stores/detail', { objectId, month, refresh });
- }
- coachDetail(objectId: string, month = '', refresh = false): Promise<CoachDetailData> {
- return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId, month, refresh });
- }
- learningReports(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<LearningReportPage> {
- return this.functions.operations<LearningReportPage>('ops/learning-reports/list', { page, pageSize, search, filters, sort: { field: 'studyDate', direction: 'desc' }, refresh });
- }
- learningReportDetail(targetId: string): Promise<LearningReportDetail> {
- return this.functions.operations<LearningReportDetail>('ops/learning-reports/detail', { targetId });
- }
- learningReportExport(targetId: string): Promise<LearningReportDetail> {
- return this.functions.operations<LearningReportDetail>('ops/learning-reports/export', { targetId });
- }
- 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 });
- }
- teachingStandards(): Promise<TeachingStandardsData> {
- return this.functions.operations<TeachingStandardsData>('ops/teaching-rules/list');
- }
- reviewRewards(filters: import('./admin.models').ReviewRewardFilters, page = 1, status = '', pageSize = 20): Promise<import('./admin.models').ReviewRewardPage> {
- 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<OperationsPage<PayrollSummary>> {
- return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize, month });
- }
- payrollPreview(month: string): Promise<PayrollDetail> {
- return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
- }
- payrollOverview(month: string, storeId = 0, search = '', refresh = false): Promise<PayrollOverview> {
- return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh });
- }
- payrollDetail(targetId: string): Promise<PayrollDetail> {
- return this.functions.operations<PayrollDetail>('ops/payroll/detail', { targetId });
- }
- payrollExport(month: string, targetId = '', storeId = 0, search = '', coachId = 0): Promise<PayrollExportData> {
- return this.functions.operations<PayrollExportData>('ops/payroll/export', { month, targetId, storeId, search, coachId });
- }
- vocabularyTree(): Promise<VocabularyTree> {
- return this.functions.operations<VocabularyTree>('ops/vocabulary/tree');
- }
- vocabulary(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<OperationsPage<VocabularyWord>> {
- return this.functions.operations<OperationsPage<VocabularyWord>>('ops/vocabulary/list', { page, pageSize, search, filters });
- }
- specialTraining(page = 1, pageSize = 50, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<SpecialTrainingPage> {
- return this.functions.operations<SpecialTrainingPage>('ops/special-training/list', { page, pageSize, search, filters, refresh });
- }
- previewVocabularyImport(fileName: string, rows: Record<string, unknown>[], reason: string): Promise<VocabularyImportPreview> {
- return this.functions.operations<VocabularyImportPreview>('ops/vocabulary/import-preview', { reason, idempotencyKey: crypto.randomUUID(), payload: { fileName, rows } });
- }
- commitVocabularyImport(batchId: string, reason: string): Promise<Record<string, unknown>> {
- return this.functions.operations<Record<string, unknown>>('ops/vocabulary/import-commit', { targetId: batchId, reason, idempotencyKey: crypto.randomUUID() });
- }
- async uploadCover(file: File, reason: string, width: number, height: number): Promise<string> {
- const dataUrl = await new Promise<string>((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<string> {
- const ticket = await this.functions.operations<MediaUploadTicket>('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<string, unknown> = {}): Promise<OperationsPage> {
- 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<OperationsPage>(operation, { page, pageSize, search, filters });
- }
- 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() });
- }
- }
|