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
+4 -1
View File
@@ -8,7 +8,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>
@@ -100,6 +100,7 @@
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
import { createProfileOpener } from '../../api/navigation'
const emit = defineEmits<{
(event: 'open-settings'): void
@@ -107,6 +108,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
const dimensions = [
{ label: '病史采集', score: 92 },
{ label: '体格检查', score: 85 },
+6 -3
View File
@@ -3,14 +3,14 @@
v-if="showScenarioPage"
:case-item="selectedCase"
@open-settings="emit('open-settings')"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
@go-home="emit('go-home')"
/>
<TeachingPage
v-else-if="showTeachingPage"
:case-item="selectedCase"
@open-settings="emit('open-settings')"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
@go-home="emit('go-home')"
/>
<view v-else class="cases-page">
@@ -23,7 +23,7 @@
<view class="home-icon"></view>
</button>
<view class="header-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>
@@ -85,6 +85,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { fetchCaseList, type CaseMode, type ClinicalCase } from '../../api/cases'
import { createProfileOpener } from '../../api/navigation'
import ScenarioPage from '../scenario/scenario.vue'
import TeachingPage from '../teaching/teaching.vue'
@@ -94,6 +95,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
const cases = ref<ClinicalCase[]>([])
const keyword = ref('')
const toastMessage = ref('')
+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;
}
+4 -1
View File
@@ -2,7 +2,7 @@
<HomePage
v-if="showHomePage"
@open-settings="returnToSettings"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
/>
<view v-else class="config-page">
<view class="phone-frame">
@@ -100,6 +100,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import HomePage from '../home/home.vue'
import { createProfileOpener } from '../../api/navigation'
import {
MOCK_CONFIG_OPTIONS,
fetchConfigOptions,
@@ -142,6 +143,8 @@ const emit = defineEmits<{
(event: 'open-profile'): void
}>()
const openProfile = createProfileOpener(emit)
let typingTimer: ReturnType<typeof setTimeout> | null = null
let toastTimer: ReturnType<typeof setTimeout> | null = null
+5 -2
View File
@@ -3,7 +3,7 @@
v-if="showTreatmentPage"
: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="diagnosis-page">
@@ -15,7 +15,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>
@@ -134,6 +134,7 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import type { ClinicalCase } from '../../api/cases'
import { fetchDiagnosisContext, submitDiagnosis, type DiagnosisDraft } from '../../api/diagnosis'
import { createProfileOpener } from '../../api/navigation'
import TreatmentPage from '../treatment/treatment.vue'
const props = defineProps<{
@@ -146,6 +147,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
type SubmitState = 'idle' | 'submitted'
const form = reactive<DiagnosisDraft>({
+5 -2
View File
@@ -2,7 +2,7 @@
<MatchingPage
v-if="showMatchingPage"
@open-settings="emit('open-settings')"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
@go-home="showMatchingPage = false"
/>
<view v-else class="home-page">
@@ -12,7 +12,7 @@
<view class="settings-icon"></view>
</button>
<view class="top-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>
@@ -49,6 +49,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { fetchHomeSummary, startTrainingSession, type HomeSummary } from '../../api/home'
import { createProfileOpener } from '../../api/navigation'
import MatchingPage from '../matching/matching.vue'
const emit = defineEmits<{
@@ -56,6 +57,8 @@ const emit = defineEmits<{
(event: 'open-profile'): void
}>()
const openProfile = createProfileOpener(emit)
const summary = reactive<HomeSummary>({
greeting: '下午好,医生。',
highlight: '让我们继续提升您的临床思维能力吧。',
+4 -1
View File
@@ -2,7 +2,7 @@
<CasesPage
v-if="showCasesPage"
@open-settings="emit('open-settings')"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
@go-home="emit('go-home')"
/>
<view v-else class="matching-page">
@@ -70,6 +70,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { fetchMatchingProfile, type MatchingProfile } from '../../api/matching'
import { createProfileOpener } from '../../api/navigation'
import CasesPage from '../cases/cases.vue'
const emit = defineEmits<{
@@ -78,6 +79,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
type Particle = {
id: number
style: Record<string, string>
+8 -2
View File
@@ -3,7 +3,7 @@
v-if="showChatPage"
: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="scenario-page">
@@ -162,6 +162,7 @@ import {
type ScenarioOptions,
type ScenarioRecommendation
} from '../../api/scenario'
import { createProfileOpener } from '../../api/navigation'
import ChatPage from '../chat/chat.vue'
const props = defineProps<{
@@ -174,6 +175,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
type ScenarioFormKey = keyof ScenarioForm
const fallbackOptions: ScenarioOptions = {
@@ -185,7 +188,7 @@ const fallbackOptions: ScenarioOptions = {
const form = reactive<ScenarioForm>({
environment: 'outpatient',
ageGroup: 'young',
ageGroup: 'youth',
education: 'higher',
personality: 'calm'
})
@@ -233,6 +236,7 @@ function handleGenerate() {
...form,
caseId: props.caseItem.id,
caseNo: props.caseItem.caseNo,
mode: props.caseItem.mode === 'teaching' ? 'teaching' : 'practice',
recommendationId: selectedRecommendationId.value || undefined
}).then(result => {
uni.setStorageSync('clinical-thinking-scenario', result)
@@ -240,6 +244,8 @@ function handleGenerate() {
setTimeout(() => {
showChatPage.value = true
}, 450)
}).catch(error => {
showToast(error instanceof Error ? error.message : '模拟场景生成失败')
}).finally(() => {
setTimeout(() => {
generating.value = false
+4 -1
View File
@@ -9,7 +9,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>
@@ -107,6 +107,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ClinicalCase } from '../../api/cases'
import { createProfileOpener } from '../../api/navigation'
const props = defineProps<{
caseItem: ClinicalCase | null
@@ -118,6 +119,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
type OptionKey = 'A' | 'B' | 'C' | 'D'
type TeachingQuestion = {
+5 -2
View File
@@ -2,7 +2,7 @@
<AssessmentPage
v-if="showAssessmentPage"
@open-settings="emit('open-settings')"
@open-profile="emit('open-profile')"
@open-profile="openProfile"
@go-home="emit('go-home')"
/>
<view v-else class="treatment-page">
@@ -15,7 +15,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>
@@ -133,6 +133,7 @@
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
import type { ClinicalCase } from '../../api/cases'
import { createProfileOpener } from '../../api/navigation'
import AssessmentPage from '../assessment/assessment.vue'
defineProps<{
@@ -145,6 +146,8 @@ const emit = defineEmits<{
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
const form = reactive({
principle: '',
measures: ['', ''],