feat(energy): 氢/电统一时间速选为「本周/本月/近15天」+ 缺失日补 0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端 Range 类型精简到 thisWeek / thisMonth / last15 rangeClause 同步精简;删除 today / thisQuarter / last7 / last30 分支 - 新增 enumerateDates(range):列出 range 内全部日期,用于补零 - /hydrogen/daily:用 enumerateDates 补齐缺失日期 totalKg=0、stations=[] 补零后基于完整日期序列重算环比(0→上一日有值时显示 -100%) - /electric/monthly:增加 range 参数,扁平日聚合 + 月份分组 缺失日期同样补零;环比基于补零序列重算 - 默认 range 改 last15 前端 - HydrogenDaily QUICK_PICK_OPTIONS 收紧到 3 项,默认 last15 - ElectricDaily 之前没有日期速选,现按氢能样式加上同样 3 项 类型 DateQuickPick 改 'thisWeek' | 'thisMonth' | 'last15' Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,18 @@ import { ChevronRight } from 'lucide-react';
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly } from './api';
|
||||||
import type { CustomerType, ElectricMonthGroup } from './types';
|
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'last15', label: '近 15 天' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ElectricDaily() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
|
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -15,7 +22,7 @@ export default function ElectricDaily() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchElectricMonthly(customer)
|
fetchElectricMonthly(customer, pick)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMonths(m);
|
setMonths(m);
|
||||||
@@ -24,7 +31,7 @@ export default function ElectricDaily() {
|
|||||||
})
|
})
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [customer]);
|
}, [customer, pick]);
|
||||||
|
|
||||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -34,6 +41,23 @@ export default function ElectricDaily() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* 日期速选 */}
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||||||
|
{QUICK_PICK_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => setPick(opt.id)}
|
||||||
|
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||||||
|
pick === opt.id
|
||||||
|
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||||
|
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 客户类型 */}
|
{/* 客户类型 */}
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
{(['lingniu', 'external'] as const).map(c => (
|
||||||
|
|||||||
@@ -8,16 +8,13 @@ import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
|||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
{ id: 'today', label: '当天' },
|
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'thisWeek', label: '本周' },
|
||||||
{ id: 'thisMonth', label: '本月' },
|
{ id: 'thisMonth', label: '本月' },
|
||||||
{ id: 'thisQuarter', label: '本季度' },
|
{ id: 'last15', label: '近 15 天' },
|
||||||
{ id: 'last7', label: '最近7天' },
|
|
||||||
{ id: 'last30', label: '最近30天' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last30');
|
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
|||||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricMonthly(customer: CustomerType): Promise<ElectricMonthGroup[]> {
|
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||||
const q = new URLSearchParams({ customer });
|
const q = new URLSearchParams({ customer, range });
|
||||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type CustomerType = 'external' | 'lingniu';
|
export type CustomerType = 'external' | 'lingniu';
|
||||||
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||||
|
|
||||||
export interface HydrogenKpi {
|
export interface HydrogenKpi {
|
||||||
yearKg: number;
|
yearKg: number;
|
||||||
|
|||||||
@@ -20,19 +20,42 @@ function customerClause(field: string, customer: CustomerKind): string {
|
|||||||
return '1=1';
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
type Range = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||||||
|
|
||||||
function rangeClause(localExpr: string, range: Range): string {
|
function rangeClause(localExpr: string, range: Range): string {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': return `DATE(${localExpr}) = CURDATE()`;
|
|
||||||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||||||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||||||
case 'thisQuarter': return `YEAR(${localExpr}) = YEAR(CURDATE()) AND QUARTER(${localExpr}) = QUARTER(CURDATE())`;
|
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
||||||
case 'last7': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND CURDATE()`;
|
|
||||||
case 'last30': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 29 DAY) AND CURDATE()`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||||
|
function enumerateDates(range: Range): string[] {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
let start: Date;
|
||||||
|
if (range === 'thisWeek') {
|
||||||
|
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
||||||
|
const day = today.getDay() || 7; // 周日 7
|
||||||
|
start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - (day - 1));
|
||||||
|
} else if (range === 'thisMonth') {
|
||||||
|
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
} else {
|
||||||
|
start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - 14);
|
||||||
|
}
|
||||||
|
const result: string[] = [];
|
||||||
|
const cur = new Date(start);
|
||||||
|
while (cur <= today) {
|
||||||
|
result.push(fmt(cur));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 氢能 总览:KPI + Top5 + 区域占比
|
// 氢能 总览:KPI + Top5 + 区域占比
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -151,7 +174,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last30') as Range;
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||||
|
|
||||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||||
@@ -232,25 +255,36 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组装为 HydrogenDailyRow[],按日期降序
|
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||||
const result = Array.from(dayMap.entries())
|
const allDates = enumerateDates(range);
|
||||||
.map(([date, info]) => ({
|
const fullDays = allDates.map(date => {
|
||||||
|
const info = dayMap.get(date);
|
||||||
|
return {
|
||||||
date,
|
date,
|
||||||
totalKg: Math.round(info.totalKg * 100) / 100,
|
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||||
chainPct: dayChainPct.get(date) ?? 0,
|
chainPct: dayChainPct.get(date) ?? 0,
|
||||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||||
stations: info.stations
|
stations: info
|
||||||
.slice()
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
.sort((a, b) => b.kg - a.kg)
|
|
||||||
.map(s => ({
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||||||
kg: Math.round(s.kg * 100) / 100,
|
kg: Math.round(s.kg * 100) / 100,
|
||||||
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
||||||
})),
|
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.date.localeCompare(a.date));
|
: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
||||||
|
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
let prevKg = 0;
|
||||||
|
for (const d of ascDays) {
|
||||||
|
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
||||||
|
prevKg = d.totalKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期降序返回
|
||||||
|
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
@@ -339,60 +373,77 @@ app.get('/electric/overview', async (c) => {
|
|||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||||||
|
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||||||
|
// 缺失日期补零
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/electric/monthly', async (c) => {
|
app.get('/electric/monthly', async (c) => {
|
||||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||||
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
|
|
||||||
const data = await cached(`electric/monthly?customer=${customer}`, async () => {
|
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||||
|
|
||||||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||||
let kindClause = '1=1';
|
let kindClause = '1=1';
|
||||||
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||||||
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||||||
|
|
||||||
// 取最近 6 个月
|
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(start_time, '%Y-%m') AS month,
|
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||||
DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
|
||||||
SUM(kwh) AS kwh,
|
SUM(kwh) AS kwh,
|
||||||
SUM(fee) AS fee
|
SUM(fee) AS fee
|
||||||
FROM bi_ele_charge_record
|
FROM bi_ele_charge_record
|
||||||
WHERE ${kindClause}
|
WHERE ${kindClause}
|
||||||
AND start_time >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
AND ${rangeClause('start_time', range)}
|
||||||
GROUP BY month, date
|
GROUP BY date`,
|
||||||
ORDER BY date DESC`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 组装 month group with daily rows + chainPct
|
// 实际数据 map
|
||||||
const monthMap = new Map<string, Array<{ date: string; kwh: number; fee: number }>>();
|
const dataMap = new Map<string, { kwh: number; fee: number }>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const m = r.month as string;
|
dataMap.set(r.date as string, {
|
||||||
if (!monthMap.has(m)) monthMap.set(m, []);
|
|
||||||
monthMap.get(m)!.push({
|
|
||||||
date: r.date as string,
|
|
||||||
kwh: Number(r.kwh) || 0,
|
kwh: Number(r.kwh) || 0,
|
||||||
fee: Number(r.fee) || 0,
|
fee: Number(r.fee) || 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 补零:枚举 range 全部日期
|
||||||
|
const allDates = enumerateDates(range);
|
||||||
|
const fullDays = allDates.map(date => {
|
||||||
|
const d = dataMap.get(date);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
|
||||||
|
fee: d ? Math.round(d.fee * 100) / 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
|
||||||
|
const monthMap = new Map<string, typeof fullDays>();
|
||||||
|
for (const d of fullDays) {
|
||||||
|
const m = d.date.slice(0, 7);
|
||||||
|
if (!monthMap.has(m)) monthMap.set(m, []);
|
||||||
|
monthMap.get(m)!.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
const months = Array.from(monthMap.entries())
|
const months = Array.from(monthMap.entries())
|
||||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
.map(([month, daysDesc]) => {
|
.map(([month, days]) => {
|
||||||
// 计算环比:daysDesc 是 DESC,需要按 ASC 算
|
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
const asc = [...daysDesc].sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
const chain = new Map<string, number>();
|
const chain = new Map<string, number>();
|
||||||
for (let i = 1; i < asc.length; i++) {
|
let prev = 0;
|
||||||
const prev = asc[i - 1].kwh;
|
for (const d of asc) {
|
||||||
chain.set(asc[i].date, prev > 0 ? (asc[i].kwh - prev) / prev : 0);
|
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
|
||||||
|
prev = d.kwh;
|
||||||
}
|
}
|
||||||
const rowsWithChain = daysDesc.map(d => ({
|
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
const rowsWithChain = desc.map(d => ({
|
||||||
date: d.date,
|
date: d.date,
|
||||||
kwh: Math.round(d.kwh * 100) / 100,
|
kwh: d.kwh,
|
||||||
fee: Math.round(d.fee * 100) / 100,
|
fee: d.fee,
|
||||||
chainPct: chain.get(d.date) ?? 0,
|
chainPct: chain.get(d.date) ?? 0,
|
||||||
}));
|
}));
|
||||||
const kwhSum = daysDesc.reduce((s, d) => s + d.kwh, 0);
|
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
|
||||||
const feeSum = daysDesc.reduce((s, d) => s + d.fee, 0);
|
const feeSum = days.reduce((s, d) => s + d.fee, 0);
|
||||||
return {
|
return {
|
||||||
month,
|
month,
|
||||||
kwh: Math.round(kwhSum * 100) / 100,
|
kwh: Math.round(kwhSum * 100) / 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user