feat(billing): UsageRecords dual-track ledger view
api.ts 新增 userApi.getBillingLedger / getBillingSummary 及 Billing* 类型; UsageRecords.tsx 增加 USD/积分 双轨切换 tab、积分汇总卡与明细表(切到积分轨时懒加载)。 随文件一并提交此前累积的支付/Comfy Cloud/工作流前端 API 改动。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -539,6 +539,22 @@ describe('user recharge api', () => {
|
||||
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('sanitizes upstream HTML errors from recharge checkout', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(
|
||||
'<!DOCTYPE html><!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US">',
|
||||
{ status: 502, headers: { 'content-type': 'text/html' } },
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(userApi.createRechargeCheckout({
|
||||
optionId: 'usd-10',
|
||||
amountUsd: 10,
|
||||
})).rejects.toMatchObject({
|
||||
status: 502,
|
||||
message: 'HTTP 502: 上游返回 HTML 错误页,请检查支付/上游 Token 配置',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps billing guidance on quota errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
error: { message: '下游密钥成本额度已用完' },
|
||||
@@ -561,4 +577,29 @@ describe('user recharge api', () => {
|
||||
retryAfterSecs: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches billing ledger with track/entryType filters and summary', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
records: [
|
||||
{ track: 'points', entryType: 'consume', amount: -10, balanceAfter: 490 },
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
summary: [
|
||||
{ track: 'points', entryType: 'consume', total: -10, count: 1 },
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const ledger = await userApi.getBillingLedger({ track: 'points', entryType: 'consume', limit: 50 });
|
||||
const summary = await userApi.getBillingSummary();
|
||||
|
||||
expect(ledger.records[0].entryType).toBe('consume');
|
||||
expect(summary.summary[0].count).toBe(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/billing/ledger?track=points&entryType=consume&limit=50');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/billing/summary');
|
||||
});
|
||||
});
|
||||
|
||||
+397
-2
@@ -9,6 +9,7 @@ const nodeBuffer = (globalThis as typeof globalThis & { Buffer?: BufferLike })
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
preserveAuthErrors?: boolean;
|
||||
};
|
||||
|
||||
export type ApiRequestError = Error & {
|
||||
@@ -44,6 +45,18 @@ function requireAuthToken(): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
function sanitizeErrorText(status: number, text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const looksLikeHtml = trimmed.includes("<!DOCTYPE")
|
||||
|| trimmed.includes("<html")
|
||||
|| trimmed.includes("</html>")
|
||||
|| trimmed.includes("<!--[if");
|
||||
if (looksLikeHtml) {
|
||||
return `HTTP ${status}: 上游返回 HTML 错误页,请检查支付/上游 Token 配置`;
|
||||
}
|
||||
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
|
||||
}
|
||||
|
||||
async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||
let message = `HTTP ${res.status}`;
|
||||
let payload: any = null;
|
||||
@@ -69,7 +82,7 @@ async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||
message = `${message} (${json.path.trim()})`;
|
||||
}
|
||||
} catch {
|
||||
message = `${message}: ${text.slice(0, 120)}`;
|
||||
message = sanitizeErrorText(res.status, text);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -136,6 +149,7 @@ async function fetchAuthenticatedResponse(
|
||||
const {
|
||||
timeoutMs = 30_000,
|
||||
signal: externalSignal,
|
||||
preserveAuthErrors = false,
|
||||
...fetchOptions
|
||||
} = options;
|
||||
const controller = new AbortController();
|
||||
@@ -168,7 +182,7 @@ async function fetchAuthenticatedResponse(
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (!preserveAuthErrors && (res.status === 401 || res.status === 403)) {
|
||||
const hadToken = !!getSessionToken('admin', localStorage);
|
||||
clearSession('admin', localStorage);
|
||||
if (
|
||||
@@ -205,6 +219,7 @@ async function fetchUserResponse(
|
||||
const {
|
||||
timeoutMs = 30_000,
|
||||
signal: externalSignal,
|
||||
preserveAuthErrors: _preserveAuthErrors = false,
|
||||
...fetchOptions
|
||||
} = options;
|
||||
const controller = new AbortController();
|
||||
@@ -336,6 +351,65 @@ async function request<T = any>(
|
||||
return parseJsonResponse<T>(res);
|
||||
}
|
||||
|
||||
async function publicRequest<T = any>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
timeoutMs = 30_000,
|
||||
signal: externalSignal,
|
||||
preserveAuthErrors: _preserveAuthErrors = false,
|
||||
...fetchOptions
|
||||
} = options;
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
let cleanupExternalSignal = () => {};
|
||||
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
const abortHandler = () => controller.abort();
|
||||
externalSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
cleanupExternalSignal = () =>
|
||||
externalSignal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers(fetchOptions.headers ?? {});
|
||||
if (shouldSetJsonContentType(fetchOptions.body) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw await extractResponseError(res);
|
||||
}
|
||||
return parseJsonResponse<T>(res);
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
if (externalSignal?.aborted) throw error;
|
||||
throw new Error(
|
||||
`请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
cleanupExternalSignal();
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
for (const key of keys) {
|
||||
@@ -889,6 +963,7 @@ export type RuntimeSettingsPayload = {
|
||||
systemProxyUrl?: string;
|
||||
payloadRules?: Record<string, unknown> | null;
|
||||
modelAvailabilityProbeEnabled?: boolean;
|
||||
registrationDisabled?: boolean;
|
||||
codexUpstreamWebsocketEnabled?: boolean;
|
||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||
disableCrossProtocolFallback?: boolean;
|
||||
@@ -1422,6 +1497,18 @@ export type DigitalHumanStatusResponse = {
|
||||
mode: string;
|
||||
keyName?: string;
|
||||
capabilities?: string[];
|
||||
pixelle?: {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
backendProvider?: string;
|
||||
backendOptions?: string[];
|
||||
baseUrl?: string;
|
||||
frameTemplate?: string;
|
||||
mediaWorkflow?: string;
|
||||
ttsWorkflow?: string;
|
||||
runninghubMediaWorkflow?: string;
|
||||
runninghubTtsWorkflow?: string;
|
||||
};
|
||||
runninghub?: {
|
||||
configured?: boolean;
|
||||
endpoint?: string;
|
||||
@@ -1448,6 +1535,8 @@ export type MoneyPrinterJobsResponse = {
|
||||
export type MoneyPrinterStatusResponse = {
|
||||
success: boolean;
|
||||
mode: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
keyName?: string;
|
||||
capabilities?: string[];
|
||||
comfy?: {
|
||||
@@ -1548,6 +1637,106 @@ export type ClearUserRechargeRecordsResponse = {
|
||||
rechargePath?: string;
|
||||
};
|
||||
|
||||
export type BillingLedgerEntry = {
|
||||
track: string;
|
||||
entryType: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
model?: string | null;
|
||||
operator?: string | null;
|
||||
refType?: string | null;
|
||||
note?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type BillingLedgerResponse = {
|
||||
success: boolean;
|
||||
records: BillingLedgerEntry[];
|
||||
};
|
||||
|
||||
export type BillingSummaryRow = {
|
||||
track: string;
|
||||
entryType: string;
|
||||
total: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type BillingSummaryResponse = {
|
||||
success: boolean;
|
||||
summary: BillingSummaryRow[];
|
||||
};
|
||||
|
||||
export type PaymentPublicConfig = {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
currency: string;
|
||||
minAmountUsd: number;
|
||||
maxAmountUsd?: number | null;
|
||||
orderTimeoutMinutes: number;
|
||||
maxPendingOrders: number;
|
||||
};
|
||||
|
||||
export type PaymentConfigResponse = {
|
||||
success: boolean;
|
||||
payment: PaymentPublicConfig;
|
||||
comfyCloud?: {
|
||||
balanceDiagnosticsPath?: string;
|
||||
checkout?: {
|
||||
dynamicEndpointConfigured?: boolean;
|
||||
creditEndpointConfigured?: boolean;
|
||||
customerTokenConfigured?: boolean;
|
||||
customerTokenProviderConfigured?: boolean;
|
||||
customerTokenSource?: string;
|
||||
customerTokenEndpoint?: string;
|
||||
firebaseRefreshConfigured?: boolean;
|
||||
firebaseApiKeyConfigured?: boolean;
|
||||
googleOAuthClientConfigured?: boolean;
|
||||
checkoutEndpointSource?: string;
|
||||
checkoutEndpoint?: string;
|
||||
firebaseLogin?: {
|
||||
configured?: boolean;
|
||||
cacheRemainingSecs?: number | null;
|
||||
lastRefreshOk?: boolean | null;
|
||||
invalidGrant?: boolean;
|
||||
lastRefreshDetail?: string | null;
|
||||
lastRefreshAtUnix?: number | null;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
webhooks?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ComfyCloudBalance = {
|
||||
amountMicros?: number;
|
||||
effectiveBalanceMicros?: number;
|
||||
prepaidBalanceMicros?: number;
|
||||
cloudCreditBalanceMicros?: number;
|
||||
pendingChargesMicros?: number;
|
||||
currency?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ComfyCloudDiagnostics = {
|
||||
apiBaseUrl?: string;
|
||||
tokenSource?: string;
|
||||
tokenKind?: string;
|
||||
jwtIssuer?: string | null;
|
||||
jwtAudience?: string | null;
|
||||
jwtSubjectPresent?: boolean;
|
||||
httpStatus?: number | null;
|
||||
upstreamMessage?: string | null;
|
||||
success?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AdminComfyCloudBalanceResponse = {
|
||||
success: boolean;
|
||||
provider?: string;
|
||||
balance?: ComfyCloudBalance | null;
|
||||
};
|
||||
|
||||
export type WorkflowModelItem = {
|
||||
name: string;
|
||||
pathIndex?: number;
|
||||
@@ -1716,6 +1905,176 @@ export const api = {
|
||||
timeoutMs: data?.wait ? 150_000 : 30_000,
|
||||
}),
|
||||
|
||||
// Payment
|
||||
getPaymentConfig: () =>
|
||||
publicRequest<PaymentConfigResponse>("/api/payment/config"),
|
||||
getComfyCloudBalance: () =>
|
||||
request<AdminComfyCloudBalanceResponse>("/api/admin/payment/comfy-cloud/balance", {
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 45_000,
|
||||
}),
|
||||
getComfyCloudDiagnostics: () =>
|
||||
request<{ success: boolean; provider: string; diagnostics: ComfyCloudDiagnostics }>(
|
||||
"/api/admin/payment/comfy-cloud/diagnostics",
|
||||
{
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
),
|
||||
updateComfyCloudPaymentSettings: (data: {
|
||||
customerToken?: string;
|
||||
clearCustomerToken?: boolean;
|
||||
customerTokenEndpoint?: string;
|
||||
firebaseApiKey?: string;
|
||||
googleOAuthClientId?: string;
|
||||
googleOAuthClientSecret?: string;
|
||||
firebaseRefreshToken?: string;
|
||||
checkoutEndpoint?: string;
|
||||
}) =>
|
||||
request<PaymentConfigResponse>("/api/admin/payment/comfy-cloud/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data || {}),
|
||||
preserveAuthErrors: true,
|
||||
}),
|
||||
startComfyCloudOAuth: () =>
|
||||
request<{
|
||||
success: boolean;
|
||||
oauth?: {
|
||||
state?: string;
|
||||
authorizationUrl?: string;
|
||||
redirectUri?: string;
|
||||
};
|
||||
}>("/api/admin/payment/comfy-cloud/oauth/start", {
|
||||
method: "POST",
|
||||
preserveAuthErrors: true,
|
||||
}),
|
||||
adminComfyCloudRecharge: (amountUsd: number) =>
|
||||
request<{
|
||||
success: boolean;
|
||||
checkout: { url: string; id?: string | null };
|
||||
amountUsd: number;
|
||||
}>("/api/admin/payment/comfy-cloud/recharge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ amountUsd }),
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 60_000,
|
||||
}),
|
||||
adminListRechargeRecords: (limit = 100) =>
|
||||
request<{
|
||||
success: boolean;
|
||||
records: Array<{
|
||||
publicId: string;
|
||||
userId: number;
|
||||
userEmail?: string | null;
|
||||
userName?: string | null;
|
||||
amountUsd?: number | null;
|
||||
provider: string;
|
||||
status: string;
|
||||
label?: string | null;
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
createdAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
}>;
|
||||
}>(`/api/admin/payment/recharge-records?limit=${limit}`, {
|
||||
preserveAuthErrors: true,
|
||||
}),
|
||||
adminConfirmRecharge: (publicId: string) =>
|
||||
request<{ success: boolean; record: any }>(
|
||||
`/api/admin/payment/recharge-records/${encodeURIComponent(publicId)}/confirm`,
|
||||
{ method: "POST", preserveAuthErrors: true },
|
||||
),
|
||||
adminCancelRecharge: (publicId: string) =>
|
||||
request<{ success: boolean; record: any }>(
|
||||
`/api/admin/payment/recharge-records/${encodeURIComponent(publicId)}/cancel`,
|
||||
{ method: "POST", preserveAuthErrors: true },
|
||||
),
|
||||
adminClearRechargeRecords: () =>
|
||||
request<{ success: boolean; cleared: number }>("/api/admin/payment/recharge-records", {
|
||||
method: "DELETE",
|
||||
preserveAuthErrors: true,
|
||||
}),
|
||||
|
||||
// Pixelle WebUI parity (digital human three-column page)
|
||||
pixelle: {
|
||||
listTtsWorkflows: () =>
|
||||
request<{ workflows: Array<{ name: string; display_name?: string; source?: string; path?: string; key?: string }> }>(
|
||||
"/api/digital-human/pixelle/workflows/tts",
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
listMediaWorkflows: () =>
|
||||
request<{ workflows: Array<{ name: string; display_name?: string; source?: string; path?: string; key?: string }> }>(
|
||||
"/api/digital-human/pixelle/workflows/media",
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
listTemplates: () =>
|
||||
request<{ templates: Array<{ name: string; display_name?: string; size?: string; width?: number; height?: number; orientation?: string; path?: string; key?: string }> }>(
|
||||
"/api/digital-human/pixelle/templates",
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
listBgm: () =>
|
||||
request<{ bgm_files: Array<{ name: string; path: string; source?: string }> }>(
|
||||
"/api/digital-human/pixelle/bgm",
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
getTemplateParams: (template: string) =>
|
||||
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
||||
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
previewImage: (data: { prompt: string; width?: number; height?: number; workflow?: string }) =>
|
||||
request<{ image_path: string }>(
|
||||
"/api/digital-human/pixelle/preview-image",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 180_000,
|
||||
},
|
||||
),
|
||||
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
||||
request<{ audio_path: string; duration?: number }>(
|
||||
"/api/digital-human/pixelle/preview-tts",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
),
|
||||
generateAsync: (data: Record<string, unknown>) =>
|
||||
request<{ task_id: string }>(
|
||||
"/api/digital-human/pixelle/generate",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
preserveAuthErrors: true,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
),
|
||||
getTask: (taskId: string) =>
|
||||
request<{
|
||||
task_id?: string;
|
||||
id?: string;
|
||||
status?: string;
|
||||
progress?: number | string;
|
||||
message?: string;
|
||||
result?: any;
|
||||
video_url?: string;
|
||||
duration?: number;
|
||||
file_size?: number;
|
||||
error?: string;
|
||||
}>(
|
||||
`/api/digital-human/pixelle/tasks/${encodeURIComponent(taskId)}`,
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
fileUrl: (path: string) => {
|
||||
const trimmed = String(path || "").replace(/^\/+/, "");
|
||||
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
|
||||
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
|
||||
},
|
||||
},
|
||||
|
||||
// Account tokens
|
||||
getAccountTokens: async (accountId?: number) => {
|
||||
const result = await request<any>(
|
||||
@@ -2412,6 +2771,33 @@ export const api = {
|
||||
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
||||
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
||||
getUserUsage: (id: number) => request(`/api/admin/users/${id}/usage`),
|
||||
listUserBalances: (params?: { page?: number; pageSize?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.page) sp.set("page", String(params.page));
|
||||
if (params?.pageSize) sp.set("page_size", String(params.pageSize));
|
||||
if (params?.q) sp.set("q", params.q);
|
||||
const qs = sp.toString();
|
||||
return request<{
|
||||
data: Array<{
|
||||
userId: number;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
status?: string | null;
|
||||
keyCount: number;
|
||||
usedCost: number;
|
||||
maxCost: number | null;
|
||||
remainingCost: number | null;
|
||||
unlimitedKeyCount: number;
|
||||
}>;
|
||||
meta: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
}>(`/api/admin/user-balances${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
adjustUserBalance: (id: number, delta: number) =>
|
||||
request<{ success: boolean; data: { userId: number; keyId: number; maxCost: number; usedCost: number; remainingCost: number } }>(
|
||||
`/api/admin/users/${id}/balance/adjust`,
|
||||
{ method: "POST", body: JSON.stringify({ delta }) },
|
||||
),
|
||||
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
|
||||
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
||||
@@ -2545,6 +2931,15 @@ export const userApi = {
|
||||
},
|
||||
getAnnouncements: (params?: string) =>
|
||||
userRequest(`/api/user/announcements${params ? "?" + params : ""}`),
|
||||
getBillingLedger: (params?: {
|
||||
track?: string;
|
||||
entryType?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
userRequest<BillingLedgerResponse>(`/api/user/billing/ledger${buildQueryString(params)}`),
|
||||
getBillingSummary: () =>
|
||||
userRequest<BillingSummaryResponse>("/api/user/billing/summary"),
|
||||
markAnnouncementRead: (id: number) =>
|
||||
userRequest(`/api/user/announcements/${id}/read`, { method: "POST" }),
|
||||
markAllAnnouncementsRead: () =>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { MODEL_TESTER_STORAGE_KEY } from './helpers/modelTesterSession.js';
|
||||
const { userApiMock, toastMock } = vi.hoisted(() => ({
|
||||
userApiMock: {
|
||||
getLogs: vi.fn(),
|
||||
getBillingLedger: vi.fn(),
|
||||
getBillingSummary: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
@@ -503,4 +505,58 @@ describe('UsageRecords diagnostics', () => {
|
||||
|
||||
expect(collectText(root.root.findByProps({ 'data-testid': 'location-probe' }))).toBe('/user/usage');
|
||||
});
|
||||
|
||||
it('switches to the points track and loads ledger rows', async () => {
|
||||
userApiMock.getBillingSummary.mockResolvedValue({
|
||||
success: true,
|
||||
summary: [
|
||||
{ track: 'points', entryType: 'recharge', total: 500, count: 1 },
|
||||
{ track: 'points', entryType: 'consume', total: -10, count: 2 },
|
||||
],
|
||||
});
|
||||
userApiMock.getBillingLedger.mockResolvedValue({
|
||||
success: true,
|
||||
records: [
|
||||
{
|
||||
track: 'points',
|
||||
entryType: 'consume',
|
||||
amount: -10,
|
||||
balanceAfter: 490,
|
||||
model: 'flux',
|
||||
createdAt: '2026-06-16T08:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root!: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/usage']}>
|
||||
<UsageRecords />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// default USD track: billing endpoints not called yet
|
||||
expect(userApiMock.getBillingLedger).not.toHaveBeenCalled();
|
||||
|
||||
const pointsTab = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['data-track'] === 'points'
|
||||
));
|
||||
await act(async () => {
|
||||
pointsTab.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(userApiMock.getBillingLedger).toHaveBeenCalledWith({
|
||||
track: 'points',
|
||||
limit: 100,
|
||||
});
|
||||
expect(userApiMock.getBillingSummary).toHaveBeenCalled();
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('flux');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { userApi } from '../api.js';
|
||||
import type { BillingLedgerEntry, BillingSummaryRow } from '../api.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import PageHeader from '../components/PageHeader.js';
|
||||
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||||
@@ -279,6 +280,18 @@ function renderContentDetail(log: LogItem) {
|
||||
);
|
||||
}
|
||||
|
||||
const LEDGER_ENTRY_TYPE_LABELS: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
admin_adjust: '管理员调整',
|
||||
refund: '退款',
|
||||
grant: '赠送',
|
||||
};
|
||||
|
||||
function ledgerEntryTypeLabel(entryType: string): string {
|
||||
return LEDGER_ENTRY_TYPE_LABELS[entryType] || entryType;
|
||||
}
|
||||
|
||||
export default function UsageRecords() {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
@@ -298,6 +311,10 @@ export default function UsageRecords() {
|
||||
const [dateTo, setDateTo] = useState(initialFilters.dateTo);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [track, setTrack] = useState<'usd' | 'points'>('usd');
|
||||
const [ledger, setLedger] = useState<BillingLedgerEntry[]>([]);
|
||||
const [ledgerSummary, setLedgerSummary] = useState<BillingSummaryRow[]>([]);
|
||||
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -324,6 +341,28 @@ export default function UsageRecords() {
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const loadLedger = useCallback(async () => {
|
||||
setLedgerLoading(true);
|
||||
try {
|
||||
const [ledgerRes, summaryRes] = await Promise.all([
|
||||
userApi.getBillingLedger({ track: 'points', limit: 100 }),
|
||||
userApi.getBillingSummary(),
|
||||
]);
|
||||
setLedger(ledgerRes.records || []);
|
||||
setLedgerSummary(summaryRes.summary || []);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '加载积分明细失败');
|
||||
} finally {
|
||||
setLedgerLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (track === 'points') {
|
||||
loadLedger();
|
||||
}
|
||||
}, [track, loadLedger]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = readUserUsageFiltersFromSearch(location.search);
|
||||
setPage((value) => (value === next.page ? value : next.page));
|
||||
@@ -676,6 +715,89 @@ export default function UsageRecords() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="usage-records-track-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-track="usd"
|
||||
aria-selected={track === 'usd'}
|
||||
className={`btn btn-sm ${track === 'usd' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setTrack('usd')}
|
||||
>
|
||||
USD 调用日志
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-track="points"
|
||||
aria-selected={track === 'points'}
|
||||
className={`btn btn-sm ${track === 'points' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setTrack('points')}
|
||||
>
|
||||
积分明细
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{track === 'points' ? (
|
||||
<>
|
||||
<div className="usage-records-summary-grid">
|
||||
{ledgerSummary.length === 0 ? (
|
||||
<div className="card usage-records-summary-card">
|
||||
<div>积分汇总</div>
|
||||
<strong>{ledgerLoading ? '加载中' : '暂无'}</strong>
|
||||
</div>
|
||||
) : (
|
||||
ledgerSummary.map((row) => (
|
||||
<div key={`${row.track}-${row.entryType}`} className="card usage-records-summary-card">
|
||||
<div>{ledgerEntryTypeLabel(row.entryType)}</div>
|
||||
<strong style={{ color: row.total < 0 ? 'var(--color-danger)' : 'var(--color-success)' }}>
|
||||
{row.total > 0 ? '+' : ''}{row.total.toLocaleString()}
|
||||
</strong>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{row.count} 笔</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card usage-records-list-card">
|
||||
{ledgerLoading ? (
|
||||
<div className="usage-records-empty">
|
||||
<div className="spinner" style={{ margin: '0 auto' }} />
|
||||
</div>
|
||||
) : ledger.length === 0 ? (
|
||||
<div className="usage-records-empty">暂无积分记录</div>
|
||||
) : (
|
||||
<div className="usage-records-table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>类型</th>
|
||||
<th>模型</th>
|
||||
<th>变动</th>
|
||||
<th>余额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ledger.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{row.createdAt ? formatDateTimeLocal(row.createdAt) : '-'}</td>
|
||||
<td>{ledgerEntryTypeLabel(row.entryType)}</td>
|
||||
<td>{row.model || '-'}</td>
|
||||
<td style={{ color: row.amount < 0 ? 'var(--color-danger)' : 'var(--color-success)' }}>
|
||||
{row.amount > 0 ? '+' : ''}{row.amount.toLocaleString()}
|
||||
</td>
|
||||
<td>{row.balanceAfter.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="usage-records-summary-grid">
|
||||
{[
|
||||
@@ -863,6 +985,8 @@ export default function UsageRecords() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user