|
|
@@ -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>
|
|
|
+ );
|
|
|
+}
|