Show latest credit ledger in admin

Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
This commit is contained in:
Rocky
2026-08-19 18:05:54 +08:00
parent 58445dd880
commit 75c046d91d
15 changed files with 356 additions and 34 deletions
+3
View File
@@ -173,6 +173,9 @@ export const adminApi = {
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (cursor?: string) =>
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
+1
View File
@@ -100,6 +100,7 @@ export type LedgerEntryType =
export interface LedgerEntry {
entryId: string;
userId: string;
type: LedgerEntryType;
amount: number;
balanceAfter: number;
+93 -28
View File
@@ -1,5 +1,12 @@
import { adminApi } from "../api/client";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import { adminApi, ApiError } from "../api/client";
import type { LedgerEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import {
escapeHtml,
formatDateTime,
@@ -14,7 +21,7 @@ export function renderCredits(container: HTMLElement): void {
<div>
<div class="eyebrow">积分账本</div>
<h1>积分流水</h1>
<p>内部用户 ID 查询不可变积分流水。</p>
<p>查看最新不可变积分流水,也可按完整内部用户 ID 查询。</p>
</div>
</div>
<section class="panel">
@@ -22,11 +29,11 @@ export function renderCredits(container: HTMLElement): void {
<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 />
<input name="userId" type="search" placeholder="输入完整用户 ID,留空查看全部" autocomplete="off" maxlength="36" />
</label>
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
</form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
<div data-ledger-results>${renderEmpty("正在加载最新积分流水")}</div>
</section>
`;
@@ -34,45 +41,103 @@ export function renderCredits(container: HTMLElement): void {
form?.addEventListener("submit", (event) => {
event.preventDefault();
const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
if (userId) void loadLedger(container, userId);
void loadLedger(container, userId || undefined);
});
void loadLedger(container);
}
async function loadLedger(container: HTMLElement, userId: string): Promise<void> {
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);
const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
results.innerHTML =
page.items.length === 0
? renderEmpty("该用户暂无积分流水")
? renderEmpty(userId ? "该用户暂无积分流水" : "暂无积分流水")
: `
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div>
<div class="result-summary">${
userId
? `用户 <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>
<caption class="sr-only">${userId ? `用户 ${escapeHtml(userId)}` : "最新"}积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">用户 ID</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-ledger-body>${ledgerRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more>加载更多流水</wa-button></div>'
: ""
}
`;
if (page.nextCursor) {
bindLedgerPagination(container, userId, page.nextCursor);
}
} catch (error) {
renderError(results, error, () => void loadLedger(container, userId));
}
}
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.userId)}</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("");
}
function bindLedgerPagination(
container: HTMLElement,
userId: string | undefined,
initialCursor: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = userId
? await adminApi.ledger(userId, cursor)
: await adminApi.latestLedger(cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(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);
}
});
}
+5 -1
View File
@@ -90,13 +90,17 @@ describe("adminApi", () => {
});
vi.stubGlobal("fetch", fetchMock);
await adminApi.latestLedger("latest+/=");
await adminApi.ledger("user/with space", "ledger+/=");
await adminApi.operators("operator+/=");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
"/v1/admin/credits/ledger?cursor=latest%2B%2F%3D",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
"/v1/admin/operators?cursor=operator%2B%2F%3D",
);
});
+52
View File
@@ -7,6 +7,7 @@ import type {
UserSummary,
} from "../api/types";
import { renderSecurity } from "../pages/security";
import { renderCredits } from "../pages/credits";
import { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111";
@@ -96,6 +97,7 @@ describe("用户页", () => {
items: [
{
entryId: "ledger-1",
userId,
type: "grant",
amount: 100,
balanceAfter: 100,
@@ -109,6 +111,7 @@ describe("用户页", () => {
items: [
{
entryId: "ledger-2",
userId,
type: "settle",
amount: -18,
balanceAfter: 82,
@@ -147,6 +150,55 @@ describe("用户页", () => {
});
});
describe("积分流水页", () => {
it("进入页面自动显示最新流水并支持继续加载", async () => {
vi.spyOn(adminApi, "latestLedger")
.mockResolvedValueOnce({
items: [
{
entryId: "latest-ledger-1",
userId,
type: "settle",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
createdAt: "2026-08-19T09:00:00Z",
},
],
nextCursor: "latest-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "latest-ledger-2",
userId: "22222222-2222-4222-8222-222222222222",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-19T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
renderCredits(container);
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(1);
});
expect(container.textContent).toContain(userId);
expect(container.textContent).toContain("USAGE_SETTLE");
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenNthCalledWith(2, "latest-next");
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-ledger-more]")).toBeNull();
});
});
describe("安全中心", () => {
it("按后端 nextCursor 加载更多管理员", async () => {
const firstOperator = operator("operator-1", "owner");