From df71e81f3aa6ac93a5fcbb620b4cfc7c6444d45a Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 4 Oct 2026 08:23:03 +0800 Subject: [PATCH] feat(web): migrate classroom repair management --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/src/app/App.tsx | 2 + web-react/src/layout/AppShell.tsx | 4 ++ web-react/src/pages/ClassroomRepairsPage.tsx | 49 ++++++++++++++++++++ web-react/src/pages/classroom-repairs.css | 1 + 6 files changed, 58 insertions(+) create mode 100644 web-react/src/pages/ClassroomRepairsPage.tsx create mode 100644 web-react/src/pages/classroom-repairs.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 8fd7868..c1d2dbf 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -54,6 +54,7 @@ | `/course-adjustments` | 调停课管理 | Teacher、SuperAdmin、AcademicAdmin、CollegeAdmin | 原课次锁定、调课/停课/补课/代课申请、草稿提交、学院范围审核、冲突复核与通知定位 | verification | | `/classroom-reservations` | 教室借用与预约 | 已登录用户;CollegeAdmin 审核 | 空闲教室检索、课程/考试/已批准预约避让、申请撤销、学院范围审核与冲突复核 | verification | | `/venue-displays` | 场地信息展牌管理 | SuperAdmin、AcademicAdmin | 教室/楼宇展牌链接、批量 Excel、范围/优先级/有效期公告管理 | verification | +| `/classroom-repairs` | 教室报修与维修 | SuperAdmin、AcademicAdmin、MaintenanceAdmin、MaintenanceStaff | 工单筛选、分派/开工/完工、详情、设备档案与扫码报修二维码 | 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 4efd1d4..57354ed 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -53,6 +53,7 @@ npm.cmd run build - 迁移调停课申请与审核工作台,覆盖原课次锁定、调课/停课/补课/代课、草稿提交、冲突复核和通知定位。 - 迁移教室借用与预约,覆盖空闲教室检索、预约申请/撤销和学院审核闭环。 - 迁移场地信息展牌管理,支持展牌链接、批量导出与定向公告投放。 +- 迁移教室报修与维修后台,覆盖工单分派、维修状态闭环、设备档案与场地报修码。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 5b3b19d..238d274 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -52,6 +52,7 @@ 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 ClassroomRepairsPage = lazy(() => import('../pages/ClassroomRepairsPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -152,6 +153,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index f674b41..0236b2d 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -36,6 +36,7 @@ import { TeamOutlined, CarryOutOutlined, SettingOutlined, + ToolOutlined, } from '@ant-design/icons' import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd' import { useQuery } from '@tanstack/react-query' @@ -128,6 +129,9 @@ export default function AppShell() { { key: '/base-data/facilities', icon: , label: '教学场所' }, { key: '/base-data/course-categories', icon: , label: '课程分类' }, ] : []), + ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'MaintenanceAdmin', 'MaintenanceStaff'].includes(role)) ? [ + { key: '/classroom-repairs', icon: , label: '教室报修与维修' }, + ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ? [ { key: '/class-timetable', icon: , label: '课表查询中心' }, ] : []), diff --git a/web-react/src/pages/ClassroomRepairsPage.tsx b/web-react/src/pages/ClassroomRepairsPage.tsx new file mode 100644 index 0000000..e0510e2 --- /dev/null +++ b/web-react/src/pages/ClassroomRepairsPage.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { QrcodeOutlined, ReloadOutlined, ToolOutlined } from '@ant-design/icons' +import { Alert, App, Button, Descriptions, Empty, Form, Input, Modal, Select, Skeleton, Table, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import * as QRCode from 'qrcode' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './classroom-repairs.css' + +type RepairStatus = 1 | 2 | 3 | 4 +interface Ticket { id: string; classroomId: string; classroomName: string; buildingName: string; equipmentName?: string; faultItem: string; description: string; reporterName: string; reporterContact: string; status: RepairStatus | 'Submitted' | 'Assigned' | 'InProgress' | 'Completed'; assignedToUserId?: string; assigneeName?: string; startedAt?: string; completedAt?: string; repairResult?: string; createdAt: string } +interface Worker { id: string; displayName: string; userName: string; staffNumber?: string } +interface Room { id: string; name: string; buildingName: string } +interface Equipment { id: string; name: string; category: number; isAvailable: boolean; notes?: string } +interface EquipmentForm { name: string; category: number; notes?: string } +const statusValues: Record = { Submitted: 1, Assigned: 2, InProgress: 3, Completed: 4 } +const statusLabel: Record = { 1: '待分派', 2: '已分派', 3: '维修中', 4: '已完成' } +const statusColor: Record = { 1: 'default', 2: 'blue', 3: 'orange', 4: 'green' } +const equipmentCategories = [{ value: 1, label: '多媒体' }, { value: 2, label: '课桌椅' }, { value: 3, label: '水电暖' }, { value: 4, label: '空调' }, { value: 5, label: '其他' }] +const normalizedStatus = (value: Ticket['status']): RepairStatus => typeof value === 'number' ? value : statusValues[value] ?? 1 +const time = (value?: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' + +export default function ClassroomRepairsPage() { + const { message, modal } = App.useApp(); const user = useAuthStore((state) => state.user); const isManager = user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'MaintenanceAdmin'].includes(role)) ?? false + const [status, setStatus] = useState(); const [selected, setSelected] = useState(); const [assignOpen, setAssignOpen] = useState(false); const [completeOpen, setCompleteOpen] = useState(false); const [qrOpen, setQrOpen] = useState(false); const [qrRoomId, setQrRoomId] = useState(); const [qrImage, setQrImage] = useState(''); const [equipmentOpen, setEquipmentOpen] = useState(false); const [equipmentRoomId, setEquipmentRoomId] = useState(); const [busyId, setBusyId] = useState(); const [assignForm] = Form.useForm<{ assignedToUserId: string }>(); const [completeForm] = Form.useForm<{ repairResult: string }>(); const [equipmentForm] = Form.useForm() + const tickets = useQuery({ queryKey: ['classroom-repairs', status], queryFn: async () => (await http.get('/classroom-repairs', { params: status ? { status } : undefined })).data }) + const refs = useQuery({ queryKey: ['classroom-repair-references'], enabled: isManager, queryFn: async () => { const [workers, classrooms] = await Promise.all([http.get('/classroom-repairs/workers'), http.get('/classroom-repairs/classrooms')]); return { workers: workers.data, classrooms: classrooms.data } } }) + const equipment = useQuery({ queryKey: ['classroom-equipment', equipmentRoomId], enabled: equipmentOpen && !!equipmentRoomId, queryFn: async () => (await http.get(`/classroom-repairs/classrooms/${equipmentRoomId}/equipment`)).data }) + async function refresh() { await Promise.all([tickets.refetch(), isManager ? refs.refetch() : Promise.resolve(), equipmentRoomId ? equipment.refetch() : Promise.resolve()]) } + function openAssign(ticket: Ticket) { setSelected(ticket); assignForm.setFieldsValue({ assignedToUserId: ticket.assignedToUserId }); setAssignOpen(true) } + async function assign(values: { assignedToUserId: string }) { if (!selected) return; try { setBusyId(selected.id); await http.put(`/classroom-repairs/${selected.id}/assign`, values); message.success('维修员已分派。'); setAssignOpen(false); setSelected(undefined); await tickets.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + function start(ticket: Ticket) { modal.confirm({ title: '开始维修', content: `确认开始处理“${ticket.faultItem}”吗?`, okText: '开始维修', onOk: async () => { try { setBusyId(ticket.id); await http.put(`/classroom-repairs/${ticket.id}/start`); message.success('工单已进入维修中。'); await tickets.refetch() } catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setBusyId(undefined) } } }) } + function openComplete(ticket: Ticket) { setSelected(ticket); completeForm.resetFields(); setCompleteOpen(true) } + async function complete(values: { repairResult: string }) { if (!selected) return; try { setBusyId(selected.id); await http.put(`/classroom-repairs/${selected.id}/complete`, { repairResult: values.repairResult.trim() }); message.success('工单已完成,场地状态已同步更新。'); setCompleteOpen(false); setSelected(undefined); await tickets.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + async function generateQr(classroomId?: string) { if (!classroomId) return message.warning('请选择教室。'); try { const url = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, '')}/classroom-repairs/report/${classroomId}`; setQrImage(await QRCode.toDataURL(url, { width: 280, margin: 1 })); setQrRoomId(classroomId) } catch { message.error('二维码生成失败,请稍后重试。') } } + async function addEquipment(values: EquipmentForm) { if (!equipmentRoomId) return; try { setBusyId('equipment'); await http.post(`/classroom-repairs/classrooms/${equipmentRoomId}/equipment`, { ...values, name: values.name.trim(), notes: values.notes?.trim() || null }); message.success('设备已添加。'); equipmentForm.resetFields(); equipmentForm.setFieldValue('category', 1); await equipment.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + if (tickets.isLoading || (isManager && refs.isLoading)) return
+ if (tickets.isError || (isManager && refs.isError)) return
+ const rooms = refs.data?.classrooms ?? [] + return
{isManager && }{isManager && }} /> +
({ value: item.id, label: `${item.displayName}${item.staffNumber ? ` · ${item.staffNumber}` : ''}` }))} /> + { setCompleteOpen(false); setSelected(undefined) }} onOk={() => void completeForm.submit()} okText="完成工单" okButtonProps={{ color: 'green' }} confirmLoading={!!busyId}>
+ setQrOpen(false)}>
{!qrImage && ({ value: room.id, label: `${room.buildingName} · ${room.name}` }))} />{equipmentRoomId && <> size="small" rowKey="id" loading={equipment.isLoading} dataSource={equipment.data} pagination={false} columns={[{ title: '设备/设施', dataIndex: 'name' }, { title: '类别', width: 100, render: (_, row) => equipmentCategories.find((item) => item.value === row.category)?.label ?? '其他' }, { title: '可用状态', width: 100, render: (_, row) => {row.isAvailable ? '可用' : '不可用'} }, { title: '备注', dataIndex: 'notes' }]} />
}
+
+} diff --git a/web-react/src/pages/classroom-repairs.css b/web-react/src/pages/classroom-repairs.css new file mode 100644 index 0000000..f6568e9 --- /dev/null +++ b/web-react/src/pages/classroom-repairs.css @@ -0,0 +1 @@ +.classroom-repairs-page{gap:16px}.repair-header-actions,.repair-actions{display:flex;gap:6px;flex-wrap:wrap}.repair-board{border:1px solid var(--line);background:#fff}.repair-board>header{padding:14px;border-bottom:1px solid var(--line)}.repair-board>header .ant-select{width:150px}.repair-board .ant-table-row{cursor:pointer}.repair-place{display:grid;gap:4px}.repair-place strong,.repair-detail-state strong{color:var(--brand-deep)}.repair-place span,.muted{color:var(--ink-muted);font-size:12px}.repair-detail-state{display:flex;align-items:center;gap:10px;margin-bottom:16px}.repair-qr{display:grid;justify-items:center;gap:14px}.repair-qr .ant-select,.repair-qr .ant-btn{width:100%}.repair-qr img{width:280px;max-width:100%}.repair-qr p{margin:0;text-align:center;color:var(--ink-muted);line-height:1.7}.equipment-manager{display:grid;gap:14px}.equipment-add{display:grid;grid-template-columns:1.7fr 120px 1fr auto;align-items:end;gap:10px}.equipment-add .ant-form-item{margin-bottom:0}@media(max-width:720px){.repair-header-actions{display:grid;grid-template-columns:1fr}.repair-header-actions>*{width:100%}.equipment-add{grid-template-columns:1fr}.repair-actions{flex-wrap:nowrap}.repair-actions .ant-btn{padding-inline:3px}}