|
@@ -1,381 +0,0 @@
|
|
|
-'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>
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|