refactor: repartition server-side and client-side code
This commit is contained in:
85
app/.client/components/header/ChatDescription.client.tsx
Normal file
85
app/.client/components/header/ChatDescription.client.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { useEffect } from 'react';
|
||||
import WithTooltip from '~/.client/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/.client/hooks';
|
||||
import { useChatHistory } from '~/.client/hooks/useChatHistory';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
|
||||
export function ChatDescription() {
|
||||
const { getChatLatestDescription } = useChatHistory();
|
||||
const description = useStore(webBuilderStore.chatStore.description);
|
||||
|
||||
const {
|
||||
editing,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
currentDescription,
|
||||
toggleEditMode,
|
||||
setCurrentDescription,
|
||||
updateChatDescription,
|
||||
} = useEditChatDescription({
|
||||
initialDescription: getChatLatestDescription() || '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentDescription && description) {
|
||||
setCurrentDescription(description);
|
||||
updateChatDescription(description);
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
if (!currentDescription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{editing ? (
|
||||
<form onSubmit={handleSubmit} className="flex items-center justify-center">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-upage-elements-background-depth-1 text-upage-elements-textPrimary rounded px-2 py-0.5 mr-2 focus:outline-none focus:ring-1 focus:ring-upage-elements-ring"
|
||||
autoFocus
|
||||
value={currentDescription}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: `${Math.max(currentDescription?.length * 9 || 0, 180)}px` }}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<WithTooltip tooltip="保存标题">
|
||||
<div className="flex justify-between items-center p-2 rounded-md bg-upage-elements-item-backgroundAccent">
|
||||
<button
|
||||
type="submit"
|
||||
className="i-ph:check-bold scale-110 hover:text-upage-elements-item-contentAccent"
|
||||
onMouseDown={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{currentDescription}
|
||||
<TooltipProvider>
|
||||
<WithTooltip tooltip="重命名聊天">
|
||||
<div className="flex justify-between items-center p-2 rounded-md bg-upage-elements-item-backgroundAccent ml-2">
|
||||
<button
|
||||
type="button"
|
||||
className="i-ph:pencil-fill scale-110 hover:text-upage-elements-item-contentAccent"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WithTooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
app/.client/components/header/DeployTo1PanelDialog.tsx
Normal file
201
app/.client/components/header/DeployTo1PanelDialog.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import _1PanelConnection from '~/.client/components/header/connections/_1PanelConnection';
|
||||
import { useChatDeployment } from '~/.client/hooks/useChatDeployment';
|
||||
import { _1PanelConnectionStore } from '~/.client/stores/1panel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
interface DeployTo1PanelDialogProps {
|
||||
isOpen: boolean;
|
||||
deploying: boolean;
|
||||
onClose: () => void;
|
||||
onDeploy: (options?: { customDomain?: string; siteId?: number; protocol?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function DeployTo1PanelDialog({ deploying, isOpen, onClose, onDeploy }: DeployTo1PanelDialogProps) {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
|
||||
const connection = useStore(_1PanelConnectionStore);
|
||||
const [is1PanelConnected, setIs1PanelConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
const [proxyProtocol, setProxyProtocol] = useState('http');
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
setIs1PanelConnected(true);
|
||||
if (isOpen && !is1PanelConnected && !showConnectionForm) {
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIs1PanelConnected(false);
|
||||
if (isOpen && !showConnectionForm) {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
}, [connection.isConnect, isOpen, is1PanelConnected]);
|
||||
|
||||
const check1PanelConnection = () => {
|
||||
if (connection.isConnect) {
|
||||
setIs1PanelConnected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async (options?: { customDomain?: string; siteId?: number; protocol?: string }) => {
|
||||
if (!connection.isConnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onDeploy({
|
||||
...options,
|
||||
customDomain: customDomain || undefined,
|
||||
protocol: proxyProtocol,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleProtocol = () => {
|
||||
setProxyProtocol(proxyProtocol === 'http' ? 'https' : 'http');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deploying) {
|
||||
onClose();
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deploymentInfo = getDeploymentByPlatform(DeploymentPlatformEnum._1PANEL);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<Dialog.Title className="sr-only">部署到 1Panel</Dialog.Title>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/icons/1panel.png" alt="1Panel" className="size-5" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{is1PanelConnected ? '部署到 1Panel' : '连接 1Panel 服务器'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{is1PanelConnected
|
||||
? '您的项目将被部署到 1Panel。点击"部署"按钮开始部署。'
|
||||
: '需要连接 1Panel 服务器才能部署项目。请在此页面完成连接。'}
|
||||
</p>
|
||||
|
||||
{!is1PanelConnected && (
|
||||
<p className="text-sm font-medium text-amber-600 dark:text-amber-500 mb-6 flex items-center gap-1.5">
|
||||
<span className="i-ph:warning-circle size-4 flex-shrink-0" />
|
||||
仅适用于 1Panel V2 版本
|
||||
</p>
|
||||
)}
|
||||
|
||||
{is1PanelConnected && !deploymentInfo?.id && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">自定义域名(可选)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleProtocol}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-[#F8F8F8] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333] text-gray-900 dark:text-white hover:bg-[#F0F0F0] dark:hover:bg-[#222222] transition-colors focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive"
|
||||
>
|
||||
{proxyProtocol}
|
||||
</button>
|
||||
<span className="text-gray-500 dark:text-gray-400">://</span>
|
||||
<input
|
||||
type="text"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
placeholder="example.upage.ai"
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-[#F8F8F8] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333] text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-upage-elements-borderColorActive"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">留空将使用自动生成的域名</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="1panel-connection-wrapper">
|
||||
<_1PanelConnection isDeploying={deploying} onDeploy={(siteId) => handleDeploy({ siteId })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{!is1PanelConnected ? (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
check1PanelConnection();
|
||||
setTimeout(check1PanelConnection, 500);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => handleDeploy()}
|
||||
disabled={deploying}
|
||||
className="px-4 py-2 rounded-lg bg-[#2b5fe3] text-white text-sm hover:bg-[#2b5fe3]/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
{!!deploymentInfo?.id ? '覆盖已有网站' : '部署到 1Panel'}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
163
app/.client/components/header/DeployToNetlifyDialog.tsx
Normal file
163
app/.client/components/header/DeployToNetlifyDialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Suspense, useEffect, useState } from 'react';
|
||||
import { useChatDeployment } from '~/.client/hooks/useChatDeployment';
|
||||
import { netlifyConnection } from '~/.client/stores/netlify';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
const NetlifyConnection = React.lazy(() => import('~/.client/components/header/connections/NetlifyConnection'));
|
||||
|
||||
interface DeployToNetlifyDialogProps {
|
||||
isOpen: boolean;
|
||||
deploying: boolean;
|
||||
onClose: () => void;
|
||||
onDeploy: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function DeployToNetlifyDialog({ deploying, isOpen, onClose, onDeploy }: DeployToNetlifyDialogProps) {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [isNetlifyConnected, setIsNetlifyConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
setIsNetlifyConnected(true);
|
||||
if (isOpen && !isNetlifyConnected && !showConnectionForm) {
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
} else {
|
||||
setIsNetlifyConnected(false);
|
||||
if (isOpen && !showConnectionForm) {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
}
|
||||
}, [connection.isConnect, isOpen, isNetlifyConnected]);
|
||||
|
||||
const checkNetlifyConnection = () => {
|
||||
if (connection.isConnect) {
|
||||
setIsNetlifyConnected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!connection.isConnect) {
|
||||
return;
|
||||
}
|
||||
await onDeploy();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deploying) {
|
||||
onClose();
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deploymentInfo = getDeploymentByPlatform(DeploymentPlatformEnum.NETLIFY);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<Dialog.Title className="sr-only">部署到 Netlify</Dialog.Title>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[#00AD9F] i-simple-icons:netlify size-5"></div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{isNetlifyConnected ? '部署到 Netlify' : '连接 Netlify 账户'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isNetlifyConnected
|
||||
? '您的项目将被部署到 Netlify。点击"部署"按钮开始部署。'
|
||||
: '需要连接 Netlify 账户才能部署项目。请在此页面完成连接。'}
|
||||
</p>
|
||||
|
||||
<div className="netlify-connection-wrapper">
|
||||
<Suspense>
|
||||
<NetlifyConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{isNetlifyConnected ? (
|
||||
<motion.button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying}
|
||||
className="px-4 py-2 rounded-lg bg-[#00AD9F] text-white text-sm hover:bg-[#009688] inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
{!!deploymentInfo?.id ? '覆盖已有网站' : '部署到 Netlify'}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
checkNetlifyConnection();
|
||||
setTimeout(checkNetlifyConnection, 500);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
159
app/.client/components/header/DeployToVercelDialog.tsx
Normal file
159
app/.client/components/header/DeployToVercelDialog.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import VercelConnection from '~/.client/components/header/connections/VercelConnection';
|
||||
import { useChatDeployment } from '~/.client/hooks/useChatDeployment';
|
||||
import { vercelConnection } from '~/.client/stores/vercel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
|
||||
interface DeployToVercelDialogProps {
|
||||
isOpen: boolean;
|
||||
deploying: boolean;
|
||||
onClose: () => void;
|
||||
onDeploy: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function DeployToVercelDialog({ deploying, isOpen, onClose, onDeploy }: DeployToVercelDialogProps) {
|
||||
const { getDeploymentByPlatform } = useChatDeployment();
|
||||
const connection = useStore(vercelConnection);
|
||||
const [isVercelConnected, setIsVercelConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.user) {
|
||||
setIsVercelConnected(true);
|
||||
if (isOpen && !isVercelConnected && !showConnectionForm) {
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
} else {
|
||||
setIsVercelConnected(false);
|
||||
if (isOpen && !showConnectionForm) {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
}
|
||||
}, [connection.user, isOpen, isVercelConnected]);
|
||||
|
||||
const checkVercelConnection = () => {
|
||||
if (connection.user) {
|
||||
setIsVercelConnected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!connection.user) {
|
||||
return;
|
||||
}
|
||||
onDeploy();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deploying) {
|
||||
onClose();
|
||||
setShowConnectionForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deploymentInfo = getDeploymentByPlatform(DeploymentPlatformEnum.VERCEL);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<Dialog.Title className="sr-only">部署到 Vercel</Dialog.Title>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-skill-icons:vercel-light size-5"></div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{connection.user ? '部署到 Vercel' : '连接 Vercel 账户'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{isVercelConnected
|
||||
? '您的项目将被部署到 Vercel。点击"部署"按钮开始部署。'
|
||||
: '需要连接 Vercel 账户才能部署项目。请在此页面完成连接。'}
|
||||
</p>
|
||||
|
||||
<div className="vercel-connection-wrapper">
|
||||
<VercelConnection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{!isVercelConnected ? (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
checkVercelConnection();
|
||||
setTimeout(checkVercelConnection, 500);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying}
|
||||
className="px-4 py-2 rounded-lg 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 }}
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
部署中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:rocket-launch size-4" />
|
||||
{!!deploymentInfo?.id ? '覆盖已有网站' : '部署到 Vercel'}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
64
app/.client/components/header/Header.tsx
Normal file
64
app/.client/components/header/Header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { useAuth } from '~/.client/hooks';
|
||||
import { aiState } from '~/.client/stores/ai-state';
|
||||
import { themeStore } from '~/stores/theme';
|
||||
import { HistorySwitch } from '../sidebar/HistorySwitch';
|
||||
import { ThemeSwitch } from '../ui/ThemeSwitch';
|
||||
import { ChatDescription } from './ChatDescription.client';
|
||||
import { HeaderActionButtons } from './HeaderActionButtons';
|
||||
import { MinimalAvatarDropdown } from './MinimalAvatarDropdown';
|
||||
|
||||
export function Header() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { chatStarted } = useStore(aiState);
|
||||
const theme = useStore(themeStore);
|
||||
const logoSrc = useMemo(() => (theme === 'dark' ? '/logo-dark.png' : '/logo.png'), [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={classNames(
|
||||
'flex items-center justify-between px-3 py-2 gap-3 shrink-0 border-b h-[var(--header-height)]',
|
||||
{
|
||||
'border-transparent': !chatStarted,
|
||||
'border-upage-elements-borderColor': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 z-logo text-upage-elements-textPrimary cursor-pointer">
|
||||
<a href="/" className="text-xl font-semibold text-accent flex items-center">
|
||||
<picture>
|
||||
<img src={logoSrc} alt="UPage Logo" className="h-6" />
|
||||
</picture>
|
||||
</a>
|
||||
<div className="flex gap-1">
|
||||
{isAuthenticated && <HistorySwitch />}
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 truncate text-center text-upage-elements-textPrimary">
|
||||
{chatStarted && <ClientOnly>{() => <ChatDescription />}</ClientOnly>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{chatStarted && (
|
||||
<>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)}
|
||||
<MinimalAvatarDropdown />
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
442
app/.client/components/header/HeaderActionButtons.tsx
Normal file
442
app/.client/components/header/HeaderActionButtons.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { toast } from 'sonner';
|
||||
import { NetlifyDeploymentLink } from '~/.client/components/chat/NetlifyDeploymentLink.client';
|
||||
import useViewport from '~/.client/hooks';
|
||||
import { setLocalStorage } from '~/.client/persistence';
|
||||
import { aiState, setShowChat } from '~/.client/stores/ai-state';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import type { _1PanelDeployResponse } from '~/types/1panel';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import { _1PanelDeploymentLink } from '../chat/_1PanelDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '../chat/VercelDeploymentLink.client';
|
||||
import { UPageIndex } from '../upage/Index';
|
||||
import { DeployTo1PanelDialog } from './DeployTo1PanelDialog';
|
||||
import { DeployToNetlifyDialog } from './DeployToNetlifyDialog';
|
||||
import { DeployToVercelDialog } from './DeployToVercelDialog';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(webBuilderStore.showWorkbench);
|
||||
const { showChat, chatId, isStreaming } = useStore(aiState);
|
||||
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | '1panel' | null>(null);
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const netlifyFetcher = useFetcher<ApiResponse>();
|
||||
const vercelFetcher = useFetcher<ApiResponse>();
|
||||
const panelFetcher = useFetcher<_1PanelDeployResponse>();
|
||||
|
||||
const isDeploying = useMemo(() => {
|
||||
return netlifyFetcher.state !== 'idle' || vercelFetcher.state !== 'idle' || panelFetcher.state !== 'idle';
|
||||
}, [netlifyFetcher.state, vercelFetcher.state, panelFetcher.state]);
|
||||
|
||||
const [showNetlifyDialog, setShowNetlifyDialog] = useState(false);
|
||||
const [showVercelDialog, setShowVercelDialog] = useState(false);
|
||||
const [show1PanelDialog, setShow1PanelDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const deploy = url.searchParams.get('deploy');
|
||||
switch (deploy) {
|
||||
case DeploymentPlatformEnum.NETLIFY:
|
||||
setShowNetlifyDialog(true);
|
||||
break;
|
||||
case DeploymentPlatformEnum.VERCEL:
|
||||
setShowVercelDialog(true);
|
||||
break;
|
||||
case DeploymentPlatformEnum._1PANEL:
|
||||
setShow1PanelDialog(true);
|
||||
break;
|
||||
}
|
||||
const recommend = url.searchParams.get('recommend');
|
||||
if (recommend) {
|
||||
setLocalStorage('recommend', recommend || '');
|
||||
}
|
||||
if (deploy || recommend) {
|
||||
url.searchParams.delete('deploy');
|
||||
url.searchParams.delete('recommend');
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (netlifyFetcher.state === 'idle' && netlifyFetcher.data) {
|
||||
const { data, success, message } = netlifyFetcher.data;
|
||||
|
||||
if (success && data?.deploy && data?.site) {
|
||||
if (data.site) {
|
||||
localStorage.setItem(`netlify-site-${chatId!}`, data.site?.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署成功!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShowNetlifyDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [netlifyFetcher.state, netlifyFetcher.data, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (vercelFetcher.state === 'idle' && vercelFetcher.data) {
|
||||
const { data, success, message } = vercelFetcher.data;
|
||||
|
||||
if (success && data?.deploy && data?.project) {
|
||||
if (data.project) {
|
||||
localStorage.setItem(`vercel-project-${chatId!}`, data.project.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署到 Vercel 成功!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShowVercelDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [vercelFetcher.state, vercelFetcher.data, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (panelFetcher.state === 'idle' && panelFetcher.data) {
|
||||
const data = panelFetcher.data as _1PanelDeployResponse;
|
||||
|
||||
const { deploy } = data.data || {};
|
||||
if (data.success && deploy) {
|
||||
localStorage.setItem(`1panel-project-${chatId!}`, deploy.id.toString());
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
部署到 1Panel 成功!{' '}
|
||||
<a href={deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
查看站点
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
setShow1PanelDialog(false);
|
||||
} else {
|
||||
console.error('Invalid deploy response:', data);
|
||||
toast.error(data.message || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [panelFetcher.state, panelFetcher.data, chatId]);
|
||||
|
||||
async function getAllFiles(): Promise<Record<string, string>> {
|
||||
const files = await webBuilderStore.getProjectFiles({ inline: false }).then((files) => {
|
||||
return files.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.filename] = file.content;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
});
|
||||
const newFiles: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(files)) {
|
||||
if (key.endsWith('.html')) {
|
||||
const html = new DOMParser().parseFromString(value, 'text/html');
|
||||
const originalContent = html.body.innerHTML;
|
||||
// 添加 UPageHtml 到 body 中
|
||||
const uPageHtml = renderToStaticMarkup(<UPageIndex />);
|
||||
html.body.innerHTML = originalContent + uPageHtml;
|
||||
newFiles[key] = '<!DOCTYPE html>\n' + html.documentElement.outerHTML;
|
||||
} else {
|
||||
newFiles[key] = value;
|
||||
}
|
||||
}
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
const handleNetlifyDeploy = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('netlify');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${chatId}`);
|
||||
|
||||
netlifyFetcher.submit(
|
||||
{
|
||||
siteId: existingSiteId || '',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/netlify/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : '部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [chatId, netlifyFetcher]);
|
||||
|
||||
const handleVercelDeploy = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('vercel');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingProjectId = localStorage.getItem(`vercel-project-${chatId}`);
|
||||
|
||||
vercelFetcher.submit(
|
||||
{
|
||||
projectId: existingProjectId || '',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/vercel/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Vercel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Vercel 部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
}, [chatId, vercelFetcher]);
|
||||
|
||||
const handle1PanelDeploy = useCallback(
|
||||
async (options?: { customDomain?: string; siteId?: number; protocol?: string }) => {
|
||||
if (!chatId) {
|
||||
toast.error('没有找到活动聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeployingTo('1panel');
|
||||
|
||||
const fileContents = await getAllFiles();
|
||||
const existingWebsiteId = localStorage.getItem(`1panel-project-${chatId}`);
|
||||
|
||||
panelFetcher.submit(
|
||||
{
|
||||
websiteId: options?.siteId || existingWebsiteId || '',
|
||||
websiteDomain: options?.customDomain || '',
|
||||
protocol: options?.protocol || 'http',
|
||||
files: fileContents,
|
||||
chatId: chatId!,
|
||||
} as any,
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/1panel/deploy',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('1Panel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : '1Panel 部署失败');
|
||||
setDeployingTo(null);
|
||||
}
|
||||
},
|
||||
[chatId, panelFetcher],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="flex border border-upage-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || isStreaming}
|
||||
onClick={() => {
|
||||
if (isDeploying || isStreaming) {
|
||||
return;
|
||||
}
|
||||
setShow1PanelDialog(true);
|
||||
}}
|
||||
className="px-4 hover:bg-upage-elements-item-backgroundActive flex items-center gap-2"
|
||||
>
|
||||
<div className="i-mingcute:rocket-line size-4" />
|
||||
{isDeploying ? `部署至 ${deployingTo} 中...` : '部署'}
|
||||
</Button>
|
||||
<div className="w-[1px] bg-upage-elements-borderColor" />
|
||||
<Button
|
||||
active
|
||||
disabled={isDeploying || isStreaming}
|
||||
onClick={() => {
|
||||
if (isDeploying || isStreaming) {
|
||||
return;
|
||||
}
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames('i-ph:caret-down size-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[14rem] bg-upage-elements-background-depth-2 rounded-md shadow-lg bg-upage-elements-backgroundDefault border border-upage-elements-borderColor">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShow1PanelDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<img src="/icons/1panel.png" alt="1Panel" className="size-5" />
|
||||
<span>部署到 1Panel</span>
|
||||
<_1PanelDeploymentLink />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowNetlifyDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<div className="i-simple-icons:netlify size-5 bg-#00C7B7"></div>
|
||||
<span>部署到 Netlify</span>
|
||||
<NetlifyDeploymentLink />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowVercelDialog(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-upage-elements-textPrimary gap-3 rounded-md group relative"
|
||||
>
|
||||
<div className="i-skill-icons:vercel-light size-5"></div>
|
||||
<span>部署到 Vercel</span>
|
||||
<VercelDeploymentLink />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex border border-upage-elements-borderColor rounded-md overflow-hidden mr-2">
|
||||
<Button
|
||||
active={showChat}
|
||||
disabled={!canHideChat || isSmallViewport}
|
||||
onClick={() => {
|
||||
if (canHideChat) {
|
||||
setShowChat(!showChat);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:chat-2-line text-sm" />
|
||||
</Button>
|
||||
<div className="w-[1px] bg-upage-elements-borderColor" />
|
||||
<Button
|
||||
active={showWorkbench}
|
||||
onClick={() => {
|
||||
if (showWorkbench && !showChat) {
|
||||
setShowChat(true);
|
||||
}
|
||||
|
||||
webBuilderStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:code-line" />
|
||||
</Button>
|
||||
</div>
|
||||
<DeployToNetlifyDialog
|
||||
isOpen={showNetlifyDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShowNetlifyDialog(false)}
|
||||
onDeploy={handleNetlifyDeploy}
|
||||
/>
|
||||
<DeployToVercelDialog
|
||||
isOpen={showVercelDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShowVercelDialog(false)}
|
||||
onDeploy={handleVercelDeploy}
|
||||
/>
|
||||
<DeployTo1PanelDialog
|
||||
isOpen={show1PanelDialog}
|
||||
deploying={isDeploying}
|
||||
onClose={() => setShow1PanelDialog(false)}
|
||||
onDeploy={handle1PanelDeploy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: any;
|
||||
onClick?: VoidFunction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center p-1.5',
|
||||
{
|
||||
'bg-upage-elements-item-backgroundDefault hover:bg-upage-elements-item-backgroundAccent text-upage-elements-textPrimary hover:text-upage-elements-item-contentAccent':
|
||||
!active,
|
||||
'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent': active && !disabled,
|
||||
'bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent cursor-not-allowed':
|
||||
active && disabled,
|
||||
'bg-upage-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
||||
!active && disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
194
app/.client/components/header/MinimalAvatarDropdown.tsx
Normal file
194
app/.client/components/header/MinimalAvatarDropdown.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChatUsageDialog } from '~/.client/components/chat/usage/ChatUsageDialog';
|
||||
import { DeploymentRecordsDialog } from '~/.client/components/chat/usage/DeploymentRecordsDialog';
|
||||
import { Button } from '~/.client/components/ui/Button';
|
||||
import { ConfirmationDialog } from '~/.client/components/ui/Dialog';
|
||||
import { useAuth } from '~/.client/hooks/useAuth';
|
||||
import { useChatUsage } from '~/.client/hooks/useChatUsage';
|
||||
|
||||
interface MinimalAvatarDropdownProps {}
|
||||
|
||||
export const MinimalAvatarDropdown = ({}: MinimalAvatarDropdownProps) => {
|
||||
const { userInfo, isAuthenticated, signOut, signIn } = useAuth();
|
||||
|
||||
const { usageStats } = useChatUsage();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Button variant="secondary" onClick={() => signIn()}>
|
||||
登录 / 注册
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return 'Guest User';
|
||||
}
|
||||
|
||||
return userInfo.name || userInfo.username;
|
||||
}, [userInfo]);
|
||||
|
||||
const contactInfo = useMemo(() => {
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userInfo.phone_number) {
|
||||
return `+${userInfo.phone_number}`;
|
||||
}
|
||||
|
||||
return userInfo.email;
|
||||
}, [userInfo]);
|
||||
|
||||
const avatarUrl = isAuthenticated && userInfo?.picture ? userInfo.picture : '';
|
||||
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const [showUsageDialog, setShowUsageDialog] = useState(false);
|
||||
const [showDeploymentRecordsDialog, setShowDeploymentRecordsDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatUsageDialog isOpen={showUsageDialog} onClose={() => setShowUsageDialog(false)} />
|
||||
<DeploymentRecordsDialog
|
||||
isOpen={showDeploymentRecordsDialog}
|
||||
onClose={() => setShowDeploymentRecordsDialog(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={showLogoutConfirm}
|
||||
onClose={() => setShowLogoutConfirm(false)}
|
||||
title="退出登录?"
|
||||
description="退出登录后,您需要重新登录才能继续使用。"
|
||||
confirmLabel="退出登录"
|
||||
cancelLabel="取消"
|
||||
variant="destructive"
|
||||
onConfirm={() => signOut()}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<motion.button
|
||||
className="size-8 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className="size-full rounded-full object-cover select-none"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
||||
<div className="i-ph:user-circle-fill size-8" />
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className={classNames(
|
||||
'min-w-[240px] z-[250]',
|
||||
'bg-white dark:bg-[#141414]',
|
||||
'rounded-lg shadow-lg',
|
||||
'border border-gray-200/50 dark:border-gray-800/50',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'p-1.5 space-y-1.5',
|
||||
)}
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
>
|
||||
<div className={classNames('px-4 py-3 flex items-center gap-3')}>
|
||||
<div className="size-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
|
||||
{Boolean(avatarUrl) ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className={classNames('size-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
||||
<div className="i-ph:user-circle-fill size-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">{displayName}</div>
|
||||
{!!userInfo?.email && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{contactInfo}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Separator className="h-1px bg-gray-100 dark:bg-gray-800" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
'rounded-md',
|
||||
)}
|
||||
onClick={() => setShowUsageDialog(true)}
|
||||
>
|
||||
<div className="i-ph:chart-line size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
<div className="flex-1">API 使用量</div>
|
||||
{usageStats && (
|
||||
<div className="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-300">
|
||||
{usageStats.total._count}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
'rounded-md',
|
||||
)}
|
||||
onClick={() => setShowDeploymentRecordsDialog(true)}
|
||||
>
|
||||
<div className="i-ph:globe size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
<div className="flex-1">部署记录</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className={classNames(
|
||||
'flex items-center gap-2 px-4 py-2.5',
|
||||
'text-sm text-gray-700 dark:text-gray-200',
|
||||
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
||||
'hover:text-purple-500 dark:hover:text-purple-400',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'outline-none',
|
||||
'group',
|
||||
'rounded-md',
|
||||
)}
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
>
|
||||
<div className="i-ph:sign-out size-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
||||
退出登录
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
957
app/.client/components/header/connections/GithubConnection.tsx
Normal file
957
app/.client/components/header/connections/GithubConnection.tsx
Normal file
@@ -0,0 +1,957 @@
|
||||
import classNames from 'classnames';
|
||||
import Cookies from 'js-cookie';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '~/.client/components/ui/Button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/.client/components/ui/Collapsible';
|
||||
import { logStore } from '~/stores/logs';
|
||||
import ConnectionBorder from './components/ConnectionBorder';
|
||||
|
||||
interface GitHubUserResponse {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
public_gists: number;
|
||||
}
|
||||
|
||||
interface GitHubRepoInfo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
languages_url: string;
|
||||
}
|
||||
|
||||
interface GitHubOrganization {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface GitHubEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
repo: {
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface GitHubLanguageStats {
|
||||
[language: string]: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
repos: GitHubRepoInfo[];
|
||||
recentActivity: GitHubEvent[];
|
||||
languages: GitHubLanguageStats;
|
||||
totalGists: number;
|
||||
publicRepos: number;
|
||||
privateRepos: number;
|
||||
stars: number;
|
||||
forks: number;
|
||||
followers: number;
|
||||
publicGists: number;
|
||||
privateGists: number;
|
||||
lastUpdated: string;
|
||||
|
||||
// Keep these for backward compatibility
|
||||
totalStars?: number;
|
||||
totalForks?: number;
|
||||
organizations?: GitHubOrganization[];
|
||||
}
|
||||
|
||||
interface GitHubConnection {
|
||||
user: GitHubUserResponse | null;
|
||||
token: string;
|
||||
tokenType: 'classic' | 'fine-grained';
|
||||
stats?: GitHubStats;
|
||||
rateLimit?: {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function GitHubConnection() {
|
||||
const [connection, setConnection] = useState<GitHubConnection>({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
||||
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
||||
const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic');
|
||||
|
||||
const fetchGithubUser = async (token: string) => {
|
||||
try {
|
||||
console.log('正在获取 GitHub 用户,使用令牌:', token.substring(0, 5) + '...');
|
||||
|
||||
// Use server-side API endpoint instead of direct GitHub API call
|
||||
const response = await fetch(`/api/system/git-info?action=getUser`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`, // Include token in headers for validation
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('获取 GitHub 用户时出错。状态:', response.status);
|
||||
throw new Error(`错误: ${response.status}`);
|
||||
}
|
||||
|
||||
// Get rate limit information from headers
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
console.log('GitHub 用户 API 响应:', data);
|
||||
|
||||
const { user } = data as { user: GitHubUserResponse };
|
||||
|
||||
// Validate that we received a user object
|
||||
if (!user || !user.login) {
|
||||
console.error('收到无效的用户数据:', user);
|
||||
throw new Error('收到无效的用户数据');
|
||||
}
|
||||
|
||||
// Use the response data
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
rateLimit,
|
||||
}));
|
||||
|
||||
// Set cookies for client-side access
|
||||
Cookies.set('githubUsername', user.login);
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
|
||||
// Store connection details in localStorage
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user,
|
||||
token,
|
||||
tokenType: tokenTypeRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('已连接到 GitHub', {
|
||||
type: 'system',
|
||||
message: `已连接到 GitHub,用户: ${user.login}`,
|
||||
});
|
||||
|
||||
// Fetch additional GitHub stats
|
||||
fetchGitHubStats(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub user:', error);
|
||||
logStore.logError(`GitHub 认证失败: ${error instanceof Error ? error.message : '未知错误'}`, {
|
||||
type: 'system',
|
||||
message: 'GitHub 认证失败',
|
||||
});
|
||||
|
||||
toast.error(`认证失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
throw error; // Rethrow to allow handling in the calling function
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async (token: string) => {
|
||||
setIsFetchingStats(true);
|
||||
|
||||
try {
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const userResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
if (userResponse.status === 401) {
|
||||
toast.error('您的 GitHub 令牌已过期。请重新连接您的账户。');
|
||||
handleDisconnect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch user data: ${userResponse.statusText}`);
|
||||
}
|
||||
|
||||
const userData = (await userResponse.json()) as any;
|
||||
|
||||
// Fetch repositories with pagination
|
||||
let allRepos: any[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reposResponse.ok) {
|
||||
throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`);
|
||||
}
|
||||
|
||||
const repos = (await reposResponse.json()) as any[];
|
||||
allRepos = [...allRepos, ...repos];
|
||||
|
||||
// Check if there are more pages
|
||||
const linkHeader = reposResponse.headers.get('Link');
|
||||
hasMore = linkHeader?.includes('rel="next"') ?? false;
|
||||
page++;
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const repoStats = await calculateRepoStats(allRepos);
|
||||
|
||||
// Fetch recent activity
|
||||
const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, {
|
||||
headers: {
|
||||
Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const events = (await eventsResponse.json()) as any[];
|
||||
const recentActivity = events.slice(0, 5).map((event: any) => ({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
repo: event.repo.name,
|
||||
created_at: event.created_at,
|
||||
}));
|
||||
|
||||
// Calculate total stars and forks
|
||||
const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0);
|
||||
const privateRepos = allRepos.filter((repo: any) => repo.private).length;
|
||||
|
||||
// Update the stats in the store
|
||||
const stats: GitHubStats = {
|
||||
repos: repoStats.repos,
|
||||
recentActivity,
|
||||
languages: repoStats.languages || {},
|
||||
totalGists: repoStats.totalGists || 0,
|
||||
publicRepos: userData.public_repos || 0,
|
||||
privateRepos: privateRepos || 0,
|
||||
stars: totalStars || 0,
|
||||
forks: totalForks || 0,
|
||||
followers: userData.followers || 0,
|
||||
publicGists: userData.public_gists || 0,
|
||||
privateGists: userData.private_gists || 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
|
||||
// For backward compatibility
|
||||
totalStars: totalStars || 0,
|
||||
totalForks: totalForks || 0,
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
// Get the current user first to ensure we have the latest value
|
||||
const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}');
|
||||
const currentUser = currentConnection.user || connection.user;
|
||||
|
||||
// Update connection with stats
|
||||
const updatedConnection: GitHubConnection = {
|
||||
user: currentUser,
|
||||
token,
|
||||
tokenType: connection.tokenType,
|
||||
stats,
|
||||
rateLimit: connection.rateLimit,
|
||||
};
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
|
||||
|
||||
// Update state
|
||||
setConnection(updatedConnection);
|
||||
|
||||
toast.success('GitHub 统计已刷新');
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stats:', error);
|
||||
toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsFetchingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRepoStats = async (
|
||||
repos: any[],
|
||||
): Promise<{ repos: GitHubRepoInfo[]; languages: GitHubLanguageStats; totalGists: number }> => {
|
||||
// 构建基本仓库信息
|
||||
const repoStats = {
|
||||
repos: repos.map((repo: any) => ({
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
html_url: repo.html_url,
|
||||
description: repo.description,
|
||||
stargazers_count: repo.stargazers_count,
|
||||
forks_count: repo.forks_count,
|
||||
default_branch: repo.default_branch,
|
||||
updated_at: repo.updated_at,
|
||||
languages_url: repo.languages_url,
|
||||
})),
|
||||
|
||||
languages: {} as Record<string, number>,
|
||||
totalGists: 0,
|
||||
};
|
||||
|
||||
// 首先使用仓库的主要语言属性构建基本的语言统计
|
||||
repos.forEach((repo: any) => {
|
||||
if (repo.language) {
|
||||
if (!repoStats.languages[repo.language]) {
|
||||
repoStats.languages[repo.language] = 0;
|
||||
}
|
||||
repoStats.languages[repo.language] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const topRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count).slice(0, 10);
|
||||
|
||||
try {
|
||||
const batchSize = 3;
|
||||
for (let i = 0; i < topRepos.length; i += batchSize) {
|
||||
const batch = topRepos.slice(i, i + batchSize);
|
||||
|
||||
const batchPromises = batch.map((repo) =>
|
||||
fetch(repo.languages_url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
console.warn('GitHub API rate limit exceeded when fetching languages');
|
||||
throw new Error('Rate limit exceeded');
|
||||
}
|
||||
throw new Error(`Error fetching languages: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((languages: any) => {
|
||||
const typedLanguages = languages as Record<string, number>;
|
||||
Object.keys(typedLanguages).forEach((language) => {
|
||||
if (!repoStats.languages[language]) {
|
||||
repoStats.languages[language] = 0;
|
||||
}
|
||||
repoStats.languages[language] += 1;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error processing languages for ${repo.name}:`, error);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
|
||||
if (i + batchSize < topRepos.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching repository languages:', error);
|
||||
}
|
||||
|
||||
return repoStats;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedConnection = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
// Update the ref with the parsed token type
|
||||
tokenTypeRef.current = parsed.tokenType;
|
||||
|
||||
// Set the connection
|
||||
setConnection(parsed);
|
||||
|
||||
// If we have a token but no stats or incomplete stats, fetch them
|
||||
if (
|
||||
parsed.user &&
|
||||
parsed.token &&
|
||||
(!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0)
|
||||
) {
|
||||
console.log('Fetching missing GitHub stats for saved connection');
|
||||
await fetchGitHubStats(parsed.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved GitHub connection:', error);
|
||||
localStorage.removeItem('github_connection');
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadSavedConnection();
|
||||
}, []);
|
||||
|
||||
// Ensure cookies are updated when connection changes
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = connection.token;
|
||||
const data = connection.user;
|
||||
|
||||
if (token) {
|
||||
Cookies.set('githubToken', token);
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
|
||||
}
|
||||
|
||||
if (data) {
|
||||
Cookies.set('githubUsername', data.login);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// Add function to update rate limits
|
||||
const updateRateLimits = async (token: string) => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/rate_limit', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const rateLimit = {
|
||||
limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'),
|
||||
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'),
|
||||
reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'),
|
||||
};
|
||||
|
||||
setConnection((prev) => ({
|
||||
...prev,
|
||||
rateLimit,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rate limits:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add effect to update rate limits periodically
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (connection.token && connection.user) {
|
||||
updateRateLimits(connection.token);
|
||||
interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [connection.token, connection.user]);
|
||||
|
||||
if (isLoading || isConnecting || isFetchingStats) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const handleConnect = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
// Update the ref with the current state value before connecting
|
||||
tokenTypeRef.current = connection.tokenType;
|
||||
|
||||
/*
|
||||
* Save token type to localStorage even before connecting
|
||||
* This ensures the token type is persisted even if connection fails
|
||||
*/
|
||||
localStorage.setItem(
|
||||
'github_connection',
|
||||
JSON.stringify({
|
||||
user: null,
|
||||
token: connection.token,
|
||||
tokenType: connection.tokenType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Attempt to fetch the user info which validates the token
|
||||
await fetchGithubUser(connection.token);
|
||||
|
||||
toast.success('已成功连接到 GitHub');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to GitHub:', error);
|
||||
|
||||
// Reset connection state on failure
|
||||
setConnection({ user: null, token: connection.token, tokenType: connection.tokenType });
|
||||
|
||||
toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
localStorage.removeItem('github_connection');
|
||||
|
||||
// Remove all GitHub-related cookies
|
||||
Cookies.remove('githubToken');
|
||||
Cookies.remove('githubUsername');
|
||||
Cookies.remove('git:github.com');
|
||||
|
||||
// Reset the token type ref
|
||||
tokenTypeRef.current = 'classic';
|
||||
setConnection({ user: null, token: '', tokenType: 'classic' });
|
||||
toast.success('已断开与 GitHub 的连接');
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
{!isConnecting && !connection.user && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
|
||||
令牌类型
|
||||
</label>
|
||||
<select
|
||||
value={connection.tokenType}
|
||||
onChange={(e) => {
|
||||
const newTokenType = e.target.value as 'classic' | 'fine-grained';
|
||||
tokenTypeRef.current = newTokenType;
|
||||
setConnection((prev) => ({ ...prev, tokenType: newTokenType }));
|
||||
}}
|
||||
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',
|
||||
'focus:outline-none focus:ring-1 focus:ring-upage-elements-item-contentAccent dark:focus:ring-upage-elements-item-contentAccent',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<option value="classic">Personal Access Token (Classic)</option>
|
||||
<option value="fine-grained">Fine-grained Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
|
||||
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={connection.token}
|
||||
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
||||
disabled={isConnecting || !!connection.user}
|
||||
placeholder={`输入您的 GitHub ${
|
||||
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
||||
}`}
|
||||
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',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-upage-elements-textSecondary">
|
||||
<a
|
||||
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-upage-elements-link-text dark:text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:hover:text-upage-elements-link-textHover flex items-center gap-1"
|
||||
>
|
||||
<div className="i-ph:key size-4" />
|
||||
获取您的令牌
|
||||
<div className="i-ph:arrow-square-out size-3" />
|
||||
</a>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
需要的权限:{' '}
|
||||
{connection.tokenType === 'classic'
|
||||
? 'repo, read:org, read:user'
|
||||
: 'Repository access, Organization access'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{!connection.user ? (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !connection.token}
|
||||
variant="default"
|
||||
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 size-4" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:github-logo size-4" />
|
||||
连接
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:check-circle size-4 text-upage-elements-icon-success dark:text-upage-elements-icon-success" />
|
||||
<span className="text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
已连接到 GitHub 使用{' '}
|
||||
<span className="text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent font-medium">
|
||||
{connection.tokenType === 'classic' ? 'PAT' : 'Fine-grained Token'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{connection.rateLimit && (
|
||||
<div className="flex items-center gap-2 text-xs text-upage-elements-textSecondary">
|
||||
<div className="i-ph:chart-line-up w-3.5 h-3.5 text-upage-elements-icon-success" />
|
||||
<span>
|
||||
API 限制: {connection.rateLimit.remaining.toLocaleString()}/
|
||||
{connection.rateLimit.limit.toLocaleString()} • 重置时间:
|
||||
{Math.max(0, Math.floor((connection.rateLimit.reset * 1000 - Date.now()) / 60000))} min
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://github.com/dashboard', '_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={() => {
|
||||
fetchGitHubStats(connection.token);
|
||||
updateRateLimits(connection.token);
|
||||
}}
|
||||
disabled={isFetchingStats}
|
||||
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"
|
||||
>
|
||||
{isFetchingStats ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap size-4 animate-spin" />
|
||||
刷新...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:arrows-clockwise size-4" />
|
||||
刷新统计
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connection.user && connection.stats && (
|
||||
<div className="mt-6 border-t border-upage-elements-borderColor dark:border-upage-elements-borderColor pt-6">
|
||||
<div className="flex items-center gap-4 p-4 bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1 rounded-lg mb-4">
|
||||
<img
|
||||
src={connection.user.avatar_url}
|
||||
alt={connection.user.login}
|
||||
className="size-12 rounded-full border-2 border-upage-elements-item-contentAccent dark:border-upage-elements-item-contentAccent"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{connection.user.name || connection.user.login}
|
||||
</h4>
|
||||
<p className="text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
{connection.user.login}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
|
||||
<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 cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:chart-bar size-4 text-upage-elements-item-contentAccent" />
|
||||
<span className="text-sm font-medium text-upage-elements-textPrimary">GitHub 统计</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 transform transition-transform duration-200 text-upage-elements-textSecondary',
|
||||
isStatsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Languages Section */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary mb-3">Top Languages</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(connection.stats.languages)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([language]) => (
|
||||
<span
|
||||
key={language}
|
||||
className="px-3 py-1 text-xs rounded-full bg-upage-elements-sidebar-buttonBackgroundDefault text-upage-elements-sidebar-buttonText"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{
|
||||
label: 'Member Since',
|
||||
value: new Date(connection.user.created_at).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
label: 'Public Gists',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
value: connection.stats.organizations ? connection.stats.organizations.length : 0,
|
||||
},
|
||||
{
|
||||
label: 'Languages',
|
||||
value: Object.keys(connection.stats.languages).length,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Repository Stats</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public Repos',
|
||||
value: connection.stats.publicRepos,
|
||||
},
|
||||
{
|
||||
label: 'Private Repos',
|
||||
value: connection.stats.privateRepos,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Contribution Stats</h5>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Stars',
|
||||
value: connection.stats.stars || 0,
|
||||
icon: 'i-ph:star',
|
||||
iconColor: 'text-upage-elements-icon-warning',
|
||||
},
|
||||
{
|
||||
label: 'Forks',
|
||||
value: connection.stats.forks || 0,
|
||||
icon: 'i-ph:git-fork',
|
||||
iconColor: 'text-upage-elements-icon-info',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: connection.stats.followers || 0,
|
||||
icon: 'i-ph:users',
|
||||
iconColor: 'text-upage-elements-icon-success',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary flex items-center gap-1">
|
||||
<div className={`${stat.icon} size-4 ${stat.iconColor}`} />
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary mb-2">Gists</h5>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Public',
|
||||
value: connection.stats.publicGists,
|
||||
},
|
||||
{
|
||||
label: 'Private',
|
||||
value: connection.stats.privateGists || 0,
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col p-3 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-2 border border-upage-elements-borderColor dark:border-upage-elements-borderColor"
|
||||
>
|
||||
<span className="text-xs text-upage-elements-textSecondary">{stat.label}</span>
|
||||
<span className="text-lg font-medium text-upage-elements-textPrimary">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-upage-elements-borderColor">
|
||||
<span className="text-xs text-upage-elements-textSecondary">
|
||||
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repositories Section */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary">Recent Repositories</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{connection.stats.repos.map((repo) => (
|
||||
<a
|
||||
key={repo.full_name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-4 rounded-lg bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-1 border border-upage-elements-borderColor dark:border-upage-elements-borderColor hover:border-upage-elements-borderColorActive dark:hover:border-upage-elements-borderColorActive transition-all duration-200"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-mingcute:github-line size-4 text-upage-elements-icon-info dark:text-upage-elements-icon-info" />
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary group-hover:text-upage-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-upage-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Stars">
|
||||
<div className="i-ph:star w-3.5 h-3.5 text-upage-elements-icon-warning" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Forks">
|
||||
<div className="i-ph:git-fork w-3.5 h-3.5 text-upage-elements-icon-info" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-xs text-upage-elements-textSecondary line-clamp-2">{repo.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-upage-elements-textSecondary">
|
||||
<span className="flex items-center gap-1" title="Default Branch">
|
||||
<div className="i-ph:git-branch w-3.5 h-3.5" />
|
||||
{repo.default_branch}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Last Updated">
|
||||
<div className="i-ph:clock w-3.5 h-3.5" />
|
||||
{new Date(repo.updated_at).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 ml-auto group-hover:text-upage-elements-item-contentAccent transition-colors">
|
||||
<div className="i-ph:arrow-square-out w-3.5 h-3.5" />
|
||||
View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
|
||||
<span className="text-upage-elements-textSecondary">加载仓库中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
700
app/.client/components/header/connections/NetlifyConnection.tsx
Normal file
700
app/.client/components/header/connections/NetlifyConnection.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
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 '~/.client/components/ui/Badge';
|
||||
import { Button } from '~/.client/components/ui/Button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/.client/components/ui/Collapsible';
|
||||
import {
|
||||
fetchNetlifyStats,
|
||||
isFetchingStats,
|
||||
netlifyConnection,
|
||||
updateNetlifyConnection,
|
||||
} from '~/.client/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<void>;
|
||||
requiresConfirmation?: boolean;
|
||||
variant?: 'default' | 'destructive' | 'outline';
|
||||
}
|
||||
|
||||
export default function NetlifyConnection() {
|
||||
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
|
||||
const connectFetcher = useFetcher<ApiResponse>();
|
||||
const settingsFetcher = useFetcher<ApiResponse>();
|
||||
|
||||
const connection = useStore(netlifyConnection);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [sites, setSites] = useState<NetlifySite[]>([]);
|
||||
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
|
||||
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
|
||||
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: 'i-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: 'i-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 (
|
||||
<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">
|
||||
Netlify 统计信息
|
||||
</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="i-heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>{connection.stats.totalSites} 站点</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-heroicons:rocket-launch size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>{deploymentCount} 部署</span>
|
||||
</Badge>
|
||||
{lastUpdated && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-heroicons:clock size-4 text-upage-elements-item-contentAccent" />
|
||||
<span>更新于 {formatDistanceToNow(new Date(lastUpdated), { locale: zhCN })} 前</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{sites.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="i-heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
您的站点
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
})
|
||||
}
|
||||
disabled={fetchingStats}
|
||||
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(
|
||||
'i-heroicons:arrow-path size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent',
|
||||
{ 'animate-spin': fetchingStats },
|
||||
)}
|
||||
/>
|
||||
{fetchingStats ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sites.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={() => {
|
||||
setActiveSiteIndex(index);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-heroicons:cloud 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.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
{site.published_deploy?.state === 'ready' ? (
|
||||
<div className="i-heroicons:check-circle size-4 text-green-500" />
|
||||
) : (
|
||||
<div className="i-heroicons:x-circle size-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{site.published_deploy?.state || 'Unknown'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
href={site.ssl_url || site.url}
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="i-heroicons:cloud size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">
|
||||
{site.ssl_url || site.url}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{activeSiteIndex === index && (
|
||||
<>
|
||||
<div className="mt-4 pt-3 border-t border-upage-elements-borderColor">
|
||||
<div className="flex items-center gap-2">
|
||||
{siteActions.map((action) => (
|
||||
<Button
|
||||
key={action.name}
|
||||
variant={action.variant || 'outline'}
|
||||
size="sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (action.requiresConfirmation) {
|
||||
if (!confirm(`您确定要 ${action.name.toLowerCase()}?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsActionLoading(true);
|
||||
await action.action(site.id);
|
||||
setIsActionLoading(false);
|
||||
}}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div
|
||||
className={`${action.icon} size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent`}
|
||||
/>
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{site.published_deploy && (
|
||||
<div className="mt-3 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="i-heroicons: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">
|
||||
发布于{' '}
|
||||
{formatDistanceToNow(new Date(site.published_deploy.published_at), {
|
||||
locale: zhCN,
|
||||
})}{' '}
|
||||
前
|
||||
</span>
|
||||
</div>
|
||||
{site.published_deploy.branch && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="i-heroicons:code-bracket size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
分支: {site.published_deploy.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeSiteIndex !== -1 && deploys.length > 0 && (
|
||||
<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-3">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
<div className="i-heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
最近部署
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deploys.map((deploy) => (
|
||||
<div
|
||||
key={deploy.id}
|
||||
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-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
deploy.state === 'ready'
|
||||
? 'default'
|
||||
: deploy.state === 'error'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{deploy.state === 'ready' ? (
|
||||
<div className="i-heroicons:check-circle size-4 text-green-500" />
|
||||
) : deploy.state === 'error' ? (
|
||||
<div className="i-heroicons:x-circle size-4 text-red-500" />
|
||||
) : (
|
||||
<div className="i-heroicons:building-library size-4 text-upage-elements-item-contentAccent" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{deploy.state}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
{formatDistanceToNow(new Date(deploy.created_at), { locale: zhCN })} 前
|
||||
</span>
|
||||
</div>
|
||||
{deploy.branch && (
|
||||
<div className="mt-2 text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-heroicons:code-bracket size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
分支: {deploy.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{deploy.deploy_url && (
|
||||
<div className="mt-2 text-xs">
|
||||
<a
|
||||
href={deploy.deploy_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="i-heroicons:cloud size-3 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-heroicons:building-library size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
发布
|
||||
</Button>
|
||||
{deploy.state === 'ready' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-heroicons:lock-closed size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
锁定
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-1 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary"
|
||||
>
|
||||
<div className="i-heroicons:lock-open size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
解锁
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSiteIndex !== -1 && builds.length > 0 && (
|
||||
<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-3">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
<div className="i-heroicons:code-bracket size-4 text-upage-elements-item-contentAccent dark:text-upage-elements-item-contentAccent" />
|
||||
最近构建
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{builds.map((build) => (
|
||||
<div
|
||||
key={build.id}
|
||||
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-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{build.done && !build.error ? (
|
||||
<div className="i-heroicons:check-circle size-4" />
|
||||
) : build.error ? (
|
||||
<div className="i-heroicons:x-circle size-4" />
|
||||
) : (
|
||||
<div className="i-heroicons:code-bracket size-4" />
|
||||
)}
|
||||
<span className="text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
{build.done ? (build.error ? '失败' : '完成') : '进行中'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
{formatDistanceToNow(new Date(build.created_at), { locale: zhCN })} 前
|
||||
</span>
|
||||
</div>
|
||||
{build.error && (
|
||||
<div className="mt-2 text-xs text-upage-elements-textDestructive dark:text-upage-elements-textDestructive flex items-center gap-1">
|
||||
<div className="i-heroicons:x-circle size-3 text-upage-elements-textDestructive dark:text-upage-elements-textDestructive" />
|
||||
错误: {build.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
<div className="p-6">
|
||||
{!connection.isConnect ? (
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary mb-2">
|
||||
API 令牌
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => 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',
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-upage-elements-textSecondary dark:text-upage-elements-textSecondary">
|
||||
<a
|
||||
href="https://app.netlify.com/user/applications#personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-upage-elements-link-text dark:text-upage-elements-link-text hover:text-upage-elements-link-textHover dark:hover:text-upage-elements-link-textHover flex items-center gap-1"
|
||||
>
|
||||
<div className="i-ph:key size-4" />
|
||||
获取您的令牌
|
||||
<div className="i-ph:arrow-square-out size-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
variant="default"
|
||||
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',
|
||||
'text-upage-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap animate-spin size-4" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:plug-charging size-4" />
|
||||
连接
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full gap-4 mt-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-heroicons:check-circle size-4 text-green-500" />
|
||||
<span className="text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary">
|
||||
已连接到 Netlify
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('https://app.netlify.com', '_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={() =>
|
||||
fetchNetlifyStats().catch((err) => {
|
||||
logger.error('获取 Netlify 统计信息失败:', err);
|
||||
})
|
||||
}
|
||||
disabled={fetchingStats}
|
||||
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"
|
||||
>
|
||||
{fetchingStats ? (
|
||||
<>
|
||||
<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="i-heroicons:academic-cap-solid 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>
|
||||
)}
|
||||
</div>
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
314
app/.client/components/header/connections/VercelConnection.tsx
Normal file
314
app/.client/components/header/connections/VercelConnection.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { fetchVercelStats, isFetchingStats, updateVercelConnection, vercelConnection } from '~/.client/stores/vercel';
|
||||
import type { ConnectionSettings } from '~/root';
|
||||
import { logStore } from '~/stores/logs';
|
||||
import { logger } from '~/utils/logger';
|
||||
import ConnectionBorder from './components/ConnectionBorder';
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export default function VercelConnection() {
|
||||
const rootData = useRouteLoaderData<{ connectionSettings?: ConnectionSettings }>('root');
|
||||
const settingsFetcher = useFetcher<ApiResponse>();
|
||||
const connectFetcher = useFetcher<ApiResponse>();
|
||||
|
||||
const connection = useStore(vercelConnection);
|
||||
const fetchingStats = useStore(isFetchingStats);
|
||||
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
updateVercelConnection({
|
||||
isConnect: rootData?.connectionSettings?.vercelConnection,
|
||||
});
|
||||
}, [rootData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection.isConnect) {
|
||||
fetchVercelStats().catch((err) => {
|
||||
logger.error('获取 Vercel 统计信息失败:', err);
|
||||
});
|
||||
if (!connection.user) {
|
||||
handleConnect();
|
||||
}
|
||||
}
|
||||
}, [connection.isConnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsFetcher.state === 'idle' && settingsFetcher.data) {
|
||||
if (settingsFetcher.data.success) {
|
||||
updateVercelConnection({ isConnect: false, user: null });
|
||||
toast.success('断开 Vercel 连接');
|
||||
}
|
||||
}
|
||||
}, [settingsFetcher.state, settingsFetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectFetcher.state === 'idle' && connectFetcher.data) {
|
||||
if (connectFetcher.data.success) {
|
||||
updateVercelConnection({
|
||||
isConnect: connectFetcher.data.data.isConnect,
|
||||
user: connectFetcher.data.data.user,
|
||||
});
|
||||
toast.success('连接 Vercel 成功');
|
||||
setTokenInput('');
|
||||
} else if (connectFetcher.data.message) {
|
||||
toast.error(connectFetcher.data.message || '连接失败');
|
||||
updateVercelConnection({ isConnect: false, user: null });
|
||||
}
|
||||
}
|
||||
}, [connectFetcher.state, connectFetcher.data]);
|
||||
|
||||
const isConnecting = useMemo(() => {
|
||||
return connectFetcher.state !== 'idle';
|
||||
}, [connectFetcher.state]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
try {
|
||||
connectFetcher.submit(
|
||||
{ token: tokenInput },
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/vercel/auth',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('连接 Vercel 失败');
|
||||
logger.error('连接 Vercel 失败:', error);
|
||||
logStore.logError('Failed to authenticate with Vercel', { error });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
settingsFetcher.submit(
|
||||
{
|
||||
category: 'connectivity',
|
||||
key: 'vercel_token',
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
action: '/api/user/settings',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('断开 Vercel 连接失败');
|
||||
logger.error('断开 Vercel 连接失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionBorder>
|
||||
{!connection.isConnect ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-upage-elements-textSecondary mb-2">个人访问令牌</label>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
disabled={isConnecting}
|
||||
placeholder="输入您的 Vercel 个人访问令牌"
|
||||
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 className="mt-2 text-sm text-upage-elements-textSecondary">
|
||||
<a
|
||||
href="https://vercel.com/account/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-upage-elements-borderColorActive hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
获取您的令牌
|
||||
<div className="i-ph:arrow-square-out size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (!tokenInput) {
|
||||
toast.error('请输入 Vercel 访问令牌');
|
||||
return;
|
||||
}
|
||||
handleConnect();
|
||||
}}
|
||||
disabled={isConnecting || !tokenInput}
|
||||
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">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
||||
'bg-red-500 text-white',
|
||||
'hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
<div className="i-ph:plug size-4" />
|
||||
断开连接
|
||||
</button>
|
||||
<span className="text-sm text-upage-elements-textSecondary flex items-center gap-1">
|
||||
<div className="i-ph:check-circle size-4 text-green-500" />
|
||||
已连接到 Vercel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
|
||||
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
|
||||
|
||||
<img
|
||||
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
alt="User Avatar"
|
||||
className="size-12 rounded-full border-2 border-upage-elements-borderColorActive"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
|
||||
</h4>
|
||||
<p className="text-sm text-upage-elements-textSecondary">
|
||||
{connection.user?.email || connection.user?.user?.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fetchingStats ? (
|
||||
<div className="flex items-center gap-2 text-sm text-upage-elements-textSecondary">
|
||||
<div className="i-ph:spinner-gap size-4 animate-spin" />
|
||||
正在获取 Vercel 项目...
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
|
||||
className="w-full bg-transparent text-left text-sm font-medium text-upage-elements-textPrimary mb-3 flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:buildings size-4" />
|
||||
您的项目 ({connection.stats?.totalProjects || 0})
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down size-4 ml-auto transition-transform',
|
||||
isProjectsExpanded ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isProjectsExpanded && connection.stats?.projects?.length ? (
|
||||
<div className="grid gap-3">
|
||||
{connection.stats.projects.map((project) => (
|
||||
<a
|
||||
key={project.id}
|
||||
href={`https://vercel.com/dashboard/${project.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-4 rounded-lg border border-upage-elements-borderColor hover:border-upage-elements-borderColorActive transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-upage-elements-textPrimary flex items-center gap-2">
|
||||
<div className="i-ph:globe size-4 text-upage-elements-borderColorActive" />
|
||||
{project.name}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-upage-elements-textSecondary">
|
||||
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-upage-elements-borderColorActive"
|
||||
>
|
||||
{project.targets.production.alias.find(
|
||||
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
|
||||
) || project.targets.production.alias[0]}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock size-3" />
|
||||
{new Date(project.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://${project.latestDeployments[0].url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-upage-elements-borderColorActive"
|
||||
>
|
||||
{project.latestDeployments[0].url}
|
||||
</a>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock size-3" />
|
||||
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{project.framework && (
|
||||
<div className="text-xs text-upage-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-mingcute:code-line size-3" />
|
||||
{project.framework}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : isProjectsExpanded ? (
|
||||
<div className="text-sm text-upage-elements-textSecondary flex items-center gap-2">
|
||||
<div className="i-ph:info size-4" />
|
||||
未找到您的 Vercel 账户中的项目
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ConnectionBorder>
|
||||
);
|
||||
}
|
||||
587
app/.client/components/header/connections/_1PanelConnection.tsx
Normal file
587
app/.client/components/header/connections/_1PanelConnection.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
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<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="i-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="i-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(
|
||||
'i-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="i-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="i-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',
|
||||
'text-upage-elements-textPrimary',
|
||||
)}
|
||||
>
|
||||
{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="i-ph:arrows-clockwise 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function ConnectionBorder({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className={classNames(
|
||||
'bg-upage-elements-background dark:bg-upage-elements-background border border-upage-elements-borderColor dark:border-upage-elements-borderColor rounded-lg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6 space-y-6">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { Suspense, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { getLocalStorage } from '~/.client/persistence';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import { formatSize } from '~/.client/utils/format';
|
||||
import { logStore } from '~/stores/logs';
|
||||
import type { GitHubUserResponse } from '~/types/github';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
const GitHubConnection = React.lazy(() => import('~/.client/components/header/connections/GithubConnection'));
|
||||
|
||||
interface PushToGitHubDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
|
||||
}
|
||||
|
||||
interface GitHubRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
html_url: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
language: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
|
||||
const [repoName, setRepoName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [user, setUser] = useState<GitHubUserResponse | null>(null);
|
||||
const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [createdRepoUrl, setCreatedRepoUrl] = useState('');
|
||||
const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
|
||||
const [isGitHubConnected, setIsGitHubConnected] = useState(false);
|
||||
const [showConnectionForm, setShowConnectionForm] = useState(false);
|
||||
|
||||
// Load GitHub connection on mount
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadGitHubConnection();
|
||||
}
|
||||
}, [isOpen, isGitHubConnected]);
|
||||
|
||||
const loadGitHubConnection = () => {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (connection?.user && connection?.token) {
|
||||
setUser(connection.user);
|
||||
setShowConnectionForm(false);
|
||||
|
||||
// Only fetch if we have both user and token
|
||||
if (connection.token.trim()) {
|
||||
fetchRecentRepos(connection.token);
|
||||
}
|
||||
} else {
|
||||
setShowConnectionForm(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加检测 GitHub 连接变化的 useEffect
|
||||
useEffect(() => {
|
||||
// 监听 localStorage 变化
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'github_connection' && e.newValue) {
|
||||
try {
|
||||
const connection = JSON.parse(e.newValue);
|
||||
if (connection?.user && connection?.token) {
|
||||
setIsGitHubConnected(true);
|
||||
loadGitHubConnection();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing github_connection from storage event:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 检查 localStorage 变化的函数,在内部组件触发
|
||||
const checkGitHubConnection = () => {
|
||||
const connection = getLocalStorage('github_connection');
|
||||
if (connection?.user && connection?.token) {
|
||||
setIsGitHubConnected(true);
|
||||
setShowConnectionForm(false);
|
||||
loadGitHubConnection();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecentRepos = async (token: string) => {
|
||||
if (!token) {
|
||||
logStore.logError('No GitHub token available');
|
||||
toast.error('GitHub 认证失败');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingRepos(true);
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.github.com/user/repos?sort=updated&per_page=5&affiliation=owner,organization_member',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401) {
|
||||
toast.error('GitHub 令牌已过期。请重新连接您的账户。');
|
||||
|
||||
// Clear invalid token
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (connection) {
|
||||
localStorage.removeItem('github_connection');
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
logStore.logError('Failed to fetch GitHub repositories', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
});
|
||||
toast.error(`无法获取 GitHub 仓库: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const repos = (await response.json()) as GitHubRepo[];
|
||||
setRecentRepos(repos);
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to fetch GitHub repositories', { error });
|
||||
toast.error('无法获取最近仓库');
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const connection = getLocalStorage('github_connection');
|
||||
|
||||
if (!connection?.token || !connection?.user) {
|
||||
setShowConnectionForm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!repoName.trim()) {
|
||||
toast.error('仓库名称是必需的');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Check if repository exists first
|
||||
const octokit = new Octokit({ auth: connection.token });
|
||||
|
||||
try {
|
||||
await octokit.repos.get({
|
||||
owner: connection.user.login,
|
||||
repo: repoName,
|
||||
});
|
||||
|
||||
// If we get here, the repo exists
|
||||
const confirmOverwrite = window.confirm(
|
||||
`仓库 "${repoName}" 已存在。是否要更新它?这将添加或修改仓库中的文件。`,
|
||||
);
|
||||
|
||||
if (!confirmOverwrite) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// 404 means repo doesn't exist, which is what we want for new repos
|
||||
if (error instanceof Error && 'status' in error && error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
|
||||
setCreatedRepoUrl(repoUrl);
|
||||
|
||||
// Get list of pushed files
|
||||
const files = await webBuilderStore.getProjectFilesAsMap({
|
||||
inline: false,
|
||||
});
|
||||
const filesList = Object.entries(files).map(([path, content]) => ({
|
||||
path,
|
||||
size: new TextEncoder().encode(content).length,
|
||||
}));
|
||||
|
||||
setPushedFiles(filesList);
|
||||
setShowSuccessDialog(true);
|
||||
} catch (error) {
|
||||
logger.error('Error pushing to GitHub:', error);
|
||||
toast.error('推送失败,请检查仓库名称并重试。');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRepoName('');
|
||||
setIsPrivate(false);
|
||||
setShowSuccessDialog(false);
|
||||
setCreatedRepoUrl('');
|
||||
setShowConnectionForm(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSwitchAccount = () => {
|
||||
setShowConnectionForm(true);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
// 清除 localStorage
|
||||
localStorage.removeItem('github_connection');
|
||||
// 清除 cookie
|
||||
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
// 更新状态
|
||||
setUser(null);
|
||||
setShowConnectionForm(true);
|
||||
toast.success('已断开与 GitHub 的连接');
|
||||
};
|
||||
|
||||
// Success Dialog
|
||||
if (showSuccessDialog) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[600px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<div className="i-ph:check-circle size-5" />
|
||||
<h3 className="text-lg font-medium">成功推送代码至 GitHub</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg p-3 text-left">
|
||||
<p className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark mb-2">
|
||||
仓库地址
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-upage-elements-background dark:bg-upage-elements-background-dark px-3 py-2 rounded border border-upage-elements-borderColor dark:border-upage-elements-borderColor-dark text-upage-elements-textPrimary dark:text-upage-elements-textPrimary-dark font-mono">
|
||||
{createdRepoUrl}
|
||||
</code>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(createdRepoUrl);
|
||||
toast.success('URL 已复制到剪贴板');
|
||||
}}
|
||||
className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div className="i-ph:copy size-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg p-3">
|
||||
<p className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark mb-2">
|
||||
推送的文件 ({pushedFiles.length})
|
||||
</p>
|
||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
{pushedFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center justify-between py-1 text-sm text-upage-elements-textPrimary dark:text-upage-elements-textPrimary-dark"
|
||||
>
|
||||
<span className="font-mono truncate flex-1">{file.path}</span>
|
||||
<span className="text-xs text-upage-elements-textSecondary dark:text-upage-elements-textSecondary-dark ml-2">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] sticky bottom-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<motion.a
|
||||
href={createdRepoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:github-logo size-4" />
|
||||
查看仓库
|
||||
</motion.a>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(createdRepoUrl);
|
||||
toast.success('URL 已复制到剪贴板');
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:copy size-4" />
|
||||
复制 URL
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
关闭
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (showConnectionForm) {
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[650px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:github-logo size-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{showConnectionForm ? 'GitHub 连接信息' : '连接 GitHub 账户'}
|
||||
</h3>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{!showConnectionForm && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
需要连接 GitHub 账户才能将代码推送到 GitHub 仓库。请在此页面完成连接。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="github-connection-wrapper">
|
||||
<Suspense>
|
||||
<GitHubConnection />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex justify-end">
|
||||
{isGitHubConnected || user ? (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
setShowConnectionForm(false);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrow-right" />
|
||||
推送列表
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
checkGitHubConnection();
|
||||
setTimeout(checkGitHubConnection, 500); // 延迟检查
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-upage-elements-item-backgroundAccent text-upage-elements-item-contentAccent text-sm hover:bg-upage-elements-item-backgroundAccent/90 inline-flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:arrows-clockwise" />
|
||||
刷新连接状态
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<Dialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onPointerDownOutside={handleClose}
|
||||
className="relative z-[101]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[90vw] md:w-[500px] my-4"
|
||||
>
|
||||
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl max-h-[calc(85vh-2rem)] flex flex-col">
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="size-10 rounded-xl bg-upage-elements-background-depth-3 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<div className="i-ph:git-branch size-5" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
推送到 GitHub
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">将代码推送到新的或现有的 GitHub 仓库</p>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
className="ml-auto flex items-center justify-center size-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="i-ph:x size-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
{user && (
|
||||
<div className="flex items-center justify-between gap-3 mb-6 p-3 bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={user?.avatar_url} alt={user?.login} className="size-10 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{user?.name || user?.login}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">@{user?.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.button
|
||||
onClick={handleSwitchAccount}
|
||||
className="px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-800 text-upage-elements-textPrimary dark:text-upage-elements-textPrimary text-sm hover:bg-gray-300 dark:hover:bg-gray-700 inline-flex items-center gap-1"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:chart-bar size-4" />
|
||||
查看统计
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleDisconnect}
|
||||
className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-sm hover:bg-red-600 inline-flex items-center gap-1"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="i-ph:sign-out size-4" />
|
||||
断开连接
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form id="github-push-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
仓库名称
|
||||
</label>
|
||||
<input
|
||||
id="repoName"
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value)}
|
||||
placeholder="my-awesome-project"
|
||||
className="w-full px-4 py-2 rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="private"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
|
||||
/>
|
||||
<label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
|
||||
将仓库设置为私有
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{recentRepos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">最近仓库</label>
|
||||
<div className="space-y-2">
|
||||
{recentRepos.map((repo) => (
|
||||
<motion.button
|
||||
key={repo.full_name}
|
||||
type="button"
|
||||
onClick={() => setRepoName(repo.name)}
|
||||
className="w-full p-3 text-left rounded-lg bg-upage-elements-background-depth-2 dark:bg-upage-elements-background-depth-3 hover:bg-upage-elements-background-depth-3 dark:hover:bg-upage-elements-background-depth-4 transition-colors group"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-mingcute:github-line size-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
||||
{repo.name}
|
||||
</span>
|
||||
</div>
|
||||
{repo.private && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
{repo.language && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-mingcute:code-line size-3" />
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:star size-3" />
|
||||
{repo.stargazers_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:git-fork size-3" />
|
||||
{repo.forks_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="i-ph:clock size-3" />
|
||||
{new Date(repo.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetchingRepos && (
|
||||
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4 mr-2" />
|
||||
正在加载仓库...
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A] bg-white dark:bg-[#0A0A0A] sticky bottom-0">
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
取消
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="submit"
|
||||
form="github-push-form"
|
||||
disabled={isLoading}
|
||||
className={classNames(
|
||||
'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
|
||||
isLoading ? 'opacity-50 cursor-not-allowed' : '',
|
||||
)}
|
||||
whileHover={!isLoading ? { scale: 1.02 } : {}}
|
||||
whileTap={!isLoading ? { scale: 0.98 } : {}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph:spinner-gap-bold animate-spin size-4" />
|
||||
正在推送...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-ph:git-branch size-4" />
|
||||
推送到 GitHub
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user