export interface KpiOverview { institution_count?: number | null mau_institution?: number | null user_total?: number | null mau_user?: number | null } export interface PlatformCoreOverview { train_new_month?: number | null train_new_mom?: number | null train_total?: number | null complete_rate?: number | null avg_score?: number | null } export interface TrendOverview { months: string[] train_counts: number[] active_users?: number[] } export interface UserComposeItem { role: string count: number } export interface HourlyAverageItem { hour: number avg: number } export interface InstitutionDistributionItem { id: number institution: string users: number active: number } export interface HospitalTrainRankItem { id: number institution: string count: number } export interface HospitalScoreRankItem { id: number institution: string avg_score: number } export interface CaseTypeCountItem { case_type: string count: number } export interface CaseTypeUsageRateItem { case_type: string rate: number } export interface CaseUsageItem { case_id: number title: string count: number } export interface CasePassRateItem { case_id: number title: string pass_rate: number total: number } export interface PlatformCaseAssetOverview { total?: number | null new_month?: number | null new_mom?: number | null type_dist: CaseTypeCountItem[] type_usage_rate: CaseTypeUsageRateItem[] top_used: CaseUsageItem[] low_pass: CasePassRateItem[] } export interface PlatformOverview { kpi: KpiOverview core: PlatformCoreOverview trend: TrendOverview user_compose: UserComposeItem[] hourly_7d: HourlyAverageItem[] institution_dist: InstitutionDistributionItem[] hospital_rank: { by_train: HospitalTrainRankItem[] by_avg_score: HospitalScoreRankItem[] } case_asset: PlatformCaseAssetOverview } export interface HospitalOverview { profile: { institution_id?: number | null name?: string logo?: string level?: string cooperation_days?: number | null } summary: { dept_count?: number | null doctor_count?: number | null student_count?: number | null train_total?: number | null complete_rate?: number | null avg_score?: number | null train_months: string[] train_monthly: number[] } dept_rank: Array<{ id: number department: string case_count: number train_count: number effective_train: number active_users: number avg_score: number }> case_asset: { total?: number | null new_month?: number | null top_used: CaseUsageItem[] pass_high: CasePassRateItem[] pass_low: CasePassRateItem[] } competency: { student_avg?: number | null platform_avg?: number | null radar: CompetencyDimension[] radar_platform: CompetencyDimension[] } } export interface ContentOverview { summary: { case_total?: number | null pending_publish?: number | null case_new_month?: number | null case_new_mom?: number | null usage_rate?: number | null train_total?: number | null train_mom?: number | null } dist: { type_dist: CaseTypeCountItem[] type_train: Record dept_dist: Array<{ id: number department: string case_count: number used_count: number train_count: number }> difficulty_dist: Array<{ difficulty: string case_count: number train_count: number }> } warning: CasePassRateItem[] hot: CaseUsageItem[] } export interface TeachingStudentOverviewItem { id: number real_name: string username: string department: string train_total: number complete_rate: number avg_score: number weak_dimensions: string[] most_trained_type: string last_trained_at: string pending_tasks: number | null } export interface CompetencyDimension { dimension: string score: number } export interface TeachingOverview { students: TeachingStudentOverviewItem[] overview: { student_count?: number | null radar: CompetencyDimension[] students_avg?: number | null institution_avg?: number | null train_months: string[] train_monthly: number[] task_summary?: unknown } } export interface TrainingRecordListParams { token: string scope: 'platform' | 'teacher' search?: string user_id?: string case_id?: string status?: string training_mode?: string case_type?: string institution?: string start_date?: string end_date?: string min_score?: string max_score?: string page?: number } export interface TrainingRecordItem { id: string userId: string userName: string username: string phone: string caseId: string caseTitle: string status: string trainingMode: string caseType: string institution: string score: number | null createdAt: string completedAt: string duration: string raw: unknown } export interface TrainingRecordListResult { records: TrainingRecordItem[] total: number } export interface StudentRankingItem { id: string name: string username: string department: string value: number | null score: number | null trainCount: number | null completed: number | null completeRate: number | null raw: unknown } export interface StudentRankingResult { students: StudentRankingItem[] total: number } export interface StudentCompetency { profile: { id: string name: string username: string department: string } summary: { trainTotal: number | null completeRate: number | null avgScore: number | null lastTrainedAt: string } radar: CompetencyDimension[] trend: { months: string[] trainCounts: number[] scores: number[] } weakDimensions: CompetencyDimension[] raw: unknown } const listKeys = ['results', 'list', 'rows', 'items', 'records', 'students', 'data'] const totalKeys = ['count', 'total', 'total_count', 'totalCount'] function createAuthorization(token: string) { return /^Bearer\s+/i.test(token) ? token : `Bearer ${token}` } function parseResponseText(text: string): unknown { if (!text) { return null } try { return JSON.parse(text) } catch { return null } } function getMessageFromResponse(data: unknown): string { if (!data || typeof data !== 'object') { return '' } const record = data as Record const message = record.message || record.msg || record.detail if (typeof message === 'string') { return message } return getMessageFromResponse(record.data) } function unwrapData(data: unknown): unknown { if (!data || typeof data !== 'object' || Array.isArray(data)) { return data } const record = data as Record const nested = record.data || record.result || record.payload if (nested && typeof nested === 'object') { const metaKeys = ['message', 'msg', 'code', 'success'] const dataOnlyKeys = Object.keys(record).filter(key => !metaKeys.includes(key)) if (dataOnlyKeys.length === 1 || ('data' in record && !('kpi' in record) && !('summary' in record))) { return unwrapData(nested) } } return data } async function requestJson(url: string, token: string, fallbackMessage: string): Promise { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', Authorization: createAuthorization(token) } }) const text = await response.text() const data = parseResponseText(text) if (!response.ok) { const message = getMessageFromResponse(data) || fallbackMessage throw new Error(message) } return unwrapData(data) as T } function getRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} } function getFirst(record: Record, keys: string[]): unknown { for (const key of keys) { const value = record[key] if (value !== undefined && value !== null) { return value } } return undefined } function getString(record: Record, keys: string[], fallback = ''): string { const value = getFirst(record, keys) if (typeof value === 'string') { return value } if (typeof value === 'number') { return String(value) } return fallback } function getNumber(record: Record, keys: string[]): number | null { const value = getFirst(record, keys) if (typeof value === 'number' && Number.isFinite(value)) { return value } if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) { return Number(value) } return null } function getTotal(record: Record, fallback: number): number { for (const key of totalKeys) { const value = record[key] if (typeof value === 'number') { return value } if (typeof value === 'string' && Number.isFinite(Number(value))) { return Number(value) } } return fallback } function findListPayload(data: unknown): { items: unknown[]; total: number } { if (Array.isArray(data)) { return { items: data, total: data.length } } if (!data || typeof data !== 'object') { return { items: [], total: 0 } } const record = data as Record for (const key of listKeys) { const value = record[key] if (Array.isArray(value)) { return { items: value, total: getTotal(record, value.length) } } } const nested = record.data || record.result || record.payload if (nested && typeof nested === 'object') { const payload = findListPayload(nested) return { items: payload.items, total: payload.total || getTotal(record, payload.items.length) } } return { items: [], total: getTotal(record, 0) } } function createQuery(params: Record) { const query = new URLSearchParams() Object.entries(params).forEach(([key, value]) => { if (value === undefined || value === null || value === '') { return } if (typeof value === 'string' && !value.trim()) { return } query.set(key, String(value).trim()) }) return query } function getNestedString(record: Record, nestedKeys: string[], valueKeys: string[], fallback = '') { for (const key of nestedKeys) { const nested = getRecord(record[key]) const value = getString(nested, valueKeys) if (value) { return value } } return fallback } function getNestedNumber(record: Record, nestedKeys: string[], valueKeys: string[]) { for (const key of nestedKeys) { const nested = getRecord(record[key]) const value = getNumber(nested, valueKeys) if (value !== null) { return value } } return null } function normalizeTrainingRecord(item: unknown): TrainingRecordItem { const record = getRecord(item) const user = getRecord(getFirst(record, ['user', 'student', 'trainee'])) const caseRecord = getRecord(getFirst(record, ['case', 'case_info', 'training_case'])) return { id: getString(record, ['id', 'record_id', 'recordId', 'uuid']), userId: getString(record, ['user_id', 'userId', 'student_id', 'studentId']) || getString(user, ['id', 'user_id']), userName: getString(record, ['user_name', 'real_name', 'realName', 'student_name', 'studentName']) || getString(user, ['real_name', 'realName', 'name']), username: getString(record, ['username', 'account']) || getString(user, ['username', 'account']), phone: getString(record, ['phone', 'mobile']) || getString(user, ['phone', 'mobile', 'username']), caseId: getString(record, ['case_id', 'caseId']) || getString(caseRecord, ['id', 'case_id']), caseTitle: getString(record, ['case_title', 'caseTitle', 'title']) || getString(caseRecord, ['title', 'name']), status: getString(record, ['status', 'state']), trainingMode: getString(record, ['training_mode', 'trainingMode', 'mode']), caseType: getString(record, ['case_type', 'caseType']) || getString(caseRecord, ['case_type', 'caseType']), institution: getString(record, ['institution', 'institution_name', 'institutionName', 'hospital', 'hospital_name', 'hospitalName']) || getNestedString(user, ['institution', 'hospital'], ['name', 'institution', 'institution_name']), score: getNumber(record, ['score', 'final_score', 'finalScore', 'total_score', 'totalScore', 'normalized_score', 'normalizedScore', 'avg_score']), createdAt: getString(record, ['created_at', 'createdAt', 'start_time', 'startTime', 'trained_at', 'trainedAt']), completedAt: getString(record, ['completed_at', 'completedAt', 'end_time', 'endTime', 'finished_at', 'finishedAt']), duration: getString(record, ['duration', 'duration_text', 'durationText', 'elapsed', 'elapsed_seconds', 'elapsedSeconds']), raw: item } } function normalizeRankingItem(item: unknown, dimension: string): StudentRankingItem { const record = getRecord(item) const user = getRecord(getFirst(record, ['user', 'student'])) const dimensions = getRecord(getFirst(record, ['dimensions', 'dimension_scores', 'scores'])) const dimensionValue = getNumber(record, [dimension]) ?? getNumber(dimensions, [dimension]) const score = getNumber(record, ['score', 'avg_score', 'avgScore', 'students_avg']) const trainCount = getNumber(record, ['train_count', 'trainCount', 'train_total', 'trainTotal']) const completed = getNumber(record, ['completed', 'completed_count', 'completedCount', 'effective_train']) const completeRate = getNumber(record, ['complete_rate', 'completeRate', 'completed_rate']) const fallbackValue = dimension === 'train_count' ? trainCount : dimension === 'completed' ? completed ?? completeRate : score return { id: getString(record, ['id', 'user_id', 'userId', 'student_id', 'studentId']) || getString(user, ['id', 'user_id']), name: getString(record, ['real_name', 'realName', 'name', 'student_name', 'studentName']) || getString(user, ['real_name', 'realName', 'name']), username: getString(record, ['username', 'account']) || getString(user, ['username', 'account']), department: getString(record, ['department', 'department_name', 'departmentName']) || getNestedString(user, ['department'], ['name', 'department']), value: getNumber(record, ['value', 'rank_value', 'rankValue']) ?? dimensionValue ?? fallbackValue, score, trainCount, completed, completeRate, raw: item } } function normalizeDimensionList(value: unknown): CompetencyDimension[] { if (Array.isArray(value)) { return value .map(item => { const record = getRecord(item) const dimension = getString(record, ['dimension', 'name', 'label']) const score = getNumber(record, ['score', 'value', 'rate']) return dimension && score !== null ? { dimension, score } : null }) .filter((item): item is CompetencyDimension => Boolean(item)) } const record = getRecord(value) return Object.entries(record) .map(([dimension, score]) => { const numericScore = typeof score === 'number' ? score : typeof score === 'string' && Number.isFinite(Number(score)) ? Number(score) : null return numericScore !== null ? { dimension, score: numericScore } : null }) .filter((item): item is CompetencyDimension => Boolean(item)) } function firstDimensionList(...values: unknown[]): CompetencyDimension[] { for (const value of values) { const list = normalizeDimensionList(value) if (list.length) { return list } } return [] } function normalizeCompetency(data: unknown, id: string | number): StudentCompetency { const root = getRecord(unwrapData(data)) const student = getRecord(getFirst(root, ['student', 'profile', 'user'])) const summary = getRecord(getFirst(root, ['summary', 'overview'])) const competency = getRecord(root.competency) const trend = getRecord(root.trend) const radar = firstDimensionList(root.radar, competency.radar, root.dimension_scores, root.dimensions, summary.radar) const explicitWeak = firstDimensionList(root.weak_dimensions, summary.weak_dimensions) const weakDimensions = explicitWeak.length ? explicitWeak : radar.filter(item => item.score < 60) return { profile: { id: getString(root, ['id', 'student_id', 'studentId', 'user_id', 'userId'], String(id)) || getString(student, ['id', 'user_id'], String(id)), name: getString(root, ['real_name', 'realName', 'name', 'student_name', 'studentName']) || getString(student, ['real_name', 'realName', 'name']), username: getString(root, ['username', 'account']) || getString(student, ['username', 'account']), department: getString(root, ['department', 'department_name', 'departmentName']) || getString(student, ['department', 'department_name', 'departmentName']) }, summary: { trainTotal: getNumber(summary, ['train_total', 'trainTotal']) ?? getNumber(root, ['train_total', 'trainTotal']), completeRate: getNumber(summary, ['complete_rate', 'completeRate']) ?? getNumber(root, ['complete_rate', 'completeRate']), avgScore: getNumber(summary, ['avg_score', 'avgScore', 'student_avg']) ?? getNumber(root, ['avg_score', 'avgScore', 'student_avg']), lastTrainedAt: getString(summary, ['last_trained_at', 'lastTrainedAt']) || getString(root, ['last_trained_at', 'lastTrainedAt']) }, radar, trend: { months: normalizeStringArray(getFirst(trend, ['months', 'train_months']) ?? getFirst(summary, ['months', 'train_months']) ?? root.train_months), trainCounts: normalizeNumberArray(getFirst(trend, ['train_counts', 'train_monthly']) ?? getFirst(summary, ['train_counts', 'train_monthly']) ?? root.train_monthly), scores: normalizeNumberArray(getFirst(trend, ['scores', 'avg_scores', 'score_monthly']) ?? getFirst(summary, ['scores', 'avg_scores', 'score_monthly'])) }, weakDimensions, raw: data } } function normalizeStringArray(value: unknown): string[] { return Array.isArray(value) ? value.map(item => String(item)) : [] } function normalizeNumberArray(value: unknown): number[] { return Array.isArray(value) ? value.map(item => (typeof item === 'number' ? item : Number(item))).filter(item => Number.isFinite(item)) : [] } export async function fetchPlatformOverview(token: string): Promise { return requestJson('/server/api/cms/stats/overview/', token, '获取平台概览失败') } export async function fetchHospitalOverview(token: string): Promise { return requestJson('/server/api/cms/stats/hospital/overview/', token, '获取医院概览失败') } export async function fetchContentOverview(token: string): Promise { return requestJson('/server/api/cms/stats/content/overview/', token, '获取内容概览失败') } export async function fetchTeachingOverview(token: string): Promise { return requestJson('/server/api/cms/stats/teaching/overview/', token, '获取教学概览失败') } export async function fetchTrainingRecords(params: TrainingRecordListParams): Promise { const { token, scope, ...queryParams } = params const query = createQuery(queryParams) const baseUrl = scope === 'teacher' ? '/server/api/cms/students/training-records/' : '/server/api/cms/training-records/' const data = await requestJson(`${baseUrl}${query.toString() ? `?${query.toString()}` : ''}`, token, '获取训练记录失败') const payload = findListPayload(data) return { records: payload.items.map(normalizeTrainingRecord), total: payload.total } } export async function fetchStudentCompetency(token: string, id: string | number): Promise { const data = await requestJson(`/server/api/cms/students/${id}/competency/`, token, '获取学生能力画像失败') return normalizeCompetency(data, id) } export async function fetchStudentRanking(token: string, dimension = 'score'): Promise { const query = createQuery({ dimension }) const data = await requestJson(`/server/api/cms/students/ranking/?${query.toString()}`, token, '获取学生排行榜失败') const payload = findListPayload(data) return { students: payload.items.map(item => normalizeRankingItem(item, dimension)), total: payload.total } }