jerry 1 сар өмнө
parent
commit
500778c4d8

+ 37 - 4
src/app/admin/remote-server/page.tsx

@@ -1,15 +1,48 @@
 'use client';
 
 import { useState } from 'react';
+import { Server, Clock } from 'lucide-react';
 import RemoteServerControl from '@/components/admin/remote-server/RemoteServerControl';
+// 注意这里的路径,请确保和你的实际路径一致
+import ContainerScheduleManager from '@/components/admin/remote-server/ContainerScheduleManager';
 
 export default function RemoteServerPage() {
+  // 增加顶级模块切换状态
+  const [activeModule, setActiveModule] = useState<'remote' | 'schedule'>('remote');
+
   return (
     <div className="space-y-6">
-      <div className="flex items-center justify-between">
-        <h1 className="text-3xl font-bold text-slate-800">远程服务器控制</h1>
+      {/* 页面头部及顶级导航 */}
+      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
+        <h1 className="text-3xl font-bold text-slate-800">
+          {activeModule === 'remote' ? '远程服务器控制' : 'Docker 运行计划'}
+        </h1>
+
+        {/* 顶级模块切换器 */}
+        <div className="flex bg-slate-200/50 p-1 rounded-lg w-full sm:w-auto">
+          <button
+            onClick={() => setActiveModule('schedule')}
+            className={`flex-1 sm:flex-none flex justify-center items-center gap-2 px-5 py-2 text-sm font-bold rounded-md transition-all ${
+              activeModule === 'schedule' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
+            }`}
+          >
+            <Clock size={16} /> 运行计划
+          </button>
+          <button
+            onClick={() => setActiveModule('remote')}
+            className={`flex-1 sm:flex-none flex justify-center items-center gap-2 px-5 py-2 text-sm font-bold rounded-md transition-all ${
+              activeModule === 'remote' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
+            }`}
+          >
+            <Server size={16} /> 远程控制
+          </button>
+        </div>
       </div>
-      <RemoteServerControl />
+
+      {activeModule === 'schedule' && <ContainerScheduleManager />}
+      {/* 模块内容区:完全独立的两个组件 */}
+      {activeModule === 'remote' && <RemoteServerControl />}
+      
     </div>
   );
-}
+}

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

@@ -35,7 +35,7 @@ export default function AdminSidebar() {
     { name: 'TROOV', href: '/admin/slots', icon: CalendarClock },
     { name: 'VISAMETRIC', href: '/admin/visametric', icon: CalendarClock },
     { name: '卡片管理', href: '/admin/cards', icon: LayoutGrid },
-    { name: '远程服务器', href: '/admin/remote-server', icon: Server },
+    { name: '服务器管理', href: '/admin/remote-server', icon: Server },
     { name: 'Worker 状态', href: '/admin/workers', icon: Server },
     { name: '账号池管理', href: '/admin/accounts', icon: UserCog },
   ];

+ 381 - 0
src/components/admin/remote-server/ContainerScheduleManager.tsx

@@ -0,0 +1,381 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import api from '@/lib/api';
+import { Clock, Plus, Trash2, Save, RefreshCw, AlertCircle, Globe, Info } from 'lucide-react';
+
+// ==========================================
+// 1. 紧凑版 24 小时圆环滑块 (TimeRangeDial)
+// ==========================================
+interface TimeDialProps {
+  startTime: string;
+  endTime: string;
+  onChangeStart: (time: string) => void;
+  onChangeEnd: (time: string) => void;
+}
+
+function TimeRangeDial({ startTime, endTime, onChangeStart, onChangeEnd }: TimeDialProps) {
+  const svgRef = useRef<SVGSVGElement>(null);
+  const [dragging, setDragging] = useState<'start' | 'end' | null>(null);
+
+  const timeToAngle = (timeStr: string) => {
+    if (!timeStr) return 0;
+    const [h, m] = timeStr.split(':').map(Number);
+    return ((h + m / 60) / 24) * 360;
+  };
+
+  const angleToTime = (deg: number) => {
+    let hoursFloat = (deg / 360) * 24;
+    const snap = 0.25; // 15分钟吸附
+    hoursFloat = Math.round(hoursFloat / snap) * snap;
+    if (hoursFloat >= 24) hoursFloat -= 24;
+    const h = Math.floor(hoursFloat);
+    const m = Math.round((hoursFloat - h) * 60);
+    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
+  };
+
+  const polarToCartesian = (cx: number, cy: number, r: number, angleDeg: number) => {
+    const angleRad = (angleDeg - 90) * Math.PI / 180.0;
+    return { x: cx + r * Math.cos(angleRad), y: cy + r * Math.sin(angleRad) };
+  };
+
+  const getArcPath = (cx: number, cy: number, r: number, startAngle: number, endAngle: number) => {
+    if (startAngle === endAngle) return '';
+    const start = polarToCartesian(cx, cy, r, startAngle);
+    const end = polarToCartesian(cx, cy, r, endAngle);
+    let diff = endAngle - startAngle;
+    if (diff < 0) diff += 360;
+    const largeArcFlag = diff > 180 ? 1 : 0;
+    return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`;
+  };
+
+  // 缩小表盘尺寸以节省空间
+  const size = 130;
+  const cx = size / 2, cy = size / 2, r = 48; 
+  const startAngle = timeToAngle(startTime);
+  const endAngle = timeToAngle(endTime);
+  const startPos = polarToCartesian(cx, cy, r, startAngle);
+  const endPos = polarToCartesian(cx, cy, r, endAngle);
+
+  useEffect(() => {
+    const handleMove = (e: PointerEvent) => {
+      if (!dragging || !svgRef.current) return;
+      const rect = svgRef.current.getBoundingClientRect();
+      const centerX = rect.left + rect.width / 2;
+      const centerY = rect.top + rect.height / 2;
+      let rad = Math.atan2(e.clientY - centerY, e.clientX - centerX);
+      let deg = rad * 180 / Math.PI + 90;
+      if (deg < 0) deg += 360;
+
+      const newTime = angleToTime(deg);
+      if (dragging === 'start') onChangeStart(newTime);
+      else onChangeEnd(newTime);
+    };
+    const handleUp = () => setDragging(null);
+    if (dragging) {
+      window.addEventListener('pointermove', handleMove);
+      window.addEventListener('pointerup', handleUp);
+    }
+    return () => {
+      window.removeEventListener('pointermove', handleMove);
+      window.removeEventListener('pointerup', handleUp);
+    };
+  }, [dragging]);
+
+  const ticks = Array.from({ length: 24 }).map((_, i) => {
+    const angle = i * 15;
+    const isMajor = i % 6 === 0;
+    const p1 = polarToCartesian(cx, cy, r - (isMajor ? 6 : 3), angle);
+    const p2 = polarToCartesian(cx, cy, r, angle);
+    const labelPos = polarToCartesian(cx, cy, r - 15, angle);
+    return (
+      <g key={i}>
+        <line x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y} stroke={isMajor ? "#94a3b8" : "#e2e8f0"} strokeWidth={isMajor ? 2 : 1} />
+        {isMajor && (
+          <text x={labelPos.x} y={labelPos.y} fill="#64748b" fontSize="9" textAnchor="middle" dominantBaseline="central" className="font-medium">
+            {i}
+          </text>
+        )}
+      </g>
+    );
+  });
+
+  return (
+    <div className="flex flex-col items-center justify-center bg-slate-50/50 p-2 rounded-xl border border-slate-100">
+      <svg ref={svgRef} width={size} height={size} className="touch-none select-none">
+        <circle cx={cx} cy={cy} r={r} fill="none" stroke="#f1f5f9" strokeWidth="8" />
+        {ticks}
+        <path d={getArcPath(cx, cy, r, startAngle, endAngle)} fill="none" stroke="#3b82f6" strokeWidth="8" strokeLinecap="round" />
+        
+        {/* 中心紧凑文字 */}
+        <text x={cx} y={cy - 8} textAnchor="middle" className="text-xs font-bold fill-slate-800">{startTime.slice(0, 5)}</text>
+        <text x={cx} y={cy + 4} textAnchor="middle" className="text-[10px] font-medium fill-slate-400">至</text>
+        <text x={cx} y={cy + 16} textAnchor="middle" className="text-xs font-bold fill-slate-800">{endTime.slice(0, 5)}</text>
+
+        <g onPointerDown={(e) => { e.preventDefault(); setDragging('start'); }} className="cursor-grab active:cursor-grabbing">
+          <circle cx={startPos.x} cy={startPos.y} r="18" fill="transparent" />
+          <circle cx={startPos.x} cy={startPos.y} r="6" fill="#10b981" stroke="#fff" strokeWidth="2" className="shadow-sm" />
+        </g>
+        <g onPointerDown={(e) => { e.preventDefault(); setDragging('end'); }} className="cursor-grab active:cursor-grabbing">
+          <circle cx={endPos.x} cy={endPos.y} r="18" fill="transparent" />
+          <circle cx={endPos.x} cy={endPos.y} r="6" fill="#ef4444" stroke="#fff" strokeWidth="2" className="shadow-sm" />
+        </g>
+      </svg>
+      <div className="flex gap-3 mt-1 text-[10px] font-bold text-slate-500">
+        <span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>启动</span>
+        <span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>停止</span>
+      </div>
+    </div>
+  );
+}
+
+// ==========================================
+// 2. 主配置页面 (ContainerScheduleManager)
+// ==========================================
+interface Schedule {
+  container_name: string;
+  enabled: boolean;
+  days_mask: number;
+  start_time: string;
+  end_time: string;
+}
+
+const CONFIG_KEY = 'docker.schedules';
+const DAYS =[
+  { label: '一', bit: 1 }, { label: '二', bit: 2 }, { label: '三', bit: 4 },
+  { label: '四', bit: 8 }, { label: '五', bit: 16 }, { label: '六', bit: 32 }, { label: '日', bit: 64 }
+];
+
+export default function ContainerScheduleManager() {
+  const[schedules, setSchedules] = useState<Schedule[]>([]);
+  const [isExistRecord, setIsExistRecord] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [localTz, setLocalTz] = useState<string>('');
+
+  useEffect(() => { setLocalTz(Intl.DateTimeFormat().resolvedOptions().timeZone); },[]);
+
+  // ====== 核心时间与掩码转换逻辑 ======
+  const formatTimeStr = (h: number, m: number, s: number) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+  
+  const localToUtcTime = (timeStr: string) => {
+    if (!timeStr) return "00:00:00";
+    const [h, m, s] = timeStr.split(':').map(Number);
+    const d = new Date(); d.setHours(h, m, s || 0, 0);
+    return formatTimeStr(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds());
+  };
+
+  const utcToLocalTime = (timeStr: string) => {
+    if (!timeStr) return "00:00:00";
+    const [h, m, s] = timeStr.split(':').map(Number);
+    const d = new Date(); d.setUTCHours(h, m, s || 0, 0);
+    return formatTimeStr(d.getHours(), d.getMinutes(), d.getSeconds());
+  };
+
+  const getUtcDayOffset = (localTimeStr: string) => {
+    const [h, m, s] = (localTimeStr || "00:00:00").split(':').map(Number);
+    const d = new Date(); d.setHours(h, m, s || 0, 0);
+    let diff = d.getUTCDay() - d.getDay();
+    if (diff === 6) diff = -1; if (diff === -6) diff = 1;
+    return diff;
+  };
+
+  const getLocalDayOffset = (utcTimeStr: string) => {
+    const [h, m, s] = (utcTimeStr || "00:00:00").split(':').map(Number);
+    const d = new Date(); d.setUTCHours(h, m, s || 0, 0);
+    let diff = d.getDay() - d.getUTCDay();
+    if (diff === 6) diff = -1; if (diff === -6) diff = 1;
+    return diff;
+  };
+
+  const shiftDaysMask = (mask: number, offset: number) => {
+    if (offset === 0) return mask;
+    let newMask = 0; const bits =[1, 2, 4, 8, 16, 32, 64];
+    for (let i = 0; i < 7; i++) {
+      if ((mask & bits[i]) === bits[i]) {
+        const newIdx = (i + offset + 7) % 7;
+        newMask |= bits[newIdx];
+      }
+    }
+    return newMask;
+  };
+
+  // ====== 接口请求 ======
+  const fetchSchedules = async () => {
+    setLoading(true); setError(null);
+    try {
+      const res = await api.get(`/api/dynamic-configurations/key/${CONFIG_KEY}`);
+      if (res.data?.code === 0 && res.data?.data) {
+        setIsExistRecord(true);
+        let parsedData: any = { schedules:[] };
+        if (typeof res.data.data.config_value === 'string') {
+          try { parsedData = JSON.parse(res.data.data.config_value); } catch (e) {}
+        } else { parsedData = res.data.data.config_value; }
+
+        if (parsedData && Array.isArray(parsedData.schedules)) {
+          setSchedules(parsedData.schedules.map((sch: Schedule) => ({
+            ...sch,
+            start_time: utcToLocalTime(sch.start_time),
+            end_time: utcToLocalTime(sch.end_time),
+            days_mask: shiftDaysMask(sch.days_mask, getLocalDayOffset(sch.start_time))
+          })));
+        } else setSchedules([]);
+      }
+    } catch (err: any) {
+      if (err.response?.status === 404) { setIsExistRecord(false); setSchedules([]); }
+      else setError(err.response?.data?.message || '获取运行计划失败');
+    } finally { setLoading(false); }
+  };
+
+  useEffect(() => { fetchSchedules(); },[]);
+
+  const handleSave = async () => {
+    setSaving(true); setError(null);
+    try {
+      const utcSchedules = schedules.map((sch: Schedule) => ({
+        ...sch,
+        start_time: localToUtcTime(sch.start_time),
+        end_time: localToUtcTime(sch.end_time),
+        days_mask: shiftDaysMask(sch.days_mask, getUtcDayOffset(sch.start_time))
+      }));
+
+      const payload = { config_key: CONFIG_KEY, config_value: JSON.stringify({ schedules: utcSchedules }), description: "Docker 容器定时运行计划", type: "json" };
+      if (isExistRecord) await api.put(`/api/dynamic-configurations/key/${CONFIG_KEY}`, payload);
+      else { await api.post(`/api/dynamic-configurations`, payload); setIsExistRecord(true); }
+      alert('保存成功!服务端将以 UTC 时间按时执行。');
+    } catch (err: any) { setError(err.response?.data?.message || '保存失败'); }
+    finally { setSaving(false); }
+  };
+
+  const addSchedule = () => setSchedules([...schedules, { container_name: '', enabled: true, days_mask: 127, start_time: '08:00:00', end_time: '18:00:00' }]);
+  const removeSchedule = (index: number) => setSchedules(schedules.filter((_, i) => i !== index));
+  const updateSchedule = (index: number, field: keyof Schedule, value: any) => {
+    const newSchedules = [...schedules];
+    newSchedules[index] = { ...newSchedules[index], [field]: value };
+    setSchedules(newSchedules);
+  };
+  const handleTimeChange = (index: number, field: 'start_time' | 'end_time', value: string) => updateSchedule(index, field, value && value.length === 5 ? `${value}:00` : value);
+  const toggleDay = (index: number, bit: number) => {
+    const currentMask = schedules[index]?.days_mask || 0;
+    updateSchedule(index, 'days_mask', (currentMask & bit) === bit ? currentMask - bit : currentMask + bit);
+  };
+
+  if (loading) return <div className="p-8 flex justify-center text-slate-500"><RefreshCw className="animate-spin mr-2" />读取配置中...</div>;
+
+  return (
+    <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 space-y-4 animate-in fade-in">
+      
+      {/* 头部导航区域 */}
+      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-2">
+        <div className="flex items-center gap-3">
+          <Clock size={18} className="text-blue-600" />
+          <h2 className="text-base font-bold text-slate-800">全局运行计划设置</h2>
+          {localTz && (
+            <div className="flex items-center gap-1 px-2 py-0.5 bg-slate-100 text-slate-500 text-[11px] rounded font-medium border border-slate-200">
+              <Globe size={10} /> {localTz}
+            </div>
+          )}
+        </div>
+        <button onClick={addSchedule} className="flex items-center gap-1 px-3 py-1.5 bg-blue-50 text-blue-600 text-xs font-bold rounded hover:bg-blue-100 transition-colors">
+          <Plus size={14} /> 添加计划
+        </button>
+      </div>
+
+      {error && <div className="bg-red-50 text-red-600 p-2.5 rounded-lg text-xs flex items-center gap-2"><AlertCircle size={14} /> {error}</div>}
+
+      {schedules.length === 0 ? (
+        <div className="text-center p-8 bg-slate-50 rounded-lg border border-dashed border-slate-200 text-slate-500 text-xs">
+          当前没有设置任何容器计划。
+        </div>
+      ) : (
+        <div className="space-y-3">
+          {schedules.map((schedule, index) => (
+            <div key={index} className="bg-white border border-slate-200 rounded-lg p-3 shadow-sm hover:border-blue-300 transition-colors">
+              <div className="flex flex-col sm:flex-row gap-4">
+                
+                {/* 1. 左侧缩略表盘 */}
+                <div className="flex justify-center shrink-0">
+                  <TimeRangeDial 
+                    startTime={schedule.start_time} endTime={schedule.end_time} 
+                    onChangeStart={(val) => handleTimeChange(index, 'start_time', val)}
+                    onChangeEnd={(val) => handleTimeChange(index, 'end_time', val)}
+                  />
+                </div>
+
+                {/* 2. 右侧紧凑表单 */}
+                <div className="flex-1 flex flex-col justify-between space-y-3">
+                  
+                  {/* 首行: 容器名称支持通配符 */}
+                  <div className="space-y-1">
+                    <label className="text-[11px] font-bold text-slate-500 uppercase flex items-center gap-2">
+                      <span>容器名称</span>
+                      <span className="flex items-center gap-0.5 text-blue-500 font-normal normal-case bg-blue-50 px-1.5 py-0.5 rounded text-[10px]">
+                        <Info size={10} /> 支持 * 通配符
+                      </span>
+                    </label>
+                    <input
+                      type="text"
+                      value={schedule.container_name || ''}
+                      onChange={(e) => updateSchedule(index, 'container_name', e.target.value)}
+                      className="w-full px-2.5 py-1.5 border border-slate-300 rounded text-sm focus:ring-1 focus:ring-blue-500 outline-none"
+                      placeholder="例如: api-server-* 或 worker-node-?"
+                    />
+                  </div>
+
+                  {/* 次行: 起止时间 */}
+                  <div className="grid grid-cols-2 gap-3">
+                    <div className="space-y-1">
+                      <label className="text-[10px] font-bold text-slate-500 uppercase">启动时间 (本地)</label>
+                      <input type="time" step="60" value={schedule.start_time || '00:00:00'} onChange={(e) => handleTimeChange(index, 'start_time', e.target.value)} className="w-full px-2 py-1.5 border border-slate-300 rounded text-sm outline-none focus:ring-1 focus:ring-blue-500" />
+                    </div>
+                    <div className="space-y-1">
+                      <label className="text-[10px] font-bold text-slate-500 uppercase">停止时间 (本地)</label>
+                      <input type="time" step="60" value={schedule.end_time || '00:00:00'} onChange={(e) => handleTimeChange(index, 'end_time', e.target.value)} className="w-full px-2 py-1.5 border border-slate-300 rounded text-sm outline-none focus:ring-1 focus:ring-blue-500" />
+                    </div>
+                  </div>
+
+                  {/* 尾行: 星期掩码 + 开关 + 删除 (高度压缩到同一行) */}
+                  <div className="flex flex-wrap items-center justify-between gap-3 pt-2.5 border-t border-slate-100">
+                    <div className="flex gap-1.5">
+                      {DAYS.map((day) => {
+                        const isSelected = ((schedule.days_mask || 0) & day.bit) === day.bit;
+                        return (
+                          <button
+                            key={day.bit} onClick={() => toggleDay(index, day.bit)}
+                            className={`w-7 h-7 rounded text-[11px] font-bold transition-all border ${isSelected ? 'bg-blue-600 text-white border-blue-600' : 'bg-slate-50 text-slate-500 border-slate-200 hover:border-blue-400'}`}
+                          >
+                            {day.label}
+                          </button>
+                        );
+                      })}
+                    </div>
+                    
+                    <div className="flex items-center gap-4 ml-auto">
+                      <label className="flex items-center gap-1.5 cursor-pointer">
+                        <input type="checkbox" checked={!!schedule.enabled} onChange={(e) => updateSchedule(index, 'enabled', e.target.checked)} className="w-3.5 h-3.5 text-blue-600 rounded cursor-pointer" />
+                        <span className="text-xs font-bold text-slate-700">启用</span>
+                      </label>
+                      <button onClick={() => removeSchedule(index)} className="text-red-400 hover:text-red-600 transition-colors p-1" title="删除该计划">
+                        <Trash2 size={16} />
+                      </button>
+                    </div>
+                  </div>
+
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* 底部保存按钮 */}
+      <div className="pt-4 mt-2 border-t border-slate-100 flex justify-end">
+        <button onClick={handleSave} disabled={saving || schedules.length === 0} className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white text-sm font-bold rounded hover:bg-blue-700 transition-all disabled:opacity-50">
+          {saving ? <RefreshCw size={16} className="animate-spin" /> : <Save size={16} />}
+          {saving ? '保存中...' : '保存所有计划'}
+        </button>
+      </div>
+    </div>
+  );
+}