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
+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");
}
}