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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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("—");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user