Add StoreKit history and modernize admin console
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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user