更新 增加使用详情

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 = () => { const UsagePage: React.FC = () => {
return ( 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 /> <UsageView />
</div> </div>
); );

View File

@ -407,7 +407,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
? "Loading..." ? "Loading..."
: `${credits} credits`} : `${credits} credits`}
</span> </span>
{/* <button <button
type="button" type="button"
onClick={() => window.open("/usage", "_blank")} 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" 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" title="Usage"
> >
<Info className="h-4 w-4 text-white" /> <Info className="h-4 w-4 text-white" />
</button> */} </button>
</div> </div>
{/* Purchase Credits 按钮 */} {/* Purchase Credits 按钮 */}

View File

@ -4,15 +4,42 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { post } from "@/api/request"; import { post } from "@/api/request";
/** 接口请求与返回类型定义 */ /** 接口请求与返回类型定义 */
interface ConsumptionItem { type ChangeType = 'INCREASE' | 'DECREASE';
/** 项目名称 */ type TransactionType = 'RECHARGE' | 'CONSUME' | 'REFUND' | 'GIFT';
project_name: string;
/** 总消耗(字符串数字) */ interface ProjectVideoItem {
total_consumption: string; video_name?: string;
video_url: string;
amount: number;
transaction_type: TransactionType;
change_type: ChangeType;
source_type: string;
created_at: string;
} }
interface ConsumptionResponseData { interface ProjectInfo {
consumption_list: ConsumptionItem[]; 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: number;
page_size: number; page_size: number;
total: number; total: number;
@ -42,9 +69,10 @@ const UsageView: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [items, setItems] = useState<ConsumptionItem[]>([]); const [items, setItems] = useState<TransactionItem[]>([]);
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
const [serverPeriodDays, setServerPeriodDays] = useState<PeriodDays>(90); const [serverPeriodDays, setServerPeriodDays] = useState<PeriodDays>(90);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
/** 读取 user_id参考 dashboard/page.tsx */ /** 读取 user_id参考 dashboard/page.tsx */
const userId = useMemo(() => { const userId = useMemo(() => {
@ -80,12 +108,12 @@ const UsageView: React.FC = () => {
page_size: pageSize, page_size: pageSize,
days, days,
}; };
const res = await post<ApiResponse<ConsumptionResponseData>>( const res = await post<ApiResponse<TransactionResponseData>>(
"/api/token/consumption", "/api/token/consumption",
body body
); );
if (res && res.successful && res.code === 0) { if (res && res.successful && res.code === 0) {
setItems(res.data?.consumption_list || []); setItems(res.data?.transaction_records || []);
setTotal(res.data?.total || 0); setTotal(res.data?.total || 0);
setServerPeriodDays(res.data?.period_days || days); setServerPeriodDays(res.data?.period_days || days);
} else { } else {
@ -121,6 +149,52 @@ const UsageView: React.FC = () => {
const periodLabel = useMemo(() => serverPeriodDays || days, [serverPeriodDays, days]); 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 ( return (
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6"> <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"> <div data-alt="header" className="mb-6 flex items-center justify-between">
@ -153,33 +227,98 @@ const UsageView: React.FC = () => {
<table data-alt="table" className="min-w-full table-fixed"> <table data-alt="table" className="min-w-full table-fixed">
<thead className="bg-white/5"> <thead className="bg-white/5">
<tr> <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/4 px-4 py-2 text-left text-sm font-medium text-white">Kind</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-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> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/10"> <tbody className="divide-y divide-white/10">
{loading ? ( {loading ? (
<tr> <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> </tr>
) : error ? ( ) : error ? (
<tr> <tr>
<td className="px-4 py-3 text-white/70" data-alt="row-error">-</td> <td className="px-4 py-3 text-white/70" data-alt="row-error" colSpan={4}>-</td>
<td className="px-4 py-3 text-right text-white/70">-</td>
</tr> </tr>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<tr> <tr>
<td className="px-4 py-3 text-white/70" data-alt="row-empty">-</td> <td className="px-4 py-3 text-white/70" data-alt="row-empty" colSpan={4}>-</td>
<td className="px-4 py-3 text-right text-white/70">-</td>
</tr> </tr>
) : ( ) : (
items.map((it, idx) => ( items.map((it, idx) => {
<tr key={`${it.project_name}-${idx}`}> const key = `${it.created_at}-${idx}`;
<td className="truncate px-4 py-2 text-white/90" data-alt="cell-project-name"> const isExpanded = expandedKeys.has(key);
{it?.project_name || "-"} 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>
<td className="px-4 py-2 text-right text-white" data-alt="cell-total-consumption"> <td className={`px-4 py-2 text-right ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
{it?.total_consumption ?? "-"} {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> </td>
</tr> </tr>
)) ))
@ -187,6 +326,16 @@ const UsageView: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
<div data-alt="pagination" className="mt-4 flex items-center justify-between text-sm text-white/80"> <div data-alt="pagination" className="mt-4 flex items-center justify-between text-sm text-white/80">
<div data-alt="total-info"> <div data-alt="total-info">