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();

View File

@@ -0,0 +1,13 @@
import type { CardFaceProps } from "./types";
/** 卡面:光泽 + 装饰圆形 */
export function CirclesFace(_: CardFaceProps) {
return (
<>
<div className="absolute inset-0 bg-linear-to-tr from-white/20 via-transparent to-transparent" />
<div className="absolute -right-8 -top-8 size-44 rounded-full bg-white/10" />
<div className="absolute right-4 -bottom-12 size-36 rounded-full bg-white/[0.07]" />
<div className="absolute -left-6 -bottom-4 size-24 rounded-full bg-black/10" />
</>
);
}

View File

@@ -0,0 +1,48 @@
import type { CardFaceProps } from "./types";
/** 卡面:渐变光晕 + 噪点纹理 */
export function GlowFace(_: CardFaceProps) {
return (
<>
{/* 噪点纹理 SVG filter */}
<svg className="absolute size-0">
<filter id="card-noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feBlend in="SourceGraphic" mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
</svg>
{/* 噪点层 */}
<div
className="absolute inset-0 opacity-[0.18] mix-blend-overlay"
style={{ filter: "url(#card-noise)" }}
/>
{/* 左下光晕 */}
<div
className="absolute -bottom-8 -left-8 size-48 rounded-full opacity-60 blur-3xl"
style={{ background: "radial-gradient(circle, white 0%, transparent 70%)" }}
/>
{/* 右上光晕 */}
<div
className="absolute -right-6 -top-6 size-40 rounded-full opacity-40 blur-2xl"
style={{ background: "radial-gradient(circle, white 0%, transparent 70%)" }}
/>
{/* 中心微光 */}
<div
className="absolute inset-x-0 top-1/2 mx-auto size-32 -translate-y-1/2 rounded-full opacity-20 blur-3xl"
style={{ background: "radial-gradient(circle, white 0%, transparent 70%)" }}
/>
{/* 顶部高光条 */}
<div className="absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-white/50 to-transparent" />
</>
);
}

View File

@@ -0,0 +1,17 @@
import { LineFace } from "./line";
import { CirclesFace } from "./circles";
import { GlowFace } from "./glow";
import { VipFace } from "./vip";
import { RedeyeFace } from "./redeye";
export type { CardFaceProps, CardFaceComponent } from "./types";
import type { CardFaceComponent } from "./types";
export const CARD_FACE_REGISTRY = {
line: LineFace,
circles: CirclesFace,
glow: GlowFace,
vip: VipFace,
redeye: RedeyeFace,
} satisfies Record<string, CardFaceComponent>;
export type CardFaceName = keyof typeof CARD_FACE_REGISTRY;

View File

@@ -0,0 +1,17 @@
import type { CardFaceProps } from "./types";
/** 卡面:斜线纹理 + 右上角光晕 */
export function LineFace(_: CardFaceProps) {
return (
<>
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"repeating-linear-gradient(135deg, #fff 0px, #fff 1px, transparent 1px, transparent 12px)",
}}
/>
<div className="absolute -right-10 -top-10 size-48 rounded-full bg-white/15 blur-2xl" />
</>
);
}

View File

@@ -0,0 +1,241 @@
import type { CardFaceProps } from "./types";
/** 卡面:深黑底色 + 深红光晕与锐利几何线条 */
export function RedeyeFace(_: CardFaceProps) {
return (
<>
{/* 深黑底色 */}
<div
className="absolute inset-0"
style={{
background: "linear-gradient(145deg, #0a0505 0%, #120808 45%, #0d0404 100%)",
}}
/>
{/* 红色主光晕:左上 */}
<div
className="absolute -left-8 -top-8 size-56 rounded-full opacity-25 blur-3xl"
style={{
background: "radial-gradient(circle, #cc1020 0%, #7a0810 50%, transparent 75%)",
}}
/>
{/* 红色副光晕:右下 */}
<div
className="absolute -bottom-10 -right-6 size-44 rounded-full opacity-15 blur-3xl"
style={{
background: "radial-gradient(circle, #e01828 0%, transparent 70%)",
}}
/>
{/* 顶部红色高光边 */}
<div
className="absolute inset-x-0 top-0 h-[1.5px]"
style={{
background:
"linear-gradient(to right, transparent 5%, #cc1020 25%, #ff2233 50%, #cc1020 75%, transparent 95%)",
}}
/>
{/* 左侧竖向红线 */}
<div
className="absolute bottom-0 left-5 top-0 w-px opacity-40"
style={{
background:
"linear-gradient(to bottom, transparent, #cc1020 20%, #ff2233 50%, #cc1020 80%, transparent)",
}}
/>
{/* 右侧双竖线 */}
<div
className="absolute bottom-3 right-4 top-3 w-px opacity-30"
style={{
background:
"linear-gradient(to bottom, transparent, #993010 30%, #cc2010 60%, transparent)",
}}
/>
<div
className="absolute bottom-3 right-[18px] top-3 w-px opacity-15"
style={{
background:
"linear-gradient(to bottom, transparent, #993010 30%, #cc2010 60%, transparent)",
}}
/>
{/* 红色横向扫描线组 */}
<div
className="absolute inset-x-0 opacity-[0.06]"
style={{
top: "30%",
height: "1px",
background:
"linear-gradient(to right, transparent 0%, #ff2233 40%, #ff5566 60%, transparent 100%)",
}}
/>
<div
className="absolute inset-x-0 opacity-[0.04]"
style={{
top: "32%",
height: "1px",
background: "linear-gradient(to right, transparent 10%, #ff2233 50%, transparent 90%)",
}}
/>
{/* 电路板风格折角线:左上 */}
<svg
className="absolute left-6 top-3 opacity-20"
width="40"
height="28"
viewBox="0 0 40 28"
fill="none"
>
<polyline points="0,28 0,8 12,0 40,0" stroke="#ff2233" strokeWidth="1" fill="none" />
<circle cx="12" cy="0" r="1.5" fill="#ff2233" />
</svg>
{/* 电路板风格折角线:右下 */}
<svg
className="absolute bottom-3 right-6 opacity-20"
width="40"
height="28"
viewBox="0 0 40 28"
fill="none"
>
<polyline points="40,0 40,20 28,28 0,28" stroke="#cc1020" strokeWidth="1" fill="none" />
<circle cx="28" cy="28" r="1.5" fill="#cc1020" />
</svg>
{/* 风格菱形 */}
<svg
className="absolute opacity-[0.12]"
style={{ right: "28px", top: "50%", transform: "translateY(-50%)" }}
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<polygon points="16,1 31,16 16,31 1,16" stroke="#ff2233" strokeWidth="1.5" fill="none" />
<polygon points="16,7 25,16 16,25 7,16" stroke="#ff2233" strokeWidth="1" fill="none" />
<circle cx="16" cy="16" r="2" fill="#ff2233" />
</svg>
{/* 徽标 — 八角框 + 内部对角交叉 + 中心菱形 */}
<svg
className="absolute opacity-60"
style={{
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
filter: "drop-shadow(0 0 6px #cc102088)",
}}
width="56"
height="56"
viewBox="0 0 56 56"
fill="none"
>
{/* 外八角轮廓 */}
<polygon
points="16,2 40,2 54,16 54,40 40,54 16,54 2,40 2,16"
stroke="#cc1020"
strokeWidth="1.5"
fill="#0d0404"
fillOpacity="0.85"
/>
{/* 内八角 */}
<polygon
points="20,8 36,8 48,20 48,36 36,48 20,48 8,36 8,20"
stroke="#cc1020"
strokeWidth="0.75"
strokeOpacity="0.5"
fill="none"
/>
{/* 对角线:左上 → 右下 */}
<line
x1="16"
y1="16"
x2="40"
y2="40"
stroke="#cc1020"
strokeWidth="1"
strokeOpacity="0.7"
/>
{/* 对角线:右上 → 左下 */}
<line
x1="40"
y1="16"
x2="16"
y2="40"
stroke="#cc1020"
strokeWidth="1"
strokeOpacity="0.7"
/>
{/* 横向中轴 */}
<line
x1="6"
y1="28"
x2="50"
y2="28"
stroke="#cc1020"
strokeWidth="0.75"
strokeOpacity="0.4"
/>
{/* 纵向中轴 */}
<line
x1="28"
y1="6"
x2="28"
y2="50"
stroke="#cc1020"
strokeWidth="0.75"
strokeOpacity="0.4"
/>
{/* 中心菱形 */}
<polygon
points="28,18 38,28 28,38 18,28"
stroke="#ff2233"
strokeWidth="1.25"
fill="#1a0505"
fillOpacity="0.9"
/>
{/* 中心圆点 */}
<circle cx="28" cy="28" r="3" fill="#ff2233" />
<circle cx="28" cy="28" r="1.5" fill="#ff6677" />
{/* 四个顶点刻度点 */}
<circle cx="28" cy="6" r="1.25" fill="#cc1020" fillOpacity="0.8" />
<circle cx="50" cy="28" r="1.25" fill="#cc1020" fillOpacity="0.8" />
<circle cx="28" cy="50" r="1.25" fill="#cc1020" fillOpacity="0.8" />
<circle cx="6" cy="28" r="1.25" fill="#cc1020" fillOpacity="0.8" />
</svg>
{/* 噪点 SVG filter */}
<svg className="absolute size-0">
<filter id="arasaka-noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.8"
numOctaves="4"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feBlend in="SourceGraphic" mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
</svg>
{/* 噪点层 */}
<div
className="absolute inset-0 opacity-[0.1] mix-blend-overlay"
style={{ filter: "url(#arasaka-noise)" }}
/>
{/* 底部红色压边 */}
<div
className="absolute inset-x-0 bottom-0 h-px opacity-30"
style={{
background:
"linear-gradient(to right, transparent 10%, #7a0810 40%, #cc1020 60%, transparent 90%)",
}}
/>
</>
);
}

View File

@@ -0,0 +1,10 @@
import type { ComponentType } from "react";
/**
* 卡面(卡底装饰)组件的 props。
* 卡面仅负责视觉装饰(颜色纹理、光效、几何图形等),不接收任何业务数据。
*/
export type CardFaceProps = Record<string, never>;
/** 卡面组件类型 */
export type CardFaceComponent = ComponentType<CardFaceProps>;

View File

@@ -0,0 +1,129 @@
import type { CardFaceProps } from "./types";
/** 卡面:黑金 VIP — 深黑底色 + 金色光晕与描边,体现尊贵身份 */
export function VipFace(_: CardFaceProps) {
return (
<>
{/* 完全覆盖底部 palette建立黑金底色 */}
<div
className="absolute inset-0"
style={{
background: "linear-gradient(135deg, #0f0f0f 0%, #1a1610 40%, #0d0d0d 100%)",
}}
/>
{/* 金色主光晕:右上角 */}
<div
className="absolute -right-10 -top-10 size-52 rounded-full opacity-30 blur-3xl"
style={{
background: "radial-gradient(circle, #d4a843 0%, #9a6f1a 50%, transparent 75%)",
}}
/>
{/* 金色副光晕:左下角 */}
<div
className="absolute -bottom-12 -left-6 size-44 rounded-full opacity-20 blur-3xl"
style={{
background: "radial-gradient(circle, #c49b2e 0%, transparent 70%)",
}}
/>
{/* 中部横向光带 */}
<div
className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 opacity-20"
style={{
background:
"linear-gradient(to right, transparent 0%, #d4a843 30%, #f0d060 50%, #d4a843 70%, transparent 100%)",
}}
/>
{/* 顶部金色高光边 */}
<div
className="absolute inset-x-0 top-0 h-px"
style={{
background:
"linear-gradient(to right, transparent 5%, #c8992a 30%, #f5d060 50%, #c8992a 70%, transparent 95%)",
}}
/>
{/* 底部深金色压边 */}
<div
className="absolute inset-x-0 bottom-0 h-px opacity-50"
style={{
background:
"linear-gradient(to right, transparent 10%, #9a6f1a 40%, #b8881f 60%, transparent 90%)",
}}
/>
{/* 右侧竖向装饰线 */}
<div
className="absolute bottom-4 right-5 top-4 w-px opacity-15"
style={{
background:
"linear-gradient(to bottom, transparent, #d4a843 30%, #f0d060 60%, transparent)",
}}
/>
{/* 细噪点 SVG filter */}
<svg className="absolute size-0">
<filter id="vip-noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.75"
numOctaves="4"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feBlend in="SourceGraphic" mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
</svg>
{/* 噪点层:增加高端质感 */}
<div
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
style={{ filter: "url(#vip-noise)" }}
/>
{/* 整体金色微光叠层 */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
background: "linear-gradient(120deg, transparent 20%, #d4a843 50%, transparent 80%)",
}}
/>
{/* 菱形网格纹 */}
<div
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, #d4a843 0px, #d4a843 1px, transparent 1px, transparent 14px), repeating-linear-gradient(-45deg, #d4a843 0px, #d4a843 1px, transparent 1px, transparent 14px)",
}}
/>
{/* VIP 大水印文字 */}
<div
className="pointer-events-none absolute -bottom-4 -left-2 select-none font-black leading-none tracking-widest opacity-[0.07]"
style={{
fontSize: "84px",
color: "#d4a843",
fontFamily: "serif",
letterSpacing: "0.15em",
}}
>
VIP
</div>
{/* 斜向扫光带 */}
<div
className="absolute -inset-y-4 w-12 -rotate-12 opacity-[0.08] blur-sm"
style={{
left: "38%",
background:
"linear-gradient(to bottom, transparent, #f5d060 30%, #fff8dc 50%, #f5d060 70%, transparent)",
}}
/>
</>
);
}

View File

@@ -0,0 +1,140 @@
import { ThunderboltFill } from "@gravity-ui/icons";
import { Nfc } from "lucide-react";
import { CARD_FACE_REGISTRY, type CardFaceName } from "@/components/faces";
// ---------------------------------------------------------------------------
// Palette
// ---------------------------------------------------------------------------
const CARD_PALETTES = [
"bg-linear-to-br from-blue-600 to-indigo-700",
"bg-linear-to-br from-violet-600 to-purple-700",
"bg-linear-to-br from-emerald-600 to-teal-700",
"bg-linear-to-br from-rose-500 to-pink-700",
"bg-linear-to-br from-amber-500 to-orange-600",
"bg-linear-to-br from-slate-600 to-zinc-700",
];
function paletteForId(idTag: string): string {
const hash = idTag.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return CARD_PALETTES[hash % CARD_PALETTES.length];
}
// ---------------------------------------------------------------------------
// Layout — 内容元素余额、logo、卡号的排列方式
// ---------------------------------------------------------------------------
export type CardLayoutName = "center" | "around";
// ---------------------------------------------------------------------------
// IdTagCard
// ---------------------------------------------------------------------------
type IdTagCardProps = {
idTag: string;
balance: number;
isSelected?: boolean;
/** 内容排列方式余额、logo、卡号等信息元素的布局 */
layout?: CardLayoutName;
/** 卡底装饰风格:纹理、光效、几何图形等视觉元素 */
skin?: CardFaceName;
onClick?: () => void;
};
export function IdTagCard({
idTag,
balance,
isSelected = false,
layout = "around",
skin = "circles",
onClick,
}: IdTagCardProps) {
const palette = paletteForId(idTag);
const Skin = CARD_FACE_REGISTRY[skin];
return (
<button
type="button"
onClick={onClick}
className={[
"relative w-full overflow-hidden rounded-2xl cursor-pointer select-none",
"aspect-[1.586/1] transition-all duration-200 active:scale-[0.97]",
isSelected
? "ring-3 ring-offset-2 ring-offset-background ring-accent shadow-2xl shadow-accent/25"
: "ring-1 ring-black/8 hover:ring-accent/40 shadow-md hover:shadow-xl",
].join(" ")}
>
{/* 渐变底色 */}
<div className={`absolute inset-0 ${palette}`} />
{/* 卡底装饰 */}
<Skin />
{/* 内容布局 */}
{layout === "center" ? (
<div className="relative flex h-full flex-col justify-between px-5 py-4">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-white/50">
</span>
<div className="flex items-center gap-1">
<ThunderboltFill className="size-3.5 text-white/60" />
<span className="text-[9px] font-bold uppercase tracking-[0.15em] text-white/60">
Helios
</span>
</div>
</div>
<div className="mt-2">
<p className="text-[24px] font-medium leading-none tracking-[0.22em] text-white drop-shadow-md">
{idTag.replace(/(.{4})/, "$1 ")}
</p>
</div>
<div className="flex items-end justify-between">
<Nfc className="size-6 rotate-180 text-white/30 stroke-[1.5]" />
<div className="text-right">
<p className="text-[8px] font-semibold uppercase tracking-[0.15em] text-white/40">
</p>
<p className="text-lg font-bold leading-tight text-white drop-shadow">
¥{(balance / 100).toFixed(2)}
</p>
</div>
</div>
</div>
) : (
<div className="relative flex h-full flex-col justify-between px-5 py-4">
<div className="flex items-start justify-between">
<Nfc className="size-7 rotate-180 text-white/35 stroke-[1.5]" />
<div className="flex items-center gap-1">
<ThunderboltFill className="size-3.5 text-white/60" />
<span className="text-[9px] font-bold uppercase tracking-[0.15em] text-white/60">
Helios
</span>
</div>
</div>
<div className="flex items-end justify-between">
<div className="flex flex-col items-start gap-0.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.15em] text-white/40">
</p>
<p className="text-[22px] font-bold text-white drop-shadow">
¥{(balance / 100).toFixed(2)}
</p>
</div>
<div className="flex flex-col items-end gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/35">
</span>
<p className="text-lg font-medium tracking-widest text-white/90 drop-shadow">
{idTag.replace(/(.{4})/, "$1 ")}
</p>
</div>
</div>
</div>
)}
</button>
);
}

View File

@@ -130,6 +130,8 @@ export type IdTag = {
parentIdTag: string | null;
userId: string | null;
balance: number;
cardLayout: "center" | "around" | null;
cardSkin: "line" | "circles" | "glow" | "vip" | "redeye" | null;
createdAt: string;
};
@@ -239,6 +241,8 @@ export const api = {
parentIdTag?: string;
userId?: string | null;
balance?: number;
cardLayout?: "center" | "around";
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye";
}) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }),
update: (
idTag: string,
@@ -248,6 +252,8 @@ export const api = {
parentIdTag?: string | null;
userId?: string | null;
balance?: number;
cardLayout?: "center" | "around" | null;
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye" | null;
},
) => apiFetch<IdTag>(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (idTag: string) =>

View File

@@ -18,6 +18,7 @@
"better-auth": "catalog:",
"dayjs": "catalog:",
"jsqr": "^1.4.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",