"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Input, Label, Spinner, TextField, toast } from "@heroui/react"; import { TagDollar, Lock, ArrowRotateRight, ChartLine } from "@gravity-ui/icons"; // TagDollar is used in the page header badge import { useSession } from "@/lib/auth-client"; import { api } from "@/lib/api"; // ───────────────────────────────────────────────────────────────────────────── // Types (designed for future backend API integration) // Backend API: PUT /api/tariff // ───────────────────────────────────────────────────────────────────────────── export type PriceTier = "peak" | "valley" | "flat"; /** * Compact time slot for the backend API. * `start`: inclusive hour (0–23), `end`: exclusive hour (1–24). * Example: { start: 8, end: 12, tier: "peak" } → 08:00–12:00 峰时 */ export type TimeSlot = { start: number; end: number; tier: PriceTier; }; export type TierPricing = { /** Grid electricity tariff in CNY/kWh */ electricityPrice: number; /** Charging service fee in CNY/kWh */ serviceFee: number; }; /** * Full tariff configuration payload. * Intended for: PUT /api/tariff */ export type TariffConfig = { /** Compact slot representation — what gets stored in the DB */ slots: TimeSlot[]; /** Electricity price + service fee breakdown per tier, in CNY/kWh */ prices: Record; }; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── /** Compress a 24-element hourly schedule into minimal time slots */ function scheduleToSlots(schedule: PriceTier[]): TimeSlot[] { const slots: TimeSlot[] = []; let i = 0; while (i < 24) { const tier = schedule[i]; let j = i + 1; while (j < 24 && schedule[j] === tier) j++; slots.push({ start: i, end: j, tier }); i = j; } return slots; } // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── const TIERS: PriceTier[] = ["peak", "valley", "flat"]; const TIER_META: Record< PriceTier, { label: string; sublabel: string; cellBg: string; activeBorder: string; activeBg: string; activeText: string; dotClass: string; } > = { peak: { label: "峰时", sublabel: "高峰时段", cellBg: "bg-orange-500/70", activeBorder: "border-orange-500/60", activeBg: "bg-orange-500/10", activeText: "text-orange-500", dotClass: "bg-orange-500", }, valley: { label: "谷时", sublabel: "低谷时段", cellBg: "bg-blue-500/65", activeBorder: "border-blue-500/60", activeBg: "bg-blue-500/10", activeText: "text-blue-500", dotClass: "bg-blue-500", }, flat: { label: "平时", sublabel: "肩峰时段", cellBg: "bg-neutral-400/45", activeBorder: "border-neutral-400/60", activeBg: "bg-neutral-400/10", activeText: "text-neutral-400", dotClass: "bg-neutral-400", }, }; /** * A typical residential TOU schedule for the Shanghai area (illustrative). * 00–07 谷, 08–11 峰, 12 平, 13–16 峰, 17–21 峰, 22–23 谷 */ const DEFAULT_SCHEDULE: PriceTier[] = [ // 0-5 "valley", "valley", "valley", "valley", "valley", "valley", // 6-11 "flat", "flat", "peak", "peak", "peak", "flat", // 12-17 "peak", "peak", "flat", "flat", "flat", "peak", // 18-23 "peak", "peak", "flat", "flat", "valley", "valley", ]; const DEFAULT_PRICES: Record = { peak: { electricityPrice: 1.0, serviceFee: 0.2 }, valley: { electricityPrice: 0.3, serviceFee: 0.1 }, flat: { electricityPrice: 0.65, serviceFee: 0.15 }, }; // ───────────────────────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────────────────────── export default function PricingPage() { const { data: session } = useSession(); const isAdmin = session?.user?.role === "admin"; const queryClient = useQueryClient(); // Load current tariff from API const { data: remoteTariff, isLoading: loadingTariff } = useQuery({ queryKey: ["tariff"], queryFn: () => api.tariff.get(), enabled: isAdmin, }); const [schedule, setSchedule] = useState([...DEFAULT_SCHEDULE]); // ── Price inputs: keep as strings to avoid controlled input jitter ────── type TierPricingStrings = { electricityPrice: string; serviceFee: string }; const toStrings = (p: TierPricing): TierPricingStrings => ({ electricityPrice: String(p.electricityPrice), serviceFee: String(p.serviceFee), }); const [priceStrings, setPriceStrings] = useState>({ peak: toStrings(DEFAULT_PRICES.peak), valley: toStrings(DEFAULT_PRICES.valley), flat: toStrings(DEFAULT_PRICES.flat), }); const [prices, setPrices] = useState>({ ...DEFAULT_PRICES }); const [activeTier, setActiveTier] = useState("peak"); const [isDirty, setIsDirty] = useState(false); const [saving, setSaving] = useState(false); // Populate state once remote tariff loads useEffect(() => { if (!remoteTariff) return; // Reconstruct 24-element schedule from slots const s: PriceTier[] = []; for (let i = 0; i < 24; i++) s.push("flat"); for (const slot of remoteTariff.slots) { for (let h = slot.start; h < slot.end; h++) s[h] = slot.tier as PriceTier; } setSchedule(s); const p = remoteTariff.prices as Record; setPrices(p); setPriceStrings({ peak: toStrings(p.peak), valley: toStrings(p.valley), flat: toStrings(p.flat), }); setIsDirty(false); }, [remoteTariff]); // ── Drag state via ref (avoids stale closures in global handler) ───────── const dragRef = useRef({ active: false, startHour: 0, endHour: 0, tier: "peak" as PriceTier, }); const [dragHighlight, setDragHighlight] = useState<[number, number] | null>(null); const commitDrag = useCallback(() => { if (!dragRef.current.active) return; const { startHour, endHour, tier } = dragRef.current; const lo = Math.min(startHour, endHour); const hi = Math.max(startHour, endHour); dragRef.current.active = false; setDragHighlight(null); setSchedule((prev) => { const next = [...prev]; for (let i = lo; i <= hi; i++) next[i] = tier; return next; }); setIsDirty(true); }, []); // reads from ref, no stale closure useEffect(() => { window.addEventListener("mouseup", commitDrag); return () => window.removeEventListener("mouseup", commitDrag); }, [commitDrag]); const handleCellMouseDown = (hour: number, e: React.MouseEvent) => { e.preventDefault(); dragRef.current = { active: true, startHour: hour, endHour: hour, tier: activeTier }; setDragHighlight([hour, hour]); }; const handleCellMouseEnter = (hour: number) => { if (!dragRef.current.active) return; dragRef.current.endHour = hour; setDragHighlight([dragRef.current.startHour, hour]); }; // ── Price input handlers ───────────────────────────────────────────────── const handlePriceChange = (tier: PriceTier, field: keyof TierPricing, value: string) => { setPriceStrings((prev) => ({ ...prev, [tier]: { ...prev[tier], [field]: value } })); const num = parseFloat(value); if (!isNaN(num) && num >= 0) { setPrices((prev) => ({ ...prev, [tier]: { ...prev[tier], [field]: num } })); setIsDirty(true); } }; // ── Reset ──────────────────────────────────────────────────────────────── const handleReset = () => { setSchedule([...DEFAULT_SCHEDULE]); setPrices({ ...DEFAULT_PRICES }); setPriceStrings({ peak: toStrings(DEFAULT_PRICES.peak), valley: toStrings(DEFAULT_PRICES.valley), flat: toStrings(DEFAULT_PRICES.flat), }); setIsDirty(false); }; // ── Save ────────────────────────────────────────────────────────────────────── const handleSave = async () => { setSaving(true); try { await api.tariff.put({ slots, prices }); setIsDirty(false); queryClient.invalidateQueries({ queryKey: ["tariff"] }); toast.success("电价配置已保存"); } catch { toast.warning("保存失败,请稍候重试"); } finally { setSaving(false); } }; // ── Derived values ─────────────────────────────────────────────────────── const slots = scheduleToSlots(schedule); // ── Admin gate ─────────────────────────────────────────────────────────── if (!isAdmin) { return (

需要管理员权限

峰谷电价配置仅对管理员开放

); } if (loadingTariff) { return (
); } return (
{/* ── Page header ───────────────────────────────────────────────────── */}

峰谷电价配置

配置各时段电价类型与单价,系统将据此自动计算充电费用

{/* ── Timeline editor ───────────────────────────────────────────────── */}

时段编辑器

选择电价类型,点击或拖动时间轴涂色分配时段

{/* Tier palette */}
当前画笔: {TIERS.map((tier) => { const meta = TIER_META[tier]; const isActive = activeTier === tier; return ( ); })}
{/* 24-hour grid */}
{/* Hour tick labels at 0, 6, 12, 18, 24 */}
{[0, 6, 12, 18, 24].map((h) => ( 0 ? "translateX(-50%)" : undefined, }} > {String(h).padStart(2, "0")}:00 ))}
{/* Hour cells */}
{schedule.map((tier, hour) => { const inDrag = dragHighlight ? hour >= Math.min(dragHighlight[0], dragHighlight[1]) && hour <= Math.max(dragHighlight[0], dragHighlight[1]) : false; const displayTier = inDrag ? activeTier : tier; const meta = TIER_META[displayTier]; return (
handleCellMouseDown(hour, e)} onMouseEnter={() => handleCellMouseEnter(hour)} > {String(hour).padStart(2, "0")}
); })}
{/* Legend */}
{TIERS.map((tier) => { const meta = TIER_META[tier]; const hours = schedule.filter((t) => t === tier).length; return (
{meta.label} ({meta.sublabel} · {hours}h)
); })}
{/* ── Price configuration ───────────────────────────────────────────── */}

单价设置

各电价类型对应的每千瓦时单价(元 / kWh)

{TIERS.map((tier) => { const meta = TIER_META[tier]; const hours = schedule.filter((t) => t === tier).length; const pct = Math.round((hours / 24) * 100); const effective = prices[tier].electricityPrice + prices[tier].serviceFee; return (
{meta.label} {hours}h · {pct}%
handlePriceChange(tier, "electricityPrice", e.target.value)} /> handlePriceChange(tier, "serviceFee", e.target.value)} />
合计单价 ¥{effective.toFixed(2)} /kWh

按 1 kW · {hours}h ≈ ¥{(effective * hours).toFixed(2)} / 天

); })}
{/* ── Time slots summary ────────────────────────────────────────────── */}

时段汇总

当前配置共 {slots.length} 个连续时段

    {slots.map((slot, i) => { const meta = TIER_META[slot.tier]; return (
  • {meta.label} {String(slot.start).padStart(2, "0")}:00 {String(slot.end).padStart(2, "0")}:00 ({slot.end - slot.start}h) ¥{(prices[slot.tier].electricityPrice + prices[slot.tier].serviceFee).toFixed(2)} /kWh
  • ); })}
{/* ── Actions ───────────────────────────────────────────────────────── */}
{isDirty && 有未保存的更改}
); }