video-flow-b/components/pages/usage-view.tsx
2025-09-28 20:45:14 +08:00

375 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { post } from "@/api/request";
/** 接口请求与返回类型定义 */
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 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;
period_days: 7 | 30 | 90;
}
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
successful: boolean;
}
type PeriodDays = 7 | 30 | 90;
/**
* Credit 使用详情页
* - 周期切换7 / 30 / 90
* - 分页:上一页/下一页固定每页20
* - 表格展示项目名、总消耗不展示项目ID
* - 空/错误状态:使用 “-” 显示
*/
const UsageView: React.FC = () => {
const [days, setDays] = useState<PeriodDays>(7);
const [page, setPage] = useState<number>(1);
const pageSize = 20;
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
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(() => {
try {
const raw = localStorage.getItem("currentUser") || "{}";
const u = JSON.parse(raw);
return String(u?.id || "");
} catch {
return "";
}
}, []);
const canPrev = page > 1;
const canNext = useMemo(() => {
if (total <= 0) return false;
const maxPage = Math.ceil(total / pageSize);
return page < maxPage;
}, [page, total]);
const loadData = useCallback(async () => {
if (!userId) {
setItems([]);
setTotal(0);
setError("no-user");
return;
}
setLoading(true);
setError("");
try {
const body = {
user_id: userId,
page,
page_size: pageSize,
days,
};
const res = await post<ApiResponse<TransactionResponseData>>(
"/api/token/consumption",
body
);
if (res && res.successful && res.code === 0) {
setItems(res.data?.transaction_records || []);
setTotal(res.data?.total || 0);
setServerPeriodDays(res.data?.period_days || days);
} else {
setItems([]);
setTotal(0);
setError("api-error");
}
} catch {
setItems([]);
setTotal(0);
setError("api-error");
} finally {
setLoading(false);
}
}, [userId, page, pageSize, days]);
useEffect(() => {
loadData();
}, [loadData]);
const handleChangeDays = useCallback((d: PeriodDays) => {
setDays(d);
setPage(1);
}, []);
const handlePrev = useCallback(() => {
if (canPrev) setPage((p) => p - 1);
}, [canPrev]);
const handleNext = useCallback(() => {
if (canNext) setPage((p) => p + 1);
}, [canNext]);
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(navigator.languages[0], {
dateStyle: 'medium',
timeStyle: 'medium',
});
} 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 h-full flex flex-col p-4 sm:p-6 pb-[max(5rem,env(safe-area-inset-bottom))]">
<div data-alt="top-meta" className="sticky top-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 pt-2 pb-3 bg-black/60 backdrop-blur supports-[backdrop-filter]:bg-black/40">
<div data-alt="header" className="mb-3 flex items-center justify-between">
<h2 className="text-base sm:text-xl font-semibold text-white">Credit Usage Details</h2>
<div data-alt="period-switch" className="inline-flex rounded-lg bg-white/5 p-1">
{([7, 30, 90] as PeriodDays[]).map((d) => (
<button
key={d}
type="button"
data-alt={`period-${d}`}
onClick={() => handleChangeDays(d)}
className={
`px-2 py-1 text-xs sm:px-3 sm:py-1.5 sm:text-sm rounded-md transition-colors ` +
(days === d
? "bg-[#C039F6] text-white"
: "text-white/80 hover:bg-white/10")
}
>
{d}d
</button>
))}
</div>
</div>
<div data-alt="meta" className="text-xs sm:text-sm text-white/70">
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
</div>
</div>
<div data-alt="table-wrapper" className="flex-1 min-h-0 overflow-auto rounded-lg border border-white/10">
<table data-alt="table" className="min-w-[32rem] sm:min-w-full table-auto">
<thead className="bg-white/5">
<tr>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Kind</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Credits</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">From</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
</tr>
) : error ? (
<tr>
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-error" colSpan={4}>-</td>
</tr>
) : items.length === 0 ? (
<tr>
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-empty" colSpan={4}>-</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-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm 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-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm ${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-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-source-type">
{formatSource(it?.source_type)}
</td>
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm 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-3 py-2 sm:px-4 sm:py-3">
<div data-alt="project-summary" className="mb-2 text-xs sm: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-[28rem] sm:min-w-full table-auto">
<tbody className="divide-y divide-white/10">
{(it.project_info.videos || []).length === 0 ? (
<tr>
<td className="px-3 py-2 text-xs sm:text-sm 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="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-name">
{v.video_name && v.video_name.trim() ? v.video_name : `video${vIdx + 1}`}
</td>
<td className={`px-2 py-1 text-left text-xs sm:px-3 sm:py-2 sm:text-sm ${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="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-source">
{formatSource(v.source_type)}
</td>
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm 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>
</div>
<div data-alt="pagination" className="sticky bottom-[max(1rem,env(safe-area-inset-bottom))] z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 mt-4 mb-[max(4rem,env(safe-area-inset-bottom))] flex items-center justify-between text-xs sm:text-sm text-white/80 bg-black/60 backdrop-blur supports-[backdrop-filter]:bg-black/40">
<div data-alt="total-info">
Total {Number.isFinite(total) ? total : 0}
</div>
<div data-alt="pager" className="inline-flex gap-2">
<button
type="button"
onClick={handlePrev}
disabled={!canPrev}
className={
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
(canPrev ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
}
data-alt="prev-page"
>
Previous
</button>
<span data-alt="page-indicator" className="px-1 py-1 sm:py-1.5">Page {page}</span>
<button
type="button"
onClick={handleNext}
disabled={!canNext}
className={
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
(canNext ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
}
data-alt="next-page"
>
Next
</button>
</div>
</div>
</div>
);
};
export default React.memo(UsageView);