diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index c1d2dbf..8819b58 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -55,6 +55,8 @@ | `/classroom-reservations` | 教室借用与预约 | 已登录用户;CollegeAdmin 审核 | 空闲教室检索、课程/考试/已批准预约避让、申请撤销、学院范围审核与冲突复核 | verification | | `/venue-displays` | 场地信息展牌管理 | SuperAdmin、AcademicAdmin | 教室/楼宇展牌链接、批量 Excel、范围/优先级/有效期公告管理 | verification | | `/classroom-repairs` | 教室报修与维修 | SuperAdmin、AcademicAdmin、MaintenanceAdmin、MaintenanceStaff | 工单筛选、分派/开工/完工、详情、设备档案与扫码报修二维码 | verification | +| `/grades/:sheetId/statistics` | 课程成绩统计 | 具备该成绩单数据范围的已登录用户 | 班级/专业/学院/全校分数分布、对比、课程讨论与互动 | verification | +| `/event-analytics` | 运行数据分析 | SuperAdmin、AcademicAdmin、CollegeAdmin、Leader | ClickHouse 考勤、成绩与全校范围访问审计只读聚合 | verification | | `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | | `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | | `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | diff --git a/web-react/README.md b/web-react/README.md index 57354ed..842dc8f 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -54,6 +54,7 @@ npm.cmd run build - 迁移教室借用与预约,覆盖空闲教室检索、预约申请/撤销和学院审核闭环。 - 迁移场地信息展牌管理,支持展牌链接、批量导出与定向公告投放。 - 迁移教室报修与维修后台,覆盖工单分派、维修状态闭环、设备档案与场地报修码。 +- 迁移课程成绩统计与运行数据分析,保留范围对比、课程讨论互动和 ClickHouse 只读聚合。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 238d274..f223c56 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -53,6 +53,8 @@ const CourseAdjustmentsPage = lazy(() => import('../pages/CourseAdjustmentsPage' const ClassroomReservationsPage = lazy(() => import('../pages/ClassroomReservationsPage')) const VenueDisplaysPage = lazy(() => import('../pages/VenueDisplaysPage')) const ClassroomRepairsPage = lazy(() => import('../pages/ClassroomRepairsPage')) +const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatisticsPage')) +const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -154,6 +156,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/web-react/src/components/CourseDiscussionPanel.tsx b/web-react/src/components/CourseDiscussionPanel.tsx new file mode 100644 index 0000000..fea4889 --- /dev/null +++ b/web-react/src/components/CourseDiscussionPanel.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' +import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons' +import { App, Button, Checkbox, Empty, Input, Popconfirm, Spin, Tag } from 'antd' +import http, { apiErrorMessage } from '../api/http' +import './course-discussion.css' + +type Reaction = 'Like' | 'Insightful' | 'Agree' | 'Hooray' | 'Heart' | 'Rocket' | 'Eyes' | 'Laugh' +interface Reply { id: string; content: string; isAnonymous: boolean; authorName: string; createdAt: string; canDelete: boolean } +interface Comment extends Reply { replyCount: number; replies?: Reply[]; [key: string]: unknown } +const reactions: { type: Reaction; emoji: string; label: string; count: string; active: string }[] = [{ type: 'Like', emoji: '👍', label: '赞', count: 'likeCount', active: 'hasLiked' }, { type: 'Insightful', emoji: '💡', label: '有启发', count: 'insightfulCount', active: 'hasInsightful' }, { type: 'Agree', emoji: '🤝', label: '认同', count: 'agreeCount', active: 'hasAgreed' }, { type: 'Hooray', emoji: '🎉', label: '庆祝', count: 'hoorayCount', active: 'hasHoorayed' }, { type: 'Heart', emoji: '❤️', label: '喜欢', count: 'heartCount', active: 'hasHearted' }, { type: 'Rocket', emoji: '🚀', label: '很棒', count: 'rocketCount', active: 'hasRocketed' }, { type: 'Eyes', emoji: '👀', label: '关注', count: 'eyesCount', active: 'hasEyed' }, { type: 'Laugh', emoji: '😄', label: '好笑', count: 'laughCount', active: 'hasLaughed' }] + +export default function CourseDiscussionPanel({ sheetId, courseName }: { sheetId: string; courseName?: string }) { + const { message } = App.useApp(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [content, setContent] = useState(''); const [anonymous, setAnonymous] = useState(false); const [cursor, setCursor] = useState<{ createdAt: string; id: string }>(); const [hasMore, setHasMore] = useState(false); const [openReply, setOpenReply] = useState(); const [drafts, setDrafts] = useState>({}); const [replyAnonymous, setReplyAnonymous] = useState>({}) + const base = `/grades/sheets/${sheetId}/discussion` + async function load(reset = false) { try { setLoading(true); const data = (await http.get<{ items: Comment[]; hasMore: boolean; nextCursor?: { createdAt: string; id: string } }>(`${base}/comments`, { params: reset || !cursor ? undefined : { beforeCreatedAt: cursor.createdAt, beforeId: cursor.id } })).data; setItems((current) => reset ? data.items : [...current, ...data.items]); setHasMore(data.hasMore); setCursor(data.nextCursor) } catch (error) { message.error(apiErrorMessage(error)) } finally { setLoading(false) } } + useEffect(() => { void load(true) }, [sheetId]) // eslint-disable-line react-hooks/exhaustive-deps + async function submit() { const value = content.trim(); if (!value) return message.warning('请先输入评论内容。'); try { setSubmitting(true); await http.post(`${base}/comments`, { content: value, isAnonymous: anonymous }); setContent(''); await load(true); message.success(anonymous ? '匿名评论已发布。' : '评论已发布。') } catch (error) { message.error(apiErrorMessage(error)) } finally { setSubmitting(false) } } + async function react(comment: Comment, type: Reaction) { try { await http.post(`${base}/comments/${comment.id}/reactions/${type}`); await load(true) } catch (error) { message.error(apiErrorMessage(error)) } } + async function replies(comment: Comment) { const opening = openReply !== comment.id; setOpenReply(opening ? comment.id : undefined); if (!opening || comment.replies) return; try { const data = (await http.get<{ items: Reply[] }>(`${base}/comments/${comment.id}/replies`)).data; setItems((rows) => rows.map((row) => row.id === comment.id ? { ...row, replies: data.items } : row)) } catch (error) { message.error(apiErrorMessage(error)) } } + async function reply(comment: Comment) { const value = drafts[comment.id]?.trim(); if (!value) return; try { await http.post(`${base}/comments/${comment.id}/replies`, { content: value, isAnonymous: replyAnonymous[comment.id] === true }); setDrafts((rows) => ({ ...rows, [comment.id]: '' })); setOpenReply(undefined); await load(true); message.success('回复已发布。') } catch (error) { message.error(apiErrorMessage(error)) } } + async function remove(id: string) { try { await http.delete(`${base}/comments/${id}`); await load(true); message.success('评论已删除。') } catch (error) { message.error(apiErrorMessage(error)) } } + return
COURSE DISCUSSION

{courseName} · 课程讨论

仅限已发布该课程成绩的用户参与;匿名会隐藏姓名。

setContent(event.target.value)} rows={3} maxLength={1000} showCount placeholder="分享对这门课程成绩、学习收获或教学改进的看法" />
setAnonymous(event.target.checked)}>匿名发布
{loading && !items.length ? : !items.length ? :
    {items.map((comment) =>
  1. {comment.authorName || '未命名用户'}{comment.isAnonymous && 匿名}

    {comment.content}

    {reactions.filter((item) => Number(comment[item.count] ?? 0) > 0).map((item) => )}{comment.canDelete && remove(comment.id)}>}
    {reactions.map((item) => )}
    {openReply === comment.id &&
    {comment.replies?.map((item) =>
    {item.authorName || '未命名用户'}{item.isAnonymous && 匿名}

    {item.content}

    {item.canDelete && remove(item.id)}>}
    )} setDrafts((rows) => ({ ...rows, [comment.id]: event.target.value }))} rows={2} maxLength={1000} placeholder="回复这条评论" />
    setReplyAnonymous((rows) => ({ ...rows, [comment.id]: event.target.checked }))}>匿名回复
    }
  2. )}
}{hasMore &&
}
+} diff --git a/web-react/src/components/course-discussion.css b/web-react/src/components/course-discussion.css new file mode 100644 index 0000000..8dbe4f0 --- /dev/null +++ b/web-react/src/components/course-discussion.css @@ -0,0 +1 @@ +.course-discussion{margin-top:16px;padding:20px;border:1px solid var(--line);background:#fff}.course-discussion header{display:flex;justify-content:space-between;gap:16px;margin-bottom:14px}.course-discussion header span{color:var(--teal);font:700 10px var(--font-data)}.course-discussion h3{margin:4px 0;color:var(--brand-deep)}.course-discussion header p,.discussion-meta,time{margin:0;color:var(--ink-muted);font-size:12px}.discussion-compose,.discussion-actions,.discussion-picker{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.discussion-compose{justify-content:space-between;margin-top:10px}.discussion-comments{margin:18px 0 0;padding:0;list-style:none;border-top:1px solid var(--line)}.discussion-comments>li{padding:14px 0;border-bottom:1px solid var(--line)}.discussion-meta{display:flex;align-items:center;gap:8px}.discussion-meta strong{color:var(--brand-deep)}.discussion-meta time{margin-left:auto}.discussion-comments p,.discussion-replies p{white-space:pre-wrap;word-break:break-word}.discussion-picker{margin-top:7px}.discussion-replies{margin-top:12px;padding:12px;background:#f6f8fb;border-left:3px solid #c7d7e8}.discussion-replies article{padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid var(--line)}.discussion-more{text-align:center;margin-top:14px}@media(max-width:640px){.course-discussion{padding:14px}.course-discussion header{display:block}.course-discussion header .ant-btn{margin-top:10px}} diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 0236b2d..b603357 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -134,6 +134,7 @@ export default function AppShell() { ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ? [ { key: '/class-timetable', icon: , label: '课表查询中心' }, + { key: '/event-analytics', icon: , label: '运行数据分析' }, ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [ { key: '/teachers', icon: , label: '教师档案' }, diff --git a/web-react/src/pages/CourseGradeStatisticsPage.tsx b/web-react/src/pages/CourseGradeStatisticsPage.tsx new file mode 100644 index 0000000..6ffcb67 --- /dev/null +++ b/web-react/src/pages/CourseGradeStatisticsPage.tsx @@ -0,0 +1,23 @@ +import { useMemo, useState } from 'react' +import { ArrowLeftOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, Button, Empty, Skeleton, Table, Tabs, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useNavigate, useParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import CourseDiscussionPanel from '../components/CourseDiscussionPanel' +import PageHeader from '../components/PageHeader' +import './course-grade-statistics.css' + +interface Distribution { range: string; count: number } +interface Scope { studentCount: number; passedCount: number; highestScore: number; averageScore: number; lowestScore: number; passRate: number; standardDeviation: number; distribution: Distribution[] } +interface Report { courseName: string; courseCode: string; termName: string; isRefreshing: boolean; class?: Scope; major?: Scope; college?: Scope; university?: Scope } +const meta = [{ key: 'class', label: '本班', caption: '同班同学' }, { key: 'major', label: '本专业', caption: '专业学生' }, { key: 'college', label: '本学院', caption: '学院学生' }, { key: 'university', label: '本校', caption: '全校学生' }] as const +const score = (value: number) => Number(value).toFixed(1) + +export default function CourseGradeStatisticsPage() { + const { sheetId = '' } = useParams(); const navigate = useNavigate(); const [scope, setScope] = useState('class'); const report = useQuery({ queryKey: ['grade-sheet-statistics', sheetId], enabled: !!sheetId, queryFn: async () => (await http.get(`/grades/sheets/${sheetId}/statistics`)).data }); const rows = useMemo(() => meta.map((item) => ({ ...item, value: report.data?.[item.key] })).filter((item): item is typeof item & { value: Scope } => !!item.value), [report.data]); const selected = rows.find((item) => item.key === scope) ?? rows[0] + if (report.isLoading) return
+ if (report.isError) return
+ const advice = !selected ? '' : selected.value.passRate < 70 ? `合格率为 ${score(selected.value.passRate)}%,建议优先复核低分段学生的平时与期末分项,并安排针对性答疑。` : selected.value.averageScore < 70 ? `平均分为 ${score(selected.value.averageScore)},建议检查易失分知识点与教学进度。` : selected.value.standardDeviation > 20 ? '成绩离散度较高,建议关注不同学生群体的学习差异。' : `平均分 ${score(selected.value.averageScore)}、合格率 ${score(selected.value.passRate)}%,当前表现稳定。` + return
} />{report.data?.isRefreshing && }{!rows.length ? : <> ({ key: item.key, label: {item.label}{item.value.studentCount} 人 }))} />
{selected.label}平均分{score(selected.value.averageScore)}统计对象:{selected.caption} · {selected.value.studentCount} 人
最高分
{score(selected.value.highestScore)}
最低分
{score(selected.value.lowestScore)}
合格人数
{selected.value.passedCount} / {selected.value.studentCount}
合格率
{score(selected.value.passRate)}%

{selected.label}分数段分布

rowKey="range" dataSource={selected.value.distribution} pagination={false} columns={[{ title: '分数段', dataIndex: 'range' }, { title: '人数', dataIndex: 'count', width: 120 }, { title: '占比', width: 120, render: (_, row) => `${(row.count / Math.max(selected.value.studentCount, 1) * 100).toFixed(1)}%` }, { title: '分布', render: (_, row) =>
}]} />

各层级整体对比

row.value.studentCount }, { title: '平均分', render: (_, row) => score(row.value.averageScore) }, { title: '最低—最高分', render: (_, row) => `${score(row.value.lowestScore)} — ${score(row.value.highestScore)}` }, { title: '合格率', render: (_, row) => {score(row.value.passRate)}% }]} />} +} diff --git a/web-react/src/pages/EventAnalyticsPage.tsx b/web-react/src/pages/EventAnalyticsPage.tsx new file mode 100644 index 0000000..c1d7941 --- /dev/null +++ b/web-react/src/pages/EventAnalyticsPage.tsx @@ -0,0 +1,23 @@ +import { useState } from 'react' +import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, Button, DatePicker, Empty, Skeleton, Table } from 'antd' +import dayjs, { type Dayjs } from 'dayjs' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './event-analytics.css' + +interface Status { enabled: boolean; syncIntervalSeconds: number; sourceLookbackDays: number; batchSize: number; database: string } +interface Attendance { attendanceDate: string; total: number; present: number; absent: number; late: number } +interface Grade { academicTermName: string; studentCount: number; averageScore: number; passRate: number } +interface Audit { date: string; total: number; failed: number } +interface Overview { attendance: Attendance[]; grades: Grade[]; audit: Audit[] } +const day = (value: Dayjs) => value.format('YYYY-MM-DD') + +export default function EventAnalyticsPage() { + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(29, 'day'), dayjs()]); const data = useQuery({ queryKey: ['event-analytics', day(range[0]), day(range[1])], queryFn: async () => { const [status, overview] = await Promise.all([http.get('/clickhouse-analytics/status'), http.get('/clickhouse-analytics/overview', { params: { from: day(range[0]), to: day(range[1]) } })]); return { status: status.data, overview: overview.data } } }) + if (data.isLoading) return
+ if (data.isError) return
+ const { status, overview } = data.data!; const attendanceTotal = overview.attendance.reduce((sum, item) => sum + Number(item.total ?? 0), 0); const absentTotal = overview.attendance.reduce((sum, item) => sum + Number(item.absent ?? 0), 0); const auditTotal = overview.audit.reduce((sum, item) => sum + Number(item.total ?? 0), 0) + return
values?.[0] && values[1] && setRange(values as [Dayjs, Dayjs])} />} />{!status.enabled ? : <>
考勤记录{attendanceTotal.toLocaleString()}
缺勤记录{absentTotal.toLocaleString()}
{overview.audit.length > 0 &&
访问审计{auditTotal.toLocaleString()}
}

每日考勤

size="small" rowKey="attendanceDate" dataSource={overview.attendance} pagination={false} locale={{ emptyText: }} columns={[{ title: '日期', dataIndex: 'attendanceDate' }, { title: '总人次', dataIndex: 'total', align: 'right' }, { title: '到课', dataIndex: 'present', align: 'right' }, { title: '缺勤', dataIndex: 'absent', align: 'right' }, { title: '迟到', dataIndex: 'late', align: 'right' }]} />

学期成绩趋势

size="small" rowKey="academicTermName" dataSource={overview.grades} pagination={false} locale={{ emptyText: }} columns={[{ title: '学期', dataIndex: 'academicTermName' }, { title: '学生数', dataIndex: 'studentCount', align: 'right' }, { title: '加权平均分', dataIndex: 'averageScore', align: 'right' }, { title: '通过率', align: 'right', render: (_, row) => `${(Number(row.passRate) * 100).toFixed(1)}%` }]} />
{overview.audit.length > 0 &&

访问审计(仅全校数据范围)

size="small" rowKey="date" dataSource={overview.audit} pagination={false} columns={[{ title: '日期', dataIndex: 'date' }, { title: '操作量', dataIndex: 'total', align: 'right' }, { title: '异常响应', dataIndex: 'failed', align: 'right' }]} />
}}
+} diff --git a/web-react/src/pages/StudentGradesPage.tsx b/web-react/src/pages/StudentGradesPage.tsx index 5dd0a0a..ffc3c89 100644 --- a/web-react/src/pages/StudentGradesPage.tsx +++ b/web-react/src/pages/StudentGradesPage.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons' import { Alert, Button, Empty, Select, Skeleton, Tag } from 'antd' import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' import http, { apiErrorMessage } from '../api/http' import PageHeader from '../components/PageHeader' import './student-learning.css' @@ -16,6 +17,7 @@ const status: Record = { Normal: '正常', Absent: '缺考', Def function rank(metric: Metric) { return metric.classRank.rank ? `班级 ${metric.classRank.rank}/${metric.classRank.peerCount}` : '暂无排名' } export default function StudentGradesPage() { + const navigate = useNavigate() const [termId, setTermId] = useState() const terms = useQuery({ queryKey: ['terms'], queryFn: async () => (await http.get('/base-data/terms')).data }) const transcript = useQuery({ queryKey: ['student-transcript', termId], queryFn: async () => (await http.get('/grades/student/transcript', { params: { academicTermId: termId } })).data }) @@ -30,6 +32,6 @@ export default function StudentGradesPage() { ({ value: term.id, label: `${term.name}${term.isCurrent ? ' · 当前' : ''}` }))} />} /> {overview.data &&
累计 GPA{overview.data.metrics.gpa.value ?? '—'}{rank(overview.data.metrics.gpa)}
加权平均分{overview.data.metrics.weightedAverageScore.value ?? '—'}{rank(overview.data.metrics.weightedAverageScore)}
已发布课程{overview.data.courseCount}{overview.data.isRefreshing ? '统计更新中' : '累计统计'}
}
筛选范围{termId ? terms.data?.find((item) => item.id === termId)?.name : '全部学期'}
课程{records.length}门
获得学分{earnedCredits}分
加权 GPA{gpa}
-
{records.map((record) => { const components = [{ name: '平时', weight: record.regularWeight, score: record.regularScore }, ...record.itemScores, { name: '期末', weight: record.finalWeight, score: record.finalScore }].filter((item) => item.weight > 0); return
{record.termName}

{record.courseName}

{record.courseCode} · {record.taskNumber} · {record.credits} 学分

{record.totalScore ?? '—'}
{status[record.examStatus] ?? record.examStatus}{record.totalScore ?? '—'}绩点 {record.gradePoint ?? '—'}
{components.map((item) => {item.name}{item.score ?? '—'} × {item.weight}%)}
})}{!records.length && }
+
{records.map((record) => { const components = [{ name: '平时', weight: record.regularWeight, score: record.regularScore }, ...record.itemScores, { name: '期末', weight: record.finalWeight, score: record.finalScore }].filter((item) => item.weight > 0); return
{record.termName}

{record.courseName}

{record.courseCode} · {record.taskNumber} · {record.credits} 学分

{record.totalScore ?? '—'}
{status[record.examStatus] ?? record.examStatus}{record.totalScore ?? '—'}绩点 {record.gradePoint ?? '—'}
{components.map((item) => {item.name}{item.score ?? '—'} × {item.weight}%)}
})}{!records.length && }
} diff --git a/web-react/src/pages/course-grade-statistics.css b/web-react/src/pages/course-grade-statistics.css new file mode 100644 index 0000000..c63b001 --- /dev/null +++ b/web-react/src/pages/course-grade-statistics.css @@ -0,0 +1 @@ +.course-grade-statistics{gap:16px}.statistics-actions{display:flex;gap:8px}.course-grade-statistics .ant-tabs-tab small{display:block;color:var(--ink-muted);font-size:10px}.statistics-summary{display:grid;grid-template-columns:minmax(210px,.8fr) 1.6fr;padding:24px;border:1px solid var(--line);background:#fff;gap:28px}.statistics-summary article{border-right:1px solid var(--line)}.statistics-summary article span,.statistics-summary article small{display:block;color:var(--ink-muted)}.statistics-summary article strong{display:block;margin:8px 0;color:var(--brand-deep);font:700 44px/1 var(--font-data)}.statistics-summary dl{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0}.statistics-summary dl div{padding:13px;background:#f6f8fb}.statistics-summary dt{color:var(--ink-muted);font-size:12px}.statistics-summary dd{margin:8px 0 0;color:var(--brand-deep);font-weight:700}.statistics-advice{margin:0}.statistics-panel{padding:18px;border:1px solid var(--line);background:#fff}.statistics-panel h3{margin:0 0 14px;color:var(--brand-deep)}.distribution-bar{height:10px;overflow:hidden;background:#e7edf4;border-radius:10px}.distribution-bar i{display:block;height:100%;min-width:2px;background:var(--teal);border-radius:inherit}@media(max-width:720px){.statistics-summary{grid-template-columns:1fr}.statistics-summary article{padding-bottom:16px;border-right:0;border-bottom:1px solid var(--line)}.statistics-summary dl{grid-template-columns:repeat(2,1fr)}.statistics-actions{display:grid;grid-template-columns:1fr}.statistics-actions>*{width:100%}} diff --git a/web-react/src/pages/event-analytics.css b/web-react/src/pages/event-analytics.css new file mode 100644 index 0000000..1126f46 --- /dev/null +++ b/web-react/src/pages/event-analytics.css @@ -0,0 +1 @@ +.event-analytics-page{gap:16px}.analytics-actions{display:flex;gap:8px;flex-wrap:wrap}.analytics-metrics,.analytics-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.analytics-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.analytics-metrics article,.analytics-grid>section,.analytics-audit{padding:18px;border:1px solid var(--line);background:#fff}.analytics-metrics article{display:grid;grid-template-columns:28px 1fr;gap:3px 8px}.analytics-metrics svg{grid-row:span 2;color:var(--teal);font-size:22px}.analytics-metrics span{color:var(--ink-muted);font-size:12px}.analytics-metrics strong{color:var(--brand-deep);font:700 26px/1 var(--font-data)}.analytics-grid h3,.analytics-audit h3{margin:0 0 14px;color:var(--brand-deep)}.analytics-audit{margin-top:14px}@media(max-width:720px){.analytics-actions{display:grid;grid-template-columns:1fr}.analytics-actions>*{width:100%}.analytics-metrics,.analytics-grid{grid-template-columns:1fr}}