diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 99cd16d..8fd7868 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -53,6 +53,7 @@ | `/timetable`、`/class-timetable`、`/teacher-timetable/:teacherId` | 公开/管理/教师课表 | 公开;教务管理与领导可查草稿 | 班级/教师/教室资源、版本切换、周/日/总览、考试实验融合、单项与批量 Excel/PDF | verification | | `/course-adjustments` | 调停课管理 | Teacher、SuperAdmin、AcademicAdmin、CollegeAdmin | 原课次锁定、调课/停课/补课/代课申请、草稿提交、学院范围审核、冲突复核与通知定位 | verification | | `/classroom-reservations` | 教室借用与预约 | 已登录用户;CollegeAdmin 审核 | 空闲教室检索、课程/考试/已批准预约避让、申请撤销、学院范围审核与冲突复核 | verification | +| `/venue-displays` | 场地信息展牌管理 | SuperAdmin、AcademicAdmin | 教室/楼宇展牌链接、批量 Excel、范围/优先级/有效期公告管理 | 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 c4dcd16..4efd1d4 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -52,6 +52,7 @@ npm.cmd run build - 迁移公开、管理和指定教师课表,支持班级/教师/教室查询、版本切换、考试实验融合与批量导出。 - 迁移调停课申请与审核工作台,覆盖原课次锁定、调课/停课/补课/代课、草稿提交、冲突复核和通知定位。 - 迁移教室借用与预约,覆盖空闲教室检索、预约申请/撤销和学院审核闭环。 +- 迁移场地信息展牌管理,支持展牌链接、批量导出与定向公告投放。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index ef5aa9b..5b3b19d 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -51,6 +51,7 @@ const SchedulesPage = lazy(() => import('../pages/SchedulesPage')) const TimetableQueryPage = lazy(() => import('../pages/TimetableQueryPage')) const CourseAdjustmentsPage = lazy(() => import('../pages/CourseAdjustmentsPage')) const ClassroomReservationsPage = lazy(() => import('../pages/ClassroomReservationsPage')) +const VenueDisplaysPage = lazy(() => import('../pages/VenueDisplaysPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -150,6 +151,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 98ccf4e..f674b41 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -122,6 +122,7 @@ export default function AppShell() { { key: '/campus-wallets', icon: , label: '学生余额管理' }, ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [ + { key: '/venue-displays', icon: , label: '场地信息展牌' }, { key: '/base-data/organization', icon: , label: '组织机构' }, { key: '/base-data/terms', icon: , label: '学年学期' }, { key: '/base-data/facilities', icon: , label: '教学场所' }, diff --git a/web-react/src/pages/VenueDisplaysPage.tsx b/web-react/src/pages/VenueDisplaysPage.tsx new file mode 100644 index 0000000..6cfb39c --- /dev/null +++ b/web-react/src/pages/VenueDisplaysPage.tsx @@ -0,0 +1,33 @@ +import { useMemo, useState } from 'react' +import { CopyOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Modal, Select, Skeleton, Table, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import { downloadApiPostFile } from '../api/files' +import PageHeader from '../components/PageHeader' +import './venue-displays.css' + +interface Campus { id: string; name: string } +interface Building { id: string; name: string; campusId: string } +interface Classroom { id: string; name: string; buildingId: string; buildingName: string } +interface Options { campuses: Campus[]; buildings: Building[]; classrooms: Classroom[] } +interface Announcement { id: string; title: string; content: string; scope: number; targetId?: string; isPublished: boolean; isPinned: boolean; isUrgent: boolean; priority: number; startsAt?: string; endsAt?: string; updatedAt: string } +interface AnnouncementForm { title: string; content: string; scope: number; targetId?: string; isPublished: boolean; isPinned: boolean; isUrgent: boolean; priority: number; startsAt?: string; endsAt?: string } +const scopes = [{ value: 1, label: '全校展屏' }, { value: 2, label: '指定校区' }, { value: 3, label: '指定教学楼' }, { value: 4, label: '指定教室' }] +const scopeName = (scope: number) => scopes.find((item) => item.value === scope)?.label ?? '未知范围' +const toInput = (value?: string) => value ? value.slice(0, 16) : undefined + +export default function VenueDisplaysPage() { + const { message, modal } = App.useApp(); const [form] = Form.useForm(); const [editing, setEditing] = useState(); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [classroomIds, setClassroomIds] = useState([]); const [buildingIds, setBuildingIds] = useState([]); const [classroomId, setClassroomId] = useState(); const [buildingId, setBuildingId] = useState(); const scope = Form.useWatch('scope', form) ?? 1 + const refs = useQuery({ queryKey: ['venue-display-options'], queryFn: async () => (await http.get('/venue-display-announcements/options')).data }); const list = useQuery({ queryKey: ['venue-display-announcements'], queryFn: async () => (await http.get('/venue-display-announcements')).data }) + const targets = useMemo(() => scope === 2 ? refs.data?.campuses ?? [] : scope === 3 ? refs.data?.buildings ?? [] : refs.data?.classrooms ?? [], [scope, refs.data]) + const classroomUrl = classroomId ? `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, '')}/venue-display/classroom/${classroomId}` : ''; const buildingUrl = buildingId ? `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, '')}/venue-display/building/${buildingId}` : '' + const targetName = (row: Announcement) => { if (row.scope === 1) return '全部展屏'; const values = row.scope === 2 ? refs.data?.campuses : row.scope === 3 ? refs.data?.buildings : refs.data?.classrooms; const item = values?.find((value) => value.id === row.targetId); return item ? ('buildingName' in item ? `${item.buildingName} · ${item.name}` : item.name) : '目标已移除' } + function edit(item?: Announcement) { setEditing(item); form.setFieldsValue(item ? { ...item, startsAt: toInput(item.startsAt), endsAt: toInput(item.endsAt) } : { title: '', content: '', scope: 1, targetId: undefined, isPublished: true, isPinned: false, isUrgent: false, priority: 0, startsAt: undefined, endsAt: undefined }); setOpen(true) } + async function save(values: AnnouncementForm) { try { setSaving(true); const payload = { ...values, targetId: values.scope === 1 ? null : values.targetId, startsAt: values.startsAt ? new Date(values.startsAt).toISOString() : null, endsAt: values.endsAt ? new Date(values.endsAt).toISOString() : null }; if (editing) await http.put(`/venue-display-announcements/${editing.id}`, payload); else await http.post('/venue-display-announcements', payload); message.success(editing ? '公告已更新' : '公告已发布'); setOpen(false); await list.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setSaving(false) } } + function remove(item: Announcement) { modal.confirm({ title: '删除展屏公告', content: `删除“${item.title}”后将不再在展屏显示。`, okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/venue-display-announcements/${item.id}`); message.success('公告已删除'); await list.refetch() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + async function copy(value: string) { try { await navigator.clipboard.writeText(value); message.success('展牌链接已复制') } catch { message.error('无法写入剪贴板,请手动复制链接。') } } + async function exportLinks() { if (!classroomIds.length && !buildingIds.length) return message.warning('请先选择要导出的展牌链接'); try { await downloadApiPostFile('/timetables/management/display-links/export.xlsx', { classroomIds, buildingIds }, '场地信息展牌链接.xlsx') } catch (error) { message.error(apiErrorMessage(error)) } } + if (refs.isLoading || list.isLoading) return
; if (refs.isError || list.isError) return
+ return
} />

展牌链接

({ value: item.id, label: item.name }))} />
PUBLIC MESSAGE

展屏公告

rowKey="id" dataSource={list.data} pagination={false} scroll={{ x: 820 }} columns={[{ title: '公告', render: (_, row) =>
{row.title}{row.content}
}, { title: '投放范围', width: 180, render: (_, row) => `${scopeName(row.scope)} · ${targetName(row)}` }, { title: '状态', width: 130, render: (_, row) => <>{{row.isPublished ? '已发布' : '草稿'}}{row.isUrgent && 紧急} }, { title: '有效期', width: 200, render: (_, row) => {row.startsAt ? new Date(row.startsAt).toLocaleString('zh-CN', { hour12: false }) : '立即'} 至 {row.endsAt ? new Date(row.endsAt).toLocaleString('zh-CN', { hour12: false }) : '长期'} }, { title: '操作', width: 125, fixed: 'right', render: (_, row) => <> }]} />
setOpen(false)} onOk={() => form.submit()}>
void save(values)}>
({ value: item.id, label: 'buildingName' in item ? `${item.buildingName} · ${item.name}` : item.name }))} />}
立即发布置顶紧急高亮
+} diff --git a/web-react/src/pages/venue-displays.css b/web-react/src/pages/venue-displays.css new file mode 100644 index 0000000..eaba41e --- /dev/null +++ b/web-react/src/pages/venue-displays.css @@ -0,0 +1 @@ +.venue-displays-page{gap:16px}.venue-actions{display:flex;gap:8px}.venue-links,.venue-announcements{padding:16px;border:1px solid var(--line);background:#fff}.venue-links h3,.venue-announcements h3{margin:0;color:var(--brand-deep)}.venue-link-grid{margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:14px}.venue-link-grid label{display:grid;gap:7px;color:var(--brand-deep);font-size:10px;font-weight:700}.venue-export{margin-top:14px;display:grid;grid-template-columns:1fr 1fr auto;gap:9px}.venue-announcements>header{margin-bottom:14px}.venue-announcements header span{color:var(--teal);font:700 8px var(--font-data)}.announcement-title{display:grid;gap:4px}.announcement-title strong{color:var(--brand-deep)}.announcement-title span,.muted{color:var(--ink-muted);font-size:8px}.venue-form-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0 12px}.venue-form-grid .ant-input-number{width:100%}.venue-checks{display:flex;gap:14px;flex-wrap:wrap}@media(max-width:720px){.venue-actions,.venue-link-grid,.venue-export,.venue-form-grid{display:grid;grid-template-columns:1fr}.venue-actions>*{width:100%}}