feat: 联调
This commit is contained in:
+5
-2
@@ -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']),
|
||||
|
||||
@@ -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`])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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('缺少登录信息,请重新登录')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user