All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 components/RotatingFooterHint:统一文案+蓝色脉冲,4 秒轮换
- 新增 components/FeedbackFab:右下角悬浮按钮(渐变 + 心形信封 + 黄色脉冲点),
点击打开 4 步引导式弹窗
Step 1 选类型(💡新维度 / 🐛bug / 🎨界面 / 📝其他)
Step 2 描述需求 + 选当前板块(chip)
Step 3 留联系方式(可选)+ 提交概览
Step 4 ❤️ 成功页(弹簧 √ 动画)
顶部 spring 进度条,底部上一步/下一步,下拉手柄,背景点击或 X 关闭
- 后端 routes/feedback:bi_user_feedback 表(自动建表,含 status 字段)
POST /api/feedback/submit + GET /api/feedback/list
- Shell 全局挂载 FeedbackFab,自动从 hash 检测当前模块
- 各模块底部追加 RotatingFooterHint:
AssetsModule / MileageModule / SchedulingModule / EleImportPage
HydrogenOverview / HydrogenDaily / ElectricOverview / ElectricDaily
(HydrogenOverview 旧的内嵌实现已替换为共享组件)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
8.2 KiB
TypeScript
202 lines
8.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
|
||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||
|
||
interface YAxisTickProps {
|
||
x?: number;
|
||
y?: number;
|
||
index?: number;
|
||
payload?: { value: string };
|
||
}
|
||
|
||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||
return (
|
||
<g transform={`translate(${x},${y})`}>
|
||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||
{index + 1}
|
||
</text>
|
||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||
{payload?.value}
|
||
</text>
|
||
</g>
|
||
);
|
||
}
|
||
|
||
const REGION_COLORS = [
|
||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||
'#94a3b8',
|
||
];
|
||
|
||
|
||
export default function HydrogenOverview() {
|
||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchHydrogenOverview()
|
||
.then(d => { if (!cancelled) setData(d); })
|
||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
if (error) {
|
||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||
}
|
||
if (!data) {
|
||
return <HydrogenOverviewSkeleton />;
|
||
}
|
||
const k = data.kpi;
|
||
const top5 = data.top5;
|
||
const regions = data.regions;
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||
数据自 2025-01-01 起,每 1 分钟更新
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{/* Top5 加氢站 */}
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={260}>
|
||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||
<XAxis type="number" hide />
|
||
<YAxis
|
||
type="category"
|
||
dataKey="name"
|
||
width={188}
|
||
tick={<RankYAxisTick />}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
/>
|
||
<Tooltip
|
||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||
/>
|
||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||
{top5.map((_, i) => (
|
||
<Cell key={i} fill={`url(#topBarGrad)`} />
|
||
))}
|
||
<LabelList
|
||
dataKey="kg"
|
||
position="right"
|
||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||
fill="#475569"
|
||
fontSize={11}
|
||
fontWeight={700}
|
||
/>
|
||
</Bar>
|
||
<defs>
|
||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||
<stop offset="0%" stopColor="#3b82f6" />
|
||
<stop offset="100%" stopColor="#22d3ee" />
|
||
</linearGradient>
|
||
</defs>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
{/* 区域占比环 */}
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative w-1/2 h-[200px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<PieChart>
|
||
<Pie
|
||
data={regions}
|
||
dataKey="kg"
|
||
nameKey="region"
|
||
innerRadius={48}
|
||
outerRadius={80}
|
||
paddingAngle={1}
|
||
>
|
||
{regions.map((_, i) => (
|
||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||
{regions.map((r, i) => (
|
||
<div key={r.region} className="flex items-center gap-1.5">
|
||
<span className="w-2 h-2 rounded-full" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||
<span className="text-slate-600">{r.region}</span>
|
||
<span className="text-slate-400 ml-auto font-bold">{(r.share * 100).toFixed(1)}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<RotatingFooterHint />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HydrogenOverviewSkeleton() {
|
||
return (
|
||
<div className="flex flex-col gap-3 animate-pulse">
|
||
{/* 顶部说明条 */}
|
||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{/* Top5 占位 */}
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||
</div>
|
||
<div className="space-y-3">
|
||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||
<div key={i} className="flex items-center gap-3">
|
||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 区域占比环 占位 */}
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||
</div>
|
||
<div className="flex-1 space-y-2">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||
正在加载氢能总览…
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|