feat: 联调

This commit is contained in:
王天骄
2026-06-14 08:32:08 +08:00
parent d68edd52aa
commit 3c2eb0a517
9 changed files with 912 additions and 55 deletions
+5 -2
View File
@@ -362,9 +362,12 @@ function normalizeCase(item: unknown, index: number): CaseListItem {
function normalizeAiGenerateResult(data: unknown): AiGenerateCaseResult {
const root = getRecord(data)
const payload = root.parse_id || root.parseId ? root : getRecord(root.data)
const payload = root.parse_id || root.parseId || root.case_type || root.caseType || root.title
? root
: getRecord(root.data)
const usage = getRecord(getFirst(payload, ['ai_usage', 'aiUsage']))
const generatedData = getRecord(payload.data)
const nestedData = getRecord(payload.data)
const generatedData = Object.keys(nestedData).length ? nestedData : payload
return {
parseId: getString(payload, ['parse_id', 'parseId']),
+12 -7
View File
@@ -27,15 +27,20 @@ export interface TeacherStudentRelationListResult {
}
export interface TeacherStudentRelationPayload {
teacher?: number
student?: number
teacher_phone?: string
student_phone?: string
relation_type?: string
status?: 0 | 1
}
export interface CreateTeacherStudentRelationPayload extends TeacherStudentRelationPayload {
teacher: number
student: number
teacher_phone: string
student_phone: string
}
export interface UpdateTeacherStudentRelationPayload extends TeacherStudentRelationPayload {
teacher_phone: string
student_phone: string
}
export interface CreateTeacherStudentRelationParams {
@@ -46,7 +51,7 @@ export interface CreateTeacherStudentRelationParams {
export interface UpdateTeacherStudentRelationParams {
token: string
id: number
payload: TeacherStudentRelationPayload
payload: UpdateTeacherStudentRelationPayload
}
export interface DisableTeacherStudentRelationParams {
@@ -111,14 +116,14 @@ function getRelatedUser(value: unknown, fallbackRecord: Record<string, unknown>,
return {
id: getString(record, ['id', 'user_id', 'userId']),
name: getString(record, ['real_name', 'realName', 'name', 'username']),
phone: getString(record, ['phone', 'mobile'])
phone: getString(record, ['phone', 'mobile', 'username'])
}
}
return {
id: typeof value === 'number' || typeof value === 'string' ? String(value) : getString(fallbackRecord, [`${prefix}_id`, `${prefix}Id`]),
name: getString(fallbackRecord, [`${prefix}_name`, `${prefix}Name`, `${prefix}_real_name`, `${prefix}RealName`]),
phone: getString(fallbackRecord, [`${prefix}_phone`, `${prefix}Phone`])
phone: getString(fallbackRecord, [`${prefix}_phone`, `${prefix}Phone`, `${prefix}_username`, `${prefix}Username`])
}
}
+58 -3
View File
@@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
@@ -25,17 +25,25 @@ const props = defineProps<{
const chartRef = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
const renderOption = computed(() => normalizeChartOption(props.option))
function renderChart() {
if (!chartRef.value) return
chart ||= echarts.init(chartRef.value)
chart.setOption(props.option, true)
try {
chart.setOption(renderOption.value, true)
} catch {
chart.clear()
chart.setOption(createEmptyOption('图表数据异常'), true)
}
}
function handleResize() {
chart?.resize()
}
watch(() => props.option, renderChart, { deep: true })
watch(renderOption, renderChart, { deep: true })
onMounted(() => {
renderChart()
@@ -45,5 +53,52 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chart?.dispose()
chart = null
})
function normalizeChartOption(option: EChartsOption): EChartsOption {
if (hasUnsafeEmptyRadar(option)) {
return createEmptyOption('暂无图表数据')
}
return option
}
function hasUnsafeEmptyRadar(option: EChartsOption) {
const rawOption = option as Record<string, unknown>
const series = toArray<Record<string, unknown>>(rawOption.series)
const hasRadarSeries = series.some(item => item?.type === 'radar')
if (!hasRadarSeries) {
return false
}
const radarComponents = toArray<Record<string, unknown>>(rawOption.radar)
return !radarComponents.some(item => toArray(item?.indicator).length > 0)
}
function toArray<T = unknown>(value: unknown): T[] {
if (Array.isArray(value)) {
return value as T[]
}
return value ? [value as T] : []
}
function createEmptyOption(text: string): EChartsOption {
return {
graphic: {
type: 'text',
left: 'center',
top: 'middle',
style: {
text,
fill: '#94a3b8',
fontSize: 14,
fontWeight: 500
}
},
series: []
}
}
</script>
+7
View File
@@ -34,7 +34,14 @@ const routes: RouteRecordRaw[] = [
{ path: 'teacher-student-relations', name: 'TeacherStudentRelations', component: () => import('@/views/TeacherStudentRelationsView.vue'), meta: { title: '师生关系管理' } },
{ path: 'settings', name: 'Settings', component: () => import('@/views/SettingsView.vue'), meta: { title: '系统配置' } },
{ path: 'module/hospital-dashboard', name: 'HospitalDashboard', component: () => import('@/views/HospitalDashboardView.vue'), meta: { title: '医院驾驶舱' } },
{ path: 'module/hospital-data', name: 'HospitalData', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '运营数据' } },
{ path: 'module/case-usage', name: 'HospitalCaseUsage', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '病例使用' } },
{ path: 'module/training-stats', name: 'HospitalTrainingStats', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '训练统计' } },
{ path: 'module/dept-analysis', name: 'HospitalDeptAnalysis', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '科室分析' } },
{ path: 'module/ability-trend', name: 'HospitalAbilityTrend', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '能力趋势' } },
{ path: 'module/student-ranking', name: 'HospitalStudentRanking', component: () => import('@/views/HospitalStatsModuleView.vue'), meta: { title: '学生排行' } },
{ path: 'module/content-dashboard', name: 'ContentDashboard', component: () => import('@/views/ContentDashboardView.vue'), meta: { title: '内容概览' } },
{ path: 'module/content-stats', name: 'ContentStats', component: () => import('@/views/ContentStatsView.vue'), meta: { title: '内容统计' } },
{ path: 'module/teacher-dashboard', name: 'TeacherDashboard', component: () => import('@/views/TeacherDashboardView.vue'), meta: { title: '教学概览' } },
{ path: 'module/case-list', redirect: '/cases' },
{ path: 'module/case-library', redirect: '/cases' },
+26 -5
View File
@@ -44,15 +44,15 @@
<div class="section-header">
<div>
<h2>生成结果</h2>
<p>{{ result ? `解析ID${result.parseId || '-'}` : '等待生成结果返回。' }}</p>
<p>{{ resultStatusText }}</p>
</div>
<el-tag v-if="result" type="success">{{ caseTypeLabel(result.caseType) }}</el-tag>
<el-tag v-if="result" type="success">{{ caseTypeLabel(result.caseType || form.case_type) }}</el-tag>
</div>
<el-empty v-if="!result" description="暂无生成结果" />
<template v-else>
<div class="ai-result-kpis">
<div v-if="hasResultMetrics" class="ai-result-kpis">
<div>
<span>输入消耗</span>
<strong>{{ formatNumber(result.aiUsage.promptTokens) }}</strong>
@@ -86,7 +86,7 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Cpu, Refresh } from '@element-plus/icons-vue'
@@ -100,7 +100,7 @@ const result = ref<AiGenerateCaseResult | null>(null)
const form = reactive({
case_type: 'traditional' as DraftCaseType,
prompt: '请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。'
prompt: ''
})
const rules: FormRules = {
@@ -108,6 +108,27 @@ const rules: FormRules = {
prompt: [{ required: true, message: '请输入病例长描述', trigger: 'blur' }]
}
const resultStatusText = computed(() => {
if (!result.value) {
return '等待生成结果返回。'
}
return result.value.parseId ? `解析ID${result.value.parseId}` : '接口已返回生成结果'
})
const hasResultMetrics = computed(() => {
if (!result.value) {
return false
}
return [
result.value.aiUsage.promptTokens,
result.value.aiUsage.completionTokens,
result.value.generatingSeconds,
result.value.parsingSeconds
].some(value => value !== null)
})
async function submitGenerate() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
+170
View File
@@ -0,0 +1,170 @@
<template>
<div v-loading="loading" class="dashboard-page page-stack">
<section class="page-toolbar">
<div>
<span class="eyebrow">内容管理员</span>
<h1>内容统计</h1>
<p>复用内容概览接口按病例难度统计病例数量与训练使用次数</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新数据</el-button>
</section>
<section class="stats-grid dashboard-kpis">
<article v-for="item in 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="overview-grid">
<ChartPanel class="wide-chart" title="不同病例难度分布与使用次数" subtitle="病例数、病例训练次数对比" :option="difficultyUsageOption" />
<ChartPanel title="不同病例类型分布" subtitle="传统、教学、脚本病例数量" :option="caseTypeDistributionOption" />
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { EChartsOption } from 'echarts'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import ChartPanel from '@/components/ChartPanel.vue'
import { fetchContentOverview, type ContentOverview } 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 overview = ref<ContentOverview | null>(null)
const dataView = computed(() => {
const data = overview.value
const summary = data?.summary || {}
const dist = data?.dist || { type_dist: [], difficulty_dist: [] }
return {
summary,
difficultyUsage: dist.difficulty_dist.map(item => ({
name: difficultyLabel(item.difficulty),
cases: item.case_count,
trainingTimes: item.train_count
})),
caseTypeDistribution: dist.type_dist.map(item => ({ name: caseTypeLabel(item.case_type), value: item.count }))
}
})
const kpis = computed<StatCard[]>(() => [
stat('病例总数', numberText(dataView.value.summary.case_total), '内容资产', 'green'),
stat('本月新增病例', numberText(dataView.value.summary.case_new_month), momText(dataView.value.summary.case_new_mom), 'purple'),
stat('病例使用率', percentText(dataView.value.summary.usage_rate), '已被训练病例占比', 'orange'),
stat('累计训练次数', numberText(dataView.value.summary.train_total), momText(dataView.value.summary.train_mom), 'blue')
])
const difficultyUsageOption = computed<EChartsOption>(() => ({
color: ['#2563eb', '#0f766e'],
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 48, right: 36, top: 42, bottom: 32 },
xAxis: { type: 'category', data: dataView.value.difficultyUsage.map(item => item.name), axisLine },
yAxis: [
{ type: 'value', name: '病例数', splitLine },
{ type: 'value', name: '训练次数', splitLine: { show: false } }
],
series: [
{ name: '病例数', type: 'bar', barWidth: 24, data: dataView.value.difficultyUsage.map(item => item.cases), itemStyle: { borderRadius: [5, 5, 0, 0] } },
{ name: '病例训练次数', type: 'line', yAxisIndex: 1, smooth: true, data: dataView.value.difficultyUsage.map(item => item.trainingTimes) }
]
}))
const caseTypeDistributionOption = computed<EChartsOption>(() => ({
color: ['#2563eb'],
tooltip: { trigger: 'axis' },
grid: { left: 98, right: 24, top: 18, bottom: 22 },
xAxis: { type: 'value', splitLine },
yAxis: {
type: 'category',
inverse: true,
data: dataView.value.caseTypeDistribution.map(item => item.name),
axisLabel: { width: 92, overflow: 'truncate' },
axisLine
},
series: [
{
name: '病例数',
type: 'bar',
barWidth: 14,
data: dataView.value.caseTypeDistribution.map(item => item.value),
itemStyle: { borderRadius: [0, 5, 5, 0] }
}
]
}))
async function loadOverview() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
try {
loading.value = true
overview.value = await fetchContentOverview(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 percentText(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? `${value}%` : '-'
}
function momText(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '暂无环比'
}
return `${value >= 0 ? '+' : ''}${value}% 环比`
}
function caseTypeLabel(type: string) {
const labels: Record<string, string> = {
diagnosis_treatment: '诊疗病例',
traditional: '传统病例',
teaching: '教学病例',
script: '脚本病例'
}
return labels[type] || type || '-'
}
function difficultyLabel(value: string) {
const labels: Record<string, string> = {
easy: '简单',
medium: '中等',
hard: '困难'
}
return labels[value] || value || '-'
}
onMounted(loadOverview)
</script>
+10 -2
View File
@@ -84,7 +84,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { EChartsOption } from 'echarts'
import { ElMessage } from 'element-plus'
import { Download, Refresh } from '@element-plus/icons-vue'
@@ -326,5 +326,13 @@ function mergeRadar(
}))
}
onMounted(loadOverview)
watch(
() => appStore.token,
token => {
if (token) {
loadOverview()
}
},
{ immediate: true }
)
</script>
+597
View File
@@ -0,0 +1,597 @@
<template>
<div v-loading="loading" class="dashboard-page page-stack">
<section class="page-toolbar">
<div>
<span class="eyebrow">{{ pageConfig.group }}</span>
<h1>{{ pageConfig.title }}</h1>
<p>{{ pageConfig.description }}</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Download">导出</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新数据</el-button>
</div>
</section>
<section class="stats-grid dashboard-kpis">
<article v-for="item in pageKpis" :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="overview-grid">
<ChartPanel
v-for="chart in pageCharts"
:key="chart.key"
:class="{ 'wide-chart': chart.wide, 'tall-chart': chart.tall }"
:title="chart.title"
:subtitle="chart.subtitle"
:option="chart.option"
/>
</section>
<section v-if="pageConfig.table === 'department'" class="data-section">
<div class="section-header">
<div>
<h2>{{ pageConfig.tableTitle }}</h2>
<p>{{ pageConfig.tableSubtitle }}</p>
</div>
</div>
<el-table :data="dashboard.departmentRows" empty-text="暂无科室数据" row-key="id">
<el-table-column prop="department" label="科室" min-width="130" />
<el-table-column prop="caseCount" label="病例数" width="100" />
<el-table-column prop="trainCount" label="训练次数" width="110" />
<el-table-column prop="effectiveTrain" label="有效训练" width="110" />
<el-table-column prop="activeUsers" label="活跃用户" width="110" />
<el-table-column prop="avgScore" label="平均分" width="100" />
</el-table>
</section>
<section v-else-if="pageConfig.table === 'case'" class="data-section">
<div class="section-header">
<div>
<h2>{{ pageConfig.tableTitle }}</h2>
<p>{{ pageConfig.tableSubtitle }}</p>
</div>
</div>
<el-table :data="caseRows" empty-text="暂无病例使用数据" row-key="id">
<el-table-column prop="title" label="病例名称" min-width="220" />
<el-table-column prop="count" label="训练次数" width="110" />
<el-table-column prop="total" label="完成训练" width="110" />
<el-table-column prop="passRate" label="通过率" width="110">
<template #default="{ row }">{{ percentText(row.passRate) }}</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { EChartsOption } from 'echarts'
import { ElMessage } from 'element-plus'
import { Download, Refresh } from '@element-plus/icons-vue'
import ChartPanel from '@/components/ChartPanel.vue'
import { fetchHospitalOverview, type HospitalOverview } from '@/api/stats'
import { useAppStore } from '@/stores/app'
type StatTone = 'blue' | 'green' | 'purple' | 'orange'
type TableMode = 'department' | 'case' | ''
interface StatCard {
label: string
value: string
change: string
tone: StatTone
}
interface PageConfig {
title: string
group: string
description: string
table: TableMode
tableTitle: string
tableSubtitle: string
}
interface ChartSpec {
key: string
title: string
subtitle: string
option: EChartsOption
wide?: boolean
tall?: boolean
}
const axisLine = { lineStyle: { color: '#dce3eb' } }
const splitLine = { lineStyle: { color: '#eef2f7' } }
const route = useRoute()
const appStore = useAppStore()
const loading = ref(false)
const overview = ref<HospitalOverview | null>(null)
const pageConfigs: Record<string, PageConfig> = {
'hospital-data': {
title: '运营数据',
group: '运营数据',
description: '汇总本院训练、科室、病例和学习效果核心指标。',
table: 'department',
tableTitle: '科室运营明细',
tableSubtitle: '来自医院驾驶舱总览的科室聚合数据'
},
'case-usage': {
title: '病例使用',
group: '运营数据',
description: '查看本院病例资产、病例使用次数和训练通过率。',
table: 'case',
tableTitle: '病例使用明细',
tableSubtitle: '病例训练次数、完成训练和通过率'
},
'training-stats': {
title: '训练统计',
group: '运营数据',
description: '跟踪本院训练规模、完成率和科室训练投入。',
table: 'department',
tableTitle: '科室训练明细',
tableSubtitle: '训练总次数、有效训练和平均成绩'
},
'dept-analysis': {
title: '科室分析',
group: '运营数据',
description: '按科室分析病例数、训练次数、活跃用户和平均成绩。',
table: 'department',
tableTitle: '科室综合排行',
tableSubtitle: '病例、训练、有效训练、活跃用户和平均分'
},
'ability-trend': {
title: '能力趋势',
group: '能力提升',
description: '对比本院学员能力维度、平均分和平台平均水平。',
table: '',
tableTitle: '',
tableSubtitle: ''
},
'student-ranking': {
title: '学生排行',
group: '能力提升',
description: '按科室聚合查看本院学员训练表现和平均成绩。',
table: 'department',
tableTitle: '科室学生表现排行',
tableSubtitle: '以科室为单位聚合活跃用户、训练次数和平均分'
}
}
const currentPage = computed(() => String(route.params.page || route.path.split('/').pop() || 'hospital-data'))
const pageConfig = computed(() => pageConfigs[currentPage.value] || pageConfigs['hospital-data'])
const dashboard = computed(() => {
const data = overview.value
const summary = data?.summary || { train_months: [], train_monthly: [] }
const competency = data?.competency || { radar: [], radar_platform: [] }
const caseAsset = data?.case_asset || { top_used: [], pass_high: [], pass_low: [] }
const departments = data?.dept_rank || []
const trainMonthly = summary.train_monthly || []
const departmentRows = departments.map(item => ({
id: item.id,
department: item.department,
caseCount: item.case_count,
trainCount: item.train_count,
effectiveTrain: item.effective_train,
activeUsers: item.active_users,
avgScore: item.avg_score
}))
return {
summary,
caseAsset,
departments,
departmentRows,
months: summary.train_months || [],
trainingCounts: trainMonthly,
latestMonthTraining: trainMonthly[trainMonthly.length - 1] || 0,
growthRates: calcGrowth(trainMonthly),
scoreGauge: {
hospital: competency.student_avg || summary.avg_score || 0,
platform: competency.platform_avg || 0
},
departmentCases: departmentRows.map(item => ({ name: item.department, value: item.caseCount })),
departmentTraining: departmentRows.map(item => ({ name: item.department, total: item.trainCount, effective: item.effectiveTrain })),
departmentActiveUsers: departmentRows.map(item => ({ name: item.department, value: item.activeUsers })),
departmentScores: departmentRows.map(item => ({ name: item.department, value: item.avgScore })),
caseUsageTop: caseAsset.top_used.map(item => ({ id: item.case_id, name: item.title, value: item.count })),
passRateBest: caseAsset.pass_high.map(item => ({ id: item.case_id, name: item.title, rate: item.pass_rate, total: item.total })),
passRateWorst: caseAsset.pass_low.map(item => ({ id: item.case_id, name: item.title, rate: item.pass_rate, total: item.total })),
abilityCompare: mergeRadar(competency.radar, competency.radar_platform),
totals: {
caseCount: departmentRows.reduce((total, item) => total + item.caseCount, 0),
trainCount: departmentRows.reduce((total, item) => total + item.trainCount, 0),
effectiveTrain: departmentRows.reduce((total, item) => total + item.effectiveTrain, 0),
activeUsers: departmentRows.reduce((total, item) => total + item.activeUsers, 0)
}
}
})
const caseRows = computed(() => {
const rows = new Map<number, { id: number; title: string; count: number; total: number; passRate: number | null }>()
dashboard.value.caseUsageTop.forEach(item => {
rows.set(item.id, { id: item.id, title: item.name, count: item.value, total: 0, passRate: null })
})
;[...dashboard.value.passRateBest, ...dashboard.value.passRateWorst].forEach(item => {
const row = rows.get(item.id)
rows.set(item.id, {
id: item.id,
title: item.name,
count: row?.count || 0,
total: item.total,
passRate: item.rate
})
})
return Array.from(rows.values()).sort((a, b) => b.count - a.count)
})
const pageKpis = computed<StatCard[]>(() => {
const summary = dashboard.value.summary
const caseAsset = dashboard.value.caseAsset
const totals = dashboard.value.totals
const bestDept = [...dashboard.value.departmentRows].sort((a, b) => b.avgScore - a.avgScore)[0]
if (currentPage.value === 'case-usage') {
return [
stat('累计病例总数', numberText(caseAsset.total), '本院病例', 'blue'),
stat('当月新增病例', numberText(caseAsset.new_month), '本月新增', 'green'),
stat('已使用病例数', numberText(caseRows.value.length), '有训练记录', 'purple'),
stat('最高使用次数', numberText(dashboard.value.caseUsageTop[0]?.value), dashboard.value.caseUsageTop[0]?.name || '暂无数据', 'orange')
]
}
if (currentPage.value === 'training-stats') {
return [
stat('累计训练次数', numberText(summary.train_total), '本院训练', 'blue'),
stat('本月训练次数', numberText(dashboard.value.latestMonthTraining), '最近月份', 'green'),
stat('训练完成率', percentText(summary.complete_rate), '完成训练占比', 'purple'),
stat('有效训练次数', numberText(totals.effectiveTrain), '科室合计', 'orange')
]
}
if (currentPage.value === 'dept-analysis') {
return [
stat('本院科室数', numberText(summary.dept_count), '当前机构', 'blue'),
stat('科室病例数', numberText(totals.caseCount), '科室合计', 'green'),
stat('科室训练数', numberText(totals.trainCount), '科室合计', 'purple'),
stat('活跃用户数', numberText(totals.activeUsers), '科室合计', 'orange')
]
}
if (currentPage.value === 'ability-trend') {
return [
stat('本院平均分', scoreText(dashboard.value.scoreGauge.hospital), '学生训练平均', 'blue'),
stat('平台平均分', scoreText(dashboard.value.scoreGauge.platform), '平台对比', 'green'),
stat('训练完成率', percentText(summary.complete_rate), '完成训练占比', 'purple'),
stat('能力维度数', numberText(dashboard.value.abilityCompare.length), '雷达维度', 'orange')
]
}
if (currentPage.value === 'student-ranking') {
return [
stat('本院学员数', numberText(summary.student_count), '学生用户', 'blue'),
stat('平均训练得分', scoreText(summary.avg_score), '本院平均', 'green'),
stat('累计训练次数', numberText(summary.train_total), '本院训练', 'purple'),
stat('最高分科室', bestDept?.department || '-', bestDept ? scoreText(bestDept.avgScore) : '暂无数据', 'orange')
]
}
return [
stat('本院科室数', numberText(summary.dept_count), '当前机构', 'blue'),
stat('带教老师数', numberText(summary.doctor_count), '医生用户', 'green'),
stat('本院学员数', numberText(summary.student_count), '学生用户', 'purple'),
stat('累计训练次数', numberText(summary.train_total), '本院训练', 'blue'),
stat('训练完成率', percentText(summary.complete_rate), '完成训练占比', 'orange'),
stat('平均训练得分', scoreText(summary.avg_score), '归一百分制', 'green')
]
})
const pageCharts = computed<ChartSpec[]>(() => {
if (currentPage.value === 'case-usage') {
return [
chart('case-top', '使用病例最高次数 Top10', '按病例训练次数排序', caseUsageTopOption.value, true, true),
chart('case-pass', '病例通过率排行', '按完成训练通过率排序', casePassRateOption.value),
chart('case-total', '病例资产概览', '累计病例与当月新增病例', caseAssetOption.value)
]
}
if (currentPage.value === 'training-stats') {
return [
chart('training-trend', '本院近6个月训练次数', '柱状图为训练次数,折线图为环比增长率', trainingTrendOption.value, true),
chart('dept-training', '各科室训练次数与有效训练次数', '训练总次数与完整完成次数对比', departmentTrainingOption.value, true),
chart('score-gauge', '本院训练完成率', '完成率与平均分概览', completionGaugeOption.value)
]
}
if (currentPage.value === 'dept-analysis') {
return [
chart('dept-cases', '各科室病例数排行', '按科室统计当前病例数', departmentCasesOption.value),
chart('dept-training', '各科室训练次数与有效训练次数', '训练总次数与完整完成次数对比', departmentTrainingOption.value, true),
chart('dept-active', '各科室活跃用户排行', '产生训练行为的活跃用户数', departmentActiveOption.value),
chart('dept-score', '各科室平均成绩', '按科室计算训练平均成绩', departmentScoreOption.value)
]
}
if (currentPage.value === 'ability-trend') {
return [
chart('ability-radar', '本院各维度得分率', '雷达图叠加平台平均线', abilityCompareOption.value, true),
chart('score-compare', '本院学员平均分与平台对比', '统计本院所有学生训练平均分', scoreGaugeOption.value),
chart('dept-score', '各科室平均成绩', '按科室计算训练平均成绩', departmentScoreOption.value)
]
}
if (currentPage.value === 'student-ranking') {
return [
chart('dept-score-rank', '科室学生平均分排行', '按科室聚合学生训练平均分', departmentScoreRankingOption.value, true),
chart('dept-active', '科室活跃学生排行', '按科室聚合活跃用户数', departmentActiveOption.value),
chart('dept-training', '科室训练投入排行', '按科室聚合训练次数', departmentTrainRankingOption.value)
]
}
return [
chart('training-trend', '本院近6个月训练次数', '柱状图为训练次数,折线图为环比增长率', trainingTrendOption.value, true),
chart('score-gauge', '本院学员平均分与平台对比', '统计本院所有学生训练平均分', scoreGaugeOption.value),
chart('dept-training', '各科室训练次数与有效训练次数', '训练总次数与完整完成次数对比', departmentTrainingOption.value, true),
chart('case-top', '使用病例最高次数 Top10', '按病例训练次数排序', caseUsageTopOption.value)
]
})
const trainingTrendOption = computed<EChartsOption>(() => ({
color: ['#2563eb', '#f59e0b'],
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 48, right: 48, top: 42, bottom: 32 },
xAxis: { type: 'category', data: dashboard.value.months, axisLine },
yAxis: [
{ type: 'value', name: '训练次数', splitLine },
{ type: 'value', name: '环比', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } }
],
series: [
{ name: '训练次数', type: 'bar', barWidth: 22, data: dashboard.value.trainingCounts, itemStyle: { borderRadius: [5, 5, 0, 0] } },
{ name: '环比增长率', type: 'line', yAxisIndex: 1, smooth: true, data: dashboard.value.growthRates }
]
}))
const scoreGaugeOption = computed<EChartsOption>(() => ({
color: ['#2563eb', '#0f766e'],
series: [
{
name: '本院平均分',
type: 'gauge',
radius: '82%',
min: 0,
max: 100,
progress: { show: true, roundCap: true, width: 12 },
axisLine: { lineStyle: { width: 12 } },
pointer: { width: 4 },
detail: { formatter: '{value}', fontSize: 24, offsetCenter: [0, '62%'] },
data: [{ value: dashboard.value.scoreGauge.hospital, name: `平台 ${dashboard.value.scoreGauge.platform}` }]
}
]
}))
const completionGaugeOption = computed<EChartsOption>(() => ({
color: ['#0f766e'],
series: [
{
name: '训练完成率',
type: 'gauge',
radius: '82%',
min: 0,
max: 100,
progress: { show: true, roundCap: true, width: 12 },
axisLine: { lineStyle: { width: 12 } },
pointer: { width: 4 },
detail: { formatter: '{value}%', fontSize: 22, offsetCenter: [0, '62%'] },
data: [{ value: dashboard.value.summary.complete_rate || 0, name: `平均分 ${scoreText(dashboard.value.summary.avg_score)}` }]
}
]
}))
const departmentCasesOption = computed<EChartsOption>(() => horizontalBarOption(
dashboard.value.departmentCases.map(item => item.name),
dashboard.value.departmentCases.map(item => item.value),
'病例数',
'#2563eb'
))
const departmentTrainingOption = computed<EChartsOption>(() => ({
color: ['#2563eb', '#0f766e'],
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 70, right: 24, top: 42, bottom: 28 },
xAxis: { type: 'value', splitLine },
yAxis: { type: 'category', inverse: true, data: dashboard.value.departmentTraining.map(item => item.name), axisLine },
series: [
{ name: '训练总次数', type: 'bar', barWidth: 14, data: dashboard.value.departmentTraining.map(item => item.total) },
{ name: '有效训练次数', type: 'bar', barWidth: 14, data: dashboard.value.departmentTraining.map(item => item.effective) }
]
}))
const departmentActiveOption = computed<EChartsOption>(() => horizontalBarOption(
dashboard.value.departmentActiveUsers.map(item => item.name),
dashboard.value.departmentActiveUsers.map(item => item.value),
'活跃用户',
'#0f766e'
))
const departmentScoreOption = computed<EChartsOption>(() => ({
color: ['#7c3aed'],
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 28, bottom: 32 },
xAxis: { type: 'category', data: dashboard.value.departmentScores.map(item => item.name), axisLine },
yAxis: { type: 'value', max: 100, splitLine },
series: [
{ name: '平均成绩', type: 'bar', barWidth: 22, data: dashboard.value.departmentScores.map(item => item.value), itemStyle: { borderRadius: [5, 5, 0, 0] } }
]
}))
const departmentScoreRankingOption = computed<EChartsOption>(() => {
const rows = [...dashboard.value.departmentScores].sort((a, b) => b.value - a.value)
return horizontalBarOption(
rows.map(item => item.name),
rows.map(item => item.value),
'平均分',
'#f59e0b',
100
)
})
const departmentTrainRankingOption = computed<EChartsOption>(() => {
const rows = [...dashboard.value.departmentTraining].sort((a, b) => b.total - a.total)
return horizontalBarOption(
rows.map(item => item.name),
rows.map(item => item.total),
'训练次数',
'#2563eb'
)
})
const caseUsageTopOption = computed<EChartsOption>(() => horizontalBarOption(
dashboard.value.caseUsageTop.map(item => item.name),
dashboard.value.caseUsageTop.map(item => item.value),
'训练次数',
'#2563eb'
))
const casePassRateOption = computed<EChartsOption>(() => {
const rows = [...caseRows.value].filter(item => item.passRate !== null).sort((a, b) => (b.passRate || 0) - (a.passRate || 0))
return horizontalBarOption(
rows.map(item => item.title),
rows.map(item => item.passRate || 0),
'通过率',
'#0f766e',
100
)
})
const caseAssetOption = computed<EChartsOption>(() => ({
color: ['#2563eb', '#f59e0b'],
tooltip: { trigger: 'axis' },
grid: { left: 42, right: 20, top: 28, bottom: 32 },
xAxis: { type: 'category', data: ['累计病例', '当月新增'], axisLine },
yAxis: { type: 'value', splitLine },
series: [
{
name: '病例数',
type: 'bar',
barWidth: 28,
data: [dashboard.value.caseAsset.total || 0, dashboard.value.caseAsset.new_month || 0],
itemStyle: { borderRadius: [5, 5, 0, 0] }
}
]
}))
const abilityCompareOption = computed<EChartsOption>(() => ({
color: ['#2563eb', '#f59e0b'],
legend: { top: 0 },
radar: {
radius: 96,
indicator: dashboard.value.abilityCompare.map(item => ({ name: item.name, max: 100 }))
},
series: [
{
type: 'radar',
data: [
{ name: '本院平均', value: dashboard.value.abilityCompare.map(item => item.hospital), areaStyle: { color: 'rgba(37, 99, 235, 0.14)' } },
{ name: '平台平均', value: dashboard.value.abilityCompare.map(item => item.platform), areaStyle: { color: 'rgba(245, 158, 11, 0.1)' } }
]
}
]
}))
function horizontalBarOption(names: string[], values: number[], seriesName: string, color: string, max?: number): EChartsOption {
return {
color: [color],
tooltip: { trigger: 'axis' },
grid: { left: 100, right: 24, top: 18, bottom: 22 },
xAxis: { type: 'value', max, splitLine },
yAxis: { type: 'category', inverse: true, data: names, axisLabel: { width: 94, overflow: 'truncate' }, axisLine },
series: [
{ name: seriesName, type: 'bar', barWidth: 14, data: values, itemStyle: { borderRadius: [0, 5, 5, 0] } }
]
}
}
async function loadOverview() {
if (!appStore.token) {
ElMessage.warning('缺少登录信息,请重新登录')
return
}
if (loading.value) {
return
}
try {
loading.value = true
overview.value = await fetchHospitalOverview(appStore.token)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取医院概览失败')
} finally {
loading.value = false
}
}
function stat(label: string, value: string, change: string, tone: StatTone): StatCard {
return { label, value, change, tone }
}
function chart(key: string, title: string, subtitle: string, option: EChartsOption, wide = false, tall = false): ChartSpec {
return { key, title, subtitle, option, wide, tall }
}
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 percentText(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(1)}%` : '-'
}
function calcGrowth(values: number[]) {
return values.map((value, index) => {
if (index === 0 || !values[index - 1]) {
return 0
}
return Number((((value - values[index - 1]) / values[index - 1]) * 100).toFixed(1))
})
}
function mergeRadar(
hospital: Array<{ dimension: string; score: number }>,
platform: Array<{ dimension: string; score: number }>
) {
const platformMap = new Map(platform.map(item => [item.dimension, item.score]))
const names = Array.from(new Set([...hospital.map(item => item.dimension), ...platform.map(item => item.dimension)]))
return names.map(name => ({
name,
hospital: hospital.find(item => item.dimension === name)?.score || 0,
platform: platformMap.get(name) || 0
}))
}
watch(
() => appStore.token,
token => {
if (token) {
loadOverview()
}
},
{ immediate: true }
)
</script>
+27 -36
View File
@@ -15,8 +15,8 @@
<section class="filter-bar relation-filter">
<el-input v-model="filters.search" :prefix-icon="Search" clearable placeholder="搜索师生姓名/手机号" @keyup.enter="loadRelations" />
<el-input v-model="filters.teacher" clearable placeholder="带教医生ID" @keyup.enter="loadRelations" />
<el-input v-model="filters.student" clearable placeholder="学生ID" @keyup.enter="loadRelations" />
<el-input v-model="filters.teacher" clearable placeholder="带教医生手机号" @keyup.enter="loadRelations" />
<el-input v-model="filters.student" clearable placeholder="学生手机号" @keyup.enter="loadRelations" />
<el-select v-model="filters.status" clearable placeholder="状态">
<el-option label="进行中" value="1" />
<el-option label="已结束" value="0" />
@@ -78,13 +78,13 @@
<el-form ref="relationFormRef" :model="relationForm" :rules="relationRules" label-position="top">
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="带教医生用户ID" prop="teacher">
<el-input-number v-model="relationForm.teacher" :min="1" :precision="0" :controls="false" placeholder="请输入医生用户ID" />
<el-form-item label="带教医生手机号" prop="teacher_phone">
<el-input v-model="relationForm.teacher_phone" maxlength="11" placeholder="请输入医生手机号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学生用户ID" prop="student">
<el-input-number v-model="relationForm.student" :min="1" :precision="0" :controls="false" placeholder="请输入学生用户ID" />
<el-form-item label="学生手机号" prop="student_phone">
<el-input v-model="relationForm.student_phone" maxlength="11" placeholder="请输入学生手机号" />
</el-form-item>
</el-col>
<el-col :span="12">
@@ -146,7 +146,7 @@ import {
updateTeacherStudentRelation,
type CreateTeacherStudentRelationPayload,
type TeacherStudentRelationItem,
type TeacherStudentRelationPayload
type UpdateTeacherStudentRelationPayload
} from '@/api/teacherStudentRelations'
import { useAppStore } from '@/stores/app'
@@ -176,15 +176,21 @@ const pagination = reactive({
total: 0
})
const relationForm = reactive({
teacher: undefined as number | undefined,
student: undefined as number | undefined,
teacher_phone: '',
student_phone: '',
relation_type: '指导',
status: 1 as 0 | 1
})
const relationRules: FormRules = {
teacher: [{ required: true, message: '请输入带教医生用户ID', trigger: 'blur' }],
student: [{ required: true, message: '请输入学生用户ID', trigger: 'blur' }]
teacher_phone: [
{ required: true, message: '请输入带教医生手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的带教医生手机号', trigger: 'blur' }
],
student_phone: [
{ required: true, message: '请输入学生手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的学生手机号', trigger: 'blur' }
]
}
const relationDialogTitle = computed(() => (relationMode.value === 'create' ? '新增师生关系' : '编辑师生关系'))
@@ -235,16 +241,16 @@ function openCreateDialog() {
function openEditDialog(row: TeacherStudentRelationItem) {
relationMode.value = 'edit'
editingRelation.value = row
relationForm.teacher = Number(row.teacherId) || undefined
relationForm.student = Number(row.studentId) || undefined
relationForm.teacher_phone = row.teacherPhone
relationForm.student_phone = row.studentPhone
relationForm.relation_type = row.relationType
relationForm.status = row.status
relationDialogVisible.value = true
}
function resetRelationForm() {
relationForm.teacher = undefined
relationForm.student = undefined
relationForm.teacher_phone = ''
relationForm.student_phone = ''
relationForm.relation_type = '指导'
relationForm.status = 1
editingRelation.value = null
@@ -253,33 +259,22 @@ function resetRelationForm() {
function buildCreatePayload(): CreateTeacherStudentRelationPayload {
return {
teacher: Number(relationForm.teacher),
student: Number(relationForm.student),
teacher_phone: relationForm.teacher_phone.trim(),
student_phone: relationForm.student_phone.trim(),
relation_type: relationForm.relation_type.trim(),
status: relationForm.status
}
}
function buildUpdatePayload(): TeacherStudentRelationPayload {
if (!editingRelation.value) {
return {}
}
function buildUpdatePayload(): UpdateTeacherStudentRelationPayload {
const current = {
teacher: Number(relationForm.teacher),
student: Number(relationForm.student),
teacher_phone: relationForm.teacher_phone.trim(),
student_phone: relationForm.student_phone.trim(),
relation_type: relationForm.relation_type.trim(),
status: relationForm.status
}
const original = editingRelation.value
const payload: TeacherStudentRelationPayload = {}
if (current.teacher !== Number(original.teacherId)) payload.teacher = current.teacher
if (current.student !== Number(original.studentId)) payload.student = current.student
if (current.relation_type !== original.relationType) payload.relation_type = current.relation_type
if (current.status !== original.status) payload.status = current.status
return payload
return current
}
async function submitRelationForm() {
@@ -304,10 +299,6 @@ async function submitRelationForm() {
pagination.page = 1
} else if (editingRelation.value) {
const payload = buildUpdatePayload()
if (!Object.keys(payload).length) {
ElMessage.warning('没有需要保存的修改')
return
}
await updateTeacherStudentRelation({
token: appStore.token,
id: editingRelation.value.id,