Files
upage-git/app/components/chat/usage/DeploymentRecordsDialog.tsx
2025-09-24 17:02:44 +08:00

640 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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