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 = () => {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 按钮 */}
|
||||||
|
|||||||
@ -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,36 +227,111 @@ 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 (
|
||||||
</td>
|
<React.Fragment key={key}>
|
||||||
<td className="px-4 py-2 text-right text-white" data-alt="cell-total-consumption">
|
<tr>
|
||||||
{it?.total_consumption ?? "-"}
|
<td className="px-4 py-2 text-white/90" data-alt="cell-transaction-type">
|
||||||
</td>
|
<div data-alt="type-cell" className="flex items-center gap-2">
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user