import { useStore } from '@nanostores/react'; import { useFetcher, useRouteLoaderData } from '@remix-run/react'; import classNames from 'classnames'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale/zh-CN'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Badge } from '~/components/ui/Badge'; import { Button } from '~/components/ui/Button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible'; import { fetchNetlifyStats, isFetchingStats, netlifyConnection, updateNetlifyConnection } from '~/lib/stores/netlify'; import type { ConnectionSettings } from '~/root'; import type { ApiResponse } from '~/types/global'; import type { NetlifyBuild, NetlifyDeploy, NetlifySite } from '~/types/netlify'; import { logger } from '~/utils/logger'; import ConnectionBorder from './components/ConnectionBorder'; // Add new interface for site actions interface SiteAction { name: string; icon: string; action: (siteId: string) => Promise; requiresConfirmation?: boolean; variant?: 'default' | 'destructive' | 'outline'; } export default function NetlifyConnection() { const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root'); const connectFetcher = useFetcher(); const settingsFetcher = useFetcher(); const connection = useStore(netlifyConnection); const [tokenInput, setTokenInput] = useState(''); const fetchingStats = useStore(isFetchingStats); const [sites, setSites] = useState([]); const [deploys, setDeploys] = useState([]); const [builds, setBuilds] = useState([]); const [deploymentCount, setDeploymentCount] = useState(0); const [lastUpdated, setLastUpdated] = useState(''); const [isStatsOpen, setIsStatsOpen] = useState(false); const [activeSiteIndex, setActiveSiteIndex] = useState(0); const [isActionLoading, setIsActionLoading] = useState(false); const isConnecting = useMemo(() => { return connectFetcher.state !== 'idle'; }, [connectFetcher.state]); useEffect(() => { updateNetlifyConnection({ isConnect: rootData?.connectionSettings?.netlifyConnection, }); }, [rootData]); // Add site actions const siteActions: SiteAction[] = [ { name: '清除缓存', icon: 'heroicons:arrow-path', action: async (siteId: string) => { try { const response = await fetch(`/api/netlify/sites/${siteId}/cache`, { method: 'POST', }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || '清除缓存失败'); } toast.success('站点缓存清除成功'); } catch (err: unknown) { const error = err instanceof Error ? err.message : '未知错误'; toast.error(`清除站点缓存失败: ${error}`); } }, }, { name: '删除站点', icon: 'heroicons:trash', action: async (siteId: string) => { try { const response = await fetch(`/api/netlify/sites/${siteId}`, { method: 'DELETE', }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || '删除站点失败'); } toast.success('站点删除成功'); fetchNetlifyStats().catch((err) => { logger.error('获取 Netlify 统计信息失败:', err); }); } catch (err: unknown) { const error = err instanceof Error ? err.message : '未知错误'; toast.error(`删除站点失败: ${error}`); } }, requiresConfirmation: true, variant: 'destructive', }, ]; const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => { try { setIsActionLoading(true); const response = await fetch(`/api/netlify/deploys/${deployId}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ siteId }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || `Failed to ${action} deploy`); } toast.success(`Deploy ${action}ed successfully`); fetchNetlifyStats().catch((err) => { logger.error('获取 Netlify 统计信息失败:', err); }); } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to ${action} deploy: ${error}`); } finally { setIsActionLoading(false); } }; useEffect(() => { if (connection.isConnect && (!connection.stats || !connection.stats.sites)) { fetchNetlifyStats().catch((err) => { logger.error('获取 Netlify 统计信息失败:', err); }); } // Update local state from connection if (connection.stats) { setSites(connection.stats.sites || []); setDeploys(connection.stats.deploys || []); setBuilds(connection.stats.builds || []); setDeploymentCount(connection.stats.deploys?.length || 0); setLastUpdated(connection.stats.lastDeployTime || ''); } }, [connection]); // 监听 connectFetcher 状态变化(连接) useEffect(() => { if (connectFetcher.state === 'idle' && connectFetcher.data) { if (connectFetcher.data.success) { updateNetlifyConnection({ isConnect: connectFetcher.data.data.isConnect, }); fetchNetlifyStats().catch((err) => { logger.error('获取 Netlify 统计信息失败:', err); }); toast.success('连接 Netlify 成功'); setTokenInput(''); } else if (connectFetcher.data.message) { toast.error(connectFetcher.data.message || '连接失败'); } } }, [connectFetcher.state, connectFetcher.data]); // 监听 settingsFetcher 状态变化(断开连接) useEffect(() => { if (settingsFetcher.state === 'idle' && settingsFetcher.data) { if (settingsFetcher.data.success) { localStorage.removeItem('netlify_connection'); document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; updateNetlifyConnection({ isConnect: false }); toast.success('断开 Netlify 连接'); } } }, [settingsFetcher.state, settingsFetcher.data]); const handleConnect = async () => { if (!tokenInput) { toast.error('请输入 Netlify API 令牌'); return; } try { connectFetcher.submit( { token: tokenInput }, { method: 'POST', action: '/api/netlify/auth', encType: 'application/json', }, ); } catch (error) { logger.error('连接 Netlify 失败:', error); toast.error(`连接 Netlify 失败: ${error instanceof Error ? error.message : '未知错误'}`); } }; const handleDisconnect = async () => { try { settingsFetcher.submit( { category: 'connectivity', key: 'netlify_token', }, { method: 'DELETE', action: '/api/user/settings', encType: 'application/json', }, ); } catch (error) { toast.error('断开 Netlify 连接失败'); logger.error('断开 Netlify 连接失败:', error); } }; const renderStats = () => { if (!connection.isConnect || !connection.stats) { return null; } return (
Netlify 统计信息
{connection.stats.totalSites} 站点
{deploymentCount} 部署 {lastUpdated && (
更新于 {formatDistanceToNow(new Date(lastUpdated), { locale: zhCN })} 前 )}
{sites.length > 0 && (

您的站点

{sites.map((site, index) => (
{ setActiveSiteIndex(index); }} >
{site.name}
{site.published_deploy?.state === 'ready' ? (
) : (
)} {site.published_deploy?.state || 'Unknown'}
e.stopPropagation()} > {activeSiteIndex === index && ( <>
{siteActions.map((action) => ( ))}
{site.published_deploy && (
发布于{' '} {formatDistanceToNow(new Date(site.published_deploy.published_at), { locale: zhCN, })}{' '} 前
{site.published_deploy.branch && (
分支: {site.published_deploy.branch}
)}
)} )}
))}
{activeSiteIndex !== -1 && deploys.length > 0 && (

最近部署

{deploys.map((deploy) => (
{deploy.state === 'ready' ? (
) : deploy.state === 'error' ? (
) : (
)} {deploy.state}
{formatDistanceToNow(new Date(deploy.created_at), { locale: zhCN })} 前
{deploy.branch && (
分支: {deploy.branch}
)} {deploy.deploy_url && (
e.stopPropagation()} > )}
{deploy.state === 'ready' ? ( ) : ( )}
))}
)} {activeSiteIndex !== -1 && builds.length > 0 && (

最近构建

{builds.map((build) => (
{build.done && !build.error ? (
) : build.error ? (
) : (
)} {build.done ? (build.error ? '失败' : '完成') : '进行中'}
{formatDistanceToNow(new Date(build.created_at), { locale: zhCN })} 前
{build.error && (
错误: {build.error}
)}
))}
)}
)}
); }; return (
{!connection.isConnect ? (
setTokenInput(e.target.value)} placeholder="输入您的 Netlify API 令牌" className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1', 'border border-upage-elements-borderColor dark:border-upage-elements-borderColor', 'text-upage-elements-textPrimary dark:text-upage-elements-textPrimary placeholder-upage-elements-textTertiary dark:placeholder-upage-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent', )} />
获取您的令牌
) : (
已连接到 Netlify
{renderStats()}
)}
); }