McpServerCard.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. /**
  2. * MCP 服务器卡片组件
  3. */
  4. 'use client';
  5. import { useState, useEffect, useRef } from 'react';
  6. import { MCP_SERVERS, mcpTokenManager, type McpServerConfig } from '@/lib/mcp-token-manager';
  7. /**
  8. * 开发环境测试账号辅助组件
  9. */
  10. function DevTestAccounts({ mcpType }: { mcpType: string }) {
  11. const [copied, setCopied] = useState<string | null>(null);
  12. // 测试账号配置
  13. const testAccounts: Record<string, { email: string; password: string }> = {
  14. 'novel-platform-user': {
  15. email: 'reader-test@example.com',
  16. password: 'ReaderTest2026@',
  17. },
  18. 'novel-platform-admin': {
  19. email: 'admin-test@example.com',
  20. password: 'AdminTest2026@',
  21. },
  22. };
  23. const account = testAccounts[mcpType];
  24. if (!account) return null;
  25. const copyToClipboard = async (text: string, type: string) => {
  26. try {
  27. await navigator.clipboard.writeText(text);
  28. setCopied(type);
  29. setTimeout(() => setCopied(null), 2000);
  30. } catch (err) {
  31. console.error('复制失败:', err);
  32. }
  33. };
  34. return (
  35. <div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
  36. <p className="text-xs font-medium text-amber-800 dark:text-amber-200 mb-2">
  37. 🛠️ 开发环境测试账号
  38. </p>
  39. <div className="space-y-2 text-xs">
  40. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
  41. <span className="text-gray-700 dark:text-gray-300">
  42. 邮箱: <span className="font-mono font-medium">{account.email}</span>
  43. </span>
  44. <button
  45. type="button"
  46. onClick={() => copyToClipboard(account.email, 'email')}
  47. 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"
  48. >
  49. {copied === 'email' ? '已复制 ✓' : '复制邮箱'}
  50. </button>
  51. </div>
  52. <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
  53. <span className="text-gray-700 dark:text-gray-300">
  54. 密码: <span className="font-mono font-medium">••••••••••••</span>
  55. </span>
  56. <button
  57. type="button"
  58. onClick={() => copyToClipboard(account.password, 'password')}
  59. 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"
  60. >
  61. {copied === 'password' ? '已复制 ✓' : '复制密码'}
  62. </button>
  63. </div>
  64. </div>
  65. </div>
  66. );
  67. }
  68. interface McpServerCardProps {
  69. mcpType: string;
  70. config: McpServerConfig;
  71. onConnectionStatusChange?: (mcpType: string, status: { healthy: boolean; loggedIn: boolean }) => void;
  72. onEnabledChange?: (mcpType: string, enabled: boolean) => void;
  73. }
  74. export function McpServerCard({ mcpType, config, onConnectionStatusChange, onEnabledChange }: McpServerCardProps) {
  75. const [showLoginForm, setShowLoginForm] = useState(false);
  76. const [showTokenForm, setShowTokenForm] = useState(false);
  77. const [email, setEmail] = useState('');
  78. const [password, setPassword] = useState('');
  79. const [manualToken, setManualToken] = useState('');
  80. const [manualUsername, setManualUsername] = useState('');
  81. const [isLoading, setIsLoading] = useState(false);
  82. const [error, setError] = useState('');
  83. const [updateTrigger, setUpdateTrigger] = useState(0);
  84. // 客户端挂载标志 - 用于避免 hydration 错误
  85. const [mounted, setMounted] = useState(false);
  86. // 启用/禁用状态 - 初始值统一为 true,客户端挂载后从 localStorage 读取真实值
  87. const [isEnabled, setIsEnabled] = useState(true);
  88. // 健康检查状态
  89. const [isHealthy, setIsHealthy] = useState<boolean | null>(null);
  90. const [isCheckingHealth, setIsCheckingHealth] = useState(false);
  91. const [latency, setLatency] = useState<number | null>(null);
  92. // 监听 localStorage 变化和组件挂载
  93. useEffect(() => {
  94. // 设置客户端挂载标志
  95. setMounted(true);
  96. // 从 localStorage 读取真实的启用状态
  97. const storedEnabled = mcpTokenManager.isEnabled(mcpType);
  98. console.log(`[McpServerCard ${mcpType}] Client mounted, isEnabled from localStorage: ${storedEnabled}`);
  99. setIsEnabled(storedEnabled);
  100. // 初始化时检查登录状态
  101. const checkLoginStatus = () => {
  102. const token = localStorage.getItem(`mcp_token_${mcpType}`);
  103. const username = localStorage.getItem(`mcp_username_${mcpType}`);
  104. console.log(`[McpServerCard ${mcpType}] Initial check:`, {
  105. hasToken: !!token,
  106. tokenLength: token?.length,
  107. username
  108. });
  109. setUpdateTrigger(prev => prev + 1);
  110. };
  111. checkLoginStatus();
  112. // 检查服务器健康状态
  113. const checkServerHealth = async () => {
  114. setIsCheckingHealth(true);
  115. try {
  116. const result = await mcpTokenManager.checkHealth(mcpType);
  117. setIsHealthy(result.healthy);
  118. setLatency(result.latency ?? null);
  119. console.log(`[McpServerCard ${mcpType}] Health check:`, result);
  120. } catch (e) {
  121. console.error(`[McpServerCard ${mcpType}] Health check error:`, e);
  122. setIsHealthy(false);
  123. } finally {
  124. setIsCheckingHealth(false);
  125. }
  126. };
  127. checkServerHealth();
  128. // 监听 storage 事件(其他标签页的更改)
  129. const handleStorageChange = (e: StorageEvent) => {
  130. if (e.key?.startsWith('mcp_token_') || e.key?.startsWith('mcp_username_')) {
  131. console.log(`[McpServerCard ${mcpType}] Storage changed:`, e.key);
  132. setUpdateTrigger(prev => prev + 1);
  133. }
  134. // 监听启用/禁用状态变化
  135. if (e.key === `mcp_enabled_${mcpType}`) {
  136. const newState = e.newValue === 'true';
  137. setIsEnabled(newState);
  138. onEnabledChange?.(mcpType, newState);
  139. }
  140. };
  141. window.addEventListener('storage', handleStorageChange);
  142. // 定期重新检查健康状态(每 30 秒)
  143. const healthInterval = setInterval(checkServerHealth, 30000);
  144. return () => {
  145. window.removeEventListener('storage', handleStorageChange);
  146. clearInterval(healthInterval);
  147. };
  148. }, [mcpType]);
  149. const isLoggedIn = mcpTokenManager.isLoggedIn(mcpType);
  150. const username = mcpTokenManager.getUsername(mcpType);
  151. const remainingTime = mcpTokenManager.getTokenRemainingTime(mcpType);
  152. const hoursRemaining = Math.floor(remainingTime / (1000 * 60 * 60));
  153. // 使用 ref 跟踪上次报告的状态,避免重复报告相同状态
  154. const lastReportedStatusRef = useRef<{ healthy: boolean | null; loggedIn: boolean }>({
  155. healthy: null,
  156. loggedIn: false
  157. });
  158. // 向父组件报告连接状态变化
  159. useEffect(() => {
  160. if (isHealthy !== null) {
  161. const lastReported = lastReportedStatusRef.current;
  162. // 只在状态真正改变时才报告
  163. if (lastReported.healthy !== isHealthy || lastReported.loggedIn !== isLoggedIn) {
  164. lastReportedStatusRef.current = { healthy: isHealthy, loggedIn: isLoggedIn };
  165. onConnectionStatusChange?.(mcpType, {
  166. healthy: isHealthy,
  167. loggedIn: isLoggedIn
  168. });
  169. }
  170. }
  171. }, [isHealthy, isLoggedIn, mcpType, onConnectionStatusChange]);
  172. // 计算连接状态显示
  173. // - 未挂载 → 加载中
  174. // - 禁用 → 灰色圆点
  175. // - 健康 + 已登录 → 已连接 ✓ | 已登录 ✓
  176. // - 健康 + 未登录 → 已连接 ✓ | 未登录
  177. // - 不健康 → 离线
  178. const getConnectionStatus = () => {
  179. // 未挂载时显示加载状态,避免 hydration 错误
  180. if (!mounted) {
  181. return { text: '加载中...', className: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400', dotColor: 'bg-gray-300' };
  182. }
  183. // 禁用状态优先,显示灰色
  184. if (!isEnabled) {
  185. return { text: '已禁用', className: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400', dotColor: 'bg-gray-400' };
  186. }
  187. if (isHealthy === null) {
  188. return { text: '检查中...', className: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400', dotColor: 'bg-gray-400' };
  189. }
  190. if (isHealthy) {
  191. if (isLoggedIn) {
  192. return { text: '已连接 | 已登录', className: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', dotColor: 'bg-green-500' };
  193. } else {
  194. return { text: '已连接 | 未登录', className: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', dotColor: 'bg-blue-500' };
  195. }
  196. } else {
  197. return { text: '离线', className: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', dotColor: 'bg-red-500' };
  198. }
  199. };
  200. const connectionStatus = getConnectionStatus();
  201. const handleLogin = async (e: React.FormEvent) => {
  202. e.preventDefault();
  203. setIsLoading(true);
  204. setError('');
  205. const result = await mcpTokenManager.loginMcp(mcpType, email, password);
  206. if (result.success) {
  207. setShowLoginForm(false);
  208. setEmail('');
  209. setPassword('');
  210. // 强制刷新组件状态
  211. setUpdateTrigger(prev => prev + 1);
  212. } else {
  213. setError(result.error || '登录失败');
  214. }
  215. setIsLoading(false);
  216. };
  217. const handleLogout = () => {
  218. mcpTokenManager.logoutMcp(mcpType);
  219. // 强制刷新组件状态
  220. setUpdateTrigger(prev => prev + 1);
  221. };
  222. const handleSetToken = () => {
  223. if (!manualToken.trim()) {
  224. setError('Token 不能为空');
  225. return;
  226. }
  227. mcpTokenManager.saveToken(mcpType, manualToken, manualUsername || 'Token User');
  228. setShowTokenForm(false);
  229. setManualToken('');
  230. setManualUsername('');
  231. setUpdateTrigger(prev => prev + 1);
  232. };
  233. const handleToggleEnabled = () => {
  234. console.log(`[McpServerCard.handleToggleEnabled] Called for ${mcpType}, current isEnabled=${isEnabled}`);
  235. const newState = mcpTokenManager.toggleEnabled(mcpType);
  236. console.log(`[McpServerCard.handleToggleEnabled] toggleEnabled returned: ${newState}`);
  237. setIsEnabled(newState);
  238. onEnabledChange?.(mcpType, newState);
  239. // 触发自定义事件,通知 Header 等组件更新状态
  240. window.dispatchEvent(new CustomEvent('mcp-enabled-change'));
  241. console.log(`[McpServerCard.handleToggleEnabled] Dispatched mcp-enabled-change event`);
  242. };
  243. return (
  244. <div
  245. 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' : ''}`}
  246. suppressHydrationWarning
  247. >
  248. {/* 头部区域 - 移动端优化 */}
  249. <div className="flex flex-col gap-3 mb-4">
  250. {/* 第一行:名称 + 启用/禁用按钮 */}
  251. <div className="flex items-start justify-between gap-2">
  252. <div className="flex-1 min-w-0">
  253. <h3 className="text-base md:text-lg font-semibold text-gray-800 dark:text-white truncate">
  254. {config.name}
  255. </h3>
  256. <p className="text-xs md:text-sm text-gray-500 dark:text-gray-400 mt-0.5">
  257. {config.authType === 'none' ? '无需认证' : '需要登录'}
  258. {mounted && latency !== null && isHealthy && isEnabled && (
  259. <span className="ml-2 text-xs text-gray-400">
  260. ({latency}ms)
  261. </span>
  262. )}
  263. </p>
  264. </div>
  265. {/* 启用/禁用按钮 - 移动端加大点击区域,只在客户端显示真实状态 */}
  266. <button
  267. type="button"
  268. onClick={handleToggleEnabled}
  269. 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 ${
  270. !mounted ? 'bg-gray-100 dark:bg-gray-700 text-gray-400' :
  271. isEnabled
  272. ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800'
  273. : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
  274. }`}
  275. title={mounted ? (isEnabled ? '点击禁用此 MCP 服务器' : '点击启用此 MCP 服务器') : '加载中...'}
  276. >
  277. <span className={`w-2 h-2 rounded-full ${!mounted ? 'bg-gray-300' : isEnabled ? 'bg-green-500' : 'bg-gray-400'}`} />
  278. {!mounted ? '...' : isEnabled ? '禁用' : '启用'}
  279. </button>
  280. </div>
  281. {/* 第二行:连接状态 */}
  282. <div className="flex items-center">
  283. <div className={`flex items-center gap-2 px-3 py-1.5 md:py-1 rounded-full text-xs md:text-sm ${connectionStatus.className}`}>
  284. <span className={`w-2 h-2 rounded-full ${connectionStatus.dotColor} ${isCheckingHealth ? 'animate-pulse' : ''}`} />
  285. {connectionStatus.text}
  286. </div>
  287. </div>
  288. {/* 描述(如果有) */}
  289. {config.description && (
  290. <p className="text-xs text-gray-400 dark:text-gray-500">
  291. {config.description}
  292. </p>
  293. )}
  294. </div>
  295. {/* 已登录状态显示 - 移动端优化,只在客户端渲染 */}
  296. {mounted && isLoggedIn && (
  297. <div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
  298. <p className="text-sm text-green-800 dark:text-green-200">
  299. 已登录: <span className="font-medium">{username}</span>
  300. </p>
  301. <p className="text-xs text-green-600 dark:text-green-400 mt-1">
  302. Token 有效期剩余约 {hoursRemaining} 小时
  303. </p>
  304. </div>
  305. )}
  306. {/* 禁用提示 - 移动端优化,只在客户端渲染 */}
  307. {mounted && !isEnabled && (
  308. <div className="mb-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
  309. <p className="text-xs md:text-sm text-gray-600 dark:text-gray-400">
  310. 此 MCP 服务器已禁用。点击右上角的"启用"按钮来启用它。
  311. </p>
  312. </div>
  313. )}
  314. {mounted && showLoginForm && !isLoggedIn && isEnabled && (
  315. <form onSubmit={handleLogin} className="mb-4 space-y-3">
  316. <div>
  317. <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  318. 邮箱
  319. </label>
  320. <input
  321. type="email"
  322. value={email}
  323. onChange={(e) => setEmail(e.target.value)}
  324. required
  325. 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"
  326. placeholder="your@email.com"
  327. />
  328. </div>
  329. <div>
  330. <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  331. 密码
  332. </label>
  333. <input
  334. type="password"
  335. value={password}
  336. onChange={(e) => setPassword(e.target.value)}
  337. required
  338. 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"
  339. placeholder="••••••••"
  340. />
  341. </div>
  342. {error && (
  343. <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
  344. )}
  345. <div className="flex gap-2">
  346. <button
  347. type="submit"
  348. disabled={isLoading}
  349. 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"
  350. >
  351. {isLoading ? '登录中...' : '登录'}
  352. </button>
  353. <button
  354. type="button"
  355. onClick={() => {
  356. setShowLoginForm(false);
  357. setError('');
  358. setEmail('');
  359. setPassword('');
  360. }}
  361. 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"
  362. >
  363. 取消
  364. </button>
  365. </div>
  366. {/* 开发环境测试账号辅助信息 */}
  367. {process.env.NODE_ENV === 'development' && (
  368. <DevTestAccounts mcpType={mcpType} />
  369. )}
  370. </form>
  371. )}
  372. {mounted && showTokenForm && !isLoggedIn && (
  373. <div className="mb-4 space-y-3">
  374. <div>
  375. <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  376. JWT Token
  377. </label>
  378. <textarea
  379. value={manualToken}
  380. onChange={(e) => setManualToken(e.target.value)}
  381. 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"
  382. placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  383. rows={3}
  384. />
  385. </div>
  386. <div>
  387. <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
  388. 用户名 (可选)
  389. </label>
  390. <input
  391. type="text"
  392. value={manualUsername}
  393. onChange={(e) => setManualUsername(e.target.value)}
  394. 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"
  395. placeholder="测试用户"
  396. />
  397. </div>
  398. {error && (
  399. <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
  400. )}
  401. <div className="flex gap-2">
  402. <button
  403. onClick={handleSetToken}
  404. 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"
  405. >
  406. 设置 Token
  407. </button>
  408. <button
  409. onClick={() => {
  410. setShowTokenForm(false);
  411. setError('');
  412. setManualToken('');
  413. setManualUsername('');
  414. }}
  415. 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"
  416. >
  417. 取消
  418. </button>
  419. </div>
  420. </div>
  421. )}
  422. {/* 底部操作区 - 移动端优化 */}
  423. <div className="flex flex-col gap-3 pt-3 border-t dark:border-gray-700">
  424. {/* URL 显示 - 移动端截断 */}
  425. <p className="text-xs text-gray-400 dark:text-gray-500 truncate max-w-full overflow-hidden" title={config.url}>
  426. {config.url}
  427. </p>
  428. {/* 操作按钮 - 移动端加大点击区域,按钮靠左对齐 */}
  429. {mounted && config.authType === 'jwt' && isEnabled && (
  430. <div className="flex gap-3 md:gap-2">
  431. {!isLoggedIn ? (
  432. !showLoginForm && !showTokenForm && (
  433. <>
  434. <button
  435. onClick={() => setShowLoginForm(true)}
  436. 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"
  437. >
  438. 登录
  439. </button>
  440. <button
  441. onClick={() => setShowTokenForm(true)}
  442. 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"
  443. >
  444. 设置 Token
  445. </button>
  446. </>
  447. )
  448. ) : (
  449. <button
  450. onClick={handleLogout}
  451. 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"
  452. >
  453. 登出
  454. </button>
  455. )}
  456. </div>
  457. )}
  458. </div>
  459. </div>
  460. );
  461. }