Add StoreKit history and modernize admin console
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
Rocky
2026-08-19 22:13:13 +08:00
parent 11ec34dacb
commit 231c5040a5
51 changed files with 6484 additions and 4236 deletions
-80
View File
@@ -1,80 +0,0 @@
import type { FunnelStep, TrendPoint } from "../api/types";
import { escapeHtml, formatNumber } from "../lib/format";
export function trendChart(points: TrendPoint[]): string {
if (points.length === 0) return '<div class="empty-state">暂无趋势数据</div>';
const width = 720;
const height = 240;
const padding = 28;
const values = points.map((point) => point.registrations);
const max = Math.max(...values, 1);
const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0;
const coordinates = points.map((point, index) => ({
x: points.length === 1 ? width / 2 : padding + index * step,
y: height - padding - (point.registrations / max) * (height - padding * 2),
point,
}));
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" ");
const midpoint = Math.ceil(max / 2);
return `
<div class="chart-scroll">
<svg class="trend-chart" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-chart-title trend-chart-description">
<title id="trend-chart-title">新增用户趋势</title>
<desc id="trend-chart-description">横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。</desc>
<line x1="${padding}" y1="${padding}" x2="${width - padding}" y2="${padding}" class="chart-axis" />
<line x1="${padding}" y1="${height / 2}" x2="${width - padding}" y2="${height / 2}" class="chart-axis" />
<line x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}" class="chart-axis" />
<text x="${padding}" y="${padding - 8}" class="chart-label">${formatNumber(max)}</text>
<text x="${padding}" y="${height / 2 - 8}" class="chart-label">${formatNumber(midpoint)}</text>
<text x="${padding}" y="${height - 8}" class="chart-label">0</text>
<polyline points="${polyline}" class="chart-line" />
${coordinates
.map(
({ x, y, point }) => `
<circle cx="${x}" cy="${y}" r="4" class="chart-dot">
<title>${escapeHtml(point.date)}${formatNumber(point.registrations)} </title>
</circle>
`,
)
.join("")}
</svg>
</div>
<table class="sr-only">
<caption>新增用户与积分消耗趋势完整数据</caption>
<thead><tr><th scope="col">UTC 日期</th><th scope="col">新增用户</th><th scope="col">消耗积分</th></tr></thead>
<tbody>
${points
.map(
(point) =>
`<tr><td>${escapeHtml(point.date)}</td><td>${formatNumber(point.registrations)}</td><td>${formatNumber(point.creditsUsed)}</td></tr>`,
)
.join("")}
</tbody>
</table>
`;
}
export function funnelChart(steps: FunnelStep[]): string {
if (steps.length === 0) return '<div class="empty-state">暂无漏斗数据</div>';
const max = Math.max(...steps.map((step) => step.count), 1);
return `
<div class="funnel" aria-label="裂变漏斗">
${steps
.map((step, index) => {
return `
<div class="funnel-step">
<div class="funnel-label">
<span>${escapeHtml(step.label)}</span>
<strong>${formatNumber(step.count)}</strong>
</div>
<progress class="funnel-progress funnel-progress--${(index % 4) + 1}" max="${max}" value="${step.count}" aria-label="${escapeHtml(step.label)}${formatNumber(step.count)}"></progress>
</div>
`;
})
.join("")}
</div>
`;
}
+80
View File
@@ -0,0 +1,80 @@
import { flexRender } from "@tanstack/react-table";
import type { RowData } from "@tanstack/table-core";
import {
getCoreRowModel,
type LegacyColumnDef,
useLegacyTable,
} from "@tanstack/react-table/legacy";
import { type ReactNode } from "react";
import { cn } from "../lib/utils";
import { EmptyState } from "./primitives";
export type DataColumn<T extends RowData> = LegacyColumnDef<T, unknown>;
export function DataTable<T extends RowData>({
data,
columns,
caption,
emptyTitle = "暂无数据",
footer,
className,
}: {
data: T[];
columns: DataColumn<T>[];
caption: string;
emptyTitle?: string;
footer?: ReactNode;
className?: string;
}) {
const table = useLegacyTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
if (data.length === 0) return <EmptyState title={emptyTitle} />;
return (
<div className={cn("overflow-hidden", className)}>
<div className="overflow-x-auto">
<table className="w-full min-w-max border-separate border-spacing-0 text-sm">
<caption className="sr-only">{caption}</caption>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="group transition-colors hover:bg-surface-muted/70"
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="border-b border-border/70 px-5 py-4 align-middle text-foreground last:text-right group-last:border-b-0"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{footer}
</div>
);
}
@@ -0,0 +1,40 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button, Card } from "./primitives";
interface State {
failed: boolean;
}
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
componentDidCatch(_error: Error, _info: ErrorInfo): void {
// 管理端禁止记录可能包含用户或运营数据的渲染上下文。
}
render() {
if (!this.state.failed) return this.props.children;
return (
<Card className="mx-auto grid min-h-80 max-w-lg place-items-center border-danger/20 p-8 text-center">
<div>
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl bg-danger-soft text-danger">
<AlertTriangle className="size-5" aria-hidden />
</span>
<h1 className="text-lg font-bold"></h1>
<p className="mt-2 text-sm leading-6 text-muted">
</p>
<Button className="mt-6" variant="secondary" onClick={() => window.location.reload()}>
<RefreshCw className="size-4" aria-hidden />
</Button>
</div>
</Card>
);
}
}
+308
View File
@@ -0,0 +1,308 @@
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { cva, type VariantProps } from "class-variance-authority";
import {
AlertTriangle,
Inbox,
LoaderCircle,
RefreshCw,
X,
type LucideIcon,
} from "lucide-react";
import {
forwardRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "../lib/utils";
const buttonVariants = cva(
"inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
primary:
"bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90",
secondary:
"border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted",
ghost: "text-muted hover:bg-surface-muted hover:text-foreground",
danger:
"bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90",
},
size: {
sm: "min-h-8 rounded-lg px-3 text-xs",
md: "min-h-10 px-4",
lg: "min-h-12 px-5",
icon: "size-10 px-0",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
loading?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{children}
</button>
),
);
Button.displayName = "Button";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:bg-surface-muted disabled:text-muted",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export const Textarea = forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"min-h-24 w-full resize-y rounded-xl border border-border bg-input px-3.5 py-3 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10",
className,
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-surface shadow-[0_1px_2px_rgb(15_23_42/0.02),0_10px_35px_rgb(15_23_42/0.035)]",
className,
)}
{...props}
/>
);
}
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
{
variants: {
tone: {
neutral: "bg-surface-muted text-muted",
success: "bg-success-soft text-success",
danger: "bg-danger-soft text-danger",
warning: "bg-warning-soft text-warning",
info: "bg-primary-soft text-primary",
violet: "bg-violet-soft text-violet",
},
},
defaultVariants: { tone: "neutral" },
},
);
export function Badge({
className,
tone,
...props
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-primary">
{eyebrow}
</p>
<h1 className="text-balance text-3xl font-bold tracking-[-0.045em] text-foreground sm:text-4xl">
{title}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{description}</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</header>
);
}
export function LoadingState({ label = "正在加载" }: { label?: string }) {
return (
<div className="grid min-h-72 place-items-center" role="status" aria-live="polite">
<div className="flex flex-col items-center gap-3 text-sm text-muted">
<span className="grid size-11 place-items-center rounded-2xl bg-primary-soft text-primary">
<LoaderCircle className="size-5 animate-spin" aria-hidden />
</span>
{label}
</div>
</div>
);
}
export function EmptyState({
title = "暂无数据",
description,
}: {
title?: string;
description?: string;
}) {
return (
<div className="grid min-h-56 place-items-center px-6 text-center">
<div>
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-surface-muted text-muted">
<Inbox className="size-5" aria-hidden />
</span>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description ? <p className="mt-1 text-sm text-muted">{description}</p> : null}
</div>
</div>
);
}
export function ErrorState({
error,
retry,
}: {
error: unknown;
retry?: () => void;
}) {
const message = error instanceof Error ? error.message : "出现未知错误,请稍后重试";
return (
<Card className="mx-auto grid min-h-72 max-w-lg place-items-center border-danger/20 p-8 text-center">
<div>
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-danger-soft text-danger">
<AlertTriangle className="size-5" aria-hidden />
</span>
<h2 className="font-semibold text-foreground"></h2>
<p className="mt-2 text-sm text-muted">{message}</p>
{retry ? (
<Button className="mt-5" variant="secondary" onClick={retry}>
<RefreshCw className="size-4" aria-hidden />
</Button>
) : null}
</div>
</Card>
);
}
export function Dialog({
open,
onOpenChange,
title,
description,
children,
preventClose = false,
className,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
preventClose?: boolean;
className?: string;
}) {
return (
<BaseDialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && preventClose) return;
onOpenChange(nextOpen);
}}
>
<BaseDialog.Portal>
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-slate-950/45 backdrop-blur-[3px] transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0" />
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4">
<BaseDialog.Popup
className={cn(
"relative my-8 w-full max-w-lg rounded-3xl border border-white/10 bg-surface-elevated p-6 shadow-2xl outline-none transition-all data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:p-8",
className,
)}
>
{!preventClose ? (
<BaseDialog.Close
className="absolute right-4 top-4 grid size-9 place-items-center rounded-xl text-muted transition hover:bg-surface-muted hover:text-foreground"
aria-label="关闭"
>
<X className="size-4" aria-hidden />
</BaseDialog.Close>
) : null}
<BaseDialog.Title className="pr-10 text-xl font-bold tracking-tight text-foreground">
{title}
</BaseDialog.Title>
{description ? (
<BaseDialog.Description className="mt-2 text-sm leading-6 text-muted">
{description}
</BaseDialog.Description>
) : null}
<div className="mt-6">{children}</div>
</BaseDialog.Popup>
</BaseDialog.Viewport>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
export function StatCard({
label,
value,
hint,
icon: Icon,
tone = "primary",
}: {
label: string;
value: string;
hint: string;
icon: LucideIcon;
tone?: "primary" | "success" | "violet" | "warning";
}) {
return (
<Card className="group relative overflow-hidden p-5 sm:p-6">
<div className={`stat-glow stat-glow--${tone}`} aria-hidden />
<div className="relative">
<div className="flex items-start justify-between gap-4">
<p className="text-sm font-medium text-muted">{label}</p>
<span className={`stat-icon stat-icon--${tone}`}>
<Icon className="size-4" aria-hidden />
</span>
</div>
<strong className="mt-5 block text-3xl font-bold tracking-[-0.05em] text-foreground tabular-nums">
{value}
</strong>
<p className="mt-2 text-xs text-muted">{hint}</p>
</div>
</Card>
);
}
-78
View File
@@ -1,78 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { ApiError } from "../api/client";
import type { UsageType } from "../api/types";
import { escapeHtml, usageTypeLabel } from "../lib/format";
export function renderUsageTypeBadge(usageType?: UsageType): string {
if (!usageType) return '<span class="muted">—</span>';
return `<span class="badge usage-badge usage-badge--${escapeHtml(usageType)}">${escapeHtml(usageTypeLabel(usageType))}</span>`;
}
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = `
<div class="state-card" role="status" aria-live="polite">
<wa-spinner class="state-spinner" aria-hidden="true"></wa-spinner>
<p>${escapeHtml(label)}…</p>
</div>
`;
}
export function renderError(
container: HTMLElement,
error: unknown,
retry?: () => void,
): void {
const message =
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
container.innerHTML = `
<wa-callout class="state-card state-card--error" variant="danger" appearance="outlined" role="alert">
<span class="state-icon" aria-hidden="true">!</span>
<h2>无法加载数据</h2>
<p>${escapeHtml(message)}</p>
${retry ? '<wa-button variant="neutral" appearance="outlined" data-retry>重试</wa-button>' : ""}
</wa-callout>
`;
container.querySelector<HTMLElement>("[data-retry]")?.addEventListener(
"click",
() => retry?.(),
);
}
export function renderEmpty(message: string): string {
return `<div class="empty-state"><p>${escapeHtml(message)}</p></div>`;
}
export function showToast(message: string, tone: "success" | "error"): void {
const toast = document.createElement("wa-callout");
toast.className = `toast toast--${tone}`;
toast.setAttribute("variant", tone === "error" ? "danger" : "success");
toast.setAttribute("appearance", "filled-outlined");
toast.setAttribute("role", tone === "error" ? "alert" : "status");
toast.textContent = message;
document.body.append(toast);
requestAnimationFrame(() => toast.classList.add("toast--visible"));
window.setTimeout(() => {
toast.classList.remove("toast--visible");
window.setTimeout(() => toast.remove(), 180);
}, 3_200);
}
export function setButtonBusy(
button: HTMLButtonElement | WaButton,
busy: boolean,
busyLabel = "处理中…",
): void {
if (button.tagName === "WA-BUTTON") {
(button as WaButton).loading = busy;
}
if (busy) {
button.dataset.label = button.textContent ?? "";
button.textContent = busyLabel;
button.disabled = true;
button.setAttribute("aria-busy", "true");
} else {
button.textContent = button.dataset.label ?? button.textContent;
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
-15
View File
@@ -1,15 +0,0 @@
import "@awesome.me/webawesome/dist/styles/webawesome.css";
import "@awesome.me/webawesome/dist/components/button/button.js";
import "@awesome.me/webawesome/dist/components/callout/callout.js";
import "@awesome.me/webawesome/dist/components/input/input.js";
import "@awesome.me/webawesome/dist/components/spinner/spinner.js";
const darkMode = window.matchMedia("(prefers-color-scheme: dark)");
function syncColorScheme(event: MediaQueryList | MediaQueryListEvent): void {
document.documentElement.classList.toggle("wa-dark", event.matches);
document.documentElement.classList.toggle("wa-light", !event.matches);
}
syncColorScheme(darkMode);
darkMode.addEventListener("change", syncColorScheme);