Add secure administrator operations console
Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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];
|
||||
}
|
||||
Reference in New Issue
Block a user