|
@@ -3,11 +3,13 @@
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useState, useEffect } from 'react';
|
|
|
import api from '@/lib/api';
|
|
import api from '@/lib/api';
|
|
|
import {
|
|
import {
|
|
|
- Search, Loader2, Users, Clock, Percent,
|
|
|
|
|
- AlertTriangle, CheckCircle, ShieldAlert, X, Terminal
|
|
|
|
|
|
|
+ Search, Loader2, Clock, Percent, ShieldAlert, X, Terminal, CalendarCheck
|
|
|
} from 'lucide-react';
|
|
} from 'lucide-react';
|
|
|
-import ProbabilityManager from '@/components/admin/slots/ProbabilityManager'; // 引入概率管理组件
|
|
|
|
|
|
|
+import ProbabilityManager from '@/components/admin/slots/ProbabilityManager';
|
|
|
|
|
+// 1. 引入新组件
|
|
|
|
|
+import DailySlotDashboard from '@/components/admin/slots/DailySlotDashboard';
|
|
|
|
|
|
|
|
|
|
+// 复用类型定义
|
|
|
interface SlotItem {
|
|
interface SlotItem {
|
|
|
time: string;
|
|
time: string;
|
|
|
rate: string | number;
|
|
rate: string | number;
|
|
@@ -15,36 +17,62 @@ interface SlotItem {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export default function AdminSlotsPage() {
|
|
export default function AdminSlotsPage() {
|
|
|
- // === Slot List States ===
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ // 数据源
|
|
|
const [slots, setSlots] = useState<SlotItem[]>([]);
|
|
const [slots, setSlots] = useState<SlotItem[]>([]);
|
|
|
|
|
+ const [grabbedTasks, setGrabbedTasks] = useState<any[]>([]); // 存储已抢任务
|
|
|
|
|
+
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
const [searchDate, setSearchDate] = useState(today);
|
|
const [searchDate, setSearchDate] = useState(today);
|
|
|
|
|
|
|
|
- // === User Check States ===
|
|
|
|
|
|
|
+ // 功能开关
|
|
|
const [showChecker, setShowChecker] = useState(false);
|
|
const [showChecker, setShowChecker] = useState(false);
|
|
|
|
|
+ const [showProbManager, setShowProbManager] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ // 冲突检测状态
|
|
|
const [checkLoading, setCheckLoading] = useState(false);
|
|
const [checkLoading, setCheckLoading] = useState(false);
|
|
|
const [checkForm, setCheckForm] = useState({ first_name: '', last_name: '', birthday: '' });
|
|
const [checkForm, setCheckForm] = useState({ first_name: '', last_name: '', birthday: '' });
|
|
|
const [apiResult, setApiResult] = useState<any>(null);
|
|
const [apiResult, setApiResult] = useState<any>(null);
|
|
|
|
|
|
|
|
- // === Prob Manager State ===
|
|
|
|
|
- const [showProbManager, setShowProbManager] = useState(false);
|
|
|
|
|
-
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- fetchSlots();
|
|
|
|
|
- }, []);
|
|
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+ }, []); // 初始化
|
|
|
|
|
|
|
|
- const fetchSlots = async () => {
|
|
|
|
|
|
|
+ // === 核心修改:同时获取容量和任务数据 ===
|
|
|
|
|
+ const fetchData = async () => {
|
|
|
if (!searchDate) return alert("请选择日期");
|
|
if (!searchDate) return alert("请选择日期");
|
|
|
setLoading(true);
|
|
setLoading(true);
|
|
|
try {
|
|
try {
|
|
|
- const res = await api.get('/api/troov/rate', { params: { date: searchDate } });
|
|
|
|
|
- const list = Array.isArray(res.data.data) ? res.data.data : (res.data.data || []);
|
|
|
|
|
- list.sort((a: SlotItem, b: SlotItem) => a.time.localeCompare(b.time));
|
|
|
|
|
- setSlots(list);
|
|
|
|
|
|
|
+ // 1. 获取容量 (Rate)
|
|
|
|
|
+ const ratePromise = api.get('/api/troov/rate', { params: { date: searchDate } });
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 获取已抢任务 (Grabbed Tasks)
|
|
|
|
|
+ // 注意:这里我们获取 status=grabbed 的任务。
|
|
|
|
|
+ // 由于后端暂时不支持按日期筛选任务,我们先获取最近的 200 条,然后在前端 (DailySlotDashboard) 进行日期匹配。
|
|
|
|
|
+ // *理想情况是后端 task/list 接口支持 date 参数过滤*
|
|
|
|
|
+ const taskPromise = api.get('/api/vas/task/list', {
|
|
|
|
|
+ params: {
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ size: 200, // 尽量多取一点以覆盖当天
|
|
|
|
|
+ status: 'grabbed',
|
|
|
|
|
+ // 如果需要特定 routing_key,可以在这里加,或者在组件内过滤
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const [rateRes, taskRes] = await Promise.all([ratePromise, taskPromise]);
|
|
|
|
|
+
|
|
|
|
|
+ // 处理 Capacity 数据
|
|
|
|
|
+ const slotList = Array.isArray(rateRes.data.data) ? rateRes.data.data : (rateRes.data.data || []);
|
|
|
|
|
+ setSlots(slotList);
|
|
|
|
|
+
|
|
|
|
|
+ // 处理 Task 数据
|
|
|
|
|
+ const taskList = taskRes.data.data?.items || [];
|
|
|
|
|
+ setGrabbedTasks(taskList);
|
|
|
|
|
+
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- console.warn("API Error, using mock data");
|
|
|
|
|
- setSlots([]);
|
|
|
|
|
|
|
+ console.warn("API Error", e);
|
|
|
|
|
+ // Mock data logic can be added here if needed
|
|
|
} finally {
|
|
} finally {
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
}
|
|
}
|
|
@@ -52,25 +80,15 @@ export default function AdminSlotsPage() {
|
|
|
|
|
|
|
|
const handleCheckUser = async (e: React.FormEvent) => {
|
|
const handleCheckUser = async (e: React.FormEvent) => {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
- if (!checkForm.first_name || !checkForm.last_name || !checkForm.birthday) {
|
|
|
|
|
- return alert("请填写完整信息");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
setCheckLoading(true);
|
|
setCheckLoading(true);
|
|
|
setApiResult(null);
|
|
setApiResult(null);
|
|
|
-
|
|
|
|
|
try {
|
|
try {
|
|
|
const res = await api.post('/api/troov/book', checkForm);
|
|
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) {
|
|
} catch (error: any) {
|
|
|
- console.error(error);
|
|
|
|
|
setApiResult({
|
|
setApiResult({
|
|
|
- status: error.response?.status || 'Network Error',
|
|
|
|
|
- statusText: error.response?.statusText || 'Failed',
|
|
|
|
|
|
|
+ status: error.response?.status || 'Error',
|
|
|
|
|
+ statusText: 'Failed',
|
|
|
data: error.response?.data || error.message
|
|
data: error.response?.data || error.message
|
|
|
});
|
|
});
|
|
|
} finally {
|
|
} finally {
|
|
@@ -78,239 +96,95 @@ export default function AdminSlotsPage() {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- 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 (
|
|
return (
|
|
|
<div className="p-4 md:p-6">
|
|
<div className="p-4 md:p-6">
|
|
|
|
|
|
|
|
- {/* === 1. 顶部 Header & 操作栏 === */}
|
|
|
|
|
- <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
|
|
|
|
|
|
+ {/* Header & Controls */}
|
|
|
|
|
+ <div className="flex flex-col xl:flex-row justify-between items-start xl:items-center mb-6 gap-4">
|
|
|
<div>
|
|
<div>
|
|
|
<h1 className="text-2xl font-bold text-slate-800">Slot 监控与风险预警</h1>
|
|
<h1 className="text-2xl font-bold text-slate-800">Slot 监控与风险预警</h1>
|
|
|
- <p className="text-sm text-slate-500 mt-1">查询预约名额容量(Capacity)及使用率(Rate)</p>
|
|
|
|
|
|
|
+ <p className="text-sm text-slate-500 mt-1">综合视图:容量风险 (Rate) + 抢单结果 (Grabbed)</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div className="flex flex-wrap gap-3 w-full md:w-auto">
|
|
|
|
|
- {/* 用户检测按钮 */}
|
|
|
|
|
|
|
+ <div className="flex flex-wrap gap-3 w-full xl:w-auto">
|
|
|
|
|
+
|
|
|
<button
|
|
<button
|
|
|
onClick={() => setShowChecker(!showChecker)}
|
|
onClick={() => setShowChecker(!showChecker)}
|
|
|
- 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
|
|
|
|
|
|
|
+ className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm font-bold transition
|
|
|
${showChecker ? 'bg-orange-50 border-orange-200 text-orange-700' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}
|
|
${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>
|
|
|
|
|
|
|
|
- {/* 概率管理按钮 */}
|
|
|
|
|
<button
|
|
<button
|
|
|
onClick={() => setShowProbManager(!showProbManager)}
|
|
onClick={() => setShowProbManager(!showProbManager)}
|
|
|
- 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
|
|
|
|
|
|
|
+ className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm font-bold transition
|
|
|
${showProbManager ? 'bg-purple-50 border-purple-200 text-purple-700' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}
|
|
${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>
|
|
</button>
|
|
|
|
|
|
|
|
- {/* 日期查询 */}
|
|
|
|
|
- <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">
|
|
|
|
|
|
|
+ <div className="flex items-center gap-2 bg-white p-1 rounded-lg border border-slate-200 shadow-sm">
|
|
|
|
|
+ <div className="relative">
|
|
|
<input
|
|
<input
|
|
|
type="date"
|
|
type="date"
|
|
|
- className="w-full pl-8 pr-2 py-1.5 text-sm border-none outline-none bg-transparent text-slate-700 font-medium"
|
|
|
|
|
|
|
+ className="pl-8 pr-2 py-1.5 text-sm border-none outline-none bg-transparent text-slate-700 font-medium"
|
|
|
value={searchDate}
|
|
value={searchDate}
|
|
|
onChange={(e) => setSearchDate(e.target.value)}
|
|
onChange={(e) => setSearchDate(e.target.value)}
|
|
|
/>
|
|
/>
|
|
|
<Clock size={14} className="absolute left-2.5 top-2.5 text-slate-400" />
|
|
<Clock size={14} className="absolute left-2.5 top-2.5 text-slate-400" />
|
|
|
</div>
|
|
</div>
|
|
|
- <button
|
|
|
|
|
- onClick={fetchSlots}
|
|
|
|
|
- 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} />}
|
|
{loading ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* === 2. 概率管理面板 (Probability Manager) === */}
|
|
|
|
|
- {showProbManager && (
|
|
|
|
|
- <div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300">
|
|
|
|
|
- <ProbabilityManager />
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {/* === 3. 用户冲突检测面板 (User Check) === */}
|
|
|
|
|
|
|
+ {/* Panels */}
|
|
|
|
|
+ {showProbManager && <div className="mb-8 animate-in fade-in slide-in-from-top-4"><ProbabilityManager /></div>}
|
|
|
|
|
+
|
|
|
{showChecker && (
|
|
{showChecker && (
|
|
|
- <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="mb-8 bg-orange-50/50 border border-orange-200 rounded-xl p-6 animate-in slide-in-from-top-2">
|
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="flex justify-between items-start mb-4">
|
|
|
- <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>
|
|
|
|
|
|
|
+ <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>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
<form onSubmit={handleCheckUser} className="flex flex-col md:flex-row gap-4 items-end mb-4">
|
|
<form onSubmit={handleCheckUser} className="flex flex-col md:flex-row gap-4 items-end mb-4">
|
|
|
- <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">
|
|
|
|
|
|
|
+ {['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">
|
|
|
<label className="block text-xs font-bold text-orange-800 mb-1 uppercase">Birthday</label>
|
|
<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 focus:ring-2 focus:ring-orange-400 outline-none"
|
|
|
|
|
- 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 outline-none focus:ring-2 focus:ring-orange-400"
|
|
|
|
|
+ value={checkForm.birthday} onChange={e => setCheckForm({...checkForm, birthday: e.target.value})} />
|
|
|
</div>
|
|
</div>
|
|
|
- <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 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>
|
|
</button>
|
|
|
</form>
|
|
</form>
|
|
|
-
|
|
|
|
|
- {/* Raw Response Viewer */}
|
|
|
|
|
{apiResult && (
|
|
{apiResult && (
|
|
|
- <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 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>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {/* === 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 > 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. Slot 列表 === */}
|
|
|
|
|
- {loading ? (
|
|
|
|
|
- <div className="py-20 text-center text-slate-400 flex flex-col items-center">
|
|
|
|
|
- <Loader2 size={32} className="animate-spin mb-3" />
|
|
|
|
|
- 正在同步 TROOV 数据...
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : slots.length === 0 ? (
|
|
|
|
|
- <div className="py-20 text-center border-2 border-dashed border-slate-200 rounded-xl bg-slate-50 text-slate-400">
|
|
|
|
|
- 该日期暂无记录
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
|
|
|
|
- {slots.map((slot, index) => {
|
|
|
|
|
- const rateVal = Number(slot.rate);
|
|
|
|
|
- const isRisk = rateVal > 100;
|
|
|
|
|
- const hasCapacity = slot.capacity > 0;
|
|
|
|
|
-
|
|
|
|
|
- let cardStyle = "bg-white border-slate-200 opacity-60";
|
|
|
|
|
- let textStyle = "text-slate-400";
|
|
|
|
|
-
|
|
|
|
|
- if (isRisk) {
|
|
|
|
|
- cardStyle = "bg-red-50 border-red-300 shadow-sm ring-1 ring-red-200";
|
|
|
|
|
- textStyle = "text-red-700";
|
|
|
|
|
- } else if (hasCapacity) {
|
|
|
|
|
- cardStyle = "bg-emerald-50 border-emerald-300 shadow-sm hover:shadow-md transition-all";
|
|
|
|
|
- textStyle = "text-emerald-800";
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ {/* === New: Unified Dashboard View === */}
|
|
|
|
|
+ <DailySlotDashboard
|
|
|
|
|
+ date={searchDate}
|
|
|
|
|
+ capacityData={slots}
|
|
|
|
|
+ grabbedTasks={grabbedTasks}
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ />
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- <div
|
|
|
|
|
- key={index}
|
|
|
|
|
- className={`relative p-4 rounded-xl border flex flex-col justify-between h-36 ${cardStyle}`}
|
|
|
|
|
- >
|
|
|
|
|
- <div className="flex justify-between items-start">
|
|
|
|
|
- <span className={`text-xl font-mono font-bold ${textStyle}`}>
|
|
|
|
|
- {slot.time}
|
|
|
|
|
- </span>
|
|
|
|
|
- {isRisk && (
|
|
|
|
|
- <AlertTriangle size={18} className="text-red-500 animate-pulse" />
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div className="space-y-2">
|
|
|
|
|
- <div className="flex items-center justify-between text-xs">
|
|
|
|
|
- <div className="flex items-center gap-1 opacity-80">
|
|
|
|
|
- <Percent size={12} />
|
|
|
|
|
- <span>使用率</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <span className={`font-bold ${isRisk ? 'text-red-600' : 'text-slate-600'}`}>
|
|
|
|
|
- {rateVal}%
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div className="w-full bg-black/5 rounded-full h-1.5 overflow-hidden">
|
|
|
|
|
- <div
|
|
|
|
|
- className={`h-full rounded-full ${isRisk ? 'bg-red-500' : 'bg-emerald-500'}`}
|
|
|
|
|
- style={{ width: `${Math.min(rateVal, 100)}%` }}
|
|
|
|
|
- ></div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div className={`flex items-center justify-between text-sm font-bold pt-1 border-t border-black/5 ${textStyle}`}>
|
|
|
|
|
- <span className="text-xs opacity-80 font-normal">余号</span>
|
|
|
|
|
- <span className="flex items-center gap-1">
|
|
|
|
|
- <Users size={14} /> {slot.capacity}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {isRisk && (
|
|
|
|
|
- <div className="absolute -top-2 -right-2 bg-red-600 text-white text-[10px] px-2 py-0.5 rounded-full shadow-sm font-bold">
|
|
|
|
|
- 易取消
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
- })}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|