forked from 77media/video-flow
更新 增加使用详情
This commit is contained in:
parent
7230a7e061
commit
177a5c18ab
@ -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>
|
||||
);
|
||||
|
||||
@ -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 按钮 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user