From 9f92b573716f84da58d3f6aa56dea3c2395b4cce Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Thu, 12 Mar 2026 13:19:46 +0800 Subject: [PATCH] feat: add card skins support --- apps/csms/src/db/ocpp-schema.ts | 11 +- apps/csms/src/routes/id-tags.ts | 2 + apps/web/app/dashboard/charge/page.tsx | 356 ++++++++++++++---------- apps/web/app/dashboard/id-tags/page.tsx | 67 ++++- apps/web/components/faces/circles.tsx | 13 + apps/web/components/faces/glow.tsx | 48 ++++ apps/web/components/faces/index.ts | 17 ++ apps/web/components/faces/line.tsx | 17 ++ apps/web/components/faces/redeye.tsx | 241 ++++++++++++++++ apps/web/components/faces/types.ts | 10 + apps/web/components/faces/vip.tsx | 129 +++++++++ apps/web/components/id-tag-card.tsx | 140 ++++++++++ apps/web/lib/api.ts | 6 + apps/web/package.json | 1 + 14 files changed, 904 insertions(+), 154 deletions(-) create mode 100644 apps/web/components/faces/circles.tsx create mode 100644 apps/web/components/faces/glow.tsx create mode 100644 apps/web/components/faces/index.ts create mode 100644 apps/web/components/faces/line.tsx create mode 100644 apps/web/components/faces/redeye.tsx create mode 100644 apps/web/components/faces/types.ts create mode 100644 apps/web/components/faces/vip.tsx create mode 100644 apps/web/components/id-tag-card.tsx diff --git a/apps/csms/src/db/ocpp-schema.ts b/apps/csms/src/db/ocpp-schema.ts index c597cdb..16c5594 100644 --- a/apps/csms/src/db/ocpp-schema.ts +++ b/apps/csms/src/db/ocpp-schema.ts @@ -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 }) diff --git a/apps/csms/src/routes/id-tags.ts b/apps/csms/src/routes/id-tags.ts index f14c53d..d13e0a7 100644 --- a/apps/csms/src/routes/id-tags.ts +++ b/apps/csms/src/routes/id-tags.ts @@ -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 }); diff --git a/apps/web/app/dashboard/charge/page.tsx b/apps/web/app/dashboard/charge/page.tsx index 703fd88..2f1fc06 100644 --- a/apps/web/app/dashboard/charge/page.tsx +++ b/apps/web/app/dashboard/charge/page.tsx @@ -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 = { Occupied: "占用", }; -const statusDotClass: Record = { - 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 ( -
+
{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(" ")} > - {isDone ? : idx} + {isDone ? : idx} {label} {!isLast && ( -
+
@@ -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 ( -
-
- +
+
+
+ +
-
-

充电指令已发送

-

+

+

充电指令已发送

+

充电桩正在响应,充电将自动开始
- 你可以在"充电记录"中查看进度 + 可在「充电记录」中查看实时进度

- +
+
+ 充电桩 + {selectedCp?.chargePointIdentifier} +
+
+ 接口 + #{selectedConnectorId} +
+
+ 储值卡 + {selectedIdTag} +
+
+
); } // ── Main UI ──────────────────────────────────────────────────────────────── return ( -
+
{/* Header */} -
+
-

立即充电

+

立即充电

选择充电桩和储值卡,远程启动充电

{/* QR scan button — mobile only */} {isMobile && ( - + + 扫码 + )}
@@ -403,7 +405,11 @@ function ChargePageContent() { - {scanError &&

{scanError}

} + {scanError && ( +
+ {scanError} +
+ )} {/* Step bar */} setStep(t)} /> @@ -412,16 +418,17 @@ function ChargePageContent() { {step === 1 && (
{cpLoading ? ( -
+
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? ( -
- -

暂无可用充电桩

+
+ +

暂无可用充电桩

+

请稍后刷新查看

) : ( -
+
{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(" ")} > -
- - {cp.chargePointIdentifier} - - - {online ? "在线" : "离线"} - -
- {(cp.chargePointVendor || cp.chargePointModel) && ( - - {[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")} - - )} -
- - - {availableCount}/{cp.connectors.length} 接口空闲 - - {cp.feePerKwh > 0 && ( - - · ¥{(cp.feePerKwh / 100).toFixed(2)}/kWh + {/* Top row: identifier + status */} +
+
+ + {cp.chargePointIdentifier} + {(cp.chargePointVendor || cp.chargePointModel) && ( + + {[cp.chargePointVendor, cp.chargePointModel] + .filter(Boolean) + .join(" · ")} + + )} +
+ + + {online ? "在线" : "离线"} + +
+ {/* Bottom row: connectors + fee */} +
+ + + + 0 ? "font-semibold text-foreground" : ""} + > + {availableCount} + + /{cp.connectors.length} 空闲 + + + {cp.feePerKwh > 0 ? ( + + ¥{(cp.feePerKwh / 100).toFixed(2)} + /kWh + + ) : ( + 免费 )} - {cp.feePerKwh === 0 && · 免费}
); @@ -484,23 +517,25 @@ function ChargePageContent() { {/* ── Step 2: Select connector ──────────────────────────────────── */} {step === 2 && ( -
+
+ {/* Context pill */} {selectedCp && ( -

- 充电桩: - +

+ + 充电桩 + {selectedCp.chargePointIdentifier} -

+
)} {selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? ( -
- -

该充电桩暂无接口信息

+
+ +

该桩暂无可用接口

) : ( -
+
{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(" ")} > -
- - 接口 #{conn.connectorId} - - -
- - {statusLabelMap[conn.status] ?? conn.status} + + {conn.connectorId} +
+

+ 接口 #{conn.connectorId} +

+

+ {statusLabelMap[conn.status] ?? conn.status} +

+
+ {selectedConnectorId === conn.connectorId && ( + + + + )} ); })}
)} + +
)} {/* ── Step 3: Select ID tag + start ────────────────────────────── */} {step === 3 && ( -
-
+
+ {/* Context pills */} +
{selectedCp && ( - - 充电桩: - +
+ + 充电桩 + {selectedCp.chargePointIdentifier} - +
)} {selectedConnectorId !== null && ( - - 接口: - #{selectedConnectorId} - +
+ + 接口 + #{selectedConnectorId} +
)}
+

选择储值卡充电

+ {tagsLoading ? ( -
+
) : myTags.length === 0 ? ( -
- -

你还没有可用的储值卡

-

请前往"储值卡"页面领取或添加

+
+ +

你还没有储值卡

+

+ 请前往 + + 储值卡 + + 页面申领或前往服务中心办理 +

) : ( -
+
{myTags.map((tag) => ( - + /> ))}
)} {startResult === "error" && ( -

{startError ?? "启动失败,请重试"}

+
+ {startError ?? "启动失败,请重试"} +
)} -
+ {/* Action bar */} +
+
+ + +
+
+ + +
); } @@ -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(); diff --git a/apps/web/components/faces/circles.tsx b/apps/web/components/faces/circles.tsx new file mode 100644 index 0000000..0d48bf6 --- /dev/null +++ b/apps/web/components/faces/circles.tsx @@ -0,0 +1,13 @@ +import type { CardFaceProps } from "./types"; + +/** 卡面:光泽 + 装饰圆形 */ +export function CirclesFace(_: CardFaceProps) { + return ( + <> +
+
+
+
+ + ); +} diff --git a/apps/web/components/faces/glow.tsx b/apps/web/components/faces/glow.tsx new file mode 100644 index 0000000..34d7d82 --- /dev/null +++ b/apps/web/components/faces/glow.tsx @@ -0,0 +1,48 @@ +import type { CardFaceProps } from "./types"; + +/** 卡面:渐变光晕 + 噪点纹理 */ +export function GlowFace(_: CardFaceProps) { + return ( + <> + {/* 噪点纹理 SVG filter */} + + + + + + + + + + {/* 噪点层 */} +
+ + {/* 左下光晕 */} +
+ {/* 右上光晕 */} +
+ {/* 中心微光 */} +
+ + {/* 顶部高光条 */} +
+ + ); +} diff --git a/apps/web/components/faces/index.ts b/apps/web/components/faces/index.ts new file mode 100644 index 0000000..f3c4c3a --- /dev/null +++ b/apps/web/components/faces/index.ts @@ -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; + +export type CardFaceName = keyof typeof CARD_FACE_REGISTRY; diff --git a/apps/web/components/faces/line.tsx b/apps/web/components/faces/line.tsx new file mode 100644 index 0000000..80f0cc0 --- /dev/null +++ b/apps/web/components/faces/line.tsx @@ -0,0 +1,17 @@ +import type { CardFaceProps } from "./types"; + +/** 卡面:斜线纹理 + 右上角光晕 */ +export function LineFace(_: CardFaceProps) { + return ( + <> +
+
+ + ); +} diff --git a/apps/web/components/faces/redeye.tsx b/apps/web/components/faces/redeye.tsx new file mode 100644 index 0000000..2a44f02 --- /dev/null +++ b/apps/web/components/faces/redeye.tsx @@ -0,0 +1,241 @@ +import type { CardFaceProps } from "./types"; + +/** 卡面:深黑底色 + 深红光晕与锐利几何线条 */ +export function RedeyeFace(_: CardFaceProps) { + return ( + <> + {/* 深黑底色 */} +
+ + {/* 红色主光晕:左上 */} +
+ + {/* 红色副光晕:右下 */} +
+ + {/* 顶部红色高光边 */} +
+ + {/* 左侧竖向红线 */} +
+ + {/* 右侧双竖线 */} +
+
+ + {/* 红色横向扫描线组 */} +
+
+ + {/* 电路板风格折角线:左上 */} + + + + + + {/* 电路板风格折角线:右下 */} + + + + + + {/* 风格菱形 */} + + + + + + + {/* 徽标 — 八角框 + 内部对角交叉 + 中心菱形 */} + + {/* 外八角轮廓 */} + + {/* 内八角 */} + + {/* 对角线:左上 → 右下 */} + + {/* 对角线:右上 → 左下 */} + + {/* 横向中轴 */} + + {/* 纵向中轴 */} + + {/* 中心菱形 */} + + {/* 中心圆点 */} + + + {/* 四个顶点刻度点 */} + + + + + + + {/* 噪点 SVG filter */} + + + + + + + + + + {/* 噪点层 */} +
+ + {/* 底部红色压边 */} +
+ + ); +} diff --git a/apps/web/components/faces/types.ts b/apps/web/components/faces/types.ts new file mode 100644 index 0000000..af3aed5 --- /dev/null +++ b/apps/web/components/faces/types.ts @@ -0,0 +1,10 @@ +import type { ComponentType } from "react"; + +/** + * 卡面(卡底装饰)组件的 props。 + * 卡面仅负责视觉装饰(颜色纹理、光效、几何图形等),不接收任何业务数据。 + */ +export type CardFaceProps = Record; + +/** 卡面组件类型 */ +export type CardFaceComponent = ComponentType; diff --git a/apps/web/components/faces/vip.tsx b/apps/web/components/faces/vip.tsx new file mode 100644 index 0000000..28a250c --- /dev/null +++ b/apps/web/components/faces/vip.tsx @@ -0,0 +1,129 @@ +import type { CardFaceProps } from "./types"; + +/** 卡面:黑金 VIP — 深黑底色 + 金色光晕与描边,体现尊贵身份 */ +export function VipFace(_: CardFaceProps) { + return ( + <> + {/* 完全覆盖底部 palette,建立黑金底色 */} +
+ + {/* 金色主光晕:右上角 */} +
+ + {/* 金色副光晕:左下角 */} +
+ + {/* 中部横向光带 */} +
+ + {/* 顶部金色高光边 */} +
+ + {/* 底部深金色压边 */} +
+ + {/* 右侧竖向装饰线 */} +
+ + {/* 细噪点 SVG filter */} + + + + + + + + + + {/* 噪点层:增加高端质感 */} +
+ + {/* 整体金色微光叠层 */} +
+ + {/* 菱形网格纹 */} +
+ + {/* VIP 大水印文字 */} +
+ VIP +
+ + {/* 斜向扫光带 */} +
+ + ); +} diff --git a/apps/web/components/id-tag-card.tsx b/apps/web/components/id-tag-card.tsx new file mode 100644 index 0000000..f6fc7f7 --- /dev/null +++ b/apps/web/components/id-tag-card.tsx @@ -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 ( + + ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index e34fdda..e2bb8e5 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -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("/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(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }), delete: (idTag: string) => diff --git a/apps/web/package.json b/apps/web/package.json index 45fe69b..c028edf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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",