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
+155 -5
View File
@@ -3,7 +3,7 @@
v-if="showDiagnosisPage"
:case-item="caseItem"
@open-settings="emit('open-settings')"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
@go-home="emit('go-home')"
/>
<view v-else class="chat-page">
@@ -16,7 +16,7 @@
<view class="home-icon"></view>
</button>
<view class="nav-spacer"></view>
<button class="icon-button" aria-label="个人中心" @click="emit('open-profile')">
<button class="icon-button" aria-label="个人中心" @click="openProfile">
<view class="account-icon"></view>
</button>
</view>
@@ -78,9 +78,9 @@
</view>
</scroll-view>
<view class="mentor-float">
<view class="mentor-float" :class="{ loading: hinting }" @click.stop="handleMentorHint" @tap.stop="handleMentorHint">
<text class="mentor-badge">王主任</text>
<view class="mentor-avatar">
<view class="mentor-avatar" @click.stop="handleMentorHint" @tap.stop="handleMentorHint">
<image src="/static/config-doctor.png" mode="aspectFill"></image>
</view>
</view>
@@ -195,6 +195,8 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import type { ClinicalCase } from '../../api/cases'
import { createMockChatSession, sendMockChatMessage, type ChatMessage, type ChatSession } from '../../api/chat'
import { createProfileOpener } from '../../api/navigation'
import { streamSessionChat, streamSessionHint, type TrainingSession } from '../../api/session'
import DiagnosisPage from '../diagnosis/diagnosis.vue'
const props = defineProps<{
@@ -207,6 +209,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
const session = reactive<ChatSession>({
patient: {
name: '陈先生',
@@ -225,14 +229,22 @@ const session = reactive<ChatSession>({
const draft = ref('')
const sending = ref(false)
const hinting = ref(false)
const scrollTop = ref(0)
const toastMessage = ref('')
const toastVisible = ref(false)
const showDiagnosisPage = ref(false)
const examPanelVisible = ref(false)
const physicalPanelVisible = ref(false)
const activeSessionId = ref<number | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
let activeStreamController: AbortController | null = null
let activeHintController: AbortController | null = null
type StoredScenario = {
session?: TrainingSession
}
type AuxiliaryExam = {
name: string
@@ -278,11 +290,33 @@ function loadSession() {
createMockChatSession(props.caseItem).then(result => {
Object.assign(session.patient, result.patient)
session.stages = result.stages
const scenario = readStoredScenario()
if (scenario?.session?.session_id) {
activeSessionId.value = scenario.session.session_id
session.messages = scenario.session.patient_opening ? [
{
id: `patient-opening-${scenario.session.session_id}`,
role: 'patient',
content: scenario.session.patient_opening,
label: '患者'
}
] : []
scrollToBottom()
void streamPatientReply('开始问诊')
return
}
session.messages = result.messages
scrollToBottom()
})
}
function readStoredScenario() {
const value = uni.getStorageSync('clinical-thinking-scenario')
if (value && typeof value === 'object') return value as StoredScenario
return null
}
function sendQuickAction(content: string) {
draft.value = content
handleSend()
@@ -393,8 +427,20 @@ function handleSend() {
const content = draft.value.trim()
if (!content || sending.value) return
sending.value = true
draft.value = ''
if (activeSessionId.value) {
session.messages.push({
id: `doctor-${Date.now()}`,
role: 'doctor',
content,
label: '我'
})
scrollToBottom()
void streamPatientReply(content)
return
}
sending.value = true
sendMockChatMessage(content).then(messages => {
session.messages.push(...messages)
scrollToBottom()
@@ -403,6 +449,97 @@ function handleSend() {
})
}
async function streamPatientReply(message: string) {
const sessionId = activeSessionId.value
if (!sessionId) return
sending.value = true
activeStreamController?.abort()
activeStreamController = new AbortController()
const streamedMessageIndex = session.messages.length
session.messages.push({
id: `patient-stream-${Date.now()}`,
role: 'patient',
content: '',
label: '患者'
})
scrollToBottom()
try {
await streamSessionChat(sessionId, message, {
onDelta(delta) {
session.messages[streamedMessageIndex].content += delta
scrollToBottom()
}
}, activeStreamController.signal)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!session.messages[streamedMessageIndex].content.trim()) {
session.messages[streamedMessageIndex].content = 'AI 病人回复失败,请重试。'
}
showToast(error instanceof Error ? error.message : 'AI 流式回复异常')
} finally {
sending.value = false
activeStreamController = null
scrollToBottom()
}
}
function findLastUserMessage() {
const lastDoctorMessage = [...session.messages].reverse().find(message => message.role === 'doctor')
return lastDoctorMessage?.content || '开始问诊'
}
async function handleMentorHint() {
const sessionId = activeSessionId.value
if (!sessionId) {
showToast('请先生成模拟场景')
return
}
if (hinting.value) return
hinting.value = true
activeHintController?.abort()
activeHintController = new AbortController()
const streamedMessageIndex = session.messages.length
session.messages.push({
id: `mentor-hint-${Date.now()}`,
role: 'mentor',
content: '王主任正在生成提示...',
label: '王主任'
})
scrollToBottom()
try {
let receivedFirstDelta = false
await streamSessionHint(sessionId, findLastUserMessage(), {
onDelta(delta) {
if (!receivedFirstDelta) {
session.messages[streamedMessageIndex].content = ''
receivedFirstDelta = true
}
session.messages[streamedMessageIndex].content += delta
scrollToBottom()
}
}, activeHintController.signal)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
if (
!session.messages[streamedMessageIndex].content.trim()
|| session.messages[streamedMessageIndex].content === '王主任正在生成提示...'
) {
session.messages[streamedMessageIndex].content = '练习提示生成失败,请稍后重试。'
}
showToast(error instanceof Error ? error.message : '练习提示生成失败,请稍后重试')
} finally {
hinting.value = false
activeHintController = null
scrollToBottom()
}
}
function scrollToBottom() {
setTimeout(() => {
scrollTop.value += 1000
@@ -425,6 +562,8 @@ function showToast(message: string) {
onMounted(loadSession)
onUnmounted(() => {
activeStreamController?.abort()
activeHintController?.abort()
if (toastTimer) clearTimeout(toastTimer)
})
</script>
@@ -872,6 +1011,17 @@ page {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
pointer-events: auto;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.mentor-float:active {
transform: scale(0.96);
}
.mentor-float.loading {
opacity: 0.72;
pointer-events: none;
}