feat: customer frontend, Sungrow collector fixes, real data (v1.2.0)
- Add frontend/ at root (no Three.js, no Charging, green #52c41a theme) - Fix Sungrow collector: add curPage/size params, unit conversion - Fix station-level dedup to prevent double-counting - Add shared token cache for API rate limit protection - Add .githooks/pre-commit, CLAUDE.md, .gitignore - Update docker-compose.override.yml frontend -> ./frontend - Pin bcrypt in requirements.txt - Add BUYOFF_RESULTS_2026-04-05.md (39/43 pass) - Data accuracy: 0.0% diff vs iSolarCloud Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
312
frontend/src/pages/Devices/index.tsx
Normal file
312
frontend/src/pages/Devices/index.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
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);
|
||||
setData(res as any);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
const loadMeta = async () => {
|
||||
try {
|
||||
const [types, groups, st] = await Promise.all([
|
||||
getDeviceTypes(), getDeviceGroups(), getDeviceStats(),
|
||||
]);
|
||||
setDeviceTypes(types as any[]);
|
||||
setDeviceGroups(groups as any[]);
|
||||
setStats(st as any);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user