Files
gongxue-base/apps/admin/src/pages/AttendanceDevices.tsx

216 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;