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
+365 -69
View File
@@ -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);