🎉 first commit
This commit is contained in:
424
app/components/chat/usage/ChatUsageDialog.tsx
Normal file
424
app/components/chat/usage/ChatUsageDialog.tsx
Normal 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 '~/lib/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>
|
||||
);
|
||||
});
|
||||
372
app/components/chat/usage/ChatUsageVisualization.tsx
Normal file
372
app/components/chat/usage/ChatUsageVisualization.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import { Doughnut, Line, Pie } from 'react-chartjs-2';
|
||||
import type { ChatUsageStats } from '~/lib/hooks/useChatUsage';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement);
|
||||
|
||||
type ChatUsageVisualizationProps = {
|
||||
usageStats: ChatUsageStats;
|
||||
};
|
||||
|
||||
export function ChatUsageVisualization({ usageStats }: ChatUsageVisualizationProps) {
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
|
||||
|
||||
const getThemeColor = (varName: string): string => {
|
||||
if (typeof document !== 'undefined') {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
}
|
||||
return isDarkMode ? '#FFFFFF' : '#000000';
|
||||
};
|
||||
|
||||
const chartColors = {
|
||||
grid: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
text: getThemeColor('--upage-elements-textPrimary'),
|
||||
textSecondary: getThemeColor('--upage-elements-textSecondary'),
|
||||
background: getThemeColor('--upage-elements-bg-depth-1'),
|
||||
accent: getThemeColor('--upage-elements-button-primary-text'),
|
||||
border: getThemeColor('--upage-elements-borderColor'),
|
||||
success: isDarkMode ? 'rgba(34, 197, 94, 0.7)' : 'rgba(34, 197, 94, 0.6)', // 绿色
|
||||
successBorder: isDarkMode ? 'rgba(34, 197, 94, 0.9)' : 'rgba(34, 197, 94, 0.8)',
|
||||
failed: isDarkMode ? 'rgba(239, 68, 68, 0.7)' : 'rgba(239, 68, 68, 0.6)', // 红色
|
||||
failedBorder: isDarkMode ? 'rgba(239, 68, 68, 0.9)' : 'rgba(239, 68, 68, 0.8)',
|
||||
pending: isDarkMode ? 'rgba(234, 179, 8, 0.7)' : 'rgba(234, 179, 8, 0.6)', // 黄色
|
||||
pendingBorder: isDarkMode ? 'rgba(234, 179, 8, 0.9)' : 'rgba(234, 179, 8, 0.8)',
|
||||
aborted: isDarkMode ? 'rgba(107, 114, 128, 0.7)' : 'rgba(107, 114, 128, 0.6)', // 灰色
|
||||
abortedBorder: isDarkMode ? 'rgba(107, 114, 128, 0.9)' : 'rgba(107, 114, 128, 0.8)',
|
||||
};
|
||||
|
||||
const getChartColors = (index: number) => {
|
||||
const baseColors = [
|
||||
{
|
||||
base: getThemeColor('--upage-elements-button-primary-text'),
|
||||
},
|
||||
{
|
||||
base: isDarkMode ? 'rgb(244, 114, 182)' : 'rgb(236, 72, 153)',
|
||||
},
|
||||
{
|
||||
base: getThemeColor('--upage-elements-icon-success'),
|
||||
},
|
||||
{
|
||||
base: isDarkMode ? 'rgb(250, 204, 21)' : 'rgb(234, 179, 8)',
|
||||
},
|
||||
{
|
||||
base: isDarkMode ? 'rgb(56, 189, 248)' : 'rgb(14, 165, 233)',
|
||||
},
|
||||
];
|
||||
|
||||
const color = baseColors[index % baseColors.length].base;
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/);
|
||||
|
||||
if (rgbMatch) {
|
||||
[, r, g, b] = rgbMatch.map(Number);
|
||||
} else if (rgbaMatch) {
|
||||
[, r, g, b] = rgbaMatch.map(Number);
|
||||
} else if (color.startsWith('#')) {
|
||||
const hex = color.slice(1);
|
||||
const bigint = parseInt(hex, 16);
|
||||
r = (bigint >> 16) & 255;
|
||||
g = (bigint >> 8) & 255;
|
||||
b = bigint & 255;
|
||||
}
|
||||
|
||||
return {
|
||||
bg: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.7 : 0.5})`,
|
||||
border: `rgba(${r}, ${g}, ${b}, ${isDarkMode ? 0.9 : 0.8})`,
|
||||
};
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
return '成功';
|
||||
case 'FAILED':
|
||||
return '失败';
|
||||
case 'PENDING':
|
||||
return '处理中';
|
||||
case 'ABORTED':
|
||||
return '中止';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string, isBackground = true) => {
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
return isBackground ? chartColors.success : chartColors.successBorder;
|
||||
case 'FAILED':
|
||||
return isBackground ? chartColors.failed : chartColors.failedBorder;
|
||||
case 'PENDING':
|
||||
return isBackground ? chartColors.pending : chartColors.pendingBorder;
|
||||
case 'ABORTED':
|
||||
return isBackground ? chartColors.aborted : chartColors.abortedBorder;
|
||||
default:
|
||||
return isBackground ? getChartColors(0).bg : getChartColors(0).border;
|
||||
}
|
||||
};
|
||||
|
||||
const statusDistributionData = {
|
||||
labels: usageStats.byStatus.map((status) => formatStatus(status.status)),
|
||||
datasets: [
|
||||
{
|
||||
label: '请求状态',
|
||||
data: usageStats.byStatus.map((status) => status._count),
|
||||
backgroundColor: usageStats.byStatus.map((status) => getStatusColor(status.status)),
|
||||
borderColor: usageStats.byStatus.map((status) => getStatusColor(status.status, false)),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tokenUsageData = {
|
||||
labels: ['输入 Token', '输出 Token', '缓存 Token'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Token 使用量',
|
||||
data: [
|
||||
usageStats.total._sum.inputTokens || 0,
|
||||
usageStats.total._sum.outputTokens || 0,
|
||||
usageStats.total._sum.cachedTokens || 0,
|
||||
],
|
||||
backgroundColor: [getChartColors(1).bg, getChartColors(2).bg, getChartColors(4).bg],
|
||||
borderColor: [getChartColors(1).border, getChartColors(2).border, getChartColors(4).border],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const dailyRequestsData = {
|
||||
labels: usageStats.byDate.map((day) => day.date),
|
||||
datasets: [
|
||||
{
|
||||
label: '每日请求数',
|
||||
data: usageStats.byDate.map((day) => day.count),
|
||||
borderColor: getChartColors(4).border,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.4, // 添加曲线平滑
|
||||
pointBackgroundColor: getChartColors(4).border,
|
||||
pointBorderColor: chartColors.background,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: '每日 Token 用量',
|
||||
data: usageStats.byDate.map((day) => day.totalTokens),
|
||||
borderColor: getChartColors(2).border,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: getChartColors(2).border,
|
||||
pointBorderColor: chartColors.background,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const baseChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
color: chartColors.text,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 'bold' as const,
|
||||
size: 12,
|
||||
},
|
||||
padding: 16,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
padding: 16,
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: chartColors.text,
|
||||
bodyColor: chartColors.text,
|
||||
backgroundColor: isDarkMode ? 'rgba(23, 23, 23, 0.8)' : 'rgba(255, 255, 255, 0.8)',
|
||||
borderColor: chartColors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const statusPieOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: '请求状态分布',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
position: 'right' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const doughnutOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: 'Token 使用分布',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
position: 'right' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lineChartOptions = {
|
||||
...baseChartOptions,
|
||||
plugins: {
|
||||
...baseChartOptions.plugins,
|
||||
title: {
|
||||
...baseChartOptions.plugins.title,
|
||||
text: '每日请求统计',
|
||||
},
|
||||
legend: {
|
||||
...baseChartOptions.plugins.legend,
|
||||
onClick: function (_e: any, legendItem: any, legend: any) {
|
||||
const index = legendItem.datasetIndex;
|
||||
const ci = legend.chart;
|
||||
|
||||
const datasets = ci.data.datasets;
|
||||
const visibleCount = datasets.reduce((count: number, _dataset: any, i: number) => {
|
||||
return count + (ci.getDatasetMeta(i).hidden ? 0 : 1);
|
||||
}, 0);
|
||||
|
||||
const meta = ci.getDatasetMeta(index);
|
||||
const isCurrentlyVisible = !meta.hidden;
|
||||
|
||||
if (isCurrentlyVisible && visibleCount === 1) {
|
||||
meta.hidden = true;
|
||||
|
||||
datasets.forEach((_dataset: any, i: number) => {
|
||||
if (i !== index) {
|
||||
ci.getDatasetMeta(i).hidden = false;
|
||||
}
|
||||
});
|
||||
} else if (visibleCount === 0) {
|
||||
datasets.forEach((_dataset: any, i: number) => {
|
||||
ci.getDatasetMeta(i).hidden = i !== index;
|
||||
});
|
||||
} else {
|
||||
meta.hidden = !meta.hidden;
|
||||
}
|
||||
|
||||
ci.update();
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.grid,
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.text,
|
||||
font: {
|
||||
weight: 500,
|
||||
},
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-6 rounded-lg shadow-sm',
|
||||
'bg-upage-elements-bg-depth-1',
|
||||
'border border-upage-elements-borderColor',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">每日请求统计</h3>
|
||||
<div className="h-64">
|
||||
<Line data={dailyRequestsData} options={lineChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">请求状态分布</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={statusDistributionData} options={statusPieOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cardClasses}>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-3">Token 使用分布</h3>
|
||||
<div className="h-64">
|
||||
<Doughnut data={tokenUsageData} options={doughnutOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
639
app/components/chat/usage/DeploymentRecordsDialog.tsx
Normal file
639
app/components/chat/usage/DeploymentRecordsDialog.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import classNames from 'classnames';
|
||||
import { motion, type Transition, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { type DeploymentRecord, useDeploymentRecords } from '~/lib/hooks/useDeploymentRecords';
|
||||
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
|
||||
import { ConfirmationDialog, DialogDescription, DialogTitle } from '../../ui/Dialog';
|
||||
import { IconButton } from '../../ui/IconButton';
|
||||
import { Tabs, TabsList, TabsTrigger } from '../../ui/Tabs';
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 0.15,
|
||||
ease: [0.16, 1, 0.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 DeploymentRecordsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DeploymentRecordsDialog = memo(({ isOpen, onClose }: DeploymentRecordsDialogProps) => {
|
||||
const {
|
||||
deploymentRecords,
|
||||
totals,
|
||||
stats,
|
||||
isLoading,
|
||||
isPlatformLoading,
|
||||
loadPlatformRecords,
|
||||
refreshDeploymentRecords,
|
||||
toggleAccess,
|
||||
deletePage,
|
||||
} = useDeploymentRecords();
|
||||
const [activePlatform, setActivePlatform] = useState<DeploymentPlatformEnum>(DeploymentPlatformEnum._1PANEL);
|
||||
const [loadedPlatforms, setLoadedPlatforms] = useState<Set<string>>(new Set());
|
||||
const initialLoadDone = useRef<boolean>(false);
|
||||
|
||||
// 确认对话框状态
|
||||
type ConfirmAction = 'toggle-access' | 'delete';
|
||||
type ConfirmDialogState = {
|
||||
isOpen: boolean;
|
||||
action: ConfirmAction;
|
||||
recordId: string | null;
|
||||
platform: string | null;
|
||||
recordStatus?: string;
|
||||
};
|
||||
|
||||
const [confirmDialogState, setConfirmDialogState] = useState<ConfirmDialogState>({
|
||||
isOpen: false,
|
||||
action: 'toggle-access',
|
||||
recordId: null,
|
||||
platform: null,
|
||||
});
|
||||
|
||||
const [isConfirmationLoading, setIsConfirmationLoading] = useState(false);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
const currentRecords = deploymentRecords[activePlatform] || [];
|
||||
loadPlatformRecords({ offset: currentRecords.length, platform: activePlatform });
|
||||
}, [activePlatform, deploymentRecords, loadPlatformRecords]);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
const newPlatform = value as DeploymentPlatformEnum;
|
||||
setActivePlatform(newPlatform);
|
||||
|
||||
if (!loadedPlatforms.has(newPlatform)) {
|
||||
loadPlatformRecords({ platform: newPlatform });
|
||||
setLoadedPlatforms((prev) => new Set(prev).add(newPlatform));
|
||||
}
|
||||
},
|
||||
[loadPlatformRecords, loadedPlatforms],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !initialLoadDone.current) {
|
||||
refreshDeploymentRecords();
|
||||
setLoadedPlatforms((prev) => new Set(prev).add(activePlatform));
|
||||
initialLoadDone.current = true;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
initialLoadDone.current = false;
|
||||
}
|
||||
}, [isOpen, activePlatform, refreshDeploymentRecords]);
|
||||
|
||||
const openConfirmDialog = useCallback((action: ConfirmAction, record: DeploymentRecord) => {
|
||||
setConfirmDialogState({
|
||||
isOpen: true,
|
||||
action,
|
||||
recordId: record.id,
|
||||
platform: record.platform,
|
||||
recordStatus: record.status,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeConfirmDialog = useCallback(() => {
|
||||
setConfirmDialogState((prev) => ({ ...prev, isOpen: false }));
|
||||
}, []);
|
||||
|
||||
const handleConfirmAction = useCallback(async () => {
|
||||
const { action, recordId, platform } = confirmDialogState;
|
||||
if (!recordId || !platform) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationLoading(true);
|
||||
|
||||
try {
|
||||
if (action === 'toggle-access') {
|
||||
await toggleAccess(recordId, platform);
|
||||
}
|
||||
if (action === 'delete') {
|
||||
await deletePage(recordId, platform);
|
||||
}
|
||||
|
||||
refreshDeploymentRecords();
|
||||
closeConfirmDialog();
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
toast.error('操作失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
} finally {
|
||||
setIsConfirmationLoading(false);
|
||||
}
|
||||
}, [confirmDialogState, toggleAccess, deletePage, refreshDeploymentRecords, closeConfirmDialog]);
|
||||
|
||||
const handleToggleAccess = useCallback(
|
||||
(record: DeploymentRecord) => {
|
||||
openConfirmDialog('toggle-access', record);
|
||||
},
|
||||
[openConfirmDialog],
|
||||
);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
(record: DeploymentRecord) => {
|
||||
openConfirmDialog('delete', record);
|
||||
},
|
||||
[openConfirmDialog],
|
||||
);
|
||||
|
||||
const cardClasses = classNames(
|
||||
'p-4 rounded-lg shadow-sm',
|
||||
'bg-upage-elements-bg-depth-1',
|
||||
'border border-upage-elements-borderColor',
|
||||
);
|
||||
|
||||
const platformIcons = {
|
||||
[DeploymentPlatformEnum._1PANEL]: 'i-ph:browser',
|
||||
[DeploymentPlatformEnum.NETLIFY]: 'i-ph:cloud',
|
||||
[DeploymentPlatformEnum.VERCEL]: 'i-ph:triangle',
|
||||
};
|
||||
|
||||
// 状态配置类型
|
||||
type StatusConfig = {
|
||||
text: string;
|
||||
bgClass: string;
|
||||
dotClass: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
// 部署状态配置
|
||||
const deploymentStatusConfig: Record<string, StatusConfig> = {
|
||||
[DeploymentStatusEnum.SUCCESS]: {
|
||||
text: '已部署',
|
||||
bgClass: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
dotClass: 'bg-green-500 dark:bg-green-400',
|
||||
icon: 'i-carbon:checkmark-filled',
|
||||
},
|
||||
[DeploymentStatusEnum.DEPLOYED]: {
|
||||
text: '已部署',
|
||||
bgClass: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
dotClass: 'bg-green-500 dark:bg-green-400',
|
||||
icon: 'i-carbon:checkmark-filled',
|
||||
},
|
||||
[DeploymentStatusEnum.PENDING]: {
|
||||
text: '部署中',
|
||||
bgClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
dotClass: 'bg-yellow-500 dark:bg-yellow-400',
|
||||
icon: 'i-carbon:time',
|
||||
},
|
||||
[DeploymentStatusEnum.DEPLOYING]: {
|
||||
text: '部署中',
|
||||
bgClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
dotClass: 'bg-yellow-500 dark:bg-yellow-400',
|
||||
icon: 'i-carbon:in-progress',
|
||||
},
|
||||
[DeploymentStatusEnum.FAILED]: {
|
||||
text: '失败',
|
||||
bgClass: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
dotClass: 'bg-red-500 dark:bg-red-400',
|
||||
icon: 'i-carbon:close-filled',
|
||||
},
|
||||
[DeploymentStatusEnum.INACTIVE]: {
|
||||
text: '已停用',
|
||||
bgClass: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
dotClass: 'bg-gray-500 dark:bg-gray-400',
|
||||
icon: 'i-carbon:pause-filled',
|
||||
},
|
||||
};
|
||||
|
||||
// 部署状态徽章组件
|
||||
const DeploymentStatusBadge = ({ status }: { status: string }) => {
|
||||
// 获取状态配置,如果不存在则使用默认配置
|
||||
const config = deploymentStatusConfig[status] || {
|
||||
text: status,
|
||||
bgClass: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
dotClass: 'bg-gray-500 dark:bg-gray-400',
|
||||
icon: 'i-carbon:help',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={classNames('px-2 py-1 text-xs rounded-full inline-flex items-center gap-1', config.bgClass)}>
|
||||
<span className={classNames('size-1.5 rounded-full', config.dotClass)} />
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const isActive = useCallback((status: string) => {
|
||||
return status === DeploymentStatusEnum.SUCCESS || status === DeploymentStatusEnum.DEPLOYED;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<>
|
||||
<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">
|
||||
展示网站部署记录,包括各平台部署的网站数量、运行状态及访问统计等信息。
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-upage-elements-borderColor">
|
||||
<DialogTitle>部署记录</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconButton
|
||||
icon={isLoading ? 'i-ph:spinner-gap-bold animate-spin' : 'i-ph:arrows-clockwise'}
|
||||
onClick={refreshDeploymentRecords}
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 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:globe-duotone size-6 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
{stats.totalSites || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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:users-duotone size-6 text-blue-500 dark:text-blue-400 mr-2" />
|
||||
{stats.totalVisits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-upage-elements-textPrimary mb-4 flex items-center">
|
||||
<span className="i-ph:list-checks-duotone size-5 text-purple-500 dark:text-purple-400 mr-2" />
|
||||
部署详情
|
||||
</h3>
|
||||
|
||||
<Tabs value={activePlatform} onValueChange={handleTabChange} className="mb-4">
|
||||
<TabsList className="w-full border border-upage-elements-borderColor rounded-md p-1 bg-gray-50 dark:bg-gray-900/20 flex">
|
||||
{Object.values(DeploymentPlatformEnum).map((platform, index) => {
|
||||
const count = stats.sitesByPlatform?.[platform] || 0;
|
||||
const isLoading = isPlatformLoading(platform);
|
||||
const isActive = activePlatform === platform;
|
||||
const isLast = index === Object.values(DeploymentPlatformEnum).length - 1;
|
||||
|
||||
return (
|
||||
<div key={platform} className="flex items-center flex-1">
|
||||
<TabsTrigger
|
||||
value={platform}
|
||||
className={classNames(
|
||||
'flex-1 relative py-2 px-3 transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white dark:bg-gray-800 shadow-sm rounded-md text-upage-elements-textPrimary font-medium'
|
||||
: 'hover:bg-gray-100/70 dark:hover:bg-gray-800/30 text-upage-elements-textSecondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={classNames(
|
||||
platformIcons[platform],
|
||||
'size-4',
|
||||
isActive ? 'text-purple-500 dark:text-purple-400' : '',
|
||||
)}
|
||||
/>
|
||||
<span>{platform === DeploymentPlatformEnum._1PANEL ? '1Panel' : platform}</span>
|
||||
{isLoading ? (
|
||||
<span className="i-carbon:circle-dash animate-spin size-3 ml-1 text-purple-500 dark:text-purple-400" />
|
||||
) : (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
|
||||
{count || 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-purple-500 dark:bg-purple-400 rounded-full mx-4" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
{!isLast && (
|
||||
<div className="h-8 w-px bg-upage-elements-borderColor dark:bg-gray-700/50" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className={classNames(cardClasses, 'p-0 overflow-hidden')}>
|
||||
<div className="min-h-[400px] max-h-[500px] flex flex-col">
|
||||
<div className="overflow-x-auto h-full relative">
|
||||
<table className="w-full table-fixed">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
|
||||
聊天
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[10%]">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[25%]">
|
||||
部署地址
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
|
||||
部署时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[15%]">
|
||||
更新时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-upage-elements-textSecondary uppercase tracking-wider w-[20%]">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-upage-elements-borderColor">
|
||||
{isPlatformLoading(activePlatform) && !deploymentRecords[activePlatform]?.length ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="h-[300px]">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-8 mb-2 text-purple-500 dark:text-purple-400" />
|
||||
<span className="text-upage-elements-textSecondary">加载中...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : deploymentRecords[activePlatform]?.length > 0 ? (
|
||||
deploymentRecords[activePlatform].map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/30 transition-colors duration-150"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<a
|
||||
href={`/chat/${record.chatId}`}
|
||||
className="text-blue-500 dark:text-blue-400 hover:underline hover:text-purple-700 dark:hover:text-purple-300 transition-colors"
|
||||
title={record.chat?.description || '未命名聊天'}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="i-ph:chat-circle-text size-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">
|
||||
{record.chat?.description || '未命名聊天'}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<DeploymentStatusBadge status={record.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-blue-500 dark:text-blue-400 text-ellipsis text-nowrap">
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
<span className="i-ph:link size-4 flex-shrink-0" />
|
||||
<span className="line-clamp-1">{record.url}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-upage-elements-textSecondary whitespace-nowrap">
|
||||
{formatDate(record.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-upage-elements-textSecondary whitespace-nowrap">
|
||||
{formatDate(record.updatedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<IconButton
|
||||
icon={isActive(record.status) ? 'i-ph:pause-fill' : 'i-ph:play-fill'}
|
||||
onClick={() => handleToggleAccess(record)}
|
||||
className="!text-gray-500 !hover:text-purple-600 dark:!text-gray-400 dark:!hover:text-purple-400"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
|
||||
sideOffset={5}
|
||||
side="top"
|
||||
>
|
||||
{isActive(record.status) ? '停止访问' : '开启访问'}
|
||||
<Tooltip.Arrow
|
||||
className="fill-upage-elements-background-depth-3"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<IconButton
|
||||
icon="i-ph:pencil-duotone"
|
||||
onClick={() => {
|
||||
window.open(`/chat/${record.chatId}`);
|
||||
}}
|
||||
className="!text-gray-500 !hover:text-blue-600 dark:!text-gray-400 dark:!hover:text-blue-400"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
|
||||
sideOffset={5}
|
||||
side="top"
|
||||
>
|
||||
编辑页面
|
||||
<Tooltip.Arrow
|
||||
className="fill-upage-elements-background-depth-3"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<IconButton
|
||||
icon={'i-ph:trash-duotone'}
|
||||
onClick={() => handleDeletePage(record)}
|
||||
className="!text-gray-500 !hover:text-red-600 dark:!text-gray-400 dark:!hover:text-red-400"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="px-2.5 py-1.5 rounded-md bg-upage-elements-background-depth-3 text-upage-elements-textPrimary text-sm z-[2000]"
|
||||
sideOffset={5}
|
||||
side="top"
|
||||
>
|
||||
删除页面
|
||||
<Tooltip.Arrow
|
||||
className="fill-upage-elements-background-depth-3"
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-4 py-8 h-[300px] text-center text-upage-elements-textSecondary"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="i-ph:cloud-slash-duotone size-8 mb-2 opacity-70" />
|
||||
<span>暂无部署记录</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deploymentRecords[activePlatform]?.length > 0 &&
|
||||
deploymentRecords[activePlatform].length < (totals[activePlatform] || 0) && (
|
||||
<div className="flex justify-center mt-4">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={isPlatformLoading(activePlatform)}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md text-sm',
|
||||
'text-upage-elements-textSecondary hover:text-upage-elements-textPrimary',
|
||||
'border border-upage-elements-borderColor hover:border-upage-elements-borderColorHover',
|
||||
'transition-colors',
|
||||
{ 'opacity-50 cursor-not-allowed': isPlatformLoading(activePlatform) },
|
||||
)}
|
||||
>
|
||||
{isPlatformLoading(activePlatform) ? (
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
|
||||
) : (
|
||||
<div className="i-ph:arrow-down size-4" />
|
||||
)}
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={confirmDialogState.isOpen}
|
||||
onClose={closeConfirmDialog}
|
||||
onConfirm={handleConfirmAction}
|
||||
title={
|
||||
confirmDialogState.action === 'toggle-access'
|
||||
? `${confirmDialogState.recordStatus === 'inactive' ? '开启' : '停止'}页面访问`
|
||||
: '删除页面'
|
||||
}
|
||||
description={
|
||||
confirmDialogState.action === 'toggle-access'
|
||||
? `确定要${confirmDialogState.recordStatus === 'inactive' ? '开启' : '停止'}此页面的访问吗?
|
||||
${confirmDialogState.recordStatus === 'inactive' ? '开启之后,可能需要等待一段时间才可访问。' : ''}
|
||||
`
|
||||
: '确定要删除此页面吗?此操作不可撤销。'
|
||||
}
|
||||
confirmLabel={
|
||||
confirmDialogState.action === 'toggle-access'
|
||||
? confirmDialogState.recordStatus === 'inactive'
|
||||
? '开启访问'
|
||||
: '停止访问'
|
||||
: '删除页面'
|
||||
}
|
||||
cancelLabel="取消"
|
||||
variant={confirmDialogState.action === 'delete' ? 'destructive' : 'default'}
|
||||
isLoading={isConfirmationLoading}
|
||||
/>
|
||||
</>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user