feat: enterprise-level enhancement — 12 modules complete
New modules: - Energy Quota Management (定额管理) - Cost/Expense Analysis with TOU pricing (费用分析) - Sub-item Energy Analysis (分项分析) - EV Charging Station Management (充电桩管理) — 8 models, 6 pages - Enhanced Energy Analysis — loss, YoY, MoM comparison - Alarm Analytics — trends, MTTR, top devices, rule toggle - Maintenance & Work Orders (运维管理) — inspections, repair orders, duty - Data Query Module (数据查询) - Equipment Topology (设备拓扑) - Management System (管理体系) — regulations, standards, processes Infrastructure: - Redis caching layer with decorator - Redis Streams data ingestion buffer - Hourly/daily/monthly aggregation engine - Rate limiting & request ID middleware - 6 Alembic migrations (003-008), 21 new tables - Extended seed data for all modules Stats: 120+ API routes, 12 pages, 27 tabs, 37 database tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,11 @@ import Reports from './pages/Reports';
|
||||
import Devices from './pages/Devices';
|
||||
import DeviceDetail from './pages/DeviceDetail';
|
||||
import SystemManagement from './pages/System';
|
||||
import Quota from './pages/Quota';
|
||||
import Charging from './pages/Charging';
|
||||
import Maintenance from './pages/Maintenance';
|
||||
import DataQuery from './pages/DataQuery';
|
||||
import Management from './pages/Management';
|
||||
import BigScreen from './pages/BigScreen';
|
||||
import BigScreen3D from './pages/BigScreen3D';
|
||||
import { isLoggedIn } from './utils/auth';
|
||||
@@ -51,6 +56,11 @@ function AppContent() {
|
||||
<Route path="alarms" element={<Alarms />} />
|
||||
<Route path="carbon" element={<Carbon />} />
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="quota" element={<Quota />} />
|
||||
<Route path="charging/*" element={<Charging />} />
|
||||
<Route path="maintenance" element={<Maintenance />} />
|
||||
<Route path="data-query" element={<DataQuery />} />
|
||||
<Route path="management" element={<Management />} />
|
||||
<Route path="system/*" element={<SystemManagement />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
"users": "User Management",
|
||||
"roles": "Roles & Permissions",
|
||||
"settings": "System Settings",
|
||||
"audit": "Audit Log"
|
||||
"audit": "Audit Log",
|
||||
"quota": "Quota Management",
|
||||
"charging": "Charging Management",
|
||||
"maintenance": "O&M Management",
|
||||
"dataQuery": "Data Query",
|
||||
"management": "Management System"
|
||||
},
|
||||
"header": {
|
||||
"alarmNotification": "Alarm Notifications",
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
"users": "用户管理",
|
||||
"roles": "角色权限",
|
||||
"settings": "系统设置",
|
||||
"audit": "审计日志"
|
||||
"audit": "审计日志",
|
||||
"quota": "定额管理",
|
||||
"charging": "充电管理",
|
||||
"maintenance": "运维管理",
|
||||
"dataQuery": "数据查询",
|
||||
"management": "管理体系"
|
||||
},
|
||||
"header": {
|
||||
"alarmNotification": "告警通知",
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined,
|
||||
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
|
||||
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
|
||||
BulbOutlined, BulbFilled,
|
||||
BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined,
|
||||
SearchOutlined, SolutionOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -41,6 +42,11 @@ export default function MainLayout() {
|
||||
{ key: '/alarms', icon: <AlertOutlined />, label: t('menu.alarms') },
|
||||
{ key: '/carbon', icon: <CloudOutlined />, label: t('menu.carbon') },
|
||||
{ key: '/reports', icon: <FileTextOutlined />, label: t('menu.reports') },
|
||||
{ key: '/quota', icon: <FundOutlined />, label: t('menu.quota', '定额管理') },
|
||||
{ key: '/charging', icon: <CarOutlined />, label: t('menu.charging', '充电管理') },
|
||||
{ key: '/maintenance', icon: <ToolOutlined />, label: t('menu.maintenance', '运维管理') },
|
||||
{ key: '/data-query', icon: <SearchOutlined />, label: t('menu.dataQuery', '数据查询') },
|
||||
{ key: '/management', icon: <SolutionOutlined />, label: t('menu.management', '管理体系') },
|
||||
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
|
||||
children: [
|
||||
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, CheckOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import { getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm } from '../../services/api';
|
||||
import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, Switch, Drawer, Row, Col, Statistic, message } from 'antd';
|
||||
import { PlusOutlined, CheckOutlined, ToolOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import {
|
||||
getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm,
|
||||
getAlarmAnalytics, getTopAlarmDevices, getAlarmMttr, toggleAlarmRule, getAlarmRuleHistory,
|
||||
} from '../../services/api';
|
||||
|
||||
const severityMap: Record<string, { color: string; text: string }> = {
|
||||
critical: { color: 'red', text: '紧急' },
|
||||
@@ -15,12 +19,122 @@ const statusMap: Record<string, { color: string; text: string }> = {
|
||||
resolved: { color: 'green', text: '已解决' },
|
||||
};
|
||||
|
||||
function AlarmAnalyticsTab() {
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
const [topDevices, setTopDevices] = useState<any[]>([]);
|
||||
const [mttr, setMttr] = useState<any>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, []);
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ana, top, mt] = await Promise.all([
|
||||
getAlarmAnalytics({}),
|
||||
getTopAlarmDevices({}),
|
||||
getAlarmMttr({}),
|
||||
]);
|
||||
setAnalytics(ana);
|
||||
setTopDevices(top as any[]);
|
||||
setMttr(mt);
|
||||
} catch {
|
||||
message.error('加载告警分析数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trendOption = analytics ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['紧急', '重要', '一般'] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: analytics.daily_trend.map((d: any) => d.date) },
|
||||
yAxis: { type: 'value', name: '次数' },
|
||||
series: [
|
||||
{ name: '紧急', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.critical), lineStyle: { color: '#f5222d' }, itemStyle: { color: '#f5222d' } },
|
||||
{ name: '重要', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.major), lineStyle: { color: '#fa8c16' }, itemStyle: { color: '#fa8c16' } },
|
||||
{ name: '一般', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.warning), lineStyle: { color: '#fadb14' }, itemStyle: { color: '#fadb14' } },
|
||||
],
|
||||
} : {};
|
||||
|
||||
const topDevicesOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 20, right: 40, bottom: 30, left: 120 },
|
||||
xAxis: { type: 'value', name: '告警次数' },
|
||||
yAxis: { type: 'category', data: [...topDevices].reverse().map(d => d.device_name) },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [...topDevices].reverse().map(d => d.alarm_count),
|
||||
itemStyle: { color: '#fa8c16' },
|
||||
}],
|
||||
};
|
||||
|
||||
const totals = analytics?.totals || {};
|
||||
const pieOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: [
|
||||
{ value: totals.critical || 0, name: '紧急', itemStyle: { color: '#f5222d' } },
|
||||
{ value: totals.major || 0, name: '重要', itemStyle: { color: '#fa8c16' } },
|
||||
{ value: totals.warning || 0, name: '一般', itemStyle: { color: '#fadb14' } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{(['critical', 'major', 'warning'] as const).map(sev => (
|
||||
<Col xs={24} sm={8} key={sev}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={`${severityMap[sev].text} MTTR`}
|
||||
value={mttr[sev]?.avg_hours || 0}
|
||||
suffix="小时"
|
||||
precision={1}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
已解决 {mttr[sev]?.count || 0} 条
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="告警趋势 (近30天)" size="small" loading={loading}>
|
||||
{analytics && <ReactECharts option={trendOption} style={{ height: 300 }} />}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="告警类型分布" size="small" loading={loading}>
|
||||
<ReactECharts option={pieOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="告警设备 Top 10" size="small" loading={loading}>
|
||||
<ReactECharts option={topDevicesOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Alarms() {
|
||||
const [events, setEvents] = useState<any>({ total: 0, items: [] });
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [historyDrawer, setHistoryDrawer] = useState<{ visible: boolean; ruleId: number; ruleName: string }>({ visible: false, ruleId: 0, ruleName: '' });
|
||||
const [historyData, setHistoryData] = useState<any>({ total: 0, items: [] });
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
@@ -60,6 +174,24 @@ export default function Alarms() {
|
||||
} catch { message.error('规则创建失败'); }
|
||||
};
|
||||
|
||||
const handleToggleRule = async (ruleId: number) => {
|
||||
try {
|
||||
await toggleAlarmRule(ruleId);
|
||||
message.success('状态已更新');
|
||||
loadData();
|
||||
} catch { message.error('切换状态失败'); }
|
||||
};
|
||||
|
||||
const handleShowHistory = async (ruleId: number, ruleName: string) => {
|
||||
setHistoryDrawer({ visible: true, ruleId, ruleName });
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await getAlarmRuleHistory(ruleId, { page: 1, page_size: 20 });
|
||||
setHistoryData(res);
|
||||
} catch { message.error('加载规则历史失败'); }
|
||||
finally { setHistoryLoading(false); }
|
||||
};
|
||||
|
||||
const eventColumns = [
|
||||
{ title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => {
|
||||
const sv = severityMap[s] || { color: 'default', text: s };
|
||||
@@ -87,7 +219,31 @@ export default function Alarms() {
|
||||
{ title: '条件', dataIndex: 'condition' },
|
||||
{ title: '阈值', dataIndex: 'threshold' },
|
||||
{ title: '级别', dataIndex: 'severity', render: (s: string) => <Tag color={severityMap[s]?.color}>{severityMap[s]?.text}</Tag> },
|
||||
{ title: '状态', dataIndex: 'is_active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag> },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean, r: any) => (
|
||||
<Switch checked={v} onChange={() => handleToggleRule(r.id)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_: any, r: any) => (
|
||||
<Button size="small" icon={<HistoryOutlined />} onClick={() => handleShowHistory(r.id, r.name)}>历史</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const historyColumns = [
|
||||
{ title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => <Tag color={severityMap[s]?.color}>{severityMap[s]?.text}</Tag> },
|
||||
{ title: '告警标题', dataIndex: 'title' },
|
||||
{ title: '触发值', dataIndex: 'value', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag> },
|
||||
{ title: '触发时间', dataIndex: 'triggered_at', width: 180 },
|
||||
{ title: '解决时间', dataIndex: 'resolved_at', width: 180 },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -106,6 +262,7 @@ export default function Alarms() {
|
||||
loading={loading} size="small" />
|
||||
</Card>
|
||||
)},
|
||||
{ key: 'analytics', label: '分析', children: <AlarmAnalyticsTab /> },
|
||||
]} />
|
||||
|
||||
<Modal title="新建告警规则" open={showRuleModal} onCancel={() => setShowRuleModal(false)}
|
||||
@@ -141,6 +298,17 @@ export default function Alarms() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={`规则触发历史 - ${historyDrawer.ruleName}`}
|
||||
open={historyDrawer.visible}
|
||||
onClose={() => setHistoryDrawer({ visible: false, ruleId: 0, ruleName: '' })}
|
||||
width={700}
|
||||
>
|
||||
<Table columns={historyColumns} dataSource={historyData.items} rowKey="id"
|
||||
loading={historyLoading} size="small"
|
||||
pagination={{ total: historyData.total, pageSize: 20 }} />
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
245
frontend/src/pages/Analysis/CostAnalysis.tsx
Normal file
245
frontend/src/pages/Analysis/CostAnalysis.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Select, Statistic, Button, Space, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getCostSummary, getCostComparison, getCostBreakdown } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export default function CostAnalysis() {
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [groupBy, setGroupBy] = useState('day');
|
||||
const [comparison, setComparison] = useState<any>(null);
|
||||
const [summary, setSummary] = useState<any[]>([]);
|
||||
const [breakdown, setBreakdown] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const start = dateRange[0].format('YYYY-MM-DD');
|
||||
const end = dateRange[1].format('YYYY-MM-DD');
|
||||
const [comp, sum, bkd] = await Promise.all([
|
||||
getCostComparison({ energy_type: 'electricity', period: 'month' }),
|
||||
getCostSummary({ start_date: start, end_date: end, group_by: groupBy, energy_type: 'electricity' }),
|
||||
getCostBreakdown({ start_date: start, end_date: end, energy_type: 'electricity' }),
|
||||
]);
|
||||
setComparison(comp);
|
||||
setSummary(sum as any[]);
|
||||
setBreakdown(bkd);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载费用数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [groupBy]);
|
||||
|
||||
// KPI calculations
|
||||
const todayCost = comparison?.current || 0;
|
||||
const monthCost = comparison?.current || 0;
|
||||
const yearCost = comparison?.yoy || 0;
|
||||
const momChange = comparison?.mom_change || 0;
|
||||
const yoyChange = comparison?.yoy_change || 0;
|
||||
|
||||
// Breakdown pie chart
|
||||
const breakdownPieOption = {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} 元 ({d}%)' },
|
||||
legend: { bottom: 10 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
data: (breakdown?.periods || []).map((p: any) => ({
|
||||
value: p.cost,
|
||||
name: p.period_label || p.period_name,
|
||||
itemStyle: {
|
||||
color: p.period_name === 'peak' || p.period_name === 'sharp' ? '#f5222d'
|
||||
: p.period_name === 'valley' || p.period_name === 'off_peak' ? '#52c41a'
|
||||
: p.period_name === 'flat' ? '#1890ff' : '#faad14',
|
||||
},
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
// Cost trend line chart
|
||||
const trendChartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['费用(元)', '用电量(kWh)'] },
|
||||
grid: { top: 50, right: 60, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: summary.map((d: any) => {
|
||||
if (d.date) return dayjs(d.date).format('MM/DD');
|
||||
if (d.period) return d.period;
|
||||
if (d.device_name) return d.device_name;
|
||||
return '';
|
||||
}),
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '元', position: 'left' },
|
||||
{ type: 'value', name: 'kWh', position: 'right' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '费用(元)',
|
||||
type: groupBy === 'device' ? 'bar' : 'line',
|
||||
smooth: true,
|
||||
data: summary.map((d: any) => d.cost || 0),
|
||||
lineStyle: { color: '#f5222d' },
|
||||
itemStyle: { color: '#f5222d' },
|
||||
yAxisIndex: 0,
|
||||
},
|
||||
{
|
||||
name: '用电量(kWh)',
|
||||
type: groupBy === 'device' ? 'bar' : 'line',
|
||||
smooth: true,
|
||||
data: summary.map((d: any) => d.consumption || 0),
|
||||
lineStyle: { color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Cost by building bar chart (using device grouping)
|
||||
const [deviceSummary, setDeviceSummary] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const loadDeviceSummary = async () => {
|
||||
try {
|
||||
const start = dateRange[0].format('YYYY-MM-DD');
|
||||
const end = dateRange[1].format('YYYY-MM-DD');
|
||||
const data = await getCostSummary({
|
||||
start_date: start, end_date: end, group_by: 'device', energy_type: 'electricity',
|
||||
});
|
||||
setDeviceSummary(data as any[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
loadDeviceSummary();
|
||||
}, [dateRange]);
|
||||
|
||||
const deviceBarOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 30, right: 20, bottom: 60, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: deviceSummary.map((d: any) => d.device_name || `#${d.device_id}`),
|
||||
axisLabel: { rotate: 30, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', name: '元' },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: deviceSummary.map((d: any) => d.cost || 0),
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#1890ff' },
|
||||
{ offset: 1, color: '#69c0ff' },
|
||||
],
|
||||
},
|
||||
},
|
||||
barMaxWidth: 40,
|
||||
}],
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const rows = summary.map((d: any) => {
|
||||
const label = d.date || d.period || d.device_name || '';
|
||||
return `${label},${d.consumption || 0},${d.cost || 0}`;
|
||||
});
|
||||
const csv = '\ufeff日期/分组,用电量(kWh),费用(元)\n' + rows.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `cost_analysis_${dateRange[0].format('YYYYMMDD')}_${dateRange[1].format('YYYYMMDD')}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Controls */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<Select value={groupBy} onChange={setGroupBy} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '按天', value: 'day' },
|
||||
{ label: '按月', value: 'month' },
|
||||
{ label: '按设备', value: 'device' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" loading={loading} onClick={loadData}>查询</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="今日费用" value={todayCost} suffix="元" precision={2}
|
||||
valueStyle={{ color: '#f5222d' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="本月费用" value={monthCost} suffix="元" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="环比变化" value={Math.abs(momChange)} suffix="%"
|
||||
prefix={momChange >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: momChange >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="同比变化" value={Math.abs(yoyChange)} suffix="%"
|
||||
prefix={yoyChange >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: yoyChange >= 0 ? '#f5222d' : '#52c41a' }} precision={1} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Charts Row */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<Card title="分时电价费用分布" size="small">
|
||||
<ReactECharts option={breakdownPieOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={14}>
|
||||
<Card title="费用趋势" size="small">
|
||||
<ReactECharts option={trendChartOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Device cost bar chart */}
|
||||
<Card title="设备/区域费用分布" size="small">
|
||||
<ReactECharts option={deviceBarOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/Analysis/LossAnalysis.tsx
Normal file
107
frontend/src/pages/Analysis/LossAnalysis.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, DatePicker, Select, Space, Tag, message } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getEnergyLoss } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface LossItem {
|
||||
group_name: string;
|
||||
parent_consumption: number;
|
||||
children_consumption: number;
|
||||
loss: number;
|
||||
loss_rate_pct: number;
|
||||
}
|
||||
|
||||
export default function LossAnalysis() {
|
||||
const [data, setData] = useState<LossItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [energyType, setEnergyType] = useState('electricity');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEnergyLoss({
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
energy_type: energyType,
|
||||
});
|
||||
setData(res as LossItem[]);
|
||||
} catch {
|
||||
message.error('加载损耗数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [dateRange, energyType]);
|
||||
|
||||
const getLossColor = (rate: number) => {
|
||||
if (rate > 10) return 'red';
|
||||
if (rate >= 5) return 'gold';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '区域', dataIndex: 'group_name' },
|
||||
{ title: '供给量', dataIndex: 'parent_consumption', render: (v: number) => `${v.toFixed(2)} kWh` },
|
||||
{ title: '消耗量', dataIndex: 'children_consumption', render: (v: number) => `${v.toFixed(2)} kWh` },
|
||||
{ title: '损耗量', dataIndex: 'loss', render: (v: number) => `${v.toFixed(2)} kWh` },
|
||||
{
|
||||
title: '损耗率',
|
||||
dataIndex: 'loss_rate_pct',
|
||||
render: (v: number) => <Tag color={getLossColor(v)}>{v.toFixed(1)}%</Tag>,
|
||||
sorter: (a: LossItem, b: LossItem) => a.loss_rate_pct - b.loss_rate_pct,
|
||||
},
|
||||
];
|
||||
|
||||
const chartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 40, right: 40, bottom: 30, left: 80 },
|
||||
xAxis: { type: 'value', name: 'kWh' },
|
||||
yAxis: { type: 'category', data: data.map(d => d.group_name) },
|
||||
series: [
|
||||
{
|
||||
name: '损耗量',
|
||||
type: 'bar',
|
||||
data: data.map(d => ({
|
||||
value: d.loss,
|
||||
itemStyle: { color: d.loss_rate_pct > 10 ? '#f5222d' : d.loss_rate_pct >= 5 ? '#faad14' : '#52c41a' },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>日期范围:</span>
|
||||
<RangePicker value={dateRange} onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])} />
|
||||
<span>能源类型:</span>
|
||||
<Select value={energyType} onChange={setEnergyType} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热力', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="损耗分布" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={chartOption} style={{ height: 300 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="损耗明细" size="small">
|
||||
<Table columns={columns} dataSource={data} rowKey="group_name"
|
||||
loading={loading} size="small" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/src/pages/Analysis/MomAnalysis.tsx
Normal file
130
frontend/src/pages/Analysis/MomAnalysis.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Select, Space, Statistic, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { getEnergyMom } from '../../services/api';
|
||||
|
||||
interface MomItem {
|
||||
label: string;
|
||||
current_period: number;
|
||||
previous_period: number;
|
||||
change_pct: number;
|
||||
}
|
||||
|
||||
interface MomData {
|
||||
items: MomItem[];
|
||||
total_current: number;
|
||||
total_previous: number;
|
||||
total_change_pct: number;
|
||||
}
|
||||
|
||||
export default function MomAnalysis() {
|
||||
const [data, setData] = useState<MomData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [period, setPeriod] = useState('month');
|
||||
const [energyType, setEnergyType] = useState('electricity');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEnergyMom({ period, energy_type: energyType });
|
||||
setData(res as MomData);
|
||||
} catch {
|
||||
message.error('加载环比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [period, energyType]);
|
||||
|
||||
const periodLabels: Record<string, [string, string]> = {
|
||||
month: ['本月', '上月'],
|
||||
week: ['本周', '上周'],
|
||||
day: ['今日', '昨日'],
|
||||
};
|
||||
const [curLabel, prevLabel] = periodLabels[period] || ['当前', '上期'];
|
||||
|
||||
const chartOption = data ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [curLabel, prevLabel] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: data.items.map(d => d.label) },
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{
|
||||
name: curLabel,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data.items.map(d => d.current_period),
|
||||
lineStyle: { color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
},
|
||||
{
|
||||
name: prevLabel,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: data.items.map(d => d.previous_period),
|
||||
lineStyle: { color: '#faad14', type: 'dashed' },
|
||||
itemStyle: { color: '#faad14' },
|
||||
},
|
||||
],
|
||||
} : {};
|
||||
|
||||
const changePct = data?.total_change_pct || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>对比周期:</span>
|
||||
<Select value={period} onChange={setPeriod} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '按月', value: 'month' },
|
||||
{ label: '按周', value: 'week' },
|
||||
{ label: '按日', value: 'day' },
|
||||
]}
|
||||
/>
|
||||
<span>能源类型:</span>
|
||||
<Select value={energyType} onChange={setEnergyType} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热力', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title={`${curLabel}用量`} value={data?.total_current || 0} suffix="kWh" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic title={`${prevLabel}用量`} value={data?.total_previous || 0} suffix="kWh" precision={2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="环比变化"
|
||||
value={Math.abs(changePct)}
|
||||
suffix="%"
|
||||
prefix={changePct >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: changePct >= 0 ? '#f5222d' : '#52c41a' }}
|
||||
precision={1}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="环比趋势" size="small">
|
||||
{data && <ReactECharts option={chartOption} style={{ height: 350 }} />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
frontend/src/pages/Analysis/SubitemAnalysis.tsx
Normal file
222
frontend/src/pages/Analysis/SubitemAnalysis.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Checkbox, Table, message } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import api from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
color: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
interface ByCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
color: string;
|
||||
consumption: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface RankingItem {
|
||||
name: string;
|
||||
color: string;
|
||||
consumption: number;
|
||||
}
|
||||
|
||||
interface TrendItem {
|
||||
date: string;
|
||||
category: string;
|
||||
color: string;
|
||||
consumption: number;
|
||||
}
|
||||
|
||||
export default function SubitemAnalysis() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [flatCategories, setFlatCategories] = useState<Category[]>([]);
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(30, 'day'), dayjs(),
|
||||
]);
|
||||
const [byCategory, setByCategory] = useState<ByCategory[]>([]);
|
||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||
const [trend, setTrend] = useState<TrendItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const flatten = (cats: Category[]): Category[] => {
|
||||
const result: Category[] = [];
|
||||
const walk = (list: Category[]) => {
|
||||
for (const c of list) {
|
||||
result.push(c);
|
||||
if (c.children) walk(c.children);
|
||||
}
|
||||
};
|
||||
walk(cats);
|
||||
return result;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const cats = await api.get('/energy/categories') as any as Category[];
|
||||
setCategories(cats);
|
||||
const flat = flatten(cats);
|
||||
setFlatCategories(flat);
|
||||
setSelectedCodes(flat.map(c => c.code));
|
||||
} catch {
|
||||
message.error('加载分项类别失败');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCodes.length > 0) loadData();
|
||||
}, [selectedCodes, dateRange]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const params = {
|
||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange[1].format('YYYY-MM-DD'),
|
||||
energy_type: 'electricity',
|
||||
};
|
||||
try {
|
||||
const [byCat, rank, trendData] = await Promise.all([
|
||||
api.get('/energy/by-category', { params }),
|
||||
api.get('/energy/category-ranking', { params }),
|
||||
api.get('/energy/category-trend', { params }),
|
||||
]);
|
||||
setByCategory((byCat as any[]).filter(c => selectedCodes.includes(c.code)));
|
||||
setRanking((rank as any[]).filter(c => selectedCodes.includes(c.name) || true));
|
||||
setTrend(trendData as any[]);
|
||||
} catch {
|
||||
message.error('加载分项数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pieOption = {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} kWh ({d}%)' },
|
||||
legend: { orient: 'vertical' as const, right: 10, top: 'center' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
data: byCategory.map(c => ({
|
||||
name: c.name, value: c.consumption,
|
||||
itemStyle: c.color ? { color: c.color } : undefined,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
const barOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { top: 10, right: 30, bottom: 30, left: 100 },
|
||||
xAxis: { type: 'value' as const, name: 'kWh' },
|
||||
yAxis: {
|
||||
type: 'category' as const,
|
||||
data: [...ranking].reverse().map(r => r.name),
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [...ranking].reverse().map(r => ({
|
||||
value: r.consumption,
|
||||
itemStyle: r.color ? { color: r.color } : undefined,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
|
||||
// Group trend data by category for line chart
|
||||
const trendCategories = [...new Set(trend.map(t => t.category))];
|
||||
const trendDates = [...new Set(trend.map(t => t.date))].sort();
|
||||
const colorMap: Record<string, string> = {};
|
||||
trend.forEach(t => { if (t.color) colorMap[t.category] = t.color; });
|
||||
|
||||
const lineOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: trendCategories },
|
||||
grid: { top: 40, right: 20, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: trendDates.map(d => dayjs(d).format('MM/DD')),
|
||||
},
|
||||
yAxis: { type: 'value' as const, name: 'kWh' },
|
||||
series: trendCategories.map(cat => ({
|
||||
name: cat,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: trendDates.map(d => {
|
||||
const item = trend.find(t => t.date === d && t.category === cat);
|
||||
return item ? item.consumption : 0;
|
||||
}),
|
||||
lineStyle: colorMap[cat] ? { color: colorMap[cat] } : undefined,
|
||||
itemStyle: colorMap[cat] ? { color: colorMap[cat] } : undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
const tableColumns = [
|
||||
{ title: '分项名称', dataIndex: 'name' },
|
||||
{ title: '用量 (kWh)', dataIndex: 'consumption', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '占比 (%)', dataIndex: 'percentage', render: (v: number) => v?.toFixed(1) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col>
|
||||
<span style={{ marginRight: 8 }}>日期范围:</span>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="auto">
|
||||
<span style={{ marginRight: 8 }}>分项类别:</span>
|
||||
<Checkbox.Group
|
||||
value={selectedCodes}
|
||||
onChange={(vals) => setSelectedCodes(vals as string[])}
|
||||
options={flatCategories.map(c => ({ label: c.name, value: c.code }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="能耗构成" size="small" loading={loading}>
|
||||
<ReactECharts option={pieOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="分项排名" size="small" loading={loading}>
|
||||
<ReactECharts option={barOption} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="分项趋势" size="small" style={{ marginTop: 16 }} loading={loading}>
|
||||
<ReactECharts option={lineOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="分项明细" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={byCategory}
|
||||
rowKey="code"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/Analysis/YoyAnalysis.tsx
Normal file
108
frontend/src/pages/Analysis/YoyAnalysis.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, DatePicker, Select, Space, message } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { getEnergyYoy } from '../../services/api';
|
||||
|
||||
interface YoyItem {
|
||||
month: number;
|
||||
current_year: number;
|
||||
previous_year: number;
|
||||
change_pct: number;
|
||||
}
|
||||
|
||||
export default function YoyAnalysis() {
|
||||
const [data, setData] = useState<YoyItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [year, setYear] = useState(dayjs().year());
|
||||
const [energyType, setEnergyType] = useState('electricity');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEnergyYoy({ year, energy_type: energyType });
|
||||
setData(res as YoyItem[]);
|
||||
} catch {
|
||||
message.error('加载同比数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [year, energyType]);
|
||||
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
|
||||
const chartOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: [`${year}年`, `${year - 1}年`] },
|
||||
grid: { top: 50, right: 40, bottom: 30, left: 60 },
|
||||
xAxis: { type: 'category', data: months },
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{
|
||||
name: `${year}年`,
|
||||
type: 'bar',
|
||||
data: data.map(d => d.current_year),
|
||||
itemStyle: { color: '#1890ff' },
|
||||
},
|
||||
{
|
||||
name: `${year - 1}年`,
|
||||
type: 'bar',
|
||||
data: data.map(d => d.previous_year),
|
||||
itemStyle: { color: '#faad14' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '月份', dataIndex: 'month', render: (v: number) => `${v}月` },
|
||||
{ title: `${year}年 (kWh)`, dataIndex: 'current_year', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: `${year - 1}年 (kWh)`, dataIndex: 'previous_year', render: (v: number) => v?.toFixed(2) },
|
||||
{
|
||||
title: '同比变化',
|
||||
dataIndex: 'change_pct',
|
||||
render: (v: number) => (
|
||||
<span style={{ color: v > 0 ? '#f5222d' : v < 0 ? '#52c41a' : '#666' }}>
|
||||
{v > 0 ? <ArrowUpOutlined /> : v < 0 ? <ArrowDownOutlined /> : null}
|
||||
{' '}{Math.abs(v).toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const yearOptions = [];
|
||||
for (let y = dayjs().year(); y >= dayjs().year() - 5; y--) {
|
||||
yearOptions.push({ label: `${y}年`, value: y });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<span>年份:</span>
|
||||
<Select value={year} onChange={setYear} style={{ width: 120 }} options={yearOptions} />
|
||||
<span>能源类型:</span>
|
||||
<Select value={energyType} onChange={setEnergyType} style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热力', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="同比分析" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={chartOption} style={{ height: 350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="月度明细" size="small">
|
||||
<Table columns={columns} dataSource={data} rowKey="month"
|
||||
loading={loading} size="small" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined, SwapOutlined } fr
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary, exportEnergyData } from '../../services/api';
|
||||
import LossAnalysis from './LossAnalysis';
|
||||
import YoyAnalysis from './YoyAnalysis';
|
||||
import MomAnalysis from './MomAnalysis';
|
||||
import CostAnalysis from './CostAnalysis';
|
||||
import SubitemAnalysis from './SubitemAnalysis';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -319,6 +324,11 @@ export default function Analysis() {
|
||||
items={[
|
||||
{ key: 'overview', label: '能耗概览', children: overviewContent },
|
||||
{ key: 'comparison', label: '数据对比', children: <ComparisonView /> },
|
||||
{ key: 'loss', label: '损耗分析', children: <LossAnalysis /> },
|
||||
{ key: 'yoy', label: '同比分析', children: <YoyAnalysis /> },
|
||||
{ key: 'mom', label: '环比分析', children: <MomAnalysis /> },
|
||||
{ key: 'cost', label: '费用分析', children: <CostAnalysis /> },
|
||||
{ key: 'subitem', label: '分项分析', children: <SubitemAnalysis /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
169
frontend/src/pages/Charging/Dashboard.tsx
Normal file
169
frontend/src/pages/Charging/Dashboard.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Statistic, message } from 'antd';
|
||||
import { DollarOutlined, ThunderboltOutlined, CarOutlined, DashboardOutlined } from '@ant-design/icons';
|
||||
import { getChargingDashboard } from '../../services/api';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
export default function ChargingDashboard() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingDashboard();
|
||||
setData(res);
|
||||
} catch {
|
||||
message.error('加载充电总览失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pileStatusData = data ? [
|
||||
{ type: '空闲', value: data.pile_status?.idle || 0 },
|
||||
{ type: '充电中', value: data.pile_status?.charging || 0 },
|
||||
{ type: '故障', value: data.pile_status?.fault || 0 },
|
||||
{ type: '离线', value: data.pile_status?.offline || 0 },
|
||||
].filter(d => d.value > 0) : [];
|
||||
|
||||
const pileStatusColors: Record<string, string> = {
|
||||
'空闲': '#52c41a',
|
||||
'充电中': '#1890ff',
|
||||
'故障': '#ff4d4f',
|
||||
'离线': '#d9d9d9',
|
||||
};
|
||||
|
||||
const revenueLineOption = {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: (data?.revenue_trend || []).map((d: any) => d.date),
|
||||
axisLabel: { rotate: 45 },
|
||||
},
|
||||
yAxis: { type: 'value' as const, name: '营收 (元)' },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: (data?.revenue_trend || []).map((d: any) => d.revenue),
|
||||
smooth: true,
|
||||
symbolSize: 6,
|
||||
}],
|
||||
grid: { left: 60, right: 20, bottom: 60, top: 30 },
|
||||
};
|
||||
|
||||
const pieOption = {
|
||||
tooltip: { trigger: 'item' as const },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: pileStatusData.map(d => ({
|
||||
name: d.type,
|
||||
value: d.value,
|
||||
itemStyle: { color: pileStatusColors[d.type] || '#d9d9d9' },
|
||||
})),
|
||||
label: { formatter: '{b} {c}' },
|
||||
}],
|
||||
};
|
||||
|
||||
const barOption = {
|
||||
tooltip: { trigger: 'axis' as const },
|
||||
xAxis: { type: 'value' as const, name: '营收 (元)' },
|
||||
yAxis: {
|
||||
type: 'category' as const,
|
||||
data: (data?.station_ranking || []).map((d: any) => d.station),
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: (data?.station_ranking || []).map((d: any) => d.revenue),
|
||||
label: { show: true, position: 'right' },
|
||||
}],
|
||||
grid: { left: 120, right: 40, bottom: 20, top: 20 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="总营收 (元)"
|
||||
value={data?.total_revenue || 0}
|
||||
prefix={<DollarOutlined />}
|
||||
precision={2}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="充电量 (kWh)"
|
||||
value={data?.total_energy || 0}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
precision={1}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="充电中"
|
||||
value={data?.active_sessions || 0}
|
||||
prefix={<CarOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small" loading={loading}>
|
||||
<Statistic
|
||||
title="利用率"
|
||||
value={data?.utilization_rate || 0}
|
||||
prefix={<DashboardOutlined />}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card size="small" title="营收趋势 (近30天)" loading={loading}>
|
||||
{data?.revenue_trend?.length > 0 ? (
|
||||
<ReactECharts option={revenueLineOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card size="small" title="充电桩状态分布" loading={loading}>
|
||||
{pileStatusData.length > 0 ? (
|
||||
<ReactECharts option={pieOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Card size="small" title="充电站营收排名" loading={loading}>
|
||||
{data?.station_ranking?.length > 0 ? (
|
||||
<ReactECharts option={barOption} style={{ height: 300 }} />
|
||||
) : (
|
||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
frontend/src/pages/Charging/Orders.tsx
Normal file
201
frontend/src/pages/Charging/Orders.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Tag, Button, Tabs, Space, DatePicker, Select, message } from 'antd';
|
||||
import { SyncOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { getChargingOrders, getChargingRealtimeOrders, getChargingAbnormalOrders, settleChargingOrder } from '../../services/api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const orderStatusMap: Record<string, { color: string; text: string }> = {
|
||||
charging: { color: 'processing', text: '充电中' },
|
||||
pending_pay: { color: 'warning', text: '待支付' },
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
refunded: { color: 'default', text: '已退款' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null) => {
|
||||
if (!seconds) return '-';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}时${m}分` : `${m}分`;
|
||||
};
|
||||
|
||||
export default function Orders() {
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveKey="realtime"
|
||||
items={[
|
||||
{ key: 'realtime', label: '实时充电', children: <RealtimeOrders /> },
|
||||
{ key: 'history', label: '历史订单', children: <HistoryOrders /> },
|
||||
{ key: 'abnormal', label: '异常订单', children: <AbnormalOrders /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RealtimeOrders() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingRealtimeOrders();
|
||||
setData(res as any[]);
|
||||
} catch { message.error('加载实时充电失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true },
|
||||
{ title: '充电桩', dataIndex: 'pile_name', width: 120 },
|
||||
{ title: '用户', dataIndex: 'user_name', width: 100 },
|
||||
{ title: '车牌', dataIndex: 'car_no', width: 100 },
|
||||
{ title: '开始时间', dataIndex: 'start_time', width: 170 },
|
||||
{ title: '时长', dataIndex: 'charge_duration', width: 80, render: formatDuration },
|
||||
{ title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '起始SOC', dataIndex: 'start_soc', width: 90, render: (v: number) => v != null ? `${v}%` : '-' },
|
||||
{ title: '当前SOC', dataIndex: 'end_soc', width: 90, render: (v: number) => v != null ? `${v}%` : '-' },
|
||||
{ title: '状态', dataIndex: 'order_status', width: 90, render: () => (
|
||||
<Tag icon={<SyncOutlined spin />} color="processing">充电中</Tag>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={<Button onClick={loadData}>刷新</Button>}>
|
||||
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} size="small" scroll={{ x: 1300 }}
|
||||
pagination={false} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryOrders() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getChargingOrders(cleanQuery);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载订单失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleDateChange = (_: any, dates: [string, string]) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
start_date: dates[0] || undefined,
|
||||
end_date: dates[1] || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true },
|
||||
{ title: '充电桩', dataIndex: 'pile_name', width: 120 },
|
||||
{ title: '用户', dataIndex: 'user_name', width: 100 },
|
||||
{ title: '车牌', dataIndex: 'car_no', width: 100 },
|
||||
{ title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '时长', dataIndex: 'charge_duration', width: 80, render: formatDuration },
|
||||
{ title: '电费(元)', dataIndex: 'elec_amt', width: 90, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '服务费(元)', dataIndex: 'serve_amt', width: 100, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '实付(元)', dataIndex: 'paid_price', width: 90, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '状态', dataIndex: 'order_status', width: 90, render: (s: string) => {
|
||||
const st = orderStatusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="订单状态" style={{ width: 120 }}
|
||||
options={Object.entries(orderStatusMap).map(([k, v]) => ({ label: v.text, value: k }))}
|
||||
onChange={v => setFilters(prev => ({ ...prev, order_status: v, page: 1 }))} />
|
||||
<RangePicker onChange={handleDateChange} />
|
||||
</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: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AbnormalOrders() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingAbnormalOrders(filters);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载异常订单失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleSettle = async (id: number) => {
|
||||
try {
|
||||
await settleChargingOrder(id);
|
||||
message.success('手动结算成功');
|
||||
loadData();
|
||||
} catch { message.error('结算失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true },
|
||||
{ title: '充电桩', dataIndex: 'pile_name', width: 120 },
|
||||
{ title: '用户', dataIndex: 'user_name', width: 100 },
|
||||
{ title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '状态', dataIndex: 'order_status', width: 90, render: (s: string) => {
|
||||
const st = orderStatusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag icon={<WarningOutlined />} color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '异常原因', dataIndex: 'abno_cause', width: 200, ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 100, render: (_: any, record: any) => (
|
||||
record.order_status === 'failed' && (
|
||||
<Button size="small" type="primary" onClick={() => handleSettle(record.id)}>手动结算</Button>
|
||||
)
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small" scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条异常订单`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
203
frontend/src/pages/Charging/Piles.tsx
Normal file
203
frontend/src/pages/Charging/Piles.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { getChargingPiles, createChargingPile, updateChargingPile, deleteChargingPile, getChargingStations, getChargingBrands } from '../../services/api';
|
||||
|
||||
const workStatusMap: Record<string, { color: string; text: string }> = {
|
||||
idle: { color: 'green', text: '空闲' },
|
||||
charging: { color: 'blue', text: '充电中' },
|
||||
fault: { color: 'red', text: '故障' },
|
||||
offline: { color: 'default', text: '离线' },
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '交流慢充', value: 'AC_slow' },
|
||||
{ label: '直流快充', value: 'DC_fast' },
|
||||
{ label: '直流超充', value: 'DC_superfast' },
|
||||
];
|
||||
|
||||
const connectorOptions = [
|
||||
{ label: 'GB/T', value: 'GB_T' },
|
||||
{ label: 'CCS', value: 'CCS' },
|
||||
{ label: 'CHAdeMO', value: 'CHAdeMO' },
|
||||
];
|
||||
|
||||
export default function Piles() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [stations, setStations] = useState<any[]>([]);
|
||||
const [brands, setBrands] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getChargingPiles(cleanQuery);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载充电桩失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
const loadMeta = async () => {
|
||||
try {
|
||||
const [st, br] = await Promise.all([
|
||||
getChargingStations({ page_size: 100 }),
|
||||
getChargingBrands(),
|
||||
]);
|
||||
setStations((st as any).items || []);
|
||||
setBrands(br as any[]);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadMeta(); }, []);
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleFilterChange = (key: string, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value, page: 1 }));
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active', work_status: 'offline' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateChargingPile(editing.id, values);
|
||||
message.success('充电桩更新成功');
|
||||
} else {
|
||||
await createChargingPile(values);
|
||||
message.success('充电桩创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.detail || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteChargingPile(id);
|
||||
message.success('已停用');
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '终端编码', dataIndex: 'encoding', width: 140 },
|
||||
{ title: '名称', dataIndex: 'name', width: 150, ellipsis: true },
|
||||
{ title: '所属充电站', dataIndex: 'station_id', width: 150, render: (id: number) => {
|
||||
const s = stations.find((st: any) => st.id === id);
|
||||
return s ? s.name : id;
|
||||
}},
|
||||
{ title: '类型', dataIndex: 'type', width: 100, render: (v: string) => typeOptions.find(o => o.value === v)?.label || v || '-' },
|
||||
{ title: '额定功率(kW)', dataIndex: 'rated_power_kw', width: 120, render: (v: number) => v != null ? v : '-' },
|
||||
{ title: '品牌', dataIndex: 'brand', width: 100 },
|
||||
{ title: '型号', dataIndex: 'model', width: 100 },
|
||||
{ title: '接口类型', dataIndex: 'connector_type', width: 100 },
|
||||
{ title: '工作状态', dataIndex: 'work_status', width: 100, render: (s: string) => {
|
||||
const st = workStatusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => (
|
||||
<Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '启用' : '停用'}</Tag>
|
||||
)},
|
||||
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>停用</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" title="充电桩管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>添加充电桩</Button>
|
||||
}>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="所属充电站" style={{ width: 180 }}
|
||||
options={stations.map((s: any) => ({ label: s.name, value: s.id }))}
|
||||
onChange={v => handleFilterChange('station_id', v)} />
|
||||
<Select allowClear placeholder="类型" style={{ width: 120 }} options={typeOptions}
|
||||
onChange={v => handleFilterChange('type', v)} />
|
||||
<Select allowClear placeholder="工作状态" style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '空闲', value: 'idle' }, { label: '充电中', value: 'charging' },
|
||||
{ label: '故障', value: 'fault' }, { label: '离线', value: 'offline' },
|
||||
]}
|
||||
onChange={v => handleFilterChange('work_status', v)} />
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns} dataSource={data.items} rowKey="id"
|
||||
loading={loading} size="small" scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 个充电桩`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑充电桩' : '添加充电桩'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="station_id" label="所属充电站" rules={[{ required: true, message: '请选择充电站' }]}>
|
||||
<Select placeholder="选择充电站"
|
||||
options={stations.map((s: any) => ({ label: s.name, value: s.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="encoding" label="终端编码" rules={[{ required: true, message: '请输入终端编码' }]}>
|
||||
<Input placeholder="唯一终端编码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input placeholder="充电桩名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear placeholder="选择类型" options={typeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="brand" label="品牌">
|
||||
<Select allowClear placeholder="选择品牌"
|
||||
options={brands.map((b: any) => ({ label: b.brand_name, value: b.brand_name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="型号">
|
||||
<Input placeholder="设备型号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rated_power_kw" label="额定功率(kW)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="connector_type" label="接口类型">
|
||||
<Select allowClear placeholder="选择接口类型" options={connectorOptions} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
193
frontend/src/pages/Charging/Pricing.tsx
Normal file
193
frontend/src/pages/Charging/Pricing.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import { getChargingPricing, createChargingPricing, updateChargingPricing, deleteChargingPricing, getChargingStations } from '../../services/api';
|
||||
|
||||
const periodMarkMap: Record<string, { color: string; text: string }> = {
|
||||
sharp: { color: 'red', text: '尖峰' },
|
||||
peak: { color: 'orange', text: '高峰' },
|
||||
flat: { color: 'blue', text: '平段' },
|
||||
valley: { color: 'green', text: '低谷' },
|
||||
};
|
||||
|
||||
export default function Pricing() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [stations, setStations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChargingPricing();
|
||||
setData(res as any[]);
|
||||
} catch { message.error('加载计费策略失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const loadStations = async () => {
|
||||
try {
|
||||
const res = await getChargingStations({ page_size: 100 });
|
||||
setStations((res as any).items || []);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); loadStations(); }, []);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'inactive', bill_model: 'tou', params: [{}] });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
strategy_name: record.strategy_name,
|
||||
station_id: record.station_id,
|
||||
bill_model: record.bill_model,
|
||||
description: record.description,
|
||||
status: record.status,
|
||||
params: record.params?.length > 0 ? record.params : [{}],
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateChargingPricing(editing.id, values);
|
||||
message.success('计费策略更新成功');
|
||||
} else {
|
||||
await createChargingPricing(values);
|
||||
message.success('计费策略创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.detail || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteChargingPricing(id);
|
||||
message.success('已停用');
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '策略名称', dataIndex: 'strategy_name', width: 180 },
|
||||
{ title: '适用站点', dataIndex: 'station_id', width: 150, render: (id: number) => {
|
||||
const s = stations.find((st: any) => st.id === id);
|
||||
return s ? s.name : id || '全部';
|
||||
}},
|
||||
{ title: '计费模式', dataIndex: 'bill_model', width: 100, render: (v: string) => v === 'tou' ? '分时计费' : v === 'flat' ? '固定计费' : v || '-' },
|
||||
{ title: '时段数', dataIndex: 'params', width: 80, render: (p: any[]) => p?.length || 0 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => (
|
||||
<Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? '启用' : '停用'}</Tag>
|
||||
)},
|
||||
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 150, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>停用</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
const expandedRowRender = (record: any) => {
|
||||
const paramCols = [
|
||||
{ title: '开始时间', dataIndex: 'start_time', width: 100 },
|
||||
{ title: '结束时间', dataIndex: 'end_time', width: 100 },
|
||||
{ title: '时段标识', dataIndex: 'period_mark', width: 100, render: (v: string) => {
|
||||
const pm = periodMarkMap[v];
|
||||
return pm ? <Tag color={pm.color}>{pm.text}</Tag> : v || '-';
|
||||
}},
|
||||
{ title: '电费(元/kWh)', dataIndex: 'elec_price', width: 120, render: (v: number) => v?.toFixed(4) },
|
||||
{ title: '服务费(元/kWh)', dataIndex: 'service_price', width: 130, render: (v: number) => v?.toFixed(4) },
|
||||
{ title: '合计(元/kWh)', key: 'total', width: 120, render: (_: any, r: any) => ((r.elec_price || 0) + (r.service_price || 0)).toFixed(4) },
|
||||
];
|
||||
return <Table columns={paramCols} dataSource={record.params || []} rowKey="id" pagination={false} size="small" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="small" title="计费策略管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>新建策略</Button>
|
||||
}>
|
||||
<Table
|
||||
columns={columns} dataSource={data} rowKey="id"
|
||||
loading={loading} size="small"
|
||||
expandable={{ expandedRowRender }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑计费策略' : '新建计费策略'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={800}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="strategy_name" label="策略名称" rules={[{ required: true, message: '请输入策略名称' }]}>
|
||||
<Input placeholder="请输入策略名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="station_id" label="适用站点">
|
||||
<Select allowClear placeholder="选择站点 (留空表示全部)"
|
||||
options={stations.map((s: any) => ({ label: s.name, value: s.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bill_model" label="计费模式">
|
||||
<Select options={[{ label: '分时计费', value: 'tou' }, { label: '固定计费', value: 'flat' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="策略描述" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>时段配置</div>
|
||||
<Form.List name="params">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline" wrap>
|
||||
<Form.Item {...restField} name={[name, 'start_time']} rules={[{ required: true, message: '开始时间' }]}>
|
||||
<Input placeholder="开始 HH:MM" style={{ width: 110 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'end_time']} rules={[{ required: true, message: '结束时间' }]}>
|
||||
<Input placeholder="结束 HH:MM" style={{ width: 110 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'period_mark']}>
|
||||
<Select placeholder="时段" style={{ width: 100 }} allowClear
|
||||
options={Object.entries(periodMarkMap).map(([k, v]) => ({ label: v.text, value: k }))} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'elec_price']} rules={[{ required: true, message: '电费' }]}>
|
||||
<InputNumber placeholder="电费" style={{ width: 110 }} min={0} step={0.01} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'service_price']}>
|
||||
<InputNumber placeholder="服务费" style={{ width: 120 }} min={0} step={0.01} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: '#ff4d4f' }} />
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加时段
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
180
frontend/src/pages/Charging/Stations.tsx
Normal file
180
frontend/src/pages/Charging/Stations.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { getChargingStations, createChargingStation, updateChargingStation, deleteChargingStation, getChargingMerchants } from '../../services/api';
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
active: { color: 'green', text: '运营中' },
|
||||
disabled: { color: 'default', text: '已停用' },
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '公共充电站', value: 'public' },
|
||||
{ label: '专用充电站', value: 'private' },
|
||||
{ label: '专属充电站', value: 'dedicated' },
|
||||
];
|
||||
|
||||
export default function Stations() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [merchants, setMerchants] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleanQuery: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v;
|
||||
});
|
||||
const res = await getChargingStations(cleanQuery);
|
||||
setData(res as any);
|
||||
} catch { message.error('加载充电站失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filters]);
|
||||
|
||||
const loadMerchants = async () => {
|
||||
try {
|
||||
const res = await getChargingMerchants();
|
||||
setMerchants(res as any[]);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadMerchants(); }, []);
|
||||
useEffect(() => { loadData(); }, [filters, loadData]);
|
||||
|
||||
const handleFilterChange = (key: string, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value, page: 1 }));
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'active' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateChargingStation(editing.id, values);
|
||||
message.success('充电站更新成功');
|
||||
} else {
|
||||
await createChargingStation(values);
|
||||
message.success('充电站创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.detail || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteChargingStation(id);
|
||||
message.success('已停用');
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '站点名称', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type', width: 100, render: (v: string) => typeOptions.find(o => o.value === v)?.label || v || '-' },
|
||||
{ title: '地址', dataIndex: 'address', width: 200, ellipsis: true },
|
||||
{ title: '充电桩总数', dataIndex: 'total_piles', width: 100 },
|
||||
{ title: '可用桩数', dataIndex: 'available_piles', width: 100 },
|
||||
{ title: '总功率(kW)', dataIndex: 'total_power_kw', width: 110 },
|
||||
{ title: '默认电价(元/kWh)', dataIndex: 'price', width: 140, render: (v: number) => v != null ? v.toFixed(2) : '-' },
|
||||
{ title: '运营时间', dataIndex: 'operating_hours', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {
|
||||
const st = statusMap[s] || { color: 'default', text: s || '-' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>编辑</Button>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>停用</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" title="充电站管理" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>添加充电站</Button>
|
||||
}>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="站点类型" style={{ width: 150 }} options={typeOptions}
|
||||
onChange={v => handleFilterChange('type', v)} />
|
||||
<Select allowClear placeholder="状态" style={{ width: 120 }}
|
||||
options={[{ label: '运营中', value: 'active' }, { label: '已停用', value: 'disabled' }]}
|
||||
onChange={v => handleFilterChange('status', v)} />
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns} dataSource={data.items} rowKey="id"
|
||||
loading={loading} size="small" scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 个充电站`,
|
||||
onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑充电站' : '添加充电站'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="站点名称" rules={[{ required: true, message: '请输入站点名称' }]}>
|
||||
<Input placeholder="请输入站点名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select allowClear placeholder="选择类型" options={typeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="merchant_id" label="运营商">
|
||||
<Select allowClear placeholder="选择运营商"
|
||||
options={merchants.map((m: any) => ({ label: m.name, value: m.id }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="请输入地址" />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="默认电价(元/kWh)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} placeholder="默认电价" />
|
||||
</Form.Item>
|
||||
<Form.Item name="total_power_kw" label="总功率(kW)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="operating_hours" label="运营时间">
|
||||
<Input placeholder="例: 00:00-24:00" />
|
||||
</Form.Item>
|
||||
<Form.Item name="activity" label="优惠活动">
|
||||
<Input.TextArea rows={2} placeholder="优惠活动信息" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ label: '运营中', value: 'active' }, { label: '已停用', value: 'disabled' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
23
frontend/src/pages/Charging/index.tsx
Normal file
23
frontend/src/pages/Charging/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Tabs } from 'antd';
|
||||
import ChargingDashboard from './Dashboard';
|
||||
import Stations from './Stations';
|
||||
import Piles from './Piles';
|
||||
import Orders from './Orders';
|
||||
import Pricing from './Pricing';
|
||||
|
||||
export default function Charging() {
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
defaultActiveKey="dashboard"
|
||||
items={[
|
||||
{ key: 'dashboard', label: '充电总览', children: <ChargingDashboard /> },
|
||||
{ key: 'stations', label: '充电站', children: <Stations /> },
|
||||
{ key: 'piles', label: '充电桩', children: <Piles /> },
|
||||
{ key: 'orders', label: '充电订单', children: <Orders /> },
|
||||
{ key: 'pricing', label: '计费策略', children: <Pricing /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
frontend/src/pages/DataQuery/index.tsx
Normal file
365
frontend/src/pages/DataQuery/index.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Row, Col, Tree, Checkbox, DatePicker, Select, Button, Table, Space, message } from 'antd';
|
||||
import { SearchOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { getDeviceGroups, getDevices, queryElectricalParams, exportEnergyData } from '../../services/api';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const PARAM_OPTIONS = [
|
||||
{ label: '功率 (kW)', value: 'power' },
|
||||
{ label: '电压 (V)', value: 'voltage' },
|
||||
{ label: '电流 (A)', value: 'current' },
|
||||
{ label: '功率因数', value: 'power_factor' },
|
||||
{ label: '温度 (℃)', value: 'temperature' },
|
||||
{ label: '频率 (Hz)', value: 'frequency' },
|
||||
{ label: 'COP', value: 'cop' },
|
||||
];
|
||||
|
||||
const PARAM_UNITS: Record<string, string> = {
|
||||
power: 'kW',
|
||||
voltage: 'V',
|
||||
current: 'A',
|
||||
power_factor: '',
|
||||
temperature: '℃',
|
||||
frequency: 'Hz',
|
||||
cop: '',
|
||||
};
|
||||
|
||||
const PARAM_COLORS = ['#1890ff', '#f5222d', '#52c41a', '#faad14', '#722ed1', '#13c2c2', '#eb2f96'];
|
||||
|
||||
const GRANULARITY_OPTIONS = [
|
||||
{ label: '原始', value: 'raw' },
|
||||
{ label: '5分钟', value: '5min' },
|
||||
{ label: '按小时', value: 'hour' },
|
||||
{ label: '按天', value: 'day' },
|
||||
];
|
||||
|
||||
export default function DataQuery() {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [deviceMap, setDeviceMap] = useState<Record<number, any>>({});
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | null>(null);
|
||||
const [selectedParams, setSelectedParams] = useState<string[]>(['power']);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(7, 'day'),
|
||||
dayjs(),
|
||||
]);
|
||||
const [granularity, setGranularity] = useState('hour');
|
||||
const [chartData, setChartData] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, []);
|
||||
|
||||
const loadTree = async () => {
|
||||
try {
|
||||
const [groups, devicesRes] = await Promise.all([
|
||||
getDeviceGroups(),
|
||||
getDevices({ page_size: 100 }),
|
||||
]);
|
||||
const groupList = groups as any[];
|
||||
const devices = (devicesRes as any).items || [];
|
||||
|
||||
const dMap: Record<number, any> = {};
|
||||
devices.forEach((d: any) => { dMap[d.id] = d; });
|
||||
setDeviceMap(dMap);
|
||||
|
||||
const groupMap: Record<number, any> = {};
|
||||
groupList.forEach(g => {
|
||||
groupMap[g.id] = { ...g, children: [], devices: [] };
|
||||
});
|
||||
|
||||
devices.forEach((d: any) => {
|
||||
if (d.group_id && groupMap[d.group_id]) {
|
||||
groupMap[d.group_id].devices.push(d);
|
||||
}
|
||||
});
|
||||
|
||||
const buildTree = (parentId: number | null): DataNode[] => {
|
||||
const nodes: DataNode[] = [];
|
||||
Object.values(groupMap).forEach((g: any) => {
|
||||
if (g.parent_id === parentId) {
|
||||
const childNodes = buildTree(g.id);
|
||||
const deviceNodes: DataNode[] = g.devices.map((d: any) => ({
|
||||
title: d.name,
|
||||
key: `device-${d.id}`,
|
||||
isLeaf: true,
|
||||
}));
|
||||
nodes.push({
|
||||
title: `${g.name}${g.location ? ` (${g.location})` : ''}`,
|
||||
key: `group-${g.id}`,
|
||||
children: [...childNodes, ...deviceNodes],
|
||||
selectable: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const tree = buildTree(null);
|
||||
|
||||
// Add ungrouped devices
|
||||
const ungrouped = devices.filter((d: any) => !d.group_id);
|
||||
if (ungrouped.length > 0) {
|
||||
tree.push({
|
||||
title: '未分组设备',
|
||||
key: 'group-ungrouped',
|
||||
children: ungrouped.map((d: any) => ({
|
||||
title: d.name,
|
||||
key: `device-${d.id}`,
|
||||
isLeaf: true,
|
||||
})),
|
||||
selectable: false,
|
||||
});
|
||||
}
|
||||
|
||||
setTreeData(tree);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTreeSelect = (selectedKeys: any[]) => {
|
||||
if (selectedKeys.length > 0) {
|
||||
const key = selectedKeys[0] as string;
|
||||
if (key.startsWith('device-')) {
|
||||
setSelectedDeviceId(parseInt(key.replace('device-', '')));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = useCallback(async () => {
|
||||
if (!selectedDeviceId) {
|
||||
message.warning('请先选择设备');
|
||||
return;
|
||||
}
|
||||
if (selectedParams.length === 0) {
|
||||
message.warning('请至少选择一个参数');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await queryElectricalParams({
|
||||
device_id: selectedDeviceId,
|
||||
params: selectedParams.join(','),
|
||||
start_time: dateRange[0].format('YYYY-MM-DD HH:mm:ss'),
|
||||
end_time: dateRange[1].format('YYYY-MM-DD HH:mm:ss'),
|
||||
granularity,
|
||||
});
|
||||
setChartData(res as Record<string, any[]>);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDeviceId, selectedParams, dateRange, granularity]);
|
||||
|
||||
const handleExport = async (format: 'csv' | 'xlsx') => {
|
||||
if (!selectedDeviceId) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportEnergyData({
|
||||
start_time: dateRange[0].format('YYYY-MM-DD'),
|
||||
end_time: dateRange[1].format('YYYY-MM-DD'),
|
||||
device_id: selectedDeviceId,
|
||||
format,
|
||||
});
|
||||
message.success('导出成功');
|
||||
} catch (e) {
|
||||
message.error('导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build chart option
|
||||
const getChartOption = () => {
|
||||
const paramKeys = Object.keys(chartData);
|
||||
if (paramKeys.length === 0) return {};
|
||||
|
||||
// Collect all timestamps from all params
|
||||
const allTimestamps = new Set<string>();
|
||||
paramKeys.forEach(p => {
|
||||
(chartData[p] || []).forEach((d: any) => allTimestamps.add(d.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(allTimestamps).sort();
|
||||
|
||||
// Build unique units for y-axes
|
||||
const units: string[] = [];
|
||||
paramKeys.forEach(p => {
|
||||
const unit = PARAM_UNITS[p] || '';
|
||||
if (!units.includes(unit)) units.push(unit);
|
||||
});
|
||||
|
||||
const yAxes = units.map((unit, i) => ({
|
||||
type: 'value' as const,
|
||||
name: unit,
|
||||
position: i === 0 ? 'left' as const : 'right' as const,
|
||||
offset: i > 1 ? (i - 1) * 60 : 0,
|
||||
axisLine: { show: true },
|
||||
}));
|
||||
|
||||
const series = paramKeys.map((param, i) => {
|
||||
const dataMap = new Map<string, number>();
|
||||
(chartData[param] || []).forEach((d: any) => {
|
||||
dataMap.set(d.timestamp, d.value);
|
||||
});
|
||||
const unit = PARAM_UNITS[param] || '';
|
||||
const yAxisIndex = units.indexOf(unit);
|
||||
const label = PARAM_OPTIONS.find(o => o.value === param)?.label || param;
|
||||
|
||||
return {
|
||||
name: label,
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
yAxisIndex,
|
||||
data: timestamps.map(t => dataMap.get(t) ?? null),
|
||||
lineStyle: { color: PARAM_COLORS[i % PARAM_COLORS.length] },
|
||||
itemStyle: { color: PARAM_COLORS[i % PARAM_COLORS.length] },
|
||||
connectNulls: true,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: series.map(s => s.name) },
|
||||
grid: { top: 50, right: units.length > 1 ? 80 + (units.length - 2) * 60 : 40, bottom: 30, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: timestamps.map(t => {
|
||||
const d = new Date(t);
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}),
|
||||
axisLabel: { rotate: 30 },
|
||||
},
|
||||
yAxis: yAxes.length > 0 ? yAxes : [{ type: 'value' }],
|
||||
series,
|
||||
dataZoom: [{ type: 'inside' }, { type: 'slider' }],
|
||||
};
|
||||
};
|
||||
|
||||
// Build table data
|
||||
const getTableData = () => {
|
||||
const paramKeys = Object.keys(chartData);
|
||||
if (paramKeys.length === 0) return { columns: [], data: [] };
|
||||
|
||||
const allTimestamps = new Set<string>();
|
||||
paramKeys.forEach(p => {
|
||||
(chartData[p] || []).forEach((d: any) => allTimestamps.add(d.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(allTimestamps).sort();
|
||||
|
||||
const dataMaps: Record<string, Map<string, number>> = {};
|
||||
paramKeys.forEach(p => {
|
||||
dataMaps[p] = new Map();
|
||||
(chartData[p] || []).forEach((d: any) => {
|
||||
dataMaps[p].set(d.timestamp, d.value);
|
||||
});
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'timestamp', width: 180 },
|
||||
...paramKeys.map(p => ({
|
||||
title: PARAM_OPTIONS.find(o => o.value === p)?.label || p,
|
||||
dataIndex: p,
|
||||
width: 120,
|
||||
render: (v: number) => v != null ? v.toFixed(2) : '-',
|
||||
})),
|
||||
];
|
||||
|
||||
const data = timestamps.map((t, i) => {
|
||||
const row: Record<string, any> = { key: i, timestamp: t };
|
||||
paramKeys.forEach(p => {
|
||||
row[p] = dataMaps[p].get(t) ?? null;
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return { columns, data };
|
||||
};
|
||||
|
||||
const tableInfo = getTableData();
|
||||
const selectedDevice = selectedDeviceId ? deviceMap[selectedDeviceId] : null;
|
||||
|
||||
return (
|
||||
<Row gutter={16} style={{ height: '100%' }}>
|
||||
{/* Left Panel - Device Tree */}
|
||||
<Col xs={24} md={6}>
|
||||
<Card title="设备选择" size="small" style={{ height: '100%' }} bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }}>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
onSelect={handleTreeSelect}
|
||||
selectedKeys={selectedDeviceId ? [`device-${selectedDeviceId}`] : []}
|
||||
defaultExpandAll
|
||||
showLine
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Right Panel - Query & Results */}
|
||||
<Col xs={24} md={18}>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8, fontWeight: 500 }}>当前设备:</span>
|
||||
<span style={{ color: selectedDevice ? '#1890ff' : '#999' }}>
|
||||
{selectedDevice ? `${selectedDevice.name} (${selectedDevice.code})` : '请在左侧选择设备'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8, fontWeight: 500 }}>查询参数:</span>
|
||||
<Checkbox.Group
|
||||
options={PARAM_OPTIONS}
|
||||
value={selectedParams}
|
||||
onChange={(vals) => setSelectedParams(vals as string[])}
|
||||
/>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
showTime
|
||||
value={dateRange}
|
||||
onChange={(dates) => dates && setDateRange(dates as [Dayjs, Dayjs])}
|
||||
/>
|
||||
<Select
|
||||
value={granularity}
|
||||
onChange={setGranularity}
|
||||
style={{ width: 100 }}
|
||||
options={GRANULARITY_OPTIONS}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={handleQuery}>
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('csv')}>
|
||||
导出CSV
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={() => handleExport('xlsx')}>
|
||||
导出Excel
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{Object.keys(chartData).length > 0 && (
|
||||
<>
|
||||
<Card title="数据趋势" size="small" style={{ marginBottom: 16 }}>
|
||||
<ReactECharts option={getChartOption()} style={{ height: 400 }} />
|
||||
</Card>
|
||||
|
||||
<Card title="数据列表" size="small">
|
||||
<Table
|
||||
columns={tableInfo.columns}
|
||||
dataSource={tableInfo.data}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{ pageSize: 20, showTotal: (total: number) => `共 ${total} 条` }}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
186
frontend/src/pages/Devices/Topology.tsx
Normal file
186
frontend/src/pages/Devices/Topology.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Tree, Table, Tag, Badge, Button, Space, Row, Col, Empty } from 'antd';
|
||||
import { ApartmentOutlined, ExpandOutlined, CompressOutlined } from '@ant-design/icons';
|
||||
import { getDeviceTopology, getDevices } from '../../services/api';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
online: { color: 'green', text: '在线' },
|
||||
offline: { color: 'default', text: '离线' },
|
||||
alarm: { color: 'red', text: '告警' },
|
||||
maintenance: { color: 'orange', text: '维护' },
|
||||
};
|
||||
|
||||
function getStatusDot(node: any): string {
|
||||
if (node.total_alarm > 0) return '#f5222d';
|
||||
if (node.total_offline > 0 && node.total_online > 0) return '#faad14';
|
||||
if (node.total_online > 0) return '#52c41a';
|
||||
if (node.total_device_count === 0) return '#d9d9d9';
|
||||
return '#999';
|
||||
}
|
||||
|
||||
function buildTreeNodes(nodes: any[]): DataNode[] {
|
||||
return nodes.map((node: any) => {
|
||||
const dotColor = getStatusDot(node);
|
||||
const title = (
|
||||
<span>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||
backgroundColor: dotColor, marginRight: 8,
|
||||
}} />
|
||||
{node.name}
|
||||
{node.location ? <span style={{ color: '#999', marginLeft: 4 }}>({node.location})</span> : null}
|
||||
<Badge
|
||||
count={node.total_device_count || 0}
|
||||
style={{ backgroundColor: '#1890ff', marginLeft: 8 }}
|
||||
overflowCount={999}
|
||||
size="small"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
return {
|
||||
title,
|
||||
key: `group-${node.id}`,
|
||||
children: buildTreeNodes(node.children || []),
|
||||
isLeaf: !node.children || node.children.length === 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function Topology() {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [topologyData, setTopologyData] = useState<any[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
||||
const [selectedGroupName, setSelectedGroupName] = useState('');
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTopology();
|
||||
}, []);
|
||||
|
||||
const loadTopology = async () => {
|
||||
try {
|
||||
const data = await getDeviceTopology() as any[];
|
||||
setTopologyData(data);
|
||||
setTreeData(buildTreeNodes(data));
|
||||
// Expand all by default
|
||||
const allKeys = collectKeys(data);
|
||||
setExpandedKeys(allKeys);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const collectKeys = (nodes: any[]): string[] => {
|
||||
const keys: string[] = [];
|
||||
nodes.forEach(n => {
|
||||
keys.push(`group-${n.id}`);
|
||||
if (n.children) {
|
||||
keys.push(...collectKeys(n.children));
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
};
|
||||
|
||||
const findGroupName = (nodes: any[], id: number): string => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n.name;
|
||||
if (n.children) {
|
||||
const found = findGroupName(n.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelect = async (selectedKeys: React.Key[]) => {
|
||||
if (selectedKeys.length === 0) return;
|
||||
const key = selectedKeys[0] as string;
|
||||
const groupId = parseInt(key.replace('group-', ''));
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedGroupName(findGroupName(topologyData, groupId));
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDevices({ group_id: groupId, page_size: 100 }) as any;
|
||||
setDevices(res.items || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandAll = () => {
|
||||
setExpandedKeys(collectKeys(topologyData));
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedKeys([]);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '设备名称', dataIndex: 'name', width: 160 },
|
||||
{ title: '设备编号', dataIndex: 'code', width: 130 },
|
||||
{ title: '类型', dataIndex: 'device_type', 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: 'location', width: 120, ellipsis: true },
|
||||
{ title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' },
|
||||
{ title: '最近数据时间', dataIndex: 'last_data_time', width: 170 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card
|
||||
title={<><ApartmentOutlined /> 设备拓扑</>}
|
||||
size="small"
|
||||
extra={
|
||||
<Space>
|
||||
<Button size="small" icon={<ExpandOutlined />} onClick={handleExpandAll}>展开</Button>
|
||||
<Button size="small" icon={<CompressOutlined />} onClick={handleCollapseAll}>收起</Button>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }}
|
||||
>
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys)}
|
||||
onSelect={handleSelect}
|
||||
showLine
|
||||
blockNode
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={16}>
|
||||
<Card
|
||||
title={selectedGroupName ? `${selectedGroupName} - 设备列表` : '设备列表'}
|
||||
size="small"
|
||||
>
|
||||
{selectedGroupId ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={devices}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{ pageSize: 20, showTotal: (total: number) => `共 ${total} 台` }}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="请在左侧拓扑树中选择一个分组" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
399
frontend/src/pages/Maintenance/index.tsx
Normal file
399
frontend/src/pages/Maintenance/index.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber,
|
||||
Space, message, Row, Col, Statistic, DatePicker, Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, ToolOutlined, CheckOutlined, WarningOutlined,
|
||||
ClockCircleOutlined, UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
getMaintenanceDashboard, getMaintenancePlans, createMaintenancePlan,
|
||||
triggerMaintenancePlan, getMaintenanceRecords, getMaintenanceOrders,
|
||||
createMaintenanceOrder, assignMaintenanceOrder, completeMaintenanceOrder,
|
||||
getMaintenanceDuty, createMaintenanceDuty,
|
||||
} from '../../services/api';
|
||||
|
||||
const priorityMap: Record<string, { color: string; text: string }> = {
|
||||
critical: { color: 'red', text: '紧急' },
|
||||
high: { color: 'orange', text: '高' },
|
||||
medium: { color: 'blue', text: '中' },
|
||||
low: { color: 'default', text: '低' },
|
||||
};
|
||||
|
||||
const orderStatusMap: Record<string, { color: string; text: string }> = {
|
||||
open: { color: 'red', text: '待处理' },
|
||||
assigned: { color: 'orange', text: '已指派' },
|
||||
in_progress: { color: 'processing', text: '处理中' },
|
||||
completed: { color: 'green', text: '已完成' },
|
||||
verified: { color: 'cyan', text: '已验证' },
|
||||
closed: { color: 'default', text: '已关闭' },
|
||||
};
|
||||
|
||||
const recordStatusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待执行' },
|
||||
in_progress: { color: 'processing', text: '执行中' },
|
||||
completed: { color: 'green', text: '已完成' },
|
||||
issues_found: { color: 'orange', text: '发现问题' },
|
||||
};
|
||||
|
||||
const shiftMap: Record<string, string> = {
|
||||
day: '白班', night: '夜班', on_call: '值班',
|
||||
};
|
||||
|
||||
// ── Tab 1: Dashboard ───────────────────────────────────────────────
|
||||
|
||||
function DashboardTab() {
|
||||
const [dashboard, setDashboard] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setDashboard(await getMaintenanceDashboard());
|
||||
} catch { message.error('加载运维概览失败'); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '工单号', dataIndex: 'code', width: 160 },
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 80, render: (v: string) => {
|
||||
const p = priorityMap[v] || { color: 'default', text: v };
|
||||
return <Tag color={p.color}>{p.text}</Tag>;
|
||||
}},
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string) => {
|
||||
const s = orderStatusMap[v] || { color: 'default', text: v };
|
||||
return <Badge status={s.color as any} text={s.text} />;
|
||||
}},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card><Statistic title="待处理工单" value={dashboard?.open_orders || 0} prefix={<ToolOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card><Statistic title="逾期工单" value={dashboard?.overdue_count || 0} prefix={<WarningOutlined />} valueStyle={{ color: dashboard?.overdue_count > 0 ? '#f5222d' : undefined }} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card><Statistic title="今日巡检" value={dashboard?.todays_inspections || 0} prefix={<CheckOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card><Statistic title="近期值班" value={dashboard?.upcoming_duties || 0} prefix={<ClockCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card title="最近工单" size="small">
|
||||
<Table columns={orderColumns} dataSource={dashboard?.recent_orders || []} rowKey="id" loading={loading} size="small" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab 2: Inspection Plans ────────────────────────────────────────
|
||||
|
||||
function PlansTab() {
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try { setPlans(await getMaintenancePlans() as any[]); }
|
||||
catch { message.error('加载巡检计划失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadPlans(); }, []);
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
await createMaintenancePlan(values);
|
||||
message.success('巡检计划创建成功');
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadPlans();
|
||||
} catch { message.error('创建失败'); }
|
||||
};
|
||||
|
||||
const handleTrigger = async (id: number) => {
|
||||
try {
|
||||
await triggerMaintenancePlan(id);
|
||||
message.success('已触发巡检');
|
||||
} catch { message.error('触发失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '计划名称', dataIndex: 'name' },
|
||||
{ title: '巡检周期', dataIndex: 'schedule_type', render: (v: string) => {
|
||||
const map: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月', custom: '自定义' };
|
||||
return map[v] || v || '-';
|
||||
}},
|
||||
{ title: '状态', dataIndex: 'is_active', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
|
||||
{ title: '下次执行', dataIndex: 'next_run_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
|
||||
{ title: '操作', key: 'action', width: 120, render: (_: any, r: any) => (
|
||||
<Button size="small" type="primary" onClick={() => handleTrigger(r.id)}>手动触发</Button>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setShowModal(true)}>新建计划</Button>}>
|
||||
<Table columns={columns} dataSource={plans} rowKey="id" loading={loading} size="small" />
|
||||
<Modal title="新建巡检计划" open={showModal} onCancel={() => setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消">
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="name" label="计划名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例: 每日热泵巡检" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="schedule_type" label="巡检周期" initialValue="daily">
|
||||
<Select options={[
|
||||
{ label: '每日', value: 'daily' }, { label: '每周', value: 'weekly' },
|
||||
{ label: '每月', value: 'monthly' }, { label: '自定义', value: 'custom' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="schedule_cron" label="Cron表达式 (自定义周期)">
|
||||
<Input placeholder="例: 0 8 * * 1-5" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab 3: Inspection Records ──────────────────────────────────────
|
||||
|
||||
function RecordsTab() {
|
||||
const [records, setRecords] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>();
|
||||
|
||||
const loadRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setRecords(await getMaintenanceRecords({ status: statusFilter }));
|
||||
} catch { message.error('加载巡检记录失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadRecords(); }, [statusFilter]);
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '计划ID', dataIndex: 'plan_id', width: 80 },
|
||||
{ title: '巡检人', dataIndex: 'inspector_id', width: 80 },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string) => {
|
||||
const s = recordStatusMap[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}},
|
||||
{ title: '开始时间', dataIndex: 'started_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
|
||||
{ title: '完成时间', dataIndex: 'completed_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={
|
||||
<Select allowClear placeholder="状态筛选" style={{ width: 140 }} value={statusFilter} onChange={setStatusFilter}
|
||||
options={Object.entries(recordStatusMap).map(([k, v]) => ({ label: v.text, value: k }))} />
|
||||
}>
|
||||
<Table columns={columns} dataSource={records.items} rowKey="id" loading={loading} size="small"
|
||||
pagination={{ total: records.total, pageSize: 20 }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab 4: Repair Orders ───────────────────────────────────────────
|
||||
|
||||
function OrdersTab() {
|
||||
const [orders, setOrders] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [assignModal, setAssignModal] = useState<{ open: boolean; orderId: number | null }>({ open: false, orderId: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadOrders = async () => {
|
||||
setLoading(true);
|
||||
try { setOrders(await getMaintenanceOrders({})); }
|
||||
catch { message.error('加载工单失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadOrders(); }, []);
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
await createMaintenanceOrder(values);
|
||||
message.success('工单创建成功');
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadOrders();
|
||||
} catch { message.error('创建失败'); }
|
||||
};
|
||||
|
||||
const handleAssign = async (userId: number) => {
|
||||
if (!assignModal.orderId) return;
|
||||
try {
|
||||
await assignMaintenanceOrder(assignModal.orderId, userId);
|
||||
message.success('已指派');
|
||||
setAssignModal({ open: false, orderId: null });
|
||||
loadOrders();
|
||||
} catch { message.error('指派失败'); }
|
||||
};
|
||||
|
||||
const handleComplete = async (id: number) => {
|
||||
try {
|
||||
await completeMaintenanceOrder(id);
|
||||
message.success('已完成');
|
||||
loadOrders();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '工单号', dataIndex: 'code', width: 160 },
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 80, render: (v: string) => {
|
||||
const p = priorityMap[v] || { color: 'default', text: v };
|
||||
return <Tag color={p.color}>{p.text}</Tag>;
|
||||
}},
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string) => {
|
||||
const s = orderStatusMap[v] || { color: 'default', text: v };
|
||||
return <Badge status={s.color as any} text={s.text} />;
|
||||
}},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
|
||||
{ title: '操作', key: 'action', width: 200, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
{r.status === 'open' && <Button size="small" icon={<UserOutlined />} onClick={() => setAssignModal({ open: true, orderId: r.id })}>指派</Button>}
|
||||
{['assigned', 'in_progress'].includes(r.status) && <Button size="small" type="primary" icon={<CheckOutlined />} onClick={() => handleComplete(r.id)}>完成</Button>}
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setShowModal(true)}>新建工单</Button>}>
|
||||
<Table columns={columns} dataSource={orders.items} rowKey="id" loading={loading} size="small"
|
||||
pagination={{ total: orders.total, pageSize: 20 }} />
|
||||
|
||||
<Modal title="新建维修工单" open={showModal} onCancel={() => setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消">
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="title" label="工单标题" rules={[{ required: true }]}>
|
||||
<Input placeholder="例: 2#热泵压缩机异响" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="故障描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="device_id" label="关联设备ID">
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="优先级" initialValue="medium">
|
||||
<Select options={Object.entries(priorityMap).map(([k, v]) => ({ label: v.text, value: k }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cost_estimate" label="预估费用">
|
||||
<InputNumber style={{ width: '100%' }} prefix="¥" min={0} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="指派工单" open={assignModal.open} onCancel={() => setAssignModal({ open: false, orderId: null })} footer={null}>
|
||||
<Form layout="vertical" onFinish={(v) => handleAssign(v.assigned_to)}>
|
||||
<Form.Item name="assigned_to" label="指派给 (用户ID)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">确认指派</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab 5: Duty Schedule ───────────────────────────────────────────
|
||||
|
||||
function DutyTab() {
|
||||
const [duties, setDuties] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadDuties = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const start = dayjs().subtract(7, 'day').format('YYYY-MM-DD');
|
||||
const end = dayjs().add(30, 'day').format('YYYY-MM-DD');
|
||||
setDuties(await getMaintenanceDuty({ start_date: start, end_date: end }) as any[]);
|
||||
} catch { message.error('加载值班安排失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadDuties(); }, []);
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
await createMaintenanceDuty({
|
||||
...values,
|
||||
duty_date: values.duty_date.format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('值班安排创建成功');
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
loadDuties();
|
||||
} catch { message.error('创建失败'); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '日期', dataIndex: 'duty_date', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
|
||||
{ title: '班次', dataIndex: 'shift', width: 80, render: (v: string) => shiftMap[v] || v || '-' },
|
||||
{ title: '人员ID', dataIndex: 'user_id', width: 80 },
|
||||
{ title: '区域ID', dataIndex: 'area_id', width: 80, render: (v: number) => v || '-' },
|
||||
{ title: '备注', dataIndex: 'notes' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small" extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setShowModal(true)}>新建排班</Button>}>
|
||||
<Table columns={columns} dataSource={duties} rowKey="id" loading={loading} size="small" />
|
||||
|
||||
<Modal title="新建值班安排" open={showModal} onCancel={() => setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消">
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="user_id" label="人员ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="duty_date" label="值班日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="shift" label="班次" initialValue="day">
|
||||
<Select options={[
|
||||
{ label: '白班', value: 'day' }, { label: '夜班', value: 'night' }, { label: '值班', value: 'on_call' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="area_id" label="区域ID">
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function Maintenance() {
|
||||
return (
|
||||
<div>
|
||||
<Tabs items={[
|
||||
{ key: 'dashboard', label: '概览', children: <DashboardTab /> },
|
||||
{ key: 'plans', label: '巡检计划', children: <PlansTab /> },
|
||||
{ key: 'records', label: '巡检记录', children: <RecordsTab /> },
|
||||
{ key: 'orders', label: '维修工单', children: <OrdersTab /> },
|
||||
{ key: 'duty', label: '值班安排', children: <DutyTab /> },
|
||||
]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
524
frontend/src/pages/Management/index.tsx
Normal file
524
frontend/src/pages/Management/index.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tabs, Card, Table, Tag, Button, Modal, Form, Input, Select, DatePicker, Space, message, Popconfirm, List, Badge } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
getRegulations, createRegulation, updateRegulation, deleteRegulation,
|
||||
getStandards, createStandard, updateStandard, deleteStandard,
|
||||
getProcessDocs, createProcessDoc, updateProcessDoc, deleteProcessDoc,
|
||||
getEmergencyPlans, createEmergencyPlan, updateEmergencyPlan, deleteEmergencyPlan,
|
||||
} from '../../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const REGULATION_CATEGORIES = [
|
||||
{ label: '安全管理', value: 'safety' },
|
||||
{ label: '运行管理', value: 'operation' },
|
||||
{ label: '质量管理', value: 'quality' },
|
||||
{ label: '环境管理', value: 'environment' },
|
||||
];
|
||||
|
||||
const REGULATION_STATUS = [
|
||||
{ label: '生效', value: 'active', color: 'green' },
|
||||
{ label: '草稿', value: 'draft', color: 'default' },
|
||||
{ label: '归档', value: 'archived', color: 'orange' },
|
||||
];
|
||||
|
||||
const STANDARD_TYPES = [
|
||||
{ label: '国家标准', value: 'national' },
|
||||
{ label: '行业标准', value: 'industry' },
|
||||
{ label: '企业标准', value: 'enterprise' },
|
||||
];
|
||||
|
||||
const COMPLIANCE_STATUS = [
|
||||
{ label: '已合规', value: 'compliant', color: 'green' },
|
||||
{ label: '不合规', value: 'non_compliant', color: 'red' },
|
||||
{ label: '待审核', value: 'pending', color: 'default' },
|
||||
{ label: '整改中', value: 'in_progress', color: 'blue' },
|
||||
];
|
||||
|
||||
const PROCESS_CATEGORIES = [
|
||||
{ label: '操作规程', value: 'operation' },
|
||||
{ label: '维护流程', value: 'maintenance' },
|
||||
{ label: '应急流程', value: 'emergency' },
|
||||
{ label: '培训流程', value: 'training' },
|
||||
];
|
||||
|
||||
const SCENARIO_TYPES = [
|
||||
{ label: '火灾', value: 'fire' },
|
||||
{ label: '停电', value: 'power_outage' },
|
||||
{ label: '设备故障', value: 'equipment_failure' },
|
||||
{ label: '化学泄漏', value: 'chemical_leak' },
|
||||
];
|
||||
|
||||
// ── Regulations Tab ──────────────────────────────────────────────────
|
||||
|
||||
function RegulationsTab() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getRegulations(filters);
|
||||
setData(res as any);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (values.effective_date) values.effective_date = values.effective_date.toISOString();
|
||||
if (editing) {
|
||||
await updateRegulation(editing.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createRegulation(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '操作失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteRegulation(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '删除失败'); }
|
||||
};
|
||||
|
||||
const openAdd = () => { setEditing(null); form.resetFields(); setShowModal(true); };
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
effective_date: record.effective_date ? dayjs(record.effective_date) : null,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{ title: '类别', dataIndex: 'category', width: 100, render: (v: string) => REGULATION_CATEGORIES.find(c => c.value === v)?.label || v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => {
|
||||
const s = REGULATION_STATUS.find(s => s.value === v);
|
||||
return <Tag color={s?.color || 'default'}>{s?.label || v}</Tag>;
|
||||
}},
|
||||
{ title: '生效日期', dataIndex: 'effective_date', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
|
||||
{ title: '操作', key: 'action', width: 150, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="类别" style={{ width: 120 }} options={REGULATION_CATEGORIES}
|
||||
onChange={v => setFilters(prev => ({ ...prev, category: v, page: 1 }))} />
|
||||
<Select allowClear placeholder="状态" style={{ width: 100 }} options={REGULATION_STATUS.map(s => ({ label: s.label, value: s.value }))}
|
||||
onChange={v => setFilters(prev => ({ ...prev, status: v, page: 1 }))} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>新增</Button>
|
||||
</Space>
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small"
|
||||
pagination={{ current: filters.page, pageSize: filters.page_size, total: data.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => setFilters(prev => ({ ...prev, page: p, page_size: ps })),
|
||||
}} />
|
||||
<Modal title={editing ? '编辑规章制度' : '新增规章制度'} open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()} destroyOnClose width={600}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="category" label="类别"><Select options={REGULATION_CATEGORIES} allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="active">
|
||||
<Select options={REGULATION_STATUS.map(s => ({ label: s.label, value: s.value }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effective_date" label="生效日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="content" label="内容"><TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="attachment_url" label="附件链接"><Input placeholder="附件URL" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Standards Tab ────────────────────────────────────────────────────
|
||||
|
||||
function StandardsTab() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getStandards(filters);
|
||||
setData(res as any);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (values.review_date) values.review_date = values.review_date.toISOString();
|
||||
if (editing) {
|
||||
await updateStandard(editing.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createStandard(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '操作失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteStandard(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '删除失败'); }
|
||||
};
|
||||
|
||||
const openAdd = () => { setEditing(null); form.resetFields(); setShowModal(true); };
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
review_date: record.review_date ? dayjs(record.review_date) : null,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标准名称', dataIndex: 'name', width: 200, ellipsis: true },
|
||||
{ title: '标准编号', dataIndex: 'code', width: 140 },
|
||||
{ title: '类型', dataIndex: 'type', width: 100, render: (v: string) => STANDARD_TYPES.find(t => t.value === v)?.label || v || '-' },
|
||||
{ title: '合规状态', dataIndex: 'compliance_status', width: 100, render: (v: string) => {
|
||||
const s = COMPLIANCE_STATUS.find(s => s.value === v);
|
||||
return <Tag color={s?.color || 'default'}>{s?.label || v}</Tag>;
|
||||
}},
|
||||
{ title: '审核日期', dataIndex: 'review_date', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
|
||||
{ title: '操作', key: 'action', width: 150, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="类型" style={{ width: 120 }} options={STANDARD_TYPES}
|
||||
onChange={v => setFilters(prev => ({ ...prev, type: v, page: 1 }))} />
|
||||
<Select allowClear placeholder="合规状态" style={{ width: 120 }} options={COMPLIANCE_STATUS.map(s => ({ label: s.label, value: s.value }))}
|
||||
onChange={v => setFilters(prev => ({ ...prev, compliance_status: v, page: 1 }))} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>新增</Button>
|
||||
</Space>
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small"
|
||||
pagination={{ current: filters.page, pageSize: filters.page_size, total: data.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => setFilters(prev => ({ ...prev, page: p, page_size: ps })),
|
||||
}} />
|
||||
<Modal title={editing ? '编辑标准规范' : '新增标准规范'} open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()} destroyOnClose width={600}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="标准名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="code" label="标准编号"><Input placeholder="e.g. ISO 50001, GB/T 23331" /></Form.Item>
|
||||
<Form.Item name="type" label="类型"><Select options={STANDARD_TYPES} allowClear /></Form.Item>
|
||||
<Form.Item name="compliance_status" label="合规状态" initialValue="pending">
|
||||
<Select options={COMPLIANCE_STATUS.map(s => ({ label: s.label, value: s.value }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="review_date" label="审核日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="description" label="描述"><TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Process Docs Tab ─────────────────────────────────────────────────
|
||||
|
||||
function ProcessDocsTab() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getProcessDocs(filters);
|
||||
setData(res as any);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (values.effective_date) values.effective_date = values.effective_date.toISOString();
|
||||
if (editing) {
|
||||
await updateProcessDoc(editing.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createProcessDoc(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '操作失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteProcessDoc(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '删除失败'); }
|
||||
};
|
||||
|
||||
const openAdd = () => { setEditing(null); form.resetFields(); setShowModal(true); };
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
effective_date: record.effective_date ? dayjs(record.effective_date) : null,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '文档标题', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{ title: '类别', dataIndex: 'category', width: 100, render: (v: string) => PROCESS_CATEGORIES.find(c => c.value === v)?.label || v || '-' },
|
||||
{ title: '版本', dataIndex: 'version', width: 80 },
|
||||
{ title: '审批人', dataIndex: 'approved_by', width: 100 },
|
||||
{ title: '生效日期', dataIndex: 'effective_date', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
|
||||
{ title: '操作', key: 'action', width: 150, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="类别" style={{ width: 120 }} options={PROCESS_CATEGORIES}
|
||||
onChange={v => setFilters(prev => ({ ...prev, category: v, page: 1 }))} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>新增</Button>
|
||||
</Space>
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small"
|
||||
pagination={{ current: filters.page, pageSize: filters.page_size, total: data.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => setFilters(prev => ({ ...prev, page: p, page_size: ps })),
|
||||
}} />
|
||||
<Modal title={editing ? '编辑管理流程' : '新增管理流程'} open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()} destroyOnClose width={600}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="title" label="文档标题" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="category" label="类别"><Select options={PROCESS_CATEGORIES} allowClear /></Form.Item>
|
||||
<Form.Item name="version" label="版本" initialValue="1.0"><Input /></Form.Item>
|
||||
<Form.Item name="approved_by" label="审批人"><Input /></Form.Item>
|
||||
<Form.Item name="effective_date" label="生效日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="content" label="内容"><TextArea rows={4} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Emergency Plans Tab ──────────────────────────────────────────────
|
||||
|
||||
function EmergencyPlansTab() {
|
||||
const [data, setData] = useState<any>({ total: 0, items: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [viewPlan, setViewPlan] = useState<any>(null);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({ page: 1, page_size: 20 });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getEmergencyPlans(filters);
|
||||
setData(res as any);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (values.review_date) values.review_date = values.review_date.toISOString();
|
||||
// Parse steps JSON
|
||||
if (values.steps_json) {
|
||||
try {
|
||||
values.steps = JSON.parse(values.steps_json);
|
||||
} catch {
|
||||
message.error('应急步骤JSON格式错误');
|
||||
return;
|
||||
}
|
||||
delete values.steps_json;
|
||||
}
|
||||
if (editing) {
|
||||
await updateEmergencyPlan(editing.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createEmergencyPlan(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '操作失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteEmergencyPlan(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) { message.error(e?.detail || '删除失败'); }
|
||||
};
|
||||
|
||||
const openAdd = () => { setEditing(null); form.resetFields(); setShowModal(true); };
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
review_date: record.review_date ? dayjs(record.review_date) : null,
|
||||
steps_json: record.steps ? JSON.stringify(record.steps, null, 2) : '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '预案标题', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{ title: '场景', dataIndex: 'scenario', width: 100, render: (v: string) => SCENARIO_TYPES.find(s => s.value === v)?.label || v || '-' },
|
||||
{ title: '负责人', dataIndex: 'responsible_person', width: 100 },
|
||||
{ title: '状态', dataIndex: 'is_active', width: 80, render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
|
||||
)},
|
||||
{ title: '审核日期', dataIndex: 'review_date', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
|
||||
{ title: '操作', key: 'action', width: 200, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EyeOutlined />} onClick={() => setViewPlan(record)}>查看步骤</Button>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="场景" style={{ width: 120 }} options={SCENARIO_TYPES}
|
||||
onChange={v => setFilters(prev => ({ ...prev, scenario: v, page: 1 }))} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>新增</Button>
|
||||
</Space>
|
||||
<Table columns={columns} dataSource={data.items} rowKey="id" loading={loading} size="small"
|
||||
pagination={{ current: filters.page, pageSize: filters.page_size, total: data.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => setFilters(prev => ({ ...prev, page: p, page_size: ps })),
|
||||
}} />
|
||||
|
||||
{/* Steps Viewer Modal */}
|
||||
<Modal title={`应急步骤 - ${viewPlan?.title || ''}`} open={!!viewPlan}
|
||||
onCancel={() => setViewPlan(null)} footer={null} width={600}>
|
||||
{viewPlan?.steps && Array.isArray(viewPlan.steps) ? (
|
||||
<List
|
||||
dataSource={viewPlan.steps}
|
||||
renderItem={(step: any, index: number) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<Badge count={step.step_number || index + 1} style={{ backgroundColor: '#1890ff' }} />}
|
||||
title={step.action}
|
||||
description={
|
||||
<Space direction="vertical" size={0}>
|
||||
{step.responsible_person && <span>负责人: {step.responsible_person}</span>}
|
||||
{step.contact && <span>联系方式: {step.contact}</span>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ color: '#999' }}>暂无应急步骤</p>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal title={editing ? '编辑应急预案' : '新增应急预案'} open={showModal}
|
||||
onCancel={() => { setShowModal(false); form.resetFields(); }}
|
||||
onOk={() => form.submit()} destroyOnClose width={600}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="title" label="预案标题" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scenario" label="场景"><Select options={SCENARIO_TYPES} allowClear /></Form.Item>
|
||||
<Form.Item name="responsible_person" label="负责人"><Input /></Form.Item>
|
||||
<Form.Item name="review_date" label="审核日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="is_active" label="启用" initialValue={true}>
|
||||
<Select options={[{ label: '启用', value: true }, { label: '停用', value: false }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="steps_json" label="应急步骤(JSON)" help='格式: [{"step_number":1,"action":"...","responsible_person":"...","contact":"..."}]'>
|
||||
<TextArea rows={5} placeholder='[{"step_number":1,"action":"关闭电源","responsible_person":"张三","contact":"13800138000"}]' />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────
|
||||
|
||||
export default function Management() {
|
||||
return (
|
||||
<Card size="small">
|
||||
<Tabs items={[
|
||||
{ key: 'regulations', label: '规章制度', children: <RegulationsTab /> },
|
||||
{ key: 'standards', label: '标准规范', children: <StandardsTab /> },
|
||||
{ key: 'process-docs', label: '管理流程', children: <ProcessDocsTab /> },
|
||||
{ key: 'emergency-plans', label: '应急预案', children: <EmergencyPlansTab /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
263
frontend/src/pages/Quota/index.tsx
Normal file
263
frontend/src/pages/Quota/index.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, Progress, Row, Col, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { getQuotas, createQuota, updateQuota, deleteQuota, getQuotaCompliance } from '../../services/api';
|
||||
|
||||
const energyTypeMap: Record<string, string> = {
|
||||
electricity: '电力', heat: '热能', water: '水', gas: '燃气',
|
||||
};
|
||||
|
||||
const targetTypeMap: Record<string, string> = {
|
||||
building: '建筑', department: '部门', device_group: '设备组',
|
||||
};
|
||||
|
||||
const periodMap: Record<string, string> = {
|
||||
monthly: '月度', yearly: '年度',
|
||||
};
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
normal: { color: 'green', text: '正常' },
|
||||
warning: { color: 'orange', text: '预警' },
|
||||
exceeded: { color: 'red', text: '超标' },
|
||||
};
|
||||
|
||||
export default function Quota() {
|
||||
const [quotas, setQuotas] = useState<any[]>([]);
|
||||
const [compliance, setCompliance] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingQuota, setEditingQuota] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [q, c] = await Promise.all([getQuotas(), getQuotaCompliance()]);
|
||||
setQuotas(q as any[]);
|
||||
setCompliance(c);
|
||||
} catch { message.error('加载配额数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleCreate = async (values: any) => {
|
||||
try {
|
||||
if (editingQuota) {
|
||||
await updateQuota(editingQuota.id, values);
|
||||
message.success('配额更新成功');
|
||||
} else {
|
||||
await createQuota(values);
|
||||
message.success('配额创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingQuota(null);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch { message.error('操作失败'); }
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingQuota(record);
|
||||
form.setFieldsValue(record);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteQuota(id);
|
||||
message.success('已删除');
|
||||
loadData();
|
||||
} catch { message.error('删除失败'); }
|
||||
};
|
||||
|
||||
// Tab 1: 配置表格
|
||||
const configColumns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '目标类型', dataIndex: 'target_type', render: (v: string) => targetTypeMap[v] || v },
|
||||
{ title: '能源类型', dataIndex: 'energy_type', render: (v: string) => energyTypeMap[v] || v },
|
||||
{ title: '周期', dataIndex: 'period', render: (v: string) => periodMap[v] || v },
|
||||
{ title: '配额值', dataIndex: 'quota_value', render: (v: number, r: any) => `${v} ${r.unit}` },
|
||||
{ title: '预警%', dataIndex: 'warning_threshold_pct' },
|
||||
{ title: '告警%', dataIndex: 'alert_threshold_pct' },
|
||||
{ title: '使用率', dataIndex: 'usage_rate_pct', render: (v: number) => `${(v || 0).toFixed(1)}%` },
|
||||
{ title: '状态', dataIndex: 'usage_status', render: (s: string) => {
|
||||
const st = statusMap[s] || { color: 'default', text: s || '正常' };
|
||||
return <Tag color={st.color}>{st.text}</Tag>;
|
||||
}},
|
||||
{ title: '操作', key: 'action', width: 140, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r.id)}>删除</Button>
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
const configTab = (
|
||||
<Card size="small" extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditingQuota(null); form.resetFields(); setShowModal(true); }}>
|
||||
新建配额
|
||||
</Button>
|
||||
}>
|
||||
<Table columns={configColumns} dataSource={quotas} rowKey="id"
|
||||
loading={loading} size="small" pagination={{ pageSize: 15 }} />
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Tab 2: 监控进度条
|
||||
const getProgressColor = (pct: number, warning: number, alert: number) => {
|
||||
if (pct >= alert) return '#f5222d';
|
||||
if (pct >= warning) return '#faad14';
|
||||
return '#52c41a';
|
||||
};
|
||||
|
||||
const monitorTab = (
|
||||
<Row gutter={[16, 16]}>
|
||||
{quotas.map(q => (
|
||||
<Col xs={24} sm={12} md={8} key={q.id}>
|
||||
<Card size="small" title={q.name}
|
||||
extra={<Tag color={statusMap[q.usage_status]?.color || 'default'}>{statusMap[q.usage_status]?.text || '正常'}</Tag>}>
|
||||
<div style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>
|
||||
{energyTypeMap[q.energy_type] || q.energy_type} | {periodMap[q.period] || q.period}
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.min(q.usage_rate_pct || 0, 100)}
|
||||
strokeColor={getProgressColor(q.usage_rate_pct || 0, q.warning_threshold_pct, q.alert_threshold_pct)}
|
||||
format={() => `${(q.usage_rate_pct || 0).toFixed(1)}%`}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||
已用 {(q.current_usage || 0).toFixed(1)} / {q.quota_value} {q.unit}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
{quotas.length === 0 && (
|
||||
<Col span={24}><Card size="small"><div style={{ textAlign: 'center', color: '#999', padding: 40 }}>暂无配额数据</div></Card></Col>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
|
||||
// Tab 3: 分析图表
|
||||
const complianceChartOption = compliance?.details ? {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['配额', '实际用量'] },
|
||||
grid: { top: 50, right: 40, bottom: 40, left: 60 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: compliance.details.map((d: any) => d.name),
|
||||
axisLabel: { rotate: 30, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', name: 'kWh' },
|
||||
series: [
|
||||
{
|
||||
name: '配额',
|
||||
type: 'bar',
|
||||
data: compliance.details.map((d: any) => d.quota_value),
|
||||
itemStyle: { color: '#1890ff', opacity: 0.4 },
|
||||
},
|
||||
{
|
||||
name: '实际用量',
|
||||
type: 'bar',
|
||||
data: compliance.details.map((d: any) => d.actual_value),
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const detail = compliance.details[params.dataIndex];
|
||||
if (detail.status === 'exceeded') return '#f5222d';
|
||||
if (detail.status === 'warning') return '#faad14';
|
||||
return '#52c41a';
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} : {};
|
||||
|
||||
const analysisTab = (
|
||||
<div>
|
||||
{compliance?.summary && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><div style={{ textAlign: 'center' }}><div style={{ fontSize: 24, fontWeight: 600 }}>{compliance.summary.total}</div><div style={{ color: '#999' }}>配额总数</div></div></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><div style={{ textAlign: 'center' }}><div style={{ fontSize: 24, fontWeight: 600, color: '#52c41a' }}>{compliance.summary.normal}</div><div style={{ color: '#999' }}>正常</div></div></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><div style={{ textAlign: 'center' }}><div style={{ fontSize: 24, fontWeight: 600, color: '#faad14' }}>{compliance.summary.warning}</div><div style={{ color: '#999' }}>预警</div></div></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><div style={{ textAlign: 'center' }}><div style={{ fontSize: 24, fontWeight: 600, color: '#f5222d' }}>{compliance.summary.exceeded}</div><div style={{ color: '#999' }}>超标</div></div></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Card title="配额合规分析" size="small">
|
||||
{compliance?.details?.length > 0 ? (
|
||||
<ReactECharts option={complianceChartOption} style={{ height: 400 }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: '#999', padding: 60 }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs items={[
|
||||
{ key: 'config', label: '配置', children: configTab },
|
||||
{ key: 'monitor', label: '监控', children: monitorTab },
|
||||
{ key: 'analysis', label: '分析', children: analysisTab },
|
||||
]} />
|
||||
|
||||
<Modal
|
||||
title={editingQuota ? '编辑配额' : '新建配额'}
|
||||
open={showModal}
|
||||
onCancel={() => { setShowModal(false); setEditingQuota(null); }}
|
||||
onOk={() => form.submit()}
|
||||
okText={editingQuota ? '更新' : '创建'}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="name" label="配额名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例: 办公楼月度用电配额" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_type" label="目标类型" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ label: '建筑', value: 'building' },
|
||||
{ label: '部门', value: 'department' },
|
||||
{ label: '设备组', value: 'device_group' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_id" label="目标ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="energy_type" label="能源类型" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ label: '电力', value: 'electricity' },
|
||||
{ label: '热能', value: 'heat' },
|
||||
{ label: '水', value: 'water' },
|
||||
{ label: '燃气', value: 'gas' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="period" label="周期" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ label: '月度', value: 'monthly' },
|
||||
{ label: '年度', value: 'yearly' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="quota_value" label="配额值" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="unit" label="单位" initialValue="kWh">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="warning_threshold_pct" label="预警阈值(%)" initialValue={80}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="alert_threshold_pct" label="告警阈值(%)" initialValue={95}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +125,54 @@ export const exportEnergyData = async (params: {
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Cost
|
||||
export const getCostPricing = (params?: Record<string, any>) => api.get('/cost/pricing', { params });
|
||||
export const createCostPricing = (data: any) => api.post('/cost/pricing', data);
|
||||
export const updateCostPricing = (id: number, data: any) => api.put(`/cost/pricing/${id}`, data);
|
||||
export const deleteCostPricing = (id: number) => api.delete(`/cost/pricing/${id}`);
|
||||
export const getCostPricingPeriods = (id: number) => api.get(`/cost/pricing/${id}/periods`);
|
||||
export const addCostPricingPeriod = (id: number, data: any) => api.post(`/cost/pricing/${id}/periods`, data);
|
||||
export const getCostSummary = (params: Record<string, any>) => api.get('/cost/summary', { params });
|
||||
export const getCostComparison = (params?: Record<string, any>) => api.get('/cost/comparison', { params });
|
||||
export const getCostBreakdown = (params: Record<string, any>) => api.get('/cost/breakdown', { params });
|
||||
|
||||
// Quota
|
||||
export const getQuotas = (params?: Record<string, any>) => api.get('/quota', { params });
|
||||
export const createQuota = (data: any) => api.post('/quota', data);
|
||||
export const updateQuota = (id: number, data: any) => api.put(`/quota/${id}`, data);
|
||||
export const deleteQuota = (id: number) => api.delete(`/quota/${id}`);
|
||||
export const getQuotaUsage = (params?: Record<string, any>) => api.get('/quota/usage', { params });
|
||||
export const getQuotaCompliance = () => api.get('/quota/compliance');
|
||||
|
||||
// Energy Categories (分项能耗)
|
||||
export const getEnergyCategories = () => api.get('/energy/categories');
|
||||
export const createEnergyCategory = (data: any) => api.post('/energy/categories', data);
|
||||
export const updateEnergyCategory = (id: number, data: any) => api.put(`/energy/categories/${id}`, data);
|
||||
export const getEnergyByCategory = (params?: Record<string, any>) => api.get('/energy/by-category', { params });
|
||||
export const getCategoryRanking = (params?: Record<string, any>) => api.get('/energy/category-ranking', { params });
|
||||
export const getCategoryTrend = (params?: Record<string, any>) => api.get('/energy/category-trend', { params });
|
||||
|
||||
// Maintenance (运维管理)
|
||||
export const getMaintenanceDashboard = () => api.get('/maintenance/dashboard');
|
||||
export const getMaintenancePlans = (params?: Record<string, any>) => api.get('/maintenance/plans', { params });
|
||||
export const createMaintenancePlan = (data: any) => api.post('/maintenance/plans', data);
|
||||
export const updateMaintenancePlan = (id: number, data: any) => api.put(`/maintenance/plans/${id}`, data);
|
||||
export const deleteMaintenancePlan = (id: number) => api.delete(`/maintenance/plans/${id}`);
|
||||
export const triggerMaintenancePlan = (id: number) => api.post(`/maintenance/plans/${id}/trigger`);
|
||||
export const getMaintenanceRecords = (params?: Record<string, any>) => api.get('/maintenance/records', { params });
|
||||
export const createMaintenanceRecord = (data: any) => api.post('/maintenance/records', data);
|
||||
export const updateMaintenanceRecord = (id: number, data: any) => api.put(`/maintenance/records/${id}`, data);
|
||||
export const getMaintenanceOrders = (params?: Record<string, any>) => api.get('/maintenance/orders', { params });
|
||||
export const createMaintenanceOrder = (data: any) => api.post('/maintenance/orders', data);
|
||||
export const updateMaintenanceOrder = (id: number, data: any) => api.put(`/maintenance/orders/${id}`, data);
|
||||
export const assignMaintenanceOrder = (id: number, userId: number) => api.put(`/maintenance/orders/${id}/assign?assigned_to=${userId}`);
|
||||
export const completeMaintenanceOrder = (id: number, resolution = '', actualCost?: number) =>
|
||||
api.put(`/maintenance/orders/${id}/complete?resolution=${encodeURIComponent(resolution)}${actualCost !== undefined ? `&actual_cost=${actualCost}` : ''}`);
|
||||
export const getMaintenanceDuty = (params?: Record<string, any>) => api.get('/maintenance/duty', { params });
|
||||
export const createMaintenanceDuty = (data: any) => api.post('/maintenance/duty', data);
|
||||
export const updateMaintenanceDuty = (id: number, data: any) => api.put(`/maintenance/duty/${id}`, data);
|
||||
export const deleteMaintenanceDuty = (id: number) => api.delete(`/maintenance/duty/${id}`);
|
||||
|
||||
// Users
|
||||
export const getUsers = (params?: Record<string, any>) => api.get('/users', { params });
|
||||
export const createUser = (data: any) => api.post('/users', data);
|
||||
@@ -138,4 +186,91 @@ export const getAuditLogs = (params?: Record<string, any>) => api.get('/audit/lo
|
||||
export const getSettings = () => api.get('/settings');
|
||||
export const updateSettings = (data: any) => api.put('/settings', data);
|
||||
|
||||
// Charging - Stations
|
||||
export const getChargingStations = (params?: Record<string, any>) => api.get('/charging/stations', { params });
|
||||
export const createChargingStation = (data: any) => api.post('/charging/stations', data);
|
||||
export const updateChargingStation = (id: number, data: any) => api.put(`/charging/stations/${id}`, data);
|
||||
export const deleteChargingStation = (id: number) => api.delete(`/charging/stations/${id}`);
|
||||
export const getStationPiles = (stationId: number) => api.get(`/charging/stations/${stationId}/piles`);
|
||||
|
||||
// Charging - Piles
|
||||
export const getChargingPiles = (params?: Record<string, any>) => api.get('/charging/piles', { params });
|
||||
export const createChargingPile = (data: any) => api.post('/charging/piles', data);
|
||||
export const updateChargingPile = (id: number, data: any) => api.put(`/charging/piles/${id}`, data);
|
||||
export const deleteChargingPile = (id: number) => api.delete(`/charging/piles/${id}`);
|
||||
|
||||
// Charging - Pricing
|
||||
export const getChargingPricing = (params?: Record<string, any>) => api.get('/charging/pricing', { params });
|
||||
export const createChargingPricing = (data: any) => api.post('/charging/pricing', data);
|
||||
export const updateChargingPricing = (id: number, data: any) => api.put(`/charging/pricing/${id}`, data);
|
||||
export const deleteChargingPricing = (id: number) => api.delete(`/charging/pricing/${id}`);
|
||||
|
||||
// Charging - Orders
|
||||
export const getChargingOrders = (params?: Record<string, any>) => api.get('/charging/orders', { params });
|
||||
export const getChargingRealtimeOrders = () => api.get('/charging/orders/realtime');
|
||||
export const getChargingAbnormalOrders = (params?: Record<string, any>) => api.get('/charging/orders/abnormal', { params });
|
||||
export const getChargingOrder = (id: number) => api.get(`/charging/orders/${id}`);
|
||||
export const settleChargingOrder = (id: number) => api.post(`/charging/orders/${id}/settle`);
|
||||
|
||||
// Charging - Dashboard
|
||||
export const getChargingDashboard = () => api.get('/charging/dashboard');
|
||||
|
||||
// Charging - Merchants
|
||||
export const getChargingMerchants = () => api.get('/charging/merchants');
|
||||
export const createChargingMerchant = (data: any) => api.post('/charging/merchants', data);
|
||||
export const updateChargingMerchant = (id: number, data: any) => api.put(`/charging/merchants/${id}`, data);
|
||||
export const deleteChargingMerchant = (id: number) => api.delete(`/charging/merchants/${id}`);
|
||||
|
||||
// Charging - Brands
|
||||
export const getChargingBrands = () => api.get('/charging/brands');
|
||||
export const createChargingBrand = (data: any) => api.post('/charging/brands', data);
|
||||
export const updateChargingBrand = (id: number, data: any) => api.put(`/charging/brands/${id}`, data);
|
||||
export const deleteChargingBrand = (id: number) => api.delete(`/charging/brands/${id}`);
|
||||
|
||||
// Energy Analysis (Loss / YoY / MoM)
|
||||
export const getEnergyLoss = (params: Record<string, any>) => api.get('/energy/loss', { params });
|
||||
export const getEnergyYoy = (params?: Record<string, any>) => api.get('/energy/yoy', { params });
|
||||
export const getEnergyMom = (params?: Record<string, any>) => api.get('/energy/mom', { params });
|
||||
|
||||
// Alarm Analytics
|
||||
export const getAlarmAnalytics = (params?: Record<string, any>) => api.get('/alarms/analytics', { params });
|
||||
export const getTopAlarmDevices = (params?: Record<string, any>) => api.get('/alarms/top-devices', { params });
|
||||
export const getAlarmMttr = (params?: Record<string, any>) => api.get('/alarms/mttr', { params });
|
||||
export const toggleAlarmRule = (ruleId: number) => api.put(`/alarms/rules/${ruleId}/toggle`);
|
||||
export const getAlarmRuleHistory = (ruleId: number, params?: Record<string, any>) =>
|
||||
api.get(`/alarms/rules/${ruleId}/history`, { params });
|
||||
|
||||
// Data Query (电参量查询)
|
||||
export const queryElectricalParams = (params: Record<string, any>) => api.get('/energy/params', { params });
|
||||
|
||||
// Device Topology (设备拓扑)
|
||||
export const getDeviceTopology = () => api.get('/devices/topology');
|
||||
|
||||
// Management - Regulations (规章制度)
|
||||
export const getRegulations = (params?: Record<string, any>) => api.get('/management/regulations', { params });
|
||||
export const createRegulation = (data: any) => api.post('/management/regulations', data);
|
||||
export const updateRegulation = (id: number, data: any) => api.put(`/management/regulations/${id}`, data);
|
||||
export const deleteRegulation = (id: number) => api.delete(`/management/regulations/${id}`);
|
||||
|
||||
// Management - Standards (标准规范)
|
||||
export const getStandards = (params?: Record<string, any>) => api.get('/management/standards', { params });
|
||||
export const createStandard = (data: any) => api.post('/management/standards', data);
|
||||
export const updateStandard = (id: number, data: any) => api.put(`/management/standards/${id}`, data);
|
||||
export const deleteStandard = (id: number) => api.delete(`/management/standards/${id}`);
|
||||
|
||||
// Management - Process Docs (管理流程)
|
||||
export const getProcessDocs = (params?: Record<string, any>) => api.get('/management/process-docs', { params });
|
||||
export const createProcessDoc = (data: any) => api.post('/management/process-docs', data);
|
||||
export const updateProcessDoc = (id: number, data: any) => api.put(`/management/process-docs/${id}`, data);
|
||||
export const deleteProcessDoc = (id: number) => api.delete(`/management/process-docs/${id}`);
|
||||
|
||||
// Management - Emergency Plans (应急预案)
|
||||
export const getEmergencyPlans = (params?: Record<string, any>) => api.get('/management/emergency-plans', { params });
|
||||
export const createEmergencyPlan = (data: any) => api.post('/management/emergency-plans', data);
|
||||
export const updateEmergencyPlan = (id: number, data: any) => api.put(`/management/emergency-plans/${id}`, data);
|
||||
export const deleteEmergencyPlan = (id: number) => api.delete(`/management/emergency-plans/${id}`);
|
||||
|
||||
// Management - Compliance
|
||||
export const getComplianceOverview = () => api.get('/management/compliance');
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8088',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user