import React, { useState, useMemo, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { classroomsSchema } from '../../api/schemas'; import { App, Table, Button, Modal, Form, Input, InputNumber, Select, Space, Tag, Popconfirm, Upload, Tooltip, Empty, } from 'antd'; import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined, } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useUserStore } from '../../store/user/userStore'; const statusMap: Record = { available: { text: '可用', color: 'green' }, in_use: { text: '使用中', color: 'blue' }, reserved: { text: '已预留', color: 'purple' }, maintenance: { text: '维护中', color: 'orange' }, archived: { text: '已归档', color: '#999' }, }; interface CurrentUsage { type: 'schedule' | 'rental'; title: string; startTime: string; endTime: string; } const typeColor: Record = { 大: 'volcano', 次大: 'geekblue', 小: 'cyan', }; const ClassroomsPage: React.FC = () => { const { modal } = App.useApp(); const { hasPermission } = usePermission(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [showArchived, setShowArchived] = useState(false); const [form] = Form.useForm(); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [saving, setSaving] = useState(false); const { data = [], isLoading, isFetching, } = useQuery({ queryKey: ['classrooms', showArchived], queryFn: async () => { try { return validateResponse( classroomsSchema, await api.get('/classrooms', { params: { includeArchived: showArchived } }), ); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); return []; } }, }); const loading = isLoading || isFetching; const saveMutation = useApiMutation( async (values: Record) => editing ? api.put(`/classrooms/${editing.id}`, values) : api.post('/classrooms', values), { invalidate: [['classrooms']] }, ); const saveCellMutation = useApiMutation( async ({ record, field, value }: { record: any; field: string; value: unknown }) => api.put(`/classrooms/${record.id}`, { [field]: value }), { invalidate: [['classrooms']] }, ); const archiveMutation = useApiMutation( async (id: number) => api.delete(`/classrooms/${id}`), { invalidate: [['classrooms']] }, ); const restoreMutation = useApiMutation( async (id: number) => api.put(`/classrooms/${id}/restore`), { invalidate: [['classrooms']] }, ); const purgeMutation = useApiMutation( async (id: number) => api.delete(`/classrooms/${id}/permanent`), { invalidate: [['classrooms']] }, ); const importMutation = useApiMutation( async (formData: FormData) => api.post('/classrooms/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }), { invalidate: [['classrooms']] }, ); const filteredData = useMemo(() => { let result = data; if (searchText) { const s = searchText.toLowerCase(); result = result.filter( (d: Record) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s)), ); } if (filterStatus) result = result.filter((d: Record) => d.effectiveStatus === filterStatus); return result; }, [data, searchText, filterStatus]); const handleSave = async () => { const values = await form.validateFields(); setSaving(true); try { await saveMutation.mutateAsync(values); message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); form.resetFields(); setEditing(null); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const saveCell = useCallback( async (record: any, field: string, value: unknown) => { try { await saveCellMutation.mutateAsync({ record, field, value }); message.success('已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [saveCellMutation], ); const handleArchive = useCallback( async (id: number) => { try { await archiveMutation.mutateAsync(id); message.success('已归档'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [archiveMutation], ); const handleRestore = useCallback( async (id: number) => { try { await restoreMutation.mutateAsync(id); message.success('已恢复'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [restoreMutation], ); const handlePurge = useCallback( (id: number, name: string) => { modal.confirm({ title: `永久删除教室「${name}」?`, content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', okText: '永久删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await purgeMutation.mutateAsync(id); message.success('已永久删除(不可恢复)'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }, [modal, purgeMutation], ); const handleDownloadTemplate = () => { const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; const token = useUserStore.getState().token; fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) .then((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '教室导入模板.xlsx'; a.click(); URL.revokeObjectURL(url); }) .catch(() => message.error('下载失败')); }; const columns = useMemo( () => [ { title: '教室名', width: 120, dataIndex: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name), render: (v: string, r: any) => ( saveCell(r, 'name', next)} > {v} ), }, { title: '楼栋', dataIndex: 'building', width: 80, render: (v: string, r: any) => ( saveCell(r, 'building', next)} > {v || '-'} ), }, { title: '楼层', dataIndex: 'floor', width: 80, render: (v: number, r: any) => ( saveCell(r, 'floor', next)} > {v ?? '-'} ), }, { title: '类型', width: 90, dataIndex: 'roomType', render: (v: string, r: any) => ( ({ value, label: value }))} permission="classroom:edit" disabled={r.status === 'archived'} onSave={(next) => saveCell(r, 'roomType', next)} > {v || '-'} ), }, { title: '容量', dataIndex: 'capacity', width: 80, render: (v: number, r: any) => ( saveCell(r, 'capacity', next)} > {v ?? '-'} ), }, { title: '状态', width: 100, dataIndex: 'status', render: ( _s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }, ) => { const effectiveStatus = record.effectiveStatus || record.status; return ( saveCell(record, 'status', next)} > {statusMap[effectiveStatus]?.text || effectiveStatus} ); }, }, { title: '操作', width: 180, render: (_: any, record: any) => ( {record.status === 'archived' ? ( <> handleRestore(record.id)}> } type="link" > 恢复 {hasPermission('classroom:purge') ? ( ) : null} ) : ( <> { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }} > 编辑 handleArchive(record.id)} okText="归档" cancelText="取消" > } > 归档 )} ), }, ], [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form], ); return (
setSearchText(v)} onChange={(e) => { if (!e.target.value) setSearchText(''); }} /> )}
); }; export default ClassroomsPage;