diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index bbbbc4b..8d12c7a 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -3,11 +3,18 @@ import { ChevronRight } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import TrendBadge from './TrendBadge'; import { fetchElectricMonthly } from './api'; -import type { CustomerType, ElectricMonthGroup } from './types'; +import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; 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() { const [customer, setCustomer] = useState('lingniu'); + const [pick, setPick] = useState('last15'); const [months, setMonths] = useState(null); const [openMonths, setOpenMonths] = useState>(new Set()); const [error, setError] = useState(null); @@ -15,7 +22,7 @@ export default function ElectricDaily() { useEffect(() => { let cancelled = false; setError(null); - fetchElectricMonthly(customer) + fetchElectricMonthly(customer, pick) .then(m => { if (cancelled) return; setMonths(m); @@ -24,7 +31,7 @@ export default function ElectricDaily() { }) .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); return () => { cancelled = true; }; - }, [customer]); + }, [customer, pick]); const toggleMonth = (m: string) => setOpenMonths(prev => { const next = new Set(prev); @@ -34,6 +41,23 @@ export default function ElectricDaily() { return (
+ {/* 日期速选 */} +
+ {QUICK_PICK_OPTIONS.map(opt => ( + + ))} +
+ {/* 客户类型 */}
{(['lingniu', 'external'] as const).map(c => ( diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index caacd32..76cf2fc 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -8,16 +8,13 @@ import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import RotatingFooterHint from '../../components/RotatingFooterHint'; const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ - { id: 'today', label: '当天' }, - { id: 'thisWeek', label: '本周' }, - { id: 'thisMonth', label: '本月' }, - { id: 'thisQuarter', label: '本季度' }, - { id: 'last7', label: '最近7天' }, - { id: 'last30', label: '最近30天' }, + { id: 'thisWeek', label: '本周' }, + { id: 'thisMonth', label: '本月' }, + { id: 'last15', label: '近 15 天' }, ]; export default function HydrogenDaily() { - const [pick, setPick] = useState('last30'); + const [pick, setPick] = useState('last15'); const [customer, setCustomer] = useState('lingniu'); const [expanded, setExpanded] = useState>(new Set()); const [rows, setRows] = useState(null); diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 5130f37..2ee72ea 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -31,7 +31,7 @@ export function fetchElectricOverview(): Promise { return fetchJson(`${BASE}/electric/overview`); } -export function fetchElectricMonthly(customer: CustomerType): Promise { - const q = new URLSearchParams({ customer }); +export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise { + const q = new URLSearchParams({ customer, range }); return fetchJson(`${BASE}/electric/monthly?${q.toString()}`); } diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 5c3f4c7..304c57a 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -1,5 +1,5 @@ export type CustomerType = 'external' | 'lingniu'; -export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30'; +export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15'; export interface HydrogenKpi { yearKg: number; diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 6fa4987..6066c4a 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -20,19 +20,42 @@ function customerClause(field: string, customer: CustomerKind): string { return '1=1'; } -type Range = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30'; +type Range = 'thisWeek' | 'thisMonth' | 'last15'; function rangeClause(localExpr: string, range: Range): string { switch (range) { - case 'today': return `DATE(${localExpr}) = CURDATE()`; case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`; 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 '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()`; + case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 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 + 区域占比 // ========================================================= @@ -151,7 +174,7 @@ app.get('/hydrogen/overview', 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 data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => { @@ -232,25 +255,36 @@ app.get('/hydrogen/daily', async (c) => { } } - // 组装为 HydrogenDailyRow[],按日期降序 - const result = Array.from(dayMap.entries()) - .map(([date, info]) => ({ + // 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[] + const allDates = enumerateDates(range); + const fullDays = allDates.map(date => { + const info = dayMap.get(date); + return { date, - totalKg: Math.round(info.totalKg * 100) / 100, + totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0, chainPct: dayChainPct.get(date) ?? 0, customerType: customer === 'lingniu' ? 'lingniu' : 'external', - stations: info.stations - .slice() - .sort((a, b) => b.kg - a.kg) - .map(s => ({ - name: s.name, - pricePerKg: Math.round(s.pricePerKg * 100) / 100, - kg: Math.round(s.kg * 100) / 100, - chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0, - })), - })) - .sort((a, b) => b.date.localeCompare(a.date)); + stations: info + ? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({ + name: s.name, + pricePerKg: Math.round(s.pricePerKg * 100) / 100, + kg: Math.round(s.kg * 100) / 100, + chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0, + })) + : [], + }; + }); + // 全量日期重算环比(含补零日,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 c.json(data); @@ -339,60 +373,77 @@ app.get('/electric/overview', async (c) => { // ========================================================= // 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record +// 支持 range 参数(thisWeek / thisMonth / last15) +// 缺失日期补零 // ========================================================= app.get('/electric/monthly', async (c) => { 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=外部 let kindClause = '1=1'; if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`; if (customer === 'external') kindClause = `vehicle_kind = 'external'`; - // 取最近 6 个月 const [rows] = await pool.query( - `SELECT DATE_FORMAT(start_time, '%Y-%m') AS month, - DATE_FORMAT(start_time, '%Y-%m-%d') AS date, + `SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date, SUM(kwh) AS kwh, SUM(fee) AS fee FROM bi_ele_charge_record WHERE ${kindClause} - AND start_time >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) - GROUP BY month, date - ORDER BY date DESC`, + AND ${rangeClause('start_time', range)} + GROUP BY date`, ); - // 组装 month group with daily rows + chainPct - const monthMap = new Map>(); + // 实际数据 map + const dataMap = new Map(); for (const r of rows) { - const m = r.month as string; - if (!monthMap.has(m)) monthMap.set(m, []); - monthMap.get(m)!.push({ - date: r.date as string, + dataMap.set(r.date as string, { kwh: Number(r.kwh) || 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(); + 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()) .sort((a, b) => b[0].localeCompare(a[0])) - .map(([month, daysDesc]) => { - // 计算环比:daysDesc 是 DESC,需要按 ASC 算 - const asc = [...daysDesc].sort((a, b) => a.date.localeCompare(b.date)); + .map(([month, days]) => { + const asc = [...days].sort((a, b) => a.date.localeCompare(b.date)); const chain = new Map(); - for (let i = 1; i < asc.length; i++) { - const prev = asc[i - 1].kwh; - chain.set(asc[i].date, prev > 0 ? (asc[i].kwh - prev) / prev : 0); + let prev = 0; + for (const d of asc) { + 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, - kwh: Math.round(d.kwh * 100) / 100, - fee: Math.round(d.fee * 100) / 100, + kwh: d.kwh, + fee: d.fee, chainPct: chain.get(d.date) ?? 0, })); - const kwhSum = daysDesc.reduce((s, d) => s + d.kwh, 0); - const feeSum = daysDesc.reduce((s, d) => s + d.fee, 0); + const kwhSum = days.reduce((s, d) => s + d.kwh, 0); + const feeSum = days.reduce((s, d) => s + d.fee, 0); return { month, kwh: Math.round(kwhSum * 100) / 100,