2026-06-13 06:05:37 +08:00
|
|
|
|
import { API_BASE_URL, ApiRequestError } from './auth'
|
|
|
|
|
|
|
2026-06-01 15:35:17 +08:00
|
|
|
|
export type CaseMode = 'training' | 'teaching'
|
2026-06-13 06:05:37 +08:00
|
|
|
|
export type CaseListSource = 'recommended' | 'specialty' | 'weak' | 'teaching' | 'teacher-task'
|
|
|
|
|
|
export type CaseTone = 'blue' | 'teal' | 'pink' | 'orange' | 'purple' | 'green'
|
2026-06-01 15:35:17 +08:00
|
|
|
|
|
2026-05-29 17:40:10 +08:00
|
|
|
|
export type ClinicalCase = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
patientName: string
|
|
|
|
|
|
gender: '男' | '女'
|
|
|
|
|
|
age: number
|
|
|
|
|
|
department: string
|
2026-06-13 06:05:37 +08:00
|
|
|
|
departmentId?: number
|
2026-05-29 17:40:10 +08:00
|
|
|
|
scene: string
|
|
|
|
|
|
caseNo: string
|
2026-06-13 06:05:37 +08:00
|
|
|
|
tone: CaseTone
|
2026-06-01 15:35:17 +08:00
|
|
|
|
mode: CaseMode
|
2026-06-13 06:05:37 +08:00
|
|
|
|
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 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<CaseListSource, string> = {
|
|
|
|
|
|
recommended: '/case/mobile/recommended/',
|
|
|
|
|
|
specialty: '/case/mobile/specialty/',
|
|
|
|
|
|
weak: '/case/mobile/weak/',
|
|
|
|
|
|
teaching: '/case/mobile/teaching/',
|
|
|
|
|
|
'teacher-task': '/case/mobile/teacher-task/'
|
2026-05-29 17:40:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 06:05:37 +08:00
|
|
|
|
const TONES: CaseTone[] = ['blue', 'teal', 'pink', 'orange', 'purple', 'green']
|
|
|
|
|
|
|
|
|
|
|
|
export async function fetchCaseList(source: CaseListSource = 'recommended', query: CaseListQuery = {}): Promise<ClinicalCase[]> {
|
|
|
|
|
|
const page = await fetchCaseListPage(source, query)
|
|
|
|
|
|
return page.results
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function fetchCaseListPage(source: CaseListSource = 'recommended', query: CaseListQuery = {}): Promise<CaseListPage> {
|
|
|
|
|
|
const path = CASE_LIST_PATHS[source] || CASE_LIST_PATHS.recommended
|
|
|
|
|
|
const payload = await requestCaseList(path, query)
|
|
|
|
|
|
const page = normalizeCaseListPage(payload, source)
|
|
|
|
|
|
|
|
|
|
|
|
return page
|
2026-05-29 17:40:10 +08:00
|
|
|
|
}
|
2026-06-09 17:00:23 +08:00
|
|
|
|
|
|
|
|
|
|
export function readStoredClinicalCase() {
|
|
|
|
|
|
const value = uni.getStorageSync('clinical-thinking-selected-case')
|
|
|
|
|
|
if (value && typeof value === 'object') return value as ClinicalCase
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
2026-06-13 06:05:37 +08:00
|
|
|
|
|
|
|
|
|
|
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<ServerCaseListPage> {
|
|
|
|
|
|
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<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 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<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>) {
|
|
|
|
|
|
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 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 '男'
|
|
|
|
|
|
}
|