diff --git a/VERSIONS.json b/VERSIONS.json index 5ece7b5..54bbda9 100644 --- a/VERSIONS.json +++ b/VERSIONS.json @@ -1,9 +1,9 @@ { "project": "zpark-ems", - "project_version": "1.5.0", + "project_version": "1.6.0", "customer": "Z-Park 中关村医疗器械园", "core_version": "1.4.0", "frontend_template_version": "1.4.0", "last_updated": "2026-04-06", - "notes": "Energy flow diagram, weather widget, curve templates, device comparison, dispersion analysis, PWA, alarm subscriptions" + "notes": "String monitoring, I-V diagnosis stub, ROI simulator, knowledge base, remote config stub, mobile responsive" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 68a676b..43e4fe9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,8 +23,13 @@ import DataQuery from './pages/DataQuery'; import Management from './pages/Management'; import Prediction from './pages/Prediction'; import EnergyStrategy from './pages/EnergyStrategy'; +import ROISimulator from './pages/ROISimulator'; import AIOperations from './pages/AIOperations'; +import KnowledgeBase from './pages/KnowledgeBase'; import BigScreen from './pages/BigScreen'; +import StringMonitoring from './pages/StringMonitoring'; +import IVDiagnosis from './pages/IVDiagnosis'; +import RemoteConfig from './pages/RemoteConfig'; import { isLoggedIn } from './utils/auth'; @@ -66,7 +71,12 @@ function AppContent() { } /> } /> } /> + } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e0fe292..56cf2e0 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -7,7 +7,8 @@ import { ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined, InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined, BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined, - SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined, + SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined, ApartmentOutlined, ScanOutlined, + DollarOutlined, BookOutlined, } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -36,6 +37,15 @@ export default function MainLayout() { const { darkMode, toggleDarkMode } = useTheme(); const { t, i18n } = useTranslation(); + useEffect(() => { + const handleResize = () => { + if (window.innerWidth < 768) setCollapsed(true); + }; + window.addEventListener('resize', handleResize); + handleResize(); + return () => window.removeEventListener('resize', handleResize); + }, []); + useEffect(() => { getBranding().then((res: any) => { setFeatures(res?.features || {}); @@ -64,8 +74,17 @@ export default function MainLayout() { { key: '/data-query', icon: , label: t('menu.dataQuery', '数据查询') }, { key: '/prediction', icon: , label: t('menu.prediction', 'AI预测') }, { key: '/management', icon: , label: t('menu.management', '管理体系') }, + { key: '/knowledge-base', icon: , label: t('menu.knowledgeBase', '知识库') }, { key: '/energy-strategy', icon: , label: t('menu.energyStrategy', '策略优化') }, + { key: '/roi-simulator', icon: , label: t('menu.roiSimulator', '投资回报') }, { key: '/ai-operations', icon: , label: t('menu.aiOperations', 'AI运维') }, + { key: '/string-monitoring', icon: , label: t('menu.stringMonitoring', '组串监控') }, + { key: '/remote-config', icon: , label: t('menu.remoteConfig', '远程配置') }, + { key: 'diagnosis-group', icon: , label: t('menu.diagnosis', '智能诊断'), + children: [ + { key: '/iv-diagnosis', icon: , label: t('menu.ivDiagnosis', 'IV曲线诊断') }, + ], + }, { key: 'bigscreen-group', icon: , label: t('menu.bigscreen'), children: [ { key: '/bigscreen', icon: , label: t('menu.bigscreen2d') }, diff --git a/frontend/src/pages/IVDiagnosis/index.tsx b/frontend/src/pages/IVDiagnosis/index.tsx new file mode 100644 index 0000000..aaa66ff --- /dev/null +++ b/frontend/src/pages/IVDiagnosis/index.tsx @@ -0,0 +1,107 @@ +import { useMemo } from 'react'; +import { Alert, Card, Row, Col } from 'antd'; +import { ScanOutlined, BugOutlined, FileTextOutlined, LineChartOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; + +const featureCards = [ + { icon: , title: '在线IV扫描', desc: 'Remote I-V curve scanning at string level' }, + { icon: , title: '23种故障诊断', desc: 'Automatic fault classification with 97% accuracy' }, + { icon: , title: '诊断报告', desc: 'Auto-generated diagnosis reports with repair recommendations' }, + { icon: , title: '趋势分析', desc: 'I-V curve degradation tracking over time' }, +]; + +// Generate a typical I-V curve: I = Isc * (1 - exp((V - Voc) / Vt)) +const generateIVData = () => { + const Isc = 10.5; + const Voc = 38; + const Vt = 3.2; + const points: { v: number; i: number; p: number }[] = []; + for (let v = 0; v <= Voc; v += 0.5) { + const i = Math.max(0, Isc * (1 - Math.exp((v - Voc) / Vt))); + points.push({ v, i, p: v * i }); + } + return points; +}; + +export default function IVDiagnosis() { + const ivData = useMemo(() => generateIVData(), []); + + const chartOption = { + tooltip: { trigger: 'axis' as const }, + legend: { data: ['电流 (A)', '功率 (W)'] }, + xAxis: { type: 'value' as const, name: '电压 (V)', min: 0, max: 40 }, + yAxis: [ + { type: 'value' as const, name: '电流 (A)', min: 0, max: 12 }, + { type: 'value' as const, name: '功率 (W)', min: 0, max: 400 }, + ], + series: [ + { + name: '电流 (A)', + type: 'line', + smooth: true, + data: ivData.map(p => [p.v, +p.i.toFixed(2)]), + lineStyle: { width: 2.5, color: '#1890ff' }, + itemStyle: { color: '#1890ff' }, + symbol: 'none', + }, + { + name: '功率 (W)', + type: 'line', + smooth: true, + yAxisIndex: 1, + data: ivData.map(p => [p.v, +p.p.toFixed(1)]), + lineStyle: { width: 2.5, color: '#fa541c' }, + itemStyle: { color: '#fa541c' }, + symbol: 'none', + areaStyle: { color: 'rgba(250,84,28,0.08)' }, + }, + ], + graphic: [ + { + type: 'text', + left: '15%', + top: '15%', + style: { text: 'Isc = 10.5A', fontSize: 12, fill: '#1890ff' }, + }, + { + type: 'text', + right: '18%', + bottom: '22%', + style: { text: 'Voc = 38V', fontSize: 12, fill: '#1890ff' }, + }, + { + type: 'text', + left: 'center', + top: '12%', + style: { text: 'Pmax = 298W @ 30.5V', fontSize: 12, fill: '#fa541c', fontWeight: 'bold' }, + }, + ], + }; + + return ( +
+ + + + {featureCards.map(card => ( + + +
{card.icon}
+ +
+ + ))} +
+ + + + +
+ ); +} diff --git a/frontend/src/pages/KnowledgeBase/index.tsx b/frontend/src/pages/KnowledgeBase/index.tsx new file mode 100644 index 0000000..c7c3c8a --- /dev/null +++ b/frontend/src/pages/KnowledgeBase/index.tsx @@ -0,0 +1,252 @@ +import { useState, useMemo } from 'react'; +import { Card, Input, Tabs, List, Tag, Typography, Empty } from 'antd'; +import { + BookOutlined, SearchOutlined, ToolOutlined, WarningOutlined, + SafetyOutlined, FileTextOutlined, +} from '@ant-design/icons'; + +const { Title, Paragraph, Text } = Typography; + +interface Article { + id: number; + category: string; + title: string; + date: string; + content: string; +} + +const CATEGORY_MAP: Record = { + maintenance: { label: '设备维护', color: 'blue', icon: }, + troubleshooting: { label: '故障排查', color: 'orange', icon: }, + safety: { label: '安全规范', color: 'red', icon: }, + operation: { label: '操作手册', color: 'green', icon: }, +}; + +const articles: Article[] = [ + { + id: 1, + category: 'maintenance', + title: '逆变器日常巡检规程', + date: '2026-04-01', + content: `## 巡检频率 +每周一次 + +## 巡检项目 +1. 检查逆变器运行指示灯状态 +2. 检查散热风扇运转是否正常 +3. 检查接线端子是否松动 +4. 记录当前功率和日发电量 +5. 检查通信模块信号强度 + +## 注意事项 +- 巡检时不得打开逆变器外壳 +- 异常情况及时上报运维主管`, + }, + { + id: 2, + category: 'troubleshooting', + title: '逆变器常见故障代码及处理', + date: '2026-04-01', + content: `## 常见故障代码 +| 代码 | 含义 | 处理方法 | +|------|------|----------| +| F01 | 电网电压过高 | 检查电网侧电压,联系供电部门 | +| F02 | 电网频率异常 | 等待电网恢复,自动并网 | +| F03 | 直流过压 | 检查组串接线,确认组串电压 | +| F04 | 绝缘阻抗低 | 检查组件和线缆绝缘 | +| F05 | 温度过高 | 清洁散热器,检查环境温度 |`, + }, + { + id: 3, + category: 'safety', + title: '光伏电站安全操作规范', + date: '2026-04-01', + content: `## 基本安全要求 +1. 所有操作必须两人以上配合 +2. 必须穿戴绝缘手套和安全帽 +3. 雷雨天气禁止室外操作 +4. 操作前确认设备已断电 +5. 使用万用表确认无残余电压`, + }, + { + id: 4, + category: 'operation', + title: 'iSolarCloud 数据导出操作指南', + date: '2026-04-01', + content: `## 导出步骤 +1. 登录 iSolarCloud 平台 +2. 进入电站详情 → 曲线 +3. 选择时间范围和参数 +4. 点击右上角导出按钮 +5. 选择 Excel 格式下载`, + }, + { + id: 5, + category: 'maintenance', + title: '光伏组件清洗规程', + date: '2026-04-01', + content: `## 清洗频率 +每季度一次(春季花粉期加密) + +## 清洗方法 +1. 使用纯净水或去离子水 +2. 软质刷子或海绵擦拭 +3. 从上到下顺序冲洗 +4. 避免使用化学清洁剂 + +## 最佳时间 +清晨或傍晚,避免组件高温时清洗`, + }, +]; + +function renderMarkdown(text: string) { + const lines = text.trim().split('\n'); + const elements: React.ReactNode[] = []; + let tableRows: string[][] = []; + let inTable = false; + + const flushTable = () => { + if (tableRows.length === 0) return; + const header = tableRows[0]; + const body = tableRows.slice(1); + elements.push( + + + + {header.map((h, i) => ( + + ))} + + + + {body.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
{h}
{cell}
+ ); + tableRows = []; + inTable = false; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('|') && line.endsWith('|')) { + const cells = line.split('|').slice(1, -1).map(c => c.trim()); + if (cells.every(c => /^[-:]+$/.test(c))) continue; // separator + tableRows.push(cells); + inTable = true; + continue; + } + if (inTable) flushTable(); + + if (line.startsWith('## ')) { + elements.push({line.slice(3)}); + } else if (/^\d+\.\s/.test(line)) { + elements.push({line}); + } else if (line.startsWith('- ')) { + elements.push({line}); + } else if (line.trim() === '') { + // skip + } else { + elements.push({line}); + } + } + if (inTable) flushTable(); + + return <>{elements}; +} + +export default function KnowledgeBase() { + const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState('all'); + const [expandedId, setExpandedId] = useState(null); + + const filtered = useMemo(() => { + return articles.filter(a => { + const matchCategory = activeTab === 'all' || a.category === activeTab; + const matchSearch = !search || a.title.includes(search) || a.content.includes(search); + return matchCategory && matchSearch; + }); + }, [search, activeTab]); + + const tabItems = [ + { key: 'all', label: '全部' }, + ...Object.entries(CATEGORY_MAP).map(([key, val]) => ({ + key, + label: {val.icon} {val.label}, + })), + ]; + + return ( +
+ + <BookOutlined style={{ marginRight: 8 }} /> + 运维知识库 + + + + } + allowClear + value={search} + onChange={e => setSearch(e.target.value)} + size="large" + /> + + + + + {filtered.length === 0 ? ( + + ) : ( + { + const cat = CATEGORY_MAP[article.category]; + const isExpanded = expandedId === article.id; + const preview = article.content.split('\n').filter(l => l.trim() && !l.startsWith('#')).slice(0, 2).join(' '); + return ( + + setExpandedId(isExpanded ? null : article.id)} + style={{ cursor: 'pointer' }} + title={ +
+ {cat?.label} + {article.title} +
+ } + extra={{article.date}} + > + {isExpanded ? ( +
+ {renderMarkdown(article.content)} +
+ ) : ( + + {preview.length > 80 ? preview.slice(0, 80) + '...' : preview} + + )} +
+
+ ); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/ROISimulator/index.tsx b/frontend/src/pages/ROISimulator/index.tsx new file mode 100644 index 0000000..b2170f1 --- /dev/null +++ b/frontend/src/pages/ROISimulator/index.tsx @@ -0,0 +1,320 @@ +import { useState, useMemo } from 'react'; +import { Card, Form, InputNumber, Button, Row, Col, Statistic, Table, Typography, Divider } from 'antd'; +import { DollarOutlined, CalculatorOutlined, ReloadOutlined } from '@ant-design/icons'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import { LineChart, BarChart } from 'echarts/charts'; +import { GridComponent, TooltipComponent, LegendComponent, MarkLineComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, MarkLineComponent, CanvasRenderer]); + +const { Title } = Typography; + +interface SimParams { + capacity: number; + costPerWp: number; + degradationRate: number; + sellPrice: number; + selfConsumption: number; + feedInTariff: number; + omRate: number; + period: number; + peakHours: number; + sunDays: number; +} + +interface YearData { + year: number; + generation: number; + revenue: number; + omCost: number; + netIncome: number; + cumulative: number; +} + +interface SimResult { + totalInvestment: number; + paybackYear: number | null; + irr: number; + npv: number; + years: YearData[]; +} + +const defaultParams: SimParams = { + capacity: 2710, + costPerWp: 3.5, + degradationRate: 0.5, + sellPrice: 0.65, + selfConsumption: 80, + feedInTariff: 0.35, + omRate: 1.0, + period: 25, + peakHours: 4.5, + sunDays: 260, +}; + +function calculateIRR(cashFlows: number[]): number { + let rate = 0.1; + for (let i = 0; i < 100; i++) { + const npv = cashFlows.reduce((sum, cf, t) => sum + cf / Math.pow(1 + rate, t), 0); + const dnpv = cashFlows.reduce((sum, cf, t) => sum - t * cf / Math.pow(1 + rate, t + 1), 0); + if (Math.abs(dnpv) < 1e-10) break; + const newRate = rate - npv / dnpv; + if (Math.abs(newRate - rate) < 1e-8) break; + rate = newRate; + } + return rate; +} + +function calculateNPV(cashFlows: number[], discountRate: number): number { + return cashFlows.reduce((sum, cf, t) => sum + cf / Math.pow(1 + discountRate, t), 0); +} + +function simulate(params: SimParams): SimResult { + const totalInvestment = params.capacity * params.costPerWp; + const years: YearData[] = []; + let cumulative = -totalInvestment; + let paybackYear: number | null = null; + + for (let y = 1; y <= params.period; y++) { + const degradation = Math.pow(1 - params.degradationRate / 100, y - 1); + const annualGen = params.capacity * params.peakHours * params.sunDays * degradation; + const selfConsumed = annualGen * params.selfConsumption / 100; + const exported = annualGen - selfConsumed; + const revenue = selfConsumed * params.sellPrice + exported * params.feedInTariff; + const omCost = totalInvestment * params.omRate / 100; + const netIncome = revenue - omCost; + cumulative += netIncome; + + if (cumulative >= 0 && paybackYear === null) paybackYear = y; + + years.push({ year: y, generation: annualGen, revenue, omCost, netIncome, cumulative }); + } + + const cashFlows = [-totalInvestment, ...years.map(y => y.netIncome)]; + const irr = calculateIRR(cashFlows); + const npv = calculateNPV(cashFlows, 0.06); + + return { totalInvestment, paybackYear, irr, npv, years }; +} + +export default function ROISimulator() { + const [form] = Form.useForm(); + const [params, setParams] = useState(defaultParams); + + const result = useMemo(() => simulate(params), [params]); + + const handleCalculate = () => { + form.validateFields().then(values => { + setParams(values as SimParams); + }); + }; + + const handleReset = () => { + form.setFieldsValue(defaultParams); + setParams(defaultParams); + }; + + const chartOption = useMemo(() => ({ + tooltip: { + trigger: 'axis', + formatter: (p: any) => { + const items = p.map((s: any) => + `${s.marker} ${s.seriesName}: ${(s.value / 10000).toFixed(2)} 万元` + ); + return `第 ${p[0].axisValue} 年
${items.join('
')}`; + }, + }, + legend: { data: ['年净收益', '累计现金流'] }, + grid: { left: 60, right: 30, bottom: 30, top: 40 }, + xAxis: { + type: 'category', + data: result.years.map(y => y.year), + name: '年', + }, + yAxis: { + type: 'value', + name: '万元', + axisLabel: { formatter: (v: number) => (v / 10000).toFixed(0) }, + }, + series: [ + { + name: '年净收益', + type: 'bar', + data: result.years.map(y => y.netIncome), + itemStyle: { color: '#52c41a' }, + barMaxWidth: 20, + }, + { + name: '累计现金流', + type: 'line', + data: result.years.map(y => y.cumulative), + itemStyle: { color: '#1890ff' }, + lineStyle: { width: 2 }, + markLine: { + silent: true, + data: [{ yAxis: 0, lineStyle: { color: '#ff4d4f', type: 'dashed' } }], + }, + }, + ], + }), [result]); + + const columns = [ + { title: '年份', dataIndex: 'year', key: 'year', width: 70 }, + { + title: '发电量 (kWh)', dataIndex: 'generation', key: 'generation', + render: (v: number) => v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), + }, + { + title: '收入 (元)', dataIndex: 'revenue', key: 'revenue', + render: (v: number) => v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), + }, + { + title: '运维成本 (元)', dataIndex: 'omCost', key: 'omCost', + render: (v: number) => v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), + }, + { + title: '净收益 (元)', dataIndex: 'netIncome', key: 'netIncome', + render: (v: number) => = 0 ? '#52c41a' : '#ff4d4f' }}> + {v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })} + , + }, + { + title: '累计 (元)', dataIndex: 'cumulative', key: 'cumulative', + render: (v: number) => = 0 ? '#52c41a' : '#ff4d4f' }}> + {v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })} + , + }, + ]; + + return ( +
+ + <CalculatorOutlined style={{ marginRight: 8 }} /> + 光伏投资回报模拟器 + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + } + /> + + + + + + + + + + 0.08 ? '#52c41a' : '#faad14' }} + /> + + + + + 0 ? '#52c41a' : '#ff4d4f' }} + /> + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/RemoteConfig/index.tsx b/frontend/src/pages/RemoteConfig/index.tsx new file mode 100644 index 0000000..5a24aac --- /dev/null +++ b/frontend/src/pages/RemoteConfig/index.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { Alert, Select, Card, Descriptions, Button, Tooltip, Timeline, Typography, Space, Row, Col } from 'antd'; +import { LockOutlined, SettingOutlined, ReadOutlined, UploadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'; +import { getDevices } from '../../services/api'; + +const { Title, Text } = Typography; + +const SAMPLE_PARAMS = [ + { label: '并网电压范围', value: '185V - 265V' }, + { label: '并网频率范围', value: '47.5Hz - 51.5Hz' }, + { label: '功率因数', value: '1.0' }, + { label: '无功功率模式', value: 'Disabled' }, + { label: '孤岛保护', value: 'Enabled' }, + { label: 'MPPT范围', value: '200V - 850V' }, + { label: '最大交流电流', value: '63A' }, +]; + +export default function RemoteConfig() { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(); + + useEffect(() => { + getDevices({ device_type: 'inverter' }).then((res: any) => { + const list = Array.isArray(res) ? res : res?.items || []; + setDevices(list); + }).catch(() => {}); + }, []); + + return ( +
+ + <SettingOutlined style={{ color: '#1890ff', marginRight: 8 }} /> + 远程配置 + + + + + +
+ + ({ label: `逆变器 ${id}`, value: id }))} /> + }> +
+ + + + + + + + + + +
+ {data.map(d => ( +
+
{d.id}
+
{d.current.toFixed(1)}A / {d.power.toFixed(0)}W
+
+ ))} +
+
+ 正常 + 低效 + 告警 +
+
+ + + + ); +}