Files
Academic-Affairs-System/web/src/views/DashboardView.vue
T

1467 lines
42 KiB
Vue

<script setup lang="ts">
import type { Component } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
Calendar,
Checked,
DataAnalysis,
DocumentChecked,
EditPen,
List,
Management,
OfficeBuilding,
Operation,
Reading,
School,
Tickets,
UserFilled,
Warning,
} from '@element-plus/icons-vue'
import http from '../api/http'
import { useAuthStore } from '../stores/auth'
import StudentDashboardView from './StudentDashboardView.vue'
import TeacherDashboardView from './TeacherDashboardView.vue'
interface DashboardData {
audience: {
level: 'System' | 'School' | 'College' | 'Leadership' | 'Counselor' | 'Teaching'
title: string
scopeName: string
description: string
}
currentTerm?: {
id: string
name: string
startDate: string
endDate: string
}
counts: {
students: number
teachers: number
courses: number
teachingTasks: number
publishedTeachingTasks: number
scheduledTeachingTasks: number
courseEnrollments: number
gradeSheets: number
publishedGradeSheets: number
submittedGradeSheets: number
openCourseSelectionRounds: number
}
pending: {
teacherApplications: number
gradeSheets: number
courseAdjustments: number
studentStatusChanges: number
gradeModifications: number
classroomReservations: number
generalApprovals: number
}
greeting: DashboardGreeting
generatedAt: string
}
interface DashboardGreeting {
role: string
title: string
subtitle: string
label: string
narrative: string
insights: Array<{
label: string
value: string
hint: string
tone: 'calm' | 'positive' | 'attention'
}>
}
interface DashboardLink {
key: string
label: string
description: string
route: string
icon: Component
}
interface WarningRecord {
id: string
studentName: string
studentNumber: string
className: string
status: number
detail: string
}
const router = useRouter()
const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? [])
const isStudentOverview = computed(() =>
roles.value.includes('Student') &&
!roles.value.some((role) =>
['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)
const counselorWarnings = ref<WarningRecord[]>([])
const now = ref(new Date())
function hasRole(...allowedRoles: string[]) {
return roles.value.some((role) => allowedRoles.includes(role))
}
const isCounselorDashboard = computed(() =>
hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'),
)
const activeCounselorWarnings = computed(() =>
counselorWarnings.value.filter((warning) => warning.status === 1),
)
function parseDateOnly(value?: string) {
if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
}
function formatDate(value?: string) {
const date = parseDateOnly(value)
if (!date) return '待设置'
return new Intl.DateTimeFormat('zh-CN', {
month: 'long',
day: 'numeric',
}).format(date)
}
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value)
}
function ratio(value: number, total: number) {
if (total <= 0) return 0
return Math.min(100, Math.round(value / total * 100))
}
const termProgress = computed(() => {
const start = parseDateOnly(data.value?.currentTerm?.startDate)
const end = parseDateOnly(data.value?.currentTerm?.endDate)
if (!start || !end) return 0
const total = Math.max(1, end.getTime() - start.getTime())
const elapsed = startOfDay(now.value).getTime() - start.getTime()
return Math.min(100, Math.max(0, Math.round(elapsed / total * 100)))
})
const termContext = computed(() => {
const start = parseDateOnly(data.value?.currentTerm?.startDate)
const end = parseDateOnly(data.value?.currentTerm?.endDate)
if (!start || !end) return '尚未设置学期日历'
const today = startOfDay(now.value)
const day = 86400000
if (today < start) {
return `距离开学 ${Math.ceil((start.getTime() - today.getTime()) / day)} 天`
}
if (today > end) return '本学期已结束'
const currentWeek = Math.floor((today.getTime() - start.getTime()) / day / 7) + 1
const totalWeeks = Math.ceil((end.getTime() - start.getTime() + day) / day / 7)
return `第 ${currentWeek} 教学周 / 共 ${totalWeeks} 周`
})
const pendingTotal = computed(() =>
Object.values(data.value?.pending ?? {}).reduce((sum, value) => sum + value, 0),
)
const todoItems = computed(() => {
const pending = data.value?.pending
if (!pending) return []
const items = [
{
key: 'teacher-applications',
label: '授课资格审核',
description: '教师申报的授课科目等待确认',
route: '/teaching-preferences',
count: pending.teacherApplications,
},
{
key: 'grade-sheets',
label: '成绩登记审核',
description: '教师已提交,等待审核或发布',
route: '/grades',
count: pending.gradeSheets,
},
{
key: 'course-adjustments',
label: '调停课审批',
description: '调课、停课、补课或代课申请',
route: '/approvals?tab=pending',
count: pending.courseAdjustments,
},
{
key: 'student-status',
label: '学籍异动审核',
description: data.value?.audience.level === 'College'
? '辅导员已审核,等待学院处理'
: '学院已审核,等待校级处理',
route: '/student-status-changes',
count: pending.studentStatusChanges,
},
{
key: 'grade-modifications',
label: '成绩修改审核',
description: data.value?.audience.level === 'College'
? '教师申请的成绩修改等待学院审核'
: '学院已审核,等待校级终审',
route: '/approvals',
count: pending.gradeModifications,
},
{
key: 'classroom-reservations',
label: '教室借用审核',
description: '本学院提交的教室借用申请',
route: '/classroom-reservations',
count: pending.classroomReservations,
},
{
key: 'general-approvals',
label: '其他教学审批',
description: '免修、缓考、课程替代或考勤申诉',
route: '/approvals',
count: pending.generalApprovals,
},
]
return items.filter((item) => item.count > 0)
})
const adminMetrics = computed(() => {
const counts = data.value?.counts
if (!counts) return []
return [
{
key: 'students',
label: '在籍学生',
value: formatNumber(counts.students),
hint: `${data.value?.audience.scopeName}当前有效学籍`,
route: '/students',
},
{
key: 'tasks',
label: '本学期教学班',
value: formatNumber(counts.teachingTasks),
hint: `${counts.publishedTeachingTasks} 个已发布`,
route: '/teaching-tasks',
},
{
key: 'schedules',
label: '已进入课表',
value: formatNumber(counts.scheduledTeachingTasks),
hint: `覆盖 ${ratio(counts.scheduledTeachingTasks, counts.publishedTeachingTasks)}% 已发布教学班`,
route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable',
},
{
key: 'enrollments',
label: '有效选课',
value: formatNumber(counts.courseEnrollments),
hint: counts.openCourseSelectionRounds > 0
? `${counts.openCourseSelectionRounds} 个选课批次开放中`
: '当前无开放选课批次',
route: '/course-selections',
},
]
})
const quickActions = computed<DashboardLink[]>(() => {
if (hasRole('SuperAdmin')) {
return [
{ key: 'organization', label: '组织与学期', description: '维护基础数据', route: '/base-data/organization', icon: School },
{ key: 'personnel', label: '人员档案', description: '教师与学生', route: '/teachers', icon: UserFilled },
{ key: 'tasks', label: '教学任务', description: '生成并发布教学班', route: '/teaching-tasks', icon: List },
{ key: 'schedules', label: '排课与课表', description: '排课、校验与发布', route: '/schedules', icon: Calendar },
{ key: 'approvals', label: '审批工作台', description: '集中处理业务申请', route: '/approvals', icon: DocumentChecked },
{ key: 'statistics', label: '统计报表', description: '观察教学质量', route: '/statistics', icon: DataAnalysis },
{ key: 'operations', label: '运维与审计', description: '任务、日志与系统状态', route: '/operations', icon: Operation },
]
}
if (hasRole('AcademicAdmin')) {
return [
{ key: 'terms', label: '学年学期', description: '维护教学日历', route: '/base-data/terms', icon: Calendar },
{ key: 'tasks', label: '教学任务', description: '统筹全校开课', route: '/teaching-tasks', icon: List },
{ key: 'schedules', label: '排课与课表', description: '排课、校验与发布', route: '/schedules', icon: Management },
{ key: 'selections', label: '选课管理', description: '批次与选课名单', route: '/course-selections', icon: Tickets },
{ key: 'grades', label: '成绩管理', description: '审核并发布成绩', route: '/grades', icon: Checked },
{ key: 'exams', label: '考试管理', description: '考试与考场安排', route: '/exams', icon: EditPen },
{ key: 'approvals', label: '审批工作台', description: '处理校级审核', route: '/approvals', icon: DocumentChecked },
{ key: 'statistics', label: '统计报表', description: '跨学院教学分析', route: '/statistics', icon: DataAnalysis },
]
}
if (hasRole('CollegeAdmin')) {
return [
{ key: 'personnel', label: '学院人员', description: '教师与学生档案', route: '/teachers', icon: UserFilled },
{ key: 'qualifications', label: '授课资格', description: '审核教师申报', route: '/teaching-preferences', icon: Checked },
{ key: 'curriculum', label: '培养方案', description: '维护专业课程结构', route: '/curriculum', icon: Reading },
{ key: 'tasks', label: '教学任务', description: '落实本学院开课', route: '/teaching-tasks', icon: List },
{ key: 'selections', label: '选课管理', description: '查看选课与名单', route: '/course-selections', icon: Tickets },
{ key: 'grades', label: '成绩管理', description: '审核课程成绩', route: '/grades', icon: EditPen },
{ key: 'rooms', label: '教室借用', description: '审核学院申请', route: '/classroom-reservations', icon: OfficeBuilding },
{ key: 'approvals', label: '审批工作台', description: '处理学院级审核', route: '/approvals', icon: DocumentChecked },
]
}
if (hasRole('Leader')) {
return [
{ key: 'statistics', label: '统计报表', description: '查看教学运行数据', route: '/statistics', icon: DataAnalysis },
{ key: 'timetable', label: '课表查询', description: '查看全校课表', route: '/class-timetable', icon: Calendar },
{ key: 'evaluations', label: '教学评价', description: '查看评价结果', route: '/evaluations', icon: Checked },
]
}
if (hasRole('Counselor')) {
return [
{ key: 'students', label: '学生档案', description: '查看所带学生', route: '/students', icon: UserFilled },
{ key: 'attendance', label: '教学点名', description: '查看班级考勤', route: '/teacher-attendance', icon: Checked },
{ key: 'approvals', label: '审批工作台', description: '处理学生申请', route: '/approvals', icon: DocumentChecked },
{ key: 'warnings', label: '学业预警', description: '跟进风险学生', route: '/warnings', icon: Warning },
]
}
return [
{ key: 'timetable', label: '我的课表', description: '查看近期授课', route: '/my-timetable', icon: Calendar },
{ key: 'roster', label: '选课名单', description: '查看课程学生', route: '/teacher-roster', icon: UserFilled },
{ key: 'grades', label: '成绩录入', description: '维护课程成绩', route: '/grades', icon: EditPen },
{ key: 'approvals', label: '审批工作台', description: '发起与跟踪教学申请', route: '/approvals', icon: DocumentChecked },
]
})
const readiness = computed(() => {
const counts = data.value?.counts
if (!counts) return []
return [
{
key: 'tasks',
label: '教学任务发布',
value: counts.publishedTeachingTasks,
total: counts.teachingTasks,
percent: ratio(counts.publishedTeachingTasks, counts.teachingTasks),
route: '/teaching-tasks',
note: counts.teachingTasks === 0
? '本学期尚未建立教学任务'
: `${counts.teachingTasks - counts.publishedTeachingTasks} 个任务尚未发布`,
},
{
key: 'schedules',
label: '课表覆盖',
value: counts.scheduledTeachingTasks,
total: counts.publishedTeachingTasks,
percent: ratio(counts.scheduledTeachingTasks, counts.publishedTeachingTasks),
route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable',
note: counts.publishedTeachingTasks === 0
? '发布教学任务后可安排课表'
: `${Math.max(0, counts.publishedTeachingTasks - counts.scheduledTeachingTasks)} 个已发布教学班尚未进入课表`,
},
{
key: 'grades',
label: '成绩发布',
value: counts.publishedGradeSheets,
total: counts.gradeSheets,
percent: ratio(counts.publishedGradeSheets, counts.gradeSheets),
route: '/grades',
note: counts.submittedGradeSheets > 0
? `${counts.submittedGradeSheets} 张成绩登记册等待审核`
: counts.gradeSheets === 0
? '本学期尚未生成成绩登记册'
: '当前没有待审核成绩',
},
]
})
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',
)
async function loadDashboard() {
loading.value = true
loadError.value = ''
try {
data.value = (await http.get<DashboardData>('/dashboard')).data
if (isCounselorDashboard.value) {
counselorWarnings.value = (await http.get<WarningRecord[]>('/warnings/records', {
params: { academicTermId: data.value.currentTerm?.id },
})).data
}
} catch {
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
} finally {
loading.value = false
}
}
onMounted(async () => {
if (isStudentOverview.value || isTeacherOverview.value) {
loading.value = false
return
}
await loadDashboard()
})
</script>
<template>
<StudentDashboardView v-if="isStudentOverview" />
<TeacherDashboardView v-else-if="isTeacherOverview" />
<div v-else v-loading="loading" class="admin-dashboard">
<el-result
v-if="loadError"
icon="warning"
title="教务总览加载失败"
:sub-title="loadError"
>
<template #extra>
<el-button type="primary" @click="loadDashboard">重新加载</el-button>
</template>
</el-result>
<template v-else-if="data">
<section class="overview-hero">
<div class="overview-heading">
<div class="scope-line">
<span>{{ data.audience.scopeName }}</span>
<i>数据范围</i>
</div>
<p class="overview-eyebrow">教务工作概览</p>
<h1>{{ data.greeting.title }}</h1>
<p class="overview-description">{{ data.greeting.subtitle }}</p>
</div>
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
<span>当前待处理</span>
<strong>{{ pendingTotal }}</strong>
<small>{{ pendingTotal > 0 ? '项工作需要跟进' : '暂无积压事项' }}</small>
<b>{{ pendingTotal > 0 ? '立即处理' : '进入工作区' }} →</b>
</button>
<div v-if="data.currentTerm" class="term-ruler">
<div class="term-ruler-head">
<div>
<span>当前学期</span>
<strong>{{ data.currentTerm.name }}</strong>
</div>
<b>{{ termContext }}</b>
</div>
<div class="term-track" :style="{ '--term-progress': `${termProgress}%` }">
<span class="term-track-fill" />
<i class="term-track-marker" />
</div>
<div class="term-dates">
<span>{{ formatDate(data.currentTerm.startDate) }} 开学</span>
<span>学期进度 {{ termProgress }}%</span>
<span>{{ formatDate(data.currentTerm.endDate) }} 结束</span>
</div>
</div>
<div v-else class="term-ruler term-ruler-empty">
<div>
<span>当前学期</span>
<strong>尚未设置</strong>
</div>
<button
v-if="hasRole('SuperAdmin', 'AcademicAdmin')"
type="button"
@click="router.push('/base-data/terms')"
>
去维护学期 →
</button>
<p v-else>请联系校级教务管理员维护当前学期。</p>
</div>
</section>
<section class="greeting-insights" :aria-label="data.greeting.label">
<span class="greeting-insights-label">{{ data.greeting.label }}</span>
<div>
<article
v-for="insight in data.greeting.insights"
:key="insight.label"
:class="`greeting-insight ${insight.tone}`"
>
<span>{{ insight.label }}</span>
<strong>{{ insight.value }}</strong>
<small>{{ insight.hint }}</small>
</article>
</div>
</section>
<p class="greeting-narrative">{{ data.greeting.narrative }}</p>
<section class="overview-metrics" aria-label="关键教学数据">
<button
v-for="metric in adminMetrics"
:key="metric.key"
type="button"
@click="router.push(metric.route)"
>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
<i>查看详情 →</i>
</button>
</section>
<section class="operation-alerts">
<header><div><span>运行提醒</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">
<div>
<span class="panel-index">学业预警</span>
<h2>需重点关注的学生</h2>
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
</div>
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 →</button>
</header>
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
<span>{{ warning.className }}</span>
<b>{{ warning.studentName }}</b>
<small>{{ warning.detail }}</small>
<i>查看 →</i>
</button>
</div>
<div v-else class="todo-empty">
<el-icon><Checked /></el-icon>
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里。</span></div>
</div>
</article>
<article class="dashboard-panel todo-panel">
<header class="panel-heading">
<div>
<span class="panel-index">待办事项</span>
<h2>需要处理</h2>
<p>只显示当前角色和数据范围内可处理的事项。</p>
</div>
<span v-if="pendingTotal > 0" class="pending-badge">{{ pendingTotal }} 项</span>
</header>
<div v-if="todoItems.length" class="todo-list">
<button
v-for="(item, index) in todoItems"
:key="item.key"
type="button"
@click="router.push(item.route)"
>
<span class="todo-order">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="todo-copy">
<b>{{ item.label }}</b>
<small>{{ item.description }}</small>
</span>
<strong>{{ item.count }}</strong>
<i>→</i>
</button>
</div>
<div v-else class="todo-empty">
<el-icon><Checked /></el-icon>
<div>
<b>当前没有待处理事项</b>
<span>新的审核任务到达后会在这里集中显示。</span>
</div>
</div>
</article>
<article class="dashboard-panel quick-panel">
<header class="panel-heading">
<div>
<span class="panel-index">常用入口</span>
<h2>常用工作</h2>
<p>按当前角色整理的高频业务入口。</p>
</div>
</header>
<div class="quick-grid">
<button
v-for="action in quickActions"
:key="action.key"
type="button"
@click="router.push(action.route)"
>
<el-icon><component :is="action.icon" /></el-icon>
<span>
<b>{{ action.label }}</b>
<small>{{ action.description }}</small>
</span>
<i>↗</i>
</button>
</div>
</article>
</section>
<section class="dashboard-panel readiness-panel">
<header class="panel-heading">
<div>
<span class="panel-index">学期进度</span>
<h2>本学期教学运行</h2>
<p>关注教学任务、课表与成绩流程的完成情况。</p>
</div>
<span class="refresh-time">数据更新于 {{ new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</span>
</header>
<div class="readiness-list">
<button
v-for="item in readiness"
:key="item.key"
type="button"
@click="router.push(item.route)"
>
<span class="readiness-label">{{ item.label }}</span>
<span class="readiness-bar">
<i :style="{ width: `${item.percent}%` }" />
</span>
<strong>{{ item.percent }}%</strong>
<small>{{ item.value }} / {{ item.total }}</small>
<em>{{ item.note }}</em>
<b>查看 →</b>
</button>
</div>
<footer class="scope-summary">
<span><b>{{ formatNumber(data.counts.teachers) }}</b> 名在岗教师</span>
<span><b>{{ formatNumber(data.counts.courses) }}</b> 门启用课程</span>
<span><b>{{ formatNumber(data.counts.courseEnrollments) }}</b> 条有效选课</span>
<span>
<b>{{ data.counts.openCourseSelectionRounds }}</b>
个选课批次开放中
</span>
</footer>
</section>
</template>
</div>
</template>
<style scoped>
.admin-dashboard {
--dashboard-navy: #17284f;
--dashboard-blue: #274683;
--dashboard-teal: #0b8175;
--dashboard-cyan: #58c5b7;
--dashboard-amber: #d09439;
--dashboard-paper: #fff;
display: grid;
gap: 18px;
min-height: 420px;
}
.overview-hero {
position: relative;
min-height: 300px;
padding: 34px 36px 28px;
display: grid;
grid-template-columns: minmax(0, 1fr) 210px;
grid-template-rows: 1fr auto;
gap: 24px 34px;
overflow: hidden;
color: #fff;
background:
linear-gradient(90deg, rgba(255, 255, 255, .035) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, .035) 1px, transparent 1px),
linear-gradient(120deg, #17284f 0%, #213a73 64%, #0b706c 125%);
background-size: 32px 32px, 32px 32px, auto;
}
.greeting-insights {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
padding: 17px 21px;
border: 1px solid #dce6eb;
background: #f7faf9;
}
.greeting-insights-label {
align-self: center;
color: var(--dashboard-teal);
font: 700 10px/1.5 Consolas, monospace;
letter-spacing: .12em;
}
.greeting-insights > div {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.greeting-insight {
min-width: 0;
padding-left: 13px;
display: grid;
gap: 3px;
border-left: 2px solid #a9bcc6;
}
.greeting-insight.positive { border-color: var(--dashboard-teal); }
.greeting-insight.attention { border-color: var(--dashboard-amber); }
.greeting-insight span { color: #687788; font-size: 11px; }
.greeting-insight strong { color: var(--dashboard-navy); font-size: 20px; }
.greeting-insight small { overflow: hidden; color: #84909d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.greeting-narrative {
margin: -7px 0 0;
padding: 0 3px;
color: #526078;
font-size: 12px;
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;
right: 180px;
top: -215px;
width: 410px;
height: 410px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 50%;
box-shadow:
0 0 0 58px rgba(255, 255, 255, .025),
0 0 0 118px rgba(255, 255, 255, .018);
pointer-events: none;
}
.overview-heading,
.pending-brief,
.term-ruler {
position: relative;
z-index: 1;
}
.scope-line {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 6px 9px 6px 10px;
border-left: 2px solid var(--dashboard-cyan);
background: rgba(255, 255, 255, .07);
}
.scope-line span {
font-size: 12px;
font-weight: 700;
}
.scope-line i {
color: #aab8d8;
font-size: 9px;
font-style: normal;
letter-spacing: .08em;
}
.overview-eyebrow,
.panel-index {
margin: 22px 0 0;
color: #73d0c4;
font: 700 9px/1.2 Consolas, monospace;
letter-spacing: .18em;
}
.overview-heading h1 {
margin: 8px 0 7px;
font-family: "STZhongsong", "Songti SC", serif;
font-size: clamp(28px, 3.2vw, 42px);
font-weight: 700;
letter-spacing: .06em;
}
.overview-description {
margin: 0;
color: #c2cce5;
font-size: 13px;
}
.pending-brief {
align-self: stretch;
padding: 21px 20px 18px;
display: grid;
align-content: center;
text-align: left;
border: 1px solid rgba(255, 255, 255, .25);
color: #fff;
background: rgba(255, 255, 255, .08);
transition: background .18s ease, transform .18s ease;
}
.pending-brief:hover {
background: rgba(255, 255, 255, .14);
transform: translateY(-2px);
}
.pending-brief span {
color: #c6d0e8;
font-size: 11px;
}
.pending-brief strong {
margin-top: 4px;
font: 700 48px/1 Consolas, monospace;
}
.pending-brief small {
margin-top: 7px;
color: #c6d0e8;
font-size: 11px;
}
.pending-brief b {
margin-top: 22px;
color: #75d8ca;
font-size: 11px;
}
.term-ruler {
grid-column: 1 / -1;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, .16);
}
.term-ruler-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.term-ruler-head div {
display: flex;
align-items: baseline;
gap: 10px;
}
.term-ruler-head span,
.term-ruler-head b {
color: #aebbd9;
font-size: 10px;
font-weight: 500;
}
.term-ruler-head strong {
font-size: 14px;
}
.term-track {
position: relative;
height: 4px;
margin-top: 13px;
background: rgba(255, 255, 255, .17);
}
.term-track::before,
.term-track::after {
content: "";
position: absolute;
top: -3px;
width: 1px;
height: 10px;
background: rgba(255, 255, 255, .55);
}
.term-track::before { left: 0; }
.term-track::after { right: 0; }
.term-track-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--term-progress);
background: linear-gradient(90deg, #47bbaa, #77dbcc);
}
.term-track-marker {
position: absolute;
left: var(--term-progress);
top: 50%;
width: 10px;
height: 10px;
border: 2px solid #fff;
border-radius: 50%;
background: var(--dashboard-amber);
box-shadow: 0 0 0 4px rgba(208, 148, 57, .2);
transform: translate(-50%, -50%);
}
.term-dates {
margin-top: 8px;
display: flex;
justify-content: space-between;
color: #98a8cb;
font-size: 9px;
}
.term-ruler-empty {
display: flex;
align-items: center;
gap: 16px;
}
.term-ruler-empty span,
.term-ruler-empty strong {
display: block;
}
.term-ruler-empty strong { margin-top: 4px; }
.term-ruler-empty button {
margin-left: auto;
padding: 9px 12px;
border: 1px solid rgba(255, 255, 255, .25);
color: #fff;
background: rgba(255, 255, 255, .08);
}
.term-ruler-empty p {
margin: 0 0 0 auto;
color: #b8c4df;
font-size: 12px;
}
.overview-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #dce2ea;
background: var(--dashboard-paper);
}
.overview-metrics button {
position: relative;
min-width: 0;
min-height: 122px;
padding: 22px 23px 19px;
display: grid;
text-align: left;
border: none;
border-right: 1px solid #e4e8ee;
color: #182033;
background: transparent;
transition: background .16s ease;
}
.overview-metrics button:last-child { border-right: none; }
.overview-metrics button:hover { background: #f7fafb; }
.overview-metrics span {
color: #6d7689;
font-size: 11px;
}
.overview-metrics strong {
margin-top: 6px;
font: 700 30px/1 Consolas, monospace;
}
.overview-metrics small {
margin-top: 8px;
overflow: hidden;
color: #727c8e;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-metrics i {
position: absolute;
top: 22px;
right: 20px;
color: var(--dashboard-teal);
font-size: 9px;
font-style: normal;
opacity: 0;
transform: translateX(-4px);
transition: opacity .16s ease, transform .16s ease;
}
.overview-metrics button:hover i {
opacity: 1;
transform: none;
}
.dashboard-work-grid {
display: grid;
grid-template-columns: minmax(0, 1.16fr) minmax(400px, .84fr);
gap: 18px;
}
.dashboard-panel {
border: 1px solid #dce2ea;
background: var(--dashboard-paper);
}
.counselor-radar { border-top: 3px solid var(--dashboard-amber); }
.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; }
.radar-link:hover { color: var(--dashboard-blue); }
.counselor-risk-list { display: grid; }
.counselor-risk-list button {
padding: 13px 0;
display: grid;
grid-template-columns: 76px 68px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
text-align: left;
border: 0;
border-bottom: 1px solid #eee8dc;
background: transparent;
}
.counselor-risk-list button:last-child { border-bottom: 0; }
.counselor-risk-list button:hover b { color: var(--dashboard-blue); }
.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; }
.counselor-risk-list b { color: #3a4758; font-size: 13px; }
.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; }
.todo-panel,
.quick-panel,
.readiness-panel {
padding: 26px 28px;
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.panel-index {
display: block;
margin: 0;
color: var(--dashboard-teal);
}
.panel-heading h2 {
margin: 7px 0 5px;
font-family: "STZhongsong", "Songti SC", serif;
font-size: 20px;
letter-spacing: .04em;
}
.panel-heading p {
margin: 0;
color: #7a8394;
font-size: 11px;
}
.pending-badge {
padding: 5px 9px;
border-radius: 20px;
color: #9b5c06;
background: #fff2dc;
font-size: 10px;
font-weight: 700;
}
.todo-list {
margin-top: 20px;
border-top: 1px solid #e7eaf0;
}
.todo-list button {
width: 100%;
min-height: 68px;
padding: 12px 4px;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto 18px;
gap: 12px;
align-items: center;
text-align: left;
border: none;
border-bottom: 1px solid #e7eaf0;
color: #1d2638;
background: transparent;
}
.todo-list button:hover .todo-copy b { color: var(--dashboard-blue); }
.todo-list button:hover > i { transform: translateX(3px); }
.todo-order {
color: #a2a9b5;
font: 500 10px/1 Consolas, monospace;
}
.todo-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.todo-copy b { font-size: 13px; }
.todo-copy small {
overflow: hidden;
color: #7a8394;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-list button > strong {
min-width: 28px;
text-align: right;
color: #a46408;
font: 700 20px/1 Consolas, monospace;
}
.todo-list button > i {
color: var(--dashboard-teal);
font-style: normal;
transition: transform .16s ease;
}
.todo-empty {
min-height: 228px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
color: #617083;
}
.todo-empty .el-icon {
width: 42px;
height: 42px;
border-radius: 50%;
color: var(--dashboard-teal);
background: #eaf6f3;
font-size: 20px;
}
.todo-empty b,
.todo-empty span {
display: block;
}
.todo-empty b { font-size: 13px; }
.todo-empty span {
margin-top: 5px;
color: #8991a0;
font-size: 10px;
}
.quick-grid {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-top: 1px solid #e7eaf0;
border-left: 1px solid #e7eaf0;
}
.quick-grid button {
min-height: 78px;
padding: 13px;
display: grid;
grid-template-columns: 31px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
text-align: left;
border: none;
border-right: 1px solid #e7eaf0;
border-bottom: 1px solid #e7eaf0;
color: #1d2638;
background: #fbfcfd;
}
.quick-grid button:hover { background: #f2f8f7; }
.quick-grid .el-icon {
width: 31px;
height: 31px;
color: var(--dashboard-blue);
background: #edf1f8;
font-size: 15px;
}
.quick-grid span {
min-width: 0;
display: grid;
gap: 4px;
}
.quick-grid b { font-size: 12px; }
.quick-grid small {
overflow: hidden;
color: #7c8594;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick-grid i {
align-self: start;
color: #a8aeba;
font-size: 10px;
font-style: normal;
}
.readiness-panel {
padding-bottom: 0;
}
.refresh-time {
color: #939aa7;
font-size: 9px;
}
.readiness-list {
margin-top: 23px;
border-top: 1px solid #e7eaf0;
}
.readiness-list button {
width: 100%;
min-height: 68px;
padding: 14px 0;
display: grid;
grid-template-columns: 112px minmax(140px, 1fr) 46px 58px minmax(180px, .8fr) 54px;
gap: 14px;
align-items: center;
text-align: left;
border: none;
border-bottom: 1px solid #e7eaf0;
color: #20293a;
background: transparent;
}
.readiness-list button:hover {
background: linear-gradient(90deg, transparent, #f7fafb 9%, #f7fafb 91%, transparent);
}
.readiness-label { font-size: 12px; font-weight: 700; }
.readiness-bar {
height: 6px;
overflow: hidden;
background: #e8edf1;
}
.readiness-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--dashboard-blue), var(--dashboard-teal));
}
.readiness-list strong {
text-align: right;
font: 700 14px/1 Consolas, monospace;
}
.readiness-list small {
color: #7d8593;
font: 500 10px/1 Consolas, monospace;
}
.readiness-list em {
overflow: hidden;
color: #6f7888;
font-size: 10px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.readiness-list button > b {
text-align: right;
color: var(--dashboard-teal);
font-size: 10px;
}
.scope-summary {
margin: 0 -28px;
min-height: 59px;
padding: 14px 28px;
display: flex;
align-items: center;
gap: 0;
color: #778090;
background: #f7f9fb;
font-size: 10px;
}
.scope-summary span {
padding: 0 20px;
border-right: 1px solid #dfe4ea;
}
.scope-summary span:first-child { padding-left: 0; }
.scope-summary span:last-child { border-right: none; }
.scope-summary b {
margin-right: 4px;
color: #28344b;
font: 700 13px/1 Consolas, monospace;
}
button:focus-visible {
outline: 2px solid #2d8f85;
outline-offset: 2px;
}
@media (max-width: 1100px) {
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
.dashboard-work-grid {
grid-template-columns: 1fr;
}
.quick-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.readiness-list button {
grid-template-columns: 105px minmax(120px, 1fr) 44px 54px minmax(130px, .7fr) 48px;
gap: 10px;
}
}
@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; }
.counselor-risk-list span { grid-column: 1 / -1; }
.counselor-risk-list small { white-space: normal; }
.admin-dashboard { gap: 10px; }
.overview-hero {
min-height: 0;
padding: 24px 20px 21px;
grid-template-columns: 1fr;
grid-template-rows: auto;
gap: 20px;
}
.overview-hero::after {
right: -210px;
}
.overview-heading h1 {
font-size: 28px;
}
.pending-brief {
min-height: 108px;
grid-template-columns: 1fr auto;
align-items: center;
}
.pending-brief strong {
grid-row: 1 / 4;
grid-column: 2;
font-size: 42px;
}
.pending-brief b { margin-top: 10px; }
.term-ruler { grid-column: 1; }
.term-ruler-head {
align-items: flex-start;
}
.term-ruler-head div {
display: grid;
gap: 4px;
}
.term-dates span:nth-child(2) { display: none; }
.overview-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-metrics button {
min-height: 105px;
padding: 18px;
}
.overview-metrics button:nth-child(2) { border-right: none; }
.overview-metrics button:nth-child(-n + 2) { border-bottom: 1px solid #e4e8ee; }
.todo-panel,
.quick-panel,
.readiness-panel {
padding: 21px 18px;
}
.quick-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.readiness-list button {
grid-template-columns: 1fr auto auto;
gap: 8px 12px;
}
.readiness-label { grid-column: 1; }
.readiness-list strong { grid-column: 2; }
.readiness-list small { grid-column: 3; }
.readiness-bar { grid-column: 1 / -1; grid-row: 2; }
.readiness-list em { grid-column: 1 / 3; grid-row: 3; }
.readiness-list button > b { grid-column: 3; grid-row: 3; }
.scope-summary {
margin: 0 -18px;
padding: 13px 18px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 9px;
}
.scope-summary span {
padding: 0;
border-right: none;
}
.refresh-time { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.pending-brief,
.todo-list button > i,
.overview-metrics i {
transition: none;
}
}
</style>