feat(web): migrate venue display management
This commit is contained in:
1 parent
8e24bc13b6
commit
2ae8fa6b64
6 files changed
+39
No files matched your search
@@ -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() {
|
||||
<Route path="/teacher-timetable/:teacherId" element={<TimetableQueryPage />} />
|
||||
<Route path="/course-adjustments" element={<CourseAdjustmentsPage />} />
|
||||
<Route path="/classroom-reservations" element={<ClassroomReservationsPage />} />
|
||||
<Route path="/venue-displays" element={<VenueDisplaysPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -122,6 +122,7 @@ export default function AppShell() {
|
||||
{ key: '/campus-wallets', icon: <WalletOutlined />, label: '学生余额管理' },
|
||||
] : []),
|
||||
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [
|
||||
{ key: '/venue-displays', icon: <FundProjectionScreenOutlined />, label: '场地信息展牌' },
|
||||
{ key: '/base-data/organization', icon: <ApartmentOutlined />, label: '组织机构' },
|
||||
{ key: '/base-data/terms', icon: <CalendarOutlined />, label: '学年学期' },
|
||||
{ key: '/base-data/facilities', icon: <EnvironmentOutlined />, label: '教学场所' },
|
||||
|
||||
@@ -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<AnnouncementForm>(); const [editing, setEditing] = useState<Announcement>(); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [classroomIds, setClassroomIds] = useState<string[]>([]); const [buildingIds, setBuildingIds] = useState<string[]>([]); const [classroomId, setClassroomId] = useState<string>(); const [buildingId, setBuildingId] = useState<string>(); const scope = Form.useWatch('scope', form) ?? 1
|
||||
const refs = useQuery({ queryKey: ['venue-display-options'], queryFn: async () => (await http.get<Options>('/venue-display-announcements/options')).data }); const list = useQuery({ queryKey: ['venue-display-announcements'], queryFn: async () => (await http.get<Announcement[]>('/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 <main className="page-container"><Skeleton active paragraph={{ rows: 10 }} /></main>; if (refs.isError || list.isError) return <main className="page-container"><Alert type="error" showIcon message="场地信息展牌无法加载" description={apiErrorMessage(refs.error ?? list.error)} /></main>
|
||||
return <main className="page-container venue-displays-page"><PageHeader kicker="SPACE DISPLAY" title="场地信息展牌" description="生成教室与教学楼展屏链接,并按范围、优先级和有效期维护公开公告。" actions={<div className="venue-actions"><Button icon={<ReloadOutlined />} onClick={() => void Promise.all([refs.refetch(), list.refetch()])}>刷新</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}>发布公告</Button></div>} /><section className="venue-links"><h3>展牌链接</h3><div className="venue-link-grid"><label>教室当天课程<Select showSearch optionFilterProp="label" value={classroomId} onChange={setClassroomId} placeholder="选择教室" options={refs.data!.classrooms.map((item) => ({ value: item.id, label: `${item.buildingName} · ${item.name}` }))} />{classroomUrl && <Input readOnly value={classroomUrl} addonAfter={<Button type="link" icon={<CopyOutlined />} onClick={() => void copy(classroomUrl)}>复制</Button>} />}</label><label>教学楼空余教室<Select showSearch optionFilterProp="label" value={buildingId} onChange={setBuildingId} placeholder="选择教学楼" options={refs.data!.buildings.map((item) => ({ value: item.id, label: item.name }))} />{buildingUrl && <Input readOnly value={buildingUrl} addonAfter={<Button type="link" icon={<CopyOutlined />} onClick={() => void copy(buildingUrl)}>复制</Button>} />}</label></div><div className="venue-export"><Select mode="multiple" showSearch optionFilterProp="label" value={classroomIds} onChange={setClassroomIds} placeholder="批量选择教室" options={refs.data!.classrooms.map((item) => ({ value: item.id, label: `${item.buildingName} · ${item.name}` }))} /><Select mode="multiple" showSearch optionFilterProp="label" value={buildingIds} onChange={setBuildingIds} placeholder="批量选择教学楼" options={refs.data!.buildings.map((item) => ({ value: item.id, label: item.name }))} /><Button icon={<DownloadOutlined />} type="primary" onClick={() => void exportLinks()}>导出链接({classroomIds.length + buildingIds.length})</Button></div></section><section className="venue-announcements"><header><div><span>PUBLIC MESSAGE</span><h3>展屏公告</h3></div></header><Table<Announcement> rowKey="id" dataSource={list.data} pagination={false} scroll={{ x: 820 }} columns={[{ title: '公告', render: (_, row) => <div className="announcement-title"><strong>{row.title}</strong><span>{row.content}</span></div> }, { title: '投放范围', width: 180, render: (_, row) => `${scopeName(row.scope)} · ${targetName(row)}` }, { title: '状态', width: 130, render: (_, row) => <>{<Tag color={row.isPublished ? 'green' : 'default'}>{row.isPublished ? '已发布' : '草稿'}</Tag>}{row.isUrgent && <Tag color="red">紧急</Tag>}</> }, { title: '有效期', width: 200, render: (_, row) => <span className="muted">{row.startsAt ? new Date(row.startsAt).toLocaleString('zh-CN', { hour12: false }) : '立即'} 至 {row.endsAt ? new Date(row.endsAt).toLocaleString('zh-CN', { hour12: false }) : '长期'}</span> }, { title: '操作', width: 125, fixed: 'right', render: (_, row) => <><Button type="link" icon={<EditOutlined />} onClick={() => edit(row)}>编辑</Button><Button type="link" danger icon={<DeleteOutlined />} onClick={() => remove(row)}>删除</Button></> }]} /></section><Modal open={open} title={editing ? '编辑展屏公告' : '发布展屏公告'} width={680} okText="保存公告" confirmLoading={saving} onCancel={() => setOpen(false)} onOk={() => form.submit()}><Form form={form} layout="vertical" onFinish={(values) => void save(values)}><Form.Item name="title" label="公告标题" rules={[{ required: true, whitespace: true }]}><Input maxLength={120} showCount /></Form.Item><Form.Item name="content" label="公告内容" rules={[{ required: true, whitespace: true }]}><Input.TextArea rows={4} maxLength={1000} showCount /></Form.Item><div className="venue-form-grid"><Form.Item name="scope" label="投放范围"><Select options={scopes} onChange={() => form.setFieldValue('targetId', undefined)} /></Form.Item>{scope !== 1 && <Form.Item name="targetId" label="投放目标" rules={[{ required: true, message: '请选择投放目标' }]}><Select showSearch optionFilterProp="label" options={targets.map((item) => ({ value: item.id, label: 'buildingName' in item ? `${item.buildingName} · ${item.name}` : item.name }))} /></Form.Item>}<Form.Item name="priority" label="优先级"><InputNumber min={0} max={100} /></Form.Item><Form.Item name="startsAt" label="开始时间"><Input type="datetime-local" /></Form.Item><Form.Item name="endsAt" label="结束时间"><Input type="datetime-local" /></Form.Item></div><div className="venue-checks"><Form.Item name="isPublished" valuePropName="checked"><Checkbox>立即发布</Checkbox></Form.Item><Form.Item name="isPinned" valuePropName="checked"><Checkbox>置顶</Checkbox></Form.Item><Form.Item name="isUrgent" valuePropName="checked"><Checkbox>紧急高亮</Checkbox></ForLine truncated
|
||||
}
|
||||
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user