jerry пре 3 месеци
родитељ
комит
1f6382c830
2 измењених фајлова са 378 додато и 51 уклоњено
  1. 163 51
      src/app/admin/slots/page.tsx
  2. 215 0
      src/components/admin/slots/ProbabilityManager.tsx

+ 163 - 51
src/app/admin/slots/page.tsx

@@ -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 &gt; 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">
                     易取消

+ 215 - 0
src/components/admin/slots/ProbabilityManager.tsx

@@ -0,0 +1,215 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import api from '@/lib/api';
+import { 
+  Percent, Trash2, Plus, Save, RotateCcw, Loader2, X 
+} from 'lucide-react';
+import LocalTime from '@/components/common/LocalTime';
+
+interface ProbItem {
+  prob_key: string; // ISO DateTime
+  prob_val: number; // 0.0 - 1.0
+}
+
+export default function ProbabilityManager() {
+  const [probs, setProbs] = useState<ProbItem[]>([]);
+  const [loading, setLoading] = useState(false);
+  
+  // 新增/编辑表单
+  const [form, setForm] = useState({
+    prob_key: '', // DateTime local string
+    prob_val: 0.5
+  });
+
+  // 重置日期
+  const [resetDate, setResetDate] = useState('');
+
+  useEffect(() => {
+    fetchProbs();
+  }, []);
+
+  const fetchProbs = async () => {
+    setLoading(true);
+    try {
+      // API: GET /api/troov/list-probs
+      const res = await api.get('/api/troov/list-probs');
+      const data = res.data.data;
+      if (Array.isArray(data)) {
+        // 按时间排序
+        data.sort((a: ProbItem, b: ProbItem) => a.prob_key.localeCompare(b.prob_key));
+        setProbs(data);
+      }
+    } catch (e) {
+      console.error("Fetch probs failed", e);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleSetProb = async () => {
+    if (!form.prob_key) return alert("请选择时间");
+    // 转换为 ISO 格式 (后端可能需要完整 ISO,或者 YYYY-MM-DDTHH:mm:ss)
+    // input type="datetime-local" 的值是 YYYY-MM-DDTHH:mm
+    const isoKey = new Date(form.prob_key).toISOString().split('.')[0]; // 去掉毫秒,视后端要求
+
+    try {
+      await api.post('/api/troov/set-prob', {
+        prob_key: isoKey,
+        prob_val: Number(form.prob_val)
+      });
+      fetchProbs();
+      // 不清空 form,方便连续添加
+    } catch (e: any) {
+      alert("设置失败: " + e.message);
+    }
+  };
+
+  const handleDeleteProb = async (key: string) => {
+    if (!confirm("确定删除此概率配置吗?")) return;
+    try {
+      await api.delete('/api/troov/del-prob', {
+        data: { prob_key: key } // axios delete body
+      });
+      fetchProbs();
+    } catch (e: any) {
+      alert("删除失败: " + e.message);
+    }
+  };
+
+  const handleResetProbs = async () => {
+    if (!resetDate) return alert("请选择要重置的日期");
+    if (!confirm(`确定要重置 ${resetDate} 当天的所有概率吗?`)) return;
+    
+    try {
+      await api.post(`/api/troov/reset-probs?date=${resetDate}`);
+      alert("重置成功");
+      fetchProbs();
+    } catch (e: any) {
+      alert("重置失败: " + e.message);
+    }
+  };
+
+  return (
+    <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
+      <div className="px-6 py-4 border-b bg-slate-50 flex justify-between items-center">
+        <h3 className="font-bold text-slate-800 flex items-center gap-2">
+          <Percent size={18} className="text-purple-600"/> 概率干预管理 (Probability Override)
+        </h3>
+        <button onClick={fetchProbs} className="text-sm text-blue-600 hover:underline">刷新列表</button>
+      </div>
+
+      <div className="p-6 grid grid-cols-1 lg:grid-cols-3 gap-8">
+        
+        {/* 左侧:列表展示 */}
+        <div className="lg:col-span-2">
+          <h4 className="text-sm font-bold text-slate-700 mb-3">当前生效规则 ({probs.length})</h4>
+          <div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto bg-slate-50">
+            {loading ? (
+              <div className="p-8 text-center text-gray-400"><Loader2 className="animate-spin inline mr-2"/> 加载中...</div>
+            ) : probs.length === 0 ? (
+              <div className="p-8 text-center text-gray-400 text-sm">暂无概率配置</div>
+            ) : (
+              <table className="min-w-full text-sm text-left">
+                <thead className="bg-slate-100 border-b border-slate-200 text-xs uppercase text-slate-500 sticky top-0">
+                  <tr>
+                    <th className="px-4 py-2">时间点 (Slot Key)</th>
+                    <th className="px-4 py-2">概率值</th>
+                    <th className="px-4 py-2 text-right">操作</th>
+                  </tr>
+                </thead>
+                <tbody className="divide-y divide-slate-200 bg-white">
+                  {probs.map((item) => (
+                    <tr key={item.prob_key} className="hover:bg-slate-50 group">
+                      <td className="px-4 py-2 font-mono text-slate-700">
+                        {item.prob_key.replace('T', ' ')}
+                      </td>
+                      <td className="px-4 py-2 font-bold">
+                        <span className={`${item.prob_val > 0.8 ? 'text-green-600' : item.prob_val < 0.2 ? 'text-red-600' : 'text-blue-600'}`}>
+                          {item.prob_val}
+                        </span>
+                      </td>
+                      <td className="px-4 py-2 text-right">
+                        <button 
+                          onClick={() => handleDeleteProb(item.prob_key)}
+                          className="p-1.5 text-slate-400 hover:text-red-600 rounded hover:bg-red-50 transition"
+                        >
+                          <Trash2 size={16} />
+                        </button>
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            )}
+          </div>
+        </div>
+
+        {/* 右侧:操作面板 */}
+        <div className="space-y-6">
+          
+          {/* 添加/修改 */}
+          <div className="bg-purple-50 p-4 rounded-xl border border-purple-100">
+            <h4 className="text-sm font-bold text-purple-900 mb-4 flex items-center gap-2">
+              <Plus size={16} /> 设置/更新概率
+            </h4>
+            <div className="space-y-3">
+              <div>
+                <label className="text-xs font-bold text-purple-700 block mb-1">时间点</label>
+                <input 
+                  type="datetime-local" 
+                  step="1" // 允许选择秒(如果需要)
+                  className="w-full border border-purple-200 rounded p-2 text-sm focus:ring-2 focus:ring-purple-400 outline-none"
+                  value={form.prob_key}
+                  onChange={e => setForm({...form, prob_key: e.target.value})}
+                />
+              </div>
+              <div>
+                <label className="text-xs font-bold text-purple-700 block mb-1">概率 (0.0 - 1.0)</label>
+                <div className="flex items-center gap-2">
+                  <input 
+                    type="number" step="0.01" min="0" max="1"
+                    className="flex-1 border border-purple-200 rounded p-2 text-sm focus:ring-2 focus:ring-purple-400 outline-none"
+                    value={form.prob_val}
+                    onChange={e => setForm({...form, prob_val: parseFloat(e.target.value)})}
+                  />
+                  <span className="text-xs text-purple-600 w-8 text-right">
+                    {(form.prob_val * 100).toFixed(0)}%
+                  </span>
+                </div>
+              </div>
+              <button 
+                onClick={handleSetProb}
+                className="w-full bg-purple-600 text-white py-2 rounded-lg font-bold hover:bg-purple-700 transition shadow-sm flex items-center justify-center gap-2"
+              >
+                <Save size={16} /> 保存配置
+              </button>
+            </div>
+          </div>
+
+          {/* 批量重置 */}
+          <div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
+            <h4 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
+              <RotateCcw size={16} /> 按日期重置
+            </h4>
+            <div className="space-y-3">
+              <input 
+                type="date"
+                className="w-full border border-slate-300 rounded p-2 text-sm"
+                value={resetDate}
+                onChange={e => setResetDate(e.target.value)}
+              />
+              <button 
+                onClick={handleResetProbs}
+                className="w-full bg-white border border-slate-300 text-slate-600 py-2 rounded-lg font-medium hover:bg-slate-100 hover:text-red-600 transition"
+              >
+                清除该日所有配置
+              </button>
+            </div>
+          </div>
+
+        </div>
+      </div>
+    </div>
+  );
+}