From 2638af3f7f525b9fd0a25e37a59025e468adb0a2 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Thu, 12 Mar 2026 16:06:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B3=B0=E8=B0=B7=E7=94=B5=E4=BB=B7?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/dashboard/settings/pricing/page.tsx | 519 ++++++++++++++++++ .../dashboard/settings/{ => user}/page.tsx | 0 apps/web/components/sidebar.tsx | 8 +- 3 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/dashboard/settings/pricing/page.tsx rename apps/web/app/dashboard/settings/{ => user}/page.tsx (100%) diff --git a/apps/web/app/dashboard/settings/pricing/page.tsx b/apps/web/app/dashboard/settings/pricing/page.tsx new file mode 100644 index 0000000..4340eaf --- /dev/null +++ b/apps/web/app/dashboard/settings/pricing/page.tsx @@ -0,0 +1,519 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +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"; + +// ───────────────────────────────────────────────────────────────────────────── +// 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 [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); + const [showPayload, setShowPayload] = useState(false); + + // ── 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 (stub: simulates API call) ───────────────────────────────────── + const handleSave = async () => { + setSaving(true); + try { + // Future: await apiFetch("/api/tariff", { method: "PUT", body: JSON.stringify(apiPayload) }) + await new Promise((r) => setTimeout(r, 700)); + setIsDirty(false); + toast.success("电价配置已保存"); + } finally { + setSaving(false); + } + }; + + // ── Derived values ─────────────────────────────────────────────────────── + const slots = scheduleToSlots(schedule); + const apiPayload: TariffConfig = { slots, prices }; + + // ── Admin gate ─────────────────────────────────────────────────────────── + if (!isAdmin) { + 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 + +
  • + ); + })} +
+
+ + {/* ── API Payload preview ───────────────────────────────────────────── */} +
+ + {showPayload && ( +
+
+              {JSON.stringify(apiPayload, null, 2)}
+            
+
+ )} +
+ + {/* ── Actions ───────────────────────────────────────────────────────── */} +
+ +
+ {isDirty && 有未保存的更改} + +
+
+
+ ); +} diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/user/page.tsx similarity index 100% rename from apps/web/app/dashboard/settings/page.tsx rename to apps/web/app/dashboard/settings/user/page.tsx diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx index eb74e93..3070351 100644 --- a/apps/web/components/sidebar.tsx +++ b/apps/web/components/sidebar.tsx @@ -9,6 +9,7 @@ import { ListCheck, Person, PlugConnection, + TagDollar, Thunderbolt, ThunderboltFill, Xmark, @@ -29,7 +30,10 @@ const navItems = [ { href: "/dashboard/users", label: "用户管理", icon: Person, adminOnly: true }, ]; -const settingsItems = [{ href: "/dashboard/settings", label: "账号设置", icon: Gear }]; +const settingsItems = [ + { href: "/dashboard/settings/user", label: "账号设置", icon: Gear, adminOnly: false }, + { href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true }, +]; function NavContent({ pathname, @@ -110,7 +114,7 @@ function NavContent({

设置

- {settingsItems.map((item) => { + {settingsItems.filter((item) => !item.adminOnly || isAdmin).map((item) => { const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); const Icon = item.icon; return (