Files
zpark-ems/frontend/src/pages/Devices/index.tsx

325 lines
13 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, Row, Col, Statistic, Switch, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, AppstoreOutlined } from '@ant-design/icons';
import { getDevices, getDeviceTypes, getDeviceGroups, getDeviceStats, createDevice, updateDevice } from '../../services/api';
import { getDevicePhoto } from '../../utils/devicePhoto';
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
offline: { color: 'default', text: '离线' },
alarm: { color: 'red', text: '告警' },
maintenance: { color: 'orange', text: '维护' },
};
const protocolOptions = [
{ label: 'Modbus TCP', value: 'modbus_tcp' },
{ label: 'Modbus RTU', value: 'modbus_rtu' },
{ label: 'OPC UA', value: 'opc_ua' },
{ label: 'MQTT', value: 'mqtt' },
{ label: 'HTTP API', value: 'http_api' },
{ label: 'DL/T 645', value: 'dlt645' },
{ label: '图像采集', value: 'image' },
];
export default function Devices() {
const navigate = useNavigate();
const [data, setData] = useState<any>({ total: 0, items: [] });
const [stats, setStats] = useState<any>({ online: 0, offline: 0, alarm: 0, total: 0 });
const [deviceTypes, setDeviceTypes] = useState<any[]>([]);
const [deviceGroups, setDeviceGroups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingDevice, setEditingDevice] = useState<any>(null);
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
const loadDevices = useCallback(async (params?: Record<string, any>) => {
setLoading(true);
try {
const query = params || filters;
// Remove empty values
const cleanQuery: Record<string, any> = {};
Object.entries(query).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
});
const res = await getDevices(cleanQuery) as any;
// Enrich items with type name and group name from cached meta
if (res?.items) {
const typeMap = new Map(deviceTypes.map((t: any) => [t.code || t.id, t.name]));
const groupMap = new Map(deviceGroups.map((g: any) => [g.id, g.name]));
res.items = res.items.map((d: any) => ({
...d,
device_type_name: d.device_type_name || typeMap.get(d.device_type) || typeMap.get(d.device_type_id) || '-',
device_group_name: d.device_group_name || groupMap.get(d.group_id) || '-',
}));
}
setData(res);
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [filters, deviceTypes, deviceGroups]);
const loadMeta = async () => {
try {
const [types, groups, st] = await Promise.all([
getDeviceTypes(), getDeviceGroups(), getDeviceStats(),
]);
setDeviceTypes(types as any[]);
setDeviceGroups(groups as any[]);
const stData = st as any;
stData.total = (stData.online || 0) + (stData.offline || 0) + (stData.alarm || 0) + (stData.maintenance || 0);
setStats(stData);
} catch (e) { console.error(e); }
};
useEffect(() => { loadMeta(); }, []);
useEffect(() => { loadDevices(); }, [filters, loadDevices]);
const handleFilterChange = (key: string, value: any) => {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }));
};
const handlePageChange = (page: number, pageSize: number) => {
setFilters(prev => ({ ...prev, page, page_size: pageSize }));
};
const openAddModal = () => {
setEditingDevice(null);
form.resetFields();
form.setFieldsValue({ collect_interval: 15, is_active: true });
setShowModal(true);
};
const openEditModal = (record: any) => {
setEditingDevice(record);
form.setFieldsValue({
...record,
device_type_id: record.device_type_id,
device_group_id: record.device_group_id,
connection_params: record.connection_params ? JSON.stringify(record.connection_params, null, 2) : '',
});
setShowModal(true);
};
const handleSubmit = async (values: any) => {
try {
// Parse connection_params if provided as string
if (values.connection_params && typeof values.connection_params === 'string') {
try {
values.connection_params = JSON.parse(values.connection_params);
} catch {
message.error('连接参数JSON格式错误');
return;
}
}
if (editingDevice) {
await updateDevice(editingDevice.id, values);
message.success('设备更新成功');
} else {
await createDevice(values);
message.success('设备创建成功');
}
setShowModal(false);
form.resetFields();
loadDevices();
loadMeta();
} catch (e: any) {
message.error(e?.detail || '操作失败');
}
};
const columns = [
{ title: '', dataIndex: 'device_type', width: 50, render: (_: any, record: any) => (
<img src={getDevicePhoto(record.device_type || record.device_type_id)} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover' }} />
)},
{ title: '设备名称', dataIndex: 'name', width: 160, ellipsis: true, render: (name: string, record: any) => (
<a onClick={() => navigate(`/devices/${record.id}`)}>{name}</a>
)},
{ title: '设备编号', dataIndex: 'code', width: 130 },
{ title: '设备类型', dataIndex: 'device_type_name', width: 120, render: (v: string) => v ? <Tag icon={<AppstoreOutlined />} color="blue">{v}</Tag> : '-' },
{ title: '设备分组', dataIndex: 'device_group_name', width: 120 },
{ title: '型号', dataIndex: 'model', width: 120, ellipsis: true },
{ title: '厂商', dataIndex: 'manufacturer', width: 120, ellipsis: true },
{ title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' },
{ title: '位置', dataIndex: 'location', width: 120, ellipsis: true },
{ title: '协议', dataIndex: 'protocol', width: 100 },
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => {
const st = statusMap[s] || { color: 'default', text: s || '-' };
return <Tag color={st.color}>{st.text}</Tag>;
}},
{ title: '最近数据时间', dataIndex: 'last_data_time', width: 170 },
{ title: '操作', key: 'action', width: 120, fixed: 'right' as const, render: (_: any, record: any) => (
<Space>
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}></Button>
</Space>
)},
];
return (
<div>
{/* Stats Cards */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="设备总数" value={stats.total} prefix={<AppstoreOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="在线" value={stats.online} prefix={<CheckCircleOutlined />} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="离线" value={stats.offline} prefix={<CloseCircleOutlined />} valueStyle={{ color: '#999' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="告警" value={stats.alarm} prefix={<WarningOutlined />} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
</Row>
{/* Device Table */}
<Card size="small" title="设备列表" extra={
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}></Button>
}>
{/* Filters */}
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear placeholder="设备类型" style={{ width: 150 }}
options={deviceTypes.map((t: any) => ({ label: t.name, value: t.id }))}
onChange={v => handleFilterChange('device_type', v)}
/>
<Select
allowClear placeholder="设备分组" style={{ width: 150 }}
options={deviceGroups.map((g: any) => ({ label: g.name, value: g.id }))}
onChange={v => handleFilterChange('device_group', v)}
/>
<Select
allowClear placeholder="状态" style={{ width: 120 }}
options={[
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '告警', value: 'alarm' },
{ label: '维护', value: 'maintenance' },
]}
onChange={v => handleFilterChange('status', v)}
/>
<Input.Search
placeholder="搜索设备名称/编号" style={{ width: 220 }}
allowClear
onSearch={v => handleFilterChange('search', v)}
/>
</Space>
<Table
columns={columns} dataSource={data.items} rowKey="id"
loading={loading} size="small" scroll={{ x: 1500 }}
pagination={{
current: filters.page,
pageSize: filters.page_size,
total: data.total,
showSizeChanger: true,
showTotal: (total: number) => `${total} 台设备`,
onChange: handlePageChange,
}}
/>
</Card>
{/* Add/Edit Modal */}
<Modal
title={editingDevice ? '编辑设备' : '添加设备'}
open={showModal}
onCancel={() => { setShowModal(false); form.resetFields(); }}
onOk={() => form.submit()}
okText={editingDevice ? '保存' : '创建'}
cancelText="取消"
width={640}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Input placeholder="请输入设备名称" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="code" label="设备编号" rules={[{ required: true, message: '请输入设备编号' }]}>
<Input placeholder="请输入唯一设备编号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="device_type_id" label="设备类型">
<Select allowClear placeholder="选择设备类型"
options={deviceTypes.map((t: any) => ({ label: t.name, value: t.id }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="device_group_id" label="设备分组">
<Select allowClear placeholder="选择设备分组"
options={deviceGroups.map((g: any) => ({ label: g.name, value: g.id }))} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="model" label="型号">
<Input placeholder="设备型号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="manufacturer" label="厂商">
<Input placeholder="制造商" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="serial_number" label="序列号">
<Input placeholder="设备序列号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="rated_power" label="额定功率(kW)">
<InputNumber style={{ width: '100%' }} min={0} step={0.1} placeholder="额定功率" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="location" label="位置">
<Input placeholder="安装位置" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="protocol" label="通信协议">
<Select allowClear placeholder="选择协议" options={protocolOptions} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="collect_interval" label="采集间隔(秒)" initialValue={15}>
<InputNumber style={{ width: '100%' }} min={1} max={3600} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="is_active" label="启用" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item name="connection_params" label="连接参数(JSON)">
<Input.TextArea rows={3} placeholder='{"host": "192.168.1.100", "port": 502}' />
</Form.Item>
</Form>
</Modal>
</div>
);
}