|
|
@@ -2,21 +2,34 @@
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
import api from '@/lib/api';
|
|
|
-import { Search, Loader2, Users, Clock, Percent, AlertTriangle, CheckCircle } from 'lucide-react';
|
|
|
+import {
|
|
|
+ Search, Loader2, Users, Clock, Percent,
|
|
|
+ AlertTriangle, CheckCircle, ShieldAlert, X, Terminal
|
|
|
+} from 'lucide-react';
|
|
|
+import ProbabilityManager from '@/components/admin/slots/ProbabilityManager'; // 引入概率管理组件
|
|
|
|
|
|
interface SlotItem {
|
|
|
time: string;
|
|
|
- rate: string | number; // 使用率 (e.g. 50, 100, 120)
|
|
|
+ rate: string | number;
|
|
|
capacity: number;
|
|
|
}
|
|
|
|
|
|
export default function AdminSlotsPage() {
|
|
|
+ // === Slot List States ===
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
const [slots, setSlots] = useState<SlotItem[]>([]);
|
|
|
-
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
const [searchDate, setSearchDate] = useState(today);
|
|
|
|
|
|
+ // === User Check States ===
|
|
|
+ const [showChecker, setShowChecker] = useState(false);
|
|
|
+ const [checkLoading, setCheckLoading] = useState(false);
|
|
|
+ const [checkForm, setCheckForm] = useState({ first_name: '', last_name: '', birthday: '' });
|
|
|
+ const [apiResult, setApiResult] = useState<any>(null);
|
|
|
+
|
|
|
+ // === Prob Manager State ===
|
|
|
+ const [showProbManager, setShowProbManager] = useState(false);
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
fetchSlots();
|
|
|
}, []);
|
|
|
@@ -25,67 +38,177 @@ export default function AdminSlotsPage() {
|
|
|
if (!searchDate) return alert("请选择日期");
|
|
|
setLoading(true);
|
|
|
try {
|
|
|
- // API: GET /api/troov/rate?date=YYYY-MM-DD
|
|
|
- const res = await api.get('/api/troov/rate', {
|
|
|
- params: { date: searchDate }
|
|
|
- });
|
|
|
-
|
|
|
+ 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);
|
|
|
} catch (e) {
|
|
|
console.warn("API Error, using mock data");
|
|
|
- // Mock Data: 包含正常和高风险数据
|
|
|
- setSlots([
|
|
|
- { time: '09:00', rate: 50, capacity: 5 }, // 正常
|
|
|
- { time: '10:00', rate: 95, capacity: 2 }, // 拥挤
|
|
|
- { time: '11:00', rate: 120, capacity: 10 }, // >100 高风险 (虽然有 capacity,但可能会被取消)
|
|
|
- { time: '13:30', rate: 10, capacity: 0 }, // 无号
|
|
|
- { time: '14:45', rate: 150, capacity: 1 }, // 极高风险
|
|
|
- { time: '16:00', rate: 80, capacity: 3 }, // 正常
|
|
|
- ]);
|
|
|
+ setSlots([]);
|
|
|
} 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
|
|
|
+ });
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error(error);
|
|
|
+ setApiResult({
|
|
|
+ status: error.response?.status || 'Network Error',
|
|
|
+ statusText: error.response?.statusText || 'Failed',
|
|
|
+ data: error.response?.data || error.message
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ setCheckLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
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>
|
|
|
- {/* 头部 & 搜索 */}
|
|
|
- <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
|
|
+ <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">
|
|
|
<div>
|
|
|
<h1 className="text-2xl font-bold text-slate-800">Slot 监控与风险预警</h1>
|
|
|
<p className="text-sm text-slate-500 mt-1">查询预约名额容量(Capacity)及使用率(Rate)</p>
|
|
|
</div>
|
|
|
|
|
|
- <div className="flex items-center gap-3 bg-white p-1.5 rounded-lg border border-slate-200 shadow-sm">
|
|
|
- <div className="relative">
|
|
|
- <input
|
|
|
- type="date"
|
|
|
- className="pl-9 pr-3 py-2 text-sm border-none outline-none bg-transparent text-slate-700 font-medium"
|
|
|
- value={searchDate}
|
|
|
- onChange={(e) => setSearchDate(e.target.value)}
|
|
|
- />
|
|
|
- <Clock size={16} className="absolute left-3 top-2.5 text-slate-400" />
|
|
|
- </div>
|
|
|
+ <div className="flex flex-wrap gap-3 w-full md:w-auto">
|
|
|
+ {/* 用户检测按钮 */}
|
|
|
+ <button
|
|
|
+ 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
|
|
|
+ ${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 ? '关闭检测' : '用户冲突检测'}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ {/* 概率管理按钮 */}
|
|
|
<button
|
|
|
- onClick={fetchSlots}
|
|
|
- disabled={loading}
|
|
|
- className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-md text-sm font-bold hover:bg-slate-800 transition disabled:opacity-70"
|
|
|
+ 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
|
|
|
+ ${showProbManager ? 'bg-purple-50 border-purple-200 text-purple-700' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}
|
|
|
+ `}
|
|
|
>
|
|
|
- {loading ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
|
|
|
- 查询
|
|
|
+ <Percent size={16} />
|
|
|
+ {showProbManager ? '关闭概率管理' : '概率管理'}
|
|
|
</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">
|
|
|
+ <input
|
|
|
+ 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"
|
|
|
+ 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={fetchSlots}
|
|
|
+ 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>
|
|
|
|
|
|
- {/* 统计看板 */}
|
|
|
+ {/* === 2. 概率管理面板 (Probability Manager) === */}
|
|
|
+ {showProbManager && (
|
|
|
+ <div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300">
|
|
|
+ <ProbabilityManager />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* === 3. 用户冲突检测面板 (User Check) === */}
|
|
|
+ {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="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>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <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">
|
|
|
+ <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})}
|
|
|
+ />
|
|
|
+ </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>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ {/* Raw Response Viewer */}
|
|
|
+ {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>
|
|
|
+ )}
|
|
|
+ </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>
|
|
|
@@ -94,7 +217,6 @@ export default function AdminSlotsPage() {
|
|
|
</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>
|
|
|
@@ -102,7 +224,6 @@ export default function AdminSlotsPage() {
|
|
|
</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>
|
|
|
@@ -112,7 +233,7 @@ export default function AdminSlotsPage() {
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- {/* Slot 列表 */}
|
|
|
+ {/* === 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" />
|
|
|
@@ -129,16 +250,13 @@ export default function AdminSlotsPage() {
|
|
|
const isRisk = rateVal > 100;
|
|
|
const hasCapacity = slot.capacity > 0;
|
|
|
|
|
|
- // 样式逻辑
|
|
|
- let cardStyle = "bg-white border-slate-200 opacity-60"; // 默认无号
|
|
|
+ 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";
|
|
|
}
|
|
|
@@ -148,19 +266,16 @@ export default function AdminSlotsPage() {
|
|
|
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">
|
|
|
- {/* Rate 使用率 */}
|
|
|
<div className="flex items-center justify-between text-xs">
|
|
|
<div className="flex items-center gap-1 opacity-80">
|
|
|
<Percent size={12} />
|
|
|
@@ -171,7 +286,6 @@ export default function AdminSlotsPage() {
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
- {/* 进度条可视化 Rate */}
|
|
|
<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'}`}
|
|
|
@@ -179,7 +293,6 @@ export default function AdminSlotsPage() {
|
|
|
></div>
|
|
|
</div>
|
|
|
|
|
|
- {/* Capacity 余号 */}
|
|
|
<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">
|
|
|
@@ -188,7 +301,6 @@ export default function AdminSlotsPage() {
|
|
|
</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">
|
|
|
易取消
|