Modernize admin UI and harden provider contracts

Adopt Web Awesome for consistent accessible controls while aligning production ASR, App Attest, and runtime dependency safeguards.
This commit is contained in:
Rocky
2026-08-17 21:07:18 +08:00
parent dcd77b86ca
commit 971b7e26c6
20 changed files with 687 additions and 73 deletions
+4 -4
View File
@@ -160,17 +160,17 @@ export class AdminApp {
<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="安全退出">
<wa-button class="logout-button" appearance="plain" 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>
</wa-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">
<wa-button class="icon-button" appearance="plain" 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>
</wa-button>
<strong data-mobile-title>运营总览</strong>
</header>
<main class="page-content" id="main-content" data-content tabindex="-1"></main>
+13 -7
View File
@@ -1,10 +1,11 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
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>
<wa-spinner class="state-spinner" aria-hidden="true"></wa-spinner>
<p>${escapeHtml(label)}…</p>
</div>
`;
@@ -18,14 +19,14 @@ export function renderError(
const message =
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
container.innerHTML = `
<div class="state-card state-card--error" role="alert">
<wa-callout class="state-card state-card--error" variant="danger" appearance="outlined" 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>
${retry ? '<wa-button variant="neutral" appearance="outlined" data-retry>重试</wa-button>' : ""}
</wa-callout>
`;
container.querySelector<HTMLButtonElement>("[data-retry]")?.addEventListener(
container.querySelector<HTMLElement>("[data-retry]")?.addEventListener(
"click",
() => retry?.(),
);
@@ -36,8 +37,10 @@ export function renderEmpty(message: string): string {
}
export function showToast(message: string, tone: "success" | "error"): void {
const toast = document.createElement("div");
const toast = document.createElement("wa-callout");
toast.className = `toast toast--${tone}`;
toast.setAttribute("variant", tone === "error" ? "danger" : "success");
toast.setAttribute("appearance", "filled-outlined");
toast.setAttribute("role", tone === "error" ? "alert" : "status");
toast.textContent = message;
document.body.append(toast);
@@ -49,10 +52,13 @@ export function showToast(message: string, tone: "success" | "error"): void {
}
export function setButtonBusy(
button: HTMLButtonElement,
button: HTMLButtonElement | WaButton,
busy: boolean,
busyLabel = "处理中…",
): void {
if (button.tagName === "WA-BUTTON") {
(button as WaButton).loading = busy;
}
if (busy) {
button.dataset.label = button.textContent ?? "";
button.textContent = busyLabel;
+15
View File
@@ -0,0 +1,15 @@
import "@awesome.me/webawesome/dist/styles/webawesome.css";
import "@awesome.me/webawesome/dist/components/button/button.js";
import "@awesome.me/webawesome/dist/components/callout/callout.js";
import "@awesome.me/webawesome/dist/components/input/input.js";
import "@awesome.me/webawesome/dist/components/spinner/spinner.js";
const darkMode = window.matchMedia("(prefers-color-scheme: dark)");
function syncColorScheme(event: MediaQueryList | MediaQueryListEvent): void {
document.documentElement.classList.toggle("wa-dark", event.matches);
document.documentElement.classList.toggle("wa-light", !event.matches);
}
syncColorScheme(darkMode);
darkMode.addEventListener("change", syncColorScheme);
+1
View File
@@ -1,3 +1,4 @@
import "./components/webawesome";
import "./styles.css";
import { AdminApp } from "./app";
+1 -1
View File
@@ -35,7 +35,7 @@ export async function renderAudit(container: HTMLElement): Promise<void> {
</div>
${
page.nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-audit-more>加载更早记录</button></div>'
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-audit-more>加载更早记录</wa-button></div>'
: ""
}
`
+6 -14
View File
@@ -1,3 +1,4 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { adminApi, ApiError, setCsrfToken } from "../api/client";
import type { AuthState } from "../api/types";
import { setButtonBusy } from "../components/ui";
@@ -27,27 +28,18 @@ export function renderPasswordLogin(
<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>
<wa-input name="username" label="管理员用户名" type="text" autocomplete="username" minlength="3" maxlength="64" appearance="outlined" required autofocus></wa-input>
<wa-input name="password" label="管理员密码" type="password" autocomplete="current-password" minlength="12" appearance="outlined" password-toggle required></wa-input>
<wa-input class="totp-input" name="totpCode" label="动态验证码" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" appearance="outlined" required></wa-input>
<p class="form-error" data-error role="alert"></p>
<button class="button button--primary button--wide" type="submit">安全登录</button>
<wa-button class="button--wide" variant="brand" appearance="accent" type="submit">安全登录</wa-button>
</form>
`);
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector<HTMLButtonElement>("button");
const button = form.querySelector<WaButton>('wa-button[type="submit"]');
const errorNode = form.querySelector<HTMLElement>("[data-error]");
const username = new FormData(form).get("username")?.toString().trim() ?? "";
const password = new FormData(form).get("password")?.toString() ?? "";
+1 -1
View File
@@ -24,7 +24,7 @@ export function renderCredits(container: HTMLElement): void {
<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>
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
</form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
</section>
+7 -7
View File
@@ -57,7 +57,7 @@ function securityTemplate(
<h1>安全中心</h1>
<p>管理运营人员、角色、登录锁定、会话与双重认证。</p>
</div>
<button class="button button--primary" data-create-operator>添加管理员</button>
<wa-button variant="brand" appearance="accent" data-create-operator>添加管理员</wa-button>
</div>
<aside class="security-notice" aria-label="安全说明">
@@ -88,7 +88,7 @@ function securityTemplate(
</div>
${
nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</button></div>'
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</wa-button></div>'
: ""
}
<p class="sr-only" id="operator-pagination-status" data-operator-status role="status" aria-live="polite"></p>
@@ -133,15 +133,15 @@ function operatorRow(
<div class="row-actions" data-operator-id="${escapeHtml(operator.operatorId)}">
${
locked
? '<button class="button button--small button--secondary" data-operator-action="unlock">解锁</button>'
? '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="unlock">解锁</wa-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>
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="sessions">撤销会话</wa-button>
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="reset">重置凭据</wa-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>'
? `<wa-button variant="danger" appearance="filled" size="s" data-operator-action="disable" ${current ? "disabled" : ""}>停用</wa-button>`
: '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="enable">启用</wa-button>'
}
</div>
</td>
+5 -5
View File
@@ -39,7 +39,7 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
<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>
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
</form>
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
</section>
@@ -102,7 +102,7 @@ function userRow(user: UserSummary): string {
<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>
<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>
`;
}
@@ -157,14 +157,14 @@ function detailTemplate(
return `
<div class="page-heading page-heading--detail">
<div>
<button class="back-link" data-back>← 返回用户查询</button>
<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"
? '<button class="button button--primary" data-open-grant>人工赠送积分</button>'
? '<wa-button variant="brand" appearance="accent" data-open-grant>人工赠送积分</wa-button>'
: ""
}
</div>
@@ -208,7 +208,7 @@ function detailTemplate(
</div>
${
ledger.nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</button></div>'
? '<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>
+66 -23
View File
@@ -22,6 +22,18 @@
--focus-ring: rgb(0 113 227 / 28%);
--danger: #d70015;
--success: #248a3d;
--wa-font-family-body:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
"Helvetica Neue", "PingFang SC", system-ui, sans-serif;
--wa-font-family-heading: var(--wa-font-family-body);
--wa-color-brand-fill-loud: var(--primary);
--wa-color-brand-border-loud: var(--primary);
--wa-color-brand-on-loud: #fff;
--wa-color-focus: var(--primary);
--wa-form-control-border-radius: 12px;
--wa-panel-border-radius: 18px;
--wa-focus-ring-width: 3px;
--wa-focus-ring-offset: 2px;
}
* {
@@ -177,6 +189,10 @@ h2 {
width: 100%;
}
wa-button.button--wide {
display: block;
}
.button--small {
min-height: 34px;
padding: 0 12px;
@@ -200,6 +216,12 @@ h2 {
background: var(--hover-fill);
}
wa-button.icon-button::part(button),
wa-button.logout-button::part(button) {
min-height: 44px;
padding: 0;
}
.icon-button svg,
.logout-button svg {
width: 21px;
@@ -246,17 +268,6 @@ h2 {
margin-left: auto;
}
.auth-brand::after {
display: block;
width: min(390px, 80vw);
height: 200px;
margin-top: 40px;
border: 1px solid var(--border);
border-radius: 30px;
background: var(--surface-muted);
content: "";
}
.auth-brand p {
margin-top: 24px;
color: var(--muted);
@@ -277,6 +288,20 @@ h2 {
gap: 18px;
}
wa-input {
width: 100%;
}
wa-input::part(form-control-label) {
color: var(--navy);
font-size: 0.88rem;
font-weight: 700;
}
wa-input::part(input) {
min-height: 44px;
}
label {
display: grid;
color: var(--navy);
@@ -329,6 +354,18 @@ input:disabled {
text-align: center;
}
wa-input.totp-input {
font-size: inherit;
letter-spacing: normal;
text-align: initial;
}
wa-input.totp-input::part(input) {
font-size: 1.45rem;
letter-spacing: 0.4em;
text-align: center;
}
.form-error {
min-height: 20px;
margin: -4px 0 0;
@@ -481,6 +518,10 @@ input:disabled {
background: var(--hover-fill);
}
wa-button.logout-button::part(button) {
padding: 0 10px;
}
.logout-button span {
font-size: 0.76rem;
font-weight: 650;
@@ -934,6 +975,13 @@ td strong {
background: #fff;
}
wa-callout.state-card::part(message) {
display: grid;
text-align: center;
align-content: center;
justify-items: center;
}
.state-icon,
.confirm-icon {
display: grid;
@@ -957,6 +1005,13 @@ td strong {
animation: spin 700ms linear infinite;
}
.state-spinner {
margin-bottom: 12px;
font-size: 28px;
--indicator-color: var(--primary);
--track-color: color-mix(in srgb, var(--primary) 18%, transparent);
}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -1148,10 +1203,6 @@ td strong {
right: 22px;
bottom: 22px;
max-width: min(390px, calc(100vw - 44px));
padding: 14px 18px;
color: #fff;
border-radius: 11px;
background: #344054;
box-shadow: 0 15px 40px rgb(16 24 40 / 25%);
opacity: 0;
transform: translateY(10px);
@@ -1165,14 +1216,6 @@ td strong {
transform: translateY(0);
}
.toast--success {
background: var(--success);
}
.toast--error {
background: var(--danger);
}
code {
padding: 3px 6px;
color: #364fc7;
+35
View File
@@ -0,0 +1,35 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { describe, expect, it, vi } from "vitest";
import { renderError, renderLoading, setButtonBusy } from "../components/ui";
describe("Web Awesome UI states", () => {
it("renders accessible loading and retry components", () => {
const container = document.createElement("div");
const retry = vi.fn();
renderLoading(container, "读取用户");
expect(container.querySelector("wa-spinner")).not.toBeNull();
expect(container.textContent).toContain("读取用户");
renderError(container, new Error("failed"), retry);
const retryButton = container.querySelector<HTMLElement>("wa-button[data-retry]");
expect(container.querySelector('wa-callout[variant="danger"]')).not.toBeNull();
retryButton?.click();
expect(retry).toHaveBeenCalledOnce();
});
it("uses the Web Awesome loading state and restores the label", () => {
const button = document.createElement("wa-button") as WaButton;
button.textContent = "提交";
setButtonBusy(button, true, "提交中…");
expect(button.loading).toBe(true);
expect(button.disabled).toBe(true);
expect(button.textContent).toBe("提交中…");
setButtonBusy(button, false);
expect(button.loading).toBe(false);
expect(button.disabled).toBe(false);
expect(button.textContent).toBe("提交");
});
});