295 lines
8.5 KiB
TypeScript
295 lines
8.5 KiB
TypeScript
|
|
import { API_BASE_URL, ApiRequestError } from './auth'
|
|||
|
|
import type { DimensionScore, EvaluationDetail, ScoreDetail } from './assessment'
|
|||
|
|
import type { ScoreType } from './session'
|
|||
|
|
|
|||
|
|
type QueryValue = string | number | boolean | null | undefined
|
|||
|
|
|
|||
|
|
type ServerEnvelope<T> = {
|
|||
|
|
code?: string
|
|||
|
|
message?: string
|
|||
|
|
data?: T
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type UserProfile = {
|
|||
|
|
id: number
|
|||
|
|
username: string
|
|||
|
|
real_name: string
|
|||
|
|
phone: string
|
|||
|
|
avatar: string
|
|||
|
|
gender: number
|
|||
|
|
role_type: string
|
|||
|
|
institution: number | string | null
|
|||
|
|
institution_name: string
|
|||
|
|
department: number | string | null
|
|||
|
|
department_name: string
|
|||
|
|
title_name: string
|
|||
|
|
practice_years: string
|
|||
|
|
major: string
|
|||
|
|
training_stage: string
|
|||
|
|
learning_target: string
|
|||
|
|
competency_profile: Record<string, unknown>
|
|||
|
|
weak_dimensions: string[]
|
|||
|
|
strong_dimensions: string[]
|
|||
|
|
ai_preference: Record<string, unknown>
|
|||
|
|
total_training_count: number
|
|||
|
|
total_case_count: number
|
|||
|
|
current_level: string
|
|||
|
|
status: number
|
|||
|
|
last_login_time: string | null
|
|||
|
|
created_at: string
|
|||
|
|
updated_at: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type TrainingRecord = {
|
|||
|
|
record_id: number
|
|||
|
|
case_id: number
|
|||
|
|
case_title: string
|
|||
|
|
department: string
|
|||
|
|
trained_at: string
|
|||
|
|
score: number
|
|||
|
|
score_type: ScoreType
|
|||
|
|
evaluation_level: string
|
|||
|
|
training_mode: string
|
|||
|
|
case_type: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type TrainingRecordSummary = {
|
|||
|
|
total_cases: number
|
|||
|
|
total_hours: number
|
|||
|
|
avg_accuracy: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type TrainingRecordsResponse = {
|
|||
|
|
count: number
|
|||
|
|
next: string | null
|
|||
|
|
previous: string | null
|
|||
|
|
results: TrainingRecord[]
|
|||
|
|
summary: TrainingRecordSummary
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type AnalysisTrendItem = {
|
|||
|
|
label: string
|
|||
|
|
score: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type AnalysisRadarItem = {
|
|||
|
|
dimension: string
|
|||
|
|
score: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type UserAnalysis = {
|
|||
|
|
current_score: number
|
|||
|
|
score_delta_pct: number | null
|
|||
|
|
recent_trend: AnalysisTrendItem[]
|
|||
|
|
radar: AnalysisRadarItem[]
|
|||
|
|
weak_dimensions: string[]
|
|||
|
|
comment: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type CompetencyMetrics = {
|
|||
|
|
completed_cases: number
|
|||
|
|
completed_cases_week: number
|
|||
|
|
total_hours: number
|
|||
|
|
avg_score: number
|
|||
|
|
diagnosis_accuracy: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type EvaluationListItem = Partial<EvaluationDetail> & Record<string, unknown>
|
|||
|
|
|
|||
|
|
export type EvaluationListResponse = {
|
|||
|
|
count: number
|
|||
|
|
next: string | null
|
|||
|
|
previous: string | null
|
|||
|
|
results: EvaluationListItem[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type TrainingRecordQuery = {
|
|||
|
|
search?: string
|
|||
|
|
page?: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type EvaluationListQuery = {
|
|||
|
|
page?: number
|
|||
|
|
page_size?: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function fetchUserProfile() {
|
|||
|
|
return serverRequest<UserProfile>('/user/profile/')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function fetchUserTrainingRecords(query: TrainingRecordQuery = {}) {
|
|||
|
|
return serverRequest<TrainingRecordsResponse>('/user/training-records/', {
|
|||
|
|
search: query.search,
|
|||
|
|
page: query.page
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function fetchUserAnalysis() {
|
|||
|
|
return serverRequest<UserAnalysis>('/user/analysis/')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function fetchCompetencyMetrics() {
|
|||
|
|
return serverRequest<CompetencyMetrics>('/user/competency-metrics/')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function fetchEvaluationList(query: EvaluationListQuery = {}) {
|
|||
|
|
const payload = await serverRequest<unknown>('/v1/evaluations', {
|
|||
|
|
page: query.page,
|
|||
|
|
page_size: query.page_size
|
|||
|
|
})
|
|||
|
|
return unwrapServerData<EvaluationListResponse>(payload)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function fetchServerEvaluationDetail(evaluationId: number) {
|
|||
|
|
const payload = await serverRequest<unknown>(`/v1/evaluations/${encodeURIComponent(String(evaluationId))}`)
|
|||
|
|
const data = unwrapServerData<Record<string, unknown>>(payload)
|
|||
|
|
return normalizeEvaluationDetail(data, evaluationId)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function serverRequest<T>(path: string, query?: Record<string, QueryValue>): Promise<T> {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
uni.request({
|
|||
|
|
url: `${API_BASE_URL}${withQuery(path, query)}`,
|
|||
|
|
method: 'GET',
|
|||
|
|
timeout: 10000,
|
|||
|
|
header: createAuthHeaders(),
|
|||
|
|
success: response => {
|
|||
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|||
|
|
resolve(response.data as T)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = response.data as Record<string, unknown> | undefined
|
|||
|
|
const code = typeof payload?.code === 'string' ? payload.code : undefined
|
|||
|
|
reject(new ApiRequestError(readErrorMessage(response.data, `请求失败(${response.statusCode})`), code, response.statusCode))
|
|||
|
|
},
|
|||
|
|
fail: error => {
|
|||
|
|
reject(new ApiRequestError(error.errMsg || '无法连接服务'))
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createAuthHeaders() {
|
|||
|
|
const headers: Record<string, string> = {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
Accept: 'application/json'
|
|||
|
|
}
|
|||
|
|
const token = readAccessToken()
|
|||
|
|
if (token) headers.Authorization = `Bearer ${token}`
|
|||
|
|
return headers
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readAccessToken() {
|
|||
|
|
try {
|
|||
|
|
const token = uni.getStorageSync('clinical-thinking-access-token')
|
|||
|
|
return typeof token === 'string' ? token.trim() : ''
|
|||
|
|
} catch {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function withQuery(path: string, query?: Record<string, QueryValue>) {
|
|||
|
|
if (!query) return path
|
|||
|
|
const params = Object.entries(query)
|
|||
|
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|||
|
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|||
|
|
.join('&')
|
|||
|
|
|
|||
|
|
return params ? `${path}?${params}` : path
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readErrorMessage(data: unknown, fallback: string) {
|
|||
|
|
if (data && typeof data === 'object') {
|
|||
|
|
const payload = data as Record<string, unknown>
|
|||
|
|
const message = payload.message || payload.detail || payload.error
|
|||
|
|
if (typeof message === 'string' && message.trim()) return message
|
|||
|
|
}
|
|||
|
|
return fallback
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function unwrapServerData<T>(payload: unknown): T {
|
|||
|
|
if (payload && typeof payload === 'object' && 'data' in payload) {
|
|||
|
|
const envelope = payload as ServerEnvelope<T>
|
|||
|
|
if (envelope.code && envelope.code !== 'OK' && envelope.code !== 'ok') {
|
|||
|
|
throw new ApiRequestError(envelope.message || '请求失败', envelope.code)
|
|||
|
|
}
|
|||
|
|
if (envelope.data !== undefined) return envelope.data
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return payload as T
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeEvaluationDetail(data: Record<string, unknown>, fallbackId: number): EvaluationDetail {
|
|||
|
|
const caseInfo = isRecord(data.case) ? data.case : {}
|
|||
|
|
const totalScore = readNumber(data.total_score ?? data.score ?? data.current_score, 0)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
evaluation_id: readNumber(data.evaluation_id ?? data.id ?? data.record_id, fallbackId),
|
|||
|
|
session_id: readNumber(data.session_id, 0),
|
|||
|
|
case_id: readNumber(data.case_id ?? caseInfo.id, 0),
|
|||
|
|
case_title: readString(data.case_title ?? data.caseTitle ?? caseInfo.title, '未命名病例'),
|
|||
|
|
score_type: readScoreType(data.score_type),
|
|||
|
|
total_score: totalScore,
|
|||
|
|
dimension_scores: normalizeDimensionScores(data.dimension_scores ?? data.radar),
|
|||
|
|
score_details: normalizeScoreDetails(data.score_details),
|
|||
|
|
overall_comment: readString(data.overall_comment ?? data.comment, '本次评价暂无详细点评。'),
|
|||
|
|
pdf_file_path: readOptionalString(data.pdf_file_path ?? data.pdfFilePath),
|
|||
|
|
created_at: readOptionalString(data.created_at ?? data.trained_at)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeDimensionScores(value: unknown): DimensionScore[] {
|
|||
|
|
if (!Array.isArray(value)) return []
|
|||
|
|
return value.map(item => {
|
|||
|
|
const record = isRecord(item) ? item : {}
|
|||
|
|
return {
|
|||
|
|
dimension: readString(record.dimension ?? record.label, '未命名维度'),
|
|||
|
|
score: readNumber(record.score, 0),
|
|||
|
|
max_score: readNumber(record.max_score, 100),
|
|||
|
|
comment: readOptionalString(record.comment),
|
|||
|
|
evidence: Array.isArray(record.evidence) ? record.evidence.map(String) : undefined,
|
|||
|
|
deductions: Array.isArray(record.deductions) ? record.deductions.map(String) : undefined,
|
|||
|
|
improvement: readOptionalString(record.improvement)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeScoreDetails(value: unknown): ScoreDetail[] {
|
|||
|
|
if (!Array.isArray(value)) return []
|
|||
|
|
return value.map(item => {
|
|||
|
|
const record = isRecord(item) ? item : {}
|
|||
|
|
return {
|
|||
|
|
dimension: readString(record.dimension, '未命名维度'),
|
|||
|
|
score: readNumber(record.score, 0),
|
|||
|
|
deducted_reason: readOptionalString(record.deducted_reason),
|
|||
|
|
ai_confidence: record.ai_confidence === undefined ? undefined : readNumber(record.ai_confidence, 0),
|
|||
|
|
comment: readOptionalString(record.comment)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readScoreType(value: unknown): ScoreType {
|
|||
|
|
return value === 'five_point' ? 'five_point' : 'percentage'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readNumber(value: unknown, fallback = 0) {
|
|||
|
|
const numberValue = Number(value)
|
|||
|
|
return Number.isFinite(numberValue) ? numberValue : fallback
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readString(value: unknown, fallback = '') {
|
|||
|
|
if (typeof value === 'string') return value.trim() || fallback
|
|||
|
|
if (typeof value === 'number') return String(value)
|
|||
|
|
return fallback
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readOptionalString(value: unknown) {
|
|||
|
|
const text = readString(value)
|
|||
|
|
return text || undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
|
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|||
|
|
}
|