浏览代码

add remote control

welin 3 月之前
父节点
当前提交
cd76a4b8d6

+ 1 - 1
.env.local

@@ -1,4 +1,4 @@
 # .env.local
 # 如果你的后端在本地运行,通常是 http://127.0.0.1:8000
 # 如果已经部署到服务器,填写服务器地址,如 https://api.visafly.com
-NEXT_PUBLIC_API_URL=https://visafly.top
+NEXT_PUBLIC_API_URL=http://localhost:8888

+ 15 - 0
src/app/admin/remote-server/page.tsx

@@ -0,0 +1,15 @@
+'use client';
+
+import { useState } from 'react';
+import RemoteServerControl from '@/components/admin/remote-server/RemoteServerControl';
+
+export default function RemoteServerPage() {
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center justify-between">
+        <h1 className="text-3xl font-bold text-slate-800">远程服务器控制</h1>
+      </div>
+      <RemoteServerControl />
+    </div>
+  );
+}

+ 3 - 2
src/components/admin/AdminSidebar.tsx

@@ -7,7 +7,7 @@ import {
   LayoutDashboard, ShoppingBag, LifeBuoy, Settings, LogOut, 
   Activity, CreditCard, Users, ChevronLeft, ChevronRight, 
   Plane, LucideIcon, CalendarClock, LayoutGrid, CheckCheck,
-  Menu, X     
+  Menu, X, Server
 } from 'lucide-react';
 
 interface MenuItem {
@@ -34,7 +34,8 @@ export default function AdminSidebar() {
     { name: '商品配置', href: '/admin/products', icon: Settings },
     { name: '系统任务', href: '/admin/tasks', icon: Activity },
     { name: 'TROOV Slot监控', href: '/admin/slots', icon: CalendarClock },
-    { name: '卡片管理', href: '/admin/cards', icon: LayoutGrid }, 
+    { name: '卡片管理', href: '/admin/cards', icon: LayoutGrid },
+    { name: '远程服务器', href: '/admin/remote-server', icon: Server },
   ];
 
   useEffect(() => {

+ 268 - 0
src/components/admin/remote-server/ConfigManager.tsx

@@ -0,0 +1,268 @@
+'use client';
+
+import { useState } from 'react';
+import api from '@/lib/api';
+import { Settings, Save, RefreshCw, AlertCircle, FileText } from 'lucide-react';
+
+interface ServerConfig {
+  host: string;
+  port: number;
+  username: string;
+  password?: string;
+  key_file?: string;
+  project_path: string;
+}
+
+export default function ConfigManager({ 
+  serverConfig,
+  serverId 
+}: { 
+  serverConfig: ServerConfig;
+  serverId?: string;
+}) {
+  const [configFile, setConfigFile] = useState<string>('config/troov_config.json');
+  const [configData, setConfigData] = useState<any>(null);
+  const [configJson, setConfigJson] = useState<string>('');
+  const [keyPath, setKeyPath] = useState<string>('');
+  const [newValue, setNewValue] = useState<string>('');
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [success, setSuccess] = useState<string | null>(null);
+
+  const fetchConfig = async () => {
+    if (!configFile) {
+      setError('请先输入配置文件路径');
+      return;
+    }
+
+    setLoading(true);
+    setError(null);
+    setSuccess(null);
+    try {
+      const url = serverId ? '/api/remote/server/config/read' : '/api/remote/config/read';
+      const payload = serverId 
+        ? { server_id: serverId, config_file: configFile }
+        : { ...serverConfig, config_file: configFile };
+
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        setConfigData(response.data.data.config);
+        setConfigJson(JSON.stringify(response.data.data.config, null, 2));
+      } else {
+        setError(response.data.message || '读取配置文件失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '读取配置文件失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const updateConfigByKey = async () => {
+    if (!configFile || !keyPath || !newValue) {
+      setError('请填写配置文件路径、键路径和新值');
+      return;
+    }
+
+    setLoading(true);
+    setError(null);
+    setSuccess(null);
+    try {
+      // 尝试解析新值(支持 JSON、数字、布尔值)
+      let parsedValue: any = newValue;
+      try {
+        if (newValue.trim().startsWith('{') || newValue.trim().startsWith('[')) {
+          parsedValue = JSON.parse(newValue);
+        } else if (newValue.toLowerCase() === 'true') {
+          parsedValue = true;
+        } else if (newValue.toLowerCase() === 'false') {
+          parsedValue = false;
+        } else if (!isNaN(Number(newValue)) && newValue.trim() !== '') {
+          parsedValue = Number(newValue);
+        }
+      } catch {
+        // 保持为字符串
+      }
+
+      const url = serverId ? '/api/remote/server/config/update' : '/api/remote/config/update';
+      const payload = serverId 
+        ? { server_id: serverId, config_file: configFile, key_path: keyPath, value: parsedValue }
+        : { ...serverConfig, config_file: configFile, key_path: keyPath, value: parsedValue };
+
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        setSuccess('配置文件更新成功');
+        await fetchConfig(); // 重新读取配置
+        setKeyPath('');
+        setNewValue('');
+      } else {
+        setError(response.data.message || '更新配置文件失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '更新配置文件失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const updateConfigByJson = async () => {
+    if (!configFile || !configJson) {
+      setError('请填写配置文件路径和JSON内容');
+      return;
+    }
+
+    setLoading(true);
+    setError(null);
+    setSuccess(null);
+    try {
+      const parsed = JSON.parse(configJson);
+      const updatePromises: Promise<any>[] = [];
+      
+      // 使用队列方式遍历对象,避免递归函数
+      const queue: Array<{ obj: any; prefix: string }> = [{ obj: parsed, prefix: '' }];
+      
+      while (queue.length > 0) {
+        const { obj, prefix } = queue.shift()!;
+        
+        for (const [key, value] of Object.entries(obj)) {
+          const fullPath = prefix ? `${prefix}.${key}` : key;
+          if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+            queue.push({ obj: value, prefix: fullPath });
+          } else {
+            const url = serverId ? '/api/remote/server/config/update' : '/api/remote/config/update';
+            const payload = serverId 
+              ? { server_id: serverId, config_file: configFile, key_path: fullPath, value: value }
+              : { ...serverConfig, config_file: configFile, key_path: fullPath, value: value };
+
+            updatePromises.push(api.post(url, payload));
+          }
+        }
+      }
+      
+      await Promise.all(updatePromises);
+      setSuccess('配置文件更新成功');
+      await fetchConfig();
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '更新配置文件失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center justify-between">
+        <h3 className="text-lg font-semibold text-slate-800">配置文件管理</h3>
+      </div>
+
+      {error && (
+        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md flex items-center gap-2">
+          <AlertCircle size={18} />
+          {error}
+        </div>
+      )}
+
+      {success && (
+        <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md flex items-center gap-2">
+          <Settings size={18} />
+          {success}
+        </div>
+      )}
+
+      {/* 读取配置 */}
+      <div className="bg-white rounded-lg border border-slate-200 p-6">
+        <h4 className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
+          <FileText size={18} />
+          读取配置文件
+        </h4>
+        <div className="flex gap-2">
+          <input
+            type="text"
+            value={configFile}
+            onChange={(e) => setConfigFile(e.target.value)}
+            placeholder="配置文件路径,如: config/troov_config.json"
+            className="flex-1 px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+          />
+          <button
+            onClick={fetchConfig}
+            disabled={loading}
+            className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+          >
+            <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
+            读取
+          </button>
+        </div>
+      </div>
+
+      {/* 显示配置内容 */}
+      {configData && (
+        <>
+          <div className="bg-slate-900 rounded-lg border border-slate-200 p-4">
+            <div className="flex items-center justify-between mb-2">
+              <h4 className="text-sm font-semibold text-white">配置文件内容 (JSON)</h4>
+            </div>
+            <textarea
+              value={configJson}
+              onChange={(e) => setConfigJson(e.target.value)}
+              className="w-full h-96 px-3 py-2 bg-slate-800 text-green-400 font-mono text-sm rounded border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
+              spellCheck={false}
+            />
+            <div className="mt-2 flex justify-end">
+              <button
+                onClick={updateConfigByJson}
+                disabled={loading}
+                className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+              >
+                <Save size={18} />
+                保存整个配置
+              </button>
+            </div>
+          </div>
+
+          {/* 更新单个键值 */}
+          <div className="bg-white rounded-lg border border-slate-200 p-6">
+            <h4 className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
+              <Settings size={18} />
+              更新单个配置项
+            </h4>
+            <div className="space-y-4">
+              <div>
+                <label className="block text-sm font-medium text-slate-700 mb-1">
+                  键路径 (如: lockV1.sessionLimit)
+                </label>
+                <input
+                  type="text"
+                  value={keyPath}
+                  onChange={(e) => setKeyPath(e.target.value)}
+                  placeholder="lockV1.sessionLimit"
+                  className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                />
+              </div>
+              <div>
+                <label className="block text-sm font-medium text-slate-700 mb-1">新值</label>
+                <input
+                  type="text"
+                  value={newValue}
+                  onChange={(e) => setNewValue(e.target.value)}
+                  placeholder={'10 或 true 或 \'string\' 或 {"key": "value"}'}
+                  className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                />
+                <p className="mt-1 text-xs text-slate-500">
+                  支持数字、字符串、布尔值、JSON对象或数组
+                </p>
+              </div>
+              <button
+                onClick={updateConfigByKey}
+                disabled={loading}
+                className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+              >
+                <Save size={18} />
+                更新配置项
+              </button>
+            </div>
+          </div>
+        </>
+      )}
+    </div>
+  );
+}

+ 274 - 0
src/components/admin/remote-server/DockerControl.tsx

@@ -0,0 +1,274 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import api from '@/lib/api';
+import { Play, Square, RotateCw, RefreshCw, FileText, AlertCircle } from 'lucide-react';
+
+interface ServerConfig {
+  host: string;
+  port: number;
+  username: string;
+  password?: string;
+  key_file?: string;
+  project_path: string;
+}
+
+interface ContainerStatus {
+  name: string;
+  status: string;
+  image: string;
+}
+
+export default function DockerControl({ 
+  serverConfig, 
+  serverId 
+}: { 
+  serverConfig: ServerConfig;
+  serverId?: string;
+}) {
+  const [containers, setContainers] = useState<Record<string, ContainerStatus>>({});
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
+  const [logs, setLogs] = useState<string>('');
+  const [logsLines, setLogsLines] = useState(100);
+
+  const fetchStatus = async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const url = serverId ? '/api/remote/server/docker/status' : '/api/remote/docker/status';
+      const payload = serverId ? { server_id: serverId } : serverConfig;
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        setContainers(response.data.data.containers || {});
+      } else {
+        setError(response.data.message || '获取状态失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '请求失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchStatus();
+  }, [serverId]);
+
+  const handleDockerAction = async (action: 'start' | 'stop' | 'restart', containerName: string) => {
+    setLoading(true);
+    setError(null);
+    try {
+      const url = serverId ? `/api/remote/server/docker/${action}` : `/api/remote/docker/${action}`;
+      const payload = serverId 
+        ? { server_id: serverId, container_name: containerName }
+        : { ...serverConfig, container_name: containerName };
+        
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        alert('操作成功');
+        await fetchStatus();
+      } else {
+        setError(response.data.message || '操作失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '操作失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleComposeAction = async (action: 'up' | 'down') => {
+    setLoading(true);
+    setError(null);
+    try {
+      const url = serverId ? `/api/remote/server/docker/${action}` : `/api/remote/docker/${action}`;
+      const payload = serverId ? { server_id: serverId } : serverConfig;
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        alert('操作成功');
+        await fetchStatus();
+      } else {
+        setError(response.data.message || '操作失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '操作失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchLogs = async (containerName: string) => {
+    setLoading(true);
+    setError(null);
+    try {
+      const url = serverId ? '/api/remote/server/docker/logs' : '/api/remote/docker/logs';
+      const payload = serverId 
+        ? { server_id: serverId, container_name: containerName, lines: logsLines, follow: false }
+        : { ...serverConfig, container_name: containerName, lines: logsLines, follow: false };
+
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        setLogs(response.data.data.logs || '');
+        setSelectedContainer(containerName);
+      } else {
+        setError(response.data.message || '获取日志失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '获取日志失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="space-y-6">
+      {/* 操作栏 */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <h3 className="text-lg font-semibold text-slate-800">Docker 容器管理</h3>
+          <button
+            onClick={fetchStatus}
+            disabled={loading}
+            className="p-2 text-slate-600 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors disabled:opacity-50"
+            title="刷新状态"
+          >
+            <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
+          </button>
+        </div>
+        <div className="flex gap-2">
+          <button
+            onClick={() => handleComposeAction('up')}
+            disabled={loading}
+            className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+          >
+            <Play size={18} />
+            启动所有服务
+          </button>
+          <button
+            onClick={() => handleComposeAction('down')}
+            disabled={loading}
+            className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+          >
+            <Square size={18} />
+            停止所有服务
+          </button>
+        </div>
+      </div>
+
+      {error && (
+        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md flex items-center gap-2">
+          <AlertCircle size={18} />
+          {error}
+        </div>
+      )}
+
+      {/* 容器列表 */}
+      <div className="bg-slate-50 rounded-lg border border-slate-200 overflow-hidden">
+        <table className="w-full">
+          <thead className="bg-slate-100 border-b border-slate-200">
+            <tr>
+              <th className="px-4 py-3 text-left text-sm font-semibold text-slate-700">容器名称</th>
+              <th className="px-4 py-3 text-left text-sm font-semibold text-slate-700">状态</th>
+              <th className="px-4 py-3 text-left text-sm font-semibold text-slate-700">镜像</th>
+              <th className="px-4 py-3 text-right text-sm font-semibold text-slate-700">操作</th>
+            </tr>
+          </thead>
+          <tbody className="divide-y divide-slate-200">
+            {Object.keys(containers).length === 0 ? (
+              <tr>
+                <td colSpan={4} className="px-4 py-8 text-center text-slate-500">
+                  {loading ? '加载中...' : '暂无容器'}
+                </td>
+              </tr>
+            ) : (
+              Object.entries(containers).map(([name, container]) => (
+                <tr key={name} className="hover:bg-slate-50">
+                  <td className="px-4 py-3 text-sm text-slate-800 font-medium">{name}</td>
+                  <td className="px-4 py-3 text-sm">
+                    <span
+                      className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${
+                        container.status.includes('Up')
+                          ? 'bg-green-100 text-green-800'
+                          : 'bg-red-100 text-red-800'
+                      }`}
+                    >
+                      {container.status}
+                    </span>
+                  </td>
+                  <td className="px-4 py-3 text-sm text-slate-600">{container.image}</td>
+                  <td className="px-4 py-3 text-right">
+                    <div className="flex items-center justify-end gap-2">
+                      <button
+                        onClick={() => handleDockerAction('start', name)}
+                        disabled={loading || container.status.includes('Up')}
+                        className="p-1.5 text-green-600 hover:bg-green-50 rounded transition-colors disabled:opacity-50"
+                        title="启动"
+                      >
+                        <Play size={16} />
+                      </button>
+                      <button
+                        onClick={() => handleDockerAction('stop', name)}
+                        disabled={loading || !container.status.includes('Up')}
+                        className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
+                        title="停止"
+                      >
+                        <Square size={16} />
+                      </button>
+                      <button
+                        onClick={() => handleDockerAction('restart', name)}
+                        disabled={loading}
+                        className="p-1.5 text-blue-600 hover:bg-blue-50 rounded transition-colors disabled:opacity-50"
+                        title="重启"
+                      >
+                        <RotateCw size={16} />
+                      </button>
+                      <button
+                        onClick={() => fetchLogs(name)}
+                        disabled={loading}
+                        className="p-1.5 text-slate-600 hover:bg-slate-100 rounded transition-colors disabled:opacity-50"
+                        title="查看日志"
+                      >
+                        <FileText size={16} />
+                      </button>
+                    </div>
+                  </td>
+                </tr>
+              ))
+            )}
+          </tbody>
+        </table>
+      </div>
+
+      {/* 日志查看 */}
+      {selectedContainer && (
+        <div className="bg-slate-900 rounded-lg border border-slate-200 p-4">
+          <div className="flex items-center justify-between mb-3">
+            <h4 className="text-sm font-semibold text-white">
+              容器日志: {selectedContainer}
+            </h4>
+            <div className="flex items-center gap-2">
+              <input
+                type="number"
+                value={logsLines}
+                onChange={(e) => setLogsLines(parseInt(e.target.value) || 100)}
+                className="w-20 px-2 py-1 bg-slate-800 text-white text-sm rounded border border-slate-700"
+                placeholder="行数"
+              />
+              <button
+                onClick={() => fetchLogs(selectedContainer)}
+                className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
+              >
+                刷新
+              </button>
+            </div>
+          </div>
+          <pre className="text-xs text-green-400 font-mono overflow-x-auto max-h-96 overflow-y-auto whitespace-pre-wrap">
+            {logs || '暂无日志'}
+          </pre>
+        </div>
+      )}
+    </div>
+  );
+}

+ 245 - 0
src/components/admin/remote-server/LogViewer.tsx

@@ -0,0 +1,245 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import api from '@/lib/api';
+import { FileText, Search, RefreshCw, AlertCircle, Download } from 'lucide-react';
+
+interface ServerConfig {
+  host: string;
+  port: number;
+  username: string;
+  password?: string;
+  key_file?: string;
+  project_path: string;
+}
+
+export default function LogViewer({ 
+  serverConfig,
+  serverId 
+}: { 
+  serverConfig: ServerConfig;
+  serverId?: string;
+}) {
+  const [logFiles, setLogFiles] = useState<string[]>([]);
+  const [selectedLog, setSelectedLog] = useState<string>('');
+  const [logContent, setLogContent] = useState<string>('');
+  const [lines, setLines] = useState(100);
+  const [fromHead, setFromHead] = useState(false);
+  const [full, setFull] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const fetchLogList = async () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const url = serverId ? '/api/remote/server/log/list' : '/api/remote/log/list';
+      const payload = serverId ? { server_id: serverId } : serverConfig;
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        setLogFiles(response.data.data.log_files || []);
+      } else {
+        setError(response.data.message || '获取日志列表失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '请求失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchLogContent = async () => {
+    if (!selectedLog) return;
+    
+    setLoading(true);
+    setError(null);
+    try {
+      const url = serverId ? '/api/remote/server/log/read' : '/api/remote/log/read';
+      const payload = serverId 
+        ? { 
+            server_id: serverId, 
+            log_file: selectedLog,
+            lines: full ? 100 : lines,
+            from_head: fromHead,
+            full: full
+          }
+        : {
+            ...serverConfig,
+            log_file: selectedLog,
+            lines: full ? 100 : lines,
+            from_head: fromHead,
+            full: full,
+          };
+
+      const response = await api.post(url, payload);
+      if (response.data.code === 0) {
+        setLogContent(response.data.data.content || '');
+      } else {
+        setError(response.data.message || '读取日志失败');
+      }
+    } catch (err: any) {
+      setError(err.response?.data?.message || err.message || '读取日志失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchLogList();
+  }, [serverId]);
+
+  useEffect(() => {
+    if (selectedLog) {
+      fetchLogContent();
+    }
+  }, [selectedLog, lines, fromHead, full]);
+
+  const downloadLog = () => {
+    if (!logContent) return;
+    const blob = new Blob([logContent], { type: 'text/plain' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = selectedLog || 'log.txt';
+    a.click();
+    URL.revokeObjectURL(url);
+  };
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center justify-between">
+        <h3 className="text-lg font-semibold text-slate-800">日志文件查看</h3>
+        <button
+          onClick={fetchLogList}
+          disabled={loading}
+          className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+        >
+          <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
+          刷新列表
+        </button>
+      </div>
+
+      {error && (
+        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md flex items-center gap-2">
+          <AlertCircle size={18} />
+          {error}
+        </div>
+      )}
+
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+        {/* 日志文件列表 */}
+        <div className="bg-slate-50 rounded-lg border border-slate-200 p-4">
+          <h4 className="text-sm font-semibold text-slate-700 mb-3">日志文件列表</h4>
+          <div className="space-y-1 max-h-96 overflow-y-auto">
+            {logFiles.length === 0 ? (
+              <p className="text-sm text-slate-500 text-center py-4">暂无日志文件</p>
+            ) : (
+              logFiles.map((file) => (
+                <button
+                  key={file}
+                  onClick={() => setSelectedLog(file)}
+                  className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
+                    selectedLog === file
+                      ? 'bg-blue-600 text-white'
+                      : 'bg-white text-slate-700 hover:bg-slate-100'
+                  }`}
+                >
+                  <FileText size={16} className="inline mr-2" />
+                  {file}
+                </button>
+              ))
+            )}
+          </div>
+        </div>
+
+        {/* 日志内容 */}
+        <div className="lg:col-span-2 space-y-4">
+          {selectedLog ? (
+            <>
+              {/* 控制栏 */}
+              <div className="bg-white rounded-lg border border-slate-200 p-4">
+                <div className="flex items-center gap-4 flex-wrap">
+                  <div className="flex items-center gap-2">
+                    <label className="text-sm text-slate-700">行数:</label>
+                    <input
+                      type="number"
+                      value={lines}
+                      onChange={(e) => {
+                        setLines(parseInt(e.target.value) || 100);
+                        setFull(false);
+                      }}
+                      disabled={full || loading}
+                      className="w-20 px-2 py-1 border border-slate-300 rounded text-sm disabled:opacity-50"
+                    />
+                  </div>
+                  <label className="flex items-center gap-2 text-sm text-slate-700">
+                    <input
+                      type="checkbox"
+                      checked={fromHead}
+                      onChange={(e) => {
+                        setFromHead(e.target.checked);
+                        setFull(false);
+                      }}
+                      disabled={full || loading}
+                      className="rounded"
+                    />
+                    从开头读取
+                  </label>
+                  <label className="flex items-center gap-2 text-sm text-slate-700">
+                    <input
+                      type="checkbox"
+                      checked={full}
+                      onChange={(e) => {
+                        setFull(e.target.checked);
+                        if (e.target.checked) {
+                          setFromHead(false);
+                        }
+                      }}
+                      disabled={loading}
+                      className="rounded"
+                    />
+                    读取全部
+                  </label>
+                  <button
+                    onClick={fetchLogContent}
+                    disabled={loading}
+                    className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+                  >
+                    <RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
+                    刷新
+                  </button>
+                  <button
+                    onClick={downloadLog}
+                    disabled={!logContent}
+                    className="px-4 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+                  >
+                    <Download size={16} />
+                    下载
+                  </button>
+                </div>
+              </div>
+
+              {/* 日志内容显示 */}
+              <div className="bg-slate-900 rounded-lg border border-slate-200 p-4">
+                <div className="flex items-center justify-between mb-2">
+                  <h4 className="text-sm font-semibold text-white">{selectedLog}</h4>
+                  <span className="text-xs text-slate-400">
+                    {logContent.split('\n').length} 行
+                  </span>
+                </div>
+                <pre className="text-xs text-green-400 font-mono overflow-x-auto max-h-[600px] overflow-y-auto whitespace-pre-wrap">
+                  {logContent || (loading ? '加载中...' : '暂无内容')}
+                </pre>
+              </div>
+            </>
+          ) : (
+            <div className="bg-slate-50 rounded-lg border border-slate-200 p-8 text-center">
+              <FileText size={48} className="mx-auto text-slate-400 mb-3" />
+              <p className="text-slate-500">请选择一个日志文件查看</p>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 396 - 0
src/components/admin/remote-server/RemoteServerControl.tsx

@@ -0,0 +1,396 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import api from '@/lib/api';
+import { Server, Play, Square, RotateCw, FileText, Settings, Search, RefreshCw } from 'lucide-react';
+import DockerControl from './DockerControl';
+import LogViewer from './LogViewer';
+import ConfigManager from './ConfigManager';
+
+interface ServerConfig {
+  host: string;
+  port: number;
+  username: string;
+  password?: string;
+  key_file?: string;
+  project_path: string;
+}
+
+interface PreConfiguredServer {
+  id: string;
+  name: string;
+  host: string;
+}
+
+export default function RemoteServerControl() {
+  const [activeTab, setActiveTab] = useState<'docker' | 'logs' | 'config'>('docker');
+  const [usePreConfigured, setUsePreConfigured] = useState(true);
+  const [preConfiguredServers, setPreConfiguredServers] = useState<PreConfiguredServer[]>([]);
+  const [selectedServerId, setSelectedServerId] = useState<string>('');
+  const [serverConfig, setServerConfig] = useState<ServerConfig>({
+    host: '',
+    port: 22,
+    username: 'root',
+    password: '',
+    key_file: '',
+    project_path: '/root/troov-asyncio',
+  });
+  const [isConnected, setIsConnected] = useState(false);
+  const [connecting, setConnecting] = useState(false);
+  const [connectionError, setConnectionError] = useState<string | null>(null);
+
+  // 获取预配置服务器列表
+  useEffect(() => {
+    const fetchServers = async () => {
+      try {
+        const response = await api.get('/api/remote/servers');
+        if (response.data.code === 0) {
+          const servers = response.data.data.servers || [];
+          setPreConfiguredServers(servers);
+          if (servers.length > 0) {
+            setSelectedServerId(servers[0].id);
+          }
+        }
+      } catch (err) {
+        console.error('获取服务器列表失败:', err);
+      }
+    };
+    fetchServers();
+  }, []);
+
+  const handleConnect = async () => {
+    setConnecting(true);
+    setConnectionError(null);
+    
+    try {
+      let response;
+      if (usePreConfigured) {
+        if (!selectedServerId) {
+          setConnectionError('请选择服务器');
+          setConnecting(false);
+          return;
+        }
+        // 使用预配置接口测试连接
+        response = await api.post('/api/remote/server/docker/status', {
+          server_id: selectedServerId
+        });
+      } else {
+        // 使用手动配置接口测试连接
+        if (!serverConfig.host || !serverConfig.username) {
+          setConnectionError('请填写服务器地址和用户名');
+          setConnecting(false);
+          return;
+        }
+        response = await api.post('/api/remote/docker/status', serverConfig);
+      }
+      
+      if (response.data.code === 0) {
+        setIsConnected(true);
+        setConnectionError(null);
+      } else {
+        setConnectionError(response.data.message || '连接失败');
+        setIsConnected(false);
+      }
+    } catch (err: any) {
+      // ... 错误处理逻辑保持不变 ...      console.error('连接错误详情:', err);
+      console.error('错误响应:', err.response);
+      console.error('错误请求:', err.request);
+      
+      let errorMsg = '连接失败,请检查服务器配置';
+      
+      if (err.response) {
+        // 服务器返回了响应
+        const status = err.response.status;
+        const data = err.response.data;
+        
+        if (status === 404) {
+          errorMsg = `API 路由未找到 (404)。请确认:
+1. 后端服务已重启并加载了新路由
+2. 访问 http://localhost:8888/docs 确认路由是否存在
+3. 检查 web-ui/.env.local 中的 NEXT_PUBLIC_API_URL 是否指向了正确的后端地址 (当前: ${process.env.NEXT_PUBLIC_API_URL})`;
+        } else if (status === 401) {
+          errorMsg = '未授权 (401),请确认您已登录且有管理员权限';
+        } else if (status === 403) {
+          errorMsg = '权限不足 (403),需要管理员权限';
+        } else if (data?.message) {
+          errorMsg = `${data.message} (${status})`;
+        } else if (data?.detail) {
+          errorMsg = `${data.detail} (${status})`;
+        } else {
+          errorMsg = `服务器错误 (${status}): ${err.response.statusText}`;
+        }
+      } else if (err.request) {
+        // 请求已发出但没有收到响应
+        errorMsg = `无法连接到后端服务器,请检查:
+1. 后端服务是否运行在 http://localhost:8888
+2. 网络连接是否正常
+3. 浏览器控制台是否有 CORS 错误`;
+      } else {
+        // 其他错误
+        errorMsg = err.message || '连接失败,请检查服务器配置';
+      }
+      
+      setConnectionError(errorMsg);
+      setIsConnected(false);
+    } finally {
+      setConnecting(false);
+    }
+  };
+
+  const handleDisconnect = () => {
+    setIsConnected(false);
+    setConnectionError(null);
+  };
+
+  return (
+    <div className="space-y-6">
+      {/* 服务器连接配置 */}
+      <div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
+        <div className="flex items-center justify-between mb-4">
+          <div className="flex items-center gap-2">
+            <Server className="text-blue-600" size={24} />
+            <h2 className="text-xl font-semibold text-slate-800">服务器连接配置</h2>
+          </div>
+          <div className="flex bg-slate-100 p-1 rounded-md">
+            <button
+              onClick={() => {
+                if (!isConnected) setUsePreConfigured(true);
+              }}
+              className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
+                usePreConfigured
+                  ? 'bg-white text-blue-600 shadow-sm'
+                  : 'text-slate-600 hover:text-slate-800'
+              }`}
+              disabled={isConnected}
+            >
+              预配置服务器
+            </button>
+            <button
+              onClick={() => {
+                if (!isConnected) setUsePreConfigured(false);
+              }}
+              className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
+                !usePreConfigured
+                  ? 'bg-white text-blue-600 shadow-sm'
+                  : 'text-slate-600 hover:text-slate-800'
+              }`}
+              disabled={isConnected}
+            >
+              手动直连
+            </button>
+          </div>
+        </div>
+        
+        {usePreConfigured ? (
+          <div className="space-y-4">
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">选择服务器</label>
+              <select
+                value={selectedServerId}
+                onChange={(e) => setSelectedServerId(e.target.value)}
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              >
+                <option value="">-- 请选择服务器 --</option>
+                {preConfiguredServers.map((server) => (
+                  <option key={server.id} value={server.id}>
+                    {server.name} ({server.host})
+                  </option>
+                ))}
+              </select>
+            </div>
+          </div>
+        ) : (
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">服务器地址 *</label>
+              <input
+                type="text"
+                value={serverConfig.host}
+                onChange={(e) => setServerConfig({ ...serverConfig, host: e.target.value })}
+                placeholder="192.168.1.100"
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              />
+            </div>
+            
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">SSH端口</label>
+              <input
+                type="number"
+                value={serverConfig.port}
+                onChange={(e) => setServerConfig({ ...serverConfig, port: parseInt(e.target.value) || 22 })}
+                placeholder="22"
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              />
+            </div>
+            
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">用户名 *</label>
+              <input
+                type="text"
+                value={serverConfig.username}
+                onChange={(e) => setServerConfig({ ...serverConfig, username: e.target.value })}
+                placeholder="root"
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              />
+            </div>
+            
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">密码</label>
+              <input
+                type="password"
+                value={serverConfig.password || ''}
+                onChange={(e) => setServerConfig({ ...serverConfig, password: e.target.value })}
+                placeholder="SSH密码"
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              />
+            </div>
+            
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">私钥文件路径</label>
+              <input
+                type="text"
+                value={serverConfig.key_file || ''}
+                onChange={(e) => setServerConfig({ ...serverConfig, key_file: e.target.value })}
+                placeholder="/path/to/key.pem"
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              />
+            </div>
+            
+            <div>
+              <label className="block text-sm font-medium text-slate-700 mb-1">项目路径</label>
+              <input
+                type="text"
+                value={serverConfig.project_path}
+                onChange={(e) => setServerConfig({ ...serverConfig, project_path: e.target.value })}
+                placeholder="/root/troov-asyncio"
+                className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                disabled={isConnected}
+              />
+            </div>
+          </div>
+        )}
+        
+        <div className="mt-4 space-y-2">
+          {connectionError && (
+            <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-md text-sm">
+              {connectionError}
+            </div>
+          )}
+          {isConnected && (
+            <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-2 rounded-md text-sm flex items-center gap-2">
+              <Server size={16} />
+              已连接到服务器
+            </div>
+          )}
+          <div className="flex gap-2">
+            {!isConnected ? (
+              <>
+                <button
+                  onClick={handleConnect}
+                  disabled={connecting}
+                  className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+                >
+                  <Server size={18} className={connecting ? 'animate-pulse' : ''} />
+                  {connecting ? '连接中...' : '连接服务器'}
+                </button>
+                <button
+                  onClick={async () => {
+                    try {
+                      const res = await api.get('/api/ping');
+                      alert(`API 测试成功: ${JSON.stringify(res.data)}`);
+                    } catch (err: any) {
+                      alert(`API 测试失败: ${err.response?.status || '无响应'} - ${err.response?.data?.message || err.message}`);
+                    }
+                  }}
+                  className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors flex items-center gap-2"
+                  title="测试后端 API 连接"
+                >
+                  <RefreshCw size={18} />
+                  测试 API
+                </button>
+              </>
+            ) : (
+              <button
+                onClick={handleDisconnect}
+                className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
+              >
+                <Server size={18} />
+                断开连接
+              </button>
+            )}
+          </div>
+        </div>
+      </div>
+
+      {/* 功能标签页 */}
+      {isConnected && (
+        <div className="bg-white rounded-lg shadow-sm border border-slate-200">
+          <div className="border-b border-slate-200">
+            <nav className="flex -mb-px">
+              <button
+                onClick={() => setActiveTab('docker')}
+                className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
+                  activeTab === 'docker'
+                    ? 'border-blue-600 text-blue-600'
+                    : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
+                }`}
+              >
+                <Play size={18} />
+                Docker控制
+              </button>
+              <button
+                onClick={() => setActiveTab('logs')}
+                className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
+                  activeTab === 'logs'
+                    ? 'border-blue-600 text-blue-600'
+                    : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
+                }`}
+              >
+                <FileText size={18} />
+                日志查看
+              </button>
+              <button
+                onClick={() => setActiveTab('config')}
+                className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
+                  activeTab === 'config'
+                    ? 'border-blue-600 text-blue-600'
+                    : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
+                }`}
+              >
+                <Settings size={18} />
+                配置文件
+              </button>
+            </nav>
+          </div>
+          
+          <div className="p-6">
+            {activeTab === 'docker' && (
+              <DockerControl 
+                serverConfig={serverConfig} 
+                serverId={usePreConfigured ? selectedServerId : undefined} 
+              />
+            )}
+            {activeTab === 'logs' && (
+              <LogViewer 
+                serverConfig={serverConfig} 
+                serverId={usePreConfigured ? selectedServerId : undefined} 
+              />
+            )}
+            {activeTab === 'config' && (
+              <ConfigManager 
+                serverConfig={serverConfig} 
+                serverId={usePreConfigured ? selectedServerId : undefined} 
+              />
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}