forked from wangziqi/gongxue-base
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
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;
|