Show complete user credit data in admin

Load users by registration order, expose consumed and current credits, and accept short internal IDs so support can reliably locate accounts.
This commit is contained in:
Rocky
2026-08-19 17:54:27 +08:00
parent c592f426be
commit 58445dd880
9 changed files with 195 additions and 31 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ export const adminApi = {
referrals: (range: string) =>
request<ReferralOverview>(`/referrals${query({ range })}`),
users: (search: string, cursor?: string) =>
users: (search = "", cursor?: string) =>
request<PageResult<UserSummary>>(
`/users${query({ q: search.trim(), cursor })}`,
),
+1
View File
@@ -58,6 +58,7 @@ export interface UserSummary {
maskedEmail?: string;
status: "active" | "suspended" | "closed";
creditBalance: number;
consumedCredits: number;
createdAt: string;
}
+76 -23
View File
@@ -28,8 +28,8 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
<div class="page-heading">
<div>
<div class="eyebrow">账户管理</div>
<h1>用户查询</h1>
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
<h1>用户列表</h1>
<p>按注册时间查看全部用户,支持完整或后 8 位内部用户 ID 查询。</p>
</div>
</div>
<section class="panel">
@@ -37,11 +37,11 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
<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 />
<input name="query" type="search" placeholder="输入完整或后 8 位用户 ID" autocomplete="off" maxlength="36" />
</label>
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
</form>
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
<div data-results>${renderEmpty("正在加载用户列表")}</div>
</section>
`;
@@ -49,11 +49,14 @@ export function renderUsers(container: HTMLElement, role: AdminRole): void {
form?.addEventListener("submit", (event) => {
event.preventDefault();
const search = new FormData(form).get("query")?.toString().trim() ?? "";
if (search) void searchUsers(container, search, role);
void loadUsers(container, search, role);
});
const results = container.querySelector<HTMLElement>("[data-results]");
if (results) bindUserRows(results, container, role);
void loadUsers(container, "", role);
}
async function searchUsers(
async function loadUsers(
container: HTMLElement,
search: string,
role: AdminRole,
@@ -71,40 +74,90 @@ async function searchUsers(
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>
<caption class="sr-only">${search ? "用户查询结果" : "全部用户"}</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 data-user-body>${userRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-user-more>加载更多用户</wa-button></div>'
: ""
}
`;
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
(button) => {
button.addEventListener("click", () => {
const userId = button.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
},
);
if (page.nextCursor) {
bindUserPagination(container, search, page.nextCursor);
}
} catch (error) {
renderError(results, error, () => void searchUsers(container, search, role));
renderError(results, error, () => void loadUsers(container, search, role));
}
}
function userRow(user: UserSummary): string {
return `
function userRows(users: UserSummary[]): string {
return users
.map(
(user) => `
<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.consumedCredits)}</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>
`;
`,
)
.join("");
}
function bindUserRows(
scope: HTMLElement,
container: HTMLElement,
role: AdminRole,
): void {
scope.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>(
"[data-user-id]",
);
const userId = button?.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
}
function bindUserPagination(
container: HTMLElement,
search: string,
initialCursor: string,
): void {
const button = container.querySelector<HTMLButtonElement>("[data-user-more]");
const body = container.querySelector<HTMLTableSectionElement>("[data-user-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.users(search, cursor);
body.insertAdjacentHTML("beforeend", userRows(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);
}
});
}
async function renderUserDetail(
+32
View File
@@ -15,6 +15,7 @@ const userSummary: UserSummary = {
displayName: "测试用户",
status: "active",
creditBalance: 120,
consumedCredits: 18,
createdAt: "2026-08-01T08:00:00Z",
};
const userDetail: UserDetail = {
@@ -45,6 +46,37 @@ afterEach(() => {
});
describe("用户页", () => {
it("进入页面自动显示积分、注册时间并按游标加载更多用户", async () => {
const secondUser: UserSummary = {
...userSummary,
userId: "22222222-2222-4222-8222-222222222222",
displayName: "第二位用户",
creditBalance: 80,
consumedCredits: 40,
};
vi.spyOn(adminApi, "users")
.mockResolvedValueOnce({ items: [userSummary], nextCursor: "user-next" })
.mockResolvedValueOnce({ items: [secondUser] });
const container = document.createElement("main");
document.body.append(container);
renderUsers(container, "SUPPORT");
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(1);
});
expect(container.textContent).toContain("累计使用积分");
expect(container.textContent).toContain("当前积分");
expect(container.textContent).toContain("18");
expect(container.textContent).toContain("120");
container.querySelector<HTMLButtonElement>("[data-user-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
expect(container.querySelectorAll("[data-user-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-user-more]")).toBeNull();
});
it("支持人员可查看详情但不显示人工赠送", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });