feat: add card skins support

This commit is contained in:
2026-03-12 13:19:46 +08:00
parent e759576b58
commit 9f92b57371
14 changed files with 904 additions and 154 deletions

View File

@@ -3,18 +3,14 @@
import { useState, useEffect, useRef, Fragment, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button, Chip, Modal, Spinner } from "@heroui/react";
import {
ThunderboltFill,
PlugConnection,
CreditCard,
Check,
QrCode,
Xmark,
} from "@gravity-ui/icons";
import { Button, Modal, Spinner } from "@heroui/react";
import { ThunderboltFill, CreditCard, Check, QrCode, Xmark } from "@gravity-ui/icons";
import jsQR from "jsqr";
import { api } from "@/lib/api";
import dayjs from "@/lib/dayjs";
import { EvCharger, Plug } from "lucide-react";
import Link from "next/link";
import { IdTagCard } from "@/components/id-tag-card";
// ── Status maps (same as charge-points page) ────────────────────────────────
@@ -31,25 +27,12 @@ const statusLabelMap: Record<string, string> = {
Occupied: "占用",
};
const statusDotClass: Record<string, string> = {
Available: "bg-success",
Charging: "bg-accent animate-pulse",
Preparing: "bg-warning animate-pulse",
Finishing: "bg-warning",
SuspendedEV: "bg-warning",
SuspendedEVSE: "bg-warning",
Reserved: "bg-warning",
Faulted: "bg-danger",
Unavailable: "bg-danger",
Occupied: "bg-warning",
};
// ── Step indicator ───────────────────────────────────────────────────────────
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
return (
<div className="flex w-full items-start">
<div className="flex w-full items-center">
{labels.map((label, i) => {
const idx = i + 1;
const isActive = step === idx;
@@ -62,36 +45,36 @@ function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number)
onClick={() => isDone && onGoBack(idx)}
disabled={!isDone}
className={[
"flex shrink-0 flex-col items-center gap-2",
isDone ? "cursor-pointer" : "cursor-default",
"flex shrink-0 flex-col items-center gap-1.5 py-1 min-w-16",
isDone ? "cursor-pointer active:opacity-70" : "cursor-default",
].join(" ")}
>
<span
className={[
"flex size-7 items-center justify-center rounded-full text-xs font-semibold ring-2 ring-offset-2 ring-offset-background transition-all",
"flex size-8 items-center justify-center rounded-full text-sm font-bold ring-2 ring-offset-2 ring-offset-background transition-all",
isActive
? "bg-accent text-accent-foreground ring-accent"
? "bg-accent text-accent-foreground ring-accent shadow-md shadow-accent/30"
: isDone
? "bg-success text-white ring-success"
: "bg-surface-tertiary text-muted ring-transparent",
].join(" ")}
>
{isDone ? <Check className="size-3.5" /> : idx}
{isDone ? <Check className="size-4" /> : idx}
</span>
<span
className={[
"text-[11px] font-medium leading-none whitespace-nowrap",
isActive ? "text-accent" : isDone ? "text-foreground" : "text-muted",
"text-[11px] font-semibold leading-none whitespace-nowrap tracking-tight mt-1",
isActive ? "text-accent" : isDone ? "text-success" : "text-muted",
].join(" ")}
>
{label}
</span>
</button>
{!isLast && (
<div className="flex-1 pt-3.5">
<div className="flex-1 mb-3.5">
<span
className={[
"block h-px w-full transition-colors",
"block h-0.5 w-full rounded-full transition-colors duration-300",
isDone ? "bg-success" : "bg-border",
].join(" ")}
/>
@@ -275,7 +258,7 @@ function ChargePageContent() {
});
const { data: idTags = [], isLoading: tagsLoading } = useQuery({
queryKey: ["idTags"],
queryKey: ["idTags", "list"],
queryFn: () => api.idTags.list().catch(() => []),
});
@@ -343,47 +326,66 @@ function ChargePageContent() {
// ── Success screen ─────────────────────────────────────────────────────────
if (startResult === "success") {
return (
<div className="flex flex-col items-center justify-center gap-6 py-20 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10">
<Check className="size-8 text-success" />
<div className="flex flex-col items-center justify-center gap-8 py-16 text-center">
<div className="relative">
<div className="flex size-24 items-center justify-center rounded-full bg-success-soft ring-8 ring-success/10">
<Check className="size-12 text-success" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="mt-1.5 text-sm text-muted">
<div className="space-y-2">
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="text-sm text-muted leading-relaxed">
<br />
"充电记录"
</p>
</div>
<Button onPress={resetAll}></Button>
<div className="w-full max-w-xs rounded-2xl border border-border bg-surface p-4 text-left space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">{selectedCp?.chargePointIdentifier}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-mono font-medium text-foreground">{selectedIdTag}</span>
</div>
</div>
<Button size="lg" onPress={resetAll} className="w-full max-w-xs">
<ThunderboltFill className="size-4" />
</Button>
</div>
);
}
// ── Main UI ────────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
<div className="space-y-5 pb-4">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
{/* QR scan button — mobile only */}
{isMobile && (
<Button
size="sm"
variant="secondary"
onPress={() => {
<button
type="button"
onClick={() => {
setScanError(null);
setShowScanner(true);
}}
isDisabled={showScanner}
disabled={showScanner}
className="flex shrink-0 flex-col items-center gap-1 rounded-2xl border border-border bg-surface px-3.5 py-2.5 text-foreground shadow-sm active:opacity-70 disabled:opacity-40"
>
<QrCode className="size-4" />
</Button>
<QrCode className="size-5" />
<span className="text-[10px] font-medium leading-none"></span>
</button>
)}
</div>
@@ -403,7 +405,11 @@ function ChargePageContent() {
</Modal.Backdrop>
</Modal>
{scanError && <p className="text-sm text-danger">{scanError}</p>}
{scanError && (
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
{scanError}
</div>
)}
{/* Step bar */}
<StepBar step={step} onGoBack={(t) => setStep(t)} />
@@ -412,16 +418,17 @@ function ChargePageContent() {
{step === 1 && (
<div className="space-y-3">
{cpLoading ? (
<div className="flex justify-center py-12">
<div className="flex justify-center py-16">
<Spinner />
</div>
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<Plug className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{chargePoints
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
@@ -442,37 +449,63 @@ function ChargePageContent() {
setStep(2);
}}
className={[
"flex flex-col gap-2.5 rounded-xl border p-4 text-left transition-all",
"flex flex-col gap-3 rounded-2xl border p-4 text-left transition-all",
disabled
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedCpId === cp.id ? "border-accent bg-accent/10" : "",
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
: selectedCpId === cp.id
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.98]"
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
].join(" ")}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground truncate">
{cp.chargePointIdentifier}
</span>
<Chip size="sm" color={online ? "success" : "default"} variant="soft">
{online ? "在线" : "离线"}
</Chip>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted">
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1 text-xs text-muted">
<PlugConnection className="size-3" />
{availableCount}/{cp.connectors.length}
</span>
{cp.feePerKwh > 0 && (
<span className="text-xs text-muted">
· ¥{(cp.feePerKwh / 100).toFixed(2)}/kWh
{/* Top row: identifier + status */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-semibold text-foreground truncate leading-tight">
{cp.chargePointIdentifier}
</span>
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted truncate">
{[cp.chargePointVendor, cp.chargePointModel]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
<span
className={[
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online
? "bg-success/12 text-success"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
<span
className={`size-1.5 rounded-full ${online ? "bg-success" : "bg-muted"}`}
/>
{online ? "在线" : "离线"}
</span>
</div>
{/* Bottom row: connectors + fee */}
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm text-muted">
<Plug className="size-3.5 shrink-0" />
<span>
<span
className={availableCount > 0 ? "font-semibold text-foreground" : ""}
>
{availableCount}
</span>
/{cp.connectors.length}
</span>
</span>
{cp.feePerKwh > 0 ? (
<span className="text-sm font-medium text-foreground">
¥{(cp.feePerKwh / 100).toFixed(2)}
<span className="text-xs text-muted font-normal">/kWh</span>
</span>
) : (
<span className="text-sm font-semibold text-success"></span>
)}
{cp.feePerKwh === 0 && <span className="text-xs text-success">· </span>}
</div>
</button>
);
@@ -484,23 +517,25 @@ function ChargePageContent() {
{/* ── Step 2: Select connector ──────────────────────────────────── */}
{step === 2 && (
<div className="space-y-3">
<div className="space-y-4">
{/* Context pill */}
{selectedCp && (
<p className="text-sm text-muted">
<span className="font-medium text-foreground">
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.chargePointIdentifier}
</span>
</p>
</div>
)}
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<Plug className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
</div>
) : (
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{selectedCp?.connectors
.filter((c) => c.connectorId > 0)
.sort((a, b) => a.connectorId - b.connectorId)
@@ -518,101 +553,119 @@ function ChargePageContent() {
}
}}
className={[
"flex flex-col gap-2 rounded-xl border p-4 text-left transition-all",
"relative flex flex-col items-center gap-3 rounded-2xl border py-5 px-3 text-center transition-all",
!available
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedConnectorId === conn.connectorId
? "border-accent bg-accent/10"
: "",
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
: selectedConnectorId === conn.connectorId
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.97]"
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.97]",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">
#{conn.connectorId}
</span>
<span
className={`size-2 rounded-full ${statusDotClass[conn.status] ?? "bg-warning"}`}
/>
</div>
<span className="text-xs text-muted">
{statusLabelMap[conn.status] ?? conn.status}
<span
className={[
"flex size-12 items-center justify-center rounded-full text-xl font-bold",
available
? "bg-success/12 text-success"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
{conn.connectorId}
</span>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground">
#{conn.connectorId}
</p>
<p
className={[
"text-xs font-medium",
conn.status === "Available" ? "text-success" : "text-muted",
].join(" ")}
>
{statusLabelMap[conn.status] ?? conn.status}
</p>
</div>
{selectedConnectorId === conn.connectorId && (
<span className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-accent">
<Check className="size-3 text-white" />
</span>
)}
</button>
);
})}
</div>
)}
<Button variant="secondary" size="sm" onPress={() => setStep(1)}>
</Button>
</div>
)}
{/* ── Step 3: Select ID tag + start ────────────────────────────── */}
{step === 3 && (
<div className="space-y-5">
<div className="flex flex-wrap gap-3 text-sm text-muted">
<div className="space-y-4">
{/* Context pills */}
<div className="flex flex-wrap gap-2">
{selectedCp && (
<span>
<span className="font-medium text-foreground">
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.chargePointIdentifier}
</span>
</span>
</div>
)}
{selectedConnectorId !== null && (
<span>
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
</span>
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<Plug className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">#{selectedConnectorId}</span>
</div>
)}
</div>
<p className="text-sm font-semibold text-foreground"></p>
{tagsLoading ? (
<div className="flex justify-center py-12">
<div className="flex justify-center py-16">
<Spinner />
</div>
) : myTags.length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<CreditCard className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<p className="mt-1 text-xs text-muted">"储值卡"</p>
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<CreditCard className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted">
<Link href="/dashboard/id-tags" className="text-accent hover:underline">
</Link>
</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{myTags.map((tag) => (
<button
<IdTagCard
key={tag.idTag}
type="button"
idTag={tag.idTag}
balance={tag.balance}
layout={tag.cardLayout ?? undefined}
skin={tag.cardSkin ?? undefined}
isSelected={selectedIdTag === tag.idTag}
onClick={() => setSelectedIdTag(tag.idTag)}
className={[
"flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all cursor-pointer",
"border-border hover:border-accent hover:bg-accent/5",
selectedIdTag === tag.idTag ? "border-accent bg-accent/10" : "",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-medium text-foreground">
{tag.idTag}
</span>
{selectedIdTag === tag.idTag && (
<Check className="size-4 shrink-0 text-accent" />
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted"></span>
<span className="text-xs font-medium text-foreground">
¥{(tag.balance / 100).toFixed(2)}
</span>
</div>
</button>
/>
))}
</div>
)}
{startResult === "error" && (
<p className="text-sm text-danger">{startError ?? "启动失败,请重试"}</p>
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
{startError ?? "启动失败,请重试"}
</div>
)}
<div className="flex gap-3">
{/* Action bar */}
<div className="flex gap-3 pt-1">
<Button
variant="secondary"
onPress={() => {
@@ -621,9 +674,10 @@ function ChargePageContent() {
setStartError(null);
}}
>
</Button>
<Button
className="flex-1"
isDisabled={!selectedIdTag || startMutation.isPending}
onPress={() => startMutation.mutate()}
>

View File

@@ -43,6 +43,8 @@ type FormState = {
parentIdTag: string;
userId: string;
balance: string;
cardLayout: string;
cardSkin: string;
};
const emptyForm: FormState = {
@@ -52,6 +54,8 @@ const emptyForm: FormState = {
parentIdTag: "",
userId: "",
balance: "0",
cardLayout: "around",
cardSkin: "circles",
};
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
@@ -325,6 +329,61 @@ function TagFormBody({
users={users}
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={form.cardLayout}
onSelectionChange={(key) => setForm({ ...form, cardLayout: String(key) })}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item key="around" id="around">
</ListBox.Item>
<ListBox.Item key="center" id="center">
</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={form.cardSkin}
onSelectionChange={(key) => setForm({ ...form, cardSkin: String(key) })}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item key="circles" id="circles">
</ListBox.Item>
<ListBox.Item key="line" id="line">
线
</ListBox.Item>
<ListBox.Item key="glow" id="glow">
</ListBox.Item>
<ListBox.Item key="vip" id="vip">
VIP
</ListBox.Item>
<ListBox.Item key="redeye" id="redeye">
</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
</>
);
}
@@ -344,7 +403,7 @@ export default function IdTagsPage() {
isFetching: refreshing,
refetch,
} = useQuery({
queryKey: ["idTags"],
queryKey: ["idTags", "withUsers"],
queryFn: async () => {
const [tagList, userList] = await Promise.all([
api.idTags.list(),
@@ -381,6 +440,8 @@ export default function IdTagsPage() {
parentIdTag: tag.parentIdTag ?? "",
userId: tag.userId ?? "",
balance: fenToYuan(tag.balance),
cardLayout: tag.cardLayout ?? "around",
cardSkin: tag.cardSkin ?? "circles",
});
};
@@ -394,6 +455,8 @@ export default function IdTagsPage() {
parentIdTag: form.parentIdTag || null,
userId: form.userId || null,
balance: yuanToFen(form.balance),
cardLayout: (form.cardLayout as "center" | "around") || null,
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || null,
});
} else {
await api.idTags.create({
@@ -403,6 +466,8 @@ export default function IdTagsPage() {
parentIdTag: form.parentIdTag || undefined,
userId: form.userId || undefined,
balance: yuanToFen(form.balance),
cardLayout: (form.cardLayout as "center" | "around") || undefined,
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || undefined,
});
}
await refetch();