281 lines
10 KiB
Vue
281 lines
10 KiB
Vue
<template>
|
||
<div v-loading="loading" class="dashboard-page page-stack">
|
||
<section class="hero-strip dashboard-hero">
|
||
<div>
|
||
<span class="eyebrow">教学数据中心</span>
|
||
<h1>带教医生教学概览</h1>
|
||
<p>实时查看所带学生训练画像、任务完成、分数分布和已下发任务情况。</p>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<el-button :icon="Download">导出教学报告</el-button>
|
||
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新数据</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="stats-grid">
|
||
<article v-for="item in dashboard.kpis" :key="item.label" class="stat-card">
|
||
<div :class="['stat-mark', item.tone]">{{ item.label.slice(0, 1) }}</div>
|
||
<div>
|
||
<span>{{ item.label }}</span>
|
||
<strong>{{ item.value }}</strong>
|
||
<em>{{ item.change }}</em>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="data-section">
|
||
<div class="section-header">
|
||
<div>
|
||
<h2>我的学生列表</h2>
|
||
<p>当前带教关系下学生训练概览</p>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<el-input v-model="searchKeyword" class="compact-search" :prefix-icon="Search" clearable placeholder="搜索学生" />
|
||
</div>
|
||
</div>
|
||
<el-table :data="filteredStudents" row-key="id">
|
||
<el-table-column prop="id" label="学生ID" width="110" />
|
||
<el-table-column prop="name" label="姓名" width="100" />
|
||
<el-table-column prop="username" label="账号" width="130" />
|
||
<el-table-column prop="department" label="科室" width="110" />
|
||
<el-table-column prop="trainings" label="累计训练" width="100" />
|
||
<el-table-column prop="completion" label="完成率" width="90" />
|
||
<el-table-column prop="avgScore" label="平均分" width="90" />
|
||
<el-table-column prop="weakDimension" label="薄弱维度" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.weakDimension === '-' ? 'info' : 'warning'">{{ row.weakDimension }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="favoriteType" label="最常训练类型" width="130" />
|
||
<el-table-column prop="lastTraining" label="最近一次训练时间" min-width="160" />
|
||
<el-table-column prop="pendingTasks" label="待完成任务" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.pendingTasks > 0 ? 'danger' : 'success'">{{ row.pendingTasks }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<section class="overview-grid">
|
||
<ChartPanel title="整体薄弱维度" subtitle="问诊、诊断、治疗、沟通、检查平均得分率" :option="weakRadarOption" />
|
||
<ChartPanel class="wide-chart" title="整体训练次数趋势" subtitle="近6个月所带学生训练总次数" :option="trainingTrendOption" />
|
||
<ChartPanel class="wide-chart" title="学生待完成任务" subtitle="按学生展示待完成任务数量" :option="taskCompletionOption" />
|
||
<ChartPanel title="学生平均分分布" subtitle="按学生训练平均分分段统计" :option="scoreDistributionOption" />
|
||
</section>
|
||
|
||
<section class="data-section">
|
||
<div class="section-header">
|
||
<div>
|
||
<h2>学生待完成任务</h2>
|
||
<p>接口返回任务摘要为空时,按学生维度展示待完成数量</p>
|
||
</div>
|
||
</div>
|
||
<el-table :data="dashboard.pendingRows" row-key="id" empty-text="暂无待完成任务数据">
|
||
<el-table-column prop="id" label="学生ID" width="110" />
|
||
<el-table-column prop="name" label="姓名" min-width="120" />
|
||
<el-table-column prop="department" label="科室" width="120" />
|
||
<el-table-column prop="trainings" label="累计训练" width="100" />
|
||
<el-table-column prop="pendingTasks" label="待完成任务" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.pendingTasks > 0 ? 'danger' : 'success'">{{ row.pendingTasks }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import type { EChartsOption } from 'echarts'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Download, Refresh, Search } from '@element-plus/icons-vue'
|
||
import ChartPanel from '@/components/ChartPanel.vue'
|
||
import { fetchTeachingOverview, type TeachingOverview } from '@/api/stats'
|
||
import { useAppStore } from '@/stores/app'
|
||
|
||
interface StatCard {
|
||
label: string
|
||
value: string
|
||
change: string
|
||
tone: 'blue' | 'green' | 'purple' | 'orange'
|
||
}
|
||
|
||
const axisLine = { lineStyle: { color: '#dce3eb' } }
|
||
const splitLine = { lineStyle: { color: '#eef2f7' } }
|
||
const appStore = useAppStore()
|
||
const loading = ref(false)
|
||
const searchKeyword = ref('')
|
||
const overview = ref<TeachingOverview | null>(null)
|
||
|
||
const dashboard = computed(() => {
|
||
const data = overview.value
|
||
const summary = data?.overview || { radar: [], train_months: [], train_monthly: [] }
|
||
const students = (data?.students || []).map(item => ({
|
||
id: String(item.id),
|
||
name: item.real_name || item.username || '-',
|
||
username: item.username || '-',
|
||
department: item.department || '-',
|
||
trainings: item.train_total,
|
||
completion: `${item.complete_rate}%`,
|
||
avgScore: item.avg_score,
|
||
weakDimension: item.weak_dimensions?.length ? item.weak_dimensions.join('、') : '-',
|
||
favoriteType: caseTypeLabel(item.most_trained_type),
|
||
lastTraining: formatDateTime(item.last_trained_at),
|
||
pendingTasks: item.pending_tasks || 0
|
||
}))
|
||
const pendingTotal = students.reduce((total, item) => total + item.pendingTasks, 0)
|
||
|
||
return {
|
||
kpis: [
|
||
stat('我的学生数', numberText(summary.student_count), '带教范围', 'blue'),
|
||
stat('学生整体平均分', scoreText(summary.students_avg), `医院 ${scoreText(summary.institution_avg)}`, 'green'),
|
||
stat('近6月训练次数', numberText((summary.train_monthly || []).reduce((total, item) => total + item, 0)), '学生训练合计', 'purple'),
|
||
stat('待完成任务数', numberText(pendingTotal), '学生未完成任务', 'orange')
|
||
],
|
||
students,
|
||
weakRadar: summary.radar.map(item => ({ name: item.dimension, value: item.score })),
|
||
months: summary.train_months || [],
|
||
trainingTrend: summary.train_monthly || [],
|
||
pendingRows: students.map(item => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
department: item.department,
|
||
trainings: item.trainings,
|
||
pendingTasks: item.pendingTasks
|
||
})),
|
||
scoreDistribution: createScoreDistribution(students.map(item => item.avgScore))
|
||
}
|
||
})
|
||
|
||
const filteredStudents = computed(() => {
|
||
const keyword = searchKeyword.value.trim()
|
||
if (!keyword) {
|
||
return dashboard.value.students
|
||
}
|
||
return dashboard.value.students.filter(item =>
|
||
item.name.includes(keyword) ||
|
||
item.username.includes(keyword) ||
|
||
item.department.includes(keyword)
|
||
)
|
||
})
|
||
|
||
const weakRadarOption = computed<EChartsOption>(() => ({
|
||
color: ['#dc2626'],
|
||
radar: {
|
||
radius: 94,
|
||
indicator: dashboard.value.weakRadar.map(item => ({ name: item.name, max: 100 }))
|
||
},
|
||
series: [
|
||
{
|
||
type: 'radar',
|
||
areaStyle: { color: 'rgba(220, 38, 38, 0.12)' },
|
||
data: [{ value: dashboard.value.weakRadar.map(item => item.value), name: '平均得分率' }]
|
||
}
|
||
]
|
||
}))
|
||
|
||
const trainingTrendOption = computed<EChartsOption>(() => ({
|
||
color: ['#2563eb'],
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 42, right: 20, top: 28, bottom: 32 },
|
||
xAxis: { type: 'category', data: dashboard.value.months, axisLine },
|
||
yAxis: { type: 'value', splitLine },
|
||
series: [
|
||
{ name: '训练次数', type: 'line', smooth: true, areaStyle: { color: 'rgba(37, 99, 235, 0.12)' }, data: dashboard.value.trainingTrend }
|
||
]
|
||
}))
|
||
|
||
const taskCompletionOption = computed<EChartsOption>(() => ({
|
||
color: ['#f59e0b'],
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 42, right: 20, top: 28, bottom: 40 },
|
||
xAxis: { type: 'category', data: dashboard.value.pendingRows.map(item => item.name), axisLine },
|
||
yAxis: { type: 'value', splitLine },
|
||
series: [
|
||
{ name: '待完成任务', type: 'bar', barWidth: 20, data: dashboard.value.pendingRows.map(item => item.pendingTasks), itemStyle: { borderRadius: [5, 5, 0, 0] } }
|
||
]
|
||
}))
|
||
|
||
const scoreDistributionOption = computed<EChartsOption>(() => ({
|
||
color: ['#16a34a', '#2563eb', '#f59e0b', '#7c3aed', '#dc2626'],
|
||
tooltip: { trigger: 'item' },
|
||
legend: { bottom: 0 },
|
||
series: [
|
||
{
|
||
name: '得分分布',
|
||
type: 'pie',
|
||
radius: ['42%', '70%'],
|
||
center: ['50%', '45%'],
|
||
data: dashboard.value.scoreDistribution,
|
||
label: { formatter: '{b}\n{d}%' }
|
||
}
|
||
]
|
||
}))
|
||
|
||
async function loadOverview() {
|
||
if (!appStore.token) {
|
||
ElMessage.warning('缺少登录信息,请重新登录')
|
||
return
|
||
}
|
||
|
||
try {
|
||
loading.value = true
|
||
overview.value = await fetchTeachingOverview(appStore.token)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '获取教学概览失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function stat(label: string, value: string, change: string, tone: StatCard['tone']): StatCard {
|
||
return { label, value, change, tone }
|
||
}
|
||
|
||
function numberText(value: number | null | undefined) {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('zh-CN') : '-'
|
||
}
|
||
|
||
function scoreText(value: number | null | undefined) {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(1) : '-'
|
||
}
|
||
|
||
function caseTypeLabel(type: string) {
|
||
const labels: Record<string, string> = {
|
||
diagnosis_treatment: '诊疗病例',
|
||
traditional: '传统病例',
|
||
teaching: '教学病例',
|
||
script: '脚本病例'
|
||
}
|
||
return labels[type] || type || '-'
|
||
}
|
||
|
||
function formatDateTime(value: string) {
|
||
if (!value) {
|
||
return '-'
|
||
}
|
||
return value.replace('T', ' ').replace('Z', '').slice(0, 19)
|
||
}
|
||
|
||
function createScoreDistribution(scores: number[]) {
|
||
const buckets = [
|
||
{ name: '90分以上', value: 0 },
|
||
{ name: '80-89分', value: 0 },
|
||
{ name: '70-79分', value: 0 },
|
||
{ name: '60-69分', value: 0 },
|
||
{ name: '60分以下', value: 0 }
|
||
]
|
||
scores.forEach(score => {
|
||
if (score >= 90) buckets[0].value += 1
|
||
else if (score >= 80) buckets[1].value += 1
|
||
else if (score >= 70) buckets[2].value += 1
|
||
else if (score >= 60) buckets[3].value += 1
|
||
else buckets[4].value += 1
|
||
})
|
||
return buckets
|
||
}
|
||
|
||
onMounted(loadOverview)
|
||
</script>
|