Files
upage-git/app/components/header/connections/_1PanelConnection.tsx
2025-09-24 17:02:44 +08:00

582 lines
26 KiB
TypeScript

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 '~/components/ui/Badge';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
import { _1PanelConnectionStore, fetch1PanelStats, isFetchingStats, update1PanelConnection } from '~/lib/stores/1panel';
import { getChatId } from '~/lib/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<ApiResponse>();
const settingsFetcher = useFetcher<ApiResponse>();
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<HTMLButtonElement>, 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<HTMLButtonElement>, 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 (
<div className="mt-6">
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-upage-elements-background dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70 dark:hover:border-upage-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
1Panel
</span>
</div>
<div
className={classNames(
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
isStatsOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center gap-4">
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
<span>{connection.stats.totalWebsites} </span>
</Badge>
{connection.stats.lastUpdated && (
<Badge
variant="outline"
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="i-lucide:clock size-4 text-upage-elements-item-contentAccent" />
<span>
{formatDistanceToNow(new Date(connection.stats.lastUpdated), { locale: zhCN })}
</span>
</Badge>
)}
</div>
{connection.stats.websites.length > 0 && (
<div className="mt-4 space-y-4">
<div className="bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
<div className="heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
</h4>
<Button
variant="outline"
size="sm"
onClick={() => fetch1PanelStats()}
disabled={fetching}
className="flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary hover:bg-upage-elements-item-backgroundActive/10"
>
<div
className={classNames(
'heroicons:arrow-path size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent',
{ 'animate-spin': fetching },
)}
/>
{fetching ? '刷新中...' : '刷新'}
</Button>
</div>
<div className="space-y-3">
{connection.stats.websites.map((site, index) => (
<div
key={site.id}
className={classNames(
'bg-upage-elements-background dark:bg-upage-elements-background-depth-1 border rounded-lg p-4 transition-all',
activeSiteIndex === index
? 'border-upage-elements-item-contentAccent bg-upage-elements-item-backgroundActive/10'
: 'border-upage-elements-borderColor hover:border-upage-elements-borderColorActive/70',
)}
onClick={() => {
if (activeSiteIndex === index) {
setActiveSiteIndex(-1);
} else {
setActiveSiteIndex(index);
}
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="heroicons:globe-alt size-5 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{site.alias}
</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={site.status === 'Running' ? 'default' : 'destructive'}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
{site.status === 'Running' ? (
<div className="i-lucide:check-circle size-4 text-green-500" />
) : (
<div className="i-lucide:x-circle size-4 text-red-500" />
)}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{site.status === 'Running' ? '已启动' : '已停止'}
</span>
</Badge>
</div>
</div>
<div className="mt-3 flex flex-col gap-2">
{site.domains.map((domain) => (
<a
key={domain.id}
href={`${site.protocol.toLowerCase()}://${domain.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm flex items-center gap-1 transition-colors text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:text-white dark:hover:text-upage-elements-link-textHover w-fit"
onClick={(e) => e.stopPropagation()}
>
<div className="heroicons:paper-airplane size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">
{`${site.protocol.toLowerCase()}://${domain.domain}`}
</span>
</a>
))}
<div className="flex items-center gap-2 mt-1">
{(() => {
const typeInfo = getWebsiteTypeInfo(site.type);
return (
<Badge
variant="secondary"
className={`flex items-center gap-1 px-1.5 py-0.5 rounded-md ${typeInfo.color}`}
>
<div className={`${typeInfo.icon} size-3`} />
<span>{typeInfo.label}</span>
</Badge>
);
})()}
{activeSiteIndex === index && (
<div className="flex gap-4 text-sm text-gray-700">
<div className="flex items-center gap-1">
<div className="i-lucide:clock size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
{' '}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{formatDistanceToNow(new Date(site.createdAt), { locale: zhCN })}
</span>{' '}
</span>
</div>
<div className="flex items-center gap-1">
<div className="i-pajamas:expire size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent"></div>
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
:{' '}
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
{formatExpirationDate(site.expireDate)}
</span>
</span>
</div>
</div>
)}
</div>
</div>
{activeSiteIndex === index && (
<>
<div className="mt-4 pt-3 border-t border-upage-elements-borderColor"></div>
<div className="text-sm flex justify-end">
<div>
<div className="flex items-center gap-2">
{site.type === 'static' && (
<motion.button
onClick={(e) => 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 ? (
<>
<div className="i-ph:spinner-gap animate-spin size-4" />
...
</>
) : (
<>
<div className="i-ph:rocket-launch size-4" />
</>
)}
</motion.button>
)}
<Button
key="delete"
variant="destructive"
size="sm"
onClick={(e) => handleDeleteWebsite(e, site)}
disabled={isActionLoading}
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
>
<div className="i-lucide:trash size-4 text-white text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
</Button>
</div>
</div>
</div>
</>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
// 根据网站类型返回对应的标签信息
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 (
<ConnectionBorder>
{!connection.isConnect ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-upage-elements-textSecondary mb-2"></label>
<input
type="text"
value={serverUrl}
onChange={(e) => 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',
)}
/>
</div>
<div>
<label className="block text-sm text-upage-elements-textSecondary mb-2">API </label>
<input
type="password"
value={apiKey}
onChange={(e) => 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',
)}
/>
</div>
<button
onClick={handleConnect}
disabled={isConnecting || !serverUrl || !apiKey}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-upage-elements-button-secondary-background',
'hover:bg-upage-elements-button-secondary-backgroundHover',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
...
</>
) : (
<>
<div className="i-ph:plug-charging size-4" />
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm text-upage-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle size-4 text-green-500" />
1Panel
</span>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
onClick={() => window.open(`${connection.serverUrl}/websites`, '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
<div className="i-mingcute:dashboard-line size-4" />
</Button>
<Button
onClick={() => fetch1PanelStats()}
disabled={fetching}
variant="outline"
className="flex items-center gap-2 hover:bg-upage-elements-item-backgroundActive/10 hover:text-upage-elements-textPrimary dark:hover:text-upage-elements-textPrimary transition-colors"
>
{fetching ? (
<>
<div className="i-ph:spinner-gap size-4 animate-spin text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
...
</span>
</>
) : (
<>
<div className="heroicons:arrow-path size-4 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary" />
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
</span>
</>
)}
</Button>
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
<div className="i-ph:sign-out size-4" />
</Button>
</div>
</div>
{renderStats()}
</div>
)}
</ConnectionBorder>
);
}