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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user