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:
Rocky
2026-08-17 15:20:34 +08:00
parent 676bfd2451
commit 1a9c518f96
76 changed files with 14602 additions and 3 deletions
+232
View File
@@ -0,0 +1,232 @@
import type {
AdminOperator,
AdminOperatorCreateRequest,
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminLoginResponse,
AuditLogEntry,
CreditGrantRequest,
CreditGrantResponse,
LedgerEntry,
Overview,
PageResult,
ReferralOverview,
SessionResponse,
UserDetail,
UserSummary,
} from "./types";
const API_BASE = "/v1/admin";
const REQUEST_TIMEOUT_MS = 12_000;
interface ApiErrorBody {
code?: string;
error?: {
code?: string;
};
}
export class ApiError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
let csrfToken = "";
export function setCsrfToken(token?: string): void {
csrfToken = token?.trim() ?? "";
}
function csrfTokenFromCookie(): string {
if (typeof document === "undefined") return "";
const prefix = "osg_admin_csrf=";
return (
document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length) ?? ""
);
}
function safeMessage(status: number, code?: string): string {
const messages: Record<string, string> = {
INVALID_CREDENTIALS: "用户名、密码或动态验证码错误",
INVALID_TOTP: "动态验证码无效或已过期",
VALIDATION_ERROR: "提交内容不符合要求",
USER_NOT_FOUND: "未找到该用户",
INSUFFICIENT_PERMISSION: "无权执行此操作",
IDEMPOTENCY_CONFLICT: "该赠送请求与已有记录冲突,请核对审计日志",
ADMIN_OPERATOR_NOT_FOUND: "未找到该管理员",
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
if (status === 401) return "登录状态已失效,请重新登录";
if (status === 403) return "无权执行此操作";
if (status === 409) return "请求冲突,请刷新后重试";
if (status === 429) return "操作过于频繁,请稍后再试";
if (status >= 500) return "服务暂时不可用,请稍后再试";
return "请求失败,请检查后重试";
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const method = options.method?.toUpperCase() ?? "GET";
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
if (options.body) headers.set("Content-Type", "application/json");
const csrfRequired =
!["GET", "HEAD", "OPTIONS"].includes(method) && path !== "/auth/login";
if (csrfRequired) {
const requestToken =
csrfToken || csrfTokenFromCookie() || headers.get("X-CSRF-Token") || "";
if (!requestToken) {
throw new ApiError("CSRF_TOKEN_MISSING", "安全令牌缺失,请重新登录", 403);
}
headers.set("X-CSRF-Token", requestToken);
}
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: "include",
signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
let body: ApiErrorBody = {};
try {
body = (await response.json()) as ApiErrorBody;
} catch {
// 非 JSON 错误响应不会暴露给界面。
}
const apiError = new ApiError(
body.code ?? body.error?.code ?? `HTTP_${response.status}`,
safeMessage(response.status, body.code ?? body.error?.code),
response.status,
);
if (
response.status === 401 &&
!path.startsWith("/auth/") &&
typeof window !== "undefined"
) {
window.dispatchEvent(new Event("admin:unauthorized"));
}
throw apiError;
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof DOMException && error.name === "TimeoutError") {
throw new ApiError("REQUEST_TIMEOUT", "请求超时,请稍后再试", 0);
}
throw new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0);
}
}
function query(params: Record<string, string | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value);
});
const result = search.toString();
return result ? `?${result}` : "";
}
export const adminApi = {
session: () => request<SessionResponse>("/auth/session"),
login: (username: string, password: string, totpCode: string) =>
request<AdminLoginResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password, totpCode }),
}),
logout: () => request<void>("/auth/logout", { method: "POST" }),
overview: (range: string) =>
request<Overview>(`/overview${query({ range })}`),
referrals: (range: string) =>
request<ReferralOverview>(`/referrals${query({ range })}`),
users: (search: string, cursor?: string) =>
request<PageResult<UserSummary>>(
`/users${query({ q: search.trim(), cursor })}`,
),
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
),
grantCredits: (payload: CreditGrantRequest) =>
request<CreditGrantResponse>("/credits/grants", {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
amount: payload.amount,
reason: payload.reason,
}),
headers: { "Idempotency-Key": payload.idempotencyKey },
}),
auditLogs: (cursor?: string) =>
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
operators: (cursor?: string) =>
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
operatorSummary: () =>
request<AdminSecuritySummary>("/operators/summary"),
createOperator: (payload: AdminOperatorCreateRequest) =>
request<AdminOperatorProvisioning>("/operators", {
method: "POST",
body: JSON.stringify(payload),
}),
setOperatorEnabled: (operatorId: string, enabled: boolean) =>
request<void>(
`/operators/${encodeURIComponent(operatorId)}/${enabled ? "enable" : "disable"}`,
{ method: "POST" },
),
unlockOperator: (operatorId: string) =>
request<void>(`/operators/${encodeURIComponent(operatorId)}/unlock`, {
method: "POST",
}),
resetOperatorCredentials: (operatorId: string, password: string) =>
request<AdminOperatorProvisioning>(
`/operators/${encodeURIComponent(operatorId)}/credentials/reset`,
{
method: "POST",
body: JSON.stringify({ password }),
},
),
revokeOperatorSessions: (operatorId: string) =>
request<void>(
`/operators/${encodeURIComponent(operatorId)}/sessions/revoke`,
{ method: "POST" },
),
};
+161
View File
@@ -0,0 +1,161 @@
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
export type AuthState =
| { status: "anonymous" }
| { status: "authenticated"; operatorName: string; role: AdminRole };
export interface SessionResponse {
authenticated: boolean;
operatorName?: string;
role?: AdminRole;
}
export interface AdminLoginResponse {
operatorName: string;
role: AdminRole;
csrfToken: string;
}
export interface TrendPoint {
date: string;
registrations: number;
creditsUsed: number;
}
export interface Overview {
totalUsers: number;
activeUsers: number;
newUsers: number;
totalCreditBalance: number;
creditsGranted: number;
creditsUsed: number;
trend: TrendPoint[];
usage: UserUsageAggregate[];
}
export interface FunnelStep {
label: string;
count: number;
}
export interface ReferralRankingItem {
userId: string;
invited: number;
qualified: number;
creditsEarned: number;
}
export interface ReferralOverview {
pendingBindings: number;
ineligibleBindings: number;
funnel: FunnelStep[];
ranking: ReferralRankingItem[];
}
export interface UserSummary {
userId: string;
displayName: string;
maskedEmail?: string;
status: "active" | "suspended" | "closed";
creditBalance: number;
createdAt: string;
}
export interface UserDetail extends UserSummary {
lastActiveAt?: string;
qualifiedUsage: boolean;
referralCode?: string;
referredByUserId?: string;
usage?: UserUsageAggregate[];
referral?: UserReferral;
}
export interface UserUsageAggregate {
kind: string;
requests: number;
chargedCredits: number;
asrMillis: number;
inputTokens: number;
outputTokens: number;
}
export interface UserReferral {
inviterUserId?: string;
invitedUsers: number;
rewardedInvites: number;
}
export interface PageResult<T> {
items: T[];
nextCursor?: string;
}
export type LedgerEntryType =
| "grant"
| "reserve"
| "settle"
| "refund"
| "adjustment";
export interface LedgerEntry {
entryId: string;
type: LedgerEntryType;
amount: number;
balanceAfter: number;
reasonCode: string;
createdAt: string;
}
export interface CreditGrantRequest {
userId: string;
amount: number;
reason: string;
idempotencyKey: string;
}
export interface CreditGrantResponse {
transactionId: string;
balanceAfter: number;
}
export interface AuditLogEntry {
auditId: string;
operatorName: string;
action: string;
targetType: string;
targetId: string;
requestId?: string;
result: "success" | "rejected";
createdAt: string;
}
export interface AdminOperator {
operatorId: string;
username: string;
role: AdminRole;
enabled: boolean;
failedLoginCount: number;
lockedUntil?: string;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
}
export interface AdminOperatorProvisioning {
operatorId: string;
operator?: AdminOperator;
totpSecret: string;
otpauthUri: string;
}
export interface AdminSecuritySummary {
enabledOperators: number;
lockedOperators: number;
activeSessions: number;
}
export interface AdminOperatorCreateRequest {
username: string;
password: string;
role: AdminRole;
}
+289
View File
@@ -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];
}
+80
View File
@@ -0,0 +1,80 @@
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>
`;
}
+66
View File
@@ -0,0 +1,66 @@
import { ApiError } from "../api/client";
import { escapeHtml } from "../lib/format";
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = `
<div class="state-card" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<p>${escapeHtml(label)}…</p>
</div>
`;
}
export function renderError(
container: HTMLElement,
error: unknown,
retry?: () => void,
): void {
const message =
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
container.innerHTML = `
<div class="state-card state-card--error" role="alert">
<span class="state-icon" aria-hidden="true">!</span>
<h2>无法加载数据</h2>
<p>${escapeHtml(message)}</p>
${retry ? '<button class="button button--secondary" data-retry>重试</button>' : ""}
</div>
`;
container.querySelector<HTMLButtonElement>("[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("div");
toast.className = `toast toast--${tone}`;
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,
busy: boolean,
busyLabel = "处理中…",
): void {
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");
}
}
+54
View File
@@ -0,0 +1,54 @@
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const numberFormatter = new Intl.NumberFormat("zh-CN");
export function formatDateTime(value?: string): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : dateTimeFormatter.format(date);
}
export function formatNumber(value: number): string {
return numberFormatter.format(value);
}
export function formatSignedCredits(value: number): string {
return `${value > 0 ? "+" : ""}${formatNumber(value)}`;
}
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export function statusLabel(status: string): string {
const labels: Record<string, string> = {
active: "正常",
suspended: "已停用",
closed: "已关闭",
success: "成功",
rejected: "已拒绝",
grant: "赠送",
reserve: "预留",
settle: "结算",
refund: "退还",
adjustment: "调整",
};
return labels[status] ?? status;
}
export function createIdempotencyKey(): string {
return globalThis.crypto?.randomUUID?.() ??
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
+7
View File
@@ -0,0 +1,7 @@
import "./styles.css";
import { AdminApp } from "./app";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
void new AdminApp(root).start();
+99
View File
@@ -0,0 +1,99 @@
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"><button class="button button--secondary" data-audit-more>加载更早记录</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);
}
});
}
+83
View File
@@ -0,0 +1,83 @@
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>
<label>
<span>管理员用户名</span>
<input name="username" type="text" autocomplete="username" minlength="3" maxlength="64" required autofocus />
</label>
<label>
<span>管理员密码</span>
<input name="password" type="password" autocomplete="current-password" minlength="12" required />
</label>
<label>
<span>动态验证码</span>
<input class="totp-input" name="totpCode" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required />
</label>
<p class="form-error" data-error role="alert"></p>
<button class="button button--primary button--wide" type="submit">安全登录</button>
</form>
`);
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector<HTMLButtonElement>("button");
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);
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import { adminApi } from "../api/client";
import { renderEmpty, renderError, renderLoading } 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" required />
</label>
<button class="button button--primary" type="submit">查询流水</button>
</form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</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() ?? "";
if (userId) void loadLedger(container, userId);
});
}
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 = await adminApi.ledger(userId);
results.innerHTML =
page.items.length === 0
? renderEmpty("该用户暂无积分流水")
: `
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户 ${escapeHtml(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>
${page.items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`;
} catch (error) {
renderError(results, error, () => void loadLedger(container, userId));
}
}
+102
View File
@@ -0,0 +1,102 @@
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
@@ -0,0 +1,97 @@
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
@@ -0,0 +1,585 @@
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>
<button class="button button--primary" data-create-operator>添加管理员</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"><button class="button button--secondary" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</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
? '<button class="button button--small button--secondary" data-operator-action="unlock">解锁</button>'
: ""
}
<button class="button button--small button--secondary" data-operator-action="sessions">撤销会话</button>
<button class="button button--small button--secondary" data-operator-action="reset">重置凭据</button>
${
operator.enabled
? `<button class="button button--small button--danger" data-operator-action="disable" ${current ? "disabled" : ""}>停用</button>`
: '<button class="button button--small button--secondary" data-operator-action="enable">启用</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];
}
+454
View File
@@ -0,0 +1,454 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminRole,
LedgerEntry,
PageResult,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
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>按内部用户 ID 精确查询,不展示 Apple 身份标识。</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="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
</label>
<button class="button button--primary" type="submit">搜索</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() ?? "";
if (search) void searchUsers(container, search, role);
});
}
async function searchUsers(
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">用户查询结果</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>
${page.items.map(userRow).join("")}
</tbody>
</table>
</div>
`;
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
(button) => {
button.addEventListener("click", () => {
const userId = button.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
},
);
} catch (error) {
renderError(results, error, () => void searchUsers(container, search, role));
}
}
function userRow(user: UserSummary): string {
return `
<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.creditBalance)}</td>
<td>${formatDateTime(user.createdAt)}</td>
<td><button class="button button--small button--secondary" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</button></td>
</tr>
`;
}
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>
<button class="back-link" data-back>← 返回用户查询</button>
<div class="eyebrow">用户详情</div>
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
<p class="mono">${escapeHtml(user.userId)}</p>
</div>
${
role === "SUPER_ADMIN"
? '<button class="button button--primary" data-open-grant>人工赠送积分</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></tr></thead>
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table>
</div>
${
ledger.nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</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 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);
}
});
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
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="');
});
});
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi, ApiError, setCsrfToken } from "../api/client";
describe("adminApi", () => {
afterEach(() => {
vi.unstubAllGlobals();
setCsrfToken();
});
it("会话恢复不依赖 csrfToken,且所有请求都携带同源凭据", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
authenticated: true,
operatorName: "owner",
role: "SUPER_ADMIN",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const session = await adminApi.session();
expect(fetchMock).toHaveBeenCalledOnce();
expect(session).toEqual({
authenticated: true,
operatorName: "owner",
role: "SUPER_ADMIN",
});
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
credentials: "include",
});
});
it("积分赠送携带 CSRF 与幂等请求头", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ transactionId: "tx-1", balanceAfter: 120 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-test");
await adminApi.grantCredits({
userId: "user-1",
amount: 20,
reason: "客服补偿",
idempotencyKey: "grant-1",
});
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
const headers = request.headers as Headers;
expect(headers.get("X-CSRF-Token")).toBe("csrf-test");
expect(headers.get("Idempotency-Key")).toBe("grant-1");
expect(request.credentials).toBe("include");
});
it("登录请求不依赖已有会话 CSRF", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
operatorName: "owner",
role: "SUPER_ADMIN",
csrfToken: "new-csrf",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const response = await adminApi.login(
"owner",
"a-strong-password",
"123456",
);
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((request.headers as Headers).has("X-CSRF-Token")).toBe(false);
expect(response.csrfToken).toBe("new-csrf");
});
it("流水与管理员列表把 cursor 安全传入查询参数", async () => {
const fetchMock = vi.fn().mockImplementation(async () => {
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
await adminApi.ledger("user/with space", "ledger+/=");
await adminApi.operators("operator+/=");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"/v1/admin/operators?cursor=operator%2B%2F%3D",
);
});
it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(adminApi.logout()).rejects.toMatchObject({
code: "CSRF_TOKEN_MISSING",
status: 403,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("服务错误转换为稳定 ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "RATE_LIMITED" }), {
status: 429,
headers: { "Content-Type": "application/json" },
}),
),
);
const promise = adminApi.overview("30d");
await expect(promise).rejects.toBeInstanceOf(ApiError);
await expect(promise).rejects.toMatchObject({
code: "RATE_LIMITED",
message: "操作过于频繁,请稍后再试",
status: 429,
});
});
it("幂等冲突显示可操作的审计提示", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "IDEMPOTENCY_CONFLICT" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
),
);
setCsrfToken("csrf-test");
await expect(
adminApi.grantCredits({
userId: "user-1",
amount: 20,
reason: "客服补偿",
idempotencyKey: "grant-conflict-1",
}),
).rejects.toMatchObject({
code: "IDEMPOTENCY_CONFLICT",
message: "该赠送请求与已有记录冲突,请核对审计日志",
status: 409,
});
});
it("创建管理员携带 CSRF 并返回一次性 TOTP 配置", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
operatorId: "operator-1",
totpSecret: "JBSWY3DPEHPK3PXP",
otpauthUri: "otpauth://totp/OSG:operator",
}),
{ status: 201, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-test");
const result = await adminApi.createOperator({
username: "support",
password: "a-strong-password",
role: "SUPPORT",
});
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-test");
expect(request.body).toBe(
JSON.stringify({
username: "support",
password: "a-strong-password",
role: "SUPPORT",
}),
);
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
escapeHtml,
formatDateTime,
formatSignedCredits,
statusLabel,
} from "../lib/format";
describe("format helpers", () => {
it("转义服务端文本以阻止 HTML 注入", () => {
expect(escapeHtml('<img src=x onerror="alert(1)">')).toBe(
"&lt;img src=x onerror=&quot;alert(1)&quot;&gt;",
);
});
it("无效日期显示占位符", () => {
expect(formatDateTime("not-a-date")).toBe("—");
expect(formatDateTime()).toBe("—");
});
it("积分变动保留明确正负号", () => {
expect(formatSignedCredits(15)).toBe("+15");
expect(formatSignedCredits(-8)).toBe("-8");
});
it("未知状态保持原值", () => {
expect(statusLabel("custom")).toBe("custom");
});
});
+191
View File
@@ -0,0 +1,191 @@
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 { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111";
const userSummary: UserSummary = {
userId,
displayName: "测试用户",
status: "active",
creditBalance: 120,
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 () => {
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",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-01T08:00:00Z",
},
],
nextCursor: "ledger-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-2",
type: "settle",
amount: -18,
balanceAfter: 82,
reasonCode: "USAGE_SETTLE",
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.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载",
);
});
});
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,
};
}