1 Commits

Author SHA1 Message Date
Du Wenbo
7003877cb2 feat: string monitoring, ROI simulator, knowledge base, stubs (v1.6.0)
New functional pages:
- String-Level Monitoring — simulated string data with heatmap,
  comparison chart, status table (Under Construction banner)
- ROI Simulator — 25-year investment return calculator with
  IRR, NPV, payback period, cash flow chart
- Knowledge Base — O&M wiki with 5 articles, search, categories
- Mobile responsive — sidebar auto-collapse on small screens

Under Construction stubs:
- I-V Curve Diagnosis — demo chart, 4 feature preview cards
- Remote Device Configuration — parameter preview, disabled
  actions, v2.0+ roadmap timeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:46:10 +08:00
8 changed files with 961 additions and 3 deletions

View File

@@ -1,9 +1,9 @@
{ {
"project": "zpark-ems", "project": "zpark-ems",
"project_version": "1.5.0", "project_version": "1.6.0",
"customer": "Z-Park 中关村医疗器械园", "customer": "Z-Park 中关村医疗器械园",
"core_version": "1.4.0", "core_version": "1.4.0",
"frontend_template_version": "1.4.0", "frontend_template_version": "1.4.0",
"last_updated": "2026-04-06", "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"
} }

View File

@@ -23,8 +23,13 @@ import DataQuery from './pages/DataQuery';
import Management from './pages/Management'; import Management from './pages/Management';
import Prediction from './pages/Prediction'; import Prediction from './pages/Prediction';
import EnergyStrategy from './pages/EnergyStrategy'; import EnergyStrategy from './pages/EnergyStrategy';
import ROISimulator from './pages/ROISimulator';
import AIOperations from './pages/AIOperations'; import AIOperations from './pages/AIOperations';
import KnowledgeBase from './pages/KnowledgeBase';
import BigScreen from './pages/BigScreen'; 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'; import { isLoggedIn } from './utils/auth';
@@ -66,7 +71,12 @@ function AppContent() {
<Route path="management" element={<Management />} /> <Route path="management" element={<Management />} />
<Route path="prediction" element={<Prediction />} /> <Route path="prediction" element={<Prediction />} />
<Route path="energy-strategy" element={<EnergyStrategy />} /> <Route path="energy-strategy" element={<EnergyStrategy />} />
<Route path="roi-simulator" element={<ROISimulator />} />
<Route path="ai-operations" element={<AIOperations />} /> <Route path="ai-operations" element={<AIOperations />} />
<Route path="knowledge-base" element={<KnowledgeBase />} />
<Route path="string-monitoring" element={<StringMonitoring />} />
<Route path="iv-diagnosis" element={<IVDiagnosis />} />
<Route path="remote-config" element={<RemoteConfig />} />
<Route path="system/*" element={<SystemManagement />} /> <Route path="system/*" element={<SystemManagement />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -7,7 +7,8 @@ import {
ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined, ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined,
InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined, InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined,
BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined, BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined,
SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined, SearchOutlined, SolutionOutlined, RobotOutlined, ExperimentOutlined, ApartmentOutlined, ScanOutlined,
DollarOutlined, BookOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -36,6 +37,15 @@ export default function MainLayout() {
const { darkMode, toggleDarkMode } = useTheme(); const { darkMode, toggleDarkMode } = useTheme();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768) setCollapsed(true);
};
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => { useEffect(() => {
getBranding().then((res: any) => { getBranding().then((res: any) => {
setFeatures(res?.features || {}); setFeatures(res?.features || {});
@@ -64,8 +74,17 @@ export default function MainLayout() {
{ key: '/data-query', icon: <SearchOutlined />, label: t('menu.dataQuery', '数据查询') }, { key: '/data-query', icon: <SearchOutlined />, label: t('menu.dataQuery', '数据查询') },
{ key: '/prediction', icon: <RobotOutlined />, label: t('menu.prediction', 'AI预测') }, { key: '/prediction', icon: <RobotOutlined />, label: t('menu.prediction', 'AI预测') },
{ key: '/management', icon: <SolutionOutlined />, label: t('menu.management', '管理体系') }, { key: '/management', icon: <SolutionOutlined />, label: t('menu.management', '管理体系') },
{ key: '/knowledge-base', icon: <BookOutlined />, label: t('menu.knowledgeBase', '知识库') },
{ key: '/energy-strategy', icon: <ThunderboltOutlined />, label: t('menu.energyStrategy', '策略优化') }, { key: '/energy-strategy', icon: <ThunderboltOutlined />, label: t('menu.energyStrategy', '策略优化') },
{ key: '/roi-simulator', icon: <DollarOutlined />, label: t('menu.roiSimulator', '投资回报') },
{ key: '/ai-operations', icon: <ExperimentOutlined />, label: t('menu.aiOperations', 'AI运维') }, { key: '/ai-operations', icon: <ExperimentOutlined />, label: t('menu.aiOperations', 'AI运维') },
{ key: '/string-monitoring', icon: <ApartmentOutlined />, label: t('menu.stringMonitoring', '组串监控') },
{ key: '/remote-config', icon: <SettingOutlined />, label: t('menu.remoteConfig', '远程配置') },
{ key: 'diagnosis-group', icon: <ScanOutlined />, label: t('menu.diagnosis', '智能诊断'),
children: [
{ key: '/iv-diagnosis', icon: <ThunderboltOutlined />, label: t('menu.ivDiagnosis', 'IV曲线诊断') },
],
},
{ key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'), { key: 'bigscreen-group', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen'),
children: [ children: [
{ key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') }, { key: '/bigscreen', icon: <FundProjectionScreenOutlined />, label: t('menu.bigscreen2d') },

View File

@@ -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: <ScanOutlined style={{ fontSize: 32, color: '#1890ff' }} />, title: '在线IV扫描', desc: 'Remote I-V curve scanning at string level' },
{ icon: <BugOutlined style={{ fontSize: 32, color: '#fa541c' }} />, title: '23种故障诊断', desc: 'Automatic fault classification with 97% accuracy' },
{ icon: <FileTextOutlined style={{ fontSize: 32, color: '#52c41a' }} />, title: '诊断报告', desc: 'Auto-generated diagnosis reports with repair recommendations' },
{ icon: <LineChartOutlined style={{ fontSize: 32, color: '#722ed1' }} />, 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 (
<div>
<Alert
type="warning"
showIcon
message="智能IV曲线诊断 — 功能开发中"
description="Smart I-V Curve Diagnosis requires inverter hardware support for online I-V scanning. This feature will support 23 fault types with 97% accuracy when hardware is available."
style={{ marginBottom: 16 }}
/>
<Row gutter={16} style={{ marginBottom: 16 }}>
{featureCards.map(card => (
<Col span={6} key={card.title}>
<Card hoverable style={{ textAlign: 'center', height: '100%' }}>
<div style={{ marginBottom: 12 }}>{card.icon}</div>
<Card.Meta title={card.title} description={card.desc} />
</Card>
</Col>
))}
</Row>
<Card size="small" title="示例 I-V 曲线 (Sample I-V Curve)">
<ReactECharts option={chartOption} style={{ height: 380 }} />
</Card>
</div>
);
}

View File

@@ -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<string, { label: string; color: string; icon: React.ReactNode }> = {
maintenance: { label: '设备维护', color: 'blue', icon: <ToolOutlined /> },
troubleshooting: { label: '故障排查', color: 'orange', icon: <WarningOutlined /> },
safety: { label: '安全规范', color: 'red', icon: <SafetyOutlined /> },
operation: { label: '操作手册', color: 'green', icon: <FileTextOutlined /> },
};
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(
<table key={`table-${elements.length}`} style={{
width: '100%', borderCollapse: 'collapse', margin: '8px 0', fontSize: 13,
}}>
<thead>
<tr>
{header.map((h, i) => (
<th key={i} style={{
border: '1px solid #e8e8e8', padding: '6px 10px', background: '#fafafa', textAlign: 'left',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} style={{ border: '1px solid #e8e8e8', padding: '6px 10px' }}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
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(<Title level={5} key={i} style={{ marginTop: 12, marginBottom: 4 }}>{line.slice(3)}</Title>);
} else if (/^\d+\.\s/.test(line)) {
elements.push(<Paragraph key={i} style={{ margin: '2px 0 2px 16px' }}>{line}</Paragraph>);
} else if (line.startsWith('- ')) {
elements.push(<Paragraph key={i} style={{ margin: '2px 0 2px 16px' }}>{line}</Paragraph>);
} else if (line.trim() === '') {
// skip
} else {
elements.push(<Paragraph key={i} style={{ margin: '4px 0' }}>{line}</Paragraph>);
}
}
if (inTable) flushTable();
return <>{elements}</>;
}
export default function KnowledgeBase() {
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState('all');
const [expandedId, setExpandedId] = useState<number | null>(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: <span>{val.icon} {val.label}</span>,
})),
];
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<BookOutlined style={{ marginRight: 8 }} />
</Title>
<Card size="small" style={{ marginBottom: 16 }}>
<Input
placeholder="搜索文章标题或内容..."
prefix={<SearchOutlined />}
allowClear
value={search}
onChange={e => setSearch(e.target.value)}
size="large"
/>
</Card>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
{filtered.length === 0 ? (
<Empty description="未找到相关文章" />
) : (
<List
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 2 }}
dataSource={filtered}
renderItem={article => {
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 (
<List.Item>
<Card
size="small"
hoverable
onClick={() => setExpandedId(isExpanded ? null : article.id)}
style={{ cursor: 'pointer' }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color={cat?.color}>{cat?.label}</Tag>
<span>{article.title}</span>
</div>
}
extra={<Text type="secondary" style={{ fontSize: 12 }}>{article.date}</Text>}
>
{isExpanded ? (
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{renderMarkdown(article.content)}
</div>
) : (
<Text type="secondary" ellipsis>
{preview.length > 80 ? preview.slice(0, 80) + '...' : preview}
</Text>
)}
</Card>
</List.Item>
);
}}
/>
)}
</div>
);
}

View File

@@ -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<SimParams>(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} 年<br/>${items.join('<br/>')}`;
},
},
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) => <span style={{ color: v >= 0 ? '#52c41a' : '#ff4d4f' }}>
{v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
</span>,
},
{
title: '累计 (元)', dataIndex: 'cumulative', key: 'cumulative',
render: (v: number) => <span style={{ color: v >= 0 ? '#52c41a' : '#ff4d4f' }}>
{v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
</span>,
},
];
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<CalculatorOutlined style={{ marginRight: 8 }} />
</Title>
<Row gutter={16}>
<Col xs={24} lg={8}>
<Card title="输入参数" size="small">
<Form form={form} layout="vertical" initialValues={defaultParams} size="small">
<Form.Item label="装机容量 (kWp)" name="capacity" rules={[{ required: true }]}>
<InputNumber min={1} max={100000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="投资单价 (元/Wp)" name="costPerWp" rules={[{ required: true }]}>
<InputNumber min={0.1} max={20} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="年衰减率 (%)" name="degradationRate" rules={[{ required: true }]}>
<InputNumber min={0} max={5} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="自用电价 (元/kWh)" name="sellPrice" rules={[{ required: true }]}>
<InputNumber min={0} max={5} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="自消纳比例 (%)" name="selfConsumption" rules={[{ required: true }]}>
<InputNumber min={0} max={100} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="上网电价 (元/kWh)" name="feedInTariff" rules={[{ required: true }]}>
<InputNumber min={0} max={5} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="年运维费率 (% 总投资)" name="omRate" rules={[{ required: true }]}>
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="模拟年限" name="period" rules={[{ required: true }]}>
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="日均峰值日照 (小时)" name="peakHours" rules={[{ required: true }]}>
<InputNumber min={1} max={10} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="年日照天数" name="sunDays" rules={[{ required: true }]}>
<InputNumber min={100} max={365} style={{ width: '100%' }} />
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Button type="primary" icon={<CalculatorOutlined />} block onClick={handleCalculate}>
</Button>
</Col>
<Col span={12}>
<Button icon={<ReloadOutlined />} block onClick={handleReset}>
</Button>
</Col>
</Row>
</Form>
</Card>
</Col>
<Col xs={24} lg={16}>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="总投资"
value={result.totalInvestment / 10000}
precision={1}
suffix="万元"
prefix={<DollarOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="回收期"
value={result.paybackYear ?? '-'}
suffix={result.paybackYear ? '年' : ''}
valueStyle={{ color: result.paybackYear && result.paybackYear <= 8 ? '#52c41a' : '#faad14' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="25年IRR"
value={(result.irr * 100)}
precision={2}
suffix="%"
valueStyle={{ color: result.irr > 0.08 ? '#52c41a' : '#faad14' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="25年NPV"
value={result.npv / 10000}
precision={1}
suffix="万元"
valueStyle={{ color: result.npv > 0 ? '#52c41a' : '#ff4d4f' }}
/>
</Card>
</Col>
</Row>
<Card size="small" title="累计现金流" style={{ marginTop: 16 }}>
<ReactEChartsCore
echarts={echarts}
option={chartOption}
style={{ height: 320 }}
notMerge
/>
</Card>
<Divider />
<Card size="small" title="年度明细">
<Table
columns={columns}
dataSource={result.years}
rowKey="year"
size="small"
pagination={{ pageSize: 10, size: 'small' }}
scroll={{ x: 600 }}
/>
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -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<any[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string | undefined>();
useEffect(() => {
getDevices({ device_type: 'inverter' }).then((res: any) => {
const list = Array.isArray(res) ? res : res?.items || [];
setDevices(list);
}).catch(() => {});
}, []);
return (
<div>
<Title level={4} style={{ margin: '0 0 16px' }}>
<SettingOutlined style={{ color: '#1890ff', marginRight: 8 }} />
</Title>
<Alert
type="warning"
showIcon
message="远程参数配置 — 功能开发中"
description="Remote device configuration requires secure command channels to inverters. This feature is planned for v2.0 when hardware integration is available."
style={{ marginBottom: 24 }}
/>
<Row gutter={[16, 16]}>
<Col xs={24} lg={16}>
<Card title="设备选择" size="small" style={{ marginBottom: 16 }}>
<Select
placeholder="选择逆变器设备"
style={{ width: '100%' }}
value={selectedDevice}
onChange={setSelectedDevice}
options={devices.map((d: any) => ({
label: d.name || d.device_name || d.id,
value: d.id || d.device_id,
}))}
allowClear
/>
</Card>
<Card title={<><LockOutlined style={{ marginRight: 8 }} /></>} size="small">
<Descriptions bordered size="small" column={1}>
{SAMPLE_PARAMS.map((p) => (
<Descriptions.Item
key={p.label}
label={<><LockOutlined style={{ color: '#d9d9d9', marginRight: 6 }} />{p.label}</>}
>
<Text type="secondary">{p.value}</Text>
</Descriptions.Item>
))}
</Descriptions>
<Space wrap style={{ marginTop: 16 }}>
<Tooltip title="Coming Soon">
<Button icon={<ReadOutlined />} disabled></Button>
</Tooltip>
<Tooltip title="Coming Soon">
<Button icon={<UploadOutlined />} disabled></Button>
</Tooltip>
<Tooltip title="Coming Soon">
<Button icon={<RocketOutlined />} disabled></Button>
</Tooltip>
<Tooltip title="Coming Soon">
<Button icon={<ReloadOutlined />} disabled></Button>
</Tooltip>
</Space>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card title="功能路线图" size="small">
<Timeline
items={[
{ color: 'green', children: <><Text strong>v1.6</Text> UI Preview ()</> },
{ color: 'blue', children: <><Text strong>v2.0</Text> 访</> },
{ color: 'blue', children: <><Text strong>v2.1</Text> </> },
{ color: 'gray', children: <><Text strong>v2.2</Text> OTA </> },
]}
/>
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState, useMemo } from 'react';
import { Card, Select, Table, Tag, Alert, Row, Col } from 'antd';
import { ApartmentOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
const INVERTERS = [
'AP101', 'AP102', 'AP103', 'AP104', 'AP105', 'AP106', 'AP107', 'AP108',
'AP201', 'AP202', 'AP203', 'AP204', 'AP205', 'AP206', 'AP207', 'AP208',
];
interface StringData {
id: string;
voltage: number;
current: number;
power: number;
status: 'normal' | 'low' | 'alarm';
}
const generateStringData = (inverterId: string): StringData[] => {
const count = inverterId.startsWith('AP1') ? 8 : 16;
return Array.from({ length: count }, (_, i) => {
const voltage = 320 + Math.random() * 80;
const current = 8 + Math.random() * 4;
const isLow = Math.random() < 0.1;
const isAlarm = Math.random() < 0.05;
const v = isAlarm ? voltage * 0.3 : isLow ? voltage * 0.7 : voltage;
const c = isAlarm ? current * 0.2 : isLow ? current * 0.6 : current;
return {
id: `STR-${String(i + 1).padStart(2, '0')}`,
voltage: v,
current: c,
power: v * c,
status: isAlarm ? 'alarm' : isLow ? 'low' : 'normal',
};
});
};
const statusMap: Record<string, { color: string; text: string }> = {
normal: { color: 'green', text: '正常' },
low: { color: 'orange', text: '低效' },
alarm: { color: 'red', text: '告警' },
};
const heatColor = (status: string, current: number, mean: number) => {
if (status === 'alarm') return '#ff4d4f';
if (status === 'low') return '#faad14';
if (current >= mean) return '#52c41a';
return '#95de64';
};
export default function StringMonitoring() {
const [inverterId, setInverterId] = useState(INVERTERS[0]);
const data = useMemo(() => generateStringData(inverterId), [inverterId]);
const meanCurrent = useMemo(() => data.reduce((s, d) => s + d.current, 0) / data.length, [data]);
const columns = [
{ title: '组串ID', dataIndex: 'id', key: 'id' },
{ title: '电压 (V)', dataIndex: 'voltage', key: 'voltage', render: (v: number) => v.toFixed(1) },
{ title: '电流 (A)', dataIndex: 'current', key: 'current', render: (v: number) => v.toFixed(2) },
{ title: '功率 (W)', dataIndex: 'power', key: 'power', render: (v: number) => v.toFixed(0) },
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {
const st = statusMap[s] || { color: 'default', text: s };
return <Tag color={st.color}>{st.text}</Tag>;
}},
];
const barOption = {
tooltip: { trigger: 'axis' as const },
xAxis: { type: 'category' as const, data: data.map(d => d.id) },
yAxis: { type: 'value' as const, name: '电流 (A)' },
series: [
{
type: 'bar',
data: data.map(d => ({
value: +d.current.toFixed(2),
itemStyle: { color: statusMap[d.status]?.color === 'green' ? '#52c41a' : statusMap[d.status]?.color === 'orange' ? '#faad14' : '#ff4d4f' },
})),
barMaxWidth: 40,
},
{
type: 'line',
markLine: {
silent: true,
symbol: 'none',
data: [{ yAxis: +meanCurrent.toFixed(2), label: { formatter: `均值 ${meanCurrent.toFixed(2)}A` } }],
lineStyle: { color: '#1890ff', type: 'dashed' as const },
},
data: [],
},
],
};
// Heatmap grid
const gridCols = data.length <= 8 ? 4 : 4;
const gridRows = Math.ceil(data.length / gridCols);
return (
<div>
<Alert
type="info"
showIcon
message="组串级监控 — 数据接入开发中"
description="String-level monitoring data integration is under development. Currently showing simulated data for UI preview."
style={{ marginBottom: 16 }}
/>
<Card size="small" title={<><ApartmentOutlined /> </>} extra={
<Select value={inverterId} onChange={setInverterId} style={{ width: 140 }}
options={INVERTERS.map(id => ({ label: `逆变器 ${id}`, value: id }))} />
}>
<Table columns={columns} dataSource={data} rowKey="id" size="small" pagination={false} />
</Card>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={14}>
<Card size="small" title="组串电流对比">
<ReactECharts option={barOption} style={{ height: 300 }} />
</Card>
</Col>
<Col span={10}>
<Card size="small" title="组串性能热力图">
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${gridCols}, 1fr)`, gap: 8, padding: 8 }}>
{data.map(d => (
<div key={d.id} style={{
background: heatColor(d.status, d.current, meanCurrent),
borderRadius: 8, padding: '12px 8px', textAlign: 'center', color: '#fff',
fontWeight: 600, fontSize: 13, minHeight: 60,
display: 'flex', flexDirection: 'column', justifyContent: 'center',
}}>
<div>{d.id}</div>
<div style={{ fontSize: 11, opacity: 0.9 }}>{d.current.toFixed(1)}A / {d.power.toFixed(0)}W</div>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 16, justifyContent: 'center', marginTop: 12, fontSize: 12 }}>
<span><span style={{ display: 'inline-block', width: 12, height: 12, background: '#52c41a', borderRadius: 2, marginRight: 4 }} /></span>
<span><span style={{ display: 'inline-block', width: 12, height: 12, background: '#faad14', borderRadius: 2, marginRight: 4 }} /></span>
<span><span style={{ display: 'inline-block', width: 12, height: 12, background: '#ff4d4f', borderRadius: 2, marginRight: 4 }} /></span>
</div>
</Card>
</Col>
</Row>
</div>
);
}