chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
import { Button, Tag, message } from 'antd';
import { CheckOutlined } from '@ant-design/icons';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useState } from 'react';
import dayjs from 'dayjs';
import { getAlertClip, listAlerts, markAlertRead, markAllRead, type Alert } from '../dal/alert';
const LEVEL_MAP: Record<string, { color: string; text: string }> = {
info: { color: 'blue', text: '提示' },
warn: { color: 'gold', text: '警告' },
danger: { color: 'red', text: '严重' },
'1': { color: 'blue', text: '一级' },
'2': { color: 'gold', text: '二级' },
'3': { color: 'orange', text: '三级' },
'4': { color: 'red', text: '四级' },
'5': { color: 'red', text: '五级' },
};
const levelOf = (alert: Alert) => alert.level || alert.severity || 'info';
const isRead = (alert: Alert) => alert.read ?? alert.acknowledged ?? false;
export default function AlertPage() {
const [, setTick] = useState(0);
const columns: ProColumns<Alert>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{
title: '等级',
dataIndex: 'level',
valueType: 'select',
valueEnum: {
info: { text: '提示', status: 'Default' },
warn: { text: '警告', status: 'Warning' },
danger: { text: '严重', status: 'Error' },
},
render: (_, r) => {
const l = LEVEL_MAP[levelOf(r)] || LEVEL_MAP.info;
return <Tag color={l.color}>{l.text}</Tag>;
},
},
{ title: '标题', dataIndex: 'title' },
{ title: '内容', dataIndex: 'message', search: false, ellipsis: true, render: (_, r) => r.message || r.content },
{ title: '设备', dataIndex: 'deviceKey', search: false },
{
title: '时间',
dataIndex: 'triggeredAt',
search: false,
valueType: 'dateTime',
render: (_, r) => dayjs(r.triggeredAt || r.createdAt).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '状态',
dataIndex: 'acknowledged',
search: false,
render: (_, r) =>
isRead(r) ? <Tag></Tag> : <Tag color="red"></Tag>,
},
{
title: '操作',
valueType: 'option',
render: (_, r) => [
!isRead(r) && (
<a
key="ack"
onClick={async () => {
await markAlertRead(r.id);
message.success('已标记为已读');
setTick((v) => v + 1);
}}
>
</a>
),
<a
key="clip"
onClick={async () => {
try {
const clip = await getAlertClip(r.id);
message.info(clip.playbackUrl ? `告警视频:${clip.playbackUrl}` : '暂无告警视频地址');
} catch {
message.info('暂无告警视频');
}
}}
>
</a>,
],
},
];
return (
<ProTable<Alert>
rowKey="id"
columns={columns}
request={async (p) => {
const res = await listAlerts(p);
return { data: res.items, total: res.total, success: true };
}}
toolBarRender={() => [
<Button
key="all"
icon={<CheckOutlined />}
onClick={async () => {
await markAllRead();
message.success('全部已读');
setTick((v) => v + 1);
}}
>
</Button>,
]}
/>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import { Card, Col, Row, Switch, Tag, Button, Space, message, Spin, Empty, Statistic } from 'antd';
import { ApiOutlined, ReloadOutlined, InfoCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { get, post } from '../api/http';
interface Device {
id: string;
deviceKey?: string;
name: string;
kind?: string;
onlineStatus?: string;
status?: string;
}
interface MetricRecord {
metric: string;
value: number;
timestamp: string;
}
const Control = () => {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [latestData, setLatestData] = useState<Record<string, MetricRecord[]>>({});
const [sending, setSending] = useState<string>('');
// 本地开关状态,提供即时视觉反馈
const [switchOverride, setSwitchOverride] = useState<Record<string, boolean>>({});
const fetchDevices = useCallback(async () => {
setLoading(true);
try {
const data = await get<Device[] | { items: Device[] }>('/devices', { params: { kind: 'actuator' } });
const items = Array.isArray(data) ? data : data.items ?? [];
setDevices(items);
// 拉取每个设备的最新数据
for (const d of items) {
const key = d.deviceKey || d.id;
try {
const metrics = await get<MetricRecord[]>(`/telemetry/${key}/latest`, { silent: true });
setLatestData((prev) => ({ ...prev, [key]: Array.isArray(metrics) ? metrics : [] }));
} catch {}
}
} catch {
message.error('加载设备列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchDevices();
const timer = setInterval(fetchDevices, 15000);
return () => clearInterval(timer);
}, [fetchDevices]);
const sendCommand = async (device: Device, action: 'on' | 'off' | 'info' | 'statistic') => {
const key = device.deviceKey || device.id;
setSending(device.id + action);
// 即时更新本地开关状态
if (action === 'on') setSwitchOverride((prev) => ({ ...prev, [key]: true }));
if (action === 'off') setSwitchOverride((prev) => ({ ...prev, [key]: false }));
try {
await post(`/devices/${device.id}/plug/${action}`);
const label = action === 'on' ? '通电' : action === 'off' ? '断电' : action === 'info' ? '查询信息' : '查询电量';
message.success(`${label}指令已发送`);
setTimeout(fetchDevices, 3000);
} catch {
message.error('指令发送失败');
// 失败时恢复本地状态
setSwitchOverride((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
setSending('');
};
const getMetric = (deviceKey: string, metric: string) => {
const records = latestData[deviceKey];
if (!records) return undefined;
const rec = records.find((r) => r.metric === metric);
return rec ? Number(rec.value) : undefined;
};
const isOnline = (d: Device) => d.onlineStatus === 'online' || d.status === 'online';
if (!loading && devices.length === 0) {
return <Empty description="暂无控制器设备" />;
}
return (
<Spin spinning={loading}>
<Row gutter={[16, 16]}>
{devices.map((d) => {
const key = d.deviceKey || d.id;
const online = isOnline(d);
const metricKey = getMetric(key, 'key');
// 优先使用本地覆盖状态,否则用遥测数据
const powerOn = key in switchOverride ? switchOverride[key] : metricKey === 1;
return (
<Col key={d.id} span={8}>
<Card
title={
<Space>
<ApiOutlined />
{d.name}
</Space>
}
extra={
<Tag color={online ? 'green' : 'default'}>{online ? '在线' : '离线'}</Tag>
}
>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{ marginBottom: 8 }}>
<ThunderboltOutlined style={{ fontSize: 48, color: powerOn ? '#1890ff' : '#ccc' }} />
</div>
<Switch
checked={powerOn}
checkedChildren="通电"
unCheckedChildren="断电"
disabled={!online}
loading={sending === d.id + 'on' || sending === d.id + 'off'}
onChange={(checked) => sendCommand(d, checked ? 'on' : 'off')}
style={{ transform: 'scale(1.2)' }}
/>
</div>
<Row gutter={16}>
<Col span={8}>
<Statistic title="电压" value={online ? (getMetric(key, 'voltage') ?? '--') : '--'} precision={1} suffix="V" />
</Col>
<Col span={8}>
<Statistic title="功率" value={online ? (getMetric(key, 'power') ?? '--') : '--'} precision={2} suffix="W" />
</Col>
<Col span={8}>
<Statistic title="电量" value={online ? (getMetric(key, 'energy') ?? '--') : '--'} precision={3} suffix="kWh" />
</Col>
</Row>
{!online && (
<div style={{ marginTop: 8, fontSize: 12, color: '#999', textAlign: 'center' }}>
线
</div>
)}
<Space style={{ marginTop: 16, width: '100%', justifyContent: 'center' }}>
<Button
size="small"
icon={<InfoCircleOutlined />}
disabled={!online}
loading={sending === d.id + 'info'}
onClick={() => sendCommand(d, 'info')}
>
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
disabled={!online}
loading={sending === d.id + 'statistic'}
onClick={() => sendCommand(d, 'statistic')}
>
</Button>
</Space>
</Card>
</Col>
);
})}
</Row>
</Spin>
);
};
export default Control;
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { Card, Col, Row, Tag, Spin } from 'antd';
import ReactECharts from 'echarts-for-react';
import { StatCard } from '../components/StatCard';
import {
fetchDeviceSummary,
fetchRealtime,
fetchRooms,
fetchTrend,
type RealtimeMetric,
type Room,
type TrendPoint,
} from '../dal/dashboard';
const METRIC_UNIT: Record<string, string> = {
temp: '℃',
humidity: '%',
co2: 'ppm',
light: 'lux',
ph: '',
};
const mockRooms: Room[] = [
{ id: 'room-01', name: '主蚕房1号', status: 'active' },
{ id: 'room-02', name: '主蚕房2号', status: 'active' },
{ id: 'room-03', name: '育幼蚕房', status: 'active' },
{ id: 'room-04', name: '备用蚕房', status: 'inactive' },
];
export default function DashboardPage() {
const [loading, setLoading] = useState(true);
const [metrics, setMetrics] = useState<RealtimeMetric[]>([]);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [summary, setSummary] = useState({ total: 0, online: 0, offline: 0, alarm: 0, running: 0, inactive: 0 });
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const [m, t, r, s] = await Promise.all([
fetchRealtime().catch(() => []),
fetchTrend(24).catch(() => []),
fetchRooms().catch(() => []),
fetchDeviceSummary().catch(() => ({ total: 0, online: 0, offline: 0, alarm: 0, running: 0, inactive: 0 })),
]);
setMetrics(m);
setTrend(t);
setRooms(r);
setSummary(s);
} finally {
setLoading(false);
}
};
load();
const timer = setInterval(load, 30000);
return () => clearInterval(timer);
}, []);
const metricCards = (metrics.length
? metrics
: [
{ key: 'temp', name: '温度', value: 24.6, status: 'normal' },
{ key: 'humidity', name: '湿度', value: 72, status: 'warn' },
{ key: 'co2', name: 'CO₂', value: 480, status: 'normal' },
{ key: 'light', name: '光照', value: 320, status: 'normal' },
]
).map((m) => ({
title: m.name,
value: m.value,
suffix: 'unit' in m ? m.unit : METRIC_UNIT[m.key as string] || '',
color: m.status === 'normal' ? '#16a34a' : m.status === 'warn' ? '#d97706' : '#dc2626',
}));
const chartData = trend.length ? trend : (() => {
const arr: TrendPoint[] = [];
const now = Date.now();
for (let i = 24; i >= 0; i--) {
arr.push({
time: new Date(now - i * 3600 * 1000).toLocaleTimeString().slice(0, 5),
temp: +(22 + Math.random() * 6).toFixed(1),
humidity: +(65 + Math.random() * 15).toFixed(0),
co2: +(420 + Math.random() * 80).toFixed(0),
});
}
return arr;
})();
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['温度', '湿度', 'CO₂'] },
grid: { left: 40, right: 20, top: 40, bottom: 40 },
xAxis: { type: 'category', data: chartData.map((d) => d.time) },
yAxis: [
{ type: 'value', name: '温度/湿度', position: 'left' },
{ type: 'value', name: 'CO₂(ppm)', position: 'right' },
],
series: [
{
name: '温度',
type: 'line',
smooth: true,
data: chartData.map((d) => d.temp),
lineStyle: { color: '#f5222d' },
},
{
name: '湿度',
type: 'line',
smooth: true,
data: chartData.map((d) => d.humidity),
lineStyle: { color: '#1677ff' },
},
{
name: 'CO₂',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: chartData.map((d) => d.co2),
lineStyle: { color: '#722ed1' },
},
],
};
const roomList = rooms.length ? rooms : mockRooms;
return (
<Spin spinning={loading}>
<StatCard items={metricCards} />
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={16}>
<Card title="最近24小时趋势曲线">
<ReactECharts option={option} style={{ height: 320 }} />
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="蚕房状态概览">
<div style={{ marginBottom: 16 }}>
<span style={{ color: '#16a34a', fontWeight: 600 }}>{summary.running || roomList.filter((d) => d.status !== 'inactive').length}</span> /
<span style={{ color: '#888', fontWeight: 600 }}>{summary.inactive || roomList.filter((d) => d.status === 'inactive').length}</span> /
<span style={{ color: '#dc2626', fontWeight: 600 }}>{summary.alarm}</span> /
{summary.total || roomList.length}
</div>
<Row gutter={[8, 8]}>
{roomList.map((room) => {
const running = room.status !== 'inactive';
return (
<Col span={12} key={room.id}>
<Card size="small" hoverable>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{room.name}</span>
<Tag color={running ? 'green' : 'default'}>{running ? '运行中' : '停用'}</Tag>
</div>
</Card>
</Col>
);
})}
</Row>
</Card>
</Col>
</Row>
</Spin>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { Button, Form, Input, Modal, Popconfirm, Select, Tag, Space, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { createDevice, deleteDevice, listDevices, sendDeviceCommand, updateDevice, type Device } from '../dal/device';
import { fetchRooms } from '../dal/dashboard';
const COMMANDS = [
{ label: '开启通风', value: 'fan_on' },
{ label: '关闭通风', value: 'fan_off' },
{ label: '开启加湿', value: 'humid_on' },
{ label: '关闭加湿', value: 'humid_off' },
{ label: '开灯', value: 'light_on' },
{ label: '关灯', value: 'light_off' },
];
export default function DevicePage() {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<Partial<Device>>();
const [editing, setEditing] = useState<Device | null>(null);
const [rooms, setRooms] = useState<{ id: string; name: string }[]>([]);
useEffect(() => { fetchRooms().then(setRooms).catch(() => {}); }, []);
const columns: ProColumns<Device>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '设备标识', dataIndex: 'deviceKey' },
{ title: '设备名称', dataIndex: 'name' },
{
title: '设备类型',
dataIndex: 'kind',
render: (_, r) => {
const kind = r.kind || r.type || '';
const map: Record<string, { label: string; color: string }> = {
sensor: { label: '传感器', color: 'blue' },
actuator: { label: '控制器', color: 'orange' },
controller: { label: '控制器', color: 'orange' },
gateway: { label: '网关', color: 'purple' },
};
const item = map[kind] || { label: kind || '未知', color: 'default' };
return <Tag color={item.color}>{item.label}</Tag>;
},
},
{ title: '所属蚕房', dataIndex: 'roomId', search: false, render: (_, r) => rooms.find(rm => rm.id === r.roomId)?.name || r.roomId || '-' },
{
title: '状态',
dataIndex: 'onlineStatus',
search: false,
render: (_, r) => {
const status = r.onlineStatus || r.status;
return (
<Tag color={status === 'online' ? 'green' : 'default'}>
{status === 'online' ? '在线' : '离线'}
</Tag>
);
},
},
{ title: '最后在线', dataIndex: 'lastSeen', search: false, valueType: 'dateTime' },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<Space key={`op-${r.id}`} size={4}>
<a
onClick={() => {
setEditing(r);
form.setFieldsValue({ ...r, kind: r.kind || r.type, roomId: r.roomId || r.houseId });
setModalOpen(true);
}}
>
</a>
<Popconfirm
title="确认删除?"
onConfirm={async () => {
await deleteDevice(r.id);
message.success('已删除');
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>
</Space>,
<Select
key={`cmd-${r.id}`}
style={{ width: 120, marginLeft: 8 }}
size="small"
placeholder="远程控制"
options={COMMANDS}
onChange={async (v) => {
try {
await sendDeviceCommand(r.deviceKey || r.id, v);
message.success('指令已下发');
} catch {
message.error('指令下发失败');
}
}}
/>,
],
},
];
return (
<>
<ProTable<Device>
rowKey="id"
columns={columns}
request={async (p) => {
const res = await listDevices(p);
return { data: res.items, total: res.total, success: true };
}}
toolBarRender={() => [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>,
]}
/>
<Modal
title={editing ? '编辑设备' : '添加设备'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={async () => {
const v = await form.validateFields();
if (editing) await updateDevice(editing.id, v);
else await createDevice(v);
message.success('保存成功');
setModalOpen(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item label="设备标识" name="deviceKey" rules={[{ required: true }]}>
<Input placeholder="如 SILK-001" />
</Form.Item>
<Form.Item label="设备名称" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="类型" name="kind" rules={[{ required: true }]}>
<Select
options={[
{ label: '传感器', value: 'sensor' },
{ label: '控制器', value: 'actuator' },
{ label: '网关', value: 'gateway' },
]}
/>
</Form.Item>
<Form.Item label="所属蚕房" name="roomId" rules={[{ required: true }]}>
<Select placeholder="选择蚕房" options={rooms.map(rm => ({ label: rm.name, value: rm.id }))} />
</Form.Item>
</Form>
</Modal>
</>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Select, Spin } from 'antd';
import ThunderboltOutlined from '@ant-design/icons/ThunderboltOutlined';
import ReactECharts from 'echarts-for-react';
import { get } from '../api/http';
interface MetricRecord {
metric: string;
value: number;
timestamp: string;
}
interface Device {
id: string;
deviceKey?: string;
name: string;
kind?: string;
onlineStatus?: string;
}
const METRIC_LABELS: Record<string, { label: string; unit: string; color: string }> = {
voltage: { label: '电压', unit: 'V', color: '#1890ff' },
current: { label: '电流', unit: 'A', color: '#52c41a' },
power: { label: '功率', unit: 'W', color: '#faad14' },
energy: { label: '电量', unit: 'kWh', color: '#eb2f96' },
key: { label: '通断', unit: '', color: '#722ed1' },
};
const TIME_RANGES = [
{ label: '最近 1 小时', value: 1 },
{ label: '最近 6 小时', value: 6 },
{ label: '最近 24 小时', value: 24 },
];
const Energy = () => {
const [devices, setDevices] = useState<Device[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string>('');
const [latest, setLatest] = useState<MetricRecord[]>([]);
const [history, setHistory] = useState<Record<string, { time: string; value: number }[]>>({});
const [loading, setLoading] = useState(false);
const [hours, setHours] = useState(1);
// 加载控制器设备列表
useEffect(() => {
get<Device[] | { items: Device[] }>('/devices', { params: { kind: 'actuator' }, silent: true })
.then((data) => {
const items = Array.isArray(data) ? data : data.items ?? [];
setDevices(items);
if (items.length > 0 && !selectedDevice) {
setSelectedDevice(items[0].deviceKey || items[0].id);
}
})
.catch(() => {});
}, []);
// 加载最新数据
const fetchLatest = useCallback(async () => {
if (!selectedDevice) return;
try {
const data = await get<MetricRecord[]>(`/telemetry/${selectedDevice}/latest`, { silent: true });
setLatest(Array.isArray(data) ? data : []);
} catch {}
}, [selectedDevice]);
// 加载历史图表数据
const fetchHistory = useCallback(async () => {
if (!selectedDevice) return;
setLoading(true);
const to = new Date();
const from = new Date(to.getTime() - hours * 3600 * 1000);
const metrics = ['voltage', 'current', 'power', 'energy'];
const results = await Promise.all(
metrics.map((m) =>
get<{ time: string; value: number }[]>(`/telemetry/${selectedDevice}/${m}/history`, {
params: { from: from.toISOString(), to: to.toISOString(), bucketMin: hours > 6 ? 10 : 2 },
silent: true,
}).catch(() => []),
),
);
const map: Record<string, { time: string; value: number }[]> = {};
metrics.forEach((m, i) => {
map[m] = results[i] || [];
});
setHistory(map);
setLoading(false);
}, [selectedDevice, hours]);
useEffect(() => {
fetchLatest();
fetchHistory();
const timer = setInterval(fetchLatest, 15000);
return () => clearInterval(timer);
}, [fetchLatest, fetchHistory]);
const getMetricValue = (metric: string) => {
const rec = latest.find((r) => r.metric === metric);
return rec ? Number(rec.value) : undefined;
};
const buildChartOption = (metric: string) => {
const data = history[metric] || [];
const config = METRIC_LABELS[metric];
return {
tooltip: { trigger: 'axis', formatter: (p: any) => `${p[0].name}<br/>${config.label}: ${p[0].value} ${config.unit}` },
xAxis: { type: 'category', data: data.map((d) => new Date(d.time).toLocaleTimeString().slice(0, 5)) },
yAxis: { type: 'value', name: config.unit },
series: [{ data: data.map((d) => Number(d.value)), type: 'line', smooth: true, areaStyle: { opacity: 0.1 }, itemStyle: { color: config.color } }],
grid: { left: 50, right: 20, top: 30, bottom: 30 },
};
};
const selected = devices.find((d) => (d.deviceKey || d.id) === selectedDevice);
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col>
<Select
style={{ width: 200 }}
placeholder="选择设备"
value={selectedDevice || undefined}
onChange={setSelectedDevice}
options={devices.map((d) => ({ label: d.name, value: d.deviceKey || d.id }))}
/>
</Col>
<Col>
<Select style={{ width: 150 }} value={hours} onChange={setHours} options={TIME_RANGES} />
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="电压"
value={getMetricValue('voltage') ?? '--'}
precision={1}
suffix="V"
prefix={<ThunderboltOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="电流" value={getMetricValue('current') ?? '--'} precision={3} suffix="A" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="功率" value={getMetricValue('power') ?? '--'} precision={2} suffix="W" />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="累计电量" value={getMetricValue('energy') ?? '--'} precision={4} suffix="kWh" />
</Card>
</Col>
</Row>
{selected && (
<div style={{ marginBottom: 16, color: '#888' }}>
: {selected.name} | : {selected.onlineStatus === 'online' ? '在线' : '离线'} | :{' '}
{getMetricValue('key') === 1 ? '通电' : '断电'}
</div>
)}
<Spin spinning={loading}>
<Row gutter={16}>
<Col span={12}>
<Card title="电压趋势">
<ReactECharts option={buildChartOption('voltage')} style={{ height: 250 }} />
</Card>
</Col>
<Col span={12}>
<Card title="电流趋势">
<ReactECharts option={buildChartOption('current')} style={{ height: 250 }} />
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={12}>
<Card title="功率趋势">
<ReactECharts option={buildChartOption('power')} style={{ height: 250 }} />
</Card>
</Col>
<Col span={12}>
<Card title="电量累计">
<ReactECharts option={buildChartOption('energy')} style={{ height: 250 }} />
</Card>
</Col>
</Row>
</Spin>
</div>
);
};
export default Energy;
+717
View File
@@ -0,0 +1,717 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ComponentType } from 'react';
import { Card, Col, Row, Tag, Button, Space, message, Spin, Empty, InputNumber, Modal, Alert, Result, Popconfirm } from 'antd';
import {
ApiOutlined,
BulbOutlined,
SendOutlined,
StopOutlined,
DeleteOutlined,
InfoCircleOutlined,
PoweroffOutlined,
ThunderboltOutlined,
CloudOutlined,
FireOutlined,
} from '@ant-design/icons';
import { get, post } from '../api/http';
interface Device {
id: string;
deviceKey?: string;
name: string;
onlineStatus?: string;
}
interface IRResult {
hasResult: boolean;
result?: {
action: string;
success: boolean;
no: number;
timestamp: string;
};
learnedCodes?: number[];
}
// 空调遥控按钮配置(顺序学习)
const AIRCON_BUTTONS = [
{ key: 'power-off', label: '关机', no: 1, icon: 'PoweroffOutlined' },
{ key: 'power-on', label: '开机', no: 2, icon: 'PoweroffOutlined' },
{ key: 'cooling', label: '制冷', no: 6, icon: 'CloudOutlined' },
{ key: 'heating', label: '制热', no: 7, icon: 'FireOutlined' },
{ key: 'dehumid', label: '除湿', no: 5, icon: 'ThunderboltOutlined' },
];
// 温度按钮配置(单独学习)
const TEMP_BUTTONS = [
{ key: 'temp-23', label: '23度', no: 3 },
{ key: 'temp-25', label: '25度', no: 4 },
{ key: 'temp-27', label: '27度', no: 8 },
{ key: 'temp-29', label: '29度', no: 9 },
];
// 图标名 -> 图标组件映射
const ICON_MAP: Record<string, ComponentType> = {
PoweroffOutlined,
ThunderboltOutlined,
CloudOutlined,
FireOutlined,
};
// 顺序学习的编号顺序(与 AIRCON_BUTTONS 的 no 字段一致)
const LEARN_SEQUENCE = [1, 2, 6, 7, 5];
// 所有可学习的按钮(用于查找按钮标签)
const ALL_BUTTONS = [...AIRCON_BUTTONS, ...TEMP_BUTTONS];
// 闪烁动画样式(正在学习的按钮高亮闪烁)
const blinkStyle = `
@keyframes ir-blink {
0%, 100% { background-color: #faad14; }
50% { background-color: #fffbe6; }
}
.ir-blinking {
animation: ir-blink 0.8s infinite;
}
`;
const IRControl = () => {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState('');
const [learnModal, setLearnModal] = useState<{ open: boolean; deviceId: string; no: number }>({ open: false, deviceId: '', no: 100 });
const [emitNo, setEmitNo] = useState<Record<string, number>>({});
const [irResults, setIrResults] = useState<Record<string, { action: string; success: boolean; no: number } | undefined>>({});
const [deviceInfo, setDeviceInfo] = useState<Record<string, { signal?: number; lastSeen?: string; learnedCodes?: number[] } | undefined>>({});
// 空调顺序学习状态:记录当前正在学习的设备与编号
const [learningDevice, setLearningDevice] = useState<{ deviceId: string; currentNo: number } | null>(null);
// 使用 ref 跟踪 learningDevice,避免轮询中的闭包陷阱
const learningDeviceRef = useRef<{ deviceId: string; currentNo: number } | null>(null);
// 空调按钮发射 loadingkey = deviceId + button.key
const [airconSending, setAirconSending] = useState<string>('');
// 同步更新 state 与 ref
const updateLearningDevice = (val: { deviceId: string; currentNo: number } | null) => {
learningDeviceRef.current = val;
setLearningDevice(val);
};
const fetchDevices = useCallback(async () => {
setLoading(true);
try {
const data = await get<Device[] | { items: Device[] }>('/devices');
const all = Array.isArray(data) ? data : data.items ?? [];
// 只显示红外控制器(名称包含"红外")
const items = all.filter((d) => d.name.includes('红外'));
setDevices(items);
} catch {
message.error('加载设备列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchDevices();
}, [fetchDevices]);
const actionLabel = (action: string) => {
const labels: Record<string, string> = { learn: '学习', emit: '发射', learnCancel: '取消学习', erase: '擦除' };
return labels[action] || action;
};
// 擦除结果轮询:erase 命令可能不返回响应,短时间轮询确认
const pollEraseResult = async (deviceId: string) => {
const startTime = Date.now();
for (let i = 0; i < 5; i++) {
await new Promise((r) => setTimeout(r, 2000));
try {
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
if (data.hasResult && data.result) {
const resultTime = new Date(data.result.timestamp).getTime();
if (resultTime >= startTime && data.result.action === 'erase') {
if (data.result.success) {
message.success('擦除成功');
} else {
message.warning('设备已进入擦除模式,本地列表已清空');
}
return;
}
}
} catch {}
}
// 超时:设备可能不返回 erase 响应,本地列表已清空
message.info('设备未响应擦除结果,本地红外码列表已清空');
};
// 轮询红外操作结果(原有功能:用于手动学习/发射/擦除)
const pollIRResult = async (deviceId: string, action: string, no?: number) => {
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 2000));
try {
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
if (data.hasResult && data.result) {
// 检查是否是本次操作的结果(action 匹配,且时间戳在最近 30 秒内)
const resultTime = new Date(data.result.timestamp).getTime();
if (Date.now() - resultTime < 30000) {
if (data.result.action === action && (no === undefined || data.result.no === no || data.result.no === 0)) {
// success=false 表示设备已进入操作模式(如进入学习/擦除模式),不是真正失败,继续轮询
if (!data.result.success) {
continue;
}
setIrResults((prev) => ({ ...prev, [deviceId]: data.result }));
// 擦除成功后清空本地已学习红外码列表
if (action === 'erase') {
setDeviceInfo((prev) => {
const curInfo = prev?.[deviceId];
return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [] } };
});
}
message.success(`${actionLabel(data.result.action)}成功${data.result.no ? `(编号 ${data.result.no}` : ''}`);
return;
}
}
}
} catch {}
}
message.warning(`${actionLabel(action)}等待结果超时,请重试`);
};
// 轮询设备信息(info 响应后 signal 会被入库)
const pollDeviceInfo = async (device: Device) => {
const key = device.deviceKey || device.id;
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 2000));
try {
// 查询遥测数据(signal
const telemetry = await get<{ metric: string; value: number; timestamp: string }[]>(
`/telemetry/${key}/latest`, { silent: true }
);
// 查询已学习的红外码
const irStatus = await get<{ hasResult: boolean; learnedCodes?: number[] }>(
`/devices/${device.id}/ir/status`, { silent: true }
);
const signalRec = Array.isArray(telemetry) ? telemetry.find((r) => r.metric === 'signal') : undefined;
if (signalRec || irStatus.learnedCodes) {
setDeviceInfo((prev) => ({
...prev,
[device.id]: {
signal: signalRec?.value,
lastSeen: signalRec?.timestamp,
learnedCodes: irStatus.learnedCodes || [],
},
}));
if (signalRec) {
const codes = irStatus.learnedCodes || [];
message.success(`设备信息已更新:信号 ${signalRec.value},已学习红外码 ${codes.length}`);
}
return;
}
} catch {}
}
message.warning('设备未响应,可能已离线');
};
// 原有通用指令发送(左侧按钮使用)
const sendIR = async (device: Device, action: 'learn' | 'emit' | 'cancel' | 'erase' | 'info', no?: number) => {
const key = device.id + action + (no ?? '');
setSending(key);
// 清除之前的结果
setIrResults((prev) => ({ ...prev, [device.id]: undefined }));
try {
if (action === 'learn' && no !== undefined) {
await post(`/devices/${device.id}/ir/learn`, { no });
message.success(`学习指令已发送(编号 ${no}),请将遥控器对准设备按键`);
setLearnModal({ open: true, deviceId: device.id, no });
pollIRResult(device.id, 'learn', no);
} else if (action === 'emit' && no !== undefined) {
await post(`/devices/${device.id}/ir/emit`, { no });
message.success(`发射指令已发送(编号 ${no}`);
pollIRResult(device.id, 'emit', no);
} else if (action === 'cancel') {
await post(`/devices/${device.id}/ir/cancel`);
message.success('取消学习指令已发送');
setLearnModal({ ...learnModal, open: false });
// learnCancel 仅在学习模式下有响应,不轮询结果
} else if (action === 'erase') {
await post(`/devices/${device.id}/ir/erase`);
// 立即清空本地已学习红外码列表
setDeviceInfo((prev) => {
const curInfo = prev?.[device.id];
return { ...prev, [device.id]: { ...curInfo, learnedCodes: [] } };
});
message.success('擦除指令已发送,已清空本地红外码列表');
// erase 可能像 learnCancel 一样不返回响应,短时间轮询确认
pollEraseResult(device.id);
} else if (action === 'info') {
await post(`/devices/${device.id}/plug/info`);
message.success('查询信息指令已发送,等待设备响应...');
// 轮询遥测数据(info 响应中的 signal 会被入库)
pollDeviceInfo(device);
}
} catch (err: any) {
const errMsg = err?.response?.data?.error || '指令发送失败';
message.error(errMsg);
}
setSending('');
};
// 空调按钮发射(右侧空调面板使用)
const emitAircon = async (deviceId: string, button: typeof AIRCON_BUTTONS[number]) => {
// 正在顺序学习中,不触发发射
if (learningDeviceRef.current && learningDeviceRef.current.deviceId === deviceId) {
return;
}
const key = deviceId + button.key;
setAirconSending(key);
try {
await post(`/devices/${deviceId}/ir/emit`, { no: button.no });
message.success(`${button.label}】指令已发送`);
} catch (err: any) {
const errMsg = err?.response?.data?.error || `${button.label}指令发送失败`;
message.error(errMsg);
}
setAirconSending('');
};
// 顺序学习轮询:循环查询学习结果,成功后自动进入下一个编号
// startTime 用于过滤旧结果,只接受该时间之后产生的学习结果
const pollSequentialLearn = async (deviceId: string, no: number, startTime: number) => {
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 2000));
// 通过 ref 检查是否已被取消或已切换到下一个编号
const cur = learningDeviceRef.current;
if (!cur || cur.deviceId !== deviceId || cur.currentNo !== no) {
return; // 已被取消或已切换
}
try {
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
if (data.hasResult && data.result) {
const resultTime = new Date(data.result.timestamp).getTime();
// 只接受 startTime 之后的结果,避免读到上一个编号的旧结果
// 设备学习成功时返回 no=0,失败时返回发送的编号,因此匹配条件需兼容 no=0
if (resultTime >= startTime && data.result.action === 'learn' && (data.result.no === no || data.result.no === 0)) {
const curButton = ALL_BUTTONS.find((b) => b.no === no);
if (data.result.success) {
// 学习成功,更新本地 learnedCodes
setDeviceInfo((prev) => {
const curInfo = prev?.[deviceId];
const codes = curInfo?.learnedCodes ?? [];
if (!codes.includes(no)) codes.push(no);
return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [...codes] } };
});
// 查找当前编号在学习序列中的位置,决定下一个学习的编号
const seqIndex = LEARN_SEQUENCE.indexOf(no);
const nextNo = seqIndex >= 0 && seqIndex + 1 < LEARN_SEQUENCE.length ? LEARN_SEQUENCE[seqIndex + 1] : null;
if (nextNo !== null) {
const nextStartTime = Date.now();
await post(`/devices/${deviceId}/ir/learn`, { no: nextNo });
updateLearningDevice({ deviceId, currentNo: nextNo });
const nextLabel = ALL_BUTTONS.find((b) => b.no === nextNo)?.label;
message.success(`${curButton?.label}】学习成功,请按下遥控器的【${nextLabel}】键`);
pollSequentialLearn(deviceId, nextNo, nextStartTime);
} else {
// 全部完成
updateLearningDevice(null);
message.success('全部学习完成');
}
return;
}
// success=false 表示设备已进入学习模式,等待用户按遥控器,继续轮询
}
}
} catch {}
}
// 超时:仅当仍处于当前学习状态时才提示
const cur = learningDeviceRef.current;
if (cur && cur.deviceId === deviceId && cur.currentNo === no) {
updateLearningDevice(null);
message.warning('学习超时,请重试');
}
};
// 开始顺序学习:从学习序列的第一个编号开始
const startSequentialLearn = async (deviceId: string) => {
try {
const firstNo = LEARN_SEQUENCE[0];
const startTime = Date.now();
await post(`/devices/${deviceId}/ir/learn`, { no: firstNo });
updateLearningDevice({ deviceId, currentNo: firstNo });
const firstLabel = ALL_BUTTONS.find((b) => b.no === firstNo)?.label;
message.success(`开始学习:请按下遥控器的【${firstLabel}】键`);
pollSequentialLearn(deviceId, firstNo, startTime);
} catch (err: any) {
const errMsg = err?.response?.data?.error || '学习指令发送失败';
message.error(errMsg);
}
};
// 取消顺序学习
const cancelSequentialLearn = async (deviceId: string) => {
try {
await post(`/devices/${deviceId}/ir/cancel`, {});
} catch {}
updateLearningDevice(null);
message.success('已取消学习');
};
// 温度按钮单独学习
const learnSingleTemp = async (deviceId: string, no: number, label: string) => {
// 如果正在顺序学习,不允许单独学习
if (learningDeviceRef.current !== null) {
message.warning('正在顺序学习中,请先取消');
return;
}
try {
const startTime = Date.now();
updateLearningDevice({ deviceId, currentNo: no });
await post(`/devices/${deviceId}/ir/learn`, { no });
message.success(`请按下遥控器的【${label}】键`);
// 轮询单次学习结果
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 2000));
const cur = learningDeviceRef.current as { deviceId: string; currentNo: number } | null;
if (!cur || cur.deviceId !== deviceId || cur.currentNo !== no) {
return; // 已被取消
}
try {
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
if (data.hasResult && data.result) {
const resultTime = new Date(data.result.timestamp).getTime();
if (resultTime >= startTime && data.result.action === 'learn' && (data.result.no === no || data.result.no === 0)) {
if (!data.result.success) {
continue; // 进入学习模式,继续等
}
// 学习成功
setDeviceInfo((prev) => {
const curInfo = prev?.[deviceId];
const codes = curInfo?.learnedCodes ?? [];
if (!codes.includes(no)) codes.push(no);
return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [...codes] } };
});
updateLearningDevice(null);
message.success(`${label}】学习成功`);
return;
}
}
} catch {}
}
// 超时
const cur = learningDeviceRef.current as { deviceId: string; currentNo: number } | null;
if (cur && cur.deviceId === deviceId && cur.currentNo === no) {
updateLearningDevice(null);
message.warning(`${label}】学习超时,请重试`);
}
} catch (err: any) {
updateLearningDevice(null);
const errMsg = err?.response?.data?.error || '学习指令发送失败';
message.error(errMsg);
}
};
const isOnline = (d: Device) => d.onlineStatus === 'online';
if (!loading && devices.length === 0) {
return <Empty description="暂无设备" />;
}
return (
<Spin spinning={loading}>
<style>{blinkStyle}</style>
<Row gutter={[16, 16]}>
{devices.map((d) => {
const online = isOnline(d);
const currentNo = emitNo[d.id] ?? 100;
const irResult = irResults[d.id];
const info = deviceInfo[d.id];
const learnedCodes = info?.learnedCodes ?? [];
// 当前设备是否处于顺序学习
const isLearning = learningDevice?.deviceId === d.id;
const learningNo = isLearning ? learningDevice!.currentNo : null;
return (
<Col key={d.id} span={24}>
<Card
title={<Space><ApiOutlined />{d.name}</Space>}
extra={<Tag color={online ? 'green' : 'default'}>{online ? '在线' : '离线'}</Tag>}
>
<Row gutter={[24, 0]}>
{/* 左侧:原有通用功能 */}
<Col span={12}>
{/* 操作结果反馈 */}
{irResult && (
<div style={{ marginBottom: 16 }}>
<Result
status={irResult.success ? 'success' : 'error'}
title={`${actionLabel(irResult.action)}${irResult.success ? '成功' : '失败'}`}
subTitle={irResult.no ? `红外码编号: ${irResult.no}` : undefined}
style={{ padding: '12px 0' }}
/>
</div>
)}
{/* 设备信息 */}
{info && (
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<div style={{ fontSize: 13, color: '#666' }}></div>
<div style={{ marginTop: 4 }}>
4G <Tag color={(info.signal ?? 0) >= 20 ? 'green' : (info.signal ?? 0) >= 10 ? 'orange' : 'red'}>{info.signal ?? '--'}/31</Tag>
</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{info.lastSeen ? new Date(info.lastSeen).toLocaleTimeString() : '--'}
</div>
<div style={{ marginTop: 8, fontSize: 13, color: '#666' }}>
{info.learnedCodes && info.learnedCodes.length > 0 ? (
<Space size={4} wrap style={{ marginTop: 4 }}>
{info.learnedCodes.slice().sort((a, b) => a - b).map((no) => (
<Tag key={no} color="blue" style={{ cursor: 'pointer' }} onClick={() => setEmitNo({ ...emitNo, [d.id]: no })}>
{no}
</Tag>
))}
</Space>
) : (
<span style={{ color: '#999' }}></span>
)}
</div>
</div>
)}
{/* 发射红外码 */}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space>
<InputNumber
min={1}
max={248}
value={currentNo}
onChange={(v) => setEmitNo({ ...emitNo, [d.id]: v ?? 100 })}
style={{ width: 80 }}
disabled={isLearning}
/>
<Button
type="primary"
icon={<SendOutlined />}
loading={sending === d.id + 'emit' + currentNo}
onClick={() => sendIR(d, 'emit', currentNo)}
disabled={isLearning}
>
</Button>
</Space>
</div>
{/* 学习红外码 */}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space>
<InputNumber
min={1}
max={248}
value={learnModal.no}
onChange={(v) => setLearnModal({ ...learnModal, no: v ?? 100 })}
style={{ width: 80 }}
disabled={isLearning}
/>
<Button
icon={<BulbOutlined />}
loading={sending === d.id + 'learn' + learnModal.no}
onClick={() => sendIR(d, 'learn', learnModal.no)}
disabled={isLearning}
>
</Button>
</Space>
</div>
{/* 其他操作 */}
<Space style={{ width: '100%', justifyContent: 'center' }}>
<Button
size="small"
icon={<StopOutlined />}
loading={sending === d.id + 'cancel'}
onClick={() => sendIR(d, 'cancel')}
>
</Button>
<Button
size="small"
icon={<InfoCircleOutlined />}
loading={sending === d.id + 'info'}
onClick={() => sendIR(d, 'info')}
>
</Button>
<Popconfirm
title="确认擦除全部红外码?"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => sendIR(d, 'erase')}
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
loading={sending === d.id + 'erase'}
>
</Button>
</Popconfirm>
</Space>
</Col>
{/* 右侧:空调遥控面板 */}
<Col span={12}>
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: 15 }}>
{isLearning && (
<Tag color="processing" style={{ marginLeft: 8 }}>...</Tag>
)}
</div>
{/* 开始学习 / 取消学习 按钮 */}
<div style={{ marginBottom: 12 }}>
{isLearning ? (
<Button
block
size="large"
danger
icon={<StopOutlined />}
style={{ height: 60, fontSize: 16, fontWeight: 500 }}
onClick={() => cancelSequentialLearn(d.id)}
>
</Button>
) : (
<Button
block
size="large"
type="primary"
icon={<BulbOutlined />}
style={{ height: 60, fontSize: 16, fontWeight: 500 }}
onClick={() => startSequentialLearn(d.id)}
>
</Button>
)}
</div>
{/* 空调控制按钮(顺序学习) */}
<Row gutter={[12, 12]}>
{AIRCON_BUTTONS.map((btn) => {
const Icon = ICON_MAP[btn.icon];
const learned = learnedCodes.includes(btn.no);
const isBlinking = isLearning && learningNo === btn.no;
const isLoading = airconSending === d.id + btn.key;
return (
<Col key={btn.key} span={12}>
<Button
block
size="large"
icon={Icon ? <Icon /> : undefined}
loading={isLoading}
className={isBlinking ? 'ir-blinking' : undefined}
style={{
height: 80,
fontSize: 16,
fontWeight: 500,
...(learned && !isBlinking
? { borderColor: '#52c41a', color: '#52c41a' }
: {}),
}}
onClick={() => emitAircon(d.id, btn)}
>
{btn.label}
</Button>
</Col>
);
})}
</Row>
{/* 温度按钮(单独学习) */}
<div style={{ marginTop: 16, marginBottom: 8, fontSize: 13, color: '#999' }}>
</div>
<Row gutter={[12, 12]}>
{TEMP_BUTTONS.map((btn) => {
const learned = learnedCodes.includes(btn.no);
const isBlinking = isLearning && learningNo === btn.no;
return (
<Col key={btn.key} span={6}>
<Button
block
size="large"
className={isBlinking ? 'ir-blinking' : undefined}
style={{
height: 60,
fontSize: 15,
fontWeight: 500,
...(learned && !isBlinking
? { borderColor: '#52c41a', color: '#52c41a' }
: {}),
}}
onClick={() => {
if (isBlinking) {
// 正在学习,取消
cancelSequentialLearn(d.id);
} else if (learned) {
// 已学习,发射
emitAircon(d.id, { ...btn, icon: '' });
} else {
// 未学习,开始单独学习
learnSingleTemp(d.id, btn.no, btn.label);
}
}}
>
{btn.label}
</Button>
</Col>
);
})}
</Row>
{isLearning && (
<div style={{ marginTop: 12, fontSize: 13, color: '#666' }}>
{ALL_BUTTONS.find((b) => b.no === learningNo)?.label}
</div>
)}
</Col>
</Row>
</Card>
</Col>
);
})}
</Row>
{/* 学习中提示弹窗(保留原有功能) */}
<Modal
open={learnModal.open}
title="红外码学习中..."
onCancel={() => {
post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {});
setLearnModal({ ...learnModal, open: false });
}}
footer={[
<Button key="cancel" onClick={() => {
post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {});
setLearnModal({ ...learnModal, open: false });
}}>
</Button>,
<Button key="done" type="primary" onClick={() => setLearnModal({ ...learnModal, open: false })}>
</Button>,
]}
>
<Alert
type="info"
showIcon
message={`正在学习编号 ${learnModal.no} 的红外码`}
description={'请将遥控器对准红外控制器,按一下对应按键。听到"嘀"一声表示学习成功。'}
/>
</Modal>
</Spin>
);
};
export default IRControl;
+45
View File
@@ -0,0 +1,45 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { Tag } from 'antd';
import dayjs from 'dayjs';
import { listLogs, type ControlLog } from '../dal/log';
export default function LogPage() {
const columns: ProColumns<ControlLog>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '设备', dataIndex: 'deviceName' },
{ title: '指令', dataIndex: 'command' },
{ title: '操作人', dataIndex: 'operator', search: false },
{
title: '结果',
dataIndex: 'success',
search: false,
render: (_, r) =>
r.success === undefined ? (
<Tag></Tag>
) : r.success ? (
<Tag color="green"></Tag>
) : (
<Tag color="red"></Tag>
),
},
{
title: '时间',
dataIndex: 'createdAt',
search: false,
valueType: 'dateTimeRange',
render: (_, r) => dayjs(r.createdAt).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<ProTable<ControlLog>
rowKey="id"
columns={columns}
request={async (p) => {
const res = await listLogs(p);
return { data: res.items, total: res.total, success: true };
}}
search={{ labelWidth: 'auto' }}
/>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Button, Card, Col, Form, Input, Row, Typography, App } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { authService } from '../services/auth';
const { Title, Text } = Typography;
export default function LoginPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const onFinish = async (v: { username: string; password: string }) => {
setLoading(true);
try {
await authService.login(v.username, v.password);
navigate('/dashboard');
} catch (err: any) {
const msg = err?.response?.data?.error || '登录失败,请重试';
message.error(typeof msg === 'string' ? msg : '登录失败,请重试');
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: '100vh',
background: 'linear-gradient(135deg,#1677ff 0%,#36cfc9 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Row>
<Col>
<Card style={{ width: 380, borderRadius: 12 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title level={3}></Title>
<Text type="secondary">Silkworm Environment Monitor</Text>
</div>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
<Input prefix={<UserOutlined />} placeholder="请输入用户名" autoComplete="username" />
</Form.Item>
<Form.Item label="密码" name="password" rules={[{ required: true }]}>
<Input.Password prefix={<LockOutlined />} placeholder="请输入密码" autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form>
</Card>
</Col>
</Row>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Button, Result } from 'antd';
import { useNavigate } from 'react-router-dom';
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在。"
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
</Button>
}
/>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { Button, Form, Input, Modal, Popconfirm, Select, Tag, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { listHouses, createHouse, updateHouse, deleteHouse, type SilkwormHouse } from '../dal/silkworm';
export default function SilkwormPage() {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<Partial<SilkwormHouse>>();
const [editing, setEditing] = useState<SilkwormHouse | null>(null);
const columns: ProColumns<SilkwormHouse>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '名称', dataIndex: 'name' },
{ title: '位置', dataIndex: 'location', search: false, render: (_, r) => r.location || '-' },
{ title: '容量(盒)', dataIndex: 'capacity', search: false, render: (_, r) => r.capacity ?? '-' },
{
title: '阶段',
dataIndex: 'stage',
valueType: 'select',
valueEnum: {
egg: { text: '卵期' },
larva: { text: '幼虫期' },
pupa: { text: '蛹期' },
moth: { text: '蛾期' },
},
render: (_, r) => {
const map: Record<string, string> = { egg: '卵期', larva: '幼虫期', pupa: '蛹期', moth: '蛾期' };
return r.stage ? (map[r.stage] || r.stage) : '-';
},
},
{
title: '状态',
dataIndex: 'status',
search: false,
render: (_, r) => {
const color = r.status === 'running' ? 'green' : r.status === 'alarm' ? 'red' : 'default';
const text = r.status === 'running' ? '运行中' : r.status === 'alarm' ? '告警' : '空闲';
return <Tag color={color}>{text}</Tag>;
},
},
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a
key="edit"
onClick={() => {
setEditing(r);
form.setFieldsValue(r);
setModalOpen(true);
}}
>
</a>,
<Popconfirm
key="del"
title="确认删除?"
onConfirm={async () => {
await deleteHouse(r.id);
message.success('已删除');
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
];
return (
<>
<ProTable<SilkwormHouse>
rowKey="id"
columns={columns}
search={{ labelWidth: 'auto' }}
request={async (params) => {
const res = await listHouses(params);
return { data: res.items, total: res.total, success: true };
}}
toolBarRender={() => [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>,
]}
/>
<Modal
title={editing ? '编辑蚕房' : '新建蚕房'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={async () => {
const v = await form.validateFields();
if (editing) await updateHouse(editing.id, v);
else await createHouse(v);
message.success('保存成功');
setModalOpen(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
<Form.Item label="位置" name="location" rules={[{ required: true, message: '请输入位置' }]}>
<Input placeholder="如:1号厂房东侧" />
</Form.Item>
<Form.Item label="容量(盒)" name="capacity" rules={[{ required: true, message: '请输入容量' }]}>
<Input type="number" min={0} placeholder="蚕盒数量" />
</Form.Item>
<Form.Item label="蚕阶段" name="stage" rules={[{ required: true, message: '请选择阶段' }]}>
<Select
placeholder="请选择阶段"
options={[
{ label: '卵期', value: 'egg' },
{ label: '幼虫期', value: 'larva' },
{ label: '蛹期', value: 'pupa' },
{ label: '蛾期', value: 'moth' },
]}
/>
</Form.Item>
</Form>
</Modal>
</>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Switch, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useState } from 'react';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { createThreshold, deleteThreshold, listThresholds, updateThreshold, type Threshold } from '../dal/threshold';
const METRICS = [
{ label: '温度', value: 'temperature' },
{ label: '湿度', value: 'humidity' },
{ label: 'CO₂', value: 'co2' },
{ label: '光照', value: 'light' },
];
const UNITS: Record<string, string> = {
temperature: '℃',
temp: '℃',
humidity: '%',
co2: 'ppm',
light: 'lux',
};
const metricText = (value?: string) => METRICS.find((item) => item.value === value)?.label || value || '-';
export default function ThresholdPage() {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<Partial<Threshold>>();
const [editing, setEditing] = useState<Threshold | null>(null);
const columns: ProColumns<Threshold>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '名称', dataIndex: 'name' },
{
title: '指标',
dataIndex: 'metric',
valueType: 'select',
valueEnum: Object.fromEntries(METRICS.map((m) => [m.value, { text: m.label }])),
render: (_, r) => metricText(r.metric || r.sensor?.metric),
},
{ title: '传感器ID', dataIndex: 'sensorId', search: false },
{ title: '下限', dataIndex: 'minValue', search: false, render: (_, r) => r.minValue ?? r.min },
{ title: '上限', dataIndex: 'maxValue', search: false, render: (_, r) => r.maxValue ?? r.max },
{ title: '防抖(秒)', dataIndex: 'debounceSeconds', search: false },
{ title: '级别', dataIndex: 'severity', search: false },
{
title: '启用',
dataIndex: 'enabled',
search: false,
valueType: 'switch',
},
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a
key="edit"
onClick={() => {
setEditing(r);
form.setFieldsValue({
...r,
metric: r.metric || r.sensor?.metric,
minValue: r.minValue ?? r.min,
maxValue: r.maxValue ?? r.max,
});
setModalOpen(true);
}}
>
</a>,
<Popconfirm
key="del"
title="确认删除?"
onConfirm={async () => {
await deleteThreshold(r.id);
message.success('已删除');
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
];
return (
<>
<ProTable<Threshold>
rowKey="id"
columns={columns}
request={async (p) => {
const res = await listThresholds(p);
return { data: res.items, total: res.total, success: true };
}}
toolBarRender={() => [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>,
]}
/>
<Modal
title={editing ? '编辑阈值' : '新建阈值'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={async () => {
const v = await form.validateFields();
v.unit = UNITS[v.metric as string] ?? v.unit;
if (editing) await updateThreshold(editing.id, v);
else await createThreshold(v);
message.success('保存成功');
setModalOpen(false);
}}
>
<Form form={form} layout="vertical" initialValues={{ enabled: true, debounceSeconds: 5, severity: 3 }}>
<Form.Item label="名称" name="name">
<Input />
</Form.Item>
<Form.Item label="指标" name="metric" rules={[{ required: true }]}>
<Select options={METRICS} />
</Form.Item>
<Form.Item label="传感器ID" name="sensorId" rules={[{ required: true }]}>
<Input placeholder="后端阈值接口需要 sensorId" />
</Form.Item>
<Form.Item label="下限" name="minValue" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="上限" name="maxValue" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="防抖(秒)" name="debounceSeconds">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item label="告警级别" name="severity">
<InputNumber style={{ width: '100%' }} min={1} max={5} />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
);
}
File diff suppressed because it is too large Load Diff