feat: 联调对话功能

This commit is contained in:
王天骄
2026-06-09 17:00:23 +08:00
parent 3414d0662c
commit 2192b855a1
77 changed files with 1082 additions and 487 deletions
+53
View File
@@ -0,0 +1,53 @@
import { FASTAPI_BASE_URL, authHeaders, readError, type ApiEnvelope, type ScoreType } from './session'
export type DimensionScore = {
dimension: string
score: number
max_score: number
comment?: string
evidence?: string[]
deductions?: string[]
improvement?: string
}
export type ScoreDetail = {
dimension: string
score: number
deducted_reason?: string
ai_confidence?: number
comment?: string
}
export type EvaluationResult = {
evaluation_id: number
score_type: ScoreType
total_score: number
dimension_scores: DimensionScore[]
score_details: ScoreDetail[]
errors: string[]
improvement_plan: string[]
evidence_summary: string[]
guideline_refs: string[]
overall_comment: string
}
export async function generateEvaluation(sessionId: number, scoreType: ScoreType = 'percentage') {
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/evaluation`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
score_type: scoreType
})
})
if (!response.ok) {
throw new Error(await readError(response))
}
const result = (await response.json()) as ApiEnvelope<EvaluationResult>
if (result.code !== 'OK' || !result.data) {
throw new Error(result.message || '评价生成失败')
}
return result.data
}
+50 -4
View File
@@ -58,15 +58,36 @@ function readErrorMessage(data: unknown, fallback: string) {
return fallback
}
function request<T>(url: string, data: unknown, method: 'POST' | 'GET' = 'POST'): Promise<T> {
function readAccessToken() {
try {
return uni.getStorageSync('clinical-thinking-access-token') || ''
} catch {
return ''
}
}
function createRequestHeaders(includeAuth = false) {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (!includeAuth) return headers
const token = readAccessToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
function request<T>(url: string, data: unknown, method: 'POST' | 'GET' = 'POST', includeAuth = false): Promise<T> {
return new Promise((resolve, reject) => {
uni.request({
url: `${API_BASE_URL}${url}`,
method,
timeout: 10000,
header: {
'Content-Type': 'application/json'
},
header: createRequestHeaders(includeAuth),
data,
success: response => {
if (response.statusCode >= 200 && response.statusCode < 300) {
@@ -114,10 +135,35 @@ export type InstitutionRecord = {
is_trial: boolean
}
export type InstitutionInfo = {
id: number
code: string
name: string
type: string
level: string
province: string
city: string
banner_url: string
}
export type DepartmentRecord = {
id: number
name: string
category: string
}
export function fetchInstitutions(): Promise<InstitutionRecord[]> {
return request<InstitutionRecord[]>('/user/institution_list/', null, 'GET')
}
export function fetchInstitutionInfo(): Promise<InstitutionInfo> {
return request<InstitutionInfo>('/user/institution_info/', null, 'GET', true)
}
export function fetchMyDepartments(): Promise<DepartmentRecord[]> {
return request<DepartmentRecord[]>('/user/my_departments/', null, 'GET', true)
}
export function loginWithCode(payload: LoginCodePayload): Promise<LoginResponse> {
return request<LoginResponse>('/user/auth/login-code/', payload).then(response => {
if (isLoginResponse(response)) return response
+6
View File
@@ -89,3 +89,9 @@ export function fetchCaseList(): Promise<ClinicalCase[]> {
}
])
}
export function readStoredClinicalCase() {
const value = uni.getStorageSync('clinical-thinking-selected-case')
if (value && typeof value === 'object') return value as ClinicalCase
return null
}
+52 -17
View File
@@ -1,3 +1,5 @@
import { API_BASE_URL, ApiRequestError } from './auth'
export type ConfigOption = {
value: string
label: string
@@ -11,21 +13,12 @@ export type ConfigOptions = {
}
export type ClinicalConfigPayload = {
userId: string
phone: string
institutionId: string
department: string
title: string
experience: string
departmentName: string
titleName: string
experienceName: string
department: number
title_name: string
practice_years: string
}
export type ClinicalConfigResult = ClinicalConfigPayload & {
id: string
updatedAt: string
}
export type ClinicalConfigResult = Record<string, unknown>
export const MOCK_CONFIG_OPTIONS: ConfigOptions = {
departments: [
@@ -65,10 +58,52 @@ export function fetchConfigOptions() {
})
}
function readAccessToken() {
try {
return uni.getStorageSync('clinical-thinking-access-token') || ''
} catch {
return ''
}
}
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
}
export function saveClinicalConfig(payload: ClinicalConfigPayload): Promise<ClinicalConfigResult> {
return Promise.resolve({
id: `mock-config-${Date.now()}`,
...payload,
updatedAt: new Date().toISOString()
return new Promise((resolve, reject) => {
const token = readAccessToken()
const header: Record<string, string> = {
'Content-Type': 'application/json'
}
if (token) {
header.Authorization = `Bearer ${token}`
}
uni.request({
url: `${API_BASE_URL}/user/profile/config/`,
method: 'POST',
timeout: 10000,
header,
data: payload,
success: response => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data as ClinicalConfigResult)
return
}
const data = response.data as Record<string, unknown> | undefined
const code = typeof data?.code === 'string' ? data.code : undefined
reject(new ApiRequestError(readErrorMessage(response.data, `保存失败(${response.statusCode}`), code, response.statusCode))
},
fail: error => {
reject(new ApiRequestError(error.errMsg || '无法连接服务'))
}
})
})
}
+29 -6
View File
@@ -1,4 +1,5 @@
import type { ClinicalCase } from './cases'
import { FASTAPI_BASE_URL, authHeaders, readError, type ApiEnvelope } from './session'
export type DiagnosisDraft = {
primaryDiagnosis: string
@@ -6,6 +7,12 @@ export type DiagnosisDraft = {
evidence: string
}
export type DiagnosisPayload = {
primary_diagnosis: string
differential_diagnoses: string[]
diagnosis_basis: string
}
export type DiagnosisContext = {
mentorAdvice: string
defaultDraft: DiagnosisDraft
@@ -26,11 +33,27 @@ export function fetchDiagnosisContext(caseItem?: ClinicalCase | null): Promise<D
})
}
export function submitDiagnosis(caseId: string, draft: DiagnosisDraft) {
return Promise.resolve({
id: `mock-diagnosis-${Date.now()}`,
caseId,
...draft,
submittedAt: new Date().toISOString()
export async function submitDiagnosis(sessionId: number, draft: DiagnosisDraft) {
const payload: DiagnosisPayload = {
primary_diagnosis: draft.primaryDiagnosis.trim(),
differential_diagnoses: draft.differentialDiagnosis.map(item => item.trim()).filter(Boolean),
diagnosis_basis: draft.evidence.trim()
}
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/diagnosis`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(await readError(response))
}
const result = (await response.json()) as ApiEnvelope<unknown>
if (result.code !== 'OK') {
throw new Error(result.message || '诊断提交失败')
}
return result.data || payload
}
+34
View File
@@ -1,6 +1,8 @@
import { getCurrentInstance } from 'vue'
type OpenProfileEmit = (event: 'open-profile') => void
type OpenSettingsEmit = (event: 'open-settings') => void
type GoHomeEmit = (event: 'go-home') => void
export function createProfileOpener(emit: OpenProfileEmit) {
const instance = getCurrentInstance()
@@ -17,3 +19,35 @@ export function createProfileOpener(emit: OpenProfileEmit) {
})
}
}
export function createSettingsOpener(emit: OpenSettingsEmit) {
const instance = getCurrentInstance()
const hasOpenSettingsListener = Boolean(instance?.vnode.props?.onOpenSettings)
return function openSettings() {
if (hasOpenSettingsListener) {
emit('open-settings')
return
}
uni.navigateTo({
url: '/pages/config/config'
})
}
}
export function createHomeNavigator(emit: GoHomeEmit) {
const instance = getCurrentInstance()
const hasGoHomeListener = Boolean(instance?.vnode.props?.onGoHome)
return function goHome() {
if (hasGoHomeListener) {
emit('go-home')
return
}
uni.reLaunch({
url: '/pages/home/home'
})
}
}
+129 -27
View File
@@ -1,4 +1,11 @@
import { createTrainingSession, type PatientConfigPayload, type TrainingMode } from './session'
import {
FASTAPI_BASE_URL,
authHeaders,
createTrainingSession,
readError,
type PatientConfigPayload,
type TrainingMode
} from './session'
export type ScenarioRecommendation = {
id: string
@@ -11,6 +18,7 @@ export type ScenarioRecommendation = {
export type ScenarioOption = {
value: string
label: string
description?: string
}
export type ScenarioForm = {
@@ -27,6 +35,12 @@ export type ScenarioOptions = {
personalities: ScenarioOption[]
}
export type TrainingConfigRecommended = {
recommended: ScenarioForm
recommendedLabels: Record<ScenarioFormKey, string>
options: ScenarioOptions
}
export type ScenarioConfigPayload = ScenarioForm & {
caseId: string
caseNo: string
@@ -34,6 +48,61 @@ export type ScenarioConfigPayload = ScenarioForm & {
recommendationId?: string
}
type TrainingConfigApiKey = 'visit_environment' | 'age_group' | 'education_level' | 'personality'
const DEFAULT_CASE_ID = 1
type ApiEnvelope<T> = {
code: string
message: string
data: T
}
type ScenarioFormKey = keyof ScenarioForm
type TrainingConfigRecommendedResponse = {
case_id: number
recommended: {
visit_environment: string
age_group: string
education_level: string
personality: string
}
recommended_labels: {
visit_environment: string
age_group: string
education_level: string
personality: string
}
options?: Partial<Record<TrainingConfigApiKey, ScenarioOption[] | Record<string, string>>>
}
export const DEFAULT_SCENARIO_OPTIONS: ScenarioOptions = {
environments: [
{ value: 'outpatient', label: '门诊' },
{ value: 'emergency', label: '急诊' },
{ value: 'ward', label: '病房' }
],
ageGroups: [
{ value: 'child', label: '儿童' },
{ value: 'youth', label: '青年' },
{ value: 'middle_aged', label: '中年' },
{ value: 'elderly', label: '老年' }
],
educations: [
{ value: 'primary_or_below', label: '小学及以下' },
{ value: 'secondary', label: '中等教育' },
{ value: 'higher', label: '高等教育' }
],
personalities: [
{ value: 'calm', label: '平和' },
{ value: 'anxious', label: '焦虑' },
{ value: 'impatient', label: '急躁' },
{ value: 'cooperative', label: '配合' },
{ value: 'suspicious', label: '多疑' }
]
}
export function fetchScenarioOptions() {
return Promise.resolve({
recommendations: [
@@ -62,37 +131,70 @@ export function fetchScenarioOptions() {
}
}
] as ScenarioRecommendation[],
options: {
environments: [
{ value: 'outpatient', label: '门诊' },
{ value: 'emergency', label: '急诊' },
{ value: 'ward', label: '病房' }
],
ageGroups: [
{ value: 'child', label: '儿童' },
{ value: 'youth', label: '青年' },
{ value: 'middle_aged', label: '中年' },
{ value: 'elderly', label: '老年' }
],
educations: [
{ value: 'primary_or_below', label: '小学及以下' },
{ value: 'secondary', label: '中等教育' },
{ value: 'higher', label: '高等教育' }
],
personalities: [
{ value: 'calm', label: '平和' },
{ value: 'anxious', label: '焦虑' },
{ value: 'impatient', label: '急躁' },
{ value: 'cooperative', label: '配合' },
{ value: 'suspicious', label: '多疑' }
]
} as ScenarioOptions
options: DEFAULT_SCENARIO_OPTIONS
})
}
function normalizeApiOptions(
source: TrainingConfigRecommendedResponse['options'] | undefined,
key: keyof TrainingConfigRecommendedResponse['recommended'],
fallback: ScenarioOption[]
) {
const value = source?.[key]
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
return Object.entries(value).map(([optionValue, label]) => ({
value: optionValue,
label: String(label)
}))
}
return fallback
}
function normalizeTrainingConfig(payload: TrainingConfigRecommendedResponse): TrainingConfigRecommended {
return {
recommended: {
environment: payload.recommended.visit_environment,
ageGroup: payload.recommended.age_group,
education: payload.recommended.education_level,
personality: payload.recommended.personality
},
recommendedLabels: {
environment: payload.recommended_labels.visit_environment,
ageGroup: payload.recommended_labels.age_group,
education: payload.recommended_labels.education_level,
personality: payload.recommended_labels.personality
},
options: {
environments: normalizeApiOptions(payload.options, 'visit_environment', DEFAULT_SCENARIO_OPTIONS.environments),
ageGroups: normalizeApiOptions(payload.options, 'age_group', DEFAULT_SCENARIO_OPTIONS.ageGroups),
educations: normalizeApiOptions(payload.options, 'education_level', DEFAULT_SCENARIO_OPTIONS.educations),
personalities: normalizeApiOptions(payload.options, 'personality', DEFAULT_SCENARIO_OPTIONS.personalities)
}
}
}
export async function fetchTrainingConfigOptions(caseId: number) {
const response = await fetch(`${FASTAPI_BASE_URL}/training-config/options?case_id=${encodeURIComponent(caseId)}`, {
method: 'GET',
headers: authHeaders()
})
if (!response.ok) {
throw new Error(await readError(response))
}
const result = (await response.json()) as ApiEnvelope<TrainingConfigRecommendedResponse>
if (result.code !== 'OK' || !result.data?.recommended) {
throw new Error(result.message || '推荐配置加载失败')
}
return normalizeTrainingConfig(result.data)
}
export function createScenarioConfig(payload: ScenarioConfigPayload) {
return createTrainingSession({
case_id: 1,
case_id: DEFAULT_CASE_ID,
training_type: 'diagnosis_treatment',
mode: payload.mode,
score_type: 'percentage',
+57 -3
View File
@@ -32,7 +32,17 @@ export type TrainingSession = {
patient_config: SessionPatientConfig
}
type ApiEnvelope<T> = {
export type CompleteInquiryResult = {
session_id: number
status: string
}
export type StoredTrainingScenario = {
session?: TrainingSession
[key: string]: unknown
}
export type ApiEnvelope<T> = {
code: string
message: string
data: T
@@ -56,7 +66,7 @@ function readAccessToken() {
return token
}
function authHeaders(accept = 'application/json') {
export function authHeaders(accept = 'application/json') {
return {
'Content-Type': 'application/json',
Accept: accept,
@@ -65,7 +75,7 @@ function authHeaders(accept = 'application/json') {
}
}
async function readError(response: Response) {
export async function readError(response: Response) {
const text = await response.text().catch(() => '')
if (!text) return `请求失败(${response.status}`
try {
@@ -76,6 +86,32 @@ async function readError(response: Response) {
return text
}
export function readStoredTrainingScenario() {
const value = uni.getStorageSync('clinical-thinking-scenario')
if (value && typeof value === 'object') return value as StoredTrainingScenario
return null
}
export function readActiveSessionId() {
const sessionId = readStoredTrainingScenario()?.session?.session_id
if (typeof sessionId === 'number' && Number.isInteger(sessionId) && sessionId > 0) {
return sessionId
}
throw new Error('未找到当前会话,请先生成模拟场景')
}
export function updateStoredSessionStatus(status: string) {
const scenario = readStoredTrainingScenario()
if (!scenario?.session) return
uni.setStorageSync('clinical-thinking-scenario', {
...scenario,
session: {
...scenario.session,
status
}
})
}
export async function createTrainingSession(payload: CreateSessionPayload) {
const response = await fetch(`${FASTAPI_BASE_URL}/sessions`, {
method: 'POST',
@@ -94,6 +130,24 @@ export async function createTrainingSession(payload: CreateSessionPayload) {
return result.data
}
export async function completeInquiry(sessionId: number) {
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/complete-inquiry`, {
method: 'POST',
headers: authHeaders()
})
if (!response.ok) {
throw new Error(await readError(response))
}
const result = (await response.json()) as ApiEnvelope<CompleteInquiryResult>
if (result.code !== 'OK' || !result.data?.session_id) {
throw new Error(result.message || '完成采集失败')
}
return result.data
}
export async function streamSessionChat(
sessionId: number,
message: string,
+44
View File
@@ -0,0 +1,44 @@
import { FASTAPI_BASE_URL, authHeaders, readError, type ApiEnvelope } from './session'
export type TreatmentDraft = {
treatmentPrinciple: string
treatmentMeasures: string
riskPlan: string
communication: string
followUp: string
}
export type TreatmentPayload = {
treatment_principle: string
treatment_measures: string
risk_plan: string
communication: string
follow_up: string
}
export async function submitTreatment(sessionId: number, draft: TreatmentDraft) {
const payload: TreatmentPayload = {
treatment_principle: draft.treatmentPrinciple.trim(),
treatment_measures: draft.treatmentMeasures.trim(),
risk_plan: draft.riskPlan.trim(),
communication: draft.communication.trim(),
follow_up: draft.followUp.trim()
}
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/treatment`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(await readError(response))
}
const result = (await response.json()) as ApiEnvelope<unknown>
if (result.code !== 'OK') {
throw new Error(result.message || '治疗方案提交失败')
}
return result.data || payload
}