forked from wangziqi/gongxue-base
354 lines
10 KiB
TypeScript
354 lines
10 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 EditableCell from '../components/EditableCell';
|
||
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 saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
|
||
await api.put(`/attendance-devices/${record.id}`, { [field]: value });
|
||
message.success('已保存');
|
||
await loadData();
|
||
};
|
||
|
||
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,
|
||
render: (value: string, record) => (
|
||
<EditableCell
|
||
value={value}
|
||
required
|
||
permission="classroom:edit"
|
||
onSave={(next) => saveCell(record, 'deviceName', next)}
|
||
>
|
||
{value}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: 'SN 码',
|
||
dataIndex: 'deviceSn',
|
||
width: 220,
|
||
render: (value, record) => (
|
||
<EditableCell
|
||
value={value}
|
||
required
|
||
permission="classroom:edit"
|
||
onSave={(next) => saveCell(record, 'deviceSn', next)}
|
||
>
|
||
<span style={{ fontFamily: 'monospace' }}>{value}</span>
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '绑定教室',
|
||
dataIndex: ['classroom', 'name'],
|
||
width: 160,
|
||
render: (_value, record) => (
|
||
<EditableCell
|
||
value={record.classroomId}
|
||
editor="select"
|
||
options={classrooms.map((item) => ({
|
||
value: item.id,
|
||
label: item.building ? `${item.building} · ${item.name}` : item.name,
|
||
}))}
|
||
permission="classroom:edit"
|
||
required
|
||
onSave={(next) => saveCell(record, 'classroomId', next)}
|
||
>
|
||
{record.classroom?.name || `教室 ${record.classroomId}`}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '位置',
|
||
dataIndex: 'location',
|
||
render: (value, record) => (
|
||
<EditableCell
|
||
value={value}
|
||
permission="classroom:edit"
|
||
onSave={(next) => saveCell(record, 'location', next)}
|
||
>
|
||
{value || <span style={{ color: '#999' }}>—</span>}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (value: keyof typeof statusMeta, record) => (
|
||
<EditableCell
|
||
value={value}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'active', label: '启用' },
|
||
{ value: 'disabled', label: '停用' },
|
||
]}
|
||
permission="classroom:edit"
|
||
onSave={(next) => saveCell(record, 'status', next)}
|
||
>
|
||
<Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag>
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '备注',
|
||
dataIndex: 'notes',
|
||
ellipsis: true,
|
||
render: (value, record) => (
|
||
<EditableCell
|
||
value={value}
|
||
editor="textarea"
|
||
permission="classroom:edit"
|
||
onSave={(next) => saveCell(record, 'notes', next)}
|
||
>
|
||
{value || <span style={{ color: '#999' }}>—</span>}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
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;
|