feat: 联调登录+对话

This commit is contained in:
王天骄
2026-06-05 15:27:29 +08:00
parent 69c6a2969c
commit d962ab98f5
55 changed files with 526 additions and 93 deletions
+1 -5
View File
@@ -1,8 +1,4 @@
let apiBaseUrl = 'http://192.168.2.76:8000/api'
// #ifdef H5
apiBaseUrl = '/backend-api'
// #endif
const apiBaseUrl = '/server/api'
export const API_BASE_URL = apiBaseUrl
+19
View File
@@ -0,0 +1,19 @@
import { getCurrentInstance } from 'vue'
type OpenProfileEmit = (event: 'open-profile') => void
export function createProfileOpener(emit: OpenProfileEmit) {
const instance = getCurrentInstance()
const hasOpenProfileListener = Boolean(instance?.vnode.props?.onOpenProfile)
return function openProfile() {
if (hasOpenProfileListener) {
emit('open-profile')
return
}
uni.navigateTo({
url: '/pages/profile/profile'
})
}
}
+38 -9
View File
@@ -1,3 +1,5 @@
import { createTrainingSession, type PatientConfigPayload, type TrainingMode } from './session'
export type ScenarioRecommendation = {
id: string
title: string
@@ -28,6 +30,7 @@ export type ScenarioOptions = {
export type ScenarioConfigPayload = ScenarioForm & {
caseId: string
caseNo: string
mode: TrainingMode
recommendationId?: string
}
@@ -41,7 +44,7 @@ export function fetchScenarioOptions() {
tags: ['中年', '配合'],
defaults: {
environment: 'outpatient',
ageGroup: 'middle',
ageGroup: 'middle_aged',
education: 'higher',
personality: 'calm'
}
@@ -55,7 +58,7 @@ export function fetchScenarioOptions() {
environment: 'emergency',
ageGroup: 'elderly',
education: 'secondary',
personality: 'irritable'
personality: 'impatient'
}
}
] as ScenarioRecommendation[],
@@ -67,19 +70,19 @@ export function fetchScenarioOptions() {
],
ageGroups: [
{ value: 'child', label: '儿童' },
{ value: 'young', label: '青年' },
{ value: 'middle', label: '中年' },
{ value: 'youth', label: '青年' },
{ value: 'middle_aged', label: '中年' },
{ value: 'elderly', label: '老年' }
],
educations: [
{ value: 'primary', label: '小学及以下' },
{ value: 'primary_or_below', label: '小学及以下' },
{ value: 'secondary', label: '中等教育' },
{ value: 'higher', label: '高等教育' }
],
personalities: [
{ value: 'calm', label: '平和' },
{ value: 'anxious', label: '焦虑' },
{ value: 'irritable', label: '急躁' },
{ value: 'impatient', label: '急躁' },
{ value: 'cooperative', label: '配合' },
{ value: 'suspicious', label: '多疑' }
]
@@ -88,9 +91,35 @@ export function fetchScenarioOptions() {
}
export function createScenarioConfig(payload: ScenarioConfigPayload) {
return Promise.resolve({
id: `mock-scenario-${Date.now()}`,
return createTrainingSession({
case_id: 1,
training_type: 'diagnosis_treatment',
mode: payload.mode,
score_type: 'percentage',
patient_config: {
visit_environment: payload.environment as PatientConfigPayload['visit_environment'],
age_group: payload.ageGroup as PatientConfigPayload['age_group'],
education_level: payload.education as PatientConfigPayload['education_level'],
personality: payload.personality as PatientConfigPayload['personality']
}
}).then(session => ({
id: session.session_code,
...payload,
session,
createdAt: new Date().toISOString()
})
}))
}
function resolveCaseId(payload: ScenarioConfigPayload) {
const explicitId = Number(payload.caseId)
if (Number.isInteger(explicitId) && explicitId > 0) return explicitId
const caseNo = Number(payload.caseNo)
if (Number.isInteger(caseNo) && caseNo > 0) return caseNo
const matched = payload.caseId.match(/\d+/)
const fallbackId = matched ? Number(matched[0]) : 0
if (Number.isInteger(fallbackId) && fallbackId > 0) return fallbackId
throw new Error('病例 ID 无效,无法新建会话')
}
+205
View File
@@ -0,0 +1,205 @@
export const FASTAPI_BASE_URL = '/fastapi/api/v1'
export type TrainingType = 'case_analysis' | 'diagnosis_treatment' | 'consultation'
export type TrainingMode = 'practice' | 'teaching'
export type ScoreType = 'percentage' | 'five_point'
export type PatientConfigPayload = {
visit_environment: 'outpatient' | 'emergency' | 'ward'
age_group: 'child' | 'youth' | 'middle_aged' | 'elderly'
education_level: 'primary_or_below' | 'secondary' | 'higher'
personality: 'calm' | 'anxious' | 'impatient' | 'cooperative' | 'suspicious'
}
export type CreateSessionPayload = {
case_id: number
training_type: TrainingType
mode: TrainingMode
score_type: ScoreType
patient_config: PatientConfigPayload
}
export type SessionPatientConfig = {
values: PatientConfigPayload
labels: Record<keyof PatientConfigPayload, string>
}
export type TrainingSession = {
session_id: number
session_code: string
status: string
patient_opening: string
patient_config: SessionPatientConfig
}
type ApiEnvelope<T> = {
code: string
message: string
data: T
}
type StreamCallbacks = {
onDelta: (delta: string) => void
onDone?: (meta: Record<string, unknown>) => void
}
type HintStreamPayload = {
last_user_message: string
scope: 'current_conversation'
}
function readAccessToken() {
const token = uni.getStorageSync('clinical-thinking-access-token')
if (typeof token !== 'string' || !token.trim()) {
throw new Error('登录已过期,请重新登录')
}
return token
}
function authHeaders(accept = 'application/json') {
return {
'Content-Type': 'application/json',
Accept: accept,
Authorization: `Bearer ${readAccessToken()}`,
'X-Entry-Scene': 'vue_frontend'
}
}
async function readError(response: Response) {
const text = await response.text().catch(() => '')
if (!text) return `请求失败(${response.status}`
try {
const payload = JSON.parse(text) as Record<string, unknown>
const message = payload.message || payload.detail || payload.error
if (typeof message === 'string' && message.trim()) return message
} catch {}
return text
}
export async function createTrainingSession(payload: CreateSessionPayload) {
const response = await fetch(`${FASTAPI_BASE_URL}/sessions`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(await readError(response))
}
const result = (await response.json()) as ApiEnvelope<TrainingSession>
if (result.code !== 'OK' || !result.data?.session_id) {
throw new Error(result.message || '新建会话失败')
}
return result.data
}
export async function streamSessionChat(
sessionId: number,
message: string,
callbacks: StreamCallbacks,
signal?: AbortSignal
) {
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/chat/stream`, {
method: 'POST',
headers: authHeaders('text/event-stream'),
body: JSON.stringify({ message }),
signal
})
if (!response.ok || !response.body) {
throw new Error(await readError(response))
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let completed = false
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split('\n\n')
buffer = blocks.pop() || ''
for (const block of blocks) {
const event = block.match(/^event:\s*(.+)$/m)?.[1]
const rawData = block.match(/^data:\s*(.+)$/m)?.[1]
if (!event || !rawData) continue
const data = JSON.parse(rawData) as Record<string, unknown>
if (event === 'message_delta') {
const delta = data.delta
if (typeof delta === 'string') callbacks.onDelta(delta)
} else if (event === 'message_done') {
completed = true
callbacks.onDone?.(data)
} else if (event === 'error') {
throw new Error(typeof data.message === 'string' ? data.message : 'AI 流式回复异常')
}
}
}
if (!completed) {
throw new Error('AI 流式回复未正常结束,请重试')
}
}
export async function streamSessionHint(
sessionId: number,
lastUserMessage: string,
callbacks: StreamCallbacks,
signal?: AbortSignal
) {
const payload: HintStreamPayload = {
last_user_message: lastUserMessage,
scope: 'current_conversation'
}
const response = await fetch(`${FASTAPI_BASE_URL}/sessions/${sessionId}/hints/stream`, {
method: 'POST',
headers: authHeaders('text/event-stream'),
body: JSON.stringify(payload),
signal
})
if (!response.ok || !response.body) {
throw new Error(await readError(response))
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let completed = false
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split('\n\n')
buffer = blocks.pop() || ''
for (const block of blocks) {
const event = block.match(/^event:\s*(.+)$/m)?.[1]
const rawData = block.match(/^data:\s*(.+)$/m)?.[1]
if (!event || !rawData) continue
const data = JSON.parse(rawData) as Record<string, unknown>
if (event === 'hint_delta') {
const delta = data.delta
if (typeof delta === 'string') callbacks.onDelta(delta)
} else if (event === 'hint_done') {
completed = true
callbacks.onDone?.(data)
} else if (event === 'error') {
throw new Error(typeof data.message === 'string' ? data.message : '练习提示生成失败,请稍后重试')
}
}
}
if (!completed) {
throw new Error('练习提示未正常结束,请重试')
}
}