Files
Academic-Affairs-System/web/src/views/GradeAnalyticsView.vue
T
biss cd073885b5 “教学班成绩分析”页面新增“定时刷新设置”,支持启停、刷新间隔、单次处理上限、查看上次/下次扫描时间。
配置保存在独立数据库表 CourseGradeStatisticsRefreshSettings,不是修改 appsettings.json。
后台每 10 秒读取配置,仅到期扫描;成绩录入、导入、审批时不再立即创建统计任务。
扫描发现过期数据后,仍通过持久任务、Outbox 和 RabbitMQ 执行;Redis统计缓存由处理任务统一刷新。
2026-08-09 20:51:57 +08:00

566 lines
27 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 { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
import { useAuthStore } from '../stores/auth'
interface TeachingClassItem {
gradeSheetId: string
teachingTaskId: string
taskNumber: string
taskName: string
courseCode: string
courseName: string
termName: string
academicTermId: string
teacherNames: string[]
classNames: string[]
studentCount: number
averageScore: number
passRate: number
excellentRate: number
calculatedAt: string
}
const terms = ref<any[]>([])
const auth = useAuthStore()
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const classes = ref<TeachingClassItem[]>([])
const selected = ref<TeachingClassItem>()
const report = ref<any>()
const termId = ref<string>()
const keyword = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = 12
const loading = ref(false)
const reportLoading = ref(false)
const exporting = ref(false)
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
const scheduleDialogVisible = ref(false)
const scheduleLoading = ref(false)
const scheduleSaving = ref(false)
const schedule = reactive({
isEnabled: true,
intervalMinutes: 5,
batchSize: 100,
lastRunAt: null as string | null,
nextRunAt: null as string | null,
})
const distributionElement = ref<HTMLElement>()
const peerAverageElement = ref<HTMLElement>()
const peerPassElement = ref<HTMLElement>()
const benchmarkElement = ref<HTMLElement>()
const historyElement = ref<HTMLElement>()
const charts: echarts.ECharts[] = []
const summary = computed(() => report.value?.summary)
const delta = computed(() => report.value?.universityDelta)
const historyMetricMeta = computed(() => ({
average: { label: '平均分', course: '同课程全校平均', instructor: '当前任课教师历年', suffix: ' 分' },
passRate: { label: '合格率', course: '同课程全校合格率', instructor: '当前任课教师历年', suffix: '%' },
excellentRate: { label: '优秀率', course: '同课程全校优秀率', instructor: '当前任课教师历年', suffix: '%' },
}[historyMetric.value]))
function number(value: unknown, digits = 1) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed.toFixed(digits) : '—'
}
function signed(value: unknown, suffix = '') {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return '—'
return `${parsed > 0 ? '+' : ''}${parsed.toFixed(1)}${suffix}`
}
function disposeCharts() {
charts.splice(0).forEach(chart => chart.dispose())
}
function createChart(element: HTMLElement | undefined) {
if (!element) return undefined
const chart = echarts.init(element)
charts.push(chart)
return chart
}
async function loadClasses(reset = false) {
if (reset) page.value = 1
loading.value = true
try {
const data = (await http.get('/grade-analytics/teaching-classes', {
params: {
academicTermId: termId.value,
keyword: keyword.value.trim() || undefined,
page: page.value,
pageSize,
},
})).data
classes.value = data.items
total.value = data.total
const retained = classes.value.find(item => item.gradeSheetId === selected.value?.gradeSheetId)
selected.value = retained ?? classes.value[0]
if (selected.value) await loadReport(selected.value)
else {
report.value = undefined
disposeCharts()
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadReport(item = selected.value) {
if (!item) return
selected.value = item
reportLoading.value = true
try {
report.value = (await http.get(`/grade-analytics/teaching-classes/${item.gradeSheetId}`)).data
await nextTick()
drawCharts()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
reportLoading.value = false
}
}
async function refreshStatistics() {
if (!selected.value) {
await loadClasses()
return
}
reportLoading.value = true
try {
await http.post(`/grade-analytics/teaching-classes/${selected.value.gradeSheetId}/refresh`)
ElMessage.success('已提交统计刷新任务。')
window.setTimeout(() => loadClasses(), 900)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
reportLoading.value = false
}
}
async function openScheduleSettings() {
scheduleDialogVisible.value = true
scheduleLoading.value = true
try {
const data = (await http.get('/grade-analytics/refresh-schedule')).data
schedule.isEnabled = data.isEnabled
schedule.intervalMinutes = Math.max(1, Math.round(data.intervalSeconds / 60))
schedule.batchSize = data.batchSize
schedule.lastRunAt = data.lastRunAt
schedule.nextRunAt = data.nextRunAt
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
scheduleLoading.value = false
}
}
async function saveScheduleSettings() {
scheduleSaving.value = true
try {
await http.put('/grade-analytics/refresh-schedule', {
isEnabled: schedule.isEnabled,
intervalSeconds: schedule.intervalMinutes * 60,
batchSize: schedule.batchSize,
})
ElMessage.success('成绩统计定时刷新设置已保存')
scheduleDialogVisible.value = false
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
scheduleSaving.value = false
}
}
async function exportWordReport() {
if (!selected.value) return
exporting.value = true
try {
await downloadApiFile(
`/grade-analytics/teaching-classes/${selected.value.gradeSheetId}/report.docx`,
`${selected.value.courseCode}-${selected.value.taskNumber}-成绩分析报告.docx`,
)
ElMessage.success('成绩分析报告已导出。')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
exporting.value = false
}
}
function baseGrid(left = 48, right = 24) {
return { left, right, top: 42, bottom: 44, containLabel: true }
}
function drawCharts() {
disposeCharts()
if (!summary.value) return
const distribution = createChart(distributionElement.value)
distribution?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: baseGrid(),
xAxis: { type: 'category', data: summary.value.scoreBands.map((x: any) => x.label), axisTick: { show: false } },
yAxis: { type: 'value', minInterval: 1, name: '人数', splitLine: { lineStyle: { color: '#edf1f6' } } },
series: [{
name: '人数', type: 'bar', barMaxWidth: 54,
data: summary.value.scoreBands.map((x: any, index: number) => ({
value: x.studentCount,
itemStyle: { color: ['#b9c6d8', '#95a9c5', '#6f8db5', '#496f9f', '#204f87'][index] },
})),
label: { show: true, position: 'top', formatter: (p: any) => `${p.value} 人` },
itemStyle: { borderRadius: [4, 4, 0, 0] },
}],
})
const peers = report.value.peerTeachingClasses ?? []
const peerNames = peers.map((x: any) => `${x.taskNumber} · ${x.teacherNames || '未标注教师'}`)
const peerAverage = createChart(peerAverageElement.value)
peerAverage?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: any) => `${number(v)} 分` },
grid: baseGrid(20, 36),
xAxis: { type: 'value', min: 0, max: 100, name: '平均分', splitLine: { lineStyle: { color: '#edf1f6' } } },
yAxis: { type: 'category', inverse: true, data: peerNames, axisTick: { show: false }, axisLabel: { width: 150, overflow: 'truncate' } },
series: [{
type: 'bar', barMaxWidth: 24,
data: peers.map((x: any) => ({ value: x.averageScore, itemStyle: { color: x.isSelected ? '#204f87' : '#aebdd1' } })),
label: { show: true, position: 'right', formatter: (p: any) => number(p.value) },
itemStyle: { borderRadius: [0, 4, 4, 0] },
}],
})
const peerPass = createChart(peerPassElement.value)
peerPass?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: any) => `${number(v)}%` },
grid: baseGrid(20, 42),
xAxis: { type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { lineStyle: { color: '#edf1f6' } } },
yAxis: { type: 'category', inverse: true, data: peerNames, axisTick: { show: false }, axisLabel: { width: 150, overflow: 'truncate' } },
series: [{
type: 'bar', barMaxWidth: 18,
data: peers.map((x: any) => ({ value: x.passRate, itemStyle: { color: x.isSelected ? '#b8732d' : '#dcc3a5' } })),
label: { show: true, position: 'right', formatter: (p: any) => `${number(p.value)}%` },
itemStyle: { borderRadius: [0, 4, 4, 0] },
}],
})
const benchmarks = report.value.scopeBenchmarks ?? []
const benchmark = createChart(benchmarkElement.value)
benchmark?.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = benchmarks[params[0]?.dataIndex ?? 0]
return `<b>${item.scope} · ${item.name}</b><br/>样本 ${item.studentCount} 人<br/>最高分 ${number(item.highestScore)}<br/>平均分 ${number(item.averageScore)}<br/>最低分 ${number(item.lowestScore)}<br/>合格率 ${number(item.passRate)}%`
},
},
legend: { top: 0, data: ['分数区间', '平均分', '合格率'] },
grid: { left: 30, right: 54, top: 58, bottom: 52, containLabel: true },
xAxis: { type: 'category', data: benchmarks.map((x: any) => `${x.scope}\n${x.name}`), axisTick: { show: false } },
yAxis: [
{ type: 'value', min: 0, max: 100, name: '分数', splitLine: { lineStyle: { color: '#edf1f6' } } },
{ type: 'value', min: 0, max: 100, name: '合格率', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } },
],
series: [
{ name: '区间起点', type: 'bar', stack: 'range', silent: true, itemStyle: { color: 'transparent' }, data: benchmarks.map((x: any) => x.lowestScore) },
{ name: '分数区间', type: 'bar', stack: 'range', barWidth: 20, itemStyle: { color: '#a7b8cf', borderRadius: 6 }, data: benchmarks.map((x: any) => x.highestScore - x.lowestScore) },
{ name: '平均分', type: 'line', symbolSize: 9, lineStyle: { color: '#204f87', width: 3 }, itemStyle: { color: '#204f87' }, data: benchmarks.map((x: any) => x.averageScore) },
{ name: '合格率', type: 'line', yAxisIndex: 1, symbol: 'diamond', symbolSize: 9, lineStyle: { color: '#b8732d', type: 'dashed', width: 2 }, itemStyle: { color: '#b8732d' }, data: benchmarks.map((x: any) => x.passRate) },
],
})
drawHistoryChart()
}
function drawHistoryChart() {
const old = charts.find(chart => chart.getDom() === historyElement.value)
if (old) {
old.dispose()
charts.splice(charts.indexOf(old), 1)
}
const history = report.value?.history ?? []
const chart = createChart(historyElement.value)
if (!chart) return
const field = historyMetric.value === 'average' ? 'AverageScore' : historyMetric.value === 'passRate' ? 'PassRate' : 'ExcellentRate'
const courseField = `course${field}`
const instructorField = field[0].toLowerCase() + field.slice(1)
const seriesType = history.length >= 4 ? 'line' : 'bar'
chart.setOption({
aria: { enabled: true, decal: { show: true } },
tooltip: { trigger: 'axis', valueFormatter: (v: any) => `${number(v)}${historyMetricMeta.value.suffix}` },
legend: { top: 0, data: [historyMetricMeta.value.course, historyMetricMeta.value.instructor] },
grid: { left: 46, right: 28, top: 58, bottom: 54, containLabel: true },
xAxis: { type: 'category', data: history.map((x: any) => x.termName), axisLabel: { rotate: history.length > 5 ? 24 : 0 } },
yAxis: { type: 'value', min: 0, max: 100, name: historyMetricMeta.value.label, axisLabel: { formatter: historyMetric.value === 'average' ? '{value}' : '{value}%' }, splitLine: { lineStyle: { color: '#edf1f6' } } },
series: [
{
name: historyMetricMeta.value.course, type: seriesType, smooth: seriesType === 'line', symbol: 'circle', symbolSize: 8,
barMaxWidth: 34, itemStyle: { color: '#9eafc5' }, lineStyle: { color: '#9eafc5', width: 2 },
data: history.map((x: any) => x[courseField]),
},
{
name: historyMetricMeta.value.instructor, type: seriesType, smooth: seriesType === 'line', symbol: 'diamond', symbolSize: 9,
barMaxWidth: 34, itemStyle: { color: '#204f87' }, lineStyle: { color: '#204f87', width: 3 },
data: history.map((x: any) => x.instructor?.[instructorField] ?? null),
},
],
})
}
function resizeCharts() {
charts.forEach(chart => chart.resize())
}
watch(historyMetric, async () => {
await nextTick()
drawHistoryChart()
})
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
await loadClasses(true)
window.addEventListener('resize', resizeCharts)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeCharts)
disposeCharts()
})
</script>
<template>
<div class="page-stack grade-analytics">
<section class="page-intro analytics-intro">
<div>
<span class="section-kicker">TEACHING CLASS ANALYTICS</span>
<h2>教学班成绩分析</h2>
<p>对比当前教学班同课程教学班学生来源范围与历年成绩统计仅使用已正式发布成绩</p>
</div>
<div class="intro-actions">
<el-button v-if="canManageSchedule" :icon="Setting" @click="openScheduleSettings">定时刷新设置</el-button>
<el-button
type="primary"
:icon="Download"
:loading="exporting"
:disabled="!selected || !summary"
@click="exportWordReport"
>导出 Word 报告</el-button>
<el-button :icon="Refresh" @click="refreshStatistics">重新计算当前教学班</el-button>
</div>
</section>
<el-dialog v-model="scheduleDialogVisible" title="成绩统计定时刷新" width="520px">
<el-form v-loading="scheduleLoading" label-width="130px">
<el-form-item label="启用定时刷新">
<el-switch v-model="schedule.isEnabled" />
</el-form-item>
<el-form-item label="刷新间隔">
<el-input-number v-model="schedule.intervalMinutes" :min="1" :max="1440" :disabled="!schedule.isEnabled" />
<span class="schedule-unit">分钟</span>
</el-form-item>
<el-form-item label="单次处理上限">
<el-input-number v-model="schedule.batchSize" :min="1" :max="5000" :step="50" :disabled="!schedule.isEnabled" />
<span class="schedule-unit">个课程学期</span>
</el-form-item>
<el-form-item label="运行状态">
<div class="schedule-status">
<span>上次扫描{{ schedule.lastRunAt ? new Date(schedule.lastRunAt).toLocaleString('zh-CN') : '尚未运行' }}</span>
<span v-if="schedule.isEnabled">下次扫描{{ schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleString('zh-CN') : '启用后将尽快运行' }}</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scheduleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="scheduleSaving" @click="saveScheduleSettings">保存设置</el-button>
</template>
</el-dialog>
<section class="filter-bar">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
</el-select>
<el-input v-model="keyword" clearable placeholder="课程、教学班名称或编号" :prefix-icon="Search" @keyup.enter="loadClasses(true)" />
<el-button type="primary" @click="loadClasses(true)">查询</el-button>
</section>
<section class="analytics-shell">
<aside class="class-rail" v-loading="loading">
<header><strong>可查看教学班</strong><small>{{ total }} 个已生成统计</small></header>
<button
v-for="item in classes"
:key="item.gradeSheetId"
type="button"
:class="['class-option', { active: selected?.gradeSheetId === item.gradeSheetId }]"
@click="loadReport(item)"
>
<span class="course-code">{{ item.courseCode }} · {{ item.taskNumber }}</span>
<strong>{{ item.courseName }}</strong>
<small>{{ item.teacherNames.join('、') || '未标注教师' }} · {{ item.studentCount }} </small>
<span class="option-metrics"><b>{{ number(item.averageScore) }}</b> 平均分 · <b>{{ number(item.passRate) }}%</b> 合格</span>
</button>
<el-empty v-if="!loading && !classes.length" :image-size="72" description="当前范围暂无已发布成绩统计" />
<el-pagination
v-if="total > pageSize"
small layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page"
@current-change="loadClasses()"
/>
</aside>
<main class="analysis-canvas" v-loading="reportLoading">
<el-empty v-if="!selected" description="请选择一个教学班查看分析" />
<el-alert v-else-if="report?.isRefreshing" type="info" :closable="false" title="该教学班统计正在生成,请稍后刷新。" />
<template v-else-if="summary">
<header class="report-heading">
<div>
<span>{{ report.courseCode }} · {{ report.taskNumber }} · {{ report.termName }}</span>
<h3>{{ report.courseName }}</h3>
<p>{{ report.taskName }} · 数据更新时间 {{ new Date(summary.calculatedAt).toLocaleString() }}</p>
</div>
<div class="sample-stamp"><b>{{ summary.studentCount }}</b><span>有效成绩</span></div>
</header>
<section class="kpi-strip">
<article><span>平均分</span><strong>{{ number(summary.averageScore) }}</strong><small>全体有效成绩</small></article>
<article><span>中位数</span><strong>{{ number(summary.medianScore) }}</strong><small>降低极端值影响</small></article>
<article><span>标准差</span><strong>{{ number(summary.standardDeviation, 2) }}</strong><small>越大表示分数越分散</small></article>
<article><span>最高 / 最低</span><strong>{{ number(summary.highestScore) }} / {{ number(summary.lowestScore) }}</strong><small>成绩跨度 {{ number(summary.highestScore - summary.lowestScore) }}</small></article>
<article><span>合格率</span><strong>{{ number(summary.passRate) }}%</strong><small>{{ summary.passedCount }} / {{ summary.studentCount }} </small></article>
<article><span>优秀率</span><strong>{{ number(summary.excellentRate) }}%</strong><small>{{ summary.excellentCount }} 人达到 90 </small></article>
</section>
<section v-if="delta" class="benchmark-note">
<span>相对本学期全校同课程</span>
<strong :class="{ negative: delta.averageScoreDifference < 0 }">平均分 {{ signed(delta.averageScoreDifference, ' 分') }}</strong>
<strong :class="{ negative: delta.passRateDifference < 0 }">合格率 {{ signed(delta.passRateDifference, '%') }}</strong>
<small>全校基准平均 {{ number(delta.universityAverageScore) }}合格率 {{ number(delta.universityPassRate) }}%</small>
</section>
<section class="chart-grid">
<article class="chart-card">
<header><div><span>DISTRIBUTION</span><h4>本教学班分数段分布</h4></div><p>{{ summary.studentCount }} 按五个固定区间统计</p></header>
<div ref="distributionElement" class="chart" />
</article>
<article class="chart-card">
<header><div><span>PEER AVERAGE</span><h4>同课程教学班平均分</h4></div><p>当前教学班使用深色标识</p></header>
<div ref="peerAverageElement" class="chart" />
</article>
<article class="chart-card">
<header><div><span>PEER PASS RATE</span><h4>同课程教学班合格率</h4></div><p>同一课程同一学期横向比较</p></header>
<div ref="peerPassElement" class="chart" />
</article>
<article class="chart-card chart-card-wide">
<header><div><span>SCOPE BENCHMARK</span><h4>行政班专业学院与全校基准</h4></div><p>对照本教学班学生来源范围显示分数区间平均分和合格率</p></header>
<div ref="benchmarkElement" class="chart chart-large" />
</article>
<article class="chart-card chart-card-wide">
<header class="history-heading">
<div><span>HISTORICAL COMPARISON</span><h4>当前任课教师与全校同课程历年对比</h4></div>
<el-radio-group v-model="historyMetric" size="small">
<el-radio-button value="average">平均分</el-radio-button>
<el-radio-button value="passRate">合格率</el-radio-button>
<el-radio-button value="excellentRate">优秀率</el-radio-button>
</el-radio-group>
</header>
<p class="chart-caption">教师序列按当前教学班任课教师在各学期所带同课程教学班加权汇总全校序列为该课程当期全部已发布成绩</p>
<div ref="historyElement" class="chart chart-large" />
</article>
</section>
</template>
</main>
</section>
</div>
</template>
<style scoped>
.grade-analytics { --ink: #18324f; --blue: #204f87; --line: #dce3ec; }
.analytics-intro { align-items: flex-end; }
.filter-bar { display: grid; grid-template-columns: 240px minmax(260px, 560px) auto; gap: 12px; align-items: center; padding: 16px 18px; background: #f7f9fc; border: 1px solid var(--line); }
.analytics-shell { display: grid; grid-template-columns: 292px minmax(0, 1fr); gap: 18px; align-items: start; }
.class-rail { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: auto; background: #f6f8fb; border: 1px solid var(--line); }
.class-rail > header { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; gap: 8px; padding: 15px 16px; background: #eef2f7; border-bottom: 1px solid var(--line); }
.class-rail > header small { color: #667085; }
.class-option { width: 100%; padding: 15px 16px; border: 0; border-bottom: 1px solid #e1e6ed; background: transparent; color: #475467; text-align: left; cursor: pointer; transition: background .18s ease, box-shadow .18s ease; }
.class-option:hover { background: #fff; }
.class-option.active { background: #fff; box-shadow: inset 4px 0 var(--blue); }
.class-option:focus-visible { outline: 2px solid var(--blue); outline-offset: -3px; }
.class-option > * { display: block; }
.class-option strong { margin: 5px 0; color: var(--ink); font-size: 14px; }
.class-option small { overflow: hidden; color: #667085; text-overflow: ellipsis; white-space: nowrap; }
.course-code { color: #7b8797; font: 11px Consolas, monospace; }
.option-metrics { margin-top: 9px; color: #667085; font-size: 11px; }
.option-metrics b { color: var(--blue); font: 700 13px Consolas, monospace; }
.class-rail :deep(.el-pagination) { justify-content: center; padding: 14px 4px; }
.analysis-canvas { min-width: 0; }
.report-heading { display: flex; justify-content: space-between; gap: 20px; padding: 22px 24px; color: #fff; background: var(--ink); }
.report-heading span { color: #b9c9da; font: 11px Consolas, monospace; }
.report-heading h3 { margin: 7px 0 5px; font-size: 24px; }
.report-heading p { margin: 0; color: #cbd5e1; font-size: 12px; }
.sample-stamp { display: flex; flex-direction: column; min-width: 94px; justify-content: center; padding-left: 22px; border-left: 1px solid rgb(255 255 255 / 20%); }
.sample-stamp b { font: 700 34px/1 Consolas, monospace; }
.sample-stamp span { margin-top: 5px; color: #b9c9da; font-size: 11px; }
.kpi-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); background: #fff; border: 1px solid var(--line); border-top: 0; }
.kpi-strip article { min-height: 112px; padding: 17px 20px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.kpi-strip article:nth-child(3n) { border-right: 0; }
.kpi-strip span, .kpi-strip small { display: block; color: #667085; }
.kpi-strip span { font-size: 11px; font-weight: 700; }
.kpi-strip strong { display: block; margin: 10px 0 7px; overflow-wrap: anywhere; color: var(--ink); font: 700 24px/1 Consolas, monospace; }
.kpi-strip small { font-size: 10px; }
.benchmark-note { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 20px; padding: 14px 18px; background: #edf3fa; border: 1px solid #ccd9e9; border-top: 0; }
.benchmark-note > span { color: var(--ink); font-weight: 700; }
.benchmark-note strong { color: var(--blue); font: 700 13px Consolas, monospace; }
.benchmark-note strong.negative { color: #9a5b28; }
.benchmark-note small { margin-left: auto; color: #667085; }
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 18px; }
.chart-card { min-width: 0; padding: 20px 22px; background: #fff; border: 1px solid var(--line); box-shadow: 0 10px 28px rgb(24 50 79 / 5%); }
.chart-card-wide { grid-column: 1 / -1; }
.chart-card header { display: flex; min-width: 0; align-items: end; justify-content: space-between; gap: 18px; }
.chart-card header span { color: #7b8797; font: 10px Consolas, monospace; letter-spacing: .08em; }
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
.chart-caption { margin-top: 9px; text-align: left; }
.schedule-unit { margin-left: 10px; color: #667085; font-size: 12px; }
.schedule-status { display: grid; gap: 3px; color: #667085; font-size: 12px; }
.chart { height: 310px; margin-top: 10px; }
.chart-large { height: 360px; }
@media (max-width: 1180px) {
.analytics-shell { grid-template-columns: 240px minmax(0, 1fr); }
.chart-grid { grid-template-columns: 1fr; }
.chart-card-wide { grid-column: auto; }
}
@media (max-width: 820px) {
.filter-bar { grid-template-columns: 1fr; }
.filter-bar :deep(.el-select) { width: 100% !important; }
.analytics-shell { grid-template-columns: 1fr; }
.class-rail { position: static; max-height: 390px; }
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
.kpi-strip article:nth-child(3n) { border-right: 1px solid var(--line); }
.kpi-strip article:nth-child(2n) { border-right: 0; }
.history-heading, .chart-card header { align-items: flex-start; flex-direction: column; }
.chart-card header p { text-align: left; }
}
@media (max-width: 520px) {
.report-heading { flex-direction: column; }
.sample-stamp { padding: 14px 0 0; border: 0; border-top: 1px solid rgb(255 255 255 / 20%); }
.kpi-strip { grid-template-columns: 1fr; }
.kpi-strip article { border-right: 0 !important; }
.chart-card { padding: 17px 14px; }
}
</style>