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
+184 -114
View File
@@ -1,12 +1,5 @@
<template>
<ChatPage
v-if="showChatPage"
:case-item="caseItem"
@open-settings="emit('open-settings')"
@open-profile="openProfile"
@go-home="emit('go-home')"
/>
<view v-else class="scenario-page">
<view class="scenario-page">
<view class="scenario-shell">
<view class="hero">
<image class="hero-image" src="/static/config-hospital.png" mode="aspectFill"></image>
@@ -60,81 +53,90 @@
</view>
</view>
<view class="section custom-section">
<view class="section-title">
<view class="section custom-section" :class="{ expanded: customConfigExpanded }">
<button
class="section-title custom-config-toggle"
:class="{ expanded: customConfigExpanded }"
:aria-label="customConfigExpanded ? '收起自定义配置' : '展开自定义配置'"
@click="toggleCustomConfig"
>
<view class="tune-icon"></view>
<text>自定义配置</text>
</view>
<view class="custom-toggle-spacer"></view>
<view class="toggle-chevron"></view>
</button>
<view class="option-block">
<view class="option-label">
<view class="location-icon"></view>
<text>就诊环境</text>
<view class="custom-options" :class="{ expanded: customConfigExpanded }">
<view class="option-block">
<view class="option-label">
<view class="location-icon"></view>
<text>就诊环境</text>
</view>
<view class="option-grid grid-3">
<button
v-for="option in options.environments"
:key="option.value"
class="choice"
:class="{ selected: form.environment === option.value }"
@click="selectOption('environment', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
<view class="option-grid grid-3">
<button
v-for="option in options.environments"
:key="option.value"
class="choice"
:class="{ selected: form.environment === option.value }"
@click="selectOption('environment', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
<view class="option-block">
<view class="option-label">
<view class="cake-icon"></view>
<text>年龄段</text>
<view class="option-block">
<view class="option-label">
<view class="cake-icon"></view>
<text>年龄段</text>
</view>
<view class="option-grid grid-4">
<button
v-for="option in options.ageGroups"
:key="option.value"
class="choice small"
:class="{ selected: form.ageGroup === option.value }"
@click="selectOption('ageGroup', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
<view class="option-grid grid-4">
<button
v-for="option in options.ageGroups"
:key="option.value"
class="choice small"
:class="{ selected: form.ageGroup === option.value }"
@click="selectOption('ageGroup', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
<view class="option-block">
<view class="option-label">
<view class="school-icon"></view>
<text>文化程度</text>
<view class="option-block">
<view class="option-label">
<view class="school-icon"></view>
<text>文化程度</text>
</view>
<view class="option-grid grid-3">
<button
v-for="option in options.educations"
:key="option.value"
class="choice mini"
:class="{ selected: form.education === option.value }"
@click="selectOption('education', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
<view class="option-grid grid-3">
<button
v-for="option in options.educations"
:key="option.value"
class="choice mini"
:class="{ selected: form.education === option.value }"
@click="selectOption('education', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
<view class="option-block personality-block">
<view class="option-label">
<view class="mind-icon"></view>
<text>性格</text>
</view>
<view class="option-grid grid-3">
<button
v-for="option in options.personalities"
:key="option.value"
class="choice"
:class="{ selected: form.personality === option.value }"
@click="selectOption('personality', option.value)"
>
{{ option.label }}
</button>
<view class="option-block personality-block">
<view class="option-label">
<view class="mind-icon"></view>
<text>性格</text>
</view>
<view class="option-grid grid-3">
<button
v-for="option in options.personalities"
:key="option.value"
class="choice"
:class="{ selected: form.personality === option.value }"
@click="selectOption('personality', option.value)"
>
{{ option.label }}
</button>
</view>
</view>
</view>
</view>
@@ -154,38 +156,23 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import type { ClinicalCase } from '../../api/cases'
import { readStoredClinicalCase, type ClinicalCase } from '../../api/cases'
import {
DEFAULT_SCENARIO_OPTIONS,
createScenarioConfig,
fetchScenarioOptions,
fetchTrainingConfigOptions,
type ScenarioForm,
type ScenarioOptions,
type ScenarioRecommendation
} from '../../api/scenario'
import { createProfileOpener } from '../../api/navigation'
import ChatPage from '../chat/chat.vue'
const props = defineProps<{
caseItem: ClinicalCase | null
caseItem?: ClinicalCase | null
}>()
const emit = defineEmits<{
(event: 'open-settings'): void
(event: 'open-profile'): void
(event: 'go-home'): void
}>()
const openProfile = createProfileOpener(emit)
type ScenarioFormKey = keyof ScenarioForm
const fallbackOptions: ScenarioOptions = {
environments: [],
ageGroups: [],
educations: [],
personalities: []
}
const form = reactive<ScenarioForm>({
environment: 'outpatient',
ageGroup: 'youth',
@@ -194,25 +181,35 @@ const form = reactive<ScenarioForm>({
})
const recommendations = ref<ScenarioRecommendation[]>([])
const options = ref<ScenarioOptions>(fallbackOptions)
const options = ref<ScenarioOptions>(DEFAULT_SCENARIO_OPTIONS)
const selectedRecommendationId = ref('')
const generating = ref(false)
const toastMessage = ref('')
const toastVisible = ref(false)
const showChatPage = ref(false)
const customConfigExpanded = ref(false)
const storedCase = ref<ClinicalCase | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
const activeCase = computed(() => props.caseItem || storedCase.value)
const currentCaseTitle = computed(() => {
if (!props.caseItem) return '未选择病例'
return `${props.caseItem.title}${props.caseItem.caseNo}`
if (!activeCase.value) return '未选择病例'
return `${activeCase.value.title}${activeCase.value.caseNo}`
})
function loadScenarioOptions() {
fetchScenarioOptions().then(result => {
recommendations.value = result.recommendations
async function loadScenarioOptions() {
const fallback = await fetchScenarioOptions()
recommendations.value = fallback.recommendations
options.value = fallback.options
try {
const result = await fetchTrainingConfigOptions(1)
options.value = result.options
})
Object.assign(form, result.recommended)
} catch (error) {
showToast(error instanceof Error ? error.message : '推荐配置加载失败')
}
}
function applyRecommendation(item: ScenarioRecommendation) {
@@ -225,8 +222,12 @@ function selectOption(key: ScenarioFormKey, value: string) {
selectedRecommendationId.value = ''
}
function toggleCustomConfig() {
customConfigExpanded.value = !customConfigExpanded.value
}
function handleGenerate() {
if (!props.caseItem) {
if (!activeCase.value) {
showToast('请先选择病例')
return
}
@@ -234,15 +235,17 @@ function handleGenerate() {
generating.value = true
createScenarioConfig({
...form,
caseId: props.caseItem.id,
caseNo: props.caseItem.caseNo,
mode: props.caseItem.mode === 'teaching' ? 'teaching' : 'practice',
caseId: activeCase.value.id,
caseNo: activeCase.value.caseNo,
mode: activeCase.value.mode === 'teaching' ? 'teaching' : 'practice',
recommendationId: selectedRecommendationId.value || undefined
}).then(result => {
uni.setStorageSync('clinical-thinking-scenario', result)
showToast('模拟场景已生成')
setTimeout(() => {
showChatPage.value = true
uni.navigateTo({
url: '/pages/chat/chat'
})
}, 450)
}).catch(error => {
showToast(error instanceof Error ? error.message : '模拟场景生成失败')
@@ -257,16 +260,15 @@ function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
uni.showToast({
title: message,
icon: 'none'
})
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
onMounted(loadScenarioOptions)
onMounted(() => {
storedCase.value = readStoredClinicalCase()
loadScenarioOptions()
})
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
@@ -444,6 +446,66 @@ page {
gap: 12px;
}
.custom-section {
margin-bottom: 16px;
}
.custom-section.expanded {
margin-bottom: 24px;
}
.custom-config-toggle {
width: 100%;
min-height: 44px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.custom-config-toggle::after {
border: 0;
}
.custom-toggle-spacer {
flex: 1;
}
.toggle-chevron {
width: 20px;
height: 20px;
background: #424752;
transition: transform 0.2s ease, background 0.2s ease;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M7.4%208.6%2012%2013.2l4.6-4.6L18%2010l-6%206-6-6%201.4-1.4z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M7.4%208.6%2012%2013.2l4.6-4.6L18%2010l-6%206-6-6%201.4-1.4z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.custom-config-toggle.expanded .toggle-chevron {
background: #00478d;
transform: rotate(180deg);
}
.custom-options {
max-height: 0;
opacity: 0;
overflow: hidden;
transform: translateY(-4px);
transition: max-height 0.24s ease, opacity 0.18s ease, transform 0.18s ease;
display: flex;
flex-direction: column;
gap: 16px;
}
.custom-options.expanded {
max-height: 760px;
opacity: 1;
transform: translateY(0);
}
.recommend-card {
width: 100%;
padding: 16px;
@@ -558,7 +620,8 @@ page {
.option-grid {
display: grid;
gap: 8px;
gap: 10px;
width: 100%;
}
.grid-3 {
@@ -570,15 +633,22 @@ page {
}
.choice {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 40px;
padding: 8px 4px;
min-height: 42px;
padding: 8px 6px;
border: 1px solid #c2c6d4;
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
color: #424752;
font-size: 14px;
line-height: 20px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
white-space: nowrap;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}