校级管理员查看全校数据,学院管理员仅查看本学院数据。 待办按当前审批阶段统计,涵盖授课资格、成绩、调停课、学籍异动、教室借用等。 增加学期进度、教学任务发布、课表覆盖、成绩发布状态。 快捷入口根据管理员角色自动调整。 完善未配置学期、无待办、加载失败等状态。 适配桌面和 390px 移动端,无横向溢出。
1276 lines
33 KiB
Vue
1276 lines
33 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'
|
||
|
||
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
|
||
}
|
||
generatedAt: string
|
||
}
|
||
|
||
interface DashboardLink {
|
||
key: string
|
||
label: string
|
||
description: string
|
||
route: string
|
||
icon: Component
|
||
}
|
||
|
||
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'].includes(role),
|
||
),
|
||
)
|
||
const loading = ref(true)
|
||
const loadError = ref('')
|
||
const data = ref<DashboardData | null>(null)
|
||
const now = ref(new Date())
|
||
|
||
function hasRole(...allowedRoles: string[]) {
|
||
return roles.value.some((role) => allowedRoles.includes(role))
|
||
}
|
||
|
||
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
|
||
} catch {
|
||
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (isStudentOverview.value) {
|
||
loading.value = false
|
||
return
|
||
}
|
||
await loadDashboard()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<StudentDashboardView v-if="isStudentOverview" />
|
||
|
||
<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.audience.title }}</h1>
|
||
<p class="overview-description">{{ data.audience.description }}</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="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 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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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) {
|
||
.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) {
|
||
.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>
|