Compare commits
2
Commits
545071a7dd
...
bb9544f8ec
No files matched your search
@@ -21,6 +21,7 @@ import {
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import StudentDashboardView from './StudentDashboardView.vue'
|
||||
import TeacherDashboardView from './TeacherDashboardView.vue'
|
||||
|
||||
interface DashboardData {
|
||||
audience: {
|
||||
@@ -101,6 +102,10 @@ const isStudentOverview = computed(() =>
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role),
|
||||
),
|
||||
)
|
||||
const isTeacherOverview = computed(() =>
|
||||
roles.value.includes('Teacher') &&
|
||||
!roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role)),
|
||||
)
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const data = ref<DashboardData | null>(null)
|
||||
@@ -375,6 +380,19 @@ const readiness = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const operationAlerts = computed(() => {
|
||||
const counts = data.value?.counts
|
||||
if (!counts) return []
|
||||
const alerts = [] as Array<{ level: 'critical' | 'warning'; title: string; detail: string; route: string }>
|
||||
const unpublished = counts.teachingTasks - counts.publishedTeachingTasks
|
||||
const unscheduled = counts.publishedTeachingTasks - counts.scheduledTeachingTasks
|
||||
if (unpublished > 0) alerts.push({ level: 'warning', title: '教学任务尚未发布', detail: `${unpublished} 个教学班尚未发布,后续排课与选课无法推进。`, route: '/teaching-tasks' })
|
||||
if (unscheduled > 0) alerts.push({ level: 'critical', title: '课表覆盖存在缺口', detail: `${unscheduled} 个已发布教学班尚未进入课表。`, route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable' })
|
||||
if (counts.submittedGradeSheets > 0) alerts.push({ level: 'warning', title: '成绩审核等待处理', detail: `${counts.submittedGradeSheets} 张成绩登记册已提交,等待审核或发布。`, route: '/grades' })
|
||||
if (counts.openCourseSelectionRounds > 0 && counts.courseEnrollments === 0) alerts.push({ level: 'warning', title: '开放选课尚无有效记录', detail: `${counts.openCourseSelectionRounds} 个选课批次开放中,但当前未发现有效选课。`, route: '/course-selections' })
|
||||
return alerts
|
||||
})
|
||||
|
||||
const primaryActionRoute = computed(() =>
|
||||
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
|
||||
)
|
||||
@@ -397,7 +415,7 @@ async function loadDashboard() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isStudentOverview.value) {
|
||||
if (isStudentOverview.value || isTeacherOverview.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
@@ -407,6 +425,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<StudentDashboardView v-if="isStudentOverview" />
|
||||
<TeacherDashboardView v-else-if="isTeacherOverview" />
|
||||
|
||||
<div v-else v-loading="loading" class="admin-dashboard">
|
||||
<el-result
|
||||
@@ -505,6 +524,14 @@ onMounted(async () => {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="operation-alerts">
|
||||
<header><div><span>OPERATION WATCH</span><h2>运行预警</h2></div><small>{{ operationAlerts.length ? `${operationAlerts.length} 个运行断点需要关注` : '当前未发现运行断点' }}</small></header>
|
||||
<div v-if="operationAlerts.length" class="operation-alert-list">
|
||||
<button v-for="alert in operationAlerts" :key="alert.title" :class="alert.level" @click="router.push(alert.route)"><b>{{ alert.title }}</b><span>{{ alert.detail }}</span><i>立即处理 →</i></button>
|
||||
</div>
|
||||
<p v-else>教学任务、课表与成绩流程当前衔接正常。</p>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-work-grid">
|
||||
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
|
||||
<header class="panel-heading">
|
||||
@@ -709,6 +736,17 @@ onMounted(async () => {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.operation-alerts { padding: 21px 24px; border: 1px solid #e5e8ed; background: #fff; }
|
||||
.operation-alerts header { display: flex; justify-content: space-between; gap: 16px; align-items: end; }
|
||||
.operation-alerts header span { color: var(--dashboard-teal); font: 700 10px/1 Consolas,monospace; letter-spacing: .12em; }
|
||||
.operation-alerts h2 { margin: 7px 0 0; color: var(--dashboard-navy); font-size: 19px; }
|
||||
.operation-alerts header small { color: #697789; font-size: 11px; }
|
||||
.operation-alert-list { margin-top: 16px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
|
||||
.operation-alert-list button { padding: 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #e8e1d3; border-left: 3px solid var(--dashboard-amber); background: #fffcf6; }
|
||||
.operation-alert-list button.critical { border-left-color: #ba5145; background: #fff9f8; }
|
||||
.operation-alert-list b { color: #334254; font-size: 13px; }.operation-alert-list span { color: #6d7888; font-size: 11px; line-height: 1.5; }.operation-alert-list i { color: #8b6d3a; font-size: 11px; font-style: normal; }
|
||||
.operation-alerts > p { margin: 15px 0 0; color: #627184; font-size: 12px; }
|
||||
|
||||
.overview-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -1316,6 +1354,8 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.operation-alerts { padding: 18px; }
|
||||
.operation-alert-list { grid-template-columns: 1fr; }
|
||||
.greeting-insights { padding: 15px; }
|
||||
.greeting-insights > div { grid-template-columns: 1fr; }
|
||||
.counselor-risk-list button { grid-template-columns: 1fr auto; }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ArrowRight, Calendar, DocumentChecked, Reading } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import http from '../api/http'
|
||||
|
||||
interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
|
||||
interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } }
|
||||
const router = useRouter()
|
||||
const greeting = ref<Greeting | null>(null)
|
||||
const sheets = ref<Sheet[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const statusLabel: Record<string, string> = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' }
|
||||
const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0
|
||||
const pendingSheets = computed(() => sheets.value.filter(x => x.sheet?.status === 'Draft' || x.sheet?.status === 'Returned'))
|
||||
async function load() {
|
||||
loading.value = true; failed.value = false
|
||||
try {
|
||||
const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard')
|
||||
greeting.value = dashboard.data.greeting
|
||||
const result = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } })
|
||||
sheets.value = result.data.items
|
||||
} catch { failed.value = true } finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="teacher-cockpit">
|
||||
<el-result v-if="failed" icon="warning" title="教学驾驶舱加载失败" sub-title="请检查服务连接后重新加载。"><template #extra><el-button type="primary" @click="load">重新加载</el-button></template></el-result>
|
||||
<template v-else-if="greeting">
|
||||
<section class="cockpit-hero">
|
||||
<span>TEACHING COCKPIT</span><h2>{{ greeting.title }}</h2><p>{{ greeting.subtitle }}</p><small>{{ greeting.narrative }}</small>
|
||||
</section>
|
||||
<section class="cockpit-metrics"><article v-for="item in greeting.insights" :key="item.label" :class="item.tone"><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.hint }}</small></article></section>
|
||||
<section class="cockpit-grid">
|
||||
<article class="cockpit-panel">
|
||||
<header><div><span><el-icon><DocumentChecked /></el-icon> GRADE PROGRESS</span><h3>成绩登记进度</h3></div><button @click="router.push('/grades')">成绩管理 <el-icon><ArrowRight /></el-icon></button></header>
|
||||
<div v-if="pendingSheets.length" class="sheet-list"><button v-for="item in pendingSheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><div><b>{{ item.courseName }}</b><small>{{ item.classNames.join('、') || item.taskName }} · {{ statusLabel[item.sheet?.status ?? ''] ?? '待登记' }}</small></div><strong>{{ progress(item.sheet) }}%</strong></button></div>
|
||||
<div v-else class="cockpit-empty"><el-icon><DocumentChecked /></el-icon><b>当前没有待提交成绩</b><span>成绩登记册会在这里按优先级显示。</span></div>
|
||||
</article>
|
||||
<article class="cockpit-panel">
|
||||
<header><div><span><el-icon><Reading /></el-icon> TEACHING CLASSES</span><h3>本学期教学班</h3></div><button @click="router.push('/teaching-tasks')">教学任务 <el-icon><ArrowRight /></el-icon></button></header>
|
||||
<div v-if="sheets.length" class="class-list"><button v-for="item in sheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><b>{{ item.courseName }}</b><span>{{ item.classNames.join('、') || item.taskName }}</span><small>{{ item.sheet ? `${item.sheet.studentCount} 人 · 已完成 ${item.sheet.completedCount} 人` : '尚未建立成绩登记册' }}</small></button></div>
|
||||
<div v-else class="cockpit-empty"><el-icon><Calendar /></el-icon><b>本学期暂未分配教学班</b><span>教学任务发布后会自动汇总到这里。</span></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.teacher-cockpit{display:grid;gap:18px}.cockpit-hero{padding:31px 35px;color:#fff;background:linear-gradient(120deg,#17284f,#244276 62%,#08726d);}.cockpit-hero>span,.cockpit-panel header>div>span{color:#71d9ca;font:700 10px/1 Consolas,monospace;letter-spacing:.12em}.cockpit-hero h2{margin:13px 0 8px;font:700 clamp(27px,3vw,38px)/1.2 "STZhongsong","Songti SC",serif}.cockpit-hero p{margin:0;color:#d1daf0}.cockpit-hero small{display:block;margin-top:15px;color:#aebee0;font-size:12px}.cockpit-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cockpit-metrics article{padding:17px;border-left:3px solid #a9bcc6;background:#f7faf9;display:grid;gap:4px}.cockpit-metrics .positive{border-color:#098174}.cockpit-metrics .attention{border-color:#ce8b2c}.cockpit-metrics span,.cockpit-metrics small{color:#657488;font-size:11px}.cockpit-metrics strong{color:#17284f;font-size:23px}.cockpit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.cockpit-panel{padding:23px 26px;border:1px solid #e2e7ec;background:#fff}.cockpit-panel header{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #e9edf0}.cockpit-panel h3{margin:8px 0 16px;color:#263648;font-size:18px}.cockpit-panel header button{border:0;background:transparent;color:#526078;font-size:12px;white-space:nowrap}.cockpit-panel button{cursor:pointer}.sheet-list button,.class-list button{width:100%;padding:14px 0;display:flex;justify-content:space-between;gap:12px;text-align:left;border:0;border-bottom:1px solid #edf0f3;background:transparent}.sheet-list b,.class-list b{display:block;color:#2e3c4c;font-size:14px}.sheet-list small,.class-list span,.class-list small{display:block;margin-top:5px;color:#748093;font-size:11px}.sheet-list strong{align-self:center;color:#0b8175;font:700 18px Consolas,monospace}.cockpit-empty{min-height:170px;display:grid;place-content:center;justify-items:center;color:#8c96a5;gap:8px;text-align:center;font-size:12px}.cockpit-empty .el-icon{font-size:26px}.cockpit-empty b{color:#5e6c7e}@media(max-width:760px){.cockpit-hero{padding:25px 21px}.cockpit-metrics,.cockpit-grid{grid-template-columns:1fr}.cockpit-panel{padding:20px 18px}}
|
||||
</style>
|
||||
Reference in new issue
Block a user