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

@@ -281,8 +281,15 @@ export const idTag = pgTable('id_tag', {
* 储值卡余额(单位:分) * 储值卡余额(单位:分)
* 以整数存储1 分 = 0.01 CNY前端显示时除以 100 * 以整数存储1 分 = 0.01 CNY前端显示时除以 100
*/ */
balance: integer('balance').notNull().default(0), balance: integer('balance').notNull().default(0), /**
createdAt: timestamp('created_at', { withTimezone: true }) * 卡面内容排列方式
*/
cardLayout: varchar('card_layout', { enum: ['center', 'around'] }).default('around'),
/**
* 卡底装饰风格
* 对应 faces/ 目录中已注册的卡面组件
*/
cardSkin: varchar('card_skin', { enum: ['line', 'circles', 'glow', 'vip', 'redeye'] }).default('circles'), createdAt: timestamp('created_at', { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }) updatedAt: timestamp('updated_at', { withTimezone: true })

View File

@@ -16,6 +16,8 @@ const idTagSchema = z.object({
expiryDate: z.string().date().optional().nullable(), expiryDate: z.string().date().optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
balance: z.number().int().min(0).default(0), balance: z.number().int().min(0).default(0),
cardLayout: z.enum(["center", "around"]).optional(),
cardSkin: z.enum(["line", "circles", "glow", "vip", "redeye"]).optional(),
}); });
const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true }); const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true });

View File

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

View File

@@ -43,6 +43,8 @@ type FormState = {
parentIdTag: string; parentIdTag: string;
userId: string; userId: string;
balance: string; balance: string;
cardLayout: string;
cardSkin: string;
}; };
const emptyForm: FormState = { const emptyForm: FormState = {
@@ -52,6 +54,8 @@ const emptyForm: FormState = {
parentIdTag: "", parentIdTag: "",
userId: "", userId: "",
balance: "0", balance: "0",
cardLayout: "around",
cardSkin: "circles",
}; };
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const; const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
@@ -325,6 +329,61 @@ function TagFormBody({
users={users} users={users}
/> />
</div> </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, isFetching: refreshing,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["idTags"], queryKey: ["idTags", "withUsers"],
queryFn: async () => { queryFn: async () => {
const [tagList, userList] = await Promise.all([ const [tagList, userList] = await Promise.all([
api.idTags.list(), api.idTags.list(),
@@ -381,6 +440,8 @@ export default function IdTagsPage() {
parentIdTag: tag.parentIdTag ?? "", parentIdTag: tag.parentIdTag ?? "",
userId: tag.userId ?? "", userId: tag.userId ?? "",
balance: fenToYuan(tag.balance), balance: fenToYuan(tag.balance),
cardLayout: tag.cardLayout ?? "around",
cardSkin: tag.cardSkin ?? "circles",
}); });
}; };
@@ -394,6 +455,8 @@ export default function IdTagsPage() {
parentIdTag: form.parentIdTag || null, parentIdTag: form.parentIdTag || null,
userId: form.userId || null, userId: form.userId || null,
balance: yuanToFen(form.balance), balance: yuanToFen(form.balance),
cardLayout: (form.cardLayout as "center" | "around") || null,
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || null,
}); });
} else { } else {
await api.idTags.create({ await api.idTags.create({
@@ -403,6 +466,8 @@ export default function IdTagsPage() {
parentIdTag: form.parentIdTag || undefined, parentIdTag: form.parentIdTag || undefined,
userId: form.userId || undefined, userId: form.userId || undefined,
balance: yuanToFen(form.balance), balance: yuanToFen(form.balance),
cardLayout: (form.cardLayout as "center" | "around") || undefined,
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || undefined,
}); });
} }
await refetch(); 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; parentIdTag: string | null;
userId: string | null; userId: string | null;
balance: number; balance: number;
cardLayout: "center" | "around" | null;
cardSkin: "line" | "circles" | "glow" | "vip" | "redeye" | null;
createdAt: string; createdAt: string;
}; };
@@ -239,6 +241,8 @@ export const api = {
parentIdTag?: string; parentIdTag?: string;
userId?: string | null; userId?: string | null;
balance?: number; balance?: number;
cardLayout?: "center" | "around";
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye";
}) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }), }) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }),
update: ( update: (
idTag: string, idTag: string,
@@ -248,6 +252,8 @@ export const api = {
parentIdTag?: string | null; parentIdTag?: string | null;
userId?: string | null; userId?: string | null;
balance?: number; 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) }), ) => apiFetch<IdTag>(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (idTag: string) => delete: (idTag: string) =>

View File

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