Files
cms/src/views/TeacherDashboardView.vue
T
2026-06-13 06:24:09 +08:00

281 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>