forked from 77media/video-flow
374 lines
18 KiB
TypeScript
374 lines
18 KiB
TypeScript
"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 p-4 sm:p-6">
|
||
<div data-alt="header" className="mb-6 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="mb-3 text-xs sm:text-sm text-white/70">
|
||
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
|
||
</div>
|
||
|
||
<div data-alt="table-wrapper" className="overflow-hidden overflow-x-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="mt-4 flex items-center justify-between text-xs sm:text-sm text-white/80">
|
||
<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);
|
||
|
||
|