Files
vueapp/pages/scenario/scenario.vue
T
2026-06-09 17:00:23 +08:00

735 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="scenario-page">
<view class="scenario-shell">
<view class="hero">
<image class="hero-image" src="/static/config-hospital.png" mode="aspectFill"></image>
<view class="hero-fade"></view>
</view>
<view class="director-row">
<view class="director-avatar">
<image class="director-image" src="/static/config-doctor.png" mode="aspectFit"></image>
</view>
<view class="speech-bubble">
<text>欢迎回来请根据您的教学目标配置临床场景</text>
</view>
</view>
<scroll-view class="config-scroll" scroll-y>
<view class="selected-case">
<text class="case-label">当前病例</text>
<text class="case-title">{{ currentCaseTitle }}</text>
</view>
<view class="section">
<view class="section-title">
<view class="star-icon"></view>
<text>王主任推荐</text>
</view>
<view class="recommend-list">
<button
v-for="item in recommendations"
:key="item.id"
class="recommend-card"
:class="{ active: selectedRecommendationId === item.id }"
@click="applyRecommendation(item)"
>
<view class="recommend-head">
<text class="recommend-icon">{{ item.tags[0] }}</text>
<text class="recommend-title">{{ item.title }}</text>
</view>
<text class="recommend-desc">{{ item.desc }}</text>
<view class="tag-row">
<text
v-for="tag in item.tags"
:key="tag"
class="tag"
:class="{ danger: tag === '急躁' }"
>
{{ tag }}
</text>
</view>
</button>
</view>
</view>
<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 class="custom-toggle-spacer"></view>
<view class="toggle-chevron"></view>
</button>
<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-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-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-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>
</scroll-view>
<view class="bottom-action">
<button class="generate-button" :disabled="generating" @click="handleGenerate">
<text>{{ generating ? '正在生成...' : '生成模拟场景' }}</text>
<view class="arrow-icon"></view>
</button>
</view>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { readStoredClinicalCase, type ClinicalCase } from '../../api/cases'
import {
DEFAULT_SCENARIO_OPTIONS,
createScenarioConfig,
fetchScenarioOptions,
fetchTrainingConfigOptions,
type ScenarioForm,
type ScenarioOptions,
type ScenarioRecommendation
} from '../../api/scenario'
const props = defineProps<{
caseItem?: ClinicalCase | null
}>()
type ScenarioFormKey = keyof ScenarioForm
const form = reactive<ScenarioForm>({
environment: 'outpatient',
ageGroup: 'youth',
education: 'higher',
personality: 'calm'
})
const recommendations = ref<ScenarioRecommendation[]>([])
const options = ref<ScenarioOptions>(DEFAULT_SCENARIO_OPTIONS)
const selectedRecommendationId = ref('')
const generating = ref(false)
const toastMessage = ref('')
const toastVisible = 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 (!activeCase.value) return '未选择病例'
return `${activeCase.value.title}${activeCase.value.caseNo}`
})
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) {
selectedRecommendationId.value = item.id
Object.assign(form, item.defaults)
}
function selectOption(key: ScenarioFormKey, value: string) {
form[key] = value
selectedRecommendationId.value = ''
}
function toggleCustomConfig() {
customConfigExpanded.value = !customConfigExpanded.value
}
function handleGenerate() {
if (!activeCase.value) {
showToast('请先选择病例')
return
}
generating.value = true
createScenarioConfig({
...form,
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(() => {
uni.navigateTo({
url: '/pages/chat/chat'
})
}, 450)
}).catch(error => {
showToast(error instanceof Error ? error.message : '模拟场景生成失败')
}).finally(() => {
setTimeout(() => {
generating.value = false
}, 600)
})
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
onMounted(() => {
storedCase.value = readStoredClinicalCase()
loadScenarioOptions()
})
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
})
</script>
<style>
page {
min-height: 100%;
background: #f9f9ff;
}
.scenario-page {
min-height: 100vh;
background: #f9f9ff;
color: #191c21;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.scenario-shell {
position: relative;
min-height: 100vh;
overflow: hidden;
background: #f9f9ff;
display: flex;
flex-direction: column;
}
.hero {
position: relative;
flex: 0 0 auto;
height: 33.333vh;
min-height: 245px;
}
.hero-image {
width: 100%;
height: 100%;
}
.hero-fade {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(249, 249, 255, 0), rgba(249, 249, 255, 0.3));
}
.director-row {
position: relative;
z-index: 2;
margin-top: -40px;
padding: 0 20px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.director-avatar {
flex: 0 0 auto;
width: 96px;
height: 96px;
}
.director-image {
width: 100%;
height: 100%;
}
.speech-bubble {
position: relative;
flex: 1;
margin-top: 16px;
padding: 16px;
border: 1px solid rgba(194, 198, 212, 0.3);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 4px 10px rgba(25, 28, 33, 0.1);
color: #00478d;
font-size: 14px;
line-height: 22px;
font-weight: 600;
}
.speech-bubble::after {
content: '';
position: absolute;
left: -9px;
top: 20px;
width: 16px;
height: 16px;
background: #ffffff;
border-left: 1px solid rgba(194, 198, 212, 0.3);
border-bottom: 1px solid rgba(194, 198, 212, 0.3);
transform: rotate(45deg);
}
.config-scroll {
box-sizing: border-box;
flex: 1;
height: 1px;
padding: 16px 20px 108px;
}
.selected-case {
margin-bottom: 20px;
padding: 12px;
border: 1px solid rgba(194, 198, 212, 0.35);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
}
.case-label {
display: block;
margin-bottom: 4px;
color: #727783;
font-size: 12px;
line-height: 16px;
}
.case-title {
color: #191c21;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.section {
margin-bottom: 24px;
}
.section-title {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
color: #191c21;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.star-icon,
.tune-icon,
.location-icon,
.cake-icon,
.school-icon,
.mind-icon,
.arrow-icon {
background: #00478d;
}
.star-icon,
.tune-icon {
width: 20px;
height: 20px;
}
.star-icon {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='m12%202%202.7%206.6%207.1.6-5.4%204.6%201.7%206.9L12%2017l-6.1%203.7%201.7-6.9-5.4-4.6%207.1-.6L12%202z'/%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='m12%202%202.7%206.6%207.1.6-5.4%204.6%201.7%206.9L12%2017l-6.1%203.7%201.7-6.9-5.4-4.6%207.1-.6L12%202z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.tune-icon {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M3%2017v2h6v-2H3zM3%205v2h10V5H3zm10%2016v-2h8v-2h-8v-2h-2v6h2zM7%209v2H3v2h4v2h2V9H7zm14%204v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z'/%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='M3%2017v2h6v-2H3zM3%205v2h10V5H3zm10%2016v-2h8v-2h-8v-2h-2v6h2zM7%209v2H3v2h4v2h2V9H7zm14%204v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.recommend-list,
.custom-section {
display: flex;
flex-direction: column;
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;
border: 1px solid #c2c6d4;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
text-align: left;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.recommend-card.active {
border-color: #00478d;
background: rgba(214, 227, 255, 0.46);
}
.recommend-card::after,
.choice::after,
.generate-button::after {
border: 0;
}
.recommend-card:active,
.choice:active,
.generate-button:active {
transform: scale(0.98);
}
.recommend-head {
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 16px;
}
.recommend-icon {
color: #00478d;
font-size: 13px;
line-height: 18px;
font-weight: 700;
}
.recommend-title {
color: #191c21;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.recommend-desc {
display: block;
margin-bottom: 8px;
color: #424752;
font-size: 12px;
line-height: 18px;
}
.tag-row {
display: flex;
gap: 8px;
}
.tag {
padding: 2px 8px;
border-radius: 999px;
background: #ecedf6;
color: #191c21;
font-size: 10px;
line-height: 16px;
}
.tag.danger {
background: rgba(255, 218, 214, 0.35);
color: #ba1a1a;
}
.option-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-label {
display: flex;
align-items: center;
gap: 4px;
color: #424752;
font-size: 12px;
line-height: 16px;
font-weight: 600;
}
.location-icon,
.cake-icon,
.school-icon,
.mind-icon {
width: 14px;
height: 14px;
background: #424752;
}
.location-icon {
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%202a7%207%200%200%200-7%207c0%205.25%207%2013%207%2013s7-7.75%207-13a7%207%200%200%200-7-7zm0%209.5A2.5%202.5%200%201%201%2012%206a2.5%202.5%200%200%201%200%205.5z'/%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='M12%202a7%207%200%200%200-7%207c0%205.25%207%2013%207%2013s7-7.75%207-13a7%207%200%200%200-7-7zm0%209.5A2.5%202.5%200%201%201%2012%206a2.5%202.5%200%200%201%200%205.5z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.cake-icon,
.school-icon,
.mind-icon {
border-radius: 50%;
}
.option-grid {
display: grid;
gap: 10px;
width: 100%;
}
.grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.choice {
box-sizing: border-box;
width: 100%;
min-width: 0;
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);
}
.choice.small {
font-size: 12px;
}
.choice.mini {
font-size: 11px;
}
.choice.selected {
border-color: #00478d;
background: rgba(0, 71, 141, 0.1);
color: #00478d;
font-weight: 700;
}
.personality-block {
padding-bottom: 32px;
}
.bottom-action {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
box-sizing: border-box;
padding: 32px 20px 20px;
background: linear-gradient(180deg, rgba(249, 249, 255, 0), #f9f9ff 38%, #f9f9ff);
}
.generate-button {
width: 100%;
min-height: 56px;
border-radius: 999px;
background: #00478d;
box-shadow: 0 8px 18px rgba(0, 71, 141, 0.22);
color: #ffffff;
font-size: 14px;
line-height: 20px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.arrow-icon {
width: 20px;
height: 20px;
background: #ffffff;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='m12%204-1.41%201.41L16.17%2011H4v2h12.17l-5.58%205.59L12%2020l8-8-8-8z'/%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='m12%204-1.41%201.41L16.17%2011H4v2h12.17l-5.58%205.59L12%2020l8-8-8-8z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.toast {
position: fixed;
left: 50%;
bottom: 96px;
z-index: 100;
max-width: 320px;
padding: 12px 24px;
border-radius: 999px;
background: #2e3037;
color: #eff0f8;
font-size: 14px;
line-height: 20px;
font-weight: 600;
text-align: center;
pointer-events: none;
opacity: 0;
transform: translate(-50%, 16px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
}
</style>