jerry před 3 měsíci
rodič
revize
d4d8d2b4ea

+ 1 - 1
.env.local

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

+ 194 - 62
src/app/admin/slots/page.tsx

@@ -3,13 +3,18 @@
 import { useState, useEffect } from 'react';
 import api from '@/lib/api';
 import { 
-  Search, Loader2, Clock, Percent, ShieldAlert, X, Terminal, CalendarCheck
+  Search, Loader2, Users, Clock, Percent, 
+  AlertTriangle, CheckCircle, ShieldAlert, X, Terminal,
+  CalendarCheck
 } from 'lucide-react';
+
+// 引入子组件
 import ProbabilityManager from '@/components/admin/slots/ProbabilityManager';
-// 1. 引入新组件
 import DailySlotDashboard from '@/components/admin/slots/DailySlotDashboard';
+import DashboardTaskPopup from '@/components/admin/slots/DashboardTaskPopup';
+
 
-// 复用类型定义
+// Slot 容量数据类型
 interface SlotItem {
   time: string;
   rate: string | number;
@@ -19,76 +24,99 @@ interface SlotItem {
 export default function AdminSlotsPage() {
   const [loading, setLoading] = useState(false);
   
-  // 数据源
-  const [slots, setSlots] = useState<SlotItem[]>([]);
-  const [grabbedTasks, setGrabbedTasks] = useState<any[]>([]); // 存储已抢任务
+  // === 数据源状态 ===
+  const [slots, setSlots] = useState<SlotItem[]>([]); // 容量数据
+  const [grabbedTasks, setGrabbedTasks] = useState<any[]>([]); // 当天的任务数据
   
+  // 日期选择
   const today = new Date().toISOString().split('T')[0];
   const [searchDate, setSearchDate] = useState(today);
 
-  // 功能开关
-  const [showChecker, setShowChecker] = useState(false);
-  const [showProbManager, setShowProbManager] = useState(false);
-  
-  // 冲突检测状态
+  // === 功能面板开关 ===
+  const [showChecker, setShowChecker] = useState(false); // 冲突检测面板
+  const [showProbManager, setShowProbManager] = useState(false); // 概率管理面板
+
+  // === 冲突检测状态 ===
   const [checkLoading, setCheckLoading] = useState(false);
   const [checkForm, setCheckForm] = useState({ first_name: '', last_name: '', birthday: '' });
-  const [apiResult, setApiResult] = useState<any>(null);
+  const [apiResult, setApiResult] = useState<any>(null); // 存储 API 原始响应
+
+  // === 任务详情弹窗状态 ===
+  const [selectedTask, setSelectedTask] = useState<any>(null);
+  const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
+
+  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
 
+  // 初始化加载
   useEffect(() => {
     fetchData();
-  }, []); // 初始化
+  }, []);
 
-  // === 核心修改:同时获取容量和任务数据 ===
+  // === 核心数据获取逻辑 ===
   const fetchData = async () => {
     if (!searchDate) return alert("请选择日期");
     setLoading(true);
+    
     try {
-      // 1. 获取容量 (Rate)
-      const ratePromise = api.get('/api/troov/rate', { params: { date: searchDate } });
+      // 并行请求:容量数据 + 任务数据
+      const ratePromise = api.get('/api/troov/rate', { 
+        params: { date: searchDate } 
+      });
       
-      // 2. 获取已抢任务 (Grabbed Tasks)
-      // 注意:这里我们获取 status=grabbed 的任务。
-      // 由于后端暂时不支持按日期筛选任务,我们先获取最近的 200 条,然后在前端 (DailySlotDashboard) 进行日期匹配。
-      // *理想情况是后端 task/list 接口支持 date 参数过滤*
+      // 获取 grabbed 状态的任务,以此来匹配日历
+      // 注意:这里取 size=200 是为了尽可能覆盖当天的任务,
+      // 如果后端支持按日期过滤 task/list 会更高效
       const taskPromise = api.get('/api/vas/task/list', { 
         params: { 
           page: 1, 
-          size: 200, // 尽量多取一点以覆盖当天
-          status: 'grabbed',
-          // 如果需要特定 routing_key,可以在这里加,或者在组件内过滤
+          size: 200, 
+          status: 'grabbed' 
         } 
       });
 
       const [rateRes, taskRes] = await Promise.all([ratePromise, taskPromise]);
 
-      // 处理 Capacity 数据
-      const slotList = Array.isArray(rateRes.data.data) ? rateRes.data.data : (rateRes.data.data || []);
-      setSlots(slotList);
+      // 1. 处理 Slot 容量数据
+      const list = Array.isArray(rateRes.data.data) ? rateRes.data.data : (rateRes.data.data || []);
+      // 按时间排序
+      list.sort((a: SlotItem, b: SlotItem) => a.time.localeCompare(b.time));
+      setSlots(list);
 
-      // 处理 Task 数据
+      // 2. 处理任务数据
       const taskList = taskRes.data.data?.items || [];
       setGrabbedTasks(taskList);
 
     } catch (e) {
       console.warn("API Error", e);
-      // Mock data logic can be added here if needed
+      setSlots([]);
+      setGrabbedTasks([]);
     } finally {
       setLoading(false);
     }
   };
 
+  // === 用户冲突检测逻辑 ===
   const handleCheckUser = async (e: React.FormEvent) => {
     e.preventDefault();
+    if (!checkForm.first_name || !checkForm.last_name || !checkForm.birthday) {
+      return alert("请填写完整信息");
+    }
+
     setCheckLoading(true);
     setApiResult(null);
+
     try {
       const res = await api.post('/api/troov/book', checkForm);
-      setApiResult({ status: res.status, statusText: res.statusText, data: res.data });
+      setApiResult({
+        status: res.status,
+        statusText: res.statusText,
+        data: res.data
+      });
     } catch (error: any) {
+      console.error(error);
       setApiResult({
-        status: error.response?.status || 'Error',
-        statusText: 'Failed',
+        status: error.response?.status || 'Network Error',
+        statusText: error.response?.statusText || 'Failed',
         data: error.response?.data || error.message
       });
     } finally {
@@ -96,10 +124,32 @@ export default function AdminSlotsPage() {
     }
   };
 
+  // === 任务操作处理 ===
+  const handleTaskClick = (task: any, target: HTMLElement) => {
+    setAnchorEl(target); // 保存锚点
+    setSelectedTask(task);
+    setIsTaskModalOpen(true);
+  };
+
+  const handleTaskSuccess = () => {
+    // 任务操作成功(如标记完成)后,刷新数据
+    fetchData();
+  };
+
+  const handleCloseModal = () => {
+    setIsTaskModalOpen(false);
+    setAnchorEl(null);
+  }
+
+  // === 统计计算 ===
+  const totalCapacity = slots.reduce((acc, curr) => acc + curr.capacity, 0);
+  const validSlots = slots.filter(s => s.capacity > 0 && Number(s.rate) <= 100).length;
+  const riskSlots = slots.filter(s => Number(s.rate) > 100).length;
+
   return (
     <div className="p-4 md:p-6">
       
-      {/* Header & Controls */}
+      {/* === 1. 顶部 Header & 操作栏 === */}
       <div className="flex flex-col xl:flex-row justify-between items-start xl:items-center mb-6 gap-4">
         <div>
           <h1 className="text-2xl font-bold text-slate-800">Slot 监控与风险预警</h1>
@@ -107,82 +157,164 @@ export default function AdminSlotsPage() {
         </div>
 
         <div className="flex flex-wrap gap-3 w-full xl:w-auto">
-          
+          {/* 用户冲突检测开关 */}
           <button 
             onClick={() => setShowChecker(!showChecker)}
-            className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm font-bold transition
+            className={`flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2 border rounded-lg text-sm font-bold transition whitespace-nowrap
               ${showChecker ? 'bg-orange-50 border-orange-200 text-orange-700' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}
             `}
           >
-            <ShieldAlert size={16} /> {showChecker ? '关闭检测' : '冲突检测'}
+            <ShieldAlert size={16} />
+            {showChecker ? '关闭检测' : '冲突检测'}
           </button>
 
+          {/* 概率管理开关 */}
           <button 
             onClick={() => setShowProbManager(!showProbManager)}
-            className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm font-bold transition
+            className={`flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2 border rounded-lg text-sm font-bold transition whitespace-nowrap
               ${showProbManager ? 'bg-purple-50 border-purple-200 text-purple-700' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}
             `}
           >
-            <Percent size={16} /> {showProbManager ? '关闭概率' : '概率管理'}
+            <Percent size={16} />
+            {showProbManager ? '关闭概率' : '概率管理'}
           </button>
 
-          <div className="flex items-center gap-2 bg-white p-1 rounded-lg border border-slate-200 shadow-sm">
-            <div className="relative">
+          {/* 日期选择与刷新 */}
+          <div className="flex items-center gap-2 bg-white p-1 rounded-lg border border-slate-200 shadow-sm w-full md:w-auto">
+            <div className="relative flex-1 md:flex-none">
               <input 
                 type="date" 
-                className="pl-8 pr-2 py-1.5 text-sm border-none outline-none bg-transparent text-slate-700 font-medium"
+                className="w-full pl-8 pr-2 py-1.5 text-sm border-none outline-none bg-transparent text-slate-700 font-medium"
                 value={searchDate}
                 onChange={(e) => setSearchDate(e.target.value)}
               />
               <Clock size={14} className="absolute left-2.5 top-2.5 text-slate-400" />
             </div>
-            <button onClick={fetchData} disabled={loading} className="p-1.5 bg-slate-900 text-white rounded-md hover:bg-slate-800 transition disabled:opacity-70">
+            <button 
+              onClick={fetchData}
+              disabled={loading}
+              className="p-1.5 bg-slate-900 text-white rounded-md hover:bg-slate-800 transition disabled:opacity-70"
+            >
               {loading ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
             </button>
           </div>
         </div>
       </div>
 
-      {/* Panels */}
-      {showProbManager && <div className="mb-8 animate-in fade-in slide-in-from-top-4"><ProbabilityManager /></div>}
-      
+      {/* === 2. 概率管理面板 === */}
+      {showProbManager && (
+        <div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300">
+          <ProbabilityManager />
+        </div>
+      )}
+
+      {/* === 3. 用户冲突检测面板 === */}
       {showChecker && (
-        <div className="mb-8 bg-orange-50/50 border border-orange-200 rounded-xl p-6 animate-in slide-in-from-top-2">
+        <div className="mb-8 bg-orange-50/50 border border-orange-200 rounded-xl p-6 animate-in slide-in-from-top-2 fade-in duration-300">
           <div className="flex justify-between items-start mb-4">
-            <h3 className="font-bold text-orange-900 flex items-center gap-2"><ShieldAlert size={20} /> 预订资格预检 (Raw)</h3>
-            <button onClick={() => setShowChecker(false)}><X size={20} className="text-orange-400"/></button>
+            <h3 className="font-bold text-orange-900 flex items-center gap-2">
+              <ShieldAlert size={20} /> 预订资格预检 API (Raw Response)
+            </h3>
+            <button onClick={() => setShowChecker(false)} className="text-orange-400 hover:text-orange-600">
+              <X size={20}/>
+            </button>
           </div>
+
           <form onSubmit={handleCheckUser} className="flex flex-col md:flex-row gap-4 items-end mb-4">
-            {['first_name', 'last_name'].map(field => (
-              <div key={field} className="flex-1 w-full">
-                <label className="block text-xs font-bold text-orange-800 mb-1 uppercase">{field.replace('_', ' ')}</label>
-                <input required type="text" className="w-full border border-orange-200 rounded-lg p-2.5 text-sm outline-none focus:ring-2 focus:ring-orange-400"
-                  value={(checkForm as any)[field]} onChange={e => setCheckForm({...checkForm, [field]: e.target.value})} />
-              </div>
-            ))}
-            <div className="flex-1 w-full">
+            <div className="flex-1 w-full md:w-auto">
+              <label className="block text-xs font-bold text-orange-800 mb-1 uppercase">First Name</label>
+              <input 
+                required type="text" placeholder="e.g. Hongping"
+                className="w-full border border-orange-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-orange-400 outline-none"
+                value={checkForm.first_name} onChange={e => setCheckForm({...checkForm, first_name: e.target.value})}
+              />
+            </div>
+            <div className="flex-1 w-full md:w-auto">
+              <label className="block text-xs font-bold text-orange-800 mb-1 uppercase">Last Name</label>
+              <input 
+                required type="text" placeholder="e.g. Liu"
+                className="w-full border border-orange-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-orange-400 outline-none"
+                value={checkForm.last_name} onChange={e => setCheckForm({...checkForm, last_name: e.target.value})}
+              />
+            </div>
+            <div className="flex-1 w-full md:w-auto">
               <label className="block text-xs font-bold text-orange-800 mb-1 uppercase">Birthday</label>
-              <input required type="date" className="w-full border border-orange-200 rounded-lg p-2.5 text-sm outline-none focus:ring-2 focus:ring-orange-400"
-                value={checkForm.birthday} onChange={e => setCheckForm({...checkForm, birthday: e.target.value})} />
+              <input 
+                required type="date"
+                className="w-full border border-orange-200 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-orange-400 outline-none"
+                value={checkForm.birthday} onChange={e => setCheckForm({...checkForm, birthday: e.target.value})}
+              />
             </div>
-            <button type="submit" disabled={checkLoading} className="px-6 py-2.5 bg-orange-600 text-white rounded-lg font-bold hover:bg-orange-700 transition disabled:opacity-70 flex items-center gap-2">
-              {checkLoading ? <Loader2 size={16} className="animate-spin"/> : <Terminal size={16}/>} 调用 API
+            <button 
+              type="submit" 
+              disabled={checkLoading}
+              className="w-full md:w-auto px-6 py-2.5 bg-orange-600 text-white rounded-lg font-bold hover:bg-orange-700 transition disabled:opacity-70 shadow-sm flex items-center justify-center gap-2"
+            >
+              {checkLoading ? <Loader2 size={16} className="animate-spin" /> : <Terminal size={16} />}
+              调用 API
             </button>
           </form>
+
+          {/* Raw Response Viewer */}
           {apiResult && (
-            <div className="bg-slate-900 rounded-lg overflow-hidden border border-slate-700 p-4 overflow-x-auto">
-              <pre className="text-xs font-mono text-green-400 whitespace-pre-wrap break-all">{JSON.stringify(apiResult, null, 2)}</pre>
+            <div className="bg-slate-900 rounded-lg overflow-hidden border border-slate-700 shadow-inner">
+              <div className="bg-slate-800 px-4 py-2 flex justify-between items-center text-xs text-slate-300 border-b border-slate-700">
+                <span className="font-mono">Response</span>
+                <span className={`font-bold px-2 py-0.5 rounded ${apiResult.status >= 200 && apiResult.status < 300 ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'}`}>
+                  HTTP {apiResult.status}
+                </span>
+              </div>
+              <div className="p-4 overflow-x-auto">
+                <pre className="text-xs font-mono text-green-400 whitespace-pre-wrap break-all">
+                  {JSON.stringify(apiResult.data, null, 2)}
+                </pre>
+              </div>
             </div>
           )}
         </div>
       )}
 
-      {/* === New: Unified Dashboard View === */}
+      {/* === 4. 统计看板 === */}
+      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
+        <div className="bg-white px-5 py-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
+          <div>
+            <p className="text-xs text-slate-400 font-bold uppercase mb-1">总释放名额</p>
+            <p className="text-2xl font-bold text-slate-800">{totalCapacity}</p>
+          </div>
+          <div className="p-3 bg-blue-50 text-blue-600 rounded-full"><Users size={20} /></div>
+        </div>
+        <div className="bg-white px-5 py-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
+          <div>
+            <p className="text-xs text-slate-400 font-bold uppercase mb-1">有效时间点 (Safe)</p>
+            <p className="text-2xl font-bold text-green-600">{validSlots}</p>
+          </div>
+          <div className="p-3 bg-green-50 text-green-600 rounded-full"><CheckCircle size={20} /></div>
+        </div>
+        <div className="bg-white px-5 py-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
+          <div>
+            <p className="text-xs text-slate-400 font-bold uppercase mb-1">高风险 (Rate &gt; 100)</p>
+            <p className="text-2xl font-bold text-red-600">{riskSlots}</p>
+          </div>
+          <div className="p-3 bg-red-50 text-red-600 rounded-full"><AlertTriangle size={20} /></div>
+        </div>
+      </div>
+
+      {/* === 5. Daily Slot Dashboard (主视图) === */}
       <DailySlotDashboard 
         date={searchDate}
         capacityData={slots}
         grabbedTasks={grabbedTasks}
         loading={loading}
+        onTaskClick={handleTaskClick} // 绑定点击事件,弹出操作框
+      />
+
+      {/* === 修改:使用新的气泡组件 === */}
+      <DashboardTaskPopup 
+          isOpen={isTaskModalOpen}
+          onClose={() => setIsTaskModalOpen(false)}
+          task={selectedTask}
+          anchorEl={anchorEl} // 传入锚点
+          onSuccess={handleTaskSuccess}
       />
 
     </div>

+ 192 - 0
src/app/admin/workers/page.tsx

@@ -0,0 +1,192 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import api from '@/lib/api';
+import { 
+  Activity, RefreshCw, Server, AlertTriangle, 
+  Clock, MapPin, Search 
+} from 'lucide-react';
+import LocalTime from '@/components/common/LocalTime';
+// 1. 引入 TimeAgo 组件
+import TimeAgo from '@/components/common/TimeAgo';
+
+interface WorkerStatus {
+  routing_key: string;
+  country: string;
+  city: string;
+  visa_type: string;
+  snapshot_source: string;
+  last_refresh_at: string;
+  last_success_at: string;
+  last_error: string | null;
+  updated_at: string;
+}
+
+export default function WorkerStatusPage() {
+  const [workers, setWorkers] = useState<WorkerStatus[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [keyword, setKeyword] = useState('');
+  const [autoRefresh, setAutoRefresh] = useState(true);
+
+  useEffect(() => {
+    fetchData();
+    let interval: NodeJS.Timeout;
+    if (autoRefresh) {
+      interval = setInterval(fetchData, 30000);
+    }
+    return () => clearInterval(interval);
+  }, [autoRefresh]);
+
+  const fetchData = async () => {
+    if (workers.length === 0) setLoading(true);
+    try {
+      const res = await api.get('/api/slot_refresh/status');
+      const data = res.data.data || [];
+      data.sort((a: WorkerStatus, b: WorkerStatus) => 
+        new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
+      );
+      setWorkers(data);
+    } catch (e) {
+      console.error("Fetch worker status failed", e);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const filteredWorkers = workers.filter(w => 
+    w.routing_key.toLowerCase().includes(keyword.toLowerCase()) ||
+    w.country.toLowerCase().includes(keyword.toLowerCase()) ||
+    w.city.toLowerCase().includes(keyword.toLowerCase())
+  );
+
+  const getHealthStatus = (worker: WorkerStatus) => {
+    const now = new Date().getTime();
+    const lastUpdate = new Date(worker.updated_at).getTime();
+    const diffMinutes = (now - lastUpdate) / 1000 / 60;
+
+    if (worker.last_error) return { label: '报错', color: 'bg-red-50 text-red-700 border-red-200', icon: AlertTriangle };
+    if (diffMinutes > 10) return { label: '离线/卡死', color: 'bg-gray-100 text-gray-500 border-gray-200', icon: Clock };
+    if (diffMinutes > 5) return { label: '延迟', color: 'bg-yellow-50 text-yellow-700 border-yellow-200', icon: Clock };
+    return { label: '运行中', color: 'bg-green-50 text-green-700 border-green-200', icon: Activity };
+  };
+
+  return (
+    <div className="p-4 md:p-6">
+      
+      {/* Header */}
+      <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
+        <div>
+          <h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
+            <Server className="text-blue-600" /> Worker 监控面板
+          </h1>
+          <p className="text-sm text-slate-500 mt-1">
+            监控爬虫节点的刷新状态。
+            <span className={`ml-2 text-xs px-2 py-0.5 rounded ${autoRefresh ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-400'}`}>
+              {autoRefresh ? '自动刷新 ON' : '自动刷新 OFF'}
+            </span>
+          </p>
+        </div>
+
+        <div className="flex flex-wrap gap-3 w-full md:w-auto">
+          <div className="relative flex-1 md:flex-none">
+            <input 
+              type="text" 
+              placeholder="搜索 Route / Country..." 
+              className="w-full pl-9 pr-4 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition"
+              value={keyword}
+              onChange={e => setKeyword(e.target.value)}
+            />
+            <Search size={16} className="absolute left-3 top-2.5 text-gray-400" />
+          </div>
+          
+          <button 
+            onClick={() => setAutoRefresh(!autoRefresh)}
+            className={`px-3 py-2 border rounded-lg text-sm font-medium transition whitespace-nowrap
+              ${autoRefresh ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-white text-slate-600 border-slate-200'}`}
+          >
+            {autoRefresh ? '暂停' : '开启'}
+          </button>
+
+          <button 
+            onClick={() => fetchData()} 
+            className="p-2 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 text-slate-600 transition"
+          >
+            <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
+          </button>
+        </div>
+      </div>
+
+      {/* Grid List */}
+      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
+        {filteredWorkers.map((worker) => {
+          const status = getHealthStatus(worker);
+          const StatusIcon = status.icon;
+
+          return (
+            <div key={worker.routing_key} className={`bg-white border rounded-xl p-5 shadow-sm transition hover:shadow-md ${status.label === '报错' ? 'border-red-200' : 'border-slate-200'}`}>
+              
+              <div className="flex justify-between items-start mb-3">
+                <div className="flex flex-col">
+                  <div className="flex items-center gap-1.5 text-sm font-bold text-slate-800">
+                    <MapPin size={14} className="text-blue-500"/>
+                    {worker.country} - {worker.city}
+                  </div>
+                  <span className="text-xs text-slate-500 mt-0.5">{worker.visa_type}</span>
+                </div>
+                
+                <span className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold border ${status.color}`}>
+                  <StatusIcon size={12} /> {status.label}
+                </span>
+              </div>
+
+              <div className="bg-slate-50 px-2 py-1 rounded text-[10px] font-mono text-slate-500 mb-4 break-all">
+                {worker.routing_key}
+              </div>
+
+              <div className="space-y-2 text-xs text-slate-600">
+                <div className="flex justify-between">
+                  <span className="text-slate-400">上次尝试:</span>
+                  <LocalTime date={worker.last_refresh_at} />
+                </div>
+
+                {/* === 修改点:增加“距离上次成功”的时间差 === */}
+                <div className="flex justify-between items-center bg-green-50/50 p-1.5 -mx-1.5 rounded">
+                  <span className="text-slate-500 font-medium">上次成功:</span>
+                  <div className="text-right">
+                    <div className="font-bold text-green-700">
+                       <TimeAgo date={worker.last_success_at} />
+                    </div>
+                    <div className="text-[10px] text-slate-400 scale-90 origin-right">
+                      <LocalTime date={worker.last_success_at} />
+                    </div>
+                  </div>
+                </div>
+
+                <div className="flex justify-between items-center pt-2 border-t border-slate-100 mt-2">
+                   <span className="text-slate-400">Worker 心跳:</span>
+                   <div className="flex items-center gap-1.5">
+                     <LocalTime date={worker.updated_at} className="text-slate-800"/>
+                     <span className="text-[10px] text-slate-400">(<TimeAgo date={worker.updated_at} />)</span>
+                   </div>
+                </div>
+              </div>
+
+              {worker.last_error && (
+                <div className="mt-3 p-2 bg-red-50 border border-red-100 rounded text-[10px] text-red-600 break-all leading-relaxed">
+                  <span className="font-bold block mb-0.5">Error Log:</span>
+                  {worker.last_error}
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+
+      {!loading && filteredWorkers.length === 0 && (
+        <div className="text-center py-20 text-slate-400 bg-white rounded-xl border border-dashed">
+          暂无 Worker 数据
+        </div>
+      )}
+    </div>
+  );
+}

+ 1 - 0
src/components/admin/AdminSidebar.tsx

@@ -36,6 +36,7 @@ export default function AdminSidebar() {
     { name: 'TROOV Slot监控', href: '/admin/slots', icon: CalendarClock },
     { name: '卡片管理', href: '/admin/cards', icon: LayoutGrid },
     { name: '远程服务器', href: '/admin/remote-server', icon: Server },
+    { name: 'Worker 状态', href: '/admin/workers', icon: Server },
   ];
 
   useEffect(() => {

+ 126 - 135
src/components/admin/remote-server/ConfigManager.tsx

@@ -1,8 +1,11 @@
 'use client';
 
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
 import api from '@/lib/api';
-import { Settings, Save, RefreshCw, AlertCircle, FileText } from 'lucide-react';
+import { 
+  Settings, Save, RefreshCw, AlertCircle, 
+  FileText, CheckCircle2, Maximize2, Minimize2 
+} from 'lucide-react';
 
 interface ServerConfig {
   host: string;
@@ -23,18 +26,40 @@ export default function ConfigManager({
   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 [isValidJson, setIsValidJson] = useState(true);
+  const [jsonErrorMsg, setJsonErrorMsg] = useState('');
+
+  // 新增:全屏控制状态
+  const [isFullScreen, setIsFullScreen] = useState(false);
+
+  // 校验 JSON
+  useEffect(() => {
+    if (!configJson) {
+      setIsValidJson(true);
+      setJsonErrorMsg('');
+      return;
+    }
+    try {
+      JSON.parse(configJson);
+      setIsValidJson(true);
+      setJsonErrorMsg('');
+    } catch (e: any) {
+      setIsValidJson(false);
+      const msg = e.message.split('\n')[0];
+      setJsonErrorMsg(msg);
+    }
+  }, [configJson]);
 
   const fetchConfig = async () => {
     if (!configFile) {
       setError('请先输入配置文件路径');
       return;
     }
-
     setLoading(true);
     setError(null);
     setSuccess(null);
@@ -58,90 +83,45 @@ export default function ConfigManager({
     }
   };
 
-  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;
     }
-
+    if (!isValidJson) {
+      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 });
+             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));
           } 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();
+      setSuccess('配置文件已全部更新');
+      setTimeout(fetchConfig, 500); 
     } catch (err: any) {
       setError(err.response?.data?.message || err.message || '更新配置文件失败');
     } finally {
@@ -150,119 +130,130 @@ export default function ConfigManager({
   };
 
   return (
-    <div className="space-y-4 sm:space-y-6">
-      <div className="flex items-center justify-between">
+    <div className="space-y-4 h-full flex flex-col">
+      <div className="flex items-center justify-between flex-shrink-0">
         <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 text-sm">
-          <AlertCircle size={18} />
-          {error}
+        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-md flex items-center gap-2 text-sm flex-shrink-0">
+          <AlertCircle size={16} />
+          <span className="truncate">{error}</span>
         </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 text-sm">
-          <Settings size={18} />
+        <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-2 rounded-md flex items-center gap-2 text-sm flex-shrink-0">
+          <Settings size={16} />
           {success}
         </div>
       )}
 
-      {/* 读取配置 */}
-      <div className="bg-white rounded-lg border border-slate-200 p-4 sm: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 flex-col sm:flex-row gap-2">
+      {/* 读取配置 */}
+      <div className="bg-white rounded-lg border border-slate-200 p-3 sm:p-4 flex-shrink-0">
+        <div className="flex flex-col sm:flex-row gap-2 items-center">
+          <div className="flex items-center gap-2 w-full sm:w-auto text-slate-600">
+             <FileText size={18} />
+             <span className="text-sm font-medium whitespace-nowrap">路径:</span>
+          </div>
           <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 text-sm"
+            placeholder="如: config/troov_config.json"
+            className="flex-1 w-full px-3 py-1.5 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm font-mono"
           />
           <button
             onClick={fetchConfig}
             disabled={loading}
-            className="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
+            className="w-full sm:w-auto px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm font-medium"
           >
-            <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
+            <RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
             读取
           </button>
         </div>
       </div>
 
-      {/* 显示配置内容 */}
+      {/* 核心编辑区域 */}
       {configData && (
-        <>
-          <div className="bg-slate-900 rounded-lg border border-slate-200 p-3 sm:p-4">
-            <div className="flex items-center justify-between mb-2">
-              <h4 className="text-sm font-semibold text-white">配置文件内容 (JSON)</h4>
+        <div 
+          className={`
+            flex flex-col bg-slate-900 border border-slate-700 shadow-inner overflow-hidden transition-all duration-300
+            ${isFullScreen 
+              ? 'fixed inset-0 z-50 h-screen w-screen rounded-none' // 全屏样式
+              : 'rounded-lg min-h-[500px] h-[60vh]' // 默认样式: 增加最小高度
+            }
+          `}
+        >
+          {/* Editor Header */}
+          <div className="px-4 py-2 bg-slate-950 border-b border-slate-800 flex justify-between items-center flex-shrink-0">
+            <span className="text-xs font-mono text-slate-400 truncate max-w-[200px]">
+              {configFile}
+            </span>
+            <div className="flex items-center gap-3">
+              {/* 状态指示 */}
+              {isValidJson ? (
+                <span className="text-[10px] text-green-500 flex items-center gap-1 bg-green-900/30 px-2 py-0.5 rounded">
+                  <CheckCircle2 size={10} /> 有效
+                </span>
+              ) : (
+                <span className="text-[10px] text-red-400 flex items-center gap-1 bg-red-900/30 px-2 py-0.5 rounded" title={jsonErrorMsg}>
+                  <AlertCircle size={10} /> 错误
+                </span>
+              )}
+              
+              {/* 全屏切换按钮 */}
+              <button 
+                onClick={() => setIsFullScreen(!isFullScreen)}
+                className="text-slate-400 hover:text-white transition-colors p-1"
+                title={isFullScreen ? "退出全屏" : "全屏编辑"}
+              >
+                {isFullScreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
+              </button>
             </div>
+          </div>
+
+          {/* Editor Body */}
+          <div className="flex-1 relative">
             <textarea
               value={configJson}
               onChange={(e) => setConfigJson(e.target.value)}
-              className="w-full h-64 sm:h-96 px-3 py-2 bg-slate-800 text-green-400 font-mono text-xs sm:text-sm rounded border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
+              className={`
+                w-full h-full p-4 bg-slate-900 font-mono text-xs leading-relaxed resize-none focus:outline-none
+                ${isValidJson ? 'text-green-400' : 'text-yellow-200'}
+                custom-scrollbar
+              `}
               spellCheck={false}
+              placeholder="// 在此编辑 JSON 配置..."
             />
-            <div className="mt-3 flex justify-end">
-              <button
-                onClick={updateConfigByJson}
-                disabled={loading}
-                className="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
-              >
-                <Save size={18} />
-                保存整个配置
-              </button>
-            </div>
           </div>
 
-          {/* 更新单个键值 */}
-          <div className="bg-white rounded-lg border border-slate-200 p-4 sm: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-xs sm: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 text-sm"
-                />
-              </div>
-              <div>
-                <label className="block text-xs sm: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 text-sm"
-                />
-                <p className="mt-1 text-[10px] sm:text-xs text-slate-500">
-                  支持数字、字符串、布尔值、JSON对象或数组
-                </p>
-              </div>
+          {/* Editor Footer */}
+          <div className="p-3 bg-slate-800 border-t border-slate-700 flex justify-end flex-shrink-0 gap-3">
+            {isFullScreen && (
               <button
-                onClick={updateConfigByKey}
-                disabled={loading}
-                className="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
+                onClick={() => setIsFullScreen(false)}
+                className="px-4 py-2 text-slate-400 hover:text-white text-sm transition"
               >
-                <Save size={18} />
-                更新配置项
+                取消/返回
               </button>
-            </div>
+            )}
+            <button
+              onClick={updateConfigByJson}
+              disabled={loading || !isValidJson}
+              className={`
+                px-6 py-2 rounded-md flex items-center gap-2 text-sm font-bold transition-all
+                ${isValidJson 
+                  ? 'bg-blue-600 text-white hover:bg-blue-500 shadow-lg shadow-blue-900/50' 
+                  : 'bg-slate-700 text-slate-500 cursor-not-allowed'}
+              `}
+            >
+              {loading ? <RefreshCw size={16} className="animate-spin"/> : <Save size={16} />}
+              保存全部更改
+            </button>
           </div>
-        </>
+        </div>
       )}
     </div>
   );
-}
+}

+ 137 - 205
src/components/admin/remote-server/RemoteServerControl.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useEffect } from 'react';
 import api from '@/lib/api';
-import { Server, Play, Square, RotateCw, FileText, Settings, Search, RefreshCw } from 'lucide-react';
+import { Server, Play, RotateCw, FileText, Settings, Search, RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react';
 import DockerControl from './DockerControl';
 import LogViewer from './LogViewer';
 import ConfigManager from './ConfigManager';
@@ -39,7 +39,6 @@ export default function RemoteServerControl() {
   const [connecting, setConnecting] = useState(false);
   const [connectionError, setConnectionError] = useState<string | null>(null);
 
-  // 获取预配置服务器列表
   useEffect(() => {
     const fetchServers = async () => {
       try {
@@ -47,9 +46,7 @@ export default function RemoteServerControl() {
         if (response.data.code === 0) {
           const servers = response.data.data.servers || [];
           setPreConfiguredServers(servers);
-          if (servers.length > 0) {
-            setSelectedServerId(servers[0].id);
-          }
+          if (servers.length > 0) setSelectedServerId(servers[0].id);
         }
       } catch (err) {
         console.error('获取服务器列表失败:', err);
@@ -61,7 +58,6 @@ export default function RemoteServerControl() {
   const handleConnect = async () => {
     setConnecting(true);
     setConnectionError(null);
-    
     try {
       let response;
       if (usePreConfigured) {
@@ -70,12 +66,8 @@ export default function RemoteServerControl() {
           setConnecting(false);
           return;
         }
-        // 使用预配置接口测试连接
-        response = await api.post('/api/remote/server/docker/status', {
-          server_id: selectedServerId
-        });
+        response = await api.post('/api/remote/server/docker/status', { server_id: selectedServerId });
       } else {
-        // 使用手动配置接口测试连接
         if (!serverConfig.host || !serverConfig.username) {
           setConnectionError('请填写服务器地址和用户名');
           setConnecting(false);
@@ -92,44 +84,14 @@ export default function RemoteServerControl() {
         setIsConnected(false);
       }
     } catch (err: any) {
-      // ... 错误处理逻辑保持不变 ...      console.error('连接错误详情:', err);
-      console.error('错误响应:', err.response);
-      console.error('错误请求:', err.request);
-      
-      let errorMsg = '连接失败,请检查服务器配置';
-      
+      console.error('连接错误详情:', err);
+      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 || '连接失败,请检查服务器配置';
+        if (status === 404) errorMsg = 'API 路由未找到 (404)';
+        else if (status === 401) errorMsg = '未授权 (401)';
+        else if (err.response.data?.message) errorMsg = err.response.data.message;
       }
-      
       setConnectionError(errorMsg);
       setIsConnected(false);
     } finally {
@@ -143,233 +105,203 @@ export default function RemoteServerControl() {
   };
 
   return (
-    <div className="space-y-4 sm:space-y-6">
-      {/* 服务器连接配置 */}
-      <div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4 sm:p-6">
-        <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
-          <div className="flex items-center gap-2">
-            <Server className="text-blue-600" size={24} />
-            <h2 className="text-lg sm:text-xl font-semibold text-slate-800">服务器连接配置</h2>
+    <div className="space-y-4 md:space-y-6 p-4 md:p-0">
+      
+      {/* === 卡片 1: 服务器连接配置 === */}
+      <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
+        
+        {/* 卡片头部 */}
+        <div className="p-4 md:p-6 border-b border-slate-100 flex flex-col md:flex-row md:items-center justify-between gap-4">
+          <div className="flex items-center gap-3">
+            <div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
+              <Server size={20} />
+            </div>
+            <h2 className="text-lg font-bold text-slate-800">服务器连接</h2>
           </div>
-          <div className="flex bg-slate-100 p-1 rounded-md self-start sm:self-auto">
+
+          {/* 切换开关:移动端全宽 */}
+          <div className="flex bg-slate-100 p-1 rounded-lg w-full md:w-auto">
             <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'
+              onClick={() => !isConnected && setUsePreConfigured(true)}
+              className={`flex-1 md:flex-none px-4 py-2 text-xs font-bold rounded-md transition-all ${
+                usePreConfigured ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'
               }`}
               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'
+              onClick={() => !isConnected && setUsePreConfigured(false)}
+              className={`flex-1 md:flex-none px-4 py-2 text-xs font-bold rounded-md transition-all ${
+                !usePreConfigured ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'
               }`}
               disabled={isConnected}
             >
-              手动直连
+              手动输入
             </button>
           </div>
         </div>
         
-        {usePreConfigured ? (
-          <div className="space-y-4">
+        {/* 卡片内容 */}
+        <div className="p-4 md:p-6 space-y-5">
+          {usePreConfigured ? (
             <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 text-sm"
-                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 text-xs sm:text-sm">服务器地址 *</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 text-sm"
-                disabled={isConnected}
-              />
-            </div>
-            
-            <div>
-              <label className="block text-sm font-medium text-slate-700 mb-1 text-xs sm:text-sm">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 text-sm"
-                disabled={isConnected}
-              />
-            </div>
-            
-            <div>
-              <label className="block text-sm font-medium text-slate-700 mb-1 text-xs sm:text-sm">用户名 *</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 text-sm"
-                disabled={isConnected}
-              />
-            </div>
-            
-            <div>
-              <label className="block text-sm font-medium text-slate-700 mb-1 text-xs sm:text-sm">密码</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 text-sm"
-                disabled={isConnected}
-              />
-            </div>
-            
-            <div>
-              <label className="block text-sm font-medium text-slate-700 mb-1 text-xs sm:text-sm">私钥文件路径</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 text-sm"
-                disabled={isConnected}
-              />
+              <label className="block text-xs font-bold text-slate-500 uppercase mb-2">选择目标服务器</label>
+              <div className="relative">
+                <select
+                  value={selectedServerId}
+                  onChange={(e) => setSelectedServerId(e.target.value)}
+                  className="w-full pl-4 pr-10 py-3 border border-slate-300 rounded-xl appearance-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm bg-white"
+                  disabled={isConnected}
+                >
+                  <option value="">-- 请选择服务器 --</option>
+                  {preConfiguredServers.map((server) => (
+                    <option key={server.id} value={server.id}>
+                      {server.name} ({server.host})
+                    </option>
+                  ))}
+                </select>
+                <div className="absolute right-3 top-3.5 text-slate-400 pointer-events-none">
+                  <Search size={16} />
+                </div>
+              </div>
             </div>
-            
-            <div>
-              <label className="block text-sm font-medium text-slate-700 mb-1 text-xs sm:text-sm">项目路径</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 text-sm"
-                disabled={isConnected}
-              />
+          ) : (
+            // 手动输入模式:移动端单列,桌面端双列
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div className="space-y-1">
+                <label className="text-xs font-bold text-slate-500">IP 地址</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.5 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                  disabled={isConnected}
+                />
+              </div>
+              <div className="space-y-1">
+                <label className="text-xs font-bold text-slate-500">端口</label>
+                <input
+                  type="number"
+                  value={serverConfig.port}
+                  onChange={(e) => setServerConfig({ ...serverConfig, port: parseInt(e.target.value) || 22 })}
+                  className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                  disabled={isConnected}
+                />
+              </div>
+              {/* 其他字段同理... */}
+              <div className="space-y-1">
+                <label className="text-xs font-bold text-slate-500">用户名</label>
+                <input
+                  type="text"
+                  value={serverConfig.username}
+                  onChange={(e) => setServerConfig({ ...serverConfig, username: e.target.value })}
+                  placeholder="root"
+                  className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                  disabled={isConnected}
+                />
+              </div>
+              <div className="space-y-1">
+                <label className="text-xs font-bold text-slate-500">密码</label>
+                <input
+                  type="password"
+                  value={serverConfig.password || ''}
+                  onChange={(e) => setServerConfig({ ...serverConfig, password: e.target.value })}
+                  className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                  disabled={isConnected}
+                />
+              </div>
             </div>
-          </div>
-        )}
-        
-        <div className="mt-4 space-y-2">
+          )}
+          
+          {/* 状态反馈区 */}
           {connectionError && (
-            <div className="bg-red-50 border border-red-200 text-red-700 px-3 sm:px-4 py-2 rounded-md text-xs sm:text-sm overflow-x-auto whitespace-pre-wrap">
-              {connectionError}
+            <div className="bg-red-50 border border-red-100 text-red-700 px-4 py-3 rounded-lg text-sm flex items-start gap-2">
+              <AlertCircle size={18} className="shrink-0 mt-0.5" />
+              <span className="break-all">{connectionError}</span>
             </div>
           )}
           {isConnected && (
-            <div className="bg-green-50 border border-green-200 text-green-700 px-3 sm:px-4 py-2 rounded-md text-xs sm:text-sm flex items-center gap-2">
-              <Server size={16} />
-              已连接到服务器
+            <div className="bg-green-50 border border-green-100 text-green-700 px-4 py-3 rounded-lg text-sm flex items-center gap-2 font-medium">
+              <CheckCircle2 size={18} />
+              已成功连接到服务器
             </div>
           )}
-          <div className="flex flex-wrap gap-2">
+
+          {/* 操作按钮:移动端垂直排列全宽,桌面端水平 */}
+          <div className="flex flex-col sm:flex-row gap-3 pt-2">
             {!isConnected ? (
               <>
                 <button
                   onClick={handleConnect}
                   disabled={connecting}
-                  className="px-4 py-2 bg-blue-600 text-white text-sm sm:text-base rounded-md hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+                  className="w-full sm:w-auto px-6 py-3 bg-blue-600 text-white text-sm font-bold rounded-xl hover:bg-blue-700 transition-all flex justify-center items-center gap-2 disabled:opacity-70 active:scale-95 shadow-md shadow-blue-100"
                 >
-                  <Server size={18} className={connecting ? 'animate-pulse' : ''} />
-                  {connecting ? '连接中...' : '连接服务器'}
+                  {connecting ? <RefreshCw size={18} className="animate-spin" /> : <Server size={18} />}
+                  {connecting ? '正在连接...' : '立即连接'}
                 </button>
                 <button
                   onClick={async () => {
                     try {
                       const res = await api.get('/api/ping');
-                      alert(`API 测试成功: ${JSON.stringify(res.data)}`);
+                      alert(`API OK: ${JSON.stringify(res.data)}`);
                     } catch (err: any) {
-                      alert(`API 测试失败: ${err.response?.status || '无响应'} - ${err.response?.data?.message || err.message}`);
+                      alert(`API Error: ${err.message}`);
                     }
                   }}
-                  className="px-4 py-2 bg-gray-600 text-white text-sm sm:text-base rounded-md hover:bg-gray-700 transition-colors flex items-center gap-2"
-                  title="测试后端 API 连接"
+                  className="w-full sm:w-auto px-6 py-3 bg-slate-100 text-slate-600 text-sm font-bold rounded-xl hover:bg-slate-200 transition-all flex justify-center items-center gap-2 active:scale-95"
                 >
-                  <RefreshCw size={18} />
-                  测试 API
+                  <RefreshCw size={18} /> 测试 API
                 </button>
               </>
             ) : (
               <button
                 onClick={handleDisconnect}
-                className="px-4 py-2 bg-red-600 text-white text-sm sm:text-base rounded-md hover:bg-red-700 transition-colors flex items-center gap-2"
+                className="w-full sm:w-auto px-6 py-3 bg-red-50 text-red-600 border border-red-100 text-sm font-bold rounded-xl hover:bg-red-100 transition-all flex justify-center items-center gap-2 active:scale-95"
               >
-                <Server size={18} />
-                断开连接
+                <Server size={18} /> 断开连接
               </button>
             )}
           </div>
         </div>
       </div>
 
-      {/* 功能标签页 */}
+      {/* === 卡片 2: 功能控制区 (连接后显示) === */}
       {isConnected && (
-        <div className="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
-          <div className="border-b border-slate-200 overflow-x-auto">
-            <nav className="flex -mb-px min-w-max">
+        <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden animate-in fade-in slide-in-from-bottom-4">
+          
+          {/* Tab 导航:移动端支持横向滚动 */}
+          <div className="border-b border-slate-100 overflow-x-auto scrollbar-hide">
+            <nav className="flex min-w-max px-2">
               <button
                 onClick={() => setActiveTab('docker')}
-                className={`px-4 sm: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'
+                className={`px-4 py-4 text-sm font-bold 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-800'
                 }`}
               >
-                <Play size={18} />
-                Docker控制
+                <Play size={18} /> Docker 控制
               </button>
               <button
                 onClick={() => setActiveTab('logs')}
-                className={`px-4 sm: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'
+                className={`px-4 py-4 text-sm font-bold 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-800'
                 }`}
               >
-                <FileText size={18} />
-                日志查看
+                <FileText size={18} /> 日志查看
               </button>
               <button
                 onClick={() => setActiveTab('config')}
-                className={`px-4 sm: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'
+                className={`px-4 py-4 text-sm font-bold 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-800'
                 }`}
               >
-                <Settings size={18} />
-                配置文件
+                <Settings size={18} /> 配置文件
               </button>
             </nav>
           </div>
           
-          <div className="p-4 sm:p-6">
+          <div className="p-4 md:p-6">
             {activeTab === 'docker' && (
               <DockerControl 
                 serverConfig={serverConfig} 
@@ -393,4 +325,4 @@ export default function RemoteServerControl() {
       )}
     </div>
   );
-}
+}

+ 40 - 80
src/components/admin/slots/DailySlotDashboard.tsx

@@ -7,106 +7,74 @@ import {
 } from 'lucide-react';
 import LocalTime from '@/components/common/LocalTime';
 
-// 定义 props 类型
 interface SlotCapacity {
-  time: string; // "09:00"
+  time: string;
   rate: string | number;
   capacity: number;
 }
 
-interface GrabbedTask {
+// 扩展接口以支持完整 Task 结构
+export interface GrabbedTask {
   id: number;
   order_id: string;
-  grabbed_history?: {
-    book_date?: string; // ISO String
-  };
-  user_inputs?: {
-    first_name?: string;
-    last_name?: string;
-    email?: string;
-  };
+  grabbed_history?: any;
+  user_inputs?: any;
+  [key: string]: any; // 允许其他字段
 }
 
 interface DailySlotDashboardProps {
-  date: string; // 当前选中的日期 YYYY-MM-DD
+  date: string;
   capacityData: SlotCapacity[];
   grabbedTasks: GrabbedTask[];
   loading: boolean;
+  onTaskClick: (task: GrabbedTask, target: HTMLElement) => void;
 }
 
-export default function DailySlotDashboard({ date, capacityData, grabbedTasks, loading }: DailySlotDashboardProps) {
-  const [expandedTime, setExpandedTime] = useState<string | null>(null);
-
-  // === 核心逻辑:数据合并 ===
+export default function DailySlotDashboard({ date, capacityData, grabbedTasks, loading, onTaskClick }: DailySlotDashboardProps) {
+  
   const mergedSlots = useMemo(() => {
-    // 1. 创建基础的时间轴 Map
-    const slotMap = new Map<string, { 
-      capacityInfo: SlotCapacity | null; 
-      tasks: GrabbedTask[] 
-    }>();
-
-    // 2. 填充容量数据
-    capacityData.forEach(item => {
-      slotMap.set(item.time, { capacityInfo: item, tasks: [] });
-    });
+    const slotMap = new Map<string, { capacityInfo: SlotCapacity | null; tasks: GrabbedTask[] }>();
+
+    // 1. 容量
+    capacityData.forEach(item => slotMap.set(item.time, { capacityInfo: item, tasks: [] }));
 
-    // 3. 填充已抢任务数据
+    // 2. 任务
     grabbedTasks.forEach(task => {
-      const bookDateStr = task.grabbed_history?.book_date;
+      // 兼容对象或数组形式的 history
+      let bookDateStr = null;
+      if (task.grabbed_history && !Array.isArray(task.grabbed_history)) {
+          bookDateStr = task.grabbed_history.book_date;
+      }
+      
       if (!bookDateStr) return;
 
-      // 解析时间,假设格式为 ISO,提取 HH:mm
-      // 注意:这里需要确保时区处理正确,或者后端返回的就是当地时间字符串
       const bookDate = new Date(bookDateStr);
-      // 简单比对日期字符串是否匹配当前选中的 date
-      const taskDateStr = bookDate.toLocaleDateString('en-CA'); // YYYY-MM-DD
+      const taskDateStr = bookDate.toLocaleDateString('en-CA');
       
       if (taskDateStr === date) {
          const timeKey = bookDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
-         
-         if (!slotMap.has(timeKey)) {
-           // 如果该时间点没有容量数据(可能是隐藏Slot),也初始化它
-           slotMap.set(timeKey, { capacityInfo: null, tasks: [] });
-         }
+         if (!slotMap.has(timeKey)) slotMap.set(timeKey, { capacityInfo: null, tasks: [] });
          slotMap.get(timeKey)!.tasks.push(task);
       }
     });
 
-    // 4. 转数组并排序
     return Array.from(slotMap.entries())
       .map(([time, data]) => ({ time, ...data }))
       .sort((a, b) => a.time.localeCompare(b.time));
 
   }, [date, capacityData, grabbedTasks]);
 
-  // 渲染加载状态
-  if (loading) {
-    return (
-      <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 animate-pulse">
-        {[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
-          <div key={i} className="h-40 bg-slate-200 rounded-xl"></div>
-        ))}
-      </div>
-    );
-  }
-
-  if (mergedSlots.length === 0) {
-    return (
-      <div className="py-20 text-center border-2 border-dashed border-slate-200 rounded-xl bg-slate-50 text-slate-400">
-        该日期 ({date}) 暂无 Slot 容量数据或抓取记录
-      </div>
-    );
-  }
+  if (loading) return <div className="p-12 text-center text-gray-400">Loading...</div>;
+  if (mergedSlots.length === 0) return <div className="p-12 text-center border-2 border-dashed rounded text-slate-400">暂无数据</div>;
 
   return (
     <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
       {mergedSlots.map(({ time, capacityInfo, tasks }) => {
         const rateVal = capacityInfo ? Number(capacityInfo.rate) : 0;
-        const isHighRisk = rateVal > 100; // 风险阈值
+        const isHighRisk = rateVal > 100;
         const hasCapacity = capacityInfo && capacityInfo.capacity > 0;
         const grabCount = tasks.length;
         
-        // 样式判定
         let borderColor = "border-slate-200";
         let bgColor = "bg-white";
         let headerColor = "bg-slate-50 text-slate-700";
@@ -122,35 +90,27 @@ export default function DailySlotDashboard({ date, capacityData, grabbedTasks, l
         }
 
         return (
-          <div key={time} className={`rounded-xl border ${borderColor} ${bgColor} overflow-hidden transition-all hover:shadow-md flex flex-col`}>
+          <div key={time} className={`rounded-xl border ${borderColor} ${bgColor} overflow-hidden flex flex-col`}>
             
-            {/* --- 卡片头部:时间 & 宏观指标 --- */}
+            {/* Header */}
             <div className={`px-4 py-3 flex justify-between items-center ${headerColor}`}>
               <div className="flex items-center gap-2">
                 <Clock size={16} />
                 <span className="text-lg font-mono font-bold">{time}</span>
               </div>
-              
-              {/* 容量/风险指示器 */}
               {capacityInfo ? (
                 <div className="flex items-center gap-3 text-xs">
                   <div className="flex flex-col items-end">
                     <span className="font-bold">{capacityInfo.capacity} Slots</span>
-                    <span className={`${isHighRisk ? 'text-red-600 font-bold' : 'opacity-70'}`}>
-                      {rateVal}% Rate
-                    </span>
+                    <span className={`${isHighRisk ? 'text-red-600 font-bold' : 'opacity-70'}`}>{rateVal}% Rate</span>
                   </div>
                   {isHighRisk && <AlertTriangle size={18} className="text-red-600 animate-pulse" />}
                 </div>
-              ) : (
-                <span className="text-xs opacity-50">Unknown Cap</span>
-              )}
+              ) : <span className="text-xs opacity-50">Unknown Cap</span>}
             </div>
 
-            {/* --- 卡片主体:抢单结果 --- */}
+            {/* Content */}
             <div className="p-4 flex-1 flex flex-col">
-              
-              {/* 抢单统计摘要 */}
               <div className="flex justify-between items-center mb-3">
                 <span className="text-xs font-bold text-slate-500 uppercase tracking-wide">Grabbed Result</span>
                 <span className={`text-xs font-bold px-2 py-0.5 rounded-full ${grabCount > 0 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-400'}`}>
@@ -158,19 +118,20 @@ export default function DailySlotDashboard({ date, capacityData, grabbedTasks, l
                 </span>
               </div>
 
-              {/* 用户列表 (限制高度,可滚动) */}
               <div className="space-y-2 max-h-[200px] overflow-y-auto pr-1 custom-scrollbar">
                 {grabCount === 0 ? (
-                  <div className="text-center py-4 text-xs text-slate-400 italic">
-                    无人抢到该时段
-                  </div>
+                  <div className="text-center py-4 text-xs text-slate-400 italic">无人抢到该时段</div>
                 ) : (
                   tasks.map(task => (
-                    <div key={task.id} className="bg-white border border-slate-100 p-2.5 rounded-lg shadow-sm">
+                    <div 
+                      key={task.id} 
+                      onClick={(e) => onTaskClick(task, e.currentTarget)} 
+                      className="bg-white border border-slate-100 p-2.5 rounded-lg shadow-sm cursor-pointer hover:border-blue-400 hover:shadow-md transition-all group"
+                    >
                       <div className="flex justify-between items-start">
-                        <div className="flex items-center gap-1.5 font-bold text-slate-700 text-sm">
+                        <div className="flex items-center gap-1.5 font-bold text-slate-700 text-sm group-hover:text-blue-600 transition-colors">
                           <User size={12} className="text-blue-500" />
-                          <span className="truncate max-w-[100px]" title={`${task.user_inputs?.first_name} ${task.user_inputs?.last_name}`}>
+                          <span className="truncate max-w-[100px]">
                             {task.user_inputs?.first_name} {task.user_inputs?.last_name}
                           </span>
                         </div>
@@ -182,11 +143,10 @@ export default function DailySlotDashboard({ date, capacityData, grabbedTasks, l
                         <span className="truncate max-w-[140px]">{task.user_inputs?.email}</span>
                       </div>
 
-                      {/* 如果是高风险时段,给具体用户打标记 */}
                       {isHighRisk && (
                         <div className="mt-1.5 pt-1.5 border-t border-slate-50 flex items-center gap-1 text-[10px] text-red-600 font-medium">
                           <AlertTriangle size={10} />
-                          <span>位于高风险区间,留意取消</span>
+                          <span>风险预警: 易取消</span>
                         </div>
                       )}
                     </div>

+ 202 - 0
src/components/admin/slots/DashboardTaskPopup.tsx

@@ -0,0 +1,202 @@
+'use client';
+
+import { useState, useEffect, useRef, useLayoutEffect } from 'react';
+import { 
+  X, CheckCircle, RotateCcw, Loader2, 
+  User, History, Mail, Clock, Hash, MessageCircle // 1. 引入 MessageCircle
+} from 'lucide-react';
+import api from '@/lib/api';
+import LocalTime from '@/components/common/LocalTime';
+
+interface TaskDetail extends Record<string, any> {
+  id: number;
+  order_id: string;
+  status?: string;
+  grabbed_history?: any;
+  user_inputs?: any;
+  created_at?: string;
+}
+
+interface DashboardTaskPopupProps {
+  isOpen: boolean;
+  onClose: () => void;
+  task: TaskDetail | null;
+  anchorEl: HTMLElement | null;
+  onSuccess: () => void;
+}
+
+export default function DashboardTaskPopup({ isOpen, onClose, task, anchorEl, onSuccess }: DashboardTaskPopupProps) {
+  const [loading, setLoading] = useState(false);
+  const [style, setStyle] = useState<React.CSSProperties>({ display: 'none' });
+  const popupRef = useRef<HTMLDivElement>(null);
+
+  // 坐标计算逻辑 (保持不变)
+  useLayoutEffect(() => {
+    if (!isOpen || !anchorEl || !popupRef.current) return;
+
+    const calculatePosition = () => {
+      const anchorRect = anchorEl.getBoundingClientRect();
+      const popupRect = popupRef.current!.getBoundingClientRect();
+      const viewportWidth = window.innerWidth;
+      const viewportHeight = window.innerHeight;
+
+      let top = anchorRect.top - popupRect.height - 12; 
+      let left = anchorRect.left + (anchorRect.width / 2) - (popupRect.width / 2);
+
+      if (top < 10) top = anchorRect.bottom + 12;
+      if (top + popupRect.height > viewportHeight) top = viewportHeight - popupRect.height - 10;
+      if (left < 10) left = 10;
+      if (left + popupRect.width > viewportWidth - 10) left = viewportWidth - popupRect.width - 10;
+
+      setStyle({
+        top: `${top}px`,
+        left: `${left}px`,
+        position: 'fixed',
+        zIndex: 60,
+        opacity: 1,
+        transform: 'scale(1)',
+      });
+    };
+
+    calculatePosition();
+    window.addEventListener('resize', calculatePosition);
+    window.addEventListener('scroll', calculatePosition, true);
+
+    return () => {
+      window.removeEventListener('resize', calculatePosition);
+      window.removeEventListener('scroll', calculatePosition, true);
+    };
+  }, [isOpen, anchorEl, task]);
+
+  // 点击外部关闭逻辑 (保持不变)
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (anchorEl && anchorEl.contains(event.target as Node)) return;
+      if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
+        onClose();
+      }
+    };
+    if (isOpen) document.addEventListener('mousedown', handleClickOutside);
+    return () => document.removeEventListener('mousedown', handleClickOutside);
+  }, [isOpen, onClose, anchorEl]);
+
+  const handleAction = async (action: 'confirm' | 'retry') => {
+    if (!confirm(action === 'confirm' ? "确认标记完成?" : "确认重回队列?")) return;
+
+    setLoading(true);
+    try {
+      const endpoint = action === 'confirm' 
+        ? '/api/vas/task/manual_confirm' 
+        : '/api/vas/task/return_to_queue';
+        
+      await api.post(endpoint, null, { params: { task_id: task?.id } });
+      onSuccess(); 
+      onClose();   
+    } catch (e: any) {
+      alert("操作失败: " + e.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  if (!isOpen || !task) return null;
+
+  // 数据解析
+  const userName = task.user_inputs?.first_name 
+    ? `${task.user_inputs.first_name} ${task.user_inputs.last_name}` 
+    : 'Unknown User';
+  const email = task.user_inputs?.email || '-';
+  // 2. 获取社交账号
+  const socialAccount = task.user_inputs?.social_media_account; 
+  
+  const grabbedInfo = task.grabbed_history?.book_date 
+    ? new Date(task.grabbed_history.book_date).toLocaleString() 
+    : '暂无时间';
+
+  return (
+    <div 
+      ref={popupRef}
+      style={style}
+      className="w-72 bg-white/95 backdrop-blur-xl border border-white/50 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.15)] transition-all duration-200 ease-out origin-center flex flex-col overflow-hidden"
+    >
+      {/* Header */}
+      <div className="px-5 py-3 border-b border-slate-200/60 flex justify-between items-center bg-white/50">
+        <div className="flex items-center gap-2">
+          <span className="bg-slate-900 text-white text-[10px] font-mono px-1.5 py-0.5 rounded">#{task.id}</span>
+          <span className={`text-xs font-bold uppercase ${task.status === 'completed' ? 'text-green-600' : 'text-slate-700'}`}>
+            {task.status}
+          </span>
+        </div>
+        <button onClick={onClose} className="text-slate-400 hover:text-slate-700 transition">
+          <X size={16} />
+        </button>
+      </div>
+
+      {/* Info Body */}
+      <div className="p-5 space-y-4">
+        
+        {/* User Info Section */}
+        <div className="flex items-start gap-3">
+          <div className="p-2 bg-blue-50 text-blue-600 rounded-lg shrink-0 mt-0.5">
+            <User size={16} />
+          </div>
+          <div className="min-w-0 flex-1">
+            <p className="text-sm font-bold text-slate-800 truncate" title={userName}>{userName}</p>
+            
+            {/* Email */}
+            <div className="flex items-center gap-1 text-xs text-slate-500 mt-1">
+              <Mail size={10} className="shrink-0" /> <span className="truncate">{email}</span>
+            </div>
+
+            {/* === 3. 新增:社交账号展示 === */}
+            {socialAccount && (
+              <div className="flex items-center gap-1.5 text-xs font-medium text-indigo-700 bg-indigo-50 border border-indigo-100 px-2 py-1 rounded-md mt-2 w-fit max-w-full">
+                <MessageCircle size={10} className="shrink-0" />
+                <span className="truncate" title={socialAccount}>{socialAccount}</span>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* Task Info Section */}
+        <div className="bg-slate-50/80 rounded-lg p-3 border border-slate-100 text-xs space-y-2">
+          <div className="flex justify-between items-center">
+              <span className="text-slate-400 flex items-center gap-1"><Hash size={10}/> Order</span>
+              <span className="font-mono text-slate-600 truncate max-w-[120px]">{task.order_id}</span>
+          </div>
+          <div className="flex justify-between items-center">
+              <span className="text-slate-400 flex items-center gap-1"><History size={10}/> Grabbed</span>
+              <span className="font-medium text-green-700 truncate max-w-[140px]">{grabbedInfo}</span>
+          </div>
+          <div className="flex justify-between items-center">
+              <span className="text-slate-400 flex items-center gap-1"><Clock size={10}/> Created</span>
+              <span className="text-slate-600"><LocalTime date={task.created_at} /></span>
+          </div>
+        </div>
+
+      </div>
+
+      {/* Footer Actions */}
+      <div className="p-3 grid grid-cols-2 gap-3 bg-slate-50/50 border-t border-slate-100">
+        <button 
+          onClick={() => handleAction('retry')}
+          disabled={loading}
+          className="flex items-center justify-center gap-1.5 py-2 rounded-lg border border-slate-200 bg-white text-slate-600 text-xs font-bold hover:bg-slate-50 hover:text-blue-600 transition active:scale-95"
+        >
+          {loading ? <Loader2 size={14} className="animate-spin"/> : <RotateCcw size={14} />}
+          重回队列
+        </button>
+
+        <button 
+          onClick={() => handleAction('confirm')}
+          disabled={loading}
+          className="flex items-center justify-center gap-1.5 py-2 rounded-lg bg-slate-900 text-white text-xs font-bold hover:bg-slate-800 transition shadow-sm active:scale-95"
+        >
+          {loading ? <Loader2 size={14} className="animate-spin"/> : <CheckCircle size={14} />}
+          标记完成
+        </button>
+      </div>
+
+    </div>
+  );
+}

+ 79 - 0
src/components/common/TimeAgo.tsx

@@ -0,0 +1,79 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface TimeAgoProps {
+  date: string | Date | null | undefined;
+  className?: string;
+}
+
+export default function TimeAgo({ date, className = '' }: TimeAgoProps) {
+  const [timeAgo, setTimeAgo] = useState<string>('-');
+
+  useEffect(() => {
+    if (!date) return;
+
+    const calculateTimeAgo = () => {
+      let dateObj: Date;
+
+      // === 核心修复逻辑 ===
+      if (typeof date === 'string') {
+        // 1. 兼容空格格式
+        let normalizedDate = date.replace(' ', 'T');
+        // 2. 如果没有时区标识,强制追加 'Z',将其视为 UTC 时间
+        if (!normalizedDate.endsWith('Z') && !normalizedDate.includes('+')) {
+          normalizedDate += 'Z';
+        }
+        dateObj = new Date(normalizedDate);
+      } else {
+        dateObj = new Date(date);
+      }
+      
+      // 检查日期有效性
+      if (isNaN(dateObj.getTime())) {
+        setTimeAgo('-');
+        return;
+      }
+
+      const past = dateObj.getTime();
+      const now = new Date().getTime();
+      const diffInSeconds = Math.floor((now - past) / 1000);
+
+      // 处理未来时间(可能是本地时间偏差或服务器时间超前)
+      if (diffInSeconds < 0) {
+        setTimeAgo('刚刚');
+        return;
+      }
+
+      if (diffInSeconds < 60) {
+        setTimeAgo(`${diffInSeconds}秒前`);
+      } else if (diffInSeconds < 3600) {
+        setTimeAgo(`${Math.floor(diffInSeconds / 60)}分钟前`);
+      } else if (diffInSeconds < 86400) {
+        setTimeAgo(`${Math.floor(diffInSeconds / 3600)}小时前`);
+      } else if (diffInSeconds < 2592000) { // 30天
+        setTimeAgo(`${Math.floor(diffInSeconds / 86400)}天前`);
+      } else if (diffInSeconds < 31536000) { // 365天
+        setTimeAgo(`${Math.floor(diffInSeconds / 2592000)}个月前`);
+      } else {
+        setTimeAgo(`${Math.floor(diffInSeconds / 31536000)}年前`);
+      }
+    };
+
+    calculateTimeAgo();
+    
+    // 每分钟更新一次文本
+    const interval = setInterval(calculateTimeAgo, 60000);
+    return () => clearInterval(interval);
+  }, [date]);
+
+  if (!date) return <span className="text-gray-300">-</span>;
+
+  // 使用 title 属性显示完整的本地时间,方便鼠标悬停查看
+  // 注意:这里仅用于 title 属性的展示,不需要像 LocalTime 那样处理 SSR 水合问题
+  return (
+    <span className={className} title={new Date(date).toLocaleString()}>
+      {timeAgo}
+    </span>
+  );
+}