diff --git a/app/account-status/account-status.tsx b/app/account-status/account-status.tsx index 5fa302d..42c2f92 100644 --- a/app/account-status/account-status.tsx +++ b/app/account-status/account-status.tsx @@ -3,7 +3,6 @@ import { Alert, Block, - Collapse, Empty, Flexbox, SkeletonButton, @@ -15,17 +14,18 @@ import { Tooltip, } from '@lobehub/ui'; import { Button } from '@lobehub/ui/base-ui'; -import { RefreshCw } from 'lucide-react'; +import { Check, Copy, RefreshCw } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { CredentialSummary } from '@/app/credentials/credentials'; interface AccountStatusProps { credentials: CredentialSummary[]; + initialStatuses?: AccountStatusSnapshot[]; } -interface AccountStatusSnapshot { +export interface AccountStatusSnapshot { checkin: { claimed: boolean | null; message: string | null }; credits: { total: number | null; @@ -70,39 +70,48 @@ const failedSnapshot = ( }); const quotaPercent = (snapshot: AccountStatusSnapshot): number | null => { - const { total, used } = snapshot.credits; - if (total === null || total <= 0 || used === null) return null; - return Math.min(100, Math.max(0, (used / total) * 100)); + const { total, remaining } = snapshot.credits; + if (total === null || total <= 0 || remaining === null) return null; + return Math.min(100, Math.max(0, (remaining / total) * 100)); }; const QuotaProgress = ({ + plan, snapshot, unknownLabel, - usedLabel, + remainingLabel, }: { + plan: string; snapshot: AccountStatusSnapshot; unknownLabel: string; - usedLabel: (percent: number) => string; + remainingLabel: (percent: number) => string; }) => { const percent = quotaPercent(snapshot); + const hasQuota = + snapshot.credits.total !== null && snapshot.credits.total > 0; const tone = percent === null ? 'unknown' - : percent >= 100 + : percent <= 0 ? 'exhausted' - : percent >= 80 + : percent <= 20 ? 'warning' : 'normal'; return ( - {percent === null ? '—' : `${percent.toFixed(0)}%`} - - {snapshot.credits.used ?? '—'} / {snapshot.credits.total ?? '—'} - + {plan} + + + {hasQuota && snapshot.credits.remaining !== null + ? `${snapshot.credits.remaining} / ${snapshot.credits.total}` + : '— / —'} + + {percent === null ? '—' : `${percent.toFixed(0)}%`} + { + const [copied, setCopied] = useState(false); + const text = useTranslations('Admin'); + const copy = async () => { + if (!navigator.clipboard) return; + await navigator.clipboard.writeText(model); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }; + return ( + + void copy()}> + + {copied ? : } + {model} + + + + ); +}; + const AccountStatusSkeleton = () => ( @@ -135,10 +165,7 @@ const AccountStatusCard = ({ onCheckin: () => void; }) => { const text = useTranslations('Admin'); - const percent = quotaPercent(snapshot); const quotaUnknown = text('accountStatus.quotaUnknown'); - const models = snapshot.models.slice(0, 8); - const hasMoreModels = snapshot.models.length > models.length; return ( - - - {credential.email || credential.user_id} - - - - {credential.filename} - - + + + + {credential.email || credential.user_id} + + + + {credential.filename} + + + + {snapshot.error ? : null} - {text('accountStatus.quota')} + remainingLabel={(value) => text('accountStatus.quotaUsed', { percent: value.toFixed(0) }) } /> - - - {text('accountStatus.total')}: {snapshot.credits.total ?? '—'} - - - {text('accountStatus.used')}: {snapshot.credits.used ?? '—'} - - - {text('accountStatus.remaining')}:{' '} - {snapshot.credits.remaining ?? '—'} - - - - {text('accountStatus.plan')}: {snapshot.credits.plan ?? '—'} - {text('accountStatus.resetAt')}: {snapshot.credits.resetAt ?? '—'} - {percent !== null && percent >= 100 ? ( - {text('accountStatus.exhausted')} - ) : null} @@ -217,57 +232,32 @@ const AccountStatusCard = ({ {text('accountStatus.models')} - {models.length ? ( - - {snapshot.models.map((model) => ( - {model} - ))} - - ), - }, - ]} - variant="borderless" - /> + {snapshot.models.length ? ( + + {snapshot.models.map((model) => ( + + ))} + ) : ( {text('accountStatus.noModels')} )} - - - {snapshot.queriedAt - ? new Date(snapshot.queriedAt).toLocaleString() - : '—'} - - - ); }; -const AccountStatus = ({ credentials }: AccountStatusProps) => { +const AccountStatus = ({ + credentials, + initialStatuses = [], +}: AccountStatusProps) => { const text = useTranslations('Admin'); const [snapshots, setSnapshots] = useState< Record - >({}); + >(() => + Object.fromEntries( + initialStatuses.map((status) => [status.filename, status]), + ), + ); const [busy, setBusy] = useState>({}); const [page, setPage] = useState(1); const [batchBusy, setBatchBusy] = useState(null); @@ -293,7 +283,13 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { } catch (error) { setSnapshots((current) => ({ ...current, - [filename]: current[filename] ?? failedSnapshot(filename, error), + [filename]: { + ...(current[filename] ?? failedSnapshot(filename, error)), + error: + error instanceof Error + ? error.message + : 'Account status query failed', + }, })); } finally { setBusy((current) => { @@ -311,21 +307,19 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { try { await Promise.all( credentials - .filter((credential) => !credential.is_expired) + .filter((credential) => { + if (credential.is_expired) return false; + if (action !== 'checkin') return true; + return snapshots[credential.filename]?.checkin.claimed !== true; + }) .map((credential) => loadOne(credential.filename, action)), ); } finally { setBatchBusy(null); } }, - [credentials, loadOne], + [credentials, loadOne, snapshots], ); - useEffect(() => { - const timer = window.setTimeout(() => { - void loadAll('refresh'); - }, 0); - return () => window.clearTimeout(timer); - }, [loadAll]); const pageCredentials = useMemo( () => credentials.length > 50 @@ -343,7 +337,6 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { horizontal wrap="wrap" > - {text('accountStatus.title')}