import { useStore } from '@nanostores/react'; import { useFetcher, useRouteLoaderData } from '@remix-run/react'; import classNames from 'classnames'; import { format, formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale/zh-CN'; import { motion } from 'framer-motion'; import React, { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Badge } from '~/.client/components/ui/Badge'; import { Button } from '~/.client/components/ui/Button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/.client/components/ui/Collapsible'; import { _1PanelConnectionStore, fetch1PanelStats, isFetchingStats, update1PanelConnection, } from '~/.client/stores/1panel'; import { getChatId } from '~/.client/stores/ai-state'; import type { ConnectionSettings } from '~/root'; import type { _1PanelWebsite } from '~/types/1panel'; import type { ApiResponse } from '~/types/global'; import ConnectionBorder from './components/ConnectionBorder'; export default function _1PanelConnection({ isDeploying, onDeploy, }: { isDeploying: boolean; onDeploy: (siteId: number) => void; }) { const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root'); const connectFetcher = useFetcher(); const settingsFetcher = useFetcher(); const connection = useStore(_1PanelConnectionStore); const [serverUrl, setServerUrl] = useState(''); const [apiKey, setApiKey] = useState(''); const fetching = useStore(isFetchingStats); const [isStatsOpen, setIsStatsOpen] = useState(false); const [activeSiteIndex, setActiveSiteIndex] = useState(-1); const [isActionLoading, setIsActionLoading] = useState(false); // 使用 useMemo 计算 isConnecting 状态 const isConnecting = useMemo(() => { return connectFetcher.state !== 'idle'; }, [connectFetcher.state]); useEffect(() => { update1PanelConnection({ isConnect: rootData?.connectionSettings?._1PanelConnection, }); }, [rootData]); useEffect(() => { if (connection.isConnect) { fetch1PanelStats(); } }, [connection.isConnect]); // 监听 connectFetcher 状态变化(连接) useEffect(() => { const data = connectFetcher.data as ApiResponse<{ websites: _1PanelWebsite[]; totalWebsites: number; lastUpdated: string; }>; if (connectFetcher.state === 'idle' && data) { if (data.success) { update1PanelConnection({ isConnect: true, stats: data.data, serverUrl, }); toast.success('连接 1Panel 成功'); } else if (data.message) { toast.error(`连接 1Panel 失败: ${data.message}`); } } }, [connectFetcher.state, connectFetcher.data, serverUrl]); // 监听 settingsFetcher 状态变化(断开连接) useEffect(() => { if (settingsFetcher.state === 'idle' && settingsFetcher.data) { if (settingsFetcher.data.success) { update1PanelConnection({ isConnect: false, serverUrl: '' }); toast.success('断开 1Panel 服务器连接'); } } }, [settingsFetcher.state, settingsFetcher.data]); const handleConnect = async (event: React.FormEvent) => { if (!serverUrl) { toast.error('请填写服务器地址'); return; } if (!apiKey) { toast.error('请输入 API 密钥'); return; } event.preventDefault(); try { connectFetcher.submit( { serverUrl, apiKey }, { method: 'POST', action: '/api/1panel/auth', encType: 'application/json', }, ); } catch (error) { toast.error(`连接 1Panel 失败: ${error instanceof Error ? error.message : '未知错误'}`); } }; const handleDisconnect = async () => { try { settingsFetcher.submit( { category: 'connectivity', key: '1panel_server_url', }, { method: 'DELETE', action: '/api/user/settings', encType: 'application/json', }, ); await fetch('/api/user/settings', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ category: 'connectivity', key: '1panel_api_key', }), }); } catch (error) { toast.error('断开 1Panel 连接失败'); console.error('断开 1Panel 连接失败:', error); } }; const handleDeleteWebsite = async (e: React.MouseEvent, site: _1PanelWebsite) => { e.stopPropagation(); if (!confirm(`您确定要删除站点 ${site.alias} 吗?`)) { return; } setIsActionLoading(true); try { const response = await fetch('/api/1panel/websites', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ siteId: site.id, }), }); const { success, message } = (await response.json()) as ApiResponse; if (!response.ok || !success) { toast.error(`删除站点失败: ${message}`); return; } toast.success(message || '站点删除成功'); const currentSiteId = localStorage.getItem(`1panel-project-${getChatId()}`); if (currentSiteId === site.id.toString()) { localStorage.removeItem(`1panel-project-${getChatId()}`); } fetch1PanelStats(); } catch (err: unknown) { const error = err instanceof Error ? err.message : '未知错误'; toast.error(`删除站点失败: ${error}`); } setIsActionLoading(false); }; const handleDeployToSite = (e: React.MouseEvent, site: _1PanelWebsite) => { e.stopPropagation(); onDeploy(site.id); }; const formatExpirationDate = (date: string) => { const dateObj = new Date(date); if (isNaN(dateObj.getTime())) { return '未知'; } // 将日期格式化为 YYYY-MM-DD const formattedDate = format(dateObj, 'yyyy-MM-dd'); if (formattedDate === '9999-12-31') { return '永不过期'; } return formattedDate; }; const renderStats = () => { if (!connection.isConnect || !connection.stats) { return null; } return (
1Panel 统计信息
{connection.stats.totalWebsites} 站点 {connection.stats.lastUpdated && (
更新于 {formatDistanceToNow(new Date(connection.stats.lastUpdated), { locale: zhCN })} 前 )}
{connection.stats.websites.length > 0 && (

您的站点

{connection.stats.websites.map((site, index) => (
{ if (activeSiteIndex === index) { setActiveSiteIndex(-1); } else { setActiveSiteIndex(index); } }} >
{site.alias}
{site.status === 'Running' ? (
) : (
)} {site.status === 'Running' ? '已启动' : '已停止'}
{site.domains.map((domain) => ( e.stopPropagation()} >
{`${site.protocol.toLowerCase()}://${domain.domain}`} ))}
{(() => { const typeInfo = getWebsiteTypeInfo(site.type); return (
{typeInfo.label} ); })()} {activeSiteIndex === index && (
创建于{' '} {formatDistanceToNow(new Date(site.createdAt), { locale: zhCN })} {' '} 前
过期时间:{' '} {formatExpirationDate(site.expireDate)}
)}
{activeSiteIndex === index && ( <>
{site.type === 'static' && ( handleDeployToSite(e, site)} disabled={isDeploying} className="px-4 py-2 rounded-lg h-8 bg-black dark:bg-white dark:text-black text-white text-sm hover:bg-gray-800 dark:hover:bg-gray-200 inline-flex items-center gap-2" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} > {isDeploying ? ( <>
部署中... ) : ( <>
部署到此网站 )} )}
)}
))}
)}
); }; // 根据网站类型返回对应的标签信息 const getWebsiteTypeInfo = (type: string) => { switch (type) { case 'deployment': return { label: '一键部署', icon: 'i-ph:rocket-launch', color: 'bg-blue-100 text-blue-700 dark:bg-blue-800/30 dark:text-blue-300', }; case 'runtime': return { label: '运行环境', icon: 'i-ph:code', color: 'bg-green-100 text-green-700 dark:bg-green-800/30 dark:text-green-300', }; case 'proxy': return { label: '反向代理', icon: 'i-ph:arrows-left-right', color: 'bg-purple-100 text-purple-700 dark:bg-purple-800/30 dark:text-purple-300', }; case 'static': return { label: '静态网站', icon: 'i-ph:file-html', color: 'bg-orange-100 text-orange-700 dark:bg-orange-800/30 dark:text-orange-300', }; case 'subsite': return { label: '子网站', icon: 'i-ph:tree-structure', color: 'bg-gray-100 text-gray-700 dark:bg-gray-800/30 dark:text-gray-300', }; default: return { label: '未知类型', icon: 'i-ph:question', color: 'bg-gray-100 text-gray-700 dark:bg-gray-800/30 dark:text-gray-300', }; } }; return ( {!connection.isConnect ? (
setServerUrl(e.target.value)} disabled={fetching} placeholder="https://your-1panel-server.com" className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive', 'disabled:opacity-50', )} />
setApiKey(e.target.value)} disabled={fetching} placeholder="请输入您的 API 密钥" className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-upage-elements-textPrimary placeholder-upage-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive', 'disabled:opacity-50', )} />
) : (
已连接到 1Panel 服务器
{renderStats()}
)} ); }