Files
Academic-Affairs-System/web/src/views/DashboardView.vue
T
2026-08-10 10:27:42 +08:00

1433 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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: '/course-adjustments',
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: 'adjustments', label: '调停课', description: '提交教学调整', route: '/course-adjustments', icon: Operation },
]
})
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 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">ACADEMIC OPERATIONS</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="dashboard-work-grid">
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
<header class="panel-heading">
<div>
<span class="panel-index">RADAR / COUNSELOR</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">01 / ACTION</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">02 / SHORTCUTS</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">03 / TERM READINESS</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;
}
.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) {
.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>