教师成绩报告

This commit is contained in:
2026-08-09 11:09:43 +08:00 Unverified
parent 924574256a
commit ca8232ad5e
12 changed files with 8048 additions and 17 deletions
+4
View File
@@ -177,6 +177,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']),
{ path: '/other-exams', label: '其他考试成绩' },
+8
View File
@@ -255,6 +255,14 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Counselor', 'Student'],
},
},
{
path: 'grade-analytics',
name: 'grade-analytics',
component: () => import('../views/GradeAnalyticsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
},
},
{
path: 'other-exams',
name: 'other-exams',
+462
View File
@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import http, { apiErrorMessage } from '../api/http'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
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 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 historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
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
}
}
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>
<el-button :icon="Refresh" @click="refreshStatistics">重新计算当前教学班</el-button>
</section>
<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; }
.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>