feat: add attendance device SN classroom bindings
This commit is contained in:
@@ -31,6 +31,7 @@ const SchedulesPage = lazy(() => import('./pages/Schedules'));
|
||||
const RolesPage = lazy(() => import('./pages/Roles'));
|
||||
const PermissionsPage = lazy(() => import('./pages/Permissions'));
|
||||
const AttendancePage = lazy(() => import('./pages/Attendance'));
|
||||
const AttendanceDevicesPage = lazy(() => import('./pages/AttendanceDevices'));
|
||||
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
|
||||
const NotificationsPage = lazy(() => import('./pages/Notifications'));
|
||||
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
|
||||
@@ -240,6 +241,16 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<Route
|
||||
path="attendance-devices"
|
||||
element={
|
||||
<PermissionRoute permission="classroom:view">
|
||||
<AttendanceDevicesPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="attendance"
|
||||
element={
|
||||
|
||||
@@ -85,6 +85,7 @@ const SECTIONS: MenuSection[] = [
|
||||
children: [
|
||||
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
|
||||
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
|
||||
{ key: '/attendance-devices', label: '考勤机绑定', icon: 'attendance', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
||||
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
{ path: '/classrooms', permission: 'classroom:view' },
|
||||
{ path: '/attendance-devices', permission: 'classroom:view' },
|
||||
{ path: '/classroom-rentals', permission: 'rental:view' },
|
||||
{ path: '/organizations', permission: 'organization:view' },
|
||||
{ path: '/expenses', permission: 'expense:view' },
|
||||
|
||||
215
apps/admin/src/pages/AttendanceDevices.tsx
Normal file
215
apps/admin/src/pages/AttendanceDevices.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import PermissionButton from '../components/PermissionButton';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
interface ClassroomOption {
|
||||
id: number;
|
||||
name: string;
|
||||
building?: string | null;
|
||||
}
|
||||
|
||||
interface AttendanceDeviceRow {
|
||||
id: number;
|
||||
deviceSn: string;
|
||||
deviceName: string;
|
||||
classroomId: number;
|
||||
classroom?: ClassroomOption | null;
|
||||
status: 'active' | 'disabled';
|
||||
location?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
const statusMeta = {
|
||||
active: { text: '启用', color: 'green' },
|
||||
disabled: { text: '停用', color: 'default' },
|
||||
} as const;
|
||||
|
||||
const AttendanceDevicesPage: React.FC = () => {
|
||||
const [data, setData] = useState<AttendanceDeviceRow[]>([]);
|
||||
const [classrooms, setClassrooms] = useState<ClassroomOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [devices, classroomList] = await Promise.all([
|
||||
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
|
||||
api.get<ClassroomOption[]>('/classrooms'),
|
||||
]);
|
||||
setData(devices);
|
||||
setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载考勤机绑定失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const classroomOptions = useMemo(
|
||||
() => classrooms.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.building ? `${item.name}(${item.building})` : item.name,
|
||||
})),
|
||||
[classrooms],
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const text = keyword.trim().toLocaleLowerCase('zh-CN');
|
||||
if (!text) return data;
|
||||
return data.filter((item) => [
|
||||
item.deviceSn,
|
||||
item.deviceName,
|
||||
item.classroom?.name,
|
||||
item.location,
|
||||
].some((value) => (value || '').toLocaleLowerCase('zh-CN').includes(text)));
|
||||
}, [data, keyword]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active' });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: AttendanceDeviceRow) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
deviceSn: record.deviceSn,
|
||||
deviceName: record.deviceName,
|
||||
classroomId: record.classroomId,
|
||||
status: record.status,
|
||||
location: record.location,
|
||||
notes: record.notes,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.put(`/attendance-devices/${editing.id}`, values);
|
||||
message.success('考勤机绑定已更新');
|
||||
} else {
|
||||
await api.post('/attendance-devices', values);
|
||||
message.success('考勤机绑定已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
await loadData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/attendance-devices/${id}`);
|
||||
message.success('已删除绑定');
|
||||
await loadData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AttendanceDeviceRow> = [
|
||||
{ title: '设备名称', dataIndex: 'deviceName', width: 180 },
|
||||
{ title: 'SN 码', dataIndex: 'deviceSn', width: 220, render: (value) => <span style={{ fontFamily: 'monospace' }}>{value}</span> },
|
||||
{ title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}` },
|
||||
{ title: '位置', dataIndex: 'location', render: (value) => value || <span style={{ color: '#999' }}>—</span> },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (value: keyof typeof statusMeta) => <Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag> },
|
||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value) => value || <span style={{ color: '#999' }}>—</span> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<PermissionButton permission="classroom:edit" size="small" type="link" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm title="确定删除此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
|
||||
<PermissionButton permission="classroom:edit" size="small" danger>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索设备/SN/教室"
|
||||
style={{ width: 260 }}
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
<PermissionButton permission="classroom:edit" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加考勤机
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table<AttendanceDeviceRow>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
|
||||
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="deviceName" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
|
||||
<Input placeholder="如:彼岸游境_N1604" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deviceSn" label="SN 码" rules={[{ required: true, message: '请输入钉钉返回的 deviceSN' }]}>
|
||||
<Input placeholder="如:300419260325WN1604" />
|
||||
</Form.Item>
|
||||
<Form.Item name="classroomId" label="绑定教室" rules={[{ required: true, message: '请选择绑定教室' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={classroomOptions} placeholder="选择教室" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="active">
|
||||
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" label="位置">
|
||||
<Input placeholder="如:教学楼一楼东侧" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendanceDevicesPage;
|
||||
Reference in New Issue
Block a user