refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,424 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import classNames from 'classnames';
import { motion, type Transition, type Variants } from 'framer-motion';
import { memo } from 'react';
import { useChatUsage } from '~/.client/hooks/useChatUsage';
import { DialogDescription, DialogTitle } from '../../ui/Dialog';
import { IconButton } from '../../ui/IconButton';
import { ChatUsageVisualization } from './ChatUsageVisualization';
const transition: Transition = {
duration: 0.15,
ease: [0.16, 1, 0.3, 1], // cubicBezier(.16,1,.3,1)
};
const backdropVariants: Variants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
};
const dialogVariants: Variants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
};
interface ChatUsageDialogProps {
isOpen: boolean;
onClose: () => void;
}
export const ChatUsageDialog = memo(({ isOpen, onClose }: ChatUsageDialogProps) => {
const { usageStats, isLoading, refreshUsageStats } = useChatUsage();
const formatNumber = (num: number | null) => {
if (num === null) {
return '0';
}
return num.toLocaleString();
};
const formatLargeNumber = (num: number | null) => {
if (num === null) {
return '0';
}
if (num < 1000) {
return num.toString();
}
if (num < 1000000) {
return `${(num / 1000).toFixed(1)}K`;
}
return `${(num / 1000000).toFixed(1)}M`;
};
// 计算成功率
const successRate = () => {
if (!usageStats) {
return 0;
}
const successCount = usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0;
const totalCount = usageStats.total._count;
return totalCount > 0 ? (successCount / totalCount) * 100 : 0;
};
// 计算平均 token 消耗
const avgTokenPerRequest = () => {
if (!usageStats || usageStats.total._count === 0) {
return 0;
}
return (usageStats.total._sum.totalTokens || 0) / usageStats.total._count;
};
const cardClasses = classNames(
'p-4 rounded-lg shadow-sm',
'bg-upage-elements-bg-depth-1',
'border border-upage-elements-borderColor',
);
return (
<RadixDialog.Root open={isOpen} onOpenChange={onClose}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className={classNames('fixed inset-0 z-[9999] bg-black/70 dark:bg-black/80 backdrop-blur-sm')}
initial="closed"
animate="open"
exit="closed"
variants={backdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-upage-elements-borderColor z-[9999] w-[95vw] max-w-[1000px] max-h-[85vh] flex flex-col',
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<DialogDescription className="sr-only">
API 使Token
</DialogDescription>
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
<DialogTitle>API 使</DialogTitle>
<div className="flex items-center gap-2">
<IconButton
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
onClick={refreshUsageStats}
disabled={isLoading}
className={classNames('text-upage-elements-textTertiary hover:text-upage-elements-textSecondary', {
'opacity-50 cursor-not-allowed': isLoading,
})}
aria-label="刷新统计数据"
title="刷新统计数据"
/>
<RadixDialog.Close asChild onClick={onClose}>
<IconButton
icon="i-ph:x"
className="text-upage-elements-textTertiary hover:text-upage-elements-textSecondary"
/>
</RadixDialog.Close>
</div>
</div>
<div className="flex-1 overflow-auto relative">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 dark:bg-gray-950/70 backdrop-blur-sm">
<div className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm">
<div className="i-ph:spinner-gap-bold animate-spin size-5 text-upage-elements-textTertiary" />
<span className="text-upage-elements-textSecondary font-medium">...</span>
</div>
</div>
)}
{!usageStats ? (
<div className="flex-1 overflow-auto text-center py-12">
<div className="i-ph:chart-line-duotone size-12 mx-auto mb-4 text-upage-elements-textTertiary opacity-80" />
<h3 className="text-lg font-medium text-upage-elements-textPrimary mb-2"></h3>
<p className="text-upage-elements-textSecondary">使使 AI </p>
</div>
) : (
<div className="flex-1 overflow-auto p-6">
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:chat-dots-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
{formatNumber(usageStats.total._count)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token </div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:hash-duotone size-6 text-green-500 dark:text-green-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.totalTokens)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:export-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.inputTokens)}
</div>
</div>
<div className={cardClasses}>
<div className="text-sm text-upage-elements-textSecondary mb-1"> Token</div>
<div className="text-2xl font-bold text-upage-elements-textPrimary flex items-center">
<span className="i-ph:import-duotone size-6 text-amber-500 dark:text-amber-400 mr-2" />
{formatLargeNumber(usageStats.total._sum.outputTokens)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:chart-pie-slice-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
</h3>
<div className="mt-4">
<div className="flex justify-between items-center mb-2">
<div className="text-3xl font-bold text-upage-elements-textPrimary">
{successRate().toFixed(1)}%
</div>
<div className="text-sm text-upage-elements-textSecondary">
{usageStats.byStatus.find((s) => s.status === 'SUCCESS')?._count || 0} /{' '}
{usageStats.total._count}
</div>
</div>
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 dark:bg-green-400 rounded-full"
style={{ width: `${Math.min(100, successRate())}%` }}
/>
</div>
</div>
<div className="mt-4 space-y-2">
{usageStats.byStatus.map((status) => (
<div key={status.status} className="flex justify-between items-center">
<div className="flex items-center">
<div
className={classNames('size-2 rounded-full mr-2', {
'bg-green-500 dark:bg-green-400': status.status === 'SUCCESS',
'bg-red-500 dark:bg-red-400': status.status === 'FAILED',
'bg-yellow-500 dark:bg-yellow-400': status.status === 'PENDING',
'bg-gray-500 dark:bg-gray-400': !['SUCCESS', 'FAILED', 'PENDING'].includes(
status.status,
),
})}
/>
<div className="text-sm text-upage-elements-textSecondary capitalize">
{status.status === 'SUCCESS'
? '成功'
: status.status === 'FAILED'
? '失败'
: status.status === 'PENDING'
? '处理中'
: status.status === 'ABORTED'
? '中止'
: status.status}
</div>
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatNumber(status._count)}
</div>
</div>
))}
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:check-circle-duotone size-5 text-green-500 dark:text-green-400 mr-2" />
Token
</h3>
<div className="space-y-4">
<div>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary">
{formatLargeNumber(avgTokenPerRequest())} Tokens
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<div className="text-sm text-upage-elements-textSecondary mb-2">Token </div>
<div className="space-y-2">
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-blue-500 dark:bg-blue-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.inputTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.inputTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-amber-500 dark:bg-amber-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.outputTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.outputTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
<div className="flex justify-between">
<div className="text-sm text-upage-elements-textSecondary flex items-center">
<div className="size-2 rounded-full bg-green-500 dark:bg-green-400 mr-2" />
Token
</div>
<div className="text-sm font-medium text-upage-elements-textPrimary">
{formatLargeNumber(usageStats.total._sum.cachedTokens)}
<span className="text-xs text-upage-elements-textTertiary ml-1">
(
{usageStats.total._sum.totalTokens
? (
((usageStats.total._sum.cachedTokens || 0) /
usageStats.total._sum.totalTokens) *
100
).toFixed(0)
: 0}
%)
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className={cardClasses}>
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3 flex items-center">
<span className="i-ph:trend-up-duotone size-5 text-blue-500 dark:text-blue-400 mr-2" />
使
</h3>
<div className="space-y-4">
{usageStats.byDate.length > 0 ? (
<>
<div>
<div className="text-sm text-upage-elements-textSecondary mb-1"></div>
<div className="text-2xl font-bold text-upage-elements-textPrimary">
{usageStats.byDate.length > 0
? formatNumber(usageStats.byDate[usageStats.byDate.length - 1].count)
: '0'}
</div>
</div>
<div className="pt-2 border-t border-upage-elements-borderColor">
<div className="text-sm text-upage-elements-textSecondary mb-2"></div>
<div className="text-sm text-upage-elements-textTertiary">
{usageStats.byDate.length > 1 ? (
(() => {
const current = usageStats.byDate[usageStats.byDate.length - 1].count;
const previous = usageStats.byDate[usageStats.byDate.length - 2].count;
const diff = current - previous;
const percentage = previous !== 0 ? (diff / previous) * 100 : 0;
return (
<div className="flex items-center">
<span className="text-upage-elements-textSecondary">:</span>
<span
className={classNames('ml-1 flex items-center', {
'text-green-500 dark:text-green-400': diff > 0,
'text-red-500 dark:text-red-400': diff < 0,
'text-upage-elements-textTertiary': diff === 0,
})}
>
{diff > 0 ? (
<span className="i-ph:arrow-up size-3.5 mr-0.5"></span>
) : diff < 0 ? (
<span className="i-ph:arrow-down size-3.5 mr-0.5"></span>
) : (
<span className="i-ph:minus size-3.5 mr-0.5"></span>
)}
{Math.abs(percentage).toFixed(0)}%
</span>
</div>
);
})()
) : (
<span></span>
)}
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full py-6">
<span className="text-sm text-upage-elements-textSecondary"></span>
</div>
)}
</div>
</div>
</div>
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
<div className="p-4 border-b border-upage-elements-borderColor">
<h3 className="text-base font-medium text-upage-elements-textPrimary flex items-center">
<span className="i-ph:chart-line-up-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
使
</h3>
</div>
<div className="p-4">
<ChatUsageVisualization usageStats={usageStats} />
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
});