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,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user