feat: add card skins support
This commit is contained in:
@@ -281,8 +281,15 @@ export const idTag = pgTable('id_tag', {
|
||||
* 储值卡余额(单位:分)
|
||||
* 以整数存储,1 分 = 0.01 CNY,前端显示时除以 100
|
||||
*/
|
||||
balance: integer('balance').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
balance: integer('balance').notNull().default(0), /**
|
||||
* 卡面内容排列方式
|
||||
*/
|
||||
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()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
|
||||
@@ -16,6 +16,8 @@ const idTagSchema = z.object({
|
||||
expiryDate: z.string().date().optional().nullable(),
|
||||
userId: z.string().optional().nullable(),
|
||||
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 });
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
13
apps/web/components/faces/circles.tsx
Normal file
13
apps/web/components/faces/circles.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/faces/glow.tsx
Normal file
48
apps/web/components/faces/glow.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
apps/web/components/faces/index.ts
Normal file
17
apps/web/components/faces/index.ts
Normal 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;
|
||||
17
apps/web/components/faces/line.tsx
Normal file
17
apps/web/components/faces/line.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
241
apps/web/components/faces/redeye.tsx
Normal file
241
apps/web/components/faces/redeye.tsx
Normal 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%)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
apps/web/components/faces/types.ts
Normal file
10
apps/web/components/faces/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
/**
|
||||
* 卡面(卡底装饰)组件的 props。
|
||||
* 卡面仅负责视觉装饰(颜色纹理、光效、几何图形等),不接收任何业务数据。
|
||||
*/
|
||||
export type CardFaceProps = Record<string, never>;
|
||||
|
||||
/** 卡面组件类型 */
|
||||
export type CardFaceComponent = ComponentType<CardFaceProps>;
|
||||
129
apps/web/components/faces/vip.tsx
Normal file
129
apps/web/components/faces/vip.tsx
Normal 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)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
apps/web/components/id-tag-card.tsx
Normal file
140
apps/web/components/id-tag-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user