| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- /**
- * MCP 服务器卡片组件
- */
- 'use client';
- import { useState, useEffect, useRef } from 'react';
- import { MCP_SERVERS, mcpTokenManager, type McpServerConfig } from '@/lib/mcp-token-manager';
- /**
- * 开发环境测试账号辅助组件
- */
- function DevTestAccounts({ mcpType }: { mcpType: string }) {
- const [copied, setCopied] = useState<string | null>(null);
- // 测试账号配置
- const testAccounts: Record<string, { email: string; password: string }> = {
- 'novel-platform-user': {
- email: 'reader-test@example.com',
- password: 'ReaderTest2026@',
- },
- 'novel-platform-admin': {
- email: 'admin-test@example.com',
- password: 'AdminTest2026@',
- },
- };
- const account = testAccounts[mcpType];
- if (!account) return null;
- const copyToClipboard = async (text: string, type: string) => {
- try {
- await navigator.clipboard.writeText(text);
- setCopied(type);
- setTimeout(() => setCopied(null), 2000);
- } catch (err) {
- console.error('复制失败:', err);
- }
- };
- return (
- <div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
- <p className="text-xs font-medium text-amber-800 dark:text-amber-200 mb-2">
- 🛠️ 开发环境测试账号
- </p>
- <div className="space-y-2 text-xs">
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
- <span className="text-gray-700 dark:text-gray-300">
- 邮箱: <span className="font-mono font-medium">{account.email}</span>
- </span>
- <button
- type="button"
- onClick={() => copyToClipboard(account.email, 'email')}
- className="px-3 py-2 sm:px-2 sm:py-1 bg-amber-100 dark:bg-amber-800 text-amber-700 dark:text-amber-200 rounded hover:bg-amber-200 dark:hover:bg-amber-700 transition-colors whitespace-nowrap text-center min-h-[44px] sm:min-h-0"
- >
- {copied === 'email' ? '已复制 ✓' : '复制邮箱'}
- </button>
- </div>
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
- <span className="text-gray-700 dark:text-gray-300">
- 密码: <span className="font-mono font-medium">••••••••••••</span>
- </span>
- <button
- type="button"
- onClick={() => copyToClipboard(account.password, 'password')}
- className="px-3 py-2 sm:px-2 sm:py-1 bg-amber-100 dark:bg-amber-800 text-amber-700 dark:text-amber-200 rounded hover:bg-amber-200 dark:hover:bg-amber-700 transition-colors whitespace-nowrap text-center min-h-[44px] sm:min-h-0"
- >
- {copied === 'password' ? '已复制 ✓' : '复制密码'}
- </button>
- </div>
- </div>
- </div>
- );
- }
- interface McpServerCardProps {
- mcpType: string;
- config: McpServerConfig;
- onConnectionStatusChange?: (mcpType: string, status: { healthy: boolean; loggedIn: boolean }) => void;
- onEnabledChange?: (mcpType: string, enabled: boolean) => void;
- }
- export function McpServerCard({ mcpType, config, onConnectionStatusChange, onEnabledChange }: McpServerCardProps) {
- const [showLoginForm, setShowLoginForm] = useState(false);
- const [showTokenForm, setShowTokenForm] = useState(false);
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [manualToken, setManualToken] = useState('');
- const [manualUsername, setManualUsername] = useState('');
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState('');
- const [updateTrigger, setUpdateTrigger] = useState(0);
- // 客户端挂载标志 - 用于避免 hydration 错误
- const [mounted, setMounted] = useState(false);
- // 启用/禁用状态 - 初始值统一为 true,客户端挂载后从 localStorage 读取真实值
- const [isEnabled, setIsEnabled] = useState(true);
- // 健康检查状态
- const [isHealthy, setIsHealthy] = useState<boolean | null>(null);
- const [isCheckingHealth, setIsCheckingHealth] = useState(false);
- const [latency, setLatency] = useState<number | null>(null);
- // 监听 localStorage 变化和组件挂载
- useEffect(() => {
- // 设置客户端挂载标志
- setMounted(true);
- // 从 localStorage 读取真实的启用状态
- const storedEnabled = mcpTokenManager.isEnabled(mcpType);
- console.log(`[McpServerCard ${mcpType}] Client mounted, isEnabled from localStorage: ${storedEnabled}`);
- setIsEnabled(storedEnabled);
- // 初始化时检查登录状态
- const checkLoginStatus = () => {
- const token = localStorage.getItem(`mcp_token_${mcpType}`);
- const username = localStorage.getItem(`mcp_username_${mcpType}`);
- console.log(`[McpServerCard ${mcpType}] Initial check:`, {
- hasToken: !!token,
- tokenLength: token?.length,
- username
- });
- setUpdateTrigger(prev => prev + 1);
- };
- checkLoginStatus();
- // 检查服务器健康状态
- const checkServerHealth = async () => {
- setIsCheckingHealth(true);
- try {
- const result = await mcpTokenManager.checkHealth(mcpType);
- setIsHealthy(result.healthy);
- setLatency(result.latency ?? null);
- console.log(`[McpServerCard ${mcpType}] Health check:`, result);
- } catch (e) {
- console.error(`[McpServerCard ${mcpType}] Health check error:`, e);
- setIsHealthy(false);
- } finally {
- setIsCheckingHealth(false);
- }
- };
- checkServerHealth();
- // 监听 storage 事件(其他标签页的更改)
- const handleStorageChange = (e: StorageEvent) => {
- if (e.key?.startsWith('mcp_token_') || e.key?.startsWith('mcp_username_')) {
- console.log(`[McpServerCard ${mcpType}] Storage changed:`, e.key);
- setUpdateTrigger(prev => prev + 1);
- }
- // 监听启用/禁用状态变化
- if (e.key === `mcp_enabled_${mcpType}`) {
- const newState = e.newValue === 'true';
- setIsEnabled(newState);
- onEnabledChange?.(mcpType, newState);
- }
- };
- window.addEventListener('storage', handleStorageChange);
- // 定期重新检查健康状态(每 30 秒)
- const healthInterval = setInterval(checkServerHealth, 30000);
- return () => {
- window.removeEventListener('storage', handleStorageChange);
- clearInterval(healthInterval);
- };
- }, [mcpType]);
- const isLoggedIn = mcpTokenManager.isLoggedIn(mcpType);
- const username = mcpTokenManager.getUsername(mcpType);
- const remainingTime = mcpTokenManager.getTokenRemainingTime(mcpType);
- const hoursRemaining = Math.floor(remainingTime / (1000 * 60 * 60));
- // 使用 ref 跟踪上次报告的状态,避免重复报告相同状态
- const lastReportedStatusRef = useRef<{ healthy: boolean | null; loggedIn: boolean }>({
- healthy: null,
- loggedIn: false
- });
- // 向父组件报告连接状态变化
- useEffect(() => {
- if (isHealthy !== null) {
- const lastReported = lastReportedStatusRef.current;
- // 只在状态真正改变时才报告
- if (lastReported.healthy !== isHealthy || lastReported.loggedIn !== isLoggedIn) {
- lastReportedStatusRef.current = { healthy: isHealthy, loggedIn: isLoggedIn };
- onConnectionStatusChange?.(mcpType, {
- healthy: isHealthy,
- loggedIn: isLoggedIn
- });
- }
- }
- }, [isHealthy, isLoggedIn, mcpType, onConnectionStatusChange]);
- // 计算连接状态显示
- // - 未挂载 → 加载中
- // - 禁用 → 灰色圆点
- // - 健康 + 已登录 → 已连接 ✓ | 已登录 ✓
- // - 健康 + 未登录 → 已连接 ✓ | 未登录
- // - 不健康 → 离线
- const getConnectionStatus = () => {
- // 未挂载时显示加载状态,避免 hydration 错误
- if (!mounted) {
- return { text: '加载中...', className: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400', dotColor: 'bg-gray-300' };
- }
- // 禁用状态优先,显示灰色
- if (!isEnabled) {
- return { text: '已禁用', className: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400', dotColor: 'bg-gray-400' };
- }
- if (isHealthy === null) {
- return { text: '检查中...', className: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400', dotColor: 'bg-gray-400' };
- }
- if (isHealthy) {
- if (isLoggedIn) {
- return { text: '已连接 | 已登录', className: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', dotColor: 'bg-green-500' };
- } else {
- return { text: '已连接 | 未登录', className: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', dotColor: 'bg-blue-500' };
- }
- } else {
- return { text: '离线', className: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', dotColor: 'bg-red-500' };
- }
- };
- const connectionStatus = getConnectionStatus();
- const handleLogin = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsLoading(true);
- setError('');
- const result = await mcpTokenManager.loginMcp(mcpType, email, password);
- if (result.success) {
- setShowLoginForm(false);
- setEmail('');
- setPassword('');
- // 强制刷新组件状态
- setUpdateTrigger(prev => prev + 1);
- } else {
- setError(result.error || '登录失败');
- }
- setIsLoading(false);
- };
- const handleLogout = () => {
- mcpTokenManager.logoutMcp(mcpType);
- // 强制刷新组件状态
- setUpdateTrigger(prev => prev + 1);
- };
- const handleSetToken = () => {
- if (!manualToken.trim()) {
- setError('Token 不能为空');
- return;
- }
- mcpTokenManager.saveToken(mcpType, manualToken, manualUsername || 'Token User');
- setShowTokenForm(false);
- setManualToken('');
- setManualUsername('');
- setUpdateTrigger(prev => prev + 1);
- };
- const handleToggleEnabled = () => {
- console.log(`[McpServerCard.handleToggleEnabled] Called for ${mcpType}, current isEnabled=${isEnabled}`);
- const newState = mcpTokenManager.toggleEnabled(mcpType);
- console.log(`[McpServerCard.handleToggleEnabled] toggleEnabled returned: ${newState}`);
- setIsEnabled(newState);
- onEnabledChange?.(mcpType, newState);
- // 触发自定义事件,通知 Header 等组件更新状态
- window.dispatchEvent(new CustomEvent('mcp-enabled-change'));
- console.log(`[McpServerCard.handleToggleEnabled] Dispatched mcp-enabled-change event`);
- };
- return (
- <div
- className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6 border dark:border-gray-700 transition-all ${mounted && !isEnabled ? 'opacity-50 grayscale' : ''}`}
- suppressHydrationWarning
- >
- {/* 头部区域 - 移动端优化 */}
- <div className="flex flex-col gap-3 mb-4">
- {/* 第一行:名称 + 启用/禁用按钮 */}
- <div className="flex items-start justify-between gap-2">
- <div className="flex-1 min-w-0">
- <h3 className="text-base md:text-lg font-semibold text-gray-800 dark:text-white truncate">
- {config.name}
- </h3>
- <p className="text-xs md:text-sm text-gray-500 dark:text-gray-400 mt-0.5">
- {config.authType === 'none' ? '无需认证' : '需要登录'}
- {mounted && latency !== null && isHealthy && isEnabled && (
- <span className="ml-2 text-xs text-gray-400">
- ({latency}ms)
- </span>
- )}
- </p>
- </div>
- {/* 启用/禁用按钮 - 移动端加大点击区域,只在客户端显示真实状态 */}
- <button
- type="button"
- onClick={handleToggleEnabled}
- className={`flex items-center gap-1.5 px-3 py-2 md:px-2.5 md:py-1 rounded-full text-xs md:text-sm font-medium transition-all min-h-[44px] md:min-h-0 ${
- !mounted ? 'bg-gray-100 dark:bg-gray-700 text-gray-400' :
- isEnabled
- ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800'
- : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
- }`}
- title={mounted ? (isEnabled ? '点击禁用此 MCP 服务器' : '点击启用此 MCP 服务器') : '加载中...'}
- >
- <span className={`w-2 h-2 rounded-full ${!mounted ? 'bg-gray-300' : isEnabled ? 'bg-green-500' : 'bg-gray-400'}`} />
- {!mounted ? '...' : isEnabled ? '禁用' : '启用'}
- </button>
- </div>
- {/* 第二行:连接状态 */}
- <div className="flex items-center">
- <div className={`flex items-center gap-2 px-3 py-1.5 md:py-1 rounded-full text-xs md:text-sm ${connectionStatus.className}`}>
- <span className={`w-2 h-2 rounded-full ${connectionStatus.dotColor} ${isCheckingHealth ? 'animate-pulse' : ''}`} />
- {connectionStatus.text}
- </div>
- </div>
- {/* 描述(如果有) */}
- {config.description && (
- <p className="text-xs text-gray-400 dark:text-gray-500">
- {config.description}
- </p>
- )}
- </div>
- {/* 已登录状态显示 - 移动端优化,只在客户端渲染 */}
- {mounted && isLoggedIn && (
- <div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
- <p className="text-sm text-green-800 dark:text-green-200">
- 已登录: <span className="font-medium">{username}</span>
- </p>
- <p className="text-xs text-green-600 dark:text-green-400 mt-1">
- Token 有效期剩余约 {hoursRemaining} 小时
- </p>
- </div>
- )}
- {/* 禁用提示 - 移动端优化,只在客户端渲染 */}
- {mounted && !isEnabled && (
- <div className="mb-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
- <p className="text-xs md:text-sm text-gray-600 dark:text-gray-400">
- 此 MCP 服务器已禁用。点击右上角的"启用"按钮来启用它。
- </p>
- </div>
- )}
- {mounted && showLoginForm && !isLoggedIn && isEnabled && (
- <form onSubmit={handleLogin} className="mb-4 space-y-3">
- <div>
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
- 邮箱
- </label>
- <input
- type="email"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- required
- className="w-full px-3 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
- placeholder="your@email.com"
- />
- </div>
- <div>
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
- 密码
- </label>
- <input
- type="password"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- required
- className="w-full px-3 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
- placeholder="••••••••"
- />
- </div>
- {error && (
- <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
- )}
- <div className="flex gap-2">
- <button
- type="submit"
- disabled={isLoading}
- className="flex-1 py-3 md:py-2 px-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors text-sm min-h-[44px] md:min-h-0"
- >
- {isLoading ? '登录中...' : '登录'}
- </button>
- <button
- type="button"
- onClick={() => {
- setShowLoginForm(false);
- setError('');
- setEmail('');
- setPassword('');
- }}
- className="py-3 md:py-2 px-4 border dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm min-h-[44px] md:min-h-0"
- >
- 取消
- </button>
- </div>
- {/* 开发环境测试账号辅助信息 */}
- {process.env.NODE_ENV === 'development' && (
- <DevTestAccounts mcpType={mcpType} />
- )}
- </form>
- )}
- {mounted && showTokenForm && !isLoggedIn && (
- <div className="mb-4 space-y-3">
- <div>
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
- JWT Token
- </label>
- <textarea
- value={manualToken}
- onChange={(e) => setManualToken(e.target.value)}
- className="w-full px-3 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-xs font-mono"
- placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
- rows={3}
- />
- </div>
- <div>
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
- 用户名 (可选)
- </label>
- <input
- type="text"
- value={manualUsername}
- onChange={(e) => setManualUsername(e.target.value)}
- className="w-full px-3 py-2 border dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
- placeholder="测试用户"
- />
- </div>
- {error && (
- <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
- )}
- <div className="flex gap-2">
- <button
- onClick={handleSetToken}
- className="flex-1 py-3 md:py-2 px-4 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm min-h-[44px] md:min-h-0"
- >
- 设置 Token
- </button>
- <button
- onClick={() => {
- setShowTokenForm(false);
- setError('');
- setManualToken('');
- setManualUsername('');
- }}
- className="py-3 md:py-2 px-4 border dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm min-h-[44px] md:min-h-0"
- >
- 取消
- </button>
- </div>
- </div>
- )}
- {/* 底部操作区 - 移动端优化 */}
- <div className="flex flex-col gap-3 pt-3 border-t dark:border-gray-700">
- {/* URL 显示 - 移动端截断 */}
- <p className="text-xs text-gray-400 dark:text-gray-500 truncate max-w-full overflow-hidden" title={config.url}>
- {config.url}
- </p>
- {/* 操作按钮 - 移动端加大点击区域,按钮靠左对齐 */}
- {mounted && config.authType === 'jwt' && isEnabled && (
- <div className="flex gap-3 md:gap-2">
- {!isLoggedIn ? (
- !showLoginForm && !showTokenForm && (
- <>
- <button
- onClick={() => setShowLoginForm(true)}
- className="text-sm text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 font-medium px-4 py-2.5 md:px-0 md:py-0 rounded-lg md:rounded-none bg-blue-50 dark:bg-blue-900/30 md:bg-transparent min-h-[44px] md:min-h-0 md:inline"
- >
- 登录
- </button>
- <button
- onClick={() => setShowTokenForm(true)}
- className="text-sm text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 font-medium px-4 py-2.5 md:px-0 md:py-0 rounded-lg md:rounded-none bg-green-50 dark:bg-green-900/30 md:bg-transparent min-h-[44px] md:min-h-0 md:inline"
- >
- 设置 Token
- </button>
- </>
- )
- ) : (
- <button
- onClick={handleLogout}
- className="text-sm text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 font-medium px-4 py-2.5 md:px-0 md:py-0 rounded-lg md:rounded-none bg-red-50 dark:bg-red-900/30 md:bg-transparent min-h-[44px] md:min-h-0 md:inline"
- >
- 登出
- </button>
- )}
- </div>
- )}
- </div>
- </div>
- );
- }
|