feat: 病例列表联调
This commit is contained in:
@@ -15,13 +15,13 @@
|
||||
<view class="summary-copy">
|
||||
<view class="summary-head">
|
||||
<text class="summary-label">当前能力评分</text>
|
||||
<view class="trend-chip">
|
||||
<view v-if="scoreDeltaText" class="trend-chip" :class="{ down: isScoreDeltaDown }">
|
||||
<view class="trend-icon"></view>
|
||||
<text>+12%</text>
|
||||
<text>{{ scoreDeltaText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="summary-score">84.5</text>
|
||||
<text class="summary-desc">相较于上周,您的临床推理能力有显著提升</text>
|
||||
<text class="summary-score">{{ currentScoreText }}</text>
|
||||
<text class="summary-desc">{{ summaryDesc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -37,7 +37,7 @@
|
||||
<view class="bar-chart">
|
||||
<view
|
||||
v-for="item in trendItems"
|
||||
:key="item.label"
|
||||
:key="`${item.label}-${item.score}`"
|
||||
class="bar-column"
|
||||
>
|
||||
<view class="bar-track">
|
||||
@@ -50,6 +50,7 @@
|
||||
<text class="bar-label" :class="{ active: item.highlight }">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="trendItems.length === 0" class="empty-card-text">{{ emptyAnalysisText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -74,35 +75,33 @@
|
||||
<view class="radar-axis axis-diagonal-four"></view>
|
||||
</view>
|
||||
|
||||
<view class="radar-label label-top">病史采集</view>
|
||||
<view class="radar-label label-right-top">体格检查</view>
|
||||
<view class="radar-label label-right-bottom">诊断能力</view>
|
||||
<view class="radar-label label-bottom">治疗决策</view>
|
||||
<view class="radar-label label-left-bottom">沟通技巧</view>
|
||||
<view class="radar-label label-left-top">临床推理</view>
|
||||
<view
|
||||
v-for="item in radarItems"
|
||||
:key="item.dimension"
|
||||
class="radar-label dynamic-label"
|
||||
:style="{ left: item.labelLeft, top: item.labelTop }"
|
||||
>
|
||||
{{ item.dimension }}
|
||||
</view>
|
||||
|
||||
<svg class="radar-svg" viewBox="0 0 100 100">
|
||||
<polygon
|
||||
v-if="radarPolygonPoints"
|
||||
class="radar-polygon"
|
||||
points="50,15 85,35 80,75 50,85 25,70 20,40"
|
||||
:points="radarPolygonPoints"
|
||||
></polygon>
|
||||
<circle cx="50" cy="15" r="3"></circle>
|
||||
<circle cx="85" cy="35" r="3"></circle>
|
||||
<circle cx="80" cy="75" r="3"></circle>
|
||||
<circle cx="50" cy="85" r="3"></circle>
|
||||
<circle cx="25" cy="70" r="3"></circle>
|
||||
<circle cx="20" cy="40" r="3"></circle>
|
||||
<circle
|
||||
v-for="item in radarItems"
|
||||
:key="`${item.dimension}-point`"
|
||||
:cx="item.pointX"
|
||||
:cy="item.pointY"
|
||||
r="3"
|
||||
></circle>
|
||||
</svg>
|
||||
</view>
|
||||
|
||||
<view class="analysis-note">
|
||||
<text>您的</text>
|
||||
<text class="strong-text">诊断能力</text>
|
||||
<text>和</text>
|
||||
<text class="strong-text">治疗决策</text>
|
||||
<text>表现突出,但</text>
|
||||
<text class="secondary-text">病史采集</text>
|
||||
<text>仍有提升空间。建议增加相关模拟训练。</text>
|
||||
<text>{{ analysisComment }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -115,8 +114,8 @@
|
||||
<view class="book-icon"></view>
|
||||
</view>
|
||||
<view class="suggestion-copy">
|
||||
<text class="suggestion-title">重点回顾:心血管查体</text>
|
||||
<text class="suggestion-desc">基于您最近在心衰案例中的表现</text>
|
||||
<text class="suggestion-title">重点回顾:{{ primaryWeakDimension }}</text>
|
||||
<text class="suggestion-desc">基于当前能力雷达图的薄弱项推荐</text>
|
||||
</view>
|
||||
<view class="chevron-icon"></view>
|
||||
</view>
|
||||
@@ -127,7 +126,7 @@
|
||||
</view>
|
||||
<view class="suggestion-copy">
|
||||
<text class="suggestion-title">临床思维专项挑战</text>
|
||||
<text class="suggestion-desc">推荐参与难度等级:高级</text>
|
||||
<text class="suggestion-desc">推荐参与难度等级:{{ challengeLevel }}</text>
|
||||
</view>
|
||||
<view class="chevron-icon"></view>
|
||||
</view>
|
||||
@@ -144,36 +143,127 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="mentor-bubble">
|
||||
<text>“你的进步非常扎实。今天休息一下,明天我们来挑战一个更复杂的病例如何?”</text>
|
||||
<text>{{ mentorAdvice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="toast" :class="{ visible: toastVisible }">{{ toastMessage }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const trendItems = [
|
||||
{ label: '周一', height: '60%', highlight: false },
|
||||
{ label: '周二', height: '70%', highlight: false },
|
||||
{ label: '周三', height: '65%', highlight: false },
|
||||
{ label: '周四', height: '80%', highlight: false },
|
||||
{ label: '周五', height: '85%', highlight: true },
|
||||
{ label: '周六', height: '90%', highlight: false },
|
||||
{ label: '周日', height: '95%', highlight: false }
|
||||
]
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { fetchUserAnalysis, type UserAnalysis } from '../../api/profile'
|
||||
|
||||
const fadeVisible = ref(false)
|
||||
const toastMessage = ref('')
|
||||
const toastVisible = ref(false)
|
||||
const analysis = ref<UserAnalysis | null>(null)
|
||||
const loadingAnalysis = ref(false)
|
||||
const analysisLoaded = ref(false)
|
||||
const analysisFailed = ref(false)
|
||||
|
||||
let fadeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const currentScoreText = computed(() => {
|
||||
const score = analysis.value?.current_score
|
||||
if (typeof score !== 'number' || !Number.isFinite(score)) return '--'
|
||||
return Number.isInteger(score) ? String(score) : score.toFixed(1)
|
||||
})
|
||||
|
||||
const scoreDeltaText = computed(() => {
|
||||
const delta = analysis.value?.score_delta_pct
|
||||
if (typeof delta !== 'number' || !Number.isFinite(delta)) return ''
|
||||
const prefix = delta > 0 ? '+' : ''
|
||||
return `${prefix}${delta}%`
|
||||
})
|
||||
|
||||
const isScoreDeltaDown = computed(() => {
|
||||
const delta = analysis.value?.score_delta_pct
|
||||
return typeof delta === 'number' && delta < 0
|
||||
})
|
||||
|
||||
const summaryDesc = computed(() => {
|
||||
if (analysisFailed.value) return '智能分析加载失败,请稍后重试'
|
||||
if (!analysisLoaded.value) return '正在加载您的临床能力分析'
|
||||
const delta = analysis.value?.score_delta_pct
|
||||
if (typeof delta === 'number' && Number.isFinite(delta)) {
|
||||
if (delta > 0) return '相较于近期表现,您的临床能力评分有所提升'
|
||||
if (delta < 0) return '相较于近期表现,当前评分略有回落,建议继续专项练习'
|
||||
}
|
||||
return '当前已生成您的临床能力评分'
|
||||
})
|
||||
|
||||
const trendItems = computed(() => {
|
||||
const items = analysis.value?.recent_trend || []
|
||||
const lastIndex = items.length - 1
|
||||
return items.map((item, index) => ({
|
||||
label: item.label,
|
||||
score: item.score,
|
||||
height: `${clampScore(item.score)}%`,
|
||||
highlight: index === lastIndex
|
||||
}))
|
||||
})
|
||||
|
||||
const radarItems = computed(() => {
|
||||
const items = (analysis.value?.radar || []).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 pointRadius = 35 * (clampScore(item.score) / 100)
|
||||
const labelRadius = 47
|
||||
const labelX = 50 + Math.cos(angle) * labelRadius
|
||||
const labelY = 50 + Math.sin(angle) * labelRadius
|
||||
const boundedLabelX = clampLabelPosition(labelX, 14, 86)
|
||||
const boundedLabelY = clampLabelPosition(labelY, 6, 94)
|
||||
|
||||
return {
|
||||
dimension: item.dimension,
|
||||
pointX: roundSvgValue(50 + Math.cos(angle) * pointRadius),
|
||||
pointY: roundSvgValue(50 + Math.sin(angle) * pointRadius),
|
||||
labelLeft: `${boundedLabelX}%`,
|
||||
labelTop: `${boundedLabelY}%`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const radarPolygonPoints = computed(() => {
|
||||
return radarItems.value.map(item => `${item.pointX},${item.pointY}`).join(' ')
|
||||
})
|
||||
|
||||
const emptyAnalysisText = computed(() => {
|
||||
if (loadingAnalysis.value) return '分析数据加载中...'
|
||||
if (analysisFailed.value) return '分析数据加载失败'
|
||||
return '暂无趋势数据'
|
||||
})
|
||||
|
||||
const analysisComment = computed(() => {
|
||||
return analysis.value?.comment || emptyAnalysisText.value
|
||||
})
|
||||
|
||||
const primaryWeakDimension = computed(() => {
|
||||
return analysis.value?.weak_dimensions?.[0] || '病史采集'
|
||||
})
|
||||
|
||||
const challengeLevel = computed(() => {
|
||||
const score = analysis.value?.current_score || 0
|
||||
if (score >= 85) return '高级'
|
||||
if (score >= 70) return '中级'
|
||||
return '基础'
|
||||
})
|
||||
|
||||
const mentorAdvice = computed(() => {
|
||||
if (analysis.value?.comment) return `“${analysis.value.comment}”`
|
||||
if (analysisFailed.value) return '“分析数据暂时未能加载,请稍后再试。”'
|
||||
return '“正在读取你的训练表现,稍后给出更具体的强化建议。”'
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
if (typeof getCurrentPages === 'function' && getCurrentPages().length > 1) {
|
||||
uni.navigateBack()
|
||||
@@ -185,23 +275,47 @@ function goBack() {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAnalysis() {
|
||||
loadingAnalysis.value = true
|
||||
analysisFailed.value = false
|
||||
try {
|
||||
analysis.value = await fetchUserAnalysis()
|
||||
analysisLoaded.value = true
|
||||
} catch (error) {
|
||||
analysisFailed.value = true
|
||||
showToast(error instanceof Error ? error.message : '智能分析加载失败')
|
||||
} finally {
|
||||
loadingAnalysis.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 clampLabelPosition(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, Math.round(value * 100) / 100))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fadeTimer = setTimeout(() => {
|
||||
fadeVisible.value = true
|
||||
}, 60)
|
||||
void loadAnalysis()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -217,6 +331,7 @@ page {
|
||||
}
|
||||
|
||||
.analysis-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #f9f9ff;
|
||||
color: #191c21;
|
||||
@@ -226,10 +341,10 @@ page {
|
||||
|
||||
.analysis-shell {
|
||||
position: relative;
|
||||
width: 380px;
|
||||
max-width: 100vw;
|
||||
min-height: 922px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f9f9ff;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -329,9 +444,10 @@ page {
|
||||
}
|
||||
|
||||
.analysis-main {
|
||||
max-width: 390px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 96px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 24px 24px 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
@@ -404,6 +520,10 @@ page {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trend-chip.down {
|
||||
background: #793100;
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -461,6 +581,16 @@ page {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-card-text {
|
||||
min-height: 80px;
|
||||
color: #727783;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -503,6 +633,7 @@ page {
|
||||
.radar-wrap {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.radar-background {
|
||||
@@ -575,6 +706,14 @@ page {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.dynamic-label {
|
||||
width: 68px;
|
||||
max-width: 68px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.label-top {
|
||||
top: -2px;
|
||||
left: 50%;
|
||||
@@ -808,6 +947,29 @@ page {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
z-index: 80;
|
||||
max-width: 300px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(46, 48, 55, 0.9);
|
||||
color: #eff0f8;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.analysis-page .analysis-main .fade-section {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -825,6 +987,9 @@ page {
|
||||
}
|
||||
|
||||
.analysis-shell {
|
||||
width: 480px;
|
||||
max-width: 100vw;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 24px 64px rgba(25, 28, 33, 0.18);
|
||||
border-left: 1px solid #c2c6d4;
|
||||
border-right: 1px solid #c2c6d4;
|
||||
|
||||
Reference in New Issue
Block a user