feat: 病例列表联调

This commit is contained in:
王天骄
2026-06-13 06:05:37 +08:00
parent 37716f200b
commit bc2e03920e
13 changed files with 1518 additions and 258 deletions
+216 -51
View File
@@ -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;
+134 -66
View File
@@ -8,7 +8,7 @@
<text class="page-title">学习记录</text>
</header>
<scroll-view class="records-scroll" scroll-y>
<scroll-view class="records-scroll" scroll-y @scrolltolower="loadMoreRecords">
<main class="records-main">
<section class="stats-grid">
<view
@@ -38,11 +38,11 @@
<text class="section-title">最近训练</text>
<view class="record-list">
<view
v-for="record in filteredRecords"
:key="record.title"
v-for="record in recordItems"
:key="record.id"
class="record-card"
:class="{ dimmed: record.dimmed }"
@click="openReport"
@click="openReport(record.id)"
>
<view class="case-icon-wrap" :class="record.tone">
<text class="case-icon-text">{{ record.abbr }}</text>
@@ -62,21 +62,21 @@
<text class="score-value">{{ record.score }}</text>
<text class="score-unit"></text>
</view>
<button class="report-button" @click.stop="openReport">
<button class="report-button" @click.stop="openReport(record.id)">
<text>查看报告</text>
<view class="small-chevron"></view>
</button>
</view>
</view>
<view v-if="filteredRecords.length === 0" class="empty-state">
<text>没有找到匹配的训练记录</text>
<view v-if="recordItems.length === 0" class="empty-state">
<text>{{ emptyText }}</text>
</view>
</view>
</section>
<view class="bottom-hint">
<text>已经到底啦</text>
<text>{{ bottomHint }}</text>
</view>
</main>
</scroll-view>
@@ -87,72 +87,62 @@
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
fetchUserTrainingRecords,
type TrainingRecord,
type TrainingRecordSummary
} from '../../api/profile'
const keyword = ref('')
const toastMessage = ref('')
const toastVisible = ref(false)
const records = ref<TrainingRecord[]>([])
const summary = ref<TrainingRecordSummary>({
total_cases: 0,
total_hours: 0,
avg_accuracy: 0
})
const currentPage = ref(1)
const totalCount = ref(0)
const hasMore = ref(false)
const loadingRecords = ref(false)
const recordsLoaded = ref(false)
const recordsFailed = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0
const stats = [
{ label: '总病例', value: '12' },
{ label: '总时长', value: '128h' },
{ label: '平均正确率', value: '92%', secondary: true }
]
const stats = computed(() => [
{ label: '总病例', value: formatNumber(summary.value.total_cases) },
{ label: '总时长', value: `${formatNumber(summary.value.total_hours)}h` },
{ label: '平均正确率', value: `${formatNumber(summary.value.avg_accuracy)}%`, secondary: true }
])
const records = [
{
title: '急性心肌梗死',
department: '心内科',
date: '2023-11-20',
score: '98',
abbr: '心',
tone: 'primary'
},
{
title: '缺血性脑卒中',
department: '神经内科',
date: '2023-11-18',
score: '85',
abbr: '神',
tone: 'secondary'
},
{
title: '重症肺炎伴呼吸衰竭',
department: '呼吸科',
date: '2023-11-15',
score: '92',
abbr: '肺',
tone: 'tertiary'
},
{
title: '急性胰腺炎',
department: '消化内科',
date: '2023-11-12',
score: '78',
abbr: '消',
tone: 'primary',
dimmed: true
},
{
title: '糖尿病肾病五期',
department: '肾内科',
date: '2023-11-10',
score: '95',
abbr: '肾',
tone: 'secondary',
dimmed: true
}
]
const recordItems = computed(() => {
return records.value.map((record, index) => ({
id: record.record_id,
title: record.case_title || '未命名病例',
department: record.department || '未标注科室',
date: formatDate(record.trained_at),
score: formatNumber(record.score),
abbr: readRecordAbbr(record),
tone: readTone(index),
dimmed: index > 2
}))
})
const filteredRecords = computed(() => {
const query = keyword.value.trim()
if (!query) return records
const emptyText = computed(() => {
if (loadingRecords.value && !recordsLoaded.value) return '训练记录加载中...'
if (recordsFailed.value) return '训练记录加载失败,请稍后重试'
return keyword.value.trim() ? '没有找到匹配的训练记录' : '暂无训练记录'
})
return records.filter(record => {
return [record.title, record.department, record.date].some(value => value.includes(query))
})
const bottomHint = computed(() => {
if (loadingRecords.value && recordsLoaded.value) return '加载更多...'
if (hasMore.value) return '上拉加载更多'
return records.value.length > 0 ? '已经到底啦' : ''
})
function goBack() {
@@ -166,12 +156,54 @@ function goBack() {
})
}
function openReport() {
function openReport(evaluationId: number) {
if (!evaluationId) {
showToast('未找到报告 ID')
return
}
uni.setStorageSync('clinical-thinking-current-evaluation-id', evaluationId)
uni.navigateTo({
url: '/pages/assessment/assessment'
url: `/pages/assessment/assessment?evaluation_id=${encodeURIComponent(String(evaluationId))}`
})
}
async function loadRecords(page = 1, append = false) {
const seq = ++requestSeq
loadingRecords.value = true
if (!append) {
recordsFailed.value = false
hasMore.value = false
}
try {
const result = await fetchUserTrainingRecords({
search: keyword.value.trim(),
page
})
if (seq !== requestSeq) return
records.value = append ? records.value.concat(result.results || []) : result.results || []
summary.value = result.summary || summary.value
totalCount.value = result.count || records.value.length
currentPage.value = page
hasMore.value = Boolean(result.next) || records.value.length < totalCount.value
recordsLoaded.value = true
recordsFailed.value = false
} catch (error) {
if (seq !== requestSeq) return
recordsFailed.value = true
if (!append) records.value = []
showToast(error instanceof Error ? error.message : '训练记录加载失败')
} finally {
if (seq === requestSeq) loadingRecords.value = false
}
}
function loadMoreRecords() {
if (!hasMore.value || loadingRecords.value) return
void loadRecords(currentPage.value + 1, true)
}
function showToast(message: string) {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
@@ -181,8 +213,44 @@ function showToast(message: string) {
}, 2200)
}
function formatDate(value: string) {
if (!value) return '--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function formatNumber(value: number) {
if (!Number.isFinite(value)) return '0'
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function readRecordAbbr(record: TrainingRecord) {
const source = record.department || record.case_title || '训'
return source.trim().slice(0, 1) || '训'
}
function readTone(index: number) {
const tones = ['primary', 'secondary', 'tertiary'] as const
return tones[index % tones.length]
}
watch(keyword, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
void loadRecords(1)
}, 350)
})
onMounted(() => {
void loadRecords(1)
})
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
if (searchTimer) clearTimeout(searchTimer)
})
</script>
+112 -19
View File
@@ -15,15 +15,15 @@
<view class="profile-content">
<view class="user-card">
<view class="avatar-wrap">
<image class="avatar-image" src="/static/config-doctor.png" mode="aspectFill"></image>
<text class="pro-badge">PRO</text>
<image class="avatar-image" :src="avatarSrc" mode="aspectFill"></image>
<text class="pro-badge">{{ roleLabel }}</text>
</view>
<view class="user-copy">
<text class="doctor-name">陈伟 医生</text>
<text class="doctor-name">{{ displayName }}</text>
<view class="tag-row">
<text class="tag primary-tag">第二阶段规培</text>
<text class="tag secondary-tag">心内科</text>
<text class="tag primary-tag">{{ stageTag }}</text>
<text class="tag secondary-tag">{{ departmentTag }}</text>
</view>
<view class="meta-grid">
<view
@@ -133,7 +133,13 @@
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, onUnmounted, ref } from 'vue'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
import {
fetchCompetencyMetrics,
fetchUserProfile,
type CompetencyMetrics,
type UserProfile
} from '../../api/profile'
const emit = defineEmits<{
(event: 'open-settings'): void
@@ -143,6 +149,9 @@ const emit = defineEmits<{
const activeMood = ref('focused')
const toastMessage = ref('')
const toastVisible = ref(false)
const profile = ref<UserProfile | null>(null)
const competencyMetrics = ref<CompetencyMetrics | null>(null)
const loadingProfile = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
@@ -150,13 +159,6 @@ const instance = getCurrentInstance()
const hasGoHomeListener = Boolean(instance?.vnode.props?.onGoHome)
const hasOpenSettingsListener = Boolean(instance?.vnode.props?.onOpenSettings)
const profileMeta = [
{ label: '北京', icon: 'location-icon' },
{ label: '北大医学部', icon: 'school-icon' },
{ label: '2022级硕士', icon: 'calendar-icon' },
{ label: '3年从业经验', icon: 'timer-icon' }
]
const moods = [
{ id: 'steady', label: '平稳专注', icon: 'satisfied-icon' },
{ id: 'focused', label: '专注度极高', icon: 'bolt-icon' },
@@ -195,12 +197,56 @@ const actionEntries: ActionEntry[] = [
}
]
const metrics = [
{ label: '已完成病例', value: '12', badge: '本周 +2' },
{ label: '累计训练时长', value: '128', unit: '小时' },
{ label: '平均分', value: '85.5', progress: '85%' },
{ label: '诊断准确率', value: '92%', trending: true }
]
const displayName = computed(() => {
const user = profile.value
const name = user?.real_name || user?.username || user?.phone
return name ? `${name} 医生` : loadingProfile.value ? '加载中...' : '未登录医生'
})
const roleLabel = computed(() => {
const map: Record<string, string> = {
student: '学员',
teacher: '教师',
admin: '管理'
}
return map[profile.value?.role_type || ''] || 'PRO'
})
const avatarSrc = computed(() => resolveAvatar(profile.value?.avatar))
const stageTag = computed(() => {
return profile.value?.training_stage || profile.value?.current_level || profile.value?.title_name || '未配置阶段'
})
const departmentTag = computed(() => {
return profile.value?.department_name || profile.value?.major || '未配置科室'
})
const profileMeta = computed(() => {
const user = profile.value
return [
{ label: user?.institution_name || '未绑定机构', icon: 'school-icon' },
{ label: user?.department_name || '未配置科室', icon: 'location-icon' },
{ label: user?.title_name || '未配置职称', icon: 'calendar-icon' },
{ label: formatPracticeYears(user?.practice_years), icon: 'timer-icon' }
]
})
const metrics = computed(() => {
const metric = competencyMetrics.value
const completedCases = metric?.completed_cases ?? profile.value?.total_case_count ?? profile.value?.total_training_count ?? 0
const completedCasesWeek = metric?.completed_cases_week ?? 0
const totalHours = metric?.total_hours ?? 0
const avgScore = metric?.avg_score ?? 0
const diagnosisAccuracy = metric?.diagnosis_accuracy ?? 0
return [
{ label: '已完成病例', value: formatNumber(completedCases), badge: `本周 +${formatNumber(completedCasesWeek)}` },
{ label: '累计训练时长', value: formatNumber(totalHours), unit: '小时' },
{ label: '平均分', value: formatScore(avgScore), progress: `${clampPercent(avgScore)}%` },
{ label: '诊断准确率', value: `${formatNumber(diagnosisAccuracy)}%`, trending: true }
]
})
const activeMoodLabel = computed(() => {
return moods.find(item => item.id === activeMood.value)?.label || '专注度极高'
@@ -215,6 +261,49 @@ function showToast(message: string) {
}, 2200)
}
async function loadProfileData() {
loadingProfile.value = true
try {
const [userProfile, metricsResult] = await Promise.all([
fetchUserProfile(),
fetchCompetencyMetrics()
])
profile.value = userProfile
competencyMetrics.value = metricsResult
uni.setStorageSync('clinical-thinking-user-profile', userProfile)
} catch (error) {
showToast(error instanceof Error ? error.message : '个人信息加载失败')
} finally {
loadingProfile.value = false
}
}
function resolveAvatar(avatar?: string) {
if (!avatar) return '/static/config-doctor.png'
if (/^https?:\/\//i.test(avatar) || avatar.startsWith('/')) return avatar
return `/${avatar}`
}
function formatPracticeYears(value?: string) {
if (!value) return '未配置年限'
return value.includes('年') ? value : `${value}年经验`
}
function formatNumber(value: number) {
if (!Number.isFinite(value)) return '0'
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function formatScore(value: number) {
if (!Number.isFinite(value)) return '0'
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value)))
}
function handleAction(entry: ActionEntry) {
if (entry.route) {
uni.navigateTo({
@@ -248,6 +337,10 @@ function openSettings() {
})
}
onMounted(() => {
void loadProfileData()
})
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer)
})