更新 增加使用详情

This commit is contained in:
moux1024 2025-09-16 15:19:19 +08:00
parent 7230a7e061
commit 177a5c18ab
3 changed files with 179 additions and 30 deletions

View File

@ -5,7 +5,7 @@ import UsageView from "@/components/pages/usage-view";
const UsagePage: React.FC = () => {
return (
<div data-alt="usage-page" className="min-h-screen px-4 py-6">
<div data-alt="usage-page" className="h-screen overflow-auto px-4 py-6">
<UsageView />
</div>
);

View File

@ -407,7 +407,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
? "Loading..."
: `${credits} credits`}
</span>
{/* <button
<button
type="button"
onClick={() => window.open("/usage", "_blank")}
className="ml-1 inline-flex items-center justify-center h-6 w-6 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
@ -415,7 +415,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
title="Usage"
>
<Info className="h-4 w-4 text-white" />
</button> */}
</button>
</div>
{/* Purchase Credits 按钮 */}

View File

@ -4,15 +4,42 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { post } from "@/api/request";
/** 接口请求与返回类型定义 */
interface ConsumptionItem {
/** 项目名称 */
project_name: string;
/** 总消耗(字符串数字) */
total_consumption: string;
type ChangeType = 'INCREASE' | 'DECREASE';
type TransactionType = 'RECHARGE' | 'CONSUME' | 'REFUND' | 'GIFT';
interface ProjectVideoItem {
video_name?: string;
video_url: string;
amount: number;
transaction_type: TransactionType;
change_type: ChangeType;
source_type: string;
created_at: string;
}
interface ConsumptionResponseData {
consumption_list: ConsumptionItem[];
interface ProjectInfo {
project_id: string;
project_name: string;
videos: ProjectVideoItem[];
}
interface TransactionItem {
/** 交易类型 */
transaction_type: TransactionType;
/** 增减类型 */
change_type: ChangeType;
/** 变动数量 */
amount: number;
/** 来源类型 */
source_type: string;
/** 项目信息,存在时支持展开展示 */
project_info: ProjectInfo | null;
/** 创建时间 */
created_at: string;
}
interface TransactionResponseData {
transaction_records: TransactionItem[];
page: number;
page_size: number;
total: number;
@ -42,9 +69,10 @@ const UsageView: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [items, setItems] = useState<ConsumptionItem[]>([]);
const [items, setItems] = useState<TransactionItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [serverPeriodDays, setServerPeriodDays] = useState<PeriodDays>(90);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
/** 读取 user_id参考 dashboard/page.tsx */
const userId = useMemo(() => {
@ -80,12 +108,12 @@ const UsageView: React.FC = () => {
page_size: pageSize,
days,
};
const res = await post<ApiResponse<ConsumptionResponseData>>(
const res = await post<ApiResponse<TransactionResponseData>>(
"/api/token/consumption",
body
);
if (res && res.successful && res.code === 0) {
setItems(res.data?.consumption_list || []);
setItems(res.data?.transaction_records || []);
setTotal(res.data?.total || 0);
setServerPeriodDays(res.data?.period_days || days);
} else {
@ -121,6 +149,52 @@ const UsageView: React.FC = () => {
const periodLabel = useMemo(() => serverPeriodDays || days, [serverPeriodDays, days]);
const toggleExpand = useCallback((key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const dateFormatter = useMemo(() => {
try {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
return new Intl.DateTimeFormat();
}
}, []);
const getAmountColorClass = useCallback((change: ChangeType | undefined) => {
if (change === 'INCREASE') return 'text-green-400';
if (change === 'DECREASE') return 'text-rose-400';
return 'text-white';
}, []);
const formatSource = useCallback((source: string | undefined) => {
if (!source) return '-';
const map: Record<string, string> = {
video_generation: 'Video Generation',
manual_admin: 'Manual (Admin)',
subscription: 'Subscription',
purchase_recharge: 'Purchase',
refund: 'Refund',
};
return map[source] || source.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
}, []);
return (
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6">
<div data-alt="header" className="mb-6 flex items-center justify-between">
@ -153,36 +227,111 @@ const UsageView: React.FC = () => {
<table data-alt="table" className="min-w-full table-fixed">
<thead className="bg-white/5">
<tr>
<th className="w-2/3 px-4 py-2 text-left text-sm font-medium text-white">Project Name</th>
<th className="w-1/3 px-4 py-2 text-right text-sm font-medium text-white">Total Consumption</th>
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Kind</th>
<th className="w-1/4 px-4 py-2 text-right text-sm font-medium text-white">Credits</th>
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">From</th>
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td className="px-4 py-3 text-white/70" colSpan={2} data-alt="row-loading">Loading...</td>
<td className="px-4 py-3 text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
</tr>
) : error ? (
<tr>
<td className="px-4 py-3 text-white/70" data-alt="row-error">-</td>
<td className="px-4 py-3 text-right text-white/70">-</td>
<td className="px-4 py-3 text-white/70" data-alt="row-error" colSpan={4}>-</td>
</tr>
) : items.length === 0 ? (
<tr>
<td className="px-4 py-3 text-white/70" data-alt="row-empty">-</td>
<td className="px-4 py-3 text-right text-white/70">-</td>
<td className="px-4 py-3 text-white/70" data-alt="row-empty" colSpan={4}>-</td>
</tr>
) : (
items.map((it, idx) => (
<tr key={`${it.project_name}-${idx}`}>
<td className="truncate px-4 py-2 text-white/90" data-alt="cell-project-name">
{it?.project_name || "-"}
</td>
<td className="px-4 py-2 text-right text-white" data-alt="cell-total-consumption">
{it?.total_consumption ?? "-"}
</td>
</tr>
))
items.map((it, idx) => {
const key = `${it.created_at}-${idx}`;
const isExpanded = expandedKeys.has(key);
return (
<React.Fragment key={key}>
<tr>
<td className="px-4 py-2 text-white/90" data-alt="cell-transaction-type">
<div data-alt="type-cell" className="flex items-center gap-2">
<span data-alt="type-text">{it?.transaction_type || '-'}</span>
{it?.project_info ? (
<button
type="button"
onClick={() => toggleExpand(key)}
className={`rounded-md p-1.5 transition-colors ${isExpanded ? 'bg-white/20' : 'bg-white/10 hover:bg-white/20'} text-white`}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
data-alt="toggle-details"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : 'rotate-0'}`}
data-alt="chevron-icon"
>
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z" clipRule="evenodd" />
</svg>
</button>
) : null}
</div>
</td>
<td className={`px-4 py-2 text-right ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
{Number.isFinite(it?.amount as number)
? `${it.change_type === 'INCREASE' ? '+' : it.change_type === 'DECREASE' ? '-' : ''}${it.amount}`
: '-'}
</td>
<td className="px-4 py-2 text-white/90" data-alt="cell-source-type">
{formatSource(it?.source_type)}
</td>
<td className="px-4 py-2 text-white/90" data-alt="cell-created-at">
{it?.created_at ? dateFormatter.format(new Date(it.created_at)) : '-'}
</td>
</tr>
{isExpanded && it.project_info && (
<tr data-alt="row-details">
<td colSpan={4} className="bg-white/5 px-4 py-3">
<div data-alt="project-summary" className="mb-2 text-sm text-white/90">
<div data-alt="project-name">Project: {it.project_info.project_name || '-'}</div>
<div data-alt="project-id" className="text-white/60">ID: {it.project_info.project_id || '-'}</div>
</div>
<div data-alt="videos-table-wrapper" className="overflow-hidden rounded-md border border-white/10">
<table data-alt="videos-table" className="min-w-full table-fixed">
<tbody className="divide-y divide-white/10">
{(it.project_info.videos || []).length === 0 ? (
<tr>
<td className="px-3 py-2 text-white/70" colSpan={4} data-alt="videos-empty">-</td>
</tr>
) : (
it.project_info.videos.map((v, vIdx) => (
<tr key={`${v.created_at}-${vIdx}`}>
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-name">
{v.video_name && v.video_name.trim() ? v.video_name : `video${vIdx + 1}`}
</td>
<td className={`w-1/4 px-3 py-2 text-right ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
{Number.isFinite(v.amount as number)
? `${v.change_type === 'INCREASE' ? '+' : v.change_type === 'DECREASE' ? '-' : ''}${v.amount}`
: '-'}
</td>
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-source">
{formatSource(v.source_type)}
</td>
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-created-at">
{v.created_at ? dateFormatter.format(new Date(v.created_at)) : '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>