From bc2e03920e0fd7281139fa5b0130bc55a1f0c051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=A4=A9=E9=AA=84?= <5307576@qq.com> Date: Sat, 13 Jun 2026 06:05:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=97=85=E4=BE=8B=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/cases.ts | 310 ++++++++++++++----- api/profile.ts | 294 ++++++++++++++++++ api/scenario.ts | 2 +- pages/assessment/assessment.vue | 26 ++ pages/cases/cases.vue | 473 +++++++++++++++++++++++++++-- pages/home/home.vue | 21 +- pages/index/index.vue | 20 +- pages/matching/matching.vue | 20 +- pages/profile/profile-analysis.vue | 267 ++++++++++++---- pages/profile/profile-records.vue | 200 ++++++++---- pages/profile/profile.vue | 131 ++++++-- pages/scenario/scenario.vue | 7 +- pages/teaching/teaching.vue | 5 +- 13 files changed, 1518 insertions(+), 258 deletions(-) create mode 100644 api/profile.ts diff --git a/api/cases.ts b/api/cases.ts index e3dfe18..60d1cca 100644 --- a/api/cases.ts +++ b/api/cases.ts @@ -1,4 +1,8 @@ +import { API_BASE_URL, ApiRequestError } from './auth' + export type CaseMode = 'training' | 'teaching' +export type CaseListSource = 'recommended' | 'specialty' | 'weak' | 'teaching' | 'teacher-task' +export type CaseTone = 'blue' | 'teal' | 'pink' | 'orange' | 'purple' | 'green' export type ClinicalCase = { id: string @@ -7,87 +11,95 @@ export type ClinicalCase = { gender: '男' | '女' age: number department: string + departmentId?: number scene: string caseNo: string - tone: 'blue' | 'teal' | 'pink' | 'orange' | 'purple' | 'green' + tone: CaseTone mode: CaseMode + caseType?: string + caseTypeDisplay?: string + difficulty?: string + difficultyScore?: number + chiefComplaint?: string + description?: string + tags?: string + competencyTags?: string[] + estimatedMinutes?: number + osceEnabled?: boolean + myBestScore?: number | null + myTrainCount?: number | null + createdAt?: string + source?: CaseListSource } -export function fetchCaseList(): Promise { - return Promise.resolve([ - { - id: 'case-31190016', - title: '间断四肢多关节肿痛5年,加重1个月', - patientName: '郭爱和', - gender: '男', - age: 43, - department: '风湿免疫科', - scene: '门诊部', - caseNo: '31190016', - tone: 'blue', - mode: 'training' - }, - { - id: 'case-31180002', - title: '右膝关节疼痛8年,腰背部疼痛2年', - patientName: '索航', - gender: '男', - age: 51, - department: '风湿免疫科', - scene: '住院部', - caseNo: '31180002', - tone: 'teal', - mode: 'training' - }, - { - id: 'case-2238015', - title: '阴道不规则流血4月。', - patientName: '韩爱利', - gender: '女', - age: 52, - department: '妇科', - scene: '住院部', - caseNo: '2238015', - tone: 'pink', - mode: 'training' - }, - { - id: 'case-1006004', - title: '持续胸痛3小时', - patientName: '陈先生', - gender: '男', - age: 60, - department: '心血管内科', - scene: '住院部', - caseNo: '1006004', - tone: 'orange', - mode: 'teaching' - }, - { - id: 'case-31190042', - title: '咳嗽、咳痰10余年,加重1周', - patientName: '厉明', - gender: '男', - age: 52, - department: '呼吸内科', - scene: '普通门诊', - caseNo: '31190042', - tone: 'purple', - mode: 'training' - }, - { - id: 'case-2238019', - title: '尿频、尿急、尿痛3天', - patientName: '刘晓元', - gender: '女', - age: 25, - department: '泌尿外科', - scene: '急诊留观', - caseNo: '2238019', - tone: 'green', - mode: 'training' - } - ]) +export type CaseListQuery = { + search?: string + case_type?: string + difficulty?: string + department?: string | number + page?: number + page_size?: number +} + +export type CaseListPage = { + count: number + next: string | null + previous: string | null + results: ClinicalCase[] +} + +type QueryValue = string | number | boolean | null | undefined + +type ServerCaseListPage = { + count?: number + next?: string | null + previous?: string | null + results?: ServerCaseRecord[] +} + +type ServerCaseRecord = { + id?: number | string + title?: string + case_type?: string + case_type_display?: string + difficulty?: string + difficulty_score?: number + department?: number + department_name?: string + chief_complaint?: string + description?: string + patient_age?: number + patient_gender?: string + tags?: string + competency_tags?: string[] + estimated_minutes?: number + osce_enabled?: boolean + created_at?: string + my_best_score?: number | null + my_train_count?: number | null +} + +const CASE_LIST_PATHS: Record = { + recommended: '/case/mobile/recommended/', + specialty: '/case/mobile/specialty/', + weak: '/case/mobile/weak/', + teaching: '/case/mobile/teaching/', + 'teacher-task': '/case/mobile/teacher-task/' +} + +const TONES: CaseTone[] = ['blue', 'teal', 'pink', 'orange', 'purple', 'green'] + +export async function fetchCaseList(source: CaseListSource = 'recommended', query: CaseListQuery = {}): Promise { + const page = await fetchCaseListPage(source, query) + return page.results +} + +export async function fetchCaseListPage(source: CaseListSource = 'recommended', query: CaseListQuery = {}): Promise { + const path = CASE_LIST_PATHS[source] || CASE_LIST_PATHS.recommended + const payload = await requestCaseList(path, query) + const page = normalizeCaseListPage(payload, source) + + return page } export function readStoredClinicalCase() { @@ -95,3 +107,149 @@ export function readStoredClinicalCase() { if (value && typeof value === 'object') return value as ClinicalCase return null } + +export function resolveClinicalCaseId(caseItem?: ClinicalCase | null) { + if (!caseItem) return 0 + + const explicitId = Number(caseItem.id) + if (Number.isInteger(explicitId) && explicitId > 0) return explicitId + + const caseNo = Number(caseItem.caseNo) + if (Number.isInteger(caseNo) && caseNo > 0) return caseNo + + const matched = String(caseItem.id).match(/\d+/) + const fallbackId = matched ? Number(matched[0]) : 0 + return Number.isInteger(fallbackId) && fallbackId > 0 ? fallbackId : 0 +} + +function requestCaseList(path: string, query: CaseListQuery): Promise { + 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 ServerCaseListPage) + return + } + + const payload = response.data as Record | 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 normalizeCaseListPage(payload: ServerCaseListPage, source: CaseListSource): CaseListPage { + const results = Array.isArray(payload.results) ? payload.results : [] + + return { + count: readNumber(payload.count, results.length), + next: typeof payload.next === 'string' ? payload.next : null, + previous: typeof payload.previous === 'string' ? payload.previous : null, + results: results.map((item, index) => normalizeCaseRecord(item, source, index)) + } +} + +function normalizeCaseRecord(item: ServerCaseRecord, source: CaseListSource, index: number): ClinicalCase { + const id = readString(item.id, `case-${index + 1}`) + const title = readString(item.title, '未命名病例') + const gender = normalizeGender(item.patient_gender) + const departmentName = readString(item.department_name, '未配置科室') + const caseType = readString(item.case_type) + const caseTypeDisplay = readString(item.case_type_display, caseType || '练习病例') + const chiefComplaint = readString(item.chief_complaint, title) + const mode = source === 'teaching' || caseType === 'teaching' ? 'teaching' : 'training' + + return { + id, + title, + patientName: gender === '女' ? '患者女士' : '患者先生', + gender, + age: readNumber(item.patient_age, 0), + department: departmentName, + departmentId: readOptionalNumber(item.department), + scene: caseTypeDisplay, + caseNo: id, + tone: TONES[index % TONES.length], + mode, + caseType, + caseTypeDisplay, + difficulty: readString(item.difficulty), + difficultyScore: readOptionalNumber(item.difficulty_score), + chiefComplaint, + description: readString(item.description), + tags: readString(item.tags), + competencyTags: Array.isArray(item.competency_tags) ? item.competency_tags.map(String) : [], + estimatedMinutes: readOptionalNumber(item.estimated_minutes), + osceEnabled: Boolean(item.osce_enabled), + myBestScore: typeof item.my_best_score === 'number' ? item.my_best_score : null, + myTrainCount: typeof item.my_train_count === 'number' ? item.my_train_count : null, + createdAt: readString(item.created_at), + source + } +} + +function createAuthHeaders() { + const headers: Record = { + '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) { + 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 + const message = payload.message || payload.detail || payload.error + if (typeof message === 'string' && message.trim()) return message + } + return fallback +} + +function readString(value: unknown, fallback = '') { + if (typeof value === 'string') return value.trim() || fallback + if (typeof value === 'number') return String(value) + return fallback +} + +function readNumber(value: unknown, fallback = 0) { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +function readOptionalNumber(value: unknown) { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : undefined +} + +function normalizeGender(value: unknown): '男' | '女' { + if (value === 'female' || value === '女') return '女' + return '男' +} diff --git a/api/profile.ts b/api/profile.ts new file mode 100644 index 0000000..32dbaf0 --- /dev/null +++ b/api/profile.ts @@ -0,0 +1,294 @@ +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 = { + 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 + weak_dimensions: string[] + strong_dimensions: string[] + ai_preference: Record + 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 & Record + +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('/user/profile/') +} + +export function fetchUserTrainingRecords(query: TrainingRecordQuery = {}) { + return serverRequest('/user/training-records/', { + search: query.search, + page: query.page + }) +} + +export function fetchUserAnalysis() { + return serverRequest('/user/analysis/') +} + +export function fetchCompetencyMetrics() { + return serverRequest('/user/competency-metrics/') +} + +export async function fetchEvaluationList(query: EvaluationListQuery = {}) { + const payload = await serverRequest('/v1/evaluations', { + page: query.page, + page_size: query.page_size + }) + return unwrapServerData(payload) +} + +export async function fetchServerEvaluationDetail(evaluationId: number) { + const payload = await serverRequest(`/v1/evaluations/${encodeURIComponent(String(evaluationId))}`) + const data = unwrapServerData>(payload) + return normalizeEvaluationDetail(data, evaluationId) +} + +function serverRequest(path: string, query?: Record): Promise { + 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 | 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 = { + '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) { + 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 + const message = payload.message || payload.detail || payload.error + if (typeof message === 'string' && message.trim()) return message + } + return fallback +} + +function unwrapServerData(payload: unknown): T { + if (payload && typeof payload === 'object' && 'data' in payload) { + const envelope = payload as ServerEnvelope + 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, 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 { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/api/scenario.ts b/api/scenario.ts index d557dc2..47048e8 100644 --- a/api/scenario.ts +++ b/api/scenario.ts @@ -194,7 +194,7 @@ export async function fetchTrainingConfigOptions(caseId: number) { export function createScenarioConfig(payload: ScenarioConfigPayload) { return createTrainingSession({ - case_id: DEFAULT_CASE_ID, + case_id: resolveCaseId(payload), training_type: 'diagnosis_treatment', mode: payload.mode, score_type: 'percentage', diff --git a/pages/assessment/assessment.vue b/pages/assessment/assessment.vue index 4b0151b..14d6bf4 100644 --- a/pages/assessment/assessment.vue +++ b/pages/assessment/assessment.vue @@ -154,6 +154,7 @@ diff --git a/pages/home/home.vue b/pages/home/home.vue index fdf8b28..776cd04 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -39,7 +39,7 @@ v-for="module in trainingModules" :key="module.title" class="module-card" - @click="handleStartTraining" + @click="openCaseList(module.source)" > {{ module.title }} @@ -67,6 +67,7 @@