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:
@@ -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 })}`,
|
||||
|
||||
@@ -100,6 +100,7 @@ export type LedgerEntryType =
|
||||
|
||||
export interface LedgerEntry {
|
||||
entryId: string;
|
||||
userId: string;
|
||||
type: LedgerEntryType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user