diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index a5d9e0c..6abbb13 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -31,5 +31,14 @@ | `/physical-fitness` | 我的体质健康 | Student | 已发布学年、单项成绩、标准分、附加分和综合等级 | verification | | `/degree-awards` | 我的学位授予 | Student | 正式发布结论、绩点门槛、规则或人工复核说明 | verification | | `/graduation-clearance` | 我的毕业离校 | Student | 跨部门事项、必办标记、办结/豁免状态和整体进度 | verification | +| `/experiments` | 我的实验 | Student | 集中课次、自主场次、容量、预约与取消 | verification | +| `/experiment-grades` | 我的实验成绩 | Student | 正式发布项目、评分维度、参与状态和课程加权总分 | verification | +| `/campus-registrations` | 校园支付与报名 | Student | 开放项目、报名、审核状态、撤销、校园余额支付 | verification | +| `/approvals` | 我的审批申请 | Student | 免修、缓考、课程替代申请与审核结果 | verification | +| `/teacher-roster` | 选课名单 | Teacher | 本人授课教学班、学生联系与特殊支持信息、Excel 导出 | verification | +| `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | +| `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | +| `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | +| `/venue-display/:type/:id` | 教室/楼宇信息屏 | 公开 | 当日课表、空闲状态、公告、报修二维码和自动刷新 | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index c693b8c..56d5b2f 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -38,6 +38,9 @@ npm.cmd run build - 批量迁移学生校历、学籍异动、电子凭证、毕业审核和学费分项缴纳流程。 - 迁移学生其他考试成绩、补考安排和教学评价提交闭环。 - 迁移学生学业规划与毕业模拟、体质健康、学位授予和毕业离校进度。 +- 迁移学生实验预约、实验成绩、校园报名缴费及免修/缓考/课程替代申请闭环。 +- 迁移公开账号激活、电子凭证验真、教室扫码报修及场地信息屏。 +- 迁移教师选课名单工作台,支持按学期查看本人教学班、学生支持信息和 Excel 导出。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 92e3c25..a844ee7 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -32,6 +32,15 @@ const AcademicPlanningPage = lazy(() => import('../pages/AcademicPlanningPage')) const PhysicalFitnessPage = lazy(() => import('../pages/PhysicalFitnessPage')) const DegreeAwardsPage = lazy(() => import('../pages/DegreeAwardsPage')) const GraduationClearancePage = lazy(() => import('../pages/GraduationClearancePage')) +const StudentExperimentsPage = lazy(() => import('../pages/StudentExperimentsPage')) +const StudentExperimentGradesPage = lazy(() => import('../pages/StudentExperimentGradesPage')) +const CampusRegistrationsPage = lazy(() => import('../pages/CampusRegistrationsPage')) +const StudentApprovalsPage = lazy(() => import('../pages/StudentApprovalsPage')) +const TeacherRosterPage = lazy(() => import('../pages/TeacherRosterPage')) +const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) +const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) +const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) +const VenueDisplayPage = lazy(() => import('../pages/VenueDisplayPage')) const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage')) const SsoBindPage = lazy(() => import('../pages/SsoBindPage')) const AppShell = lazy(() => import('../layout/AppShell')) @@ -71,6 +80,10 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> }> } /> } /> @@ -98,6 +111,11 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 6143cec..fc21820 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -29,6 +29,11 @@ import { HeartOutlined, SafetyCertificateOutlined as DegreeOutlined, CheckCircleOutlined, + ExperimentOutlined, + FundProjectionScreenOutlined, + BankOutlined, + ApartmentOutlined, + TeamOutlined, } from '@ant-design/icons' import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd' import { useQuery } from '@tanstack/react-query' @@ -88,8 +93,15 @@ export default function AppShell() { { key: '/physical-fitness', icon: , label: '体质健康' }, { key: '/degree-awards', icon: , label: '学位授予' }, { key: '/graduation-clearance', icon: , label: '毕业离校' }, + { key: '/experiments', icon: , label: '我的实验' }, + { key: '/experiment-grades', icon: , label: '实验成绩' }, + { key: '/campus-registrations', icon: , label: '校园报名' }, + { key: '/approvals', icon: , label: '审批申请' }, { key: '/warnings', icon: , label: '学业预警' }, ] : []), + ...(roles.includes('Teacher') ? [ + { key: '/teacher-roster', icon: , label: '选课名单' }, + ] : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/AccountActivationPage.tsx b/web-react/src/pages/AccountActivationPage.tsx new file mode 100644 index 0000000..501e096 --- /dev/null +++ b/web-react/src/pages/AccountActivationPage.tsx @@ -0,0 +1,25 @@ +import { SafetyCertificateOutlined } from '@ant-design/icons' +import { Alert, App, Button, Form, Input, Select, Skeleton } from 'antd' +import { useMutation, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import { branding } from '../config/branding' +import './public-services.css' + +interface College { id: string; name: string } +interface Major { id: string; name: string; collegeId: string } +interface ClassOption { id: string; name: string; majorId: string; grade: number } +interface ActivationOptions { colleges: College[]; majors: Major[]; classes: ClassOption[] } +interface ActivationForm { name: string; studentNumber: string; collegeId: string; majorId: string; grade: number; administrativeClassId: string; password: string; confirmPassword: string } + +export default function AccountActivationPage() { + const { message } = App.useApp(); const navigate = useNavigate(); const [form] = Form.useForm() + const options = useQuery({ queryKey: ['activation-options'], queryFn: async () => (await http.get('/auth/activation-options', { skipAuthSession: true })).data }) + const collegeId = Form.useWatch('collegeId', form); const majorId = Form.useWatch('majorId', form); const grade = Form.useWatch('grade', form) + const majors = useMemo(() => options.data?.majors.filter((item) => item.collegeId === collegeId) ?? [], [options.data, collegeId]) + const grades = useMemo(() => [...new Set((options.data?.classes ?? []).filter((item) => item.majorId === majorId).map((item) => Number(item.grade)))].sort((a, b) => b - a), [options.data, majorId]) + const classes = useMemo(() => options.data?.classes.filter((item) => item.majorId === majorId && Number(item.grade) === grade) ?? [], [options.data, majorId, grade]) + const activate = useMutation({ mutationFn: async (value: ActivationForm) => (await http.post<{ userName: string }>('/auth/activate-student', { ...value, name: value.name.trim(), studentNumber: value.studentNumber.trim(), confirmPassword: undefined }, { skipAuthSession: true })).data, onSuccess: (data) => { message.success('账号激活成功,请使用学号和新密码登录'); navigate(`/login?userName=${encodeURIComponent(data.userName)}&activated=1`, { replace: true }) }, onError: (error) => message.error(apiErrorMessage(error)) }) + return
{branding.shortName}{branding.institutionName} · 学生服务
STUDENT ACCOUNT ACTIVATION

学生账号自助激活

按学籍档案填写身份信息,核验通过后学号将成为登录账号。

{options.isLoading ? : options.isError ? : <>
activate.mutate(value)}>
({ value: item.id, label: item.name }))} onChange={() => form.setFieldsValue({ grade: undefined, administrativeClassId: undefined })} /> ({ value: item.id, label: item.name }))} /> ({ validator: (_, value) => !value || getFieldValue('password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) })]}>
}
+} diff --git a/web-react/src/pages/CampusRegistrationsPage.tsx b/web-react/src/pages/CampusRegistrationsPage.tsx new file mode 100644 index 0000000..819d1a1 --- /dev/null +++ b/web-react/src/pages/CampusRegistrationsPage.tsx @@ -0,0 +1,14 @@ +import { BankOutlined, WalletOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Input, Modal, Progress, Skeleton, Tabs, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-operations.css' + +interface Project { id: string; code: string; name: string; description?: string; registrationStartsAt: string; registrationEndsAt: string; capacity: number; feeAmount: number; requiresReview: boolean; status: string; contactName?: string; contactPhone?: string; collegeName: string; confirmedCount: number; isAvailableNow: boolean } +interface Order { id: string; orderNumber: string; amount: number; paymentMethod: string; status: string; referenceNumber?: string } +interface Application { id: string; projectName: string; projectCode: string; status: string; applicantNote?: string; reviewComment?: string; createdAt: string; order?: Order } +const statusLabels: Record = { PendingReview: '待审核', PendingPayment: '待缴费', Confirmed: '已确认', Waitlisted: '候补中', Rejected: '已驳回', Cancelled: '已取消' } +const money = (value: number) => value === 0 ? '免费' : `¥${Number(value).toFixed(2)}` +export default function CampusRegistrationsPage() { const { message } = App.useApp(); const client = useQueryClient(); const [applying, setApplying] = useState(null); const [note, setNote] = useState(''); const projects = useQuery({ queryKey: ['campus-registration-projects'], queryFn: async () => (await http.get('/campus-registrations/projects')).data }); const mine = useQuery({ queryKey: ['campus-registration-mine'], queryFn: async () => (await http.get('/campus-registrations/mine')).data }); const wallet = useQuery({ queryKey: ['campus-wallet-mine'], queryFn: async () => (await http.get<{ balance: number }>('/campus-registrations/wallet/mine')).data }); const refresh = () => Promise.all([client.invalidateQueries({ queryKey: ['campus-registration-projects'] }), client.invalidateQueries({ queryKey: ['campus-registration-mine'] }), client.invalidateQueries({ queryKey: ['campus-wallet-mine'] })]); const apply = useMutation({ mutationFn: () => http.post(`/campus-registrations/projects/${applying!.id}/apply`, { note: note.trim() || null }), onSuccess: () => { message.success('报名已提交'); setApplying(null); setNote(''); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }); const cancel = useMutation({ mutationFn: (id: string) => http.post(`/campus-registrations/applications/${id}/cancel`), onSuccess: () => { message.success('报名已撤销'); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }); const pay = useMutation({ mutationFn: (id: string) => http.post(`/campus-registrations/orders/${id}/pay-balance`), onSuccess: () => { message.success('余额支付成功,报名已确认'); void refresh() }, onError: (error) => message.error(apiErrorMessage(error)) }); if (projects.isLoading || mine.isLoading || wallet.isLoading) return
; const error = projects.error ?? mine.error ?? wallet.error; if (error) return
; const projectPanel =
{projects.data?.map((project) =>
{project.code} · {project.collegeName}{project.isAvailableNow ? '报名开放' : '当前不可报名'}

{project.name}

{project.description || '暂无项目说明'}

报名时间
{new Date(project.registrationStartsAt).toLocaleString('zh-CN')} 至 {new Date(project.registrationEndsAt).toLocaleString('zh-CN')}
名额
{project.confirmedCount} / {project.capacity}
费用
{money(project.feeAmount)} · {project.requiresReview ? '需审核' : '自动受理'}
{project.contactName && 咨询:{project.contactName} {project.contactPhone}}
)}{!projects.data?.length && }
; const minePanel =
校园余额{money(wallet.data?.balance ?? 0)}可用于支付待缴费报名
{mine.data?.map((item) =>
{item.projectCode} · {new Date(item.createdAt).toLocaleDateString('zh-CN')}

{item.projectName}

{item.reviewComment || item.applicantNote || '等待项目处理'}

{statusLabels[item.status] ?? item.status}
{item.order ? `${money(item.order.amount)} · ${item.order.status === 'Paid' ? '已支付' : '待支付'}` : '暂无订单'}{item.status === 'PendingPayment' && item.order?.status === 'Pending' && }{['PendingReview', 'PendingPayment', 'Waitlisted'].includes(item.status) && }
)}{!mine.data?.length && }
; return
{projects.data?.filter((item) => item.isAvailableNow).length ?? 0}个开放项目} /> apply.mutate()} onCancel={() => setApplying(null)}>
} diff --git a/web-react/src/pages/ClassroomRepairReportPage.tsx b/web-react/src/pages/ClassroomRepairReportPage.tsx new file mode 100644 index 0000000..6b9f59c --- /dev/null +++ b/web-react/src/pages/ClassroomRepairReportPage.tsx @@ -0,0 +1,10 @@ +import { ToolOutlined } from '@ant-design/icons' +import { Alert, App, Button, Form, Input, Select, Skeleton } from 'antd' +import { useMutation, useQuery } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import './public-services.css' + +interface ReportInfo { classroom: { code: string; name: string; buildingName: string }; equipment: { id: string; name: string; isAvailable: boolean }[] } +interface ReportForm { classroomEquipmentId?: string; category: number; faultItem: string; description: string; reporterName: string; reporterContact: string } +export default function ClassroomRepairReportPage() { const { classroomId } = useParams(); const { message } = App.useApp(); const [form] = Form.useForm(); const query = useQuery({ queryKey: ['classroom-repair-report', classroomId], enabled: !!classroomId, retry: false, queryFn: async () => (await http.get(`/classroom-repairs/report/${classroomId}`, { skipAuthSession: true })).data }); const submit = useMutation({ mutationFn: (value: ReportForm) => http.post(`/classroom-repairs/report/${classroomId}`, { ...value, classroomEquipmentId: value.classroomEquipmentId || null }, { skipAuthSession: true }), onSuccess: () => { message.success('报修已提交,维修部门将尽快处理'); form.resetFields(); form.setFieldValue('category', 1); void query.refetch() }, onError: (error) => message.error(apiErrorMessage(error)) }); return
{query.isLoading ? : query.isError || !query.data ? : <>
CLASSROOM SERVICE · 扫码报修

{query.data.classroom.buildingName} · {query.data.classroom.name}

{query.data.classroom.code}。提交后将生成维修工单,并通知相关管理人员。

submit.mutate(value)}>
}
} diff --git a/web-react/src/pages/OfficialDocumentVerifyPage.tsx b/web-react/src/pages/OfficialDocumentVerifyPage.tsx new file mode 100644 index 0000000..5d59471 --- /dev/null +++ b/web-react/src/pages/OfficialDocumentVerifyPage.tsx @@ -0,0 +1,13 @@ +import { CheckCircleFilled, CloseCircleFilled, SearchOutlined } from '@ant-design/icons' +import { Alert, Button, Input, Skeleton } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import { branding } from '../config/branding' +import './public-services.css' + +interface VerifyResult { found: boolean; valid: boolean; status?: string; documentNumber?: string; type?: string; institutionName?: string; studentName?: string; studentNumber?: string; collegeName?: string; majorName?: string; issuedAt?: string; invalidatedAt?: string; replacementDocumentNumber?: string; pdfSha256?: string } +const typeLabels: Record = { Transcript: '官方电子成绩单', StudentStatusCertificate: '学籍状态证明' } +const statusLabels: Record = { Invalidated: '凭证已失效', Superseded: '凭证已被重签凭证替代' } +export default function OfficialDocumentVerifyPage() { const { verificationCode } = useParams(); const navigate = useNavigate(); const [code, setCode] = useState(verificationCode ?? ''); const [target, setTarget] = useState(verificationCode ?? ''); useEffect(() => { if (verificationCode) { setCode(verificationCode); setTarget(verificationCode) } }, [verificationCode]); const query = useQuery({ queryKey: ['official-document-verify', target], enabled: !!target, retry: false, queryFn: async () => (await http.get(`/official-documents/verify/${encodeURIComponent(target)}`, { skipAuthSession: true })).data }); const verify = () => { const value = code.trim(); if (!value) return; setTarget(value); navigate(`/verify/${encodeURIComponent(value)}`, { replace: true }) }; const result = query.data; return
{branding.institutionName} · 教务处

官方电子凭证验真

扫描凭证二维码会自动验真,也可以输入 PDF 上印制的唯一凭证编号。

setCode(event.target.value)} onPressEnter={verify} />
{query.isLoading &&
}{query.isError && }{result &&
{result.valid ? : }

{!result.found ? '未查到该凭证' : result.valid ? '验真通过 · 凭证有效' : statusLabels[result.status ?? ''] ?? '凭证无效'}

{!result.found ? '验真码不存在或输入不完整,请核对原始二维码。' : result.valid ? '该编号由本系统签发,当前未失效、未被替代。' : '请勿继续将此文件作为有效官方凭证使用。'}

{result.found &&
凭证编号
{result.documentNumber}
凭证类型
{typeLabels[result.type ?? ''] ?? result.type}
签发单位
{result.institutionName}
学生
{result.studentName} · {result.studentNumber}
学院专业
{result.collegeName} · {result.majorName}
签发时间
{result.issuedAt ? new Date(result.issuedAt).toLocaleString('zh-CN') : '—'}
{result.replacementDocumentNumber &&
替代凭证
{result.replacementDocumentNumber}
}
PDF SHA-256
{result.pdfSha256}
}
}
验真结果以本页面实时状态为准,请核对 PDF 凭证编号。返回{branding.shortName}
} diff --git a/web-react/src/pages/StudentApprovalsPage.tsx b/web-react/src/pages/StudentApprovalsPage.tsx new file mode 100644 index 0000000..daf4b4a --- /dev/null +++ b/web-react/src/pages/StudentApprovalsPage.tsx @@ -0,0 +1,76 @@ +import { ClockCircleOutlined, FileDoneOutlined, SwapOutlined } from '@ant-design/icons' +import { Alert, App, Empty, Input, Modal, Select, Skeleton, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-operations.css' + +interface Course { teachingTaskId: string; courseId: string; taskNumber: string; courseCode: string; courseName: string; credits: number; academicTermName: string; teacherNames: string[]; hasPublishedSchedule: boolean; schedulingMode: string } +interface Grade { courseId: string; courseCode: string; courseName: string; credits: number; totalScore?: number; academicTermName: string; taskNumber: string } +interface RecordItem { id: string; status: string; reason: string; reviewComment?: string; submittedAt: string; courseName?: string; originalCourseCode?: string; originalCourseName?: string; substituteCourseCode?: string; substituteCourseName?: string } +interface ApprovalData { courses: Course[]; grades: Grade[]; exemptions: RecordItem[]; deferred: RecordItem[]; substitutions: RecordItem[] } +interface OriginalOption { courseId: string; courseCode: string; courseName: string; detail: string } +type Kind = 'exemption' | 'deferred' | 'substitution' +const statusText: Record = { Submitted: '待审核', Approved: '已通过', Rejected: '已驳回' } + +export default function StudentApprovalsPage() { + const { message } = App.useApp() + const client = useQueryClient() + const [kind, setKind] = useState(null) + const [courseId, setCourseId] = useState('') + const [originalId, setOriginalId] = useState('') + const [substituteId, setSubstituteId] = useState('') + const [reason, setReason] = useState('') + const query = useQuery({ + queryKey: ['student-approvals'], + queryFn: async (): Promise => { + const [courses, grades, exemptions, deferred, substitutions] = await Promise.all([ + http.get('/approvals/my-courses'), http.get('/approvals/my-grades'), + http.get('/approvals/exemptions/mine'), http.get('/approvals/deferred/mine'), + http.get('/approvals/substitutions/mine'), + ]) + return { courses: courses.data, grades: grades.data, exemptions: exemptions.data, deferred: deferred.data, substitutions: substitutions.data } + }, + }) + const passed = useMemo(() => { + const map = new Map() + for (const grade of query.data?.grades ?? []) if (Number(grade.totalScore) >= 60 && !map.has(grade.courseId)) map.set(grade.courseId, grade) + return [...map.values()] + }, [query.data]) + const originalOptions = useMemo(() => { + const map = new Map() + for (const course of query.data?.courses ?? []) map.set(course.courseId, { courseId: course.courseId, courseCode: course.courseCode, courseName: course.courseName, detail: `${course.academicTermName} · ${course.taskNumber}` }) + for (const grade of query.data?.grades ?? []) if (grade.totalScore != null && grade.totalScore < 60 && !map.has(grade.courseId)) map.set(grade.courseId, { courseId: grade.courseId, courseCode: grade.courseCode, courseName: grade.courseName, detail: `${grade.academicTermName} · ${grade.totalScore} 分` }) + return [...map.values()] + }, [query.data]) + const close = () => { setKind(null); setCourseId(''); setOriginalId(''); setSubstituteId(''); setReason('') } + const submit = useMutation({ + mutationFn: async (): Promise => { + if (kind === 'exemption') await http.post('/approvals/exemptions', { teachingTaskId: courseId, reason: reason.trim() }) + else if (kind === 'deferred') await http.post('/approvals/deferred', { teachingTaskId: courseId, reason: reason.trim() }) + else await http.post('/approvals/substitutions', { originalCourseId: originalId, substituteCourseId: substituteId, reason: reason.trim() }) + }, + onSuccess: () => { message.success('申请已提交'); close(); void client.invalidateQueries({ queryKey: ['student-approvals'] }) }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + if (query.isLoading) return
+ if (query.isError) return
+ const records = [{ key: '免修申请', items: query.data?.exemptions ?? [] }, { key: '缓考申请', items: query.data?.deferred ?? [] }, { key: '课程替代', items: query.data?.substitutions ?? [] }] + const valid = reason.trim().length > 0 && (kind === 'substitution' ? !!originalId && !!substituteId && originalId !== substituteId : !!courseId) + return
+ +
+ + + +
+
{records.map((group) =>

{group.key}

{group.items.length}
{group.items.map((item) =>
{statusText[item.status] ?? item.status}
{item.courseName || `${item.substituteCourseCode} ${item.substituteCourseName} → ${item.originalCourseCode} ${item.originalCourseName}`}

{item.reason}

{item.reviewComment && 审核意见:{item.reviewComment}}
)}{!group.items.length && }
)}
+ submit.mutate()} onCancel={close}> +
+ {kind !== 'substitution' ?
+} diff --git a/web-react/src/pages/VenueDisplayPage.tsx b/web-react/src/pages/VenueDisplayPage.tsx new file mode 100644 index 0000000..3a0cb26 --- /dev/null +++ b/web-react/src/pages/VenueDisplayPage.tsx @@ -0,0 +1,15 @@ +import { FullscreenExitOutlined, FullscreenOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons' +import { Alert, Button, Skeleton } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import { branding } from '../config/branding' +import './venue-display.css' + +interface Slot { periodNumber: number; startsAt: string; endsAt: string } +interface Entry { startPeriod: number; periodCount: number; courseName: string; classNames: string[]; teacherNames: string[]; isReservation: boolean } +interface Room { code: string; name: string; freePeriods: number[] } +interface VenueData { today: string; term: string; classroom?: { name: string; campusName: string; buildingName: string; code: string }; building?: { name: string; campusName: string; code: string }; slots: Slot[]; entries?: Entry[]; rooms?: Room[]; announcements: { id: string; title: string; content: string; isUrgent: boolean }[] } +const minutes = (value = '') => { const [hour, minute] = value.split(':').map(Number); return hour * 60 + minute } +export default function VenueDisplayPage() { const params = useParams(); const type = params.type as 'classroom' | 'building'; const id = params.id; const [now, setNow] = useState(new Date()); const [page, setPage] = useState(0); const [fullscreen, setFullscreen] = useState(false); const query = useQuery({ queryKey: ['venue-display', type, id], enabled: !!id && ['classroom', 'building'].includes(type), refetchInterval: 60_000, queryFn: async () => (await http.get(`/venue-displays/${type}s/${id}`, { skipAuthSession: true })).data }); useEffect(() => { const timer = window.setInterval(() => setNow(new Date()), 1000); const onFullscreen = () => setFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFullscreen); return () => { window.clearInterval(timer); document.removeEventListener('fullscreenchange', onFullscreen) } }, []); const items = type === 'classroom' ? query.data?.entries ?? [] : query.data?.rooms ?? []; const pageSize = type === 'classroom' ? 6 : 12; const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); useEffect(() => { const timer = pageCount > 1 ? window.setInterval(() => setPage((value) => (value + 1) % pageCount), 25_000) : undefined; return () => { if (timer) window.clearInterval(timer) } }, [pageCount]); const visible = items.slice(page * pageSize, (page + 1) * pageSize); const current = useMemo(() => { if (type !== 'classroom' || !query.data) return undefined; const value = now.getHours() * 60 + now.getMinutes(); return query.data.entries?.find((entry) => { const start = query.data!.slots.find((slot) => slot.periodNumber === entry.startPeriod); const end = query.data!.slots.find((slot) => slot.periodNumber === entry.startPeriod + entry.periodCount - 1); return start && end && value >= minutes(start.startsAt) && value < minutes(end.endsAt) }) }, [query.data, now, type]); if (query.isLoading) return
; if (query.isError || !query.data) return
; const title = type === 'classroom' ? query.data.classroom : query.data.building; const toggleFullscreen = async () => document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen(); return
{branding.institutionName} · 智慧教学空间

{title?.name}

{title?.campusName} · {type === 'classroom' ? query.data.classroom?.buildingName : query.data.building?.code} · {query.data.term}

{type === 'classroom' &&
当前状态{current?.courseName ?? '当前无课程'}{current && 第 {current.startPeriod}–{current.startPeriod + current.periodCount - 1} 节}
}{type === 'classroom' && 扫码报修二维码扫码报修}
{type === 'classroom' ? (visible as Entry[]).map((entry) =>
{entry.startPeriod}–{entry.startPeriod + entry.periodCount - 1} 节
{entry.isReservation && 已借用}

{entry.courseName}

{entry.classNames?.join('、')} · {entry.teacherNames?.join('、')}

) :
教室{query.data.slots.map((slot) => {slot.periodNumber}{slot.startsAt.slice(0, 5)})}
{(visible as Room[]).map((room) =>
{room.name}{query.data.slots.map((slot) => {room.freePeriods.includes(slot.periodNumber) ? '空闲' : '占用'})}
)}
}{!items.length &&
今日暂无安排
}
{query.data.announcements?.length > 0 && }{pageCount > 1 && }
} diff --git a/web-react/src/pages/public-services.css b/web-react/src/pages/public-services.css new file mode 100644 index 0000000..3aa7e00 --- /dev/null +++ b/web-react/src/pages/public-services.css @@ -0,0 +1,2 @@ +.public-page{min-height:100vh;padding:0 20px 48px;background:radial-gradient(circle at 12% 0,#dcebe8,transparent 32%),var(--canvas)}.public-topbar{max-width:920px;height:68px;margin:auto;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.public-topbar a{color:var(--brand-deep);font:700 15px var(--font-display);text-decoration:none}.public-topbar span{color:var(--ink-muted);font-size:9px}.public-card{width:min(760px,100%);margin:44px auto 0;padding:34px 40px;border:1px solid var(--line);background:white;box-shadow:var(--shadow-panel)}.public-intro{margin-bottom:22px;display:flex;gap:16px;align-items:flex-start}.public-intro>.anticon{padding:12px;color:white;background:var(--brand);font-size:26px}.public-intro span{color:var(--teal);font:700 9px var(--font-data);letter-spacing:.12em}.public-intro h1{margin:7px 0;color:var(--brand-deep);font:600 26px var(--font-display)}.public-intro p{margin:0;color:var(--ink-muted);font-size:10px}.activation-form{margin-top:22px}.public-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}.repair-public{padding-top:7vh}.repair-card{max-width:680px;margin-top:0}.verify-public{display:grid;place-items:center;padding:28px 16px}.verify-shell{width:min(720px,100%);overflow:hidden;border:1px solid #cbdcd8;background:rgba(255,255,255,.96);box-shadow:0 24px 70px rgba(31,70,64,.14)}.verify-shell>header{padding:32px 38px;color:white;background:#214f49}.verify-shell>header span{color:var(--teal-bright);font:700 9px var(--font-data);letter-spacing:.13em}.verify-shell>header h1{margin:8px 0;color:white;font:600 27px var(--font-display)}.verify-shell>header p{margin:0;color:#c5d9d5;font-size:10px}.verify-search{padding:24px 38px 16px;display:grid;grid-template-columns:1fr auto;gap:9px}.verify-loading,.verify-shell>.ant-alert{margin:0 38px 20px}.verify-result{margin:16px 38px 28px;border:1px solid #cce2db}.verify-result.invalid{border-color:#ecd2cf}.verify-state{padding:18px 20px;display:flex;gap:13px;align-items:center;color:#236b5b;background:#eff8f5}.invalid .verify-state{color:#a44840;background:#fff3f1}.verify-state>.anticon{font-size:34px}.verify-state h2{margin:0 0 4px;font-size:16px}.verify-state p{margin:0;color:var(--ink-muted);font-size:9px}.verify-result dl{margin:0;padding:10px 20px 17px;display:grid;grid-template-columns:1fr 1fr}.verify-result dl div{padding:10px 4px;border-bottom:1px solid #edf1f0}.verify-result dt{color:var(--ink-muted);font-size:8px}.verify-result dd{margin:4px 0 0;color:var(--ink);font-size:10px;font-weight:650}.verify-result .hash{grid-column:1/-1}.verify-result .hash dd{overflow-wrap:anywhere;font:8px var(--font-data)}.verify-shell>footer{padding:16px 38px;display:flex;justify-content:space-between;background:#f6f8f7;color:var(--ink-muted);font-size:8px}.verify-shell>footer a{color:var(--teal);text-decoration:none} +@media(max-width:600px){.public-card{margin-top:22px;padding:25px 20px}.public-form-grid{grid-template-columns:1fr}.verify-search{grid-template-columns:1fr}.verify-shell>header,.verify-search{padding-left:22px;padding-right:22px}.verify-result,.verify-loading,.verify-shell>.ant-alert{margin-left:22px;margin-right:22px}.verify-result dl{grid-template-columns:1fr}.verify-result .hash{grid-column:auto}.verify-shell>footer{padding:15px 22px;flex-direction:column;gap:8px}} diff --git a/web-react/src/pages/student-operations.css b/web-react/src/pages/student-operations.css new file mode 100644 index 0000000..6bf1ac3 --- /dev/null +++ b/web-react/src/pages/student-operations.css @@ -0,0 +1,3 @@ +.operations-page{gap:16px}.operations-count{min-width:150px;padding:11px 14px;display:grid;grid-template-columns:auto auto;align-items:center;gap:0 10px;border-left:3px solid var(--teal);background:var(--teal-soft)}.operations-count>.anticon{grid-row:1/3;color:var(--teal);font-size:25px}.operations-count strong{color:var(--brand-deep);font:700 20px var(--font-data)}.operations-count span{color:var(--ink-muted);font-size:8px}.experiment-groups{display:grid;gap:16px}.experiment-groups>article{border:1px solid var(--line);background:white}.experiment-groups>article>header{padding:17px 20px;color:white;background:var(--brand-deep)}.experiment-groups header span{color:var(--teal-bright);font:700 8px var(--font-data)}.experiment-groups h2{margin:5px 0;color:white;font-size:16px}.experiment-groups header p{margin:0;color:#c2ccdc;font-size:8px}.experiment-groups>article>div>section{padding:17px 19px;display:grid;grid-template-columns:250px 1fr;gap:20px;border-bottom:1px solid var(--line)}.project-copy span{color:var(--teal);font:8px var(--font-data)}.project-copy h3{margin:5px 0;color:var(--brand-deep);font-size:13px}.project-copy p{margin:0 0 10px;color:var(--ink-muted);font-size:9px}.fixed-session{padding:13px 16px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:5px 12px;background:#f4f7fb}.fixed-session>.anticon{grid-row:1/3;color:var(--teal);font-size:22px}.fixed-session strong{font-size:10px}.fixed-session small{color:var(--ink-muted);font-size:8px}.session-options{display:grid;gap:7px}.session-options>div{padding:10px 12px;display:grid;grid-template-columns:1fr auto auto;gap:10px;align-items:center;border:1px solid var(--line)}.session-options>div.selected{border-color:var(--teal);background:var(--teal-soft)}.session-options strong,.session-options small{display:block}.session-options strong{font-size:10px}.session-options small{margin-top:3px;color:var(--ink-muted);font-size:8px}.lab-grade-list{display:grid;gap:16px}.lab-grade-list>article{border:1px solid var(--line);background:white}.lab-grade-list>article>header{padding:19px 21px;display:flex;justify-content:space-between;color:white;background:linear-gradient(110deg,var(--brand-deep),var(--brand))}.lab-grade-list header span{color:var(--teal-bright);font:8px var(--font-data)}.lab-grade-list h2{margin:6px 0;color:white;font-size:16px}.lab-grade-list header p{margin:0;color:#c3cede;font-size:8px}.course-grade-seal{display:grid;grid-template-columns:auto auto;align-items:center;gap:0 10px}.course-grade-seal>.anticon{grid-row:1/3;font-size:25px}.course-grade-seal span{font-size:8px}.course-grade-seal strong{font:700 22px var(--font-data)}.lab-grade-list>article>div>section{padding:16px 19px;display:grid;grid-template-columns:240px 1fr 85px;gap:18px;align-items:center;border-bottom:1px solid var(--line)}.lab-grade-list section>div:first-child span{color:var(--teal);font:8px var(--font-data)}.lab-grade-list h3{margin:5px 0;color:var(--brand-deep);font-size:12px}.lab-grade-list section p{margin:0;color:var(--ink-muted);font-size:8px}.teacher-note{display:block;margin-top:8px;color:#755019;font-size:8px}.rubric-list{display:flex;flex-wrap:wrap;gap:7px}.rubric-list>div{min-width:92px;padding:8px 10px;background:#f4f7fb}.rubric-list span,.rubric-list strong,.rubric-list small{display:block}.rubric-list span,.rubric-list small{color:var(--ink-muted);font-size:7px}.rubric-list strong{margin-top:3px;color:var(--brand-deep);font:700 13px var(--font-data)}.project-grade{display:grid;justify-items:center;gap:5px}.registration-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:13px}.registration-grid>article{padding:18px 20px;border:1px solid var(--line);background:white}.registration-grid header{display:flex;justify-content:space-between}.registration-grid header span{color:var(--teal);font:8px var(--font-data)}.registration-grid h2{margin:12px 0 5px;color:var(--brand-deep);font-size:15px}.registration-grid>article>p{min-height:34px;margin:0;color:var(--ink-muted);font-size:9px}.registration-grid dl{display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px;margin:17px 0}.registration-grid dt{color:var(--ink-muted);font-size:8px}.registration-grid dd{margin:4px 0 0;color:var(--ink);font-size:9px}.registration-grid footer{margin-top:14px;display:flex;align-items:center;justify-content:space-between}.registration-grid footer span{color:var(--ink-muted);font-size:8px}.wallet-banner{padding:18px 21px;display:flex;gap:14px;align-items:center;color:white;background:linear-gradient(110deg,#124d50,var(--teal))}.wallet-banner>.anticon{font-size:30px}.wallet-banner span,.wallet-banner strong,.wallet-banner small{display:block}.wallet-banner span{color:var(--teal-bright);font-size:8px}.wallet-banner strong{margin:4px 0;font:700 22px var(--font-data)}.wallet-banner small{color:#c5e2de;font-size:8px}.application-list{border:1px solid var(--line);background:white}.application-list>article{padding:15px 18px;display:grid;grid-template-columns:1fr auto 220px;gap:15px;align-items:center;border-bottom:1px solid var(--line)}.application-list span{color:var(--teal);font:8px var(--font-data)}.application-list h3{margin:5px 0;color:var(--brand-deep);font-size:11px}.application-list p{margin:0;color:var(--ink-muted);font-size:8px}.application-order{display:flex;align-items:center;justify-content:flex-end;gap:6px}.application-order strong{font-size:9px}.operation-field,.approval-form label{margin-top:16px;display:grid;gap:7px;color:var(--ink);font-size:10px}.approval-launcher{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.approval-launcher button{min-height:120px;padding:19px;display:flex;gap:14px;text-align:left;border:1px solid var(--line);background:white;cursor:pointer}.approval-launcher button:hover{border-color:var(--teal);box-shadow:var(--shadow-panel)}.approval-launcher button>.anticon{padding:10px;color:white;background:var(--brand);font-size:20px}.approval-launcher span,.approval-launcher small,.approval-launcher strong,.approval-launcher em{display:block}.approval-launcher small{color:var(--teal);font:8px var(--font-data)}.approval-launcher strong{margin:8px 0;color:var(--brand-deep);font-size:14px}.approval-launcher em{color:var(--ink-muted);font-size:8px;font-style:normal}.approval-records{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.approval-records>article{border:1px solid var(--line);background:white}.approval-records>article>header{padding:13px 16px;display:flex;justify-content:space-between;border-bottom:1px solid var(--line)}.approval-records h2{margin:0;color:var(--brand-deep);font-size:12px}.approval-records article>div>section{padding:13px 15px;display:grid;grid-template-columns:auto 1fr;gap:10px;border-bottom:1px solid var(--line)}.approval-records strong{font-size:9px}.approval-records p,.approval-records small{margin:4px 0 0;color:var(--ink-muted);font-size:8px}.approval-form{display:grid;gap:10px}.approval-form label .ant-select{width:100%} +@media(max-width:900px){.registration-grid,.approval-records{grid-template-columns:1fr}.approval-launcher{grid-template-columns:1fr}.lab-grade-list>article>div>section{grid-template-columns:1fr 85px}.lab-grade-list>article>div>section>div:first-child{grid-column:1/-1}.experiment-groups>article>div>section{grid-template-columns:1fr}} +@media(max-width:600px){.session-options>div{grid-template-columns:1fr auto}.session-options>div>.ant-btn{grid-column:1/-1}.registration-grid dl{grid-template-columns:1fr}.application-list>article{grid-template-columns:1fr auto}.application-order{grid-column:1/-1;justify-content:flex-start}.lab-grade-list>article>header{gap:15px}.course-grade-seal>.anticon{display:none}.rubric-list{grid-column:1/-1}.lab-grade-list>article>div>section{grid-template-columns:1fr}.project-grade{justify-items:start}} diff --git a/web-react/src/pages/teacher-workspace.css b/web-react/src/pages/teacher-workspace.css new file mode 100644 index 0000000..9b5afeb --- /dev/null +++ b/web-react/src/pages/teacher-workspace.css @@ -0,0 +1 @@ +.teacher-roster-page{gap:16px}.teacher-roster-toolbar{min-height:56px;padding:10px 16px;display:flex;align-items:center;gap:14px;border:1px solid var(--line);background:#fbfcfd}.teacher-roster-toolbar .ant-select{width:min(320px,100%)}.teacher-roster-toolbar>span{color:var(--ink-muted);font-size:10px}.teacher-roster-toolbar strong{color:var(--brand-deep);font:700 13px var(--font-data)}.teacher-roster-workspace{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:520px;border:1px solid var(--line);background:white}.teacher-task-list{max-height:690px;overflow-y:auto;border-right:1px solid var(--line)}.teacher-task-list>button{width:100%;padding:14px 16px;display:grid;gap:4px;text-align:left;border:0;border-bottom:1px solid #edf0f4;background:white;cursor:pointer;transition:background .15s,border-color .15s}.teacher-task-list>button:hover{background:#f5f7fa}.teacher-task-list>button.active{padding-left:13px;border-left:3px solid var(--teal);background:var(--teal-soft)}.teacher-task-list button span{color:var(--teal);font:700 8px var(--font-data)}.teacher-task-list button strong{color:var(--brand-deep);font-size:11px}.teacher-task-list button small{color:var(--ink-muted);font-size:8px}.teacher-task-list button em{color:var(--brand);font-size:8px;font-style:normal;font-weight:700}.teacher-task-list>.ant-empty{margin:56px 12px}.teacher-roster-detail{min-width:0}.teacher-roster-detail>.ant-skeleton,.teacher-roster-detail>.ant-alert,.teacher-roster-detail>.ant-empty{margin:28px}.teacher-roster-detail>header{min-height:88px;padding:17px 21px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--line);background:#f8fafb}.teacher-roster-detail header span{color:var(--teal);font:700 8px var(--font-data)}.teacher-roster-detail h2{margin:5px 0 3px;color:var(--brand-deep);font-size:16px}.teacher-roster-detail p{margin:0;color:var(--ink-muted);font-size:9px}.teacher-roster-detail .ant-table{font-size:10px}.roster-contact{display:grid;gap:4px;line-height:1.35}.roster-contact small{color:var(--ink-muted);white-space:normal}.roster-contact .ant-tag{width:fit-content;max-width:100%;white-space:normal}.teacher-roster-note{padding:12px 15px;display:flex;align-items:center;gap:9px;color:#476282;border-left:3px solid #6b86a8;background:#edf3f9;font-size:9px}.teacher-roster-note>.anticon{font-size:16px}@media(prefers-reduced-motion:reduce){.teacher-task-list>button{transition:none}}@media(max-width:820px){.teacher-roster-workspace{grid-template-columns:1fr}.teacher-task-list{max-height:240px;border-right:0;border-bottom:1px solid var(--line)}.teacher-roster-detail>header{align-items:flex-start;flex-direction:column}.teacher-roster-toolbar{align-items:flex-start;flex-direction:column}.teacher-roster-toolbar .ant-select{width:100%}} diff --git a/web-react/src/pages/venue-display.css b/web-react/src/pages/venue-display.css new file mode 100644 index 0000000..91511e8 --- /dev/null +++ b/web-react/src/pages/venue-display.css @@ -0,0 +1,3 @@ +.venue-display{--venue-ink:#f0faf8;--venue-muted:#adc4ca;--venue-line:rgb(159 223 210 / .25);min-height:100dvh;padding:clamp(18px,3.4vw,54px);display:flex;flex-direction:column;gap:22px;overflow:hidden;background:radial-gradient(circle at 80% 0,#1d5865 0,transparent 32%),#0a1c29;color:var(--venue-ink)}.venue-display.loading{display:grid;place-items:center;background:var(--canvas)}.venue-display>header{display:grid;grid-template-columns:minmax(260px,1fr) auto auto auto auto;align-items:center;gap:24px;padding-bottom:20px;border-bottom:1px solid var(--venue-line)}.venue-identity>span,.venue-current>span{color:var(--teal-bright);font:700 10px var(--font-data);letter-spacing:.13em}.venue-identity h1{margin:7px 0;color:white;font:600 clamp(30px,4.5vw,60px)/1.1 var(--font-display)}.venue-identity p{margin:0;color:var(--venue-muted);font-size:12px}.venue-current{min-width:150px;padding-left:18px;border-left:2px solid var(--teal-bright)}.venue-current strong,.venue-current small{display:block}.venue-current strong{margin:6px 0;font-size:18px}.venue-current small{color:var(--venue-muted);font-size:10px}.venue-repair{padding:8px;display:grid;justify-items:center;background:#effdf9;color:#102a3a;text-decoration:none}.venue-repair img{width:62px;height:62px}.venue-repair span{font-size:9px;font-weight:700}.venue-display time{color:var(--teal-bright);font:clamp(24px,3.2vw,46px) var(--font-data);text-align:right;white-space:nowrap}.venue-display time small{display:block;color:var(--venue-muted);font:9px var(--font-body)}.venue-board{flex:1;min-height:0}.venue-classroom .venue-board{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(135px,1fr);gap:13px}.venue-board>article{padding:20px;display:grid;grid-template-columns:auto 1fr;gap:17px;align-items:center;border-left:5px solid var(--teal-bright);background:#123443}.venue-board>article.current{box-shadow:inset 0 0 0 1px var(--teal-bright);background:#164756}.venue-board>article.reservation{border-color:#f5a524;background:#3b3020}.venue-period{color:var(--teal-bright);font:700 clamp(32px,4vw,56px)/.8 var(--font-data)}.venue-period small{display:block;margin-top:9px;color:var(--venue-muted);font:10px var(--font-body);white-space:nowrap}.venue-board article h2{margin:5px 0 9px;color:white;font-size:clamp(20px,2.4vw,34px)}.venue-board article p{margin:0;color:var(--venue-muted);font-size:14px}.venue-board article span{color:#ffd07b;font-size:9px}.venue-empty{display:grid;place-items:center;grid-column:1/-1;background:#102d3b;color:var(--teal-bright);font-size:30px}.room-table{min-width:900px}.room-table>header,.room-table>div{display:grid;grid-template-columns:minmax(140px,2fr) repeat(12,minmax(52px,1fr));gap:4px}.room-table>header{padding-bottom:5px;color:var(--venue-muted);text-align:center}.room-table span small{display:block;font-size:8px}.room-table>div{margin-top:4px}.room-table>div>strong,.room-table>div>span{padding:11px 6px;display:grid;place-items:center;background:#213f4b;text-align:center;font-size:10px}.room-table>div>span.free{background:#1b6d5d;color:white;font-weight:700}.venue-display>aside{min-height:48px;padding:0 17px;display:flex;align-items:center;gap:15px;border-left:4px solid var(--teal-bright);background:#123443;overflow:hidden}.venue-display>aside>strong{color:var(--teal-bright);font-size:11px}.venue-display>aside>div{display:flex;gap:45px;overflow:auto}.venue-display>aside span{white-space:nowrap;font-size:10px}.venue-display>aside em{margin-right:6px;padding:2px 5px;background:#d9504a;color:white;font-style:normal}.venue-display>footer{display:flex;align-items:center;justify-content:center;gap:10px;color:var(--venue-muted)}.venue-display>footer small{font-size:9px} +@media(max-width:1060px){.venue-display>header{grid-template-columns:1fr auto auto auto}.venue-current{display:none}.venue-classroom .venue-board{grid-template-columns:1fr}} +@media(max-width:680px){.venue-display{padding:18px;overflow:auto}.venue-display>header{grid-template-columns:1fr auto}.venue-repair{grid-row:2;grid-column:1/-1}.venue-repair img{width:80px;height:80px}.venue-identity h1{font-size:32px}.venue-display>header>.ant-btn{display:none}.venue-board>article{padding:17px}.venue-board article h2{font-size:23px}}