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
+2 -1
View File
@@ -4,11 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f5f7fb" />
<meta name="referrer" content="same-origin" />
<title>OSG 运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1629 -330
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -9,13 +9,29 @@
"test": "vitest run"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^27.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.2",
"vite": "^7.1.2",
"vitest": "^3.2.4"
},
"dependencies": {
"@awesome.me/webawesome": "^3.11.0"
"@base-ui/react": "^1.7.0",
"@tanstack/react-table": "^9.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0"
}
}
-289
View File
@@ -1,289 +0,0 @@
import { adminApi, ApiError, setCsrfToken } from "./api/client";
import type { AdminRole, AuthState } from "./api/types";
import { renderLoading, showToast } from "./components/ui";
import { escapeHtml } from "./lib/format";
import { renderAudit } from "./pages/audit";
import { renderPasswordLogin } from "./pages/auth";
import { renderCredits } from "./pages/credits";
import { renderOverview } from "./pages/overview";
import { renderReferrals } from "./pages/referrals";
import { renderSecurity } from "./pages/security";
import { renderUsers } from "./pages/users";
type Route =
| "overview"
| "referrals"
| "users"
| "credits"
| "audit"
| "security";
interface RouteDefinition {
id: Route;
label: string;
icon: string;
roles: AdminRole[];
}
const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"];
const routes: RouteDefinition[] = [
{
id: "overview",
label: "运营总览",
roles: ALL_ROLES,
icon: '<path d="M4 18V10M10 18V6M16 18V3M2 18h18" />',
},
{
id: "referrals",
label: "裂变分析",
roles: ALL_ROLES,
icon:
'<circle cx="5" cy="11" r="2.5" /><circle cx="17" cy="5" r="2.5" /><circle cx="17" cy="17" r="2.5" /><path d="m7.3 9.8 7.4-3.6M7.3 12.2l7.4 3.6" />',
},
{
id: "users",
label: "用户查询",
roles: SUPPORT_ROLES,
icon:
'<circle cx="8" cy="7" r="3" /><circle cx="16" cy="8" r="2.5" /><path d="M2.5 18c.5-3.5 2.3-5.5 5.5-5.5s5 2 5.5 5.5M13 13.5c3.8-.7 6 1 6.5 4.5" />',
},
{
id: "credits",
label: "积分流水",
roles: SUPPORT_ROLES,
icon:
'<rect x="3" y="4" width="16" height="15" rx="2" /><path d="M7 8h8M7 12h8M7 16h5" />',
},
{
id: "audit",
label: "审计日志",
roles: SUPER_ADMIN_ONLY,
icon:
'<path d="M11 2.5 18 5v5.5c0 4.5-2.7 7.3-7 9-4.3-1.7-7-4.5-7-9V5l7-2.5Z" /><path d="m8 11 2 2 4-4" />',
},
{
id: "security",
label: "安全中心",
roles: SUPER_ADMIN_ONLY,
icon:
'<rect x="4" y="9" width="14" height="10" rx="2" /><path d="M7.5 9V6.5a3.5 3.5 0 0 1 7 0V9M11 13v2.5" />',
},
];
export class AdminApp {
private auth: AuthState = { status: "anonymous" };
private renderVersion = 0;
constructor(private readonly root: HTMLElement) {
window.addEventListener("hashchange", () => void this.renderRoute());
window.addEventListener("admin:unauthorized", () => {
setCsrfToken();
this.auth = { status: "anonymous" };
this.render();
showToast("登录状态已失效,请重新登录", "error");
});
}
async start(): Promise<void> {
renderLoading(this.root, "检查登录状态");
try {
const session = await adminApi.session();
if (session.authenticated && session.operatorName && session.role) {
this.auth = {
status: "authenticated",
operatorName: session.operatorName,
role: session.role,
};
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
showToast("暂时无法确认登录状态", "error");
}
}
this.render();
}
private render(): void {
if (this.auth.status === "anonymous") {
renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) });
return;
}
this.renderShell();
void this.renderRoute();
}
private setAuth(state: AuthState): void {
this.auth = state;
this.render();
}
private currentRoute(): Route {
const value = window.location.hash.replace(/^#\/?/, "") as Route;
return this.availableRoutes().some((route) => route.id === value)
? value
: "overview";
}
private availableRoutes(): RouteDefinition[] {
if (this.auth.status !== "authenticated") return [];
const role = this.auth.role;
return routes.filter((route) => route.roles.includes(role));
}
private renderShell(): void {
if (this.auth.status !== "authenticated") return;
this.root.innerHTML = `
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" id="primary-navigation" data-sidebar>
<div class="sidebar-brand">
<div class="brand-mark brand-mark--small">OSG</div>
<div><strong>管理中心</strong><span>OSG ACCOUNT</span></div>
</div>
<nav class="nav-list" aria-label="主导航">
${this.availableRoutes()
.map(
(route) => `
<a href="#/${route.id}" data-route="${route.id}">
<svg class="nav-icon" viewBox="0 0 22 22" aria-hidden="true">${route.icon}</svg>
${route.label}
</a>
`,
)
.join("")}
</nav>
<div class="sidebar-footer">
<div class="operator">
<span class="operator-avatar" aria-hidden="true">管</span>
<div><strong>${escapeHtml(this.auth.operatorName)}</strong><span>${roleLabel(this.auth.role)}</span></div>
</div>
<wa-button class="logout-button" appearance="plain" data-logout title="安全退出">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M9 4H4v14h5M13 7l4 4-4 4M17 11H8" /></svg>
<span>退出</span>
</wa-button>
</div>
</aside>
<div class="main-column">
<header class="mobile-header">
<wa-button class="icon-button" appearance="plain" data-menu aria-label="打开导航" aria-expanded="false" aria-controls="primary-navigation">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M3 6h16M3 11h16M3 16h16" /></svg>
</wa-button>
<strong data-mobile-title>运营总览</strong>
</header>
<main class="page-content" id="main-content" data-content tabindex="-1"></main>
</div>
<button class="sidebar-scrim" data-scrim aria-label="关闭导航"></button>
</div>
`;
const sidebar = this.root.querySelector<HTMLElement>("[data-sidebar]");
const menuButton =
this.root.querySelector<HTMLButtonElement>("[data-menu]");
const mobileLayout = window.matchMedia("(max-width: 1023px)");
const syncSidebarState = () => {
if (!sidebar) return;
sidebar.inert =
mobileLayout.matches && !sidebar.classList.contains("sidebar--open");
};
const closeMenu = (restoreFocus = false) => {
sidebar?.classList.remove("sidebar--open");
menuButton?.setAttribute("aria-expanded", "false");
syncSidebarState();
if (restoreFocus) menuButton?.focus();
};
const openMenu = () => {
sidebar?.classList.add("sidebar--open");
menuButton?.setAttribute("aria-expanded", "true");
syncSidebarState();
sidebar
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a')
?.focus();
};
menuButton?.addEventListener("click", openMenu);
this.root
.querySelector<HTMLButtonElement>("[data-scrim]")
?.addEventListener("click", () => closeMenu(true));
this.root.querySelectorAll("[data-route]").forEach((link) => {
link.addEventListener("click", () => closeMenu());
});
sidebar?.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu(true);
});
mobileLayout.addEventListener("change", syncSidebarState);
syncSidebarState();
this.root
.querySelector<HTMLButtonElement>("[data-logout]")
?.addEventListener("click", () => void this.logout());
}
private async renderRoute(): Promise<void> {
if (this.auth.status !== "authenticated") return;
const route = this.currentRoute();
this.root.querySelectorAll<HTMLElement>("[data-route]").forEach((link) => {
const active = link.dataset.route === route;
link.classList.toggle("active", active);
if (active) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
const routeDefinition = routes.find((item) => item.id === route);
document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`;
const mobileTitle =
this.root.querySelector<HTMLElement>("[data-mobile-title]");
if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心";
const content = this.root.querySelector<HTMLElement>("[data-content]");
if (!content) return;
const version = ++this.renderVersion;
const stage = document.createElement("div");
stage.className = "page-stage";
renderLoading(content);
if (route === "overview") await renderOverview(stage);
if (route === "referrals") await renderReferrals(stage);
if (route === "users") renderUsers(stage, this.auth.role);
if (route === "credits") renderCredits(stage);
if (route === "audit") await renderAudit(stage);
if (route === "security") {
await renderSecurity(stage, this.auth.operatorName);
}
if (version === this.renderVersion) {
content.replaceChildren(stage);
const heading = content.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
} else {
content.focus({ preventScroll: true });
}
}
}
private async logout(): Promise<void> {
try {
await adminApi.logout();
} catch {
// 即使服务端退出失败,也立即清理前端认证状态。
} finally {
setCsrfToken();
this.auth = { status: "anonymous" };
window.history.replaceState(null, "", window.location.pathname);
this.render();
}
}
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
+470
View File
@@ -0,0 +1,470 @@
import {
Activity,
BookOpenCheck,
ChevronRight,
Coins,
GitBranch,
LogOut,
Menu,
Moon,
Search,
ShieldCheck,
Sun,
Users,
X,
type LucideIcon,
} from "lucide-react";
import {
lazy,
Suspense,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
HashRouter,
Navigate,
NavLink,
Route,
Routes,
useLocation,
} from "react-router-dom";
import { Toaster, toast } from "sonner";
import type { AdminRole } from "./api/types";
import { ErrorBoundary } from "./components/error-boundary";
import { Button, LoadingState } from "./components/primitives";
import { AuthProvider, useAuth } from "./features/auth/auth-context";
import { LoginPage } from "./features/auth/login-page";
import { cn } from "./lib/utils";
const OverviewPage = lazy(() =>
import("./features/overview/overview-page").then((module) => ({
default: module.OverviewPage,
})),
);
const ReferralsPage = lazy(() =>
import("./features/referrals/referrals-page").then((module) => ({
default: module.ReferralsPage,
})),
);
const UsersPage = lazy(() =>
import("./features/users/users-page").then((module) => ({
default: module.UsersPage,
})),
);
const CreditsPage = lazy(() =>
import("./features/credits/credits-page").then((module) => ({
default: module.CreditsPage,
})),
);
const AuditPage = lazy(() =>
import("./features/audit/audit-page").then((module) => ({
default: module.AuditPage,
})),
);
const SecurityPage = lazy(() =>
import("./features/security/security-page").then((module) => ({
default: module.SecurityPage,
})),
);
interface NavItem {
path: string;
label: string;
description: string;
icon: LucideIcon;
roles: AdminRole[];
}
const allRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
const supportRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
const navigation: NavItem[] = [
{
path: "/overview",
label: "运营总览",
description: "增长与消耗",
icon: Activity,
roles: allRoles,
},
{
path: "/referrals",
label: "裂变分析",
description: "转化与排行",
icon: GitBranch,
roles: allRoles,
},
{
path: "/users",
label: "用户查询",
description: "账户与使用",
icon: Users,
roles: supportRoles,
},
{
path: "/credits",
label: "积分流水",
description: "不可变账本",
icon: Coins,
roles: supportRoles,
},
{
path: "/audit",
label: "审计日志",
description: "操作追踪",
icon: BookOpenCheck,
roles: ["SUPER_ADMIN"],
},
{
path: "/security",
label: "安全中心",
description: "权限与会话",
icon: ShieldCheck,
roles: ["SUPER_ADMIN"],
},
];
export function App() {
return (
<AuthProvider>
<HashRouter>
<AppContent />
</HashRouter>
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{
classNames: {
toast: "osg-toast",
},
}}
/>
</AuthProvider>
);
}
function AppContent() {
const { auth, checking } = useAuth();
useEffect(() => {
function handleToast(event: Event) {
const detail = (event as CustomEvent<{ message: string; tone: "success" | "error" }>)
.detail;
if (!detail) return;
if (detail.tone === "error") toast.error(detail.message);
else toast.success(detail.message);
}
window.addEventListener("admin:toast", handleToast);
return () => window.removeEventListener("admin:toast", handleToast);
}, []);
if (checking) {
return (
<main className="grid min-h-screen place-items-center bg-page">
<LoadingState label="检查登录状态" />
</main>
);
}
if (auth.status === "anonymous") return <LoginPage />;
return <AuthenticatedApp role={auth.role} />;
}
function AuthenticatedApp({ role }: { role: AdminRole }) {
const available = useMemo(
() => navigation.filter((item) => item.roles.includes(role)),
[role],
);
return (
<AppShell navigationItems={available}>
<ErrorBoundary>
<Suspense fallback={<LoadingState label="加载页面" />}>
<Routes>
<Route path="/overview" element={<OverviewPage />} />
<Route path="/referrals" element={<ReferralsPage />} />
{supportRoles.includes(role) ? (
<>
<Route path="/users" element={<UsersPage />} />
<Route path="/credits" element={<CreditsPage />} />
</>
) : null}
{role === "SUPER_ADMIN" ? (
<>
<Route path="/audit" element={<AuditPage />} />
<Route path="/security" element={<SecurityPage />} />
</>
) : null}
<Route path="*" element={<Navigate to="/overview" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AppShell>
);
}
function AppShell({
navigationItems,
children,
}: {
navigationItems: NavItem[];
children: ReactNode;
}) {
const { auth, logout } = useAuth();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [mobile, setMobile] = useState(
() =>
typeof window.matchMedia === "function" &&
window.matchMedia("(max-width: 1023px)").matches,
);
const sidebarRef = useRef<HTMLElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem("osg-admin-theme");
return saved
? saved === "dark"
: typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("osg-admin-theme", dark ? "dark" : "light");
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", dark ? "#0a0c10" : "#f5f7fb");
}, [dark]);
useEffect(() => {
setSidebarOpen(false);
const active = navigation.find((item) => item.path === location.pathname);
document.title = `${active?.label ?? "管理中心"} · OSG`;
const main = document.querySelector<HTMLElement>("#main-content");
const focusHeading = () => {
const heading = main?.querySelector<HTMLElement>("h1");
if (!heading) return false;
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
return true;
};
if (focusHeading()) return;
const observer = new MutationObserver(() => {
if (focusHeading()) observer.disconnect();
});
if (main) observer.observe(main, { childList: true, subtree: true });
return () => observer.disconnect();
}, [location.pathname]);
useEffect(() => {
if (!sidebarOpen) return;
function close(event: KeyboardEvent) {
if (event.key === "Escape") {
setSidebarOpen(false);
menuButtonRef.current?.focus();
}
}
window.addEventListener("keydown", close);
return () => window.removeEventListener("keydown", close);
}, [sidebarOpen]);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(max-width: 1023px)");
const update = () => setMobile(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
if (!sidebarOpen || !mobile) return;
requestAnimationFrame(() => {
sidebarRef.current
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a[href]')
?.focus();
});
}, [mobile, sidebarOpen]);
if (auth.status !== "authenticated") return null;
return (
<div className="min-h-screen bg-page text-foreground">
<a
className="fixed left-3 top-3 z-[100] -translate-y-24 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white transition focus:translate-y-0"
href="#main-content"
>
</a>
<aside
ref={sidebarRef}
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-[272px] flex-col border-r border-border bg-sidebar/95 p-4 backdrop-blur-2xl transition-transform duration-300 lg:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
inert={mobile && !sidebarOpen}
aria-hidden={mobile && !sidebarOpen}
aria-label="主导航"
>
<div className="flex h-16 items-center justify-between px-2">
<div className="flex items-center gap-3">
<span className="brand-symbol size-10 rounded-xl text-sm">O</span>
<span>
<strong className="block text-sm font-bold tracking-tight">OSG</strong>
<span className="block text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Account Intelligence
</span>
</span>
</div>
<Button
ref={menuButtonRef}
className="lg:hidden"
size="icon"
variant="ghost"
onClick={() => setSidebarOpen(false)}
aria-label="关闭导航"
>
<X className="size-5" aria-hidden />
</Button>
</div>
<nav className="mt-5 flex-1 space-y-1.5">
<p className="mb-3 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-muted/80">
</p>
{navigationItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
"group flex min-h-14 items-center gap-3 rounded-2xl px-3 transition-all",
isActive
? "bg-surface text-primary shadow-[0_1px_2px_rgb(15_23_42/0.06),0_8px_25px_rgb(15_23_42/0.05)]"
: "text-muted hover:bg-surface-muted hover:text-foreground",
)
}
>
{({ isActive }) => (
<>
<span
className={cn(
"grid size-9 shrink-0 place-items-center rounded-xl transition",
isActive ? "bg-primary-soft text-primary" : "bg-transparent",
)}
>
<item.icon className="size-[18px]" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{item.label}</span>
<span className="mt-0.5 block text-[10px] text-muted">{item.description}</span>
</span>
<ChevronRight
className={cn(
"size-4 opacity-0 transition group-hover:opacity-60",
isActive && "opacity-60",
)}
aria-hidden
/>
</>
)}
</NavLink>
))}
</nav>
<div className="rounded-2xl border border-border bg-surface/70 p-3">
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-primary to-violet text-sm font-bold text-white shadow-md">
{auth.operatorName.slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<strong className="block truncate text-xs">{auth.operatorName}</strong>
<span className="mt-0.5 block text-[10px] text-muted">{roleLabel(auth.role)}</span>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => void logout()}
aria-label="安全退出"
title="安全退出"
>
<LogOut className="size-4" aria-hidden />
</Button>
</div>
</div>
</aside>
<div className="lg:pl-[272px]">
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-page/85 px-4 backdrop-blur-xl sm:px-6 lg:px-10">
<Button
className="lg:hidden"
size="icon"
variant="ghost"
onClick={() => setSidebarOpen(true)}
aria-label="打开导航"
aria-expanded={sidebarOpen}
>
<Menu className="size-5" aria-hidden />
</Button>
<div className="hidden items-center gap-2 text-xs text-muted sm:flex">
<span>OSG </span>
<ChevronRight className="size-3.5" aria-hidden />
<strong className="font-semibold text-foreground">
{navigation.find((item) => item.path === location.pathname)?.label ?? "运营总览"}
</strong>
</div>
<div className="ml-auto flex items-center gap-1">
<Button
className="hidden sm:inline-flex"
size="icon"
variant="ghost"
aria-label="全局搜索即将提供"
title="全局搜索即将提供"
disabled
>
<Search className="size-4" aria-hidden />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => setDark((current) => !current)}
aria-label={dark ? "切换浅色模式" : "切换深色模式"}
title={dark ? "浅色模式" : "深色模式"}
>
{dark ? (
<Sun className="size-4" aria-hidden />
) : (
<Moon className="size-4" aria-hidden />
)}
</Button>
</div>
</header>
<main id="main-content" className="mx-auto w-full max-w-[1500px] px-4 py-7 sm:px-6 sm:py-9 lg:px-10 lg:py-11">
<div className="animate-page-in" key={location.pathname}>
{children}
</div>
</main>
</div>
{sidebarOpen ? (
<button
className="fixed inset-0 z-30 bg-slate-950/45 backdrop-blur-[2px] lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-label="关闭导航"
/>
) : null}
</div>
);
}
function roleLabel(role: AdminRole): string {
return {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
}[role];
}
-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);
+169
View File
@@ -0,0 +1,169 @@
import { FileCheck2, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type { AuditLogEntry } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
ErrorState,
LoadingState,
PageHeader,
} from "../../components/primitives";
import { formatDateTime, statusLabel } from "../../lib/format";
export function AuditPage() {
const [items, setItems] = useState<AuditLogEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.auditLogs();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.auditLogs(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
);
} finally {
setLoadingMore(false);
}
}
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "operatorName",
header: "操作员",
cell: ({ getValue }) => <span className="font-semibold">{String(getValue())}</span>,
},
{
accessorKey: "action",
header: "操作",
cell: ({ getValue }) => (
<span className="rounded-lg bg-primary-soft px-2 py-1 font-mono text-[11px] text-primary">
{String(getValue())}
</span>
),
},
{
id: "target",
header: "目标",
cell: ({ row }) => (
<span>
<span className="block text-xs font-medium">{row.original.targetType}</span>
<span className="mt-1 block max-w-48 truncate font-mono text-[11px] text-muted">
{row.original.targetId}
</span>
</span>
),
},
{
accessorKey: "requestId",
header: "请求 ID",
cell: ({ getValue }) => (
<span className="font-mono text-[11px] text-muted">{String(getValue() || "—")}</span>
),
},
{
accessorKey: "result",
header: "结果",
cell: ({ getValue }) => {
const result = String(getValue());
return (
<Badge tone={result === "success" ? "success" : "danger"}>
{statusLabel(result)}
</Badge>
);
},
},
],
[],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="安全与合规"
title="审计日志"
description="追踪管理员操作、目标与执行结果,形成完整可追溯链路。"
/>
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</span>
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-xs leading-5 text-muted">
</p>
</div>
<Badge className="sm:ml-auto" tone="info">
<FileCheck2 className="size-3.5" aria-hidden />
{items.length}
</Badge>
</Card>
<Card className="overflow-hidden">
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load()} />
</div>
) : loading ? (
<LoadingState label="加载审计日志" />
) : (
<DataTable
data={items}
columns={columns}
caption="管理员审计事件"
emptyTitle="暂无审计记录"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,109 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { adminApi, ApiError, setCsrfToken } from "../../api/client";
import type { AdminRole, AuthState } from "../../api/types";
interface AuthContextValue {
auth: AuthState;
checking: boolean;
login: (username: string, password: string, totpCode: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [auth, setAuth] = useState<AuthState>({ status: "anonymous" });
const [checking, setChecking] = useState(true);
const expireSession = useCallback(() => {
setCsrfToken();
setAuth({ status: "anonymous" });
}, []);
useEffect(() => {
let active = true;
void adminApi
.session()
.then((session) => {
if (
active &&
session.authenticated &&
session.operatorName &&
isAdminRole(session.role)
) {
setAuth({
status: "authenticated",
operatorName: session.operatorName,
role: session.role,
});
}
})
.catch((error: unknown) => {
if (!(error instanceof ApiError) || error.status !== 401) {
window.dispatchEvent(
new CustomEvent("admin:toast", {
detail: { message: "暂时无法确认登录状态", tone: "error" },
}),
);
}
})
.finally(() => {
if (active) setChecking(false);
});
window.addEventListener("admin:unauthorized", expireSession);
return () => {
active = false;
window.removeEventListener("admin:unauthorized", expireSession);
};
}, [expireSession]);
const login = useCallback(
async (username: string, password: string, totpCode: string) => {
const response = await adminApi.login(username, password, totpCode);
setCsrfToken(response.csrfToken);
setAuth({
status: "authenticated",
operatorName: response.operatorName,
role: response.role,
});
},
[],
);
const logout = useCallback(async () => {
try {
await adminApi.logout();
} catch {
// 即使服务端退出失败,也立即清理前端认证状态。
} finally {
expireSession();
window.history.replaceState(null, "", window.location.pathname);
}
}, [expireSession]);
const value = useMemo(
() => ({ auth, checking, login, logout }),
[auth, checking, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth 必须在 AuthProvider 中使用");
return context;
}
function isAdminRole(role?: AdminRole): role is AdminRole {
return role === "SUPER_ADMIN" || role === "SUPPORT" || role === "ANALYST";
}
+178
View File
@@ -0,0 +1,178 @@
import { ArrowRight, Fingerprint, LockKeyhole, ShieldCheck, Sparkles } from "lucide-react";
import { useState, type FormEvent } from "react";
import { ApiError } from "../../api/client";
import { Button, Input } from "../../components/primitives";
import { useAuth } from "./auth-context";
export function LoginPage() {
const { login } = useAuth();
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const password = data.get("password")?.toString() ?? "";
const totpCode = data.get("totpCode")?.toString().trim() ?? "";
if (username.length < 3 || password.length < 12 || !/^\d{6}$/.test(totpCode)) {
setError("请输入有效的用户名、密码和 6 位动态验证码");
return;
}
setSubmitting(true);
setError("");
try {
await login(username, password, totpCode);
form.reset();
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "登录验证失败");
setSubmitting(false);
}
}
return (
<main className="relative min-h-screen overflow-hidden bg-auth">
<div className="auth-orb auth-orb--one" aria-hidden />
<div className="auth-orb auth-orb--two" aria-hidden />
<div className="relative mx-auto grid min-h-screen max-w-7xl items-center gap-16 px-5 py-10 lg:grid-cols-[1.1fr_0.9fr] lg:px-12">
<section className="animate-page-in hidden lg:block" aria-label="OSG 运营后台">
<BrandMark size="large" />
<p className="mt-10 inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary-soft px-3 py-1.5 text-xs font-semibold text-primary">
<Sparkles className="size-3.5" aria-hidden />
OSG Account Intelligence
</p>
<h1 className="mt-6 max-w-xl text-5xl font-bold leading-[1.08] tracking-[-0.06em] text-foreground">
<br />
</h1>
<p className="mt-6 max-w-lg text-base leading-7 text-muted">
</p>
<div className="mt-12 grid max-w-xl grid-cols-3 gap-4">
<TrustItem icon={ShieldCheck} label="角色权限" />
<TrustItem icon={Fingerprint} label="双重认证" />
<TrustItem icon={LockKeyhole} label="不可变审计" />
</div>
</section>
<section className="animate-card-in mx-auto w-full max-w-md rounded-[2rem] border border-white/60 bg-surface-elevated/90 p-7 shadow-[0_30px_90px_rgb(15_23_42/0.12)] backdrop-blur-2xl sm:p-10">
<div className="lg:hidden">
<BrandMark size="small" />
</div>
<p className="mt-8 text-xs font-bold uppercase tracking-[0.18em] text-primary lg:mt-0">
访
</p>
<h2 className="mt-3 text-3xl font-bold tracking-[-0.045em] text-foreground">
</h2>
<p className="mt-2 text-sm leading-6 text-muted">
使
</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit} noValidate>
<Field label="管理员用户名" htmlFor="username">
<Input
id="username"
name="username"
autoComplete="username"
minLength={3}
maxLength={64}
autoFocus
required
/>
</Field>
<Field label="管理员密码" htmlFor="password">
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
minLength={12}
required
/>
</Field>
<Field label="动态验证码" htmlFor="totpCode">
<Input
id="totpCode"
name="totpCode"
className="text-center text-lg font-semibold tracking-[0.35em]"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<Button className="w-full" size="lg" type="submit" loading={submitting}>
{!submitting ? <ArrowRight className="size-4" aria-hidden /> : null}
</Button>
</form>
<p className="mt-6 text-center text-xs leading-5 text-muted">
</p>
</section>
</div>
</main>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: React.ReactNode;
}) {
return (
<label className="block" htmlFor={htmlFor}>
<span className="mb-2 block text-sm font-semibold text-foreground">{label}</span>
{children}
</label>
);
}
function BrandMark({ size }: { size: "small" | "large" }) {
return (
<div className="flex items-center gap-3">
<span
className={`brand-symbol ${size === "large" ? "size-14 rounded-2xl text-base" : "size-11 rounded-xl text-sm"}`}
>
O
</span>
<span>
<strong className="block text-sm font-bold tracking-tight text-foreground">OSG</strong>
<span className="block text-[10px] font-semibold tracking-[0.16em] text-muted">
</span>
</span>
</div>
);
}
function TrustItem({
icon: Icon,
label,
}: {
icon: typeof ShieldCheck;
label: string;
}) {
return (
<div className="flex items-center gap-2.5 text-xs font-semibold text-muted">
<span className="grid size-8 place-items-center rounded-xl bg-surface shadow-sm">
<Icon className="size-4 text-primary" aria-hidden />
</span>
{label}
</div>
);
}
@@ -0,0 +1,213 @@
import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { adminApi, ApiError } from "../../api/client";
import type { LedgerEntry } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
ErrorState,
Input,
LoadingState,
PageHeader,
} from "../../components/primitives";
import {
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { toast } from "sonner";
export function CreditsPage() {
const [query, setQuery] = useState("");
const [activeUserId, setActiveUserId] = useState<string>();
const [items, setItems] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (userId?: string) => {
setLoading(true);
setError(undefined);
try {
const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = activeUserId
? await adminApi.ledger(activeUserId, nextCursor)
: await adminApi.latestLedger(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
} finally {
setLoadingMore(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = query.trim() || undefined;
setActiveUserId(userId);
void load(userId);
}
const columns = useMemo<DataColumn<LedgerEntry>[]>(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "userId",
header: "用户 ID",
cell: ({ getValue }) => (
<span className="block max-w-48 truncate font-mono text-xs" title={String(getValue())}>
{String(getValue())}
</span>
),
},
{
accessorKey: "type",
header: "类型",
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "usageType",
header: "消费类型",
cell: ({ getValue }) =>
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "reasonCode",
header: "原因",
cell: ({ getValue }) => (
<span className="rounded-lg bg-surface-muted px-2 py-1 font-mono text-[11px] text-muted">
{String(getValue())}
</span>
),
},
],
[],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="积分账本"
title="积分流水"
description="查看不可变积分账本,可按完整内部用户 ID 精确查询。"
/>
<Card className="overflow-hidden">
<div className="border-b border-border p-5 sm:p-6">
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
<label className="relative flex-1">
<span className="sr-only"> ID</span>
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
aria-hidden
/>
<Input
className="pl-10"
value={query}
onChange={(event) => setQuery(event.target.value)}
type="search"
placeholder="输入完整用户 ID,留空查看全部"
maxLength={36}
autoComplete="off"
/>
</label>
<Button type="submit">
<ArrowDownUp className="size-4" aria-hidden />
</Button>
</form>
</div>
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Coins className="size-4 text-primary" aria-hidden />
<span>{activeUserId ? "指定用户流水" : "最新积分流水"}</span>
<span aria-hidden>·</span>
<strong className="font-semibold text-foreground">{formatNumber(items.length)} </strong>
{activeUserId ? (
<span className="ml-auto max-w-64 truncate font-mono" title={activeUserId}>
{activeUserId}
</span>
) : null}
</div>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeUserId)} />
</div>
) : loading ? (
<LoadingState label="加载积分流水" />
) : (
<DataTable
data={items}
columns={columns}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,330 @@
import {
Activity,
ArrowUpRight,
Coins,
CreditCard,
Sparkles,
UserPlus,
Users,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { adminApi } from "../../api/client";
import type { Overview, TrendPoint } from "../../api/types";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { formatNumber, usageTypeLabel } from "../../lib/format";
const ranges = [
{ value: "7d", label: "7 天" },
{ value: "30d", label: "30 天" },
{ value: "90d", label: "90 天" },
] as const;
export function OverviewPage() {
const [range, setRange] = useState("30d");
const [data, setData] = useState<Overview>();
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setError(undefined);
try {
setData(await adminApi.overview(range));
} catch (requestError) {
setError(requestError);
}
}, [range]);
useEffect(() => {
void load();
}, [load]);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载运营总览" />;
const activeRate =
data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : 0;
const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0);
return (
<div className="space-y-6">
<PageHeader
eyebrow="核心指标"
title="运营总览"
description="聚合用户增长、活跃度与积分流转,快速识别业务变化。"
actions={<RangeControl value={range} onChange={setRange} />}
/>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="关键指标">
<StatCard
label="用户总数"
value={formatNumber(data.totalUsers)}
hint={`当前周期新增 ${formatNumber(data.newUsers)}`}
icon={Users}
/>
<StatCard
label="活跃用户"
value={formatNumber(data.activeUsers)}
hint={`活跃率 ${activeRate}%`}
icon={Activity}
tone="success"
/>
<StatCard
label="积分消耗"
value={formatNumber(data.creditsUsed)}
hint={`${formatNumber(usageRequests)} 次已结算请求`}
icon={CreditCard}
tone="violet"
/>
<StatCard
label="积分余额"
value={formatNumber(data.totalCreditBalance)}
hint={`周期赠送 ${formatNumber(data.creditsGranted)}`}
icon={Coins}
tone="warning"
/>
</section>
<section className="grid gap-6 xl:grid-cols-[minmax(0,1.65fr)_minmax(280px,0.65fr)]">
<Card className="min-w-0 p-5 sm:p-6">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-base font-bold text-foreground"></h2>
<p className="mt-1 text-xs text-muted"> UTC </p>
</div>
<div className="flex items-center gap-4 text-xs text-muted">
<Legend color="bg-primary" label="新增用户" />
<Legend color="bg-violet" label="积分消耗" />
</div>
</div>
<div className="h-[320px] w-full">
<TrendChart points={data.trend} />
</div>
</Card>
<Card className="overflow-hidden">
<div className="border-b border-border p-5 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-foreground"></h2>
<p className="mt-1 text-xs text-muted"></p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<Sparkles className="size-4" aria-hidden />
</span>
</div>
</div>
<div className="divide-y divide-border">
<SummaryRow
icon={UserPlus}
label="新增用户"
value={formatNumber(data.newUsers)}
/>
<SummaryRow
icon={CreditCard}
label="赠送 / 消耗"
value={`${formatNumber(data.creditsGranted)} / ${formatNumber(data.creditsUsed)}`}
/>
<SummaryRow
icon={ArrowUpRight}
label="使用类型"
value={`${formatNumber(data.usage.length)}`}
/>
</div>
</Card>
</section>
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold text-foreground">使</h2>
<p className="mt-1 text-xs text-muted"></p>
</div>
</div>
{data.usage.length === 0 ? (
<div className="p-8 text-center text-sm text-muted">使</div>
) : (
<div className="grid divide-y divide-border md:grid-cols-2 md:divide-x md:divide-y-0 xl:grid-cols-3">
{data.usage.map((item) => (
<div key={item.kind} className="p-5 sm:p-6">
<div className="flex items-center justify-between">
<span className="rounded-full bg-violet-soft px-2.5 py-1 text-xs font-semibold text-violet">
{usageTypeLabel(item.kind)}
</span>
<span className="text-xs text-muted">{formatNumber(item.requests)} </span>
</div>
<strong className="mt-5 block text-2xl font-bold tracking-tight tabular-nums">
{formatNumber(item.chargedCredits)}
</strong>
<span className="mt-1 block text-xs text-muted"></span>
</div>
))}
</div>
)}
</Card>
</div>
);
}
function RangeControl({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
{ranges.map((range) => (
<button
key={range.value}
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
value === range.value
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
onClick={() => onChange(range.value)}
type="button"
aria-pressed={value === range.value}
>
{range.label}
</button>
))}
</div>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-2">
<i className={`size-2 rounded-full ${color}`} aria-hidden />
{label}
</span>
);
}
function SummaryRow({
icon: Icon,
label,
value,
}: {
icon: typeof Users;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-3 px-5 py-4 sm:px-6">
<span className="grid size-9 place-items-center rounded-xl bg-surface-muted text-muted">
<Icon className="size-4" aria-hidden />
</span>
<span className="text-sm text-muted">{label}</span>
<strong className="ml-auto text-sm font-semibold tabular-nums text-foreground">{value}</strong>
</div>
);
}
export function TrendChart({ points }: { points: TrendPoint[] }) {
if (points.length === 0) {
return <div className="grid h-full place-items-center text-sm text-muted"></div>;
}
const width = 760;
const height = 300;
const paddingX = 42;
const paddingY = 30;
const plotHeight = height - paddingY * 2;
const step = points.length > 1 ? (width - paddingX * 2) / (points.length - 1) : 0;
const registrationMax = Math.max(...points.map((point) => point.registrations), 1);
const creditMax = Math.max(...points.map((point) => point.creditsUsed), 1);
const coordinates = points.map((point, index) => {
const x = points.length === 1 ? width / 2 : paddingX + index * step;
return {
point,
x,
registrationY:
height - paddingY - (point.registrations / registrationMax) * plotHeight,
creditY: height - paddingY - (point.creditsUsed / creditMax) * plotHeight,
};
});
const registrationLine = coordinates
.map(({ x, registrationY }) => `${x},${registrationY}`)
.join(" ");
const creditLine = coordinates.map(({ x, creditY }) => `${x},${creditY}`).join(" ");
const labelEvery = Math.max(Math.ceil(points.length / 6), 1);
return (
<>
<div className="chart-scroll h-full overflow-x-auto">
<svg
className="trend-chart block h-full min-w-[620px] overflow-visible"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-labelledby="overview-trend-title overview-trend-description"
>
<title id="overview-trend-title"></title>
<desc id="overview-trend-description">
UTC 线
</desc>
{[paddingY, height / 2, height - paddingY].map((y) => (
<line
key={y}
x1={paddingX}
x2={width - paddingX}
y1={y}
y2={y}
className="chart-grid-line"
/>
))}
<text x={paddingX} y={paddingY - 9} className="chart-label">
{formatNumber(registrationMax)}
</text>
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
{formatNumber(creditMax)}
</text>
<polyline points={registrationLine} className="chart-line chart-line--primary" />
<polyline points={creditLine} className="chart-line chart-line--violet" />
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
<g key={point.date}>
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
<title>
{point.date} {formatNumber(point.registrations)}
</title>
</circle>
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
<title>
{point.date} {formatNumber(point.creditsUsed)}
</title>
</circle>
{index % labelEvery === 0 || index === points.length - 1 ? (
<text
x={x}
y={height - 5}
textAnchor={index === 0 ? "start" : index === points.length - 1 ? "end" : "middle"}
className="chart-label"
>
{point.date.slice(5)}
</text>
) : null}
</g>
))}
</svg>
</div>
<table className="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 key={point.date}>
<td>{point.date}</td>
<td>{formatNumber(point.registrations)}</td>
<td>{formatNumber(point.creditsUsed)}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
@@ -0,0 +1,232 @@
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { adminApi } from "../../api/client";
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Card,
ErrorState,
LoadingState,
PageHeader,
StatCard,
} from "../../components/primitives";
import { formatNumber } from "../../lib/format";
export function ReferralsPage() {
const [range, setRange] = useState("30d");
const [data, setData] = useState<ReferralOverview>();
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setError(undefined);
try {
setData(await adminApi.referrals(range));
} catch (requestError) {
setError(requestError);
}
}, [range]);
useEffect(() => {
void load();
}, [load]);
const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
() => [
{
id: "rank",
header: "名次",
cell: ({ row }) => <Rank value={row.index + 1} />,
},
{
accessorKey: "userId",
header: "用户 ID",
cell: ({ getValue }) => (
<span className="font-mono text-xs text-muted">{String(getValue())}</span>
),
},
{
accessorKey: "invited",
header: "邀请",
cell: ({ getValue }) => (
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "qualified",
header: "有效",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums text-success">
{formatNumber(Number(getValue()))}
</span>
),
},
{
accessorKey: "creditsEarned",
header: "奖励积分",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">
{formatNumber(Number(getValue()))}
</span>
),
},
],
[],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载裂变分析" />;
const first = data.funnel.at(0)?.count ?? 0;
const last = data.funnel.at(-1)?.count ?? 0;
const conversion = first > 0 ? Math.round((last / first) * 100) : 0;
return (
<div className="space-y-6">
<PageHeader
eyebrow="增长分析"
title="裂变与排行"
description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。"
actions={<RangeControl value={range} onChange={setRange} />}
/>
<section className="grid gap-4 sm:grid-cols-3">
<StatCard
label="待资格确认"
value={formatNumber(data.pendingBindings)}
hint="等待首次有效使用"
icon={Clock3}
tone="warning"
/>
<StatCard
label="未达奖励条件"
value={formatNumber(data.ineligibleBindings)}
hint="未产生积分奖励"
icon={CircleOff}
tone="violet"
/>
<StatCard
label="漏斗转化率"
value={`${conversion}%`}
hint="首环节至最终有效使用"
icon={TrendingUp}
tone="success"
/>
</section>
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
<Card className="p-5 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted">使</p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<GitBranch className="size-4" aria-hidden />
</span>
</div>
<div className="mt-8 space-y-6">
{data.funnel.length === 0 ? (
<p className="py-16 text-center text-sm text-muted"></p>
) : (
data.funnel.map((step, index) => {
const max = Math.max(data.funnel[0]?.count ?? 1, 1);
return (
<div key={`${step.label}-${index}`}>
<div className="mb-2.5 flex items-center justify-between text-sm">
<span className="font-medium text-muted">{step.label}</span>
<strong className="tabular-nums">{formatNumber(step.count)}</strong>
</div>
<progress
className="funnel-progress block h-3 w-full overflow-hidden rounded-full"
max={max}
value={step.count}
aria-label={`${step.label}${formatNumber(step.count)}`}
/>
</div>
);
})
)}
</div>
</Card>
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted"></p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
<Award className="size-4" aria-hidden />
</span>
</div>
<DataTable
data={data.ranking}
columns={columns}
caption="有效邀请用户排行"
emptyTitle="当前周期暂无排行数据"
/>
</Card>
</section>
<Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-success-soft text-success">
<Users className="size-5" aria-hidden />
</span>
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-xs leading-5 text-muted">
使
</p>
</div>
</Card>
</div>
);
}
function Rank({ value }: { value: number }) {
const tone =
value === 1
? "bg-warning-soft text-warning"
: value === 2
? "bg-surface-muted text-muted"
: value === 3
? "bg-danger-soft text-danger"
: "bg-surface-muted text-muted";
return (
<span className={`grid size-7 place-items-center rounded-lg text-xs font-bold ${tone}`}>
{value}
</span>
);
}
function RangeControl({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
{[
["7d", "7 天"],
["30d", "30 天"],
["90d", "90 天"],
].map(([range, label]) => (
<button
key={range}
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
value === range
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
onClick={() => onChange(range ?? "30d")}
type="button"
aria-pressed={value === range}
>
{label}
</button>
))}
</div>
);
}
@@ -0,0 +1,714 @@
import {
Ban,
Check,
Clipboard,
KeyRound,
LockKeyhole,
Plus,
RotateCcwKey,
ShieldCheck,
Unlock,
UserCheck,
UsersRound,
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
AdminOperator,
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
Dialog,
ErrorState,
Input,
LoadingState,
PageHeader,
StatCard,
} from "../../components/primitives";
import { formatDateTime, formatNumber } from "../../lib/format";
import { useAuth } from "../auth/auth-context";
type OperatorAction = "enable" | "disable" | "unlock" | "sessions";
interface ConfirmationState {
operator: AdminOperator;
action: OperatorAction;
}
export function SecurityPage() {
const { auth } = useAuth();
const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
const [operators, setOperators] = useState<AdminOperator[]>([]);
const [summary, setSummary] = useState<AdminSecuritySummary>();
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [createOpen, setCreateOpen] = useState(false);
const [resetOperator, setResetOperator] = useState<AdminOperator>();
const [confirmation, setConfirmation] = useState<ConfirmationState>();
const [provisioning, setProvisioning] = useState<{
data: AdminOperatorProvisioning;
title: string;
}>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const [page, securitySummary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
setOperators(page.items);
setNextCursor(page.nextCursor);
setSummary(securitySummary);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.operators(nextCursor);
setOperators((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载管理员失败",
);
} finally {
setLoadingMore(false);
}
}
async function handleConfirmedAction() {
if (!confirmation) return;
const { operator, action } = confirmation;
setConfirmation(undefined);
try {
if (action === "enable") {
await adminApi.setOperatorEnabled(operator.operatorId, true);
} else if (action === "disable") {
await adminApi.setOperatorEnabled(operator.operatorId, false);
} else if (action === "unlock") {
await adminApi.unlockOperator(operator.operatorId);
} else {
await adminApi.revokeOperatorSessions(operator.operatorId);
}
toast.success("安全设置已更新");
await load();
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "安全设置更新失败",
);
}
}
const columns = useMemo<DataColumn<AdminOperator>[]>(
() => [
{
id: "operator",
header: "管理员",
cell: ({ row }) => {
const current = row.original.username === currentUsername;
return (
<span>
<span className="flex items-center gap-2 font-semibold">
{row.original.username}
{current ? <Badge></Badge> : null}
</span>
<span className="mt-1 block font-mono text-[11px] text-muted">
{row.original.operatorId}
</span>
</span>
);
},
},
{
accessorKey: "role",
header: "角色",
cell: ({ getValue }) => <RoleBadge role={String(getValue()) as AdminRole} />,
},
{
id: "status",
header: "状态",
cell: ({ row }) => {
const locked =
Boolean(row.original.lockedUntil) &&
new Date(row.original.lockedUntil ?? "").getTime() > Date.now();
return (
<span className="flex gap-1.5">
<Badge tone={row.original.enabled ? "success" : "danger"}>
{row.original.enabled ? "已启用" : "已停用"}
</Badge>
{locked ? <Badge tone="warning"></Badge> : null}
</span>
);
},
},
{
accessorKey: "lastLoginAt",
header: "最近登录",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(getValue() ? String(getValue()) : undefined)}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<OperatorActions
operator={row.original}
currentUsername={currentUsername}
onAction={(action) => setConfirmation({ operator: row.original, action })}
onReset={() => setResetOperator(row.original)}
/>
),
},
],
[currentUsername],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !summary) return <LoadingState label="加载安全中心" />;
return (
<div className="space-y-6">
<PageHeader
eyebrow="访问控制"
title="安全中心"
description="管理运营人员、角色、登录锁定、活动会话与双重认证。"
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" aria-hidden />
</Button>
}
/>
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</span>
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-xs leading-5 text-muted">
访
</p>
</div>
<Badge className="sm:ml-auto" tone="success">
<Check className="size-3.5" aria-hidden />
</Badge>
</Card>
<section className="grid gap-4 sm:grid-cols-3">
<StatCard
label="已启用管理员"
value={formatNumber(summary.enabledOperators)}
hint="具备登录资格的账户"
icon={UserCheck}
tone="success"
/>
<StatCard
label="已锁定账户"
value={formatNumber(summary.lockedOperators)}
hint="等待解锁或锁定到期"
icon={LockKeyhole}
tone="warning"
/>
<StatCard
label="活动会话"
value={formatNumber(summary.activeSessions)}
hint="尚未过期且未撤销"
icon={UsersRound}
tone="violet"
/>
</section>
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted"> {formatNumber(operators.length)} </p>
</div>
<KeyRound className="size-5 text-muted" aria-hidden />
</div>
<DataTable
data={operators}
columns={columns}
caption="管理员账户与安全状态"
emptyTitle="暂无管理员账户"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
</Card>
<CreateOperatorDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(data) => {
setCreateOpen(false);
setProvisioning({ data, title: "管理员已创建" });
}}
/>
<ResetCredentialsDialog
operator={resetOperator}
onOpenChange={(open) => {
if (!open) setResetOperator(undefined);
}}
onReset={(data) => {
setResetOperator(undefined);
setProvisioning({ data, title: "登录凭据已重置" });
}}
/>
<ConfirmationDialog
state={confirmation}
onOpenChange={(open) => {
if (!open) setConfirmation(undefined);
}}
onConfirm={() => void handleConfirmedAction()}
/>
<ProvisioningDialog
value={provisioning}
onComplete={() => {
setProvisioning(undefined);
void load();
}}
/>
</div>
);
}
function OperatorActions({
operator,
currentUsername,
onAction,
onReset,
}: {
operator: AdminOperator;
currentUsername: string;
onAction: (action: OperatorAction) => void;
onReset: () => void;
}) {
const locked =
Boolean(operator.lockedUntil) &&
new Date(operator.lockedUntil ?? "").getTime() > Date.now();
const current = operator.username === currentUsername;
return (
<div className="flex max-w-md flex-wrap justify-end gap-2">
{locked ? (
<Button size="sm" variant="secondary" onClick={() => onAction("unlock")}>
<Unlock className="size-3.5" aria-hidden />
</Button>
) : null}
<Button size="sm" variant="secondary" onClick={() => onAction("sessions")}>
</Button>
<Button size="sm" variant="secondary" onClick={onReset}>
<RotateCcwKey className="size-3.5" aria-hidden />
</Button>
<Button
size="sm"
variant={operator.enabled ? "danger" : "secondary"}
disabled={current && operator.enabled}
onClick={() => onAction(operator.enabled ? "disable" : "enable")}
>
{operator.enabled ? <Ban className="size-3.5" aria-hidden /> : null}
{operator.enabled ? "停用" : "启用"}
</Button>
</div>
);
}
function CreateOperatorDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (data: AdminOperatorProvisioning) => void;
}) {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const role = data.get("role")?.toString() as AdminRole;
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
if (
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
password.length < 12 ||
password !== confirmation
) {
setError("请检查用户名、角色及两次输入的密码");
return;
}
setSubmitting(true);
setError("");
try {
const result = await adminApi.createOperator({ username, role, password });
form.reset();
setSubmitting(false);
onCreated(result);
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "创建管理员失败");
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setError("");
onOpenChange(nextOpen);
}}
title="添加管理员"
description="创建后,TOTP 密钥只显示一次,请准备安全交付。"
>
<form className="space-y-5" onSubmit={submit} noValidate>
<Field label="用户名">
<Input
name="username"
autoComplete="off"
minLength={3}
maxLength={64}
pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}"
required
/>
</Field>
<Field label="角色">
<select
className="h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
name="role"
defaultValue="ANALYST"
required
>
<option value="ANALYST"> · </option>
<option value="SUPPORT"> · </option>
<option value="SUPER_ADMIN"> · </option>
</select>
</Field>
<Field label="初始密码">
<Input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<Field label="确认密码">
<Input
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" loading={submitting}>
TOTP
</Button>
</div>
</form>
</Dialog>
);
}
function ResetCredentialsDialog({
operator,
onOpenChange,
onReset,
}: {
operator?: AdminOperator;
onOpenChange: (open: boolean) => void;
onReset: (data: AdminOperatorProvisioning) => void;
}) {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!operator) return;
const data = new FormData(event.currentTarget);
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
if (password.length < 12 || password !== confirmation) {
setError("请输入至少 12 位且两次一致的新密码");
return;
}
setSubmitting(true);
setError("");
try {
const result = await adminApi.resetOperatorCredentials(operator.operatorId, password);
setSubmitting(false);
onReset(result);
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "凭据重置失败");
setSubmitting(false);
}
}
return (
<Dialog
open={Boolean(operator)}
onOpenChange={(open) => {
setError("");
onOpenChange(open);
}}
title="重置登录凭据"
description={`将为“${operator?.username ?? ""}”重置密码与 TOTP,并立即撤销全部会话。`}
>
<form className="space-y-5" onSubmit={submit}>
<Field label="新密码">
<Input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<Field label="确认新密码">
<Input
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" variant="danger" loading={submitting}>
</Button>
</div>
</form>
</Dialog>
);
}
function ConfirmationDialog({
state,
onOpenChange,
onConfirm,
}: {
state?: ConfirmationState;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
if (!state) return null;
const content = confirmationContent(state.action, state.operator.username);
return (
<Dialog
open
onOpenChange={onOpenChange}
title={content.title}
description={content.message}
>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => onOpenChange(false)}>
</Button>
<Button variant={content.dangerous ? "danger" : "primary"} onClick={onConfirm}>
{content.label}
</Button>
</div>
</Dialog>
);
}
function ProvisioningDialog({
value,
onComplete,
}: {
value?: { data: AdminOperatorProvisioning; title: string };
onComplete: () => void;
}) {
const [saved, setSaved] = useState(false);
if (!value) return null;
async function copy(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label}已复制,请妥善保管`);
} catch {
toast.error(`无法复制${label},请手动选择`);
}
}
return (
<Dialog
open
onOpenChange={() => undefined}
title={value.title}
description="TOTP 配置仅显示一次,请立即安全保存并交付给对应管理员。"
preventClose
className="max-w-xl"
>
<div className="rounded-2xl border border-warning/20 bg-warning-soft/60 p-5">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-warning">Base32 </p>
<code className="mt-3 block select-all break-all font-mono text-base font-semibold tracking-wider">
{value.data.totpSecret}
</code>
<Button
className="mt-4"
size="sm"
variant="secondary"
onClick={() => void copy(value.data.totpSecret, "TOTP 密钥")}
>
<Clipboard className="size-3.5" aria-hidden />
</Button>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<a
className="inline-flex min-h-10 items-center rounded-xl border border-border bg-surface px-4 text-sm font-semibold transition hover:bg-surface-muted"
href={value.data.otpauthUri}
>
</a>
<Button
variant="secondary"
onClick={() => void copy(value.data.otpauthUri, "认证器配置链接")}
>
</Button>
</div>
<label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-border p-4 text-sm">
<input
className="mt-0.5 size-4 accent-primary"
type="checkbox"
checked={saved}
onChange={(event) => setSaved(event.target.checked)}
/>
<span></span>
</label>
<Button
className="mt-5 w-full"
disabled={!saved}
onClick={() => {
setSaved(false);
onComplete();
}}
>
</Button>
</Dialog>
);
}
function confirmationContent(action: OperatorAction, username: string) {
const target = `${username}`;
if (action === "disable") {
return {
title: "停用管理员?",
message: `${target} 将无法登录,全部活动会话会立即失效。`,
label: "停用管理员",
dangerous: true,
};
}
if (action === "enable") {
return {
title: "启用管理员?",
message: `${target} 将恢复登录权限。`,
label: "确认启用",
dangerous: false,
};
}
if (action === "unlock") {
return {
title: "解除登录锁定?",
message: `${target} 可以立即重新尝试登录。`,
label: "确认解锁",
dangerous: false,
};
}
return {
title: "撤销全部会话?",
message: `${target} 已登录的所有设备都需要重新认证。`,
label: "撤销会话",
dangerous: true,
};
}
function RoleBadge({ role }: { role: AdminRole }) {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return (
<Badge tone={role === "SUPER_ADMIN" ? "violet" : role === "SUPPORT" ? "info" : "neutral"}>
{labels[role]}
</Badge>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-2 block text-sm font-semibold">{label}</span>
{children}
</label>
);
}
+733
View File
@@ -0,0 +1,733 @@
import {
ArrowLeft,
CheckCircle2,
Coins,
Gift,
Search,
Sparkles,
Users,
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
LedgerEntry,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
Dialog,
ErrorState,
Input,
LoadingState,
PageHeader,
Textarea,
} from "../../components/primitives";
import {
createIdempotencyKey,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { useAuth } from "../auth/auth-context";
export function UsersPage() {
const { auth } = useAuth();
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
const [selectedUserId, setSelectedUserId] = useState<string>();
return selectedUserId ? (
<UserDetailView
userId={selectedUserId}
canGrant={role === "SUPER_ADMIN"}
onBack={() => setSelectedUserId(undefined)}
/>
) : (
<UserList onSelect={setSelectedUserId} />
);
}
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
const [query, setQuery] = useState("");
const [activeQuery, setActiveQuery] = useState("");
const [items, setItems] = useState<UserSummary[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (search = "") => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.users(search);
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const search = query.trim();
setActiveQuery(search);
void load(search);
}
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.users(activeQuery, nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败");
} finally {
setLoadingMore(false);
}
}
const columns = useMemo<DataColumn<UserSummary>[]>(
() => [
{
id: "user",
header: "用户",
cell: ({ row }) => (
<button
className="group/user max-w-64 text-left"
type="button"
onClick={() => onSelect(row.original.userId)}
>
<span className="block font-semibold transition group-hover/user:text-primary">
{row.original.displayName || "未命名用户"}
</span>
<span className="mt-1 block truncate font-mono text-[11px] text-muted">
{row.original.userId}
</span>
</button>
),
},
{
accessorKey: "status",
header: "状态",
cell: ({ getValue }) => <UserStatus status={String(getValue())} />,
},
{
accessorKey: "consumedCredits",
header: "累计使用",
cell: ({ getValue }) => (
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "creditBalance",
header: "当前积分",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "createdAt",
header: "注册时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<Button size="sm" variant="secondary" onClick={() => onSelect(row.original.userId)}>
</Button>
),
},
],
[onSelect],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="账户管理"
title="用户列表"
description="按注册时间浏览账户,支持完整或后 8 位内部用户 ID 查询。"
/>
<Card className="overflow-hidden">
<div className="border-b border-border p-5 sm:p-6">
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
<label className="relative flex-1">
<span className="sr-only"> ID</span>
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
aria-hidden
/>
<Input
className="pl-10"
value={query}
onChange={(event) => setQuery(event.target.value)}
type="search"
placeholder="输入完整或后 8 位用户 ID"
maxLength={36}
autoComplete="off"
/>
</label>
<Button type="submit">
<Search className="size-4" aria-hidden />
</Button>
</form>
</div>
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Users className="size-4 text-primary" aria-hidden />
{activeQuery ? "查询结果" : "全部用户"}
<span aria-hidden>·</span>
<strong className="text-foreground">{formatNumber(items.length)} </strong>
</div>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeQuery)} />
</div>
) : loading ? (
<LoadingState label="加载用户列表" />
) : (
<DataTable
data={items}
columns={columns}
caption={activeQuery ? "用户查询结果" : "全部用户"}
emptyTitle="未找到匹配用户"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}
function UserDetailView({
userId,
canGrant,
onBack,
}: {
userId: string;
canGrant: boolean;
onBack: () => void;
}) {
const [user, setUser] = useState<UserDetail>();
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [grantOpen, setGrantOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const [detail, page] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
]);
setUser(detail);
setLedger(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
void load();
}, [load]);
async function loadMoreLedger() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.ledger(userId, nextCursor);
setLedger((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
);
} finally {
setLoadingMore(false);
}
}
const ledgerColumns = useLedgerColumns();
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !user) return <LoadingState label="加载用户详情" />;
const usage = user.usage ?? [];
const requests = usage.reduce((total, item) => total + item.requests, 0);
const chargedCredits = usage.reduce((total, item) => total + item.chargedCredits, 0);
const inviterUserId = user.referral?.inviterUserId ?? user.referredByUserId;
const qualifiedUsage = user.qualifiedUsage || requests > 0;
return (
<div className="space-y-6">
<div>
<Button className="-ml-3 mb-4" variant="ghost" onClick={onBack}>
<ArrowLeft className="size-4" aria-hidden />
</Button>
<PageHeader
eyebrow="用户详情"
title={user.displayName || "未命名用户"}
description={user.userId}
actions={
canGrant ? (
<Button onClick={() => setGrantOpen(true)}>
<Gift className="size-4" aria-hidden />
</Button>
) : undefined
}
/>
</div>
<section className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card className="h-fit overflow-hidden">
<div className="bg-gradient-to-br from-primary-soft to-violet-soft/40 p-6">
<div className="flex items-center gap-4">
<span className="grid size-14 place-items-center rounded-2xl bg-surface text-xl font-bold text-primary shadow-sm">
{(user.displayName || "用").slice(0, 1)}
</span>
<div className="min-w-0">
<strong className="block truncate">{user.displayName || "未命名用户"}</strong>
<div className="mt-2">
<UserStatus status={user.status} />
</div>
</div>
</div>
</div>
<div className="p-6">
<p className="text-xs font-medium text-muted"></p>
<strong className="mt-2 block text-4xl font-bold tracking-[-0.06em] tabular-nums">
{formatNumber(user.creditBalance)}
</strong>
<dl className="mt-6 divide-y divide-border">
<DetailRow label="注册时间" value={formatDateTime(user.createdAt)} />
<DetailRow label="最近活跃" value={formatDateTime(user.lastActiveAt)} />
<DetailRow
label="有效使用"
value={qualifiedUsage ? `已达成 · ${formatNumber(requests)}` : "未达成"}
/>
<DetailRow label="累计消耗" value={`${formatNumber(chargedCredits)} 积分`} />
<DetailRow label="邀请码" value={user.referralCode || "—"} mono />
<DetailRow label="邀请来源" value={inviterUserId || "—"} mono />
{user.referral ? (
<DetailRow
label="邀请成效"
value={`${formatNumber(user.referral.rewardedInvites)} / ${formatNumber(user.referral.invitedUsers)} 已奖励`}
/>
) : null}
</dl>
</div>
</Card>
<div className="space-y-6">
<Card className="overflow-hidden">
<SectionHeading
icon={Coins}
title="积分流水"
description="所有变动均来自不可变账本"
/>
<DataTable
data={ledger}
columns={ledgerColumns}
caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle="暂无积分流水"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button
variant="secondary"
onClick={() => void loadMoreLedger()}
loading={loadingMore}
>
</Button>
</div>
) : null
}
/>
</Card>
{usage.length > 0 ? <UsagePanel usage={usage} /> : null}
</div>
</section>
{canGrant ? (
<GrantDialog
open={grantOpen}
user={user}
onOpenChange={setGrantOpen}
onSuccess={load}
/>
) : null}
</div>
);
}
export function GrantDialog({
open,
user,
onOpenChange,
onSuccess,
}: {
open: boolean;
user: UserDetail;
onOpenChange: (open: boolean) => void;
onSuccess: () => Promise<void>;
}) {
const [step, setStep] = useState<"input" | "confirm">("input");
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [outcomeUnknown, setOutcomeUnknown] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(createIdempotencyKey);
function reset() {
setStep("input");
setAmount("");
setReason("");
setError("");
setSubmitting(false);
setOutcomeUnknown(false);
setIdempotencyKey(createIdempotencyKey());
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && outcomeUnknown) {
setError("赠送结果尚未确认,请在当前窗口使用同一请求安全重试");
return;
}
onOpenChange(nextOpen);
if (!nextOpen) reset();
}
function review() {
const numericAmount = Number(amount);
if (!Number.isSafeInteger(numericAmount) || numericAmount < 1 || numericAmount > 100_000) {
setError("积分必须是 1 至 100,000 的整数");
return;
}
if (reason.trim().length < 4) {
setError("请填写至少 4 个字符的赠送原因");
return;
}
setError("");
setStep("confirm");
}
async function submit() {
setSubmitting(true);
setError("");
try {
await adminApi.grantCredits({
userId: user.userId,
amount: Number(amount),
reason: reason.trim(),
idempotencyKey,
});
onOpenChange(false);
reset();
toast.success("积分赠送成功,账本已更新");
await onSuccess();
} catch (requestError) {
const unknown =
!(requestError instanceof ApiError) ||
requestError.status === 0 ||
requestError.status >= 500;
setOutcomeUnknown(unknown);
setError(
unknown
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
: requestError instanceof ApiError
? requestError.message
: "赠送失败,请重试",
);
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={step === "input" ? "人工赠送积分" : "确认赠送"}
description="赠送将写入不可变账本,并记录完整管理员审计日志。"
preventClose={outcomeUnknown}
>
{step === "input" ? (
<div className="space-y-5">
<Field label="用户 ID">
<Input value={user.userId} disabled />
</Field>
<Field label="赠送积分">
<Input
value={amount}
onChange={(event) => setAmount(event.target.value)}
type="number"
min={1}
max={100000}
step={1}
inputMode="numeric"
/>
</Field>
<Field label="赠送原因">
<Textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
minLength={4}
maxLength={200}
placeholder="填写可审计的业务原因"
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
</Button>
<Button onClick={review}></Button>
</div>
</div>
) : (
<div>
<div className="rounded-2xl border border-danger/15 bg-danger-soft/60 p-5">
<p className="text-sm text-muted"></p>
<strong className="mt-2 block text-2xl font-bold tabular-nums">
{formatNumber(Number(amount))}
</strong>
<p className="mt-3 text-sm">{user.displayName || user.userId}</p>
<p className="mt-2 rounded-xl bg-surface/70 p-3 text-xs leading-5 text-muted">
{reason.trim()}
</p>
</div>
<p className="mt-4 min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="mt-5 flex justify-end gap-3">
<Button
variant="secondary"
disabled={outcomeUnknown || submitting}
onClick={() => setStep("input")}
>
</Button>
<Button variant="danger" loading={submitting} onClick={() => void submit()}>
</Button>
</div>
</div>
)}
</Dialog>
);
}
function useLedgerColumns(): DataColumn<LedgerEntry>[] {
return useMemo(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "type",
header: "类型",
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "usageType",
header: "消费类型",
cell: ({ getValue }) =>
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "reasonCode",
header: "原因",
cell: ({ getValue }) => (
<span className="font-mono text-[11px] text-muted">{String(getValue())}</span>
),
},
],
[],
);
}
function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) {
const columns = useMemo<DataColumn<UserUsageAggregate>[]>(
() => [
{
accessorKey: "kind",
header: "类型",
cell: ({ getValue }) => <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "requests",
header: "请求",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "chargedCredits",
header: "消耗积分",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "asrMillis",
header: "语音毫秒",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "inputTokens",
header: "输入 Token",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "outputTokens",
header: "输出 Token",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
],
[],
);
return (
<Card className="overflow-hidden">
<SectionHeading
icon={Sparkles}
title="使用统计"
description="按使用类型汇总,不包含任何用户内容"
/>
<DataTable data={usage} columns={columns} caption="用户使用统计" />
</Card>
);
}
function SectionHeading({
icon: Icon,
title,
description,
}: {
icon: typeof Coins;
title: string;
description: string;
}) {
return (
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold">{title}</h2>
<p className="mt-1 text-xs text-muted">{description}</p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<Icon className="size-4" aria-hidden />
</span>
</div>
);
}
function DetailRow({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-4 py-3 text-xs">
<dt className="shrink-0 text-muted">{label}</dt>
<dd
className={`m-0 max-w-[190px] break-all text-right font-medium ${mono ? "font-mono text-[11px]" : ""}`}
>
{value}
</dd>
</div>
);
}
function UserStatus({ status }: { status: string }) {
const tone = status === "active" ? "success" : status === "suspended" ? "danger" : "neutral";
return (
<Badge tone={tone}>
{status === "active" ? <CheckCircle2 className="size-3" aria-hidden /> : null}
{statusLabel(status)}
</Badge>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-2 block text-sm font-semibold">{label}</span>
{children}
</label>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-8
View File
@@ -1,8 +0,0 @@
import "./components/webawesome";
import "./styles.css";
import { AdminApp } from "./app";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
void new AdminApp(root).start();
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./styles.css";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
-99
View File
@@ -1,99 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type { AuditLogEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime, statusLabel } from "../lib/format";
export async function renderAudit(container: HTMLElement): Promise<void> {
renderLoading(container, "加载审计日志");
try {
const page = await adminApi.auditLogs();
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">安全与合规</div>
<h1>审计日志</h1>
<p>追踪管理员操作、目标和执行结果。</p>
</div>
</div>
<section class="panel">
${
page.items.length === 0
? renderEmpty("暂无审计记录")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">管理员审计事件</caption>
<thead><tr><th scope="col">时间</th><th scope="col">操作员</th><th scope="col">操作</th><th scope="col">目标</th><th scope="col">请求 ID</th><th scope="col">结果</th></tr></thead>
<tbody data-audit-body>${auditRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-audit-more>加载更早记录</wa-button></div>'
: ""
}
`
}
</section>
`;
if (page.nextCursor) bindAuditPagination(container, page.nextCursor);
} catch (error) {
renderError(container, error, () => void renderAudit(container));
}
}
function auditRows(items: AuditLogEntry[]): string {
return items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(entry.operatorName)}</td>
<td><code>${escapeHtml(entry.action)}</code></td>
<td>
<span>${escapeHtml(entry.targetType)}</span>
<div class="subtle mono">${escapeHtml(entry.targetId)}</div>
</td>
<td class="mono">${escapeHtml(entry.requestId || "—")}</td>
<td><span class="badge badge--${entry.result}">${statusLabel(entry.result)}</span></td>
</tr>
`,
)
.join("");
}
function bindAuditPagination(container: HTMLElement, initialCursor: string): void {
const button =
container.querySelector<HTMLButtonElement>("[data-audit-more]");
const body = container.querySelector<HTMLTableSectionElement>("[data-audit-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.auditLogs(cursor);
body.insertAdjacentHTML("beforeend", auditRows(page.items));
cursor = page.nextCursor;
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载审计记录失败",
"error",
);
setButtonBusy(button, false);
}
});
}
-75
View File
@@ -1,75 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { adminApi, ApiError, setCsrfToken } from "../api/client";
import type { AuthState } from "../api/types";
import { setButtonBusy } from "../components/ui";
interface AuthCallbacks {
onChange: (state: AuthState) => void;
}
function authFrame(content: string): string {
return `
<main class="auth-shell">
<section class="auth-brand" aria-label="OSG 运营后台">
<div class="brand-mark">OSG</div>
<p>安全、克制、可追溯的运营管理</p>
</section>
<section class="auth-card">${content}</section>
</main>
`;
}
export function renderPasswordLogin(
root: HTMLElement,
callbacks: AuthCallbacks,
): void {
root.innerHTML = authFrame(`
<div class="eyebrow">运营后台</div>
<h1>管理员登录</h1>
<p class="muted">使用管理员凭据和认证器动态验证码登录。凭据不会保存到浏览器。</p>
<form class="form-stack" data-login-form novalidate>
<wa-input name="username" label="管理员用户名" type="text" autocomplete="username" minlength="3" maxlength="64" appearance="outlined" required autofocus></wa-input>
<wa-input name="password" label="管理员密码" type="password" autocomplete="current-password" minlength="12" appearance="outlined" password-toggle required></wa-input>
<wa-input class="totp-input" name="totpCode" label="动态验证码" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" appearance="outlined" required></wa-input>
<p class="form-error" data-error role="alert"></p>
<wa-button class="button--wide" variant="brand" appearance="accent" type="submit">安全登录</wa-button>
</form>
`);
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector<WaButton>('wa-button[type="submit"]');
const errorNode = form.querySelector<HTMLElement>("[data-error]");
const username = new FormData(form).get("username")?.toString().trim() ?? "";
const password = new FormData(form).get("password")?.toString() ?? "";
const totpCode = new FormData(form).get("totpCode")?.toString().trim() ?? "";
if (
username.length < 3 ||
password.length < 12 ||
!/^\d{6}$/.test(totpCode) ||
!button ||
!errorNode
) {
if (errorNode) errorNode.textContent = "请输入有效的用户名、密码和 6 位动态验证码";
return;
}
setButtonBusy(button, true, "验证中…");
errorNode.textContent = "";
try {
const response = await adminApi.login(username, password, totpCode);
form.reset();
setCsrfToken(response.csrfToken);
callbacks.onChange({
status: "authenticated",
operatorName: response.operatorName,
role: response.role,
});
} catch (error) {
errorNode.textContent =
error instanceof ApiError ? error.message : "验证码验证失败";
setButtonBusy(button, false);
}
});
}
-145
View File
@@ -1,145 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type { LedgerEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
renderUsageTypeBadge,
setButtonBusy,
showToast,
} from "../components/ui";
import {
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderCredits(container: HTMLElement): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">积分账本</div>
<h1>积分流水</h1>
<p>查看最新不可变积分流水,也可按完整内部用户 ID 查询。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-ledger-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="userId" type="search" placeholder="输入完整用户 ID,留空查看全部" autocomplete="off" maxlength="36" />
</label>
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
</form>
<div data-ledger-results>${renderEmpty("正在加载最新积分流水")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-ledger-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
void loadLedger(container, userId || undefined);
});
void loadLedger(container);
}
async function loadLedger(
container: HTMLElement,
userId?: string,
): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-ledger-results]");
if (!results) return;
renderLoading(results, "加载积分流水");
try {
const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
results.innerHTML =
page.items.length === 0
? renderEmpty(userId ? "该用户暂无积分流水" : "暂无积分流水")
: `
<div class="result-summary">${
userId
? `用户 <span class="mono">${escapeHtml(userId)}</span>`
: "最新积分流水"
} · ${formatNumber(page.items.length)} 条记录</div>
<div class="table-wrap">
<table>
<caption class="sr-only">${userId ? `用户 ${escapeHtml(userId)}` : "最新"}积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">用户 ID</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">消费类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more>加载更多流水</wa-button></div>'
: ""
}
`;
if (page.nextCursor) {
bindLedgerPagination(container, userId, page.nextCursor);
}
} catch (error) {
renderError(results, error, () => void loadLedger(container, userId));
}
}
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.userId)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td>${renderUsageTypeBadge(entry.usageType)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("");
}
function bindLedgerPagination(
container: HTMLElement,
userId: string | undefined,
initialCursor: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = userId
? await adminApi.ledger(userId, cursor)
: await adminApi.latestLedger(cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(page.items));
cursor = page.nextCursor;
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载积分流水失败",
"error",
);
setButtonBusy(button, false);
}
});
}
-102
View File
@@ -1,102 +0,0 @@
import { adminApi } from "../api/client";
import { trendChart } from "../components/charts";
import { renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderOverview(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载总览");
try {
const data = await adminApi.overview(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">核心指标</div>
<h1>运营总览</h1>
<p>快速掌握增长与积分消耗情况。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid" aria-label="关键指标">
${metric("用户总数", data.totalUsers, "累计账户")}
${metric("活跃用户", data.activeUsers, "当前周期")}
${metric("新增用户", data.newUsers, "当前周期")}
${metric("积分余额", data.totalCreditBalance, "所有账户")}
${metric("赠送积分", data.creditsGranted, "整数积分")}
${metric("消耗积分", data.creditsUsed, "已结算")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>新增用户趋势</h2><p>按 UTC 日期统计注册数</p></div>
<span class="legend"><i></i>新增用户</span>
</div>
${trendChart(data.trend)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>使用汇总</h2><p>按类型统计,不包含用户内容</p></div></div>
${
data.usage.length === 0
? '<p class="muted">当前周期暂无使用记录</p>'
: `
<div class="table-wrap">
<table>
<caption class="sr-only">当前周期使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音毫秒</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${data.usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderOverview(container, (event.target as HTMLInputElement).value);
});
});
} catch (error) {
renderError(container, error, () => void renderOverview(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="overview-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
function metric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<p>${label}</p>
<strong>${formatNumber(value)}</strong>
<span>${hint}</span>
</article>
`;
}
-97
View File
@@ -1,97 +0,0 @@
import { adminApi } from "../api/client";
import { funnelChart } from "../components/charts";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderReferrals(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载裂变数据");
try {
const data = await adminApi.referrals(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">增长分析</div>
<h1>裂变与排行</h1>
<p>奖励以有效使用为前提,不以注册量代替真实转化。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid metric-grid--two" aria-label="邀请资格状态">
<article class="metric-card">
<p>待资格确认</p>
<strong>${formatNumber(data.pendingBindings)}</strong>
<span>等待有效使用</span>
</article>
<article class="metric-card">
<p>不符合奖励条件</p>
<strong>${formatNumber(data.ineligibleBindings)}</strong>
<span>未发放积分</span>
</article>
</section>
<div class="two-column">
<section class="panel">
<div class="panel-heading"><div><h2>裂变漏斗</h2><p>从分享至有效使用</p></div></div>
${funnelChart(data.funnel)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>邀请排行</h2><p>按有效邀请数排序</p></div></div>
${
data.ranking.length === 0
? renderEmpty("当前周期暂无排行数据")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">有效邀请用户排行</caption>
<thead><tr><th scope="col">名次</th><th scope="col">用户 ID</th><th scope="col">邀请</th><th scope="col">有效</th><th scope="col">奖励积分</th></tr></thead>
<tbody>
${data.ranking
.map(
(item, index) => `
<tr>
<td><span class="rank rank--${index + 1}">${index + 1}</span></td>
<td class="mono">${escapeHtml(item.userId)}</td>
<td>${formatNumber(item.invited)}</td>
<td>${formatNumber(item.qualified)}</td>
<td>${formatNumber(item.creditsEarned)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
</div>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderReferrals(
container,
(event.target as HTMLInputElement).value,
);
});
});
} catch (error) {
renderError(container, error, () => void renderReferrals(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="referral-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
-585
View File
@@ -1,585 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminOperator,
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
} from "../api/types";
import {
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime } from "../lib/format";
export async function renderSecurity(
container: HTMLElement,
currentUsername: string,
): Promise<void> {
renderLoading(container, "加载安全中心");
try {
const [page, summary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
const operators = [...page.items];
container.innerHTML = securityTemplate(
operators,
currentUsername,
summary,
page.nextCursor,
);
bindSecurityActions(container, operators, currentUsername);
bindOperatorPagination(
container,
operators,
currentUsername,
page.nextCursor,
);
} catch (error) {
renderError(container, error, () => {
void renderSecurity(container, currentUsername);
});
}
}
function securityTemplate(
operators: AdminOperator[],
currentUsername: string,
summary: AdminSecuritySummary,
nextCursor?: string,
): string {
return `
<div class="page-heading">
<div>
<div class="eyebrow">访问控制</div>
<h1>安全中心</h1>
<p>管理运营人员、角色、登录锁定、会话与双重认证。</p>
</div>
<wa-button variant="brand" appearance="accent" data-create-operator>添加管理员</wa-button>
</div>
<aside class="security-notice" aria-label="安全说明">
<strong>双重边界已启用</strong>
<span>访问仍需受信客户端证书;所有管理员变更都会写入不可变审计日志。</span>
</aside>
<section class="metric-grid metric-grid--three" aria-label="安全指标">
${securityMetric("已启用管理员", summary.enabledOperators, "可登录账户")}
${securityMetric("已锁定账户", summary.lockedOperators, "等待解锁或锁定到期")}
${securityMetric("活动会话", summary.activeSessions, "尚未过期且未撤销")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>管理员</h2><p data-operator-count>已加载 ${operators.length} 个账户</p></div>
</div>
<div class="table-wrap">
<table>
<caption class="sr-only">管理员账户与安全状态</caption>
<thead>
<tr><th scope="col">管理员</th><th scope="col">角色</th><th scope="col">状态</th><th scope="col">最近登录</th><th scope="col">操作</th></tr>
</thead>
<tbody data-operator-body>
${operators.map((operator) => operatorRow(operator, currentUsername)).join("")}
</tbody>
</table>
</div>
${
nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</wa-button></div>'
: ""
}
<p class="sr-only" id="operator-pagination-status" data-operator-status role="status" aria-live="polite"></p>
</section>
${operatorFormDialog()}
${credentialResetDialog()}
`;
}
function securityMetric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<span>${escapeHtml(label)}</span>
<strong>${value.toLocaleString("zh-CN")}</strong>
<small>${escapeHtml(hint)}</small>
</article>
`;
}
function operatorRow(
operator: AdminOperator,
currentUsername: string,
): string {
const current = operator.username === currentUsername;
const locked = Boolean(
operator.lockedUntil && new Date(operator.lockedUntil).getTime() > Date.now(),
);
return `
<tr>
<td>
<strong>${escapeHtml(operator.username)} ${current ? '<span class="badge">当前</span>' : ""}</strong>
<div class="subtle mono">${escapeHtml(operator.operatorId)}</div>
</td>
<td>${roleLabel(operator.role)}</td>
<td>
<span class="badge ${operator.enabled ? "badge--success" : "badge--rejected"}">${operator.enabled ? "已启用" : "已停用"}</span>
${locked ? '<span class="badge badge--rejected">已锁定</span>' : ""}
</td>
<td>${formatDateTime(operator.lastLoginAt)}</td>
<td>
<div class="row-actions" data-operator-id="${escapeHtml(operator.operatorId)}">
${
locked
? '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="unlock">解锁</wa-button>'
: ""
}
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="sessions">撤销会话</wa-button>
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="reset">重置凭据</wa-button>
${
operator.enabled
? `<wa-button variant="danger" appearance="filled" size="s" data-operator-action="disable" ${current ? "disabled" : ""}>停用</wa-button>`
: '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="enable">启用</wa-button>'
}
</div>
</td>
</tr>
`;
}
function bindOperatorPagination(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-operator-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-operator-body]");
const count = container.querySelector<HTMLElement>("[data-operator-count]");
const status = container.querySelector<HTMLElement>("[data-operator-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.operators(cursor);
body.insertAdjacentHTML(
"beforeend",
page.items.map((operator) => operatorRow(operator, currentUsername)).join(""),
);
operators.push(...page.items);
cursor = page.nextCursor;
if (count) count.textContent = `已加载 ${operators.length} 个账户`;
if (status) {
status.textContent = cursor
? `已加载 ${page.items.length} 个更多管理员`
: `已加载 ${page.items.length} 个管理员,全部账户已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(errorMessage(error, "加载管理员失败"), "error");
setButtonBusy(button, false);
button.focus();
}
});
}
function operatorFormDialog(): string {
return `
<dialog class="dialog" data-operator-dialog aria-labelledby="operator-dialog-title">
<form class="dialog-card form-stack" data-operator-form novalidate>
<div class="eyebrow">访问授权</div>
<h2 id="operator-dialog-title">添加管理员</h2>
<p class="muted">创建后,TOTP 密钥只显示一次。</p>
<label><span>用户名</span><input name="username" autocomplete="off" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}" required /></label>
<label>
<span>角色</span>
<select name="role" required>
<option value="ANALYST">分析员 · 仅统计</option>
<option value="SUPPORT">支持人员 · 用户与流水只读</option>
<option value="SUPER_ADMIN">超级管理员 · 完整权限</option>
</select>
</label>
<label><span>初始密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-operator-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-operator>取消</button>
<button class="button button--primary" type="submit">创建并生成 TOTP</button>
</div>
</form>
</dialog>
`;
}
function credentialResetDialog(): string {
return `
<dialog class="dialog" data-reset-dialog aria-labelledby="reset-dialog-title">
<form class="dialog-card form-stack" data-reset-form novalidate>
<div class="eyebrow">高风险操作</div>
<h2 id="reset-dialog-title">重置登录凭据</h2>
<p>将为 <strong data-reset-username></strong> 重置密码和 TOTP,并立即撤销其全部会话。</p>
<label><span>新密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认新密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-reset-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-reset>取消</button>
<button class="button button--danger" type="submit">重置并撤销会话</button>
</div>
</form>
</dialog>
`;
}
function bindSecurityActions(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
): void {
const createDialog =
container.querySelector<HTMLDialogElement>("[data-operator-dialog]");
const createForm =
container.querySelector<HTMLFormElement>("[data-operator-form]");
container
.querySelector<HTMLButtonElement>("[data-create-operator]")
?.addEventListener("click", () => {
createForm?.reset();
clearError(createForm, "[data-operator-error]");
createDialog?.showModal();
createForm?.querySelector<HTMLInputElement>('input[name="username"]')?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-close-operator]")
?.addEventListener("click", () => createDialog?.close());
createForm?.addEventListener("submit", (event) => {
event.preventDefault();
void createOperator(container, createDialog, createForm, currentUsername);
});
const resetDialog =
container.querySelector<HTMLDialogElement>("[data-reset-dialog]");
const resetForm = container.querySelector<HTMLFormElement>("[data-reset-form]");
container
.querySelector<HTMLButtonElement>("[data-close-reset]")
?.addEventListener("click", () => resetDialog?.close());
resetForm?.addEventListener("submit", (event) => {
event.preventDefault();
void resetCredentials(container, resetDialog, resetForm, currentUsername);
});
container.querySelector("tbody")?.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
"[data-operator-action]",
);
const operatorId = button?.closest<HTMLElement>("[data-operator-id]")?.dataset
.operatorId;
const operator = operators.find((item) => item.operatorId === operatorId);
if (!button || !operator) return;
void handleOperatorAction(
container,
button,
operator,
resetDialog,
resetForm,
currentUsername,
);
});
}
async function createOperator(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const role = data.get("role")?.toString() as AdminRole;
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-operator-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
password.length < 12 ||
password !== confirmation ||
!submit
) {
if (errorNode) errorNode.textContent = "请检查用户名、角色及两次输入的密码";
return;
}
setButtonBusy(submit, true, "创建中…");
clearError(form, "[data-operator-error]");
try {
const provisioning = await adminApi.createOperator({
username,
password,
role,
});
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "管理员已创建");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "创建管理员失败");
setButtonBusy(submit, false);
}
}
async function handleOperatorAction(
container: HTMLElement,
button: HTMLButtonElement,
operator: AdminOperator,
resetDialog: HTMLDialogElement | null,
resetForm: HTMLFormElement | null,
currentUsername: string,
): Promise<void> {
const action = button.dataset.operatorAction;
if (action === "reset" && resetDialog && resetForm) {
resetForm.reset();
resetForm.dataset.operatorId = operator.operatorId;
const username = resetForm.querySelector<HTMLElement>("[data-reset-username]");
if (username) username.textContent = operator.username;
clearError(resetForm, "[data-reset-error]");
resetDialog.showModal();
resetForm.querySelector<HTMLInputElement>('input[name="password"]')?.focus();
return;
}
const confirmation = actionConfirmation(action, operator.username);
if (!confirmation || !(await confirmAction(container, confirmation))) return;
setButtonBusy(button, true);
try {
if (action === "enable") {
await adminApi.setOperatorEnabled(operator.operatorId, true);
} else if (action === "disable") {
await adminApi.setOperatorEnabled(operator.operatorId, false);
} else if (action === "unlock") {
await adminApi.unlockOperator(operator.operatorId);
} else if (action === "sessions") {
await adminApi.revokeOperatorSessions(operator.operatorId);
}
showToast("安全设置已更新", "success");
await renderSecurity(container, currentUsername);
} catch (error) {
showToast(errorMessage(error, "安全设置更新失败"), "error");
setButtonBusy(button, false);
}
}
async function resetCredentials(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const operatorId = form.dataset.operatorId;
const data = new FormData(form);
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-reset-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (!operatorId || password.length < 12 || password !== confirmation || !submit) {
if (errorNode) errorNode.textContent = "请输入至少 12 位且两次一致的新密码";
return;
}
setButtonBusy(submit, true, "重置中…");
clearError(form, "[data-reset-error]");
try {
const provisioning = await adminApi.resetOperatorCredentials(
operatorId,
password,
);
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "登录凭据已重置");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "凭据重置失败");
setButtonBusy(submit, false);
}
}
interface Confirmation {
title: string;
message: string;
label: string;
dangerous?: boolean;
}
function actionConfirmation(
action: string | undefined,
username: string,
): Confirmation | null {
const target = `${username}`;
if (action === "disable") {
return {
title: "停用管理员?",
message: `${target} 将无法登录,全部活动会话会立即失效。`,
label: "停用管理员",
dangerous: true,
};
}
if (action === "enable") {
return {
title: "启用管理员?",
message: `${target} 将恢复登录权限。`,
label: "确认启用",
};
}
if (action === "unlock") {
return {
title: "解除登录锁定?",
message: `${target} 可以立即重新尝试登录。`,
label: "确认解锁",
};
}
if (action === "sessions") {
return {
title: "撤销全部会话?",
message: `${target} 已登录的所有设备都需要重新认证。`,
label: "撤销会话",
dangerous: true,
};
}
return null;
}
function confirmAction(
container: HTMLElement,
confirmation: Confirmation,
): Promise<boolean> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog";
dialog.setAttribute("aria-labelledby", "confirm-action-title");
dialog.innerHTML = `
<form method="dialog" class="dialog-card">
<div class="eyebrow">确认操作</div>
<h2 id="confirm-action-title">${escapeHtml(confirmation.title)}</h2>
<p>${escapeHtml(confirmation.message)}</p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button ${confirmation.dangerous ? "button--danger" : "button--primary"}" value="confirm">${escapeHtml(confirmation.label)}</button>
</div>
</form>
`;
container.append(dialog);
dialog.addEventListener(
"close",
() => {
const confirmed = dialog.returnValue === "confirm";
dialog.remove();
resolve(confirmed);
},
{ once: true },
);
dialog.showModal();
});
}
function showProvisioning(
container: HTMLElement,
provisioning: AdminOperatorProvisioning,
title: string,
): Promise<void> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.setAttribute("aria-labelledby", "provisioning-title");
dialog.innerHTML = `
<div class="dialog-card provisioning-card">
<div class="eyebrow">仅显示一次</div>
<h2 id="provisioning-title">${escapeHtml(title)}</h2>
<p>请立即把 TOTP 密钥交给对应管理员,并确认已添加到认证器。</p>
<div class="secret-panel">
<span>Base32 密钥</span>
<code>${escapeHtml(provisioning.totpSecret)}</code>
<button class="button button--secondary" type="button" data-copy-secret>复制密钥</button>
</div>
<div class="dialog-actions dialog-actions--spread">
<a class="button button--secondary" href="${escapeHtml(provisioning.otpauthUri)}">在认证器中打开</a>
<button class="button button--secondary" type="button" data-copy-uri>复制配置链接</button>
</div>
<label class="confirmation-check">
<input type="checkbox" data-provisioning-saved />
<span>我已安全保存密钥,理解关闭后无法再次查看</span>
</label>
<button class="button button--primary button--wide" type="button" data-close-provisioning disabled>完成</button>
</div>
`;
const close = dialog.querySelector<HTMLButtonElement>("[data-close-provisioning]");
dialog
.querySelector<HTMLInputElement>("[data-provisioning-saved]")
?.addEventListener("change", (event) => {
if (close) close.disabled = !(event.target as HTMLInputElement).checked;
});
dialog.addEventListener("cancel", (event) => event.preventDefault());
dialog
.querySelector<HTMLButtonElement>("[data-copy-secret]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.totpSecret, "TOTP 密钥");
});
dialog
.querySelector<HTMLButtonElement>("[data-copy-uri]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.otpauthUri, "认证器配置链接");
});
close?.addEventListener("click", () => dialog.close());
dialog.addEventListener(
"close",
() => {
dialog.remove();
resolve();
},
{ once: true },
);
container.append(dialog);
dialog.showModal();
});
}
async function copySensitiveValue(value: string, label: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
showToast(`${label}已复制,请妥善保管`, "success");
} catch {
showToast(`无法复制${label},请手动选择`, "error");
}
}
function clearError(
container: ParentNode | null,
selector: string,
): void {
const node = container?.querySelector<HTMLElement>(selector);
if (node) node.textContent = "";
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
-509
View File
@@ -1,509 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminRole,
LedgerEntry,
PageResult,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
renderUsageTypeBadge,
setButtonBusy,
showToast,
} from "../components/ui";
import {
createIdempotencyKey,
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderUsers(container: HTMLElement, role: AdminRole): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">账户管理</div>
<h1>用户列表</h1>
<p>按注册时间查看全部用户,支持完整或后 8 位内部用户 ID 查询。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-search-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="query" type="search" placeholder="输入完整或后 8 位用户 ID" autocomplete="off" maxlength="36" />
</label>
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
</form>
<div data-results>${renderEmpty("正在加载用户列表")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const search = new FormData(form).get("query")?.toString().trim() ?? "";
void loadUsers(container, search, role);
});
const results = container.querySelector<HTMLElement>("[data-results]");
if (results) bindUserRows(results, container, role);
void loadUsers(container, "", role);
}
async function loadUsers(
container: HTMLElement,
search: string,
role: AdminRole,
): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-results]");
if (!results) return;
renderLoading(results, "搜索用户");
try {
const page = await adminApi.users(search);
if (page.items.length === 0) {
results.innerHTML = renderEmpty("未找到匹配用户");
return;
}
results.innerHTML = `
<div class="table-wrap">
<table>
<caption class="sr-only">${search ? "用户查询结果" : "全部用户"}</caption>
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">累计使用积分</th><th scope="col">当前积分</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
<tbody data-user-body>${userRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-user-more>加载更多用户</wa-button></div>'
: ""
}
`;
if (page.nextCursor) {
bindUserPagination(container, search, page.nextCursor);
}
} catch (error) {
renderError(results, error, () => void loadUsers(container, search, role));
}
}
function userRows(users: UserSummary[]): string {
return users
.map(
(user) => `
<tr>
<td>
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
<div class="subtle mono">${escapeHtml(user.userId)}</div>
</td>
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
<td>${formatNumber(user.consumedCredits)}</td>
<td>${formatNumber(user.creditBalance)}</td>
<td>${formatDateTime(user.createdAt)}</td>
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
</tr>
`,
)
.join("");
}
function bindUserRows(
scope: HTMLElement,
container: HTMLElement,
role: AdminRole,
): void {
scope.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>(
"[data-user-id]",
);
const userId = button?.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
}
function bindUserPagination(
container: HTMLElement,
search: string,
initialCursor: string,
): void {
const button = container.querySelector<HTMLButtonElement>("[data-user-more]");
const body = container.querySelector<HTMLTableSectionElement>("[data-user-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.users(search, cursor);
body.insertAdjacentHTML("beforeend", userRows(page.items));
cursor = page.nextCursor;
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载用户失败",
"error",
);
setButtonBusy(button, false);
}
});
}
async function renderUserDetail(
container: HTMLElement,
userId: string,
role: AdminRole,
): Promise<void> {
renderLoading(container, "加载用户详情");
try {
const [user, ledger] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
]);
container.innerHTML = detailTemplate(user, ledger, role);
container
.querySelector<HTMLButtonElement>("[data-back]")
?.addEventListener("click", () => {
renderUsers(container, role);
container.querySelector<HTMLInputElement>('input[name="query"]')?.focus();
});
bindLedgerPagination(container, user, ledger.nextCursor);
if (role === "SUPER_ADMIN") bindGrantDialog(container, user, role);
const heading = container.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
}
} catch (error) {
renderError(container, error, () =>
void renderUserDetail(container, userId, role),
);
}
}
function detailTemplate(
user: UserDetail,
ledger: PageResult<LedgerEntry>,
role: AdminRole,
): string {
const usage = user.usage ?? [];
const usageRequests = usage.reduce((total, item) => total + item.requests, 0);
const chargedCredits = usage.reduce(
(total, item) => total + item.chargedCredits,
0,
);
const referral = user.referral;
const inviterUserId = referral?.inviterUserId ?? user.referredByUserId;
const qualifiedUsage = user.qualifiedUsage || usageRequests > 0;
return `
<div class="page-heading page-heading--detail">
<div>
<wa-button class="back-link" appearance="plain" data-back>← 返回用户查询</wa-button>
<div class="eyebrow">用户详情</div>
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
<p class="mono">${escapeHtml(user.userId)}</p>
</div>
${
role === "SUPER_ADMIN"
? '<wa-button variant="brand" appearance="accent" data-open-grant>人工赠送积分</wa-button>'
: ""
}
</div>
<section class="detail-grid">
<article class="panel profile-card">
<div class="profile-header">
<div class="avatar" aria-hidden="true">${escapeHtml((user.displayName || "用").slice(0, 1))}</div>
<div><strong>${escapeHtml(user.displayName || "未命名用户")}</strong><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></div>
</div>
<dl class="detail-list">
<div><dt>积分余额</dt><dd class="credit-value">${formatNumber(user.creditBalance)}</dd></div>
<div><dt>注册时间</dt><dd>${formatDateTime(user.createdAt)}</dd></div>
<div><dt>最近活跃</dt><dd>${formatDateTime(user.lastActiveAt)}</dd></div>
<div><dt>有效使用</dt><dd>${qualifiedUsage ? "已达成" : "未达成"}${usage.length > 0 ? ` · ${formatNumber(usageRequests)} 次请求` : ""}</dd></div>
${
usage.length > 0
? `<div><dt>累计消耗</dt><dd>${formatNumber(chargedCredits)} 积分</dd></div>`
: ""
}
<div><dt>邀请码</dt><dd class="mono">${escapeHtml(user.referralCode || "—")}</dd></div>
<div><dt>邀请来源</dt><dd class="mono">${escapeHtml(inviterUserId || "—")}</dd></div>
${
referral
? `<div><dt>邀请成效</dt><dd>${formatNumber(referral.rewardedInvites)} / ${formatNumber(referral.invitedUsers)} 已奖励</dd></div>`
: ""
}
</dl>
</article>
<section class="panel">
<div class="panel-heading"><div><h2>积分流水</h2><p>不可变账本记录</p></div></div>
${
ledger.items.length === 0
? renderEmpty("暂无积分流水")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">消费类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table>
</div>
${
ledger.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</wa-button></div>'
: ""
}
<p class="sr-only" id="ledger-pagination-status" data-ledger-status role="status" aria-live="polite"></p>
`
}
</section>
${usagePanel(usage)}
</section>
${role === "SUPER_ADMIN" ? grantDialog(user) : ""}
`;
}
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(statusLabel(entry.type))}</td>
<td>${renderUsageTypeBadge(entry.usageType)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("");
}
function usagePanel(usage: UserUsageAggregate[]): string {
if (usage.length === 0) return "";
return `
<section class="panel detail-panel--wide">
<div class="panel-heading"><div><h2>使用统计</h2><p>按使用类型汇总,不包含用户内容</p></div></div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音时长(毫秒)</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</section>
`;
}
function bindLedgerPagination(
container: HTMLElement,
user: UserDetail,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
const status = container.querySelector<HTMLElement>("[data-ledger-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.ledger(user.userId, cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(page.items));
cursor = page.nextCursor;
if (status) {
status.textContent = cursor
? `已加载 ${formatNumber(page.items.length)} 条更多流水`
: `已加载 ${formatNumber(page.items.length)} 条流水,全部记录已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载积分流水失败",
"error",
);
setButtonBusy(button, false);
button.focus();
}
});
}
function grantDialog(user: UserDetail): string {
return `
<dialog class="dialog" data-grant-dialog aria-labelledby="grant-dialog-title">
<form method="dialog" class="dialog-card" data-grant-form>
<div data-grant-inputs>
<div class="eyebrow">高风险操作</div>
<h2 id="grant-dialog-title">人工赠送积分</h2>
<p class="muted">赠送将写入不可变账本,并记录管理员审计日志。</p>
<label><span>用户 ID</span><input value="${escapeHtml(user.userId)}" disabled /></label>
<label><span>赠送积分</span><input name="amount" type="number" inputmode="numeric" min="1" max="100000" step="1" aria-describedby="grant-input-error" required /></label>
<label><span>赠送原因</span><textarea name="reason" minlength="4" maxlength="200" placeholder="请填写可审计的业务原因" aria-describedby="grant-input-error" required></textarea></label>
<p class="form-error" id="grant-input-error" data-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button button--primary" type="button" data-review-grant>下一步</button>
</div>
</div>
<div data-grant-confirm hidden>
<div class="confirm-icon" aria-hidden="true">!</div>
<h2 data-confirm-title tabindex="-1">确认赠送?</h2>
<p>将向 <strong>${escapeHtml(user.displayName || user.userId)}</strong> 赠送 <strong data-confirm-amount></strong> 积分。</p>
<p class="confirm-reason" data-confirm-reason></p>
<p class="form-error" id="grant-confirm-error" data-confirm-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-edit-grant>返回修改</button>
<button class="button button--danger" type="button" data-submit-grant aria-describedby="grant-confirm-error">确认赠送</button>
</div>
</div>
</form>
</dialog>
`;
}
function bindGrantDialog(
container: HTMLElement,
user: UserDetail,
role: AdminRole,
): void {
const dialog = container.querySelector<HTMLDialogElement>("[data-grant-dialog]");
const form = container.querySelector<HTMLFormElement>("[data-grant-form]");
const inputs = container.querySelector<HTMLElement>("[data-grant-inputs]");
const confirm = container.querySelector<HTMLElement>("[data-grant-confirm]");
if (!dialog || !form || !inputs || !confirm) return;
let idempotencyKey = createIdempotencyKey();
let outcomeUnknown = false;
container
.querySelector<HTMLButtonElement>("[data-open-grant]")
?.addEventListener("click", () => {
idempotencyKey = createIdempotencyKey();
outcomeUnknown = false;
form.reset();
inputs.hidden = false;
confirm.hidden = true;
form.querySelectorAll<HTMLElement>(".form-error").forEach((node) => {
node.textContent = "";
});
dialog.showModal();
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
dialog.addEventListener("cancel", (event) => {
if (!outcomeUnknown) return;
event.preventDefault();
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试";
}
});
container
.querySelector<HTMLButtonElement>("[data-review-grant]")
?.addEventListener("click", () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-error]");
if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) {
if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数";
return;
}
if (reason.length < 4) {
if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因";
return;
}
if (errorNode) errorNode.textContent = "";
const amountNode = form.querySelector<HTMLElement>("[data-confirm-amount]");
const reasonNode = form.querySelector<HTMLElement>("[data-confirm-reason]");
if (amountNode) amountNode.textContent = formatNumber(amount);
if (reasonNode) reasonNode.textContent = `原因:${reason}`;
inputs.hidden = true;
confirm.hidden = false;
form.querySelector<HTMLElement>("[data-confirm-title]")?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-edit-grant]")
?.addEventListener("click", () => {
if (outcomeUnknown) {
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试";
}
return;
}
confirm.hidden = true;
inputs.hidden = false;
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
const submit = container.querySelector<HTMLButtonElement>("[data-submit-grant]");
submit?.addEventListener("click", async () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
setButtonBusy(submit, true, "赠送中…");
if (errorNode) errorNode.textContent = "";
try {
await adminApi.grantCredits({
userId: user.userId,
amount,
reason,
idempotencyKey,
});
dialog.close();
showToast("积分赠送成功,账本已更新", "success");
await renderUserDetail(container, user.userId, role);
} catch (error) {
const message =
error instanceof ApiError ? error.message : "赠送失败,请重试";
outcomeUnknown =
!(error instanceof ApiError) || error.status === 0 || error.status >= 500;
if (errorNode) {
errorNode.textContent = outcomeUnknown
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
: message;
}
setButtonBusy(submit, false);
}
});
}
+236 -1423
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { funnelChart, trendChart } from "../components/charts";
describe("accessible charts", () => {
it("provides a titled SVG and complete data table for trends", () => {
const html = trendChart([
{ date: "2026-08-16", registrations: 12, creditsUsed: 345 },
]);
expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"');
expect(html).toContain("<caption>新增用户与积分消耗趋势完整数据</caption>");
expect(html).toContain("<td>345</td>");
});
it("renders the funnel without CSP-sensitive inline styles", () => {
const html = funnelChart([
{ label: "访问", count: 20 },
{ label: "注册", count: 10 },
]);
expect(html).toContain("<progress");
expect(html).not.toContain('style="');
});
});
-279
View File
@@ -1,279 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type {
AdminOperator,
AdminSecuritySummary,
UserDetail,
UserSummary,
} from "../api/types";
import { renderSecurity } from "../pages/security";
import { renderCredits } from "../pages/credits";
import { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111";
const userSummary: UserSummary = {
userId,
displayName: "测试用户",
status: "active",
creditBalance: 120,
consumedCredits: 18,
createdAt: "2026-08-01T08:00:00Z",
};
const userDetail: UserDetail = {
...userSummary,
lastActiveAt: "2026-08-16T08:00:00Z",
qualifiedUsage: true,
referralCode: "OSG-TEST",
usage: [
{
kind: "ASR",
requests: 3,
chargedCredits: 18,
asrMillis: 12_000,
inputTokens: 0,
outputTokens: 0,
},
],
referral: {
inviterUserId: "22222222-2222-4222-8222-222222222222",
invitedUsers: 4,
rewardedInvites: 2,
},
};
afterEach(() => {
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("用户页", () => {
it("进入页面自动显示积分、注册时间并按游标加载更多用户", async () => {
const secondUser: UserSummary = {
...userSummary,
userId: "22222222-2222-4222-8222-222222222222",
displayName: "第二位用户",
creditBalance: 80,
consumedCredits: 40,
};
vi.spyOn(adminApi, "users")
.mockResolvedValueOnce({ items: [userSummary], nextCursor: "user-next" })
.mockResolvedValueOnce({ items: [secondUser] });
const container = document.createElement("main");
document.body.append(container);
renderUsers(container, "SUPPORT");
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(1);
});
expect(container.textContent).toContain("累计使用积分");
expect(container.textContent).toContain("当前积分");
expect(container.textContent).toContain("18");
expect(container.textContent).toContain("120");
container.querySelector<HTMLButtonElement>("[data-user-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-user-more]")).toBeNull();
});
it("支持人员可查看详情但不显示人工赠送", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPPORT");
expect(container.querySelector("[data-open-grant]")).toBeNull();
expect(container.querySelector("[data-grant-dialog]")).toBeNull();
});
it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger")
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-1",
userId,
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-01T08:00:00Z",
},
],
nextCursor: "ledger-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-2",
userId,
type: "settle",
amount: -18,
balanceAfter: 82,
reasonCode: "USAGE_SETTLE",
usageType: "asr",
createdAt: "2026-08-02T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPER_ADMIN");
expect(container.querySelector("[data-open-grant]")).not.toBeNull();
expect(container.textContent).toContain("OSG-TEST");
expect(container.textContent).toContain("3 次请求");
expect(container.textContent).toContain("18 积分");
expect(container.textContent).toContain("2 / 4 已奖励");
expect(container.textContent).toContain(
"22222222-2222-4222-8222-222222222222",
);
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.ledger).toHaveBeenNthCalledWith(
2,
userId,
"ledger-next",
);
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.textContent).toContain("ASR");
expect(container.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载",
);
});
});
describe("积分流水页", () => {
it("进入页面自动显示最新流水并支持继续加载", async () => {
vi.spyOn(adminApi, "latestLedger")
.mockResolvedValueOnce({
items: [
{
entryId: "latest-ledger-1",
userId,
type: "settle",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
usageType: "hotword",
createdAt: "2026-08-19T09:00:00Z",
},
],
nextCursor: "latest-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "latest-ledger-2",
userId: "22222222-2222-4222-8222-222222222222",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-19T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
renderCredits(container);
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(1);
});
expect(container.textContent).toContain(userId);
expect(container.textContent).toContain("USAGE_SETTLE");
expect(container.textContent).toContain("热词");
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenNthCalledWith(2, "latest-next");
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-ledger-more]")).toBeNull();
});
});
describe("安全中心", () => {
it("按后端 nextCursor 加载更多管理员", async () => {
const firstOperator = operator("operator-1", "owner");
const secondOperator = operator("operator-2", "support");
vi.spyOn(adminApi, "operators")
.mockResolvedValueOnce({
items: [firstOperator],
nextCursor: "operator-next",
})
.mockResolvedValueOnce({ items: [secondOperator] });
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
const container = document.createElement("main");
document.body.append(container);
await renderSecurity(container, "owner");
container.querySelector<HTMLButtonElement>("[data-operator-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-operator-count]")?.textContent).toBe(
"已加载 2 个账户",
);
expect(container.querySelector("[data-operator-more]")).toBeNull();
expect(
container.querySelector("[data-operator-status]")?.textContent,
).toContain("全部账户已加载");
});
});
function mockUserRequests(): void {
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] });
vi.spyOn(adminApi, "user").mockResolvedValue(userDetail);
}
async function openUserDetail(
container: HTMLElement,
role: "SUPER_ADMIN" | "SUPPORT",
): Promise<void> {
renderUsers(container, role);
const input = container.querySelector<HTMLInputElement>('input[name="query"]');
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
if (!input || !form) throw new Error("用户查询表单未渲染");
input.value = userId;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(container.querySelector("[data-user-id]")).not.toBeNull();
});
container.querySelector<HTMLButtonElement>("[data-user-id]")?.click();
await vi.waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("测试用户");
});
}
function operator(operatorId: string, username: string): AdminOperator {
return {
operatorId,
username,
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
enabled: true,
failedLoginCount: 0,
createdAt: "2026-08-01T08:00:00Z",
updatedAt: "2026-08-01T08:00:00Z",
};
}
function summary(): AdminSecuritySummary {
return {
enabledOperators: 2,
lockedOperators: 0,
activeSessions: 1,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "../app";
import { adminApi } from "../api/client";
import type {
AdminOperator,
AdminSecuritySummary,
LedgerEntry,
UserSummary,
} from "../api/types";
const userId = "11111111-1111-4111-8111-111111111111";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.location.hash = "";
});
describe("React 管理页面", () => {
it("用户列表保留游标分页与角色权限", async () => {
mockSession("SUPPORT");
const first = user("测试用户", userId);
const second = user("第二位用户", "22222222-2222-4222-8222-222222222222");
vi.spyOn(adminApi, "users")
.mockResolvedValueOnce({ items: [first], nextCursor: "user-next" })
.mockResolvedValueOnce({ items: [second] });
window.location.hash = "#/users";
render(<App />);
expect(await screen.findByRole("heading", { name: "用户列表" })).toBeTruthy();
expect(await screen.findByText("测试用户")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
await waitFor(() => {
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
});
expect(await screen.findByText("第二位用户")).toBeTruthy();
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
});
it("积分页自动显示最新不可变流水", async () => {
mockSession("SUPPORT");
const entry: LedgerEntry = {
entryId: "ledger-1",
userId,
type: "settle",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
usageType: "hotword",
createdAt: "2026-08-19T09:00:00Z",
};
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
window.location.hash = "#/credits";
render(<App />);
expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy();
expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy();
expect(screen.getByText("热词")).toBeTruthy();
expect(screen.getByText("-18")).toBeTruthy();
});
it("安全中心保留管理员游标分页", async () => {
mockSession("SUPER_ADMIN");
const first = operator("operator-1", "owner");
const second = operator("operator-2", "support");
vi.spyOn(adminApi, "operators")
.mockResolvedValueOnce({ items: [first], nextCursor: "operator-next" })
.mockResolvedValueOnce({ items: [second] });
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
window.location.hash = "#/security";
render(<App />);
expect(await screen.findByRole("heading", { name: "安全中心" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
await waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
});
expect(await screen.findByText("support")).toBeTruthy();
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
});
});
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
vi.spyOn(adminApi, "session").mockResolvedValue({
authenticated: true,
operatorName: "owner",
role,
});
}
function user(displayName: string, id: string): UserSummary {
return {
userId: id,
displayName,
status: "active",
creditBalance: 120,
consumedCredits: 18,
createdAt: "2026-08-01T08:00:00Z",
};
}
function operator(operatorId: string, username: string): AdminOperator {
return {
operatorId,
username,
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
enabled: true,
failedLoginCount: 0,
createdAt: "2026-08-01T08:00:00Z",
updatedAt: "2026-08-01T08:00:00Z",
};
}
function summary(): AdminSecuritySummary {
return {
enabledOperators: 2,
lockedOperators: 0,
activeSessions: 1,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi, ApiError } from "../api/client";
import type { UserDetail } from "../api/types";
import { TrendChart } from "../features/overview/overview-page";
import { GrantDialog } from "../features/users/users-page";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("高风险交互与 CSP", () => {
it("趋势图不生成内联样式并提供完整辅助数据表", () => {
const { container } = render(
<TrendChart
points={[
{ date: "2026-08-18", registrations: 4, creditsUsed: 20 },
{ date: "2026-08-19", registrations: 7, creditsUsed: 35 },
]}
/>,
);
expect(screen.getByRole("img", { name: /新增用户与积分消耗趋势/ })).toBeTruthy();
expect(container.querySelector("[style]")).toBeNull();
expect(screen.getByText("新增用户与积分消耗趋势完整数据")).toBeTruthy();
expect(screen.getByText("35")).toBeTruthy();
});
it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => {
const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue(
new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0),
);
const user = userDetail();
render(
<GrantDialog
open
user={user}
onOpenChange={vi.fn()}
onSuccess={vi.fn()}
/>,
);
await userEvent.type(screen.getByLabelText("赠送积分"), "100");
await userEvent.type(screen.getByLabelText("赠送原因"), "运营补偿");
await userEvent.click(screen.getByRole("button", { name: "下一步" }));
await userEvent.click(screen.getByRole("button", { name: "确认赠送" }));
expect(
await screen.findByText("赠送结果尚未确认;重试会复用同一请求,不会重复到账"),
).toBeTruthy();
const firstKey = grant.mock.calls[0]?.[0].idempotencyKey;
await userEvent.click(screen.getByRole("button", { name: "确认赠送" }));
await waitFor(() => expect(grant).toHaveBeenCalledTimes(2));
expect(grant.mock.calls[1]?.[0].idempotencyKey).toBe(firstKey);
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
function userDetail(): UserDetail {
return {
userId: "11111111-1111-4111-8111-111111111111",
displayName: "测试用户",
status: "active",
creditBalance: 120,
consumedCredits: 18,
createdAt: "2026-08-01T08:00:00Z",
qualifiedUsage: true,
};
}
-35
View File
@@ -1,35 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { describe, expect, it, vi } from "vitest";
import { renderError, renderLoading, setButtonBusy } from "../components/ui";
describe("Web Awesome UI states", () => {
it("renders accessible loading and retry components", () => {
const container = document.createElement("div");
const retry = vi.fn();
renderLoading(container, "读取用户");
expect(container.querySelector("wa-spinner")).not.toBeNull();
expect(container.textContent).toContain("读取用户");
renderError(container, new Error("failed"), retry);
const retryButton = container.querySelector<HTMLElement>("wa-button[data-retry]");
expect(container.querySelector('wa-callout[variant="danger"]')).not.toBeNull();
retryButton?.click();
expect(retry).toHaveBeenCalledOnce();
});
it("uses the Web Awesome loading state and restores the label", () => {
const button = document.createElement("wa-button") as WaButton;
button.textContent = "提交";
setButtonBusy(button, true, "提交中…");
expect(button.loading).toBe(true);
expect(button.disabled).toBe(true);
expect(button.textContent).toBe("提交中…");
setButtonBusy(button, false);
expect(button.loading).toBe(false);
expect(button.disabled).toBe(false);
expect(button.textContent).toBe("提交");
});
});
+1
View File
@@ -6,6 +6,7 @@
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
+18
View File
@@ -1,7 +1,10 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
base: "/admin/",
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/v1/admin": {
@@ -12,6 +15,21 @@ export default defineConfig({
},
build: {
target: "es2022",
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes("/node_modules/react/") ||
id.includes("/node_modules/react-dom/") ||
id.includes("/node_modules/react-router")
) {
return "react-vendor";
}
if (id.includes("/node_modules/@base-ui/")) return "base-ui";
return undefined;
},
},
},
},
test: {
environment: "jsdom",
+11
View File
@@ -47,3 +47,14 @@ mix and require ongoing margin monitoring.
transaction. A client retry returns the original grant.
- The app finishes the StoreKit transaction only after server acknowledgement.
- Signed transaction bodies and Apple certificate contents are never logged.
## Purchase history
- `GET /v1/storekit/transactions` requires the existing Bearer access token.
- The account comes only from the authenticated session; clients never submit it.
- History is read from the existing purchase audit and immutable credit ledger,
so purchases credited before the endpoint was deployed are included.
- Results are ordered by `purchasedAt` and then `transactionId`, both descending,
with opaque cursor pagination (`limit` defaults to 50 and is bounded to 1100).
- Responses omit JWS data, Apple identifiers, account IDs, and internal record IDs.
- History queries never call the App Store.
+45
View File
@@ -177,6 +177,28 @@ paths:
items: { $ref: "#/components/schemas/StoreKitProduct" }
default: { $ref: "#/components/responses/Error" }
/v1/storekit/transactions:
get:
summary: Return credited StoreKit purchase history for the current account
description: |
Returns only App Store transactions that were previously verified and
committed with their immutable credit-ledger entries. Results include
purchases credited before this endpoint was introduced and never call
the App Store at query time. The opaque cursor follows the stable
descending order of `purchasedAt` and `transactionId`.
parameters:
- $ref: "#/components/parameters/Limit"
- name: cursor
in: query
description: Opaque cursor returned by the preceding page.
schema: { type: string, minLength: 1, maxLength: 256 }
responses:
"200":
description: Credited StoreKit purchases for the authenticated account
content:
application/json:
schema: { $ref: "#/components/schemas/StoreKitTransactionHistory" }
"400": { $ref: "#/components/responses/Error" }
default: { $ref: "#/components/responses/Error" }
post:
summary: Verify an App Store transaction and idempotently grant credits
description: |
@@ -1306,6 +1328,29 @@ components:
creditsGranted: { type: integer, format: int64, minimum: 1 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
replayed: { type: boolean }
StoreKitTransactionHistoryItem:
type: object
additionalProperties: false
required:
[transactionId, productId, creditsGranted, balanceAfter, purchasedAt, status]
properties:
transactionId: { type: string, pattern: "^[0-9]{1,64}$" }
productId: { type: string, minLength: 3, maxLength: 128 }
creditsGranted: { type: integer, format: int64, minimum: 1 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
purchasedAt: { type: string, format: date-time }
status: { type: string, enum: [credited] }
StoreKitTransactionHistory:
type: object
additionalProperties: false
required: [items, nextCursor]
properties:
items:
type: array
items: { $ref: "#/components/schemas/StoreKitTransactionHistoryItem" }
nextCursor:
type: ["string", "null"]
maxLength: 256
AppleAppSiteAssociation:
type: object
additionalProperties: false
@@ -295,7 +295,7 @@ fun Application.module() {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
@@ -55,6 +55,24 @@ data class StoreKitPurchaseResult(
val replayed: Boolean,
)
data class StoreKitTransactionCursor(
val purchasedAt: Instant,
val transactionId: String,
)
data class CreditedStoreKitTransaction(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: Instant,
)
data class StoreKitTransactionPage(
val items: List<CreditedStoreKitTransaction>,
val nextCursor: String?,
)
sealed class StoreKitException(message: String) : RuntimeException(message)
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
@@ -1,8 +1,13 @@
package com.osglab.account.features.storekit.models
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class StoreKitProductDto(
@@ -40,3 +45,41 @@ data class StoreKitPurchaseResponse(
}
}
@Serializable
data class StoreKitTransactionHistoryItemResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: String,
val status: String,
) {
companion object {
fun fromDomain(
transaction: CreditedStoreKitTransaction,
): StoreKitTransactionHistoryItemResponse =
StoreKitTransactionHistoryItemResponse(
transactionId = transaction.transactionId,
productId = transaction.productId,
creditsGranted = transaction.creditsGranted,
balanceAfter = transaction.balanceAfter,
purchasedAt = transaction.purchasedAt.toString(),
status = "credited",
)
}
}
@Serializable
data class StoreKitTransactionHistoryResponse(
val items: List<StoreKitTransactionHistoryItemResponse>,
val nextCursor: JsonElement,
) {
companion object {
fun fromDomain(page: StoreKitTransactionPage): StoreKitTransactionHistoryResponse =
StoreKitTransactionHistoryResponse(
items = page.items.map(StoreKitTransactionHistoryItemResponse::fromDomain),
nextCursor = page.nextCursor?.let(::JsonPrimitive) ?: JsonNull,
)
}
}
@@ -1,16 +1,26 @@
package com.osglab.account.features.storekit.repositories
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import java.util.UUID
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
interface StoreKitRepository {
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction>
fun insert(purchase: StoreKitCreditPurchase)
}
@@ -22,6 +32,60 @@ object ExposedStoreKitRepository : StoreKitRepository {
.singleOrNull()
?.toStoreKitCreditPurchase()
override fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction> {
val accountId = userId.toString()
val query = StoreKitCreditPurchases
.innerJoin(
otherTable = StoreKitPurchaseLedger,
onColumn = { ledgerEntryId },
otherColumn = { id },
)
.select(
StoreKitCreditPurchases.transactionId,
StoreKitCreditPurchases.productId,
StoreKitCreditPurchases.creditsGranted,
StoreKitCreditPurchases.purchasedAt,
StoreKitPurchaseLedger.balanceAfter,
)
query.where {
val accountAndCredited =
(StoreKitCreditPurchases.userId eq accountId) and
(StoreKitPurchaseLedger.userId eq accountId) and
(StoreKitPurchaseLedger.entryType eq LedgerEntryType.STOREKIT_PURCHASE) and
(
StoreKitPurchaseLedger.amountDelta eq
StoreKitCreditPurchases.creditsGranted
) and
(StoreKitPurchaseLedger.referenceId eq StoreKitCreditPurchases.id)
if (before == null) {
accountAndCredited
} else {
accountAndCredited and
(
(StoreKitCreditPurchases.purchasedAt less before.purchasedAt) or
(
(StoreKitCreditPurchases.purchasedAt eq before.purchasedAt) and
(
StoreKitCreditPurchases.transactionId less
before.transactionId
)
)
)
}
}
return query
.orderBy(
StoreKitCreditPurchases.purchasedAt to SortOrder.DESC,
StoreKitCreditPurchases.transactionId to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toCreditedStoreKitTransaction)
}
override fun insert(purchase: StoreKitCreditPurchase) {
StoreKitCreditPurchases.insert {
it[id] = purchase.id.toString()
@@ -59,6 +123,17 @@ private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
override val primaryKey = PrimaryKey(id)
}
private object StoreKitPurchaseLedger : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val referenceId = varchar("reference_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
StoreKitCreditPurchase(
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
@@ -75,3 +150,12 @@ private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
signedAt = this[StoreKitCreditPurchases.signedAt],
createdAt = this[StoreKitCreditPurchases.createdAt],
)
private fun ResultRow.toCreditedStoreKitTransaction(): CreditedStoreKitTransaction =
CreditedStoreKitTransaction(
transactionId = this[StoreKitCreditPurchases.transactionId],
productId = this[StoreKitCreditPurchases.productId],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
balanceAfter = this[StoreKitPurchaseLedger.balanceAfter],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
)
@@ -2,6 +2,7 @@ package com.osglab.account.features.storekit.routes
import com.osglab.account.common.api.ApiError
import com.osglab.account.common.api.ApiErrorResponse
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
@@ -12,8 +13,10 @@ import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.models.StoreKitProductDto
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
import com.osglab.account.features.storekit.models.StoreKitTransactionHistoryResponse
import com.osglab.account.features.storekit.services.StoreKitService
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
@@ -25,6 +28,7 @@ fun Route.storeKitRoutes(
service: StoreKitService,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
authenticate(SESSION_AUTH_NAME) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
@@ -37,6 +41,42 @@ fun Route.storeKitRoutes(
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
get("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
try {
if (call.request.queryParameters.contains("accountId")) {
throw InvalidStoreKitRequest("accountId is not accepted")
}
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) {
50
} else {
throw InvalidStoreKitRequest("limit must be an integer")
}
call.respond(
StoreKitTransactionHistoryResponse.fromDomain(
service.listTransactions(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
)
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
}
}
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
@@ -86,4 +126,5 @@ fun Route.storeKitRoutes(
}
}
}
}
}
@@ -9,13 +9,17 @@ import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
class StoreKitService(
@@ -33,6 +37,33 @@ class StoreKitService(
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
suspend fun listTransactions(
userId: UUID,
limit: Int = DEFAULT_PAGE_SIZE,
cursor: String? = null,
): StoreKitTransactionPage {
if (limit !in 1..MAX_PAGE_SIZE) {
throw InvalidStoreKitRequest("limit must be between 1 and 100")
}
val decodedCursor = cursor?.let(StoreKitTransactionCursorCodec::decode)
val results = transactions.inTransaction { unit ->
unit.storeKit.listCreditedTransactions(userId, limit + 1, decodedCursor)
}
val items = results.take(limit)
return StoreKitTransactionPage(
items = items,
nextCursor = if (results.size > limit) {
items.lastOrNull()?.let {
StoreKitTransactionCursorCodec.encode(
StoreKitTransactionCursor(it.purchasedAt, it.transactionId)
)
}
} else {
null
},
)
}
suspend fun submit(
userId: UUID,
signedTransaction: String,
@@ -155,6 +186,40 @@ class StoreKitService(
private companion object {
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
}
}
internal object StoreKitTransactionCursorCodec {
private const val MAX_CURSOR_LENGTH = 256
private const val INVALID_CURSOR_MESSAGE = "cursor is invalid"
private val TRANSACTION_ID = Regex("[0-9]{1,64}")
fun encode(cursor: StoreKitTransactionCursor): String {
val value = "${cursor.purchasedAt}|${cursor.transactionId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): StoreKitTransactionCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH || value != value.trim()) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2 && TRANSACTION_ID.matches(parts[1]))
StoreKitTransactionCursor(
purchasedAt = Instant.parse(parts[0]),
transactionId = parts[1],
)
} catch (_: IllegalArgumentException) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
}
}
@@ -0,0 +1,2 @@
CREATE INDEX idx_storekit_purchase_user_purchased_transaction
ON storekit_credit_purchases (user_id, purchased_at, transaction_id);
@@ -77,6 +77,26 @@ class DeploymentConsistencyTest : FunSpec({
rates shouldContain "1,\n 1000,\n 1,\n 400,"
}
test("StoreKit history remains ledger backed, indexed, and privacy minimized") {
val historyIndex = root.read(
"src/main/resources/db/migration/V15__storekit_purchase_history_index.sql",
)
historyIndex shouldContain
"ON storekit_credit_purchases (user_id, purchased_at, transaction_id)"
val openApi = root.read("docs/openapi.yaml")
val historySchema = openApi
.substringAfter(" StoreKitTransactionHistoryItem:")
.substringBefore(" AppleAppSiteAssociation:")
historySchema shouldContain "purchasedAt"
historySchema shouldContain "status"
historySchema shouldContain "nextCursor"
historySchema shouldNotContain "signedTransaction"
historySchema shouldNotContain "appAccountToken"
historySchema shouldNotContain "userId"
historySchema shouldNotContain "accountId"
}
test("account profiles cascade on deletion and grants stay aligned") {
val profileMigration = root.read(
"src/main/resources/db/migration/V11__account_profiles.sql",
@@ -21,7 +21,9 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import java.time.Instant
import java.util.UUID
@@ -292,6 +294,48 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
storeKitPurchases[transactionId]
override fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction> =
storeKitPurchases.values
.asSequence()
.filter { purchase ->
purchase.userId == userId &&
(
before == null ||
purchase.purchasedAt < before.purchasedAt ||
(
purchase.purchasedAt == before.purchasedAt &&
purchase.transactionId < before.transactionId
)
)
}
.mapNotNull { purchase ->
ledger.singleOrNull {
it.id == purchase.ledgerEntryId &&
it.userId == userId &&
it.type == LedgerEntryType.STOREKIT_PURCHASE &&
it.amountDelta == purchase.creditsGranted &&
it.referenceId == purchase.id
}?.let { ledgerEntry ->
CreditedStoreKitTransaction(
transactionId = purchase.transactionId,
productId = purchase.productId,
creditsGranted = purchase.creditsGranted,
balanceAfter = ledgerEntry.balanceAfter,
purchasedAt = purchase.purchasedAt,
)
}
}
.sortedWith(
compareByDescending<CreditedStoreKitTransaction> { it.purchasedAt }
.thenByDescending { it.transactionId }
)
.take(limit)
.toList()
override fun insert(purchase: StoreKitCreditPurchase) {
check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null)
}
@@ -0,0 +1,175 @@
package com.osglab.account.features.storekit
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import java.sql.DriverManager
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class StoreKitRepositoryIntegrationTest : FunSpec({
test("existing credited rows are account isolated and use stable cursor pagination") {
withStoreKitDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-19T14:00:00Z")
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
val otherUserId = UUID.fromString("10000000-0000-0000-0000-000000000011")
val product = StoreKitProduct("3000tks", 3_000)
insertAccounts(config, listOf(userId, otherUserId), now)
val transactions = mapOf(
"a".repeat(100) to verified("2000000000001", userId, now.minusSeconds(30)),
"b".repeat(100) to verified("2000000000002", userId, now.minusSeconds(20)),
"c".repeat(100) to verified("2000000000003", userId, now.minusSeconds(20)),
"d".repeat(100) to verified("2000000000009", otherUserId, now.minusSeconds(10)),
)
val runner = ExposedBillingTransactionRunner(databaseFactory.database)
val purchaseService = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier(transactions::getValue),
transactions = runner,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
transactions.keys.take(3).forEach { purchaseService.submit(userId, it) }
purchaseService.submit(otherUserId, "d".repeat(100))
purchaseService.submit(userId, "c".repeat(100))
// A fresh service instance proves the query reads persisted V9 audit/ledger rows.
val historyService = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier { error("history must not call Apple") },
transactions = runner,
clock = Clock.fixed(now.plusSeconds(60), ZoneOffset.UTC),
)
val firstPage = historyService.listTransactions(userId, limit = 2)
val secondPage = historyService.listTransactions(
userId = userId,
limit = 2,
cursor = firstPage.nextCursor,
)
firstPage.items.map { it.transactionId } shouldContainExactly listOf(
"2000000000003",
"2000000000002",
)
secondPage.items.map { it.transactionId } shouldContainExactly
listOf("2000000000001")
secondPage.nextCursor shouldBe null
storeKitHistoryIndexColumns(config) shouldContainExactly listOf(
"user_id",
"purchased_at",
"transaction_id",
)
}
}
})
private fun verified(
transactionId: String,
userId: UUID,
purchasedAt: Instant,
) = VerifiedStoreKitTransaction(
transactionId = transactionId,
originalTransactionId = transactionId,
appAccountToken = userId,
productId = "3000tks",
environment = StoreKitEnvironment.SANDBOX,
purchasedAt = purchasedAt,
signedAt = purchasedAt.plusSeconds(1),
revokedAt = null,
)
private suspend fun withStoreKitDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
StoreKitMySqlContainer("mysql:8.4")
.withDatabaseName("osg_storekit_history_test")
.withUsername("test")
.withPassword("test")
.also(StoreKitMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun storeKitHistoryIndexColumns(config: DatabaseConfig): List<String> =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
SELECT column_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'storekit_credit_purchases'
AND index_name = 'idx_storekit_purchase_user_purchased_transaction'
ORDER BY seq_in_index
""".trimIndent()
).use { statement ->
statement.executeQuery().use { result ->
buildList {
while (result.next()) {
add(result.getString("column_name"))
}
}
}
}
}
private fun insertAccounts(
config: DatabaseConfig,
accountIds: List<UUID>,
now: Instant,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent()
).use { statement ->
accountIds.forEach { accountId ->
statement.setString(1, accountId.toString())
statement.setString(2, "test-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.addBatch()
}
statement.executeBatch()
}
}
}
private class StoreKitMySqlContainer(image: String) :
MySQLContainer<StoreKitMySqlContainer>(image)
@@ -1,7 +1,9 @@
package com.osglab.account.features.storekit
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
@@ -10,6 +12,8 @@ import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -32,6 +36,8 @@ import kotlin.test.Test
class StoreKitRoutesTest {
private val now = Instant.parse("2026-08-18T08:00:00Z")
private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
private val sessionId = UUID.fromString("50000000-0000-0000-0000-000000000010")
private val accessToken = "valid-access-token"
private val signedTransaction = "s".repeat(100)
@Test
@@ -39,20 +45,31 @@ class StoreKitRoutesTest {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { token ->
token.takeIf { it == accessToken }?.let { AccountPrincipal(userId, sessionId) }
}
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
storeKitRoutes(service)
}
}
val catalog = client.get("/v1/storekit/products")
val catalog = client.get("/v1/storekit/products") {
bearerAuth(accessToken)
}
val first = client.post("/v1/storekit/transactions") {
bearerAuth(accessToken)
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
val replay = client.post("/v1/storekit/transactions") {
bearerAuth(accessToken)
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
val history = client.get("/v1/storekit/transactions") {
bearerAuth(accessToken)
}
catalog.status shouldBe HttpStatusCode.OK
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
@@ -63,25 +80,70 @@ class StoreKitRoutesTest {
first.bodyAsText() shouldContain """"replayed":false"""
replay.status shouldBe HttpStatusCode.OK
replay.bodyAsText() shouldContain """"replayed":true"""
history.status shouldBe HttpStatusCode.OK
history.bodyAsText() shouldContain
""""transactionId":"2000000000001","productId":"3000tks","creditsGranted":3000"""
history.bodyAsText() shouldContain """"purchasedAt":"2026-08-18T07:59:50Z""""
history.bodyAsText() shouldContain """"status":"credited""""
history.bodyAsText() shouldContain """"nextCursor":null"""
history.bodyAsText() shouldNotContain "signedTransaction"
history.bodyAsText() shouldNotContain "appAccountToken"
history.bodyAsText() shouldNotContain userId.toString()
}
@Test
fun `product catalog and transaction submission require authentication`() = testApplication {
fun `StoreKit endpoints require bearer authentication`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { null }
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { null })
storeKitRoutes(service)
}
}
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
client.get("/v1/storekit/transactions").status shouldBe HttpStatusCode.Unauthorized
client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}.status shouldBe HttpStatusCode.Unauthorized
}
@Test
fun `empty history and invalid pagination use the public response contract`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { AccountPrincipal(userId, sessionId) }
routing {
storeKitRoutes(service)
}
}
val empty = client.get("/v1/storekit/transactions") {
bearerAuth(accessToken)
}
empty.status shouldBe HttpStatusCode.OK
empty.bodyAsText() shouldBe """{"items":[],"nextCursor":null}"""
listOf(
"limit=0",
"limit=101",
"limit=abc",
"cursor=not-base64!",
"accountId=$userId",
).forEach { query ->
val response = client.get("/v1/storekit/transactions?$query") {
bearerAuth(accessToken)
}
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldContain """"code":"invalid_request""""
}
}
private fun service(): StoreKitService {
val verified = VerifiedStoreKitTransaction(
transactionId = "2000000000001",
@@ -1,8 +1,10 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
@@ -11,6 +13,7 @@ import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
@@ -78,6 +81,7 @@ class StoreKitServiceTest : FunSpec({
replay.replayed shouldBe true
replay.balanceAfter shouldBeExactly 3_000
store.ledger shouldHaveSize 1
service.listTransactions(userId).items shouldHaveSize 1
}
test("concurrent transaction replay grants credits exactly once") {
@@ -159,4 +163,116 @@ class StoreKitServiceTest : FunSpec({
}
revokedStore.ledger shouldHaveSize 0
}
test("history is account isolated, stably sorted, and cursor paginated") {
val store = TestBillingStore()
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
val transactionsByJws = mapOf(
"a".repeat(100) to transaction(
transactionId = "2000000000001",
purchasedAt = now.minusSeconds(30),
),
"b".repeat(100) to transaction(
transactionId = "2000000000002",
purchasedAt = now.minusSeconds(20),
),
"c".repeat(100) to transaction(
transactionId = "2000000000003",
purchasedAt = now.minusSeconds(20),
),
"d".repeat(100) to transaction(
transactionId = "2000000000009",
accountToken = otherUser,
purchasedAt = now.minusSeconds(10),
),
)
val historyService = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier(transactionsByJws::getValue),
transactions = store,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
transactionsByJws.keys.take(3).forEach { historyService.submit(userId, it) }
historyService.submit(otherUser, "d".repeat(100))
historyService.submit(userId, "c".repeat(100))
val firstPage = historyService.listTransactions(userId, limit = 2)
val secondPage = historyService.listTransactions(
userId = userId,
limit = 2,
cursor = firstPage.nextCursor,
)
firstPage.items.map { it.transactionId } shouldContainExactly listOf(
"2000000000003",
"2000000000002",
)
firstPage.items.map { it.balanceAfter } shouldContainExactly listOf(9_000, 6_000)
(firstPage.nextCursor != null) shouldBe true
secondPage.items.map { it.transactionId } shouldContainExactly listOf("2000000000001")
secondPage.nextCursor shouldBe null
store.storeKitPurchases.values shouldHaveSize 4
}
test("history reads purchases credited before the history endpoint exists") {
val store = TestBillingStore()
val purchaseId = UUID.fromString("30000000-0000-0000-0000-000000000001")
val ledgerEntryId = UUID.fromString("40000000-0000-0000-0000-000000000001")
val purchasedAt = Instant.parse("2026-01-01T01:02:03Z")
store.inTransaction { unit ->
unit.credits.insertLedgerEntry(
LedgerEntry(
id = ledgerEntryId,
userId = userId,
type = LedgerEntryType.STOREKIT_PURCHASE,
amountDelta = 3_000,
balanceAfter = 4_500,
idempotencyKey = "storekit:2000000000001",
referenceId = purchaseId,
createdAt = purchasedAt.plusSeconds(5),
)
)
unit.storeKit.insert(
StoreKitCreditPurchase(
id = purchaseId,
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
userId = userId,
appAccountToken = userId,
productId = product.productId,
environment = StoreKitEnvironment.PRODUCTION,
creditsGranted = 3_000,
ledgerEntryId = ledgerEntryId,
signedTransactionSha256 = "a".repeat(64),
purchasedAt = purchasedAt,
signedAt = purchasedAt.plusSeconds(1),
createdAt = purchasedAt.plusSeconds(5),
)
)
}
val history = service(store).listTransactions(userId)
history.items.single().run {
transactionId shouldBe "2000000000001"
balanceAfter shouldBeExactly 4_500
this.purchasedAt shouldBe purchasedAt
}
}
test("history validates limit boundaries and rejects malformed cursors") {
val historyService = service(TestBillingStore())
shouldThrow<InvalidStoreKitRequest> {
historyService.listTransactions(userId, limit = 0)
}
shouldThrow<InvalidStoreKitRequest> {
historyService.listTransactions(userId, limit = 101)
}
shouldThrow<InvalidStoreKitRequest> {
historyService.listTransactions(userId, cursor = "not-base64!")
}
historyService.listTransactions(userId, limit = 1).items shouldHaveSize 0
historyService.listTransactions(userId, limit = 100).items shouldHaveSize 0
}
})