feat: 联调
This commit is contained in:
+365
-69
@@ -13,6 +13,7 @@
|
||||
<view class="report-meta">
|
||||
<text>评估日期:{{ report.date }}</text>
|
||||
<text>模拟编号:{{ report.no }}</text>
|
||||
<text v-if="report.caseTitle">病例:{{ report.caseTitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
></circle>
|
||||
</svg>
|
||||
<view class="score-center">
|
||||
<text class="score-value">{{ report.score }}</text>
|
||||
<text class="score-value">{{ report.scoreText }}</text>
|
||||
<text class="score-total">/100</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -51,23 +52,33 @@
|
||||
<text>临床胜任力雷达图</text>
|
||||
</view>
|
||||
<view class="radar-wrap">
|
||||
<svg class="radar-svg" viewBox="0 0 400 400">
|
||||
<svg v-if="radarItems.length >= 3" class="radar-svg" viewBox="0 0 400 400">
|
||||
<circle class="radar-grid" cx="200" cy="200" r="160"></circle>
|
||||
<circle class="radar-grid" cx="200" cy="200" r="120"></circle>
|
||||
<circle class="radar-grid" cx="200" cy="200" r="80"></circle>
|
||||
<circle class="radar-grid" cx="200" cy="200" r="40"></circle>
|
||||
<line class="radar-grid" x1="200" x2="200" y1="200" y2="40"></line>
|
||||
<line class="radar-grid" x1="200" x2="352" y1="200" y2="150"></line>
|
||||
<line class="radar-grid" x1="200" x2="294" y1="200" y2="330"></line>
|
||||
<line class="radar-grid" x1="200" x2="106" y1="200" y2="330"></line>
|
||||
<line class="radar-grid" x1="200" x2="48" y1="200" y2="150"></line>
|
||||
<polygon class="radar-area" points="200,60 340,160 270,300 120,310 60,170"></polygon>
|
||||
<text class="radar-label" text-anchor="middle" x="200" y="30">病史采集</text>
|
||||
<text class="radar-label" text-anchor="start" x="355" y="155">体格检查</text>
|
||||
<text class="radar-label" text-anchor="middle" x="310" y="360">临床思维</text>
|
||||
<text class="radar-label" text-anchor="middle" x="90" y="360">诊断准确</text>
|
||||
<text class="radar-label" text-anchor="end" x="40" y="155">治疗方案</text>
|
||||
<line
|
||||
v-for="item in radarItems"
|
||||
:key="`axis-${item.label}`"
|
||||
class="radar-grid"
|
||||
x1="200"
|
||||
:x2="item.axisX"
|
||||
y1="200"
|
||||
:y2="item.axisY"
|
||||
></line>
|
||||
<polygon class="radar-area" :points="radarPolygonPoints"></polygon>
|
||||
<text
|
||||
v-for="item in radarItems"
|
||||
:key="`label-${item.label}`"
|
||||
class="radar-label"
|
||||
:text-anchor="item.anchor"
|
||||
:x="item.labelX"
|
||||
:y="item.labelY"
|
||||
>{{ item.label }}</text>
|
||||
</svg>
|
||||
<view v-else class="empty-data radar-empty">
|
||||
<text>{{ emptyRadarText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -77,22 +88,27 @@
|
||||
<text>分项得分与解析</text>
|
||||
</view>
|
||||
<view class="breakdown-list">
|
||||
<view
|
||||
v-for="item in breakdownItems"
|
||||
:key="item.label"
|
||||
class="breakdown-item"
|
||||
>
|
||||
<view class="breakdown-head">
|
||||
<text>{{ item.label }}</text>
|
||||
<text class="breakdown-score">{{ item.displayScore }}</text>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: barsReady ? `${item.percent}%` : '0%' }"></view>
|
||||
</view>
|
||||
<view class="analysis-box">
|
||||
<text>{{ item.analysis }}</text>
|
||||
</view>
|
||||
<view v-if="breakdownItems.length === 0" class="empty-data">
|
||||
<text>{{ emptyBreakdownText }}</text>
|
||||
</view>
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="(item, index) in breakdownItems"
|
||||
:key="`${item.label}-${index}`"
|
||||
class="breakdown-item"
|
||||
>
|
||||
<view class="breakdown-head">
|
||||
<text>{{ item.label }}</text>
|
||||
<text class="breakdown-score">{{ item.displayScore }}</text>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: barsReady ? `${item.percent}%` : '0%' }"></view>
|
||||
</view>
|
||||
<view class="analysis-box">
|
||||
<text>{{ item.analysis }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -120,8 +136,13 @@
|
||||
</scroll-view>
|
||||
|
||||
<view class="footer-actions">
|
||||
<button class="download-button" @click="showToast('完整 PDF 报告生成中')">
|
||||
下载完整 PDF 报告
|
||||
<button
|
||||
class="download-button"
|
||||
:class="{ disabled: !activeEvaluationId || exportingPdf }"
|
||||
:disabled="!activeEvaluationId || exportingPdf"
|
||||
@click="downloadPdfReport"
|
||||
>
|
||||
{{ exportingPdf ? 'PDF 生成中...' : '下载完整 PDF 报告' }}
|
||||
</button>
|
||||
<button class="next-button" @click="goHome">
|
||||
开始下一轮强化训练
|
||||
@@ -134,7 +155,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { generateEvaluation, type EvaluationResult } from '../../api/assessment'
|
||||
import {
|
||||
downloadEvaluationPdf,
|
||||
fetchEvaluationDetail,
|
||||
generateEvaluation,
|
||||
type EvaluationDetail,
|
||||
type EvaluationResult
|
||||
} from '../../api/assessment'
|
||||
import { readActiveSessionId } from '../../api/session'
|
||||
|
||||
type BreakdownItem = {
|
||||
@@ -144,55 +171,63 @@ type BreakdownItem = {
|
||||
analysis: string
|
||||
}
|
||||
|
||||
const fallbackBreakdownItems: BreakdownItem[] = [
|
||||
{
|
||||
label: '病史采集',
|
||||
percent: 92,
|
||||
displayScore: '92%',
|
||||
analysis: '问诊逻辑清晰,主诉把握精准。成功识别了诱发因素及既往史。'
|
||||
},
|
||||
{
|
||||
label: '体格检查',
|
||||
percent: 85,
|
||||
displayScore: '85%',
|
||||
analysis: '操作规范,但触诊顺序有微小疏漏。建议更注重痛点的动态观察。'
|
||||
},
|
||||
{
|
||||
label: '临床思维',
|
||||
percent: 78,
|
||||
displayScore: '78%',
|
||||
analysis: '能够建立初步假设,但在多系统受累时,思维发散性略显不足。'
|
||||
},
|
||||
{
|
||||
label: '诊断准确性',
|
||||
percent: 82,
|
||||
displayScore: '82%',
|
||||
analysis: '核心诊断正确。但在鉴别诊断中漏掉了罕见但致死性的并发症。'
|
||||
}
|
||||
]
|
||||
type RadarItem = BreakdownItem & {
|
||||
axisX: number
|
||||
axisY: number
|
||||
labelX: number
|
||||
labelY: number
|
||||
pointX: number
|
||||
pointY: number
|
||||
anchor: 'start' | 'middle' | 'end'
|
||||
}
|
||||
|
||||
const report = reactive({
|
||||
date: formatReportDate(new Date()),
|
||||
no: 'STJ-99283-X',
|
||||
score: 88,
|
||||
level: '优良',
|
||||
overallComment: '表现已达到临床住院医师中高级水平。需在复杂病例鉴别诊断上精进。'
|
||||
date: '生成中',
|
||||
no: '生成中',
|
||||
caseTitle: '',
|
||||
score: 0,
|
||||
scoreText: '--',
|
||||
level: '生成中',
|
||||
overallComment: '评价生成中,请稍候。'
|
||||
})
|
||||
|
||||
const barsReady = ref(false)
|
||||
const toastMessage = ref('')
|
||||
const toastVisible = ref(false)
|
||||
const evaluation = ref<EvaluationResult | null>(null)
|
||||
const evaluationDetail = ref<EvaluationDetail | null>(null)
|
||||
const activeEvaluationId = ref<number | null>(null)
|
||||
const exportingPdf = ref(false)
|
||||
const evaluationLoaded = ref(false)
|
||||
const evaluationFailed = ref(false)
|
||||
|
||||
const scoreDashOffset = computed(() => {
|
||||
return 264 - (264 * report.score) / 100
|
||||
})
|
||||
|
||||
const mentorComment = computed(() => report.overallComment || '本次评价已生成,请结合分项得分继续强化训练。')
|
||||
const mentorComment = computed(() => report.overallComment || emptyReportText.value)
|
||||
|
||||
const emptyReportText = computed(() => {
|
||||
if (evaluationFailed.value) return '评价生成失败,请稍后重试。'
|
||||
if (!evaluationLoaded.value) return '评价生成中,请稍候。'
|
||||
return '本次评价暂无详细点评。'
|
||||
})
|
||||
|
||||
const emptyBreakdownText = computed(() => {
|
||||
if (evaluationFailed.value) return '评价生成失败,请稍后重试。'
|
||||
if (!evaluationLoaded.value) return '分项评分生成中。'
|
||||
return '暂无分项评分。'
|
||||
})
|
||||
|
||||
const emptyRadarText = computed(() => {
|
||||
if (evaluationFailed.value) return '评价生成失败,暂无雷达图。'
|
||||
if (!evaluationLoaded.value) return '雷达图生成中。'
|
||||
return '暂无足够分项数据生成雷达图。'
|
||||
})
|
||||
|
||||
const breakdownItems = computed<BreakdownItem[]>(() => {
|
||||
const result = evaluation.value
|
||||
if (!result) return fallbackBreakdownItems
|
||||
const result = evaluationDetail.value || evaluation.value
|
||||
if (!result) return []
|
||||
|
||||
if (Array.isArray(result.dimension_scores) && result.dimension_scores.length > 0) {
|
||||
return result.dimension_scores.map(item => {
|
||||
@@ -220,7 +255,73 @@ const breakdownItems = computed<BreakdownItem[]>(() => {
|
||||
}))
|
||||
}
|
||||
|
||||
return fallbackBreakdownItems
|
||||
if (
|
||||
evaluationDetail.value &&
|
||||
Array.isArray(evaluation.value?.dimension_scores) &&
|
||||
evaluation.value.dimension_scores.length > 0
|
||||
) {
|
||||
return evaluation.value.dimension_scores.map(item => {
|
||||
const score = Number(item.score)
|
||||
const maxScore = Number(item.max_score)
|
||||
const percent = maxScore > 0 ? clampScore((score / maxScore) * 100) : clampScore(score)
|
||||
const analysis = [
|
||||
item.comment,
|
||||
item.improvement ? `改进建议:${item.improvement}` : ''
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return {
|
||||
label: item.dimension,
|
||||
percent,
|
||||
displayScore: maxScore > 0 ? `${item.score}/${item.max_score}` : `${item.score}%`,
|
||||
analysis: analysis || '暂无分项解析。'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationDetail.value &&
|
||||
Array.isArray(evaluation.value?.score_details) &&
|
||||
evaluation.value.score_details.length > 0
|
||||
) {
|
||||
return evaluation.value.score_details.map(item => ({
|
||||
label: item.dimension,
|
||||
percent: clampScore(Number(item.score)),
|
||||
displayScore: `${item.score}%`,
|
||||
analysis: item.comment || item.deducted_reason || '暂无分项解析。'
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const radarItems = computed<RadarItem[]>(() => {
|
||||
const items = breakdownItems.value.slice(0, 6)
|
||||
const total = items.length
|
||||
if (total < 3) return []
|
||||
|
||||
return items.map((item, index) => {
|
||||
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / total
|
||||
const axisRadius = 160
|
||||
const labelRadius = 184
|
||||
const pointRadius = axisRadius * (item.percent / 100)
|
||||
const labelX = 200 + Math.cos(angle) * labelRadius
|
||||
const labelY = 200 + Math.sin(angle) * labelRadius
|
||||
|
||||
return {
|
||||
...item,
|
||||
axisX: roundSvgValue(200 + Math.cos(angle) * axisRadius),
|
||||
axisY: roundSvgValue(200 + Math.sin(angle) * axisRadius),
|
||||
labelX: roundSvgValue(labelX),
|
||||
labelY: roundSvgValue(labelY),
|
||||
pointX: roundSvgValue(200 + Math.cos(angle) * pointRadius),
|
||||
pointY: roundSvgValue(200 + Math.sin(angle) * pointRadius),
|
||||
anchor: labelX < 170 ? 'end' : labelX > 230 ? 'start' : 'middle'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const radarPolygonPoints = computed(() => {
|
||||
return radarItems.value.map(item => `${item.pointX},${item.pointY}`).join(' ')
|
||||
})
|
||||
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -259,11 +360,22 @@ function formatReportDate(date: Date) {
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
function formatApiDate(value?: string) {
|
||||
if (!value) return formatReportDate(new Date())
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return formatReportDate(date)
|
||||
}
|
||||
|
||||
function clampScore(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function roundSvgValue(value: number) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function getLevel(score: number) {
|
||||
if (score >= 90) return '优秀'
|
||||
if (score >= 80) return '优良'
|
||||
@@ -274,10 +386,41 @@ function getLevel(score: number) {
|
||||
|
||||
function applyEvaluation(result: EvaluationResult) {
|
||||
evaluation.value = result
|
||||
activeEvaluationId.value = result.evaluation_id
|
||||
report.no = `EV-${result.evaluation_id}`
|
||||
report.score = clampScore(Number(result.total_score))
|
||||
updateReportScore(result.total_score)
|
||||
report.level = getLevel(report.score)
|
||||
report.overallComment = result.overall_comment || report.overallComment
|
||||
report.date = formatReportDate(new Date())
|
||||
report.overallComment = result.overall_comment || '本次评价暂无详细点评。'
|
||||
}
|
||||
|
||||
function applyEvaluationDetail(detail: EvaluationDetail) {
|
||||
evaluationDetail.value = detail
|
||||
activeEvaluationId.value = detail.evaluation_id
|
||||
report.no = `EV-${detail.evaluation_id}`
|
||||
report.caseTitle = detail.case_title || ''
|
||||
report.date = formatApiDate(detail.created_at)
|
||||
updateReportScore(detail.total_score)
|
||||
report.level = getLevel(report.score)
|
||||
report.overallComment = detail.overall_comment || evaluation.value?.overall_comment || '本次评价暂无详细点评。'
|
||||
}
|
||||
|
||||
function updateReportScore(score: number) {
|
||||
report.score = clampScore(Number(score))
|
||||
report.scoreText = Number.isFinite(Number(score)) ? String(report.score) : '--'
|
||||
}
|
||||
|
||||
function resetReportToError() {
|
||||
evaluation.value = null
|
||||
evaluationDetail.value = null
|
||||
activeEvaluationId.value = null
|
||||
report.date = '--'
|
||||
report.no = '--'
|
||||
report.caseTitle = ''
|
||||
report.score = 0
|
||||
report.scoreText = '--'
|
||||
report.level = '生成失败'
|
||||
report.overallComment = '评价生成失败,请稍后重试。'
|
||||
}
|
||||
|
||||
function animateBars() {
|
||||
@@ -290,19 +433,147 @@ function animateBars() {
|
||||
}
|
||||
|
||||
async function loadEvaluation() {
|
||||
evaluationLoaded.value = false
|
||||
evaluationFailed.value = false
|
||||
try {
|
||||
if (isTeachingAssessment()) {
|
||||
await loadTeachingEvaluation()
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = readActiveSessionId()
|
||||
report.no = `SID-${sessionId}`
|
||||
const result = await generateEvaluation(sessionId, 'percentage')
|
||||
uni.setStorageSync('clinical-thinking-evaluation', result)
|
||||
applyEvaluation(result)
|
||||
const detail = await fetchEvaluationDetail(result.evaluation_id)
|
||||
uni.setStorageSync('clinical-thinking-evaluation-detail', detail)
|
||||
applyEvaluationDetail(detail)
|
||||
evaluationLoaded.value = true
|
||||
} catch (error) {
|
||||
evaluationFailed.value = true
|
||||
resetReportToError()
|
||||
showToast(error instanceof Error ? error.message : '评价生成失败')
|
||||
} finally {
|
||||
animateBars()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeachingEvaluation() {
|
||||
const evaluationId = readTeachingEvaluationId()
|
||||
const storedEvaluation = readStoredTeachingEvaluation()
|
||||
if (storedEvaluation) {
|
||||
applyEvaluation(storedEvaluation)
|
||||
}
|
||||
const detail = await fetchEvaluationDetail(evaluationId)
|
||||
uni.setStorageSync('clinical-thinking-evaluation-detail', detail)
|
||||
applyEvaluationDetail(detail)
|
||||
evaluationLoaded.value = true
|
||||
}
|
||||
|
||||
function isTeachingAssessment() {
|
||||
return uni.getStorageSync('clinical-thinking-case-mode') === 'teaching' && Boolean(readTeachingEvaluationId(false))
|
||||
}
|
||||
|
||||
function readTeachingEvaluationId(throwOnMissing = true) {
|
||||
const value = uni.getStorageSync('clinical-thinking-teaching-evaluation-id')
|
||||
const evaluationId = Number(value)
|
||||
if (Number.isInteger(evaluationId) && evaluationId > 0) return evaluationId
|
||||
if (throwOnMissing) throw new Error('未找到教学评价,请先完成教学题目')
|
||||
return 0
|
||||
}
|
||||
|
||||
function readStoredTeachingEvaluation() {
|
||||
const value = uni.getStorageSync('clinical-thinking-teaching-evaluation')
|
||||
if (value && typeof value === 'object') return value as EvaluationResult
|
||||
return null
|
||||
}
|
||||
|
||||
async function downloadPdfReport() {
|
||||
if (!activeEvaluationId.value || exportingPdf.value) return
|
||||
|
||||
exportingPdf.value = true
|
||||
try {
|
||||
const result = await downloadEvaluationPdf(activeEvaluationId.value)
|
||||
uni.setStorageSync('clinical-thinking-evaluation-pdf', {
|
||||
fileName: result.fileName,
|
||||
filePath: result.filePath || ''
|
||||
})
|
||||
await triggerBrowserPdfDownload(result)
|
||||
showToast('PDF 已生成')
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'PDF 下载失败')
|
||||
} finally {
|
||||
exportingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerBrowserPdfDownload(result: Awaited<ReturnType<typeof downloadEvaluationPdf>>) {
|
||||
if (result.blob) {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
|
||||
const blobUrl = window.URL.createObjectURL(result.blob)
|
||||
startBrowserDownload(blobUrl, result.fileName)
|
||||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.filePath) {
|
||||
throw new Error('PDF 下载地址为空')
|
||||
}
|
||||
|
||||
const downloadUrl = resolvePdfDownloadUrl(result.filePath)
|
||||
const fileName = result.fileName
|
||||
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
uni.downloadFile({
|
||||
url: downloadUrl,
|
||||
fail: () => showToast('PDF 下载失败')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PDF 下载失败(${response.status})`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
startBrowserDownload(blobUrl, fileName)
|
||||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
startBrowserDownload(downloadUrl, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePdfDownloadUrl(filePath: string) {
|
||||
const value = filePath.trim()
|
||||
if (/^https?:\/\//i.test(value)) return value
|
||||
if (value.startsWith('/')) return value
|
||||
return `/${value}`
|
||||
}
|
||||
|
||||
function startBrowserDownload(url: string, fileName: string) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.rel = 'noopener'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadEvaluation()
|
||||
})
|
||||
@@ -617,11 +888,31 @@ page {
|
||||
|
||||
.radar-label {
|
||||
fill: #424752;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.empty-data {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 18px 14px;
|
||||
border: 1px dashed rgba(194, 198, 212, 0.7);
|
||||
border-radius: 8px;
|
||||
background: rgba(242, 243, 251, 0.55);
|
||||
color: rgba(66, 71, 82, 0.78);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.radar-empty {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.breakdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -817,6 +1108,11 @@ page {
|
||||
color: #00478d;
|
||||
}
|
||||
|
||||
.download-button.disabled {
|
||||
border-color: rgba(66, 71, 82, 0.24);
|
||||
color: rgba(66, 71, 82, 0.45);
|
||||
}
|
||||
|
||||
.next-button {
|
||||
background: #00478d;
|
||||
box-shadow: 0 2px 8px rgba(0, 71, 141, 0.2);
|
||||
|
||||
+243
-100
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user