feat: 联调

This commit is contained in:
王天骄
2026-06-11 12:12:55 +08:00
parent 2192b855a1
commit cdbe16dde3
35 changed files with 918 additions and 217 deletions
+243 -100
View File
@@ -34,12 +34,23 @@
<text class="mentor-name">王主任</text>
</view>
<view class="question-bubble">
<text>{{ currentQuestion.question }}</text>
<text>{{ currentQuestion?.question || questionStatusText }}</text>
</view>
</view>
<view v-if="showVideoView" class="video-section">
<view class="video-player" @click="toggleVideoPlay">
<view v-if="showVideoView && currentQuestion" class="video-section">
<video
v-if="currentQuestion.videoUrl"
class="video-player real-video"
:src="currentQuestion.videoUrl"
controls
autoplay
@play="videoPlaying = true"
@pause="videoPlaying = false"
@ended="videoPlaying = false"
@error="handleVideoError"
></video>
<view v-else class="video-player" @click="toggleVideoPlay">
<view class="video-poster" :class="{ playing: videoPlaying }">
<view class="heart-visual">
<view class="heart-core"></view>
@@ -57,57 +68,72 @@
</view>
</view>
<view class="video-copy">
<text class="video-title">{{ currentQuestion.videoTitle }}</text>
<text class="video-desc">{{ currentQuestion.videoDesc }}</text>
<text class="video-title">{{ currentQuestion.videoTitle || '讲解视频' }}</text>
<text v-if="currentQuestion.videoDesc" class="video-desc">{{ currentQuestion.videoDesc }}</text>
</view>
</view>
<view v-else class="option-list">
<view v-else-if="currentQuestion" class="option-list">
<button
v-for="option in currentQuestion.options"
:key="option.key"
:key="option.value"
class="option-card"
:class="getOptionClass(option.key)"
@click="selectOption(option.key)"
:class="getOptionClass(option.value)"
@click="selectOption(option.value)"
>
<text class="option-key">{{ option.key }}</text>
<text class="option-text">{{ option.text }}</text>
<view v-if="selectedOption === option.key && option.key !== currentQuestion.correctOption" class="wrong-icon"></view>
<view v-if="selectedOption === option.key && option.key === currentQuestion.correctOption" class="right-icon"></view>
<view v-if="hasCorrectAnswer && selectedOption === option.value && option.value !== currentQuestion.correctAnswer" class="wrong-icon"></view>
<view v-if="hasCorrectAnswer && selectedOption === option.value && option.value === currentQuestion.correctAnswer" class="right-icon"></view>
</button>
</view>
<view v-else class="empty-state">
<text>{{ questionStatusText }}</text>
</view>
<view v-if="!showVideoView && selectedOption" class="analysis-card">
<view v-if="!showVideoView && selectedOption && hasAnalysis" class="analysis-card">
<view class="analysis-title">
<view class="bulb-icon"></view>
<text>答案解析</text>
</view>
<view class="analysis-content">
<text class="analysis-main">{{ currentQuestion.analysis }}</text>
<view class="analysis-divider"></view>
<text class="analysis-note">{{ currentQuestion.note }}</text>
<text v-if="currentQuestion?.analysis" class="analysis-main">{{ currentQuestion.analysis }}</text>
<view v-if="currentQuestion?.analysis && currentQuestion?.note" class="analysis-divider"></view>
<text v-if="currentQuestion?.note" class="analysis-note">{{ currentQuestion.note }}</text>
</view>
</view>
<view class="bottom-actions">
<button v-if="!showVideoView" class="video-button" @click="handleWatchVideo">
<button v-if="!showVideoView && hasVideo" class="video-button" @click="handleWatchVideo">
<view class="video-icon"></view>
<text>查看知识点视频</text>
</button>
<button class="next-button" @click="handleNextQuestion">
<text>下一题</text>
<button
class="next-button"
:class="{ disabled: !currentQuestion || submittingEvaluation || loadingQuestions }"
:disabled="!currentQuestion || submittingEvaluation || loadingQuestions"
@click="handleNextQuestion"
>
<text>{{ nextButtonText }}</text>
<view class="next-icon"></view>
</button>
</view>
</scroll-view>
</view>
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { readStoredClinicalCase, type ClinicalCase } from '../../api/cases'
import { createHomeNavigator, createProfileOpener, createSettingsOpener } from '../../api/navigation'
import {
fetchTeachingCaseItems,
generateTeachingEvaluation,
type TeachingAnswer,
type TeachingCaseQuestion
} from '../../api/teaching'
const props = defineProps<{
caseItem?: ClinicalCase | null
@@ -122,84 +148,32 @@ const emit = defineEmits<{
const openProfile = createProfileOpener(emit)
const openSettings = createSettingsOpener(emit)
const goHome = createHomeNavigator(emit)
type OptionKey = 'A' | 'B' | 'C' | 'D'
type TeachingQuestion = {
id: string
question: string
options: Array<{ key: OptionKey; text: string }>
correctOption: OptionKey
defaultSelected?: OptionKey
analysis: string
note: string
videoTitle: string
videoDesc: string
}
const questions: TeachingQuestion[] = [
{
id: 'acs-ecg',
question: '根据陈先生目前的临床表现(急性剧烈胸痛伴大汗),首选的初步辅助检查应该是哪一项?',
options: [
{ key: 'A', text: '胸部增强CT (CTA)' },
{ key: 'B', text: '血清心肌酶学检查' },
{ key: 'C', text: '心脏彩色多普勒超声' },
{ key: 'D', text: '床边12导联心电图' }
],
correctOption: 'D',
defaultSelected: 'B',
analysis: '解析:对于疑似急性冠脉综合征(ACS)的患者,18导联或12导联心电图是首选且最快捷的辅助检查工具。',
note: '心肌酶学检查虽然重要,但通常在症状发作2-4小时后才会升高,不能作为首诊即刻排除依据。你的选择偏慢了。正确选项应为 D。',
videoTitle: '临床知识点:急性冠脉综合征的早期识别',
videoDesc: '本视频将由王主任为您深入解析 ACS 的典型心电图表现与急诊处理流程。'
},
{
id: 'aortic-dissection',
question: '若患者胸痛呈撕裂样并伴双上肢血压明显不对称,下一步最需要警惕的疾病是什么?',
options: [
{ key: 'A', text: '急性主动脉夹层' },
{ key: 'B', text: '稳定型心绞痛' },
{ key: 'C', text: '肋软骨炎' },
{ key: 'D', text: '胃食管反流病' }
],
correctOption: 'A',
analysis: '解析:突发撕裂样胸背痛、血压或脉搏不对称、神经系统症状等均提示主动脉夹层风险,需要快速识别并优先排查。',
note: '主动脉夹层是胸痛鉴别诊断中的高危疾病,漏诊风险极高。正确选项应为 A。',
videoTitle: '临床知识点:主动脉夹层的危险信号',
videoDesc: '本视频讲解主动脉夹层的典型表现、床旁识别线索与 CTA 检查时机。'
},
{
id: 'nitroglycerin',
question: '疑似急性冠脉综合征患者使用硝酸甘油前,最应该先评估哪一项?',
options: [
{ key: 'A', text: '患者是否空腹' },
{ key: 'B', text: '血压水平及禁忌证' },
{ key: 'C', text: '白细胞计数' },
{ key: 'D', text: '既往疫苗接种史' }
],
correctOption: 'B',
analysis: '解析:硝酸甘油可降低血压,使用前应评估收缩压、右室梗死风险、近期 PDE-5 抑制剂使用等禁忌证。',
note: '急诊处理强调先评估风险再给药,避免因低血压或禁忌证导致病情恶化。正确选项应为 B。',
videoTitle: '临床知识点:胸痛患者硝酸甘油使用要点',
videoDesc: '本视频梳理硝酸甘油的适应证、禁忌证及急诊场景下的用药安全。'
}
]
const TEACHING_CASE_ID = 1
const questionIndex = ref(0)
const selectedOption = ref<OptionKey | ''>(questions[0].defaultSelected || '')
const selectedOption = ref('')
const showVideoView = ref(false)
const videoPlaying = ref(false)
const storedCase = ref<ClinicalCase | null>(null)
const questions = ref<TeachingCaseQuestion[]>([])
const answerMap = ref<Record<string, string>>({})
const loadingQuestions = ref(false)
const submittingEvaluation = ref(false)
const questionLoadFailed = ref(false)
const toastMessage = ref('')
const toastVisible = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
const activeCase = computed(() => props.caseItem || storedCase.value)
const activeCaseId = computed(() => TEACHING_CASE_ID)
const patient = computed(() => ({
name: activeCase.value?.patientName || '陈先生',
gender: activeCase.value?.gender || '',
age: activeCase.value?.age || 60,
department: activeCase.value?.department || '心血管内科',
chiefComplaint: activeCase.value?.title || '持续胸痛3小时'
name: activeCase.value?.patientName || '未选择病例',
gender: activeCase.value?.gender || '-',
age: activeCase.value?.age || '-',
department: activeCase.value?.department || '-',
chiefComplaint: activeCase.value?.title || '暂无病例信息'
}))
const complaintShort = computed(() => {
@@ -207,31 +181,63 @@ const complaintShort = computed(() => {
return patient.value.chiefComplaint.slice(0, 6)
})
const currentQuestion = computed(() => questions[questionIndex.value])
const currentQuestion = computed(() => questions.value[questionIndex.value] || null)
const hasCorrectAnswer = computed(() => Boolean(currentQuestion.value?.correctAnswer))
const hasAnalysis = computed(() => Boolean(currentQuestion.value?.analysis || currentQuestion.value?.note))
const hasVideo = computed(() => Boolean(currentQuestion.value?.videoUrl))
const isLastQuestion = computed(() => questionIndex.value >= questions.value.length - 1)
const nextButtonText = computed(() => {
if (submittingEvaluation.value) return '提交中...'
return isLastQuestion.value ? '提交评价' : '下一题'
})
const questionStatusText = computed(() => {
if (loadingQuestions.value) return '题目加载中...'
if (questionLoadFailed.value) return '题目加载失败,请稍后重试。'
return '暂无教学题目'
})
function selectOption(key: OptionKey) {
selectedOption.value = key
function selectOption(value: string) {
selectedOption.value = value
if (currentQuestion.value) {
answerMap.value[String(currentQuestion.value.id)] = value
}
}
function getOptionClass(key: OptionKey) {
if (selectedOption.value !== key) return ''
return key === currentQuestion.value.correctOption ? 'selected-correct' : 'selected-wrong'
function getOptionClass(value: string) {
if (selectedOption.value !== value) return ''
if (!currentQuestion.value?.correctAnswer) return 'selected-correct'
return value === currentQuestion.value.correctAnswer ? 'selected-correct' : 'selected-wrong'
}
function handleWatchVideo() {
if (!currentQuestion.value?.videoUrl) {
showToast('当前题目暂无讲解视频')
return
}
showVideoView.value = true
videoPlaying.value = false
}
function handleNextQuestion() {
const nextIndex = (questionIndex.value + 1) % questions.length
async function handleNextQuestion() {
if (!currentQuestion.value || submittingEvaluation.value || loadingQuestions.value) return
if (!selectedOption.value) {
showToast('请先选择答案')
return
}
if (isLastQuestion.value) {
await submitTeachingEvaluation()
return
}
const nextIndex = questionIndex.value + 1
questionIndex.value = nextIndex
selectedOption.value = questions[nextIndex].defaultSelected || ''
selectedOption.value = answerMap.value[String(questions.value[nextIndex].id)] || ''
showVideoView.value = false
videoPlaying.value = false
uni.setStorageSync('clinical-thinking-teaching-question', {
caseId: activeCase.value?.id || '',
questionId: questions[nextIndex].id,
caseId: activeCaseId.value,
questionId: questions.value[nextIndex].id,
index: nextIndex
})
}
@@ -240,8 +246,95 @@ function toggleVideoPlay() {
videoPlaying.value = !videoPlaying.value
}
function handleVideoError() {
showToast('讲解视频加载失败')
}
async function loadTeachingQuestions() {
if (!activeCaseId.value) {
questionLoadFailed.value = true
showToast('未找到当前教学病例')
return
}
loadingQuestions.value = true
questionLoadFailed.value = false
try {
const result = await fetchTeachingCaseItems(activeCaseId.value)
questions.value = result
questionIndex.value = 0
selectedOption.value = ''
answerMap.value = {}
showVideoView.value = false
videoPlaying.value = false
if (result.length === 0) {
questionLoadFailed.value = true
showToast('暂无教学题目')
}
} catch (error) {
questionLoadFailed.value = true
showToast(error instanceof Error ? error.message : '题目列表加载失败')
} finally {
loadingQuestions.value = false
}
}
async function submitTeachingEvaluation() {
if (!activeCaseId.value) {
showToast('未找到当前教学病例')
return
}
const answers = buildTeachingAnswers()
if (answers.length !== questions.value.length) {
showToast('请完成全部题目后再提交')
return
}
submittingEvaluation.value = true
try {
const result = await generateTeachingEvaluation({
case_id: activeCaseId.value,
answers
})
uni.setStorageSync('clinical-thinking-case-mode', 'teaching')
uni.setStorageSync('clinical-thinking-teaching-evaluation', result)
uni.setStorageSync('clinical-thinking-teaching-evaluation-id', result.evaluation_id)
uni.navigateTo({
url: '/pages/assessment/assessment'
})
} catch (error) {
showToast(error instanceof Error ? error.message : '教学评价生成失败')
} finally {
submittingEvaluation.value = false
}
}
function buildTeachingAnswers(): TeachingAnswer[] {
return questions.value
.map(item => ({
question_id: item.id,
selected_answer: answerMap.value[String(item.id)] || ''
}))
.filter(item => item.selected_answer)
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = setTimeout(() => {
toastVisible.value = false
}, 2200)
}
onMounted(() => {
storedCase.value = readStoredClinicalCase()
void loadTeachingQuestions()
})
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
})
</script>
@@ -470,6 +563,11 @@ page {
overflow: hidden;
}
.real-video {
display: block;
object-fit: contain;
}
.video-poster {
position: absolute;
inset: 0;
@@ -663,6 +761,22 @@ page {
color: #ffffff;
}
.empty-state {
margin: 24px 20px 0;
min-height: 160px;
padding: 24px;
border: 1px dashed rgba(194, 198, 212, 0.8);
border-radius: 16px;
background: rgba(255, 255, 255, 0.64);
color: rgba(66, 71, 82, 0.82);
font-size: 15px;
line-height: 24px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.wrong-icon,
.right-icon {
position: relative;
@@ -768,7 +882,7 @@ page {
}
.bottom-actions {
padding: 0 20px 40px;
padding: 24px 20px 40px;
display: flex;
flex-direction: column;
gap: 12px;
@@ -813,6 +927,10 @@ page {
color: #ffffff;
}
.next-button.disabled {
opacity: 0.5;
}
.video-icon,
.next-icon {
flex: 0 0 auto;
@@ -832,6 +950,31 @@ page {
transform: rotate(180deg);
}
.toast {
position: fixed;
left: 50%;
bottom: 32px;
z-index: 100;
max-width: 320px;
padding: 12px 20px;
border-radius: 12px;
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);
}
@keyframes pulse-border {
0% {
box-shadow: 0 0 0 0 rgba(0, 71, 141, 0.4);