mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 05:31:47 +00:00
# Conflicts: # src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js # src/app/(dashboard)/dashboard/cli-tools/components/index.js # src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js # src/app/api/cli-tools/all-statuses/route.js # src/app/api/settings/route.js # src/app/api/v1/models/route.js # src/shared/constants/cliTools.js
281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { formatResetTime, getRemainingPercentage } from "./utils";
|
|
import { formatVietnamDateTime, formatVietnamTime, isSameVietnamDay } from "@/shared/utils/dateTime";
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
/**
|
|
* Format reset time display (Today, 12:00 PM)
|
|
*/
|
|
function formatResetTimeDisplay(resetTime) {
|
|
if (!resetTime) return null;
|
|
|
|
try {
|
|
const date = new Date(resetTime);
|
|
if (!Number.isFinite(date.getTime())) return null;
|
|
|
|
let dayStr = "";
|
|
if (isSameVietnamDay(date)) {
|
|
dayStr = "Today";
|
|
} else if (isSameVietnamDay(date, new Date(Date.now() + 86400000))) {
|
|
dayStr = "Tomorrow";
|
|
} else {
|
|
dayStr = formatVietnamDateTime(date, { month: "short", day: "numeric" });
|
|
}
|
|
|
|
const timeStr = formatVietnamTime(date, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hourCycle: "h23",
|
|
});
|
|
|
|
return `${dayStr}, ${timeStr}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get color classes based on remaining percentage
|
|
*/
|
|
function getColorClasses(remainingPercentage) {
|
|
if (remainingPercentage > 70) {
|
|
return {
|
|
text: "text-green-600 dark:text-green-400",
|
|
bg: "bg-green-500",
|
|
bgLight: "bg-green-500/10",
|
|
emoji: "🟢",
|
|
};
|
|
}
|
|
|
|
if (remainingPercentage >= 30) {
|
|
return {
|
|
text: "text-yellow-600 dark:text-yellow-400",
|
|
bg: "bg-yellow-500",
|
|
bgLight: "bg-yellow-500/10",
|
|
emoji: "🟡",
|
|
};
|
|
}
|
|
|
|
return {
|
|
text: "text-red-600 dark:text-red-400",
|
|
bg: "bg-red-500",
|
|
bgLight: "bg-red-500/10",
|
|
emoji: "🔴",
|
|
};
|
|
}
|
|
|
|
function sortQuotas(quotas, sortMode) {
|
|
if (sortMode === "remaining-asc") {
|
|
return [...quotas].sort((a, b) => a.remaining - b.remaining || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
if (sortMode === "remaining-desc") {
|
|
return [...quotas].sort((a, b) => b.remaining - a.remaining || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
return quotas;
|
|
}
|
|
|
|
/**
|
|
* Quota Table Component - Table-based display for quota data
|
|
*/
|
|
export default function QuotaTable({
|
|
quotas = [],
|
|
compact = false,
|
|
sortMode = "default",
|
|
showSortLabel = false,
|
|
onHideQuota = null,
|
|
}) {
|
|
const [page, setPage] = useState(1);
|
|
|
|
const normalizedQuotas = useMemo(
|
|
() => quotas.map((quota, index) => ({
|
|
...quota,
|
|
index,
|
|
remaining: getRemainingPercentage(quota),
|
|
})),
|
|
[quotas],
|
|
);
|
|
|
|
const sortedQuotas = useMemo(
|
|
() => sortQuotas(normalizedQuotas, sortMode),
|
|
[normalizedQuotas, sortMode],
|
|
);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(sortedQuotas.length / PAGE_SIZE));
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [sortMode, quotas]);
|
|
|
|
useEffect(() => {
|
|
setPage((currentPage) => Math.min(currentPage, totalPages));
|
|
}, [totalPages]);
|
|
|
|
if (!quotas || quotas.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const currentPageRows = sortedQuotas.slice(
|
|
(page - 1) * PAGE_SIZE,
|
|
page * PAGE_SIZE,
|
|
);
|
|
const pageStart = sortedQuotas.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
|
|
const pageEnd = Math.min(page * PAGE_SIZE, sortedQuotas.length);
|
|
|
|
const cellPad = compact ? "py-1 px-1.5" : "py-2 px-3";
|
|
const nameText = compact ? "text-[11px]" : "text-sm";
|
|
const resetPrimary = compact ? "text-[11px]" : "text-sm";
|
|
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
|
|
const sortLabel = "Sorted by account remaining";
|
|
const hasHideAction = typeof onHideQuota === "function";
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[10px] text-text-muted">
|
|
{sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""}
|
|
</div>
|
|
{showSortLabel && (
|
|
<div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1 text-[10px] text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
|
|
{sortLabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full table-fixed text-left">
|
|
<tbody>
|
|
{currentPageRows.map((quota) => {
|
|
const colors = getColorClasses(quota.remaining);
|
|
const countdown = formatResetTime(quota.resetAt);
|
|
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
|
// recurring defaults true: a missing flag means the quota
|
|
// refreshes at resetAt. Bonus/one-shot packs set recurring:false
|
|
// and their resetAt is a hard expiry, so word it as "expires".
|
|
const recurring = quota.recurring !== false;
|
|
const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`;
|
|
|
|
return (
|
|
<tr
|
|
key={`${quota.name}-${quota.index}`}
|
|
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
|
>
|
|
<td className={`${cellPad} w-[30%]`}>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
|
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
|
{quota.name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className={`${cellPad} w-[45%]`}>
|
|
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
|
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
|
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
|
|
}`}>
|
|
<div
|
|
className={`h-full transition-all duration-300 ${colors.bg}`}
|
|
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
|
|
<span className="text-text-muted">
|
|
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
|
</span>
|
|
<span className={`font-medium ${colors.text}`}>
|
|
{quota.remaining}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
|
|
{countdown !== "-" || resetDisplay ? (
|
|
compact ? (
|
|
<div
|
|
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
|
title={resetDisplay || ""}
|
|
>
|
|
{countdown !== "-" ? countdownLabel : resetDisplay}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0.5">
|
|
{countdown !== "-" && (
|
|
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
|
{countdownLabel}
|
|
</div>
|
|
)}
|
|
{resetDisplay && (
|
|
<div className={`${resetSecondary} text-text-muted`}>
|
|
{resetDisplay}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
|
)}
|
|
</td>
|
|
|
|
{hasHideAction && (
|
|
<td className={`${cellPad} w-[5%] text-right`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onHideQuota(quota)}
|
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
|
|
title="Hide this quota row"
|
|
aria-label={`Hide quota ${quota.name}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[15px]">
|
|
visibility_off
|
|
</span>
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1.5 dark:border-white/10 dark:bg-white/[0.03]">
|
|
<div className="flex items-center justify-between gap-2 text-[10px] text-text-muted">
|
|
<span>
|
|
Showing {pageStart}-{pageEnd} of {sortedQuotas.length}
|
|
</span>
|
|
<span>
|
|
Page {page} / {totalPages}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1.5 flex items-center justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
|
|
disabled={page === 1}
|
|
className="flex h-6 items-center rounded-md border border-black/10 px-2 text-[10px] text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5"
|
|
>
|
|
Prev
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))}
|
|
disabled={page === totalPages}
|
|
className="flex h-6 items-center rounded-md border border-black/10 px-2 text-[10px] text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|