971b7e26c6
Adopt Web Awesome for consistent accessible controls while aligning production ASR, App Attest, and runtime dependency safeguards.
455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
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>
|
|
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-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><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-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>
|
|
<wa-button class="back-link" appearance="plain" data-back>← 返回用户查询</wa-button>
|
|
<div class="eyebrow">用户详情</div>
|
|
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
|
|
<p class="mono">${escapeHtml(user.userId)}</p>
|
|
</div>
|
|
${
|
|
role === "SUPER_ADMIN"
|
|
? '<wa-button variant="brand" appearance="accent" data-open-grant>人工赠送积分</wa-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"><wa-button variant="neutral" appearance="outlined" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</wa-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);
|
|
}
|
|
});
|
|
}
|