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>
);
});

View 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 '~/.client/hooks/useChatUsage';
import { themeStore } from '~/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>
);
}

View 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 '~/.client/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/50 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 dark:text-upage-elements-textPrimary font-medium'
: 'bg-transparent dark:bg-transparent hover:bg-gray-100/70 dark:hover:bg-gray-800/30 text-upage-elements-textSecondary dark: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>
);
});