Show product usage types in credit ledger

Persist hotword request origin so every reservation lifecycle entry can be classified without mutating the immutable ledger.
This commit is contained in:
Rocky
2026-08-19 19:47:12 +08:00
parent e1fd35b1ff
commit 3edc86a9a0
22 changed files with 205 additions and 16 deletions
+3
View File
@@ -98,6 +98,8 @@ export type LedgerEntryType =
| "refund"
| "adjustment";
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
export interface LedgerEntry {
entryId: string;
userId: string;
@@ -105,6 +107,7 @@ export interface LedgerEntry {
amount: number;
balanceAfter: number;
reasonCode: string;
usageType?: UsageType;
createdAt: string;
}
+7 -1
View File
@@ -1,6 +1,12 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { ApiError } from "../api/client";
import { escapeHtml } from "../lib/format";
import type { UsageType } from "../api/types";
import { escapeHtml, usageTypeLabel } from "../lib/format";
export function renderUsageTypeBadge(usageType?: UsageType): string {
if (!usageType) return '<span class="muted">—</span>';
return `<span class="badge usage-badge usage-badge--${escapeHtml(usageType)}">${escapeHtml(usageTypeLabel(usageType))}</span>`;
}
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = `
+11
View File
@@ -48,6 +48,17 @@ export function statusLabel(status: string): string {
return labels[status] ?? status;
}
export function usageTypeLabel(usageType?: string): string {
const labels: Record<string, string> = {
polish: "润色",
asr: "ASR",
ai: "AI",
agent: "Agent",
hotword: "热词",
};
return usageType ? (labels[usageType] ?? usageType) : "—";
}
export function createIdempotencyKey(): string {
return globalThis.crypto?.randomUUID?.() ??
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+3 -1
View File
@@ -4,6 +4,7 @@ import {
renderEmpty,
renderError,
renderLoading,
renderUsageTypeBadge,
setButtonBusy,
showToast,
} from "../components/ui";
@@ -69,7 +70,7 @@ async function loadLedger(
<div class="table-wrap">
<table>
<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>
<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><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(page.items)}</tbody>
</table>
</div>
@@ -96,6 +97,7 @@ function ledgerRows(entries: LedgerEntry[]): string {
<td class="mono">${escapeHtml(entry.userId)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td>${renderUsageTypeBadge(entry.usageType)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
+3 -1
View File
@@ -11,6 +11,7 @@ import {
renderEmpty,
renderError,
renderLoading,
renderUsageTypeBadge,
setButtonBusy,
showToast,
} from "../components/ui";
@@ -255,7 +256,7 @@ function detailTemplate(
<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>
<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-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table>
</div>
@@ -281,6 +282,7 @@ function ledgerRows(entries: LedgerEntry[]): string {
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(statusLabel(entry.type))}</td>
<td>${renderUsageTypeBadge(entry.usageType)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
+46
View File
@@ -938,6 +938,31 @@ td strong {
background: #eaecf0;
}
.usage-badge--polish {
color: #6941c6;
background: #f4f3ff;
}
.usage-badge--asr {
color: #026aa2;
background: #f0f9ff;
}
.usage-badge--ai {
color: #175cd3;
background: #eff8ff;
}
.usage-badge--agent {
color: #067647;
background: #ecfdf3;
}
.usage-badge--hotword {
color: #b54708;
background: #fffaeb;
}
.search-form {
display: flex;
max-width: 760px;
@@ -1302,6 +1327,27 @@ code {
background: var(--surface-muted);
}
.usage-badge--polish {
color: #bf5af2;
background: rgb(191 90 242 / 14%);
}
.usage-badge--asr,
.usage-badge--ai {
color: #64d2ff;
background: rgb(100 210 255 / 14%);
}
.usage-badge--agent {
color: #30d158;
background: rgb(48 209 88 / 14%);
}
.usage-badge--hotword {
color: #ffd60a;
background: rgb(255 214 10 / 14%);
}
.rank--1 {
color: #ffd60a;
background: rgb(255 214 10 / 14%);
+7
View File
@@ -4,6 +4,7 @@ import {
formatDateTime,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../lib/format";
describe("format helpers", () => {
@@ -26,4 +27,10 @@ describe("format helpers", () => {
it("未知状态保持原值", () => {
expect(statusLabel("custom")).toBe("custom");
});
it("消费类型使用清晰的中文标签", () => {
expect(usageTypeLabel("polish")).toBe("润色");
expect(usageTypeLabel("hotword")).toBe("热词");
expect(usageTypeLabel()).toBe("—");
});
});
+4
View File
@@ -116,6 +116,7 @@ describe("用户页", () => {
amount: -18,
balanceAfter: 82,
reasonCode: "USAGE_SETTLE",
usageType: "asr",
createdAt: "2026-08-02T08:00:00Z",
},
],
@@ -143,6 +144,7 @@ describe("用户页", () => {
);
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.textContent).toContain("ASR");
expect(container.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载",
@@ -162,6 +164,7 @@ describe("积分流水页", () => {
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
usageType: "hotword",
createdAt: "2026-08-19T09:00:00Z",
},
],
@@ -189,6 +192,7 @@ describe("积分流水页", () => {
});
expect(container.textContent).toContain(userId);
expect(container.textContent).toContain("USAGE_SETTLE");
expect(container.textContent).toContain("热词");
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {