1a9c518f96
Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
290 lines
9.8 KiB
TypeScript
290 lines
9.8 KiB
TypeScript
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>
|
|
<button class="logout-button" data-logout title="安全退出">
|
|
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M9 4H4v14h5M13 7l4 4-4 4M17 11H8" /></svg>
|
|
<span>退出</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<div class="main-column">
|
|
<header class="mobile-header">
|
|
<button class="icon-button" 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>
|
|
</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];
|
|
}
|