jerry пре 3 месеци
родитељ
комит
6c3ff7ce05

+ 28 - 2
package-lock.json

@@ -13,6 +13,7 @@
                 "next": "14.1.0",
                 "react": "^18",
                 "react-dom": "^18",
+                "react-hot-toast": "^2.6.0",
                 "react-is": "^19.2.3",
                 "recharts": "^3.6.0",
                 "use-debounce": "^10.0.6"
@@ -667,8 +668,7 @@
         "node_modules/csstype": {
             "version": "3.2.3",
             "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
-            "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-            "devOptional": true
+            "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
         },
         "node_modules/d3-array": {
             "version": "3.2.4",
@@ -1066,6 +1066,15 @@
                 "node": ">=10.13.0"
             }
         },
+        "node_modules/goober": {
+            "version": "2.1.18",
+            "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
+            "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
+            "license": "MIT",
+            "peerDependencies": {
+                "csstype": "^3.0.10"
+            }
+        },
         "node_modules/gopd": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1657,6 +1666,23 @@
                 "react": "^18.3.1"
             }
         },
+        "node_modules/react-hot-toast": {
+            "version": "2.6.0",
+            "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
+            "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
+            "license": "MIT",
+            "dependencies": {
+                "csstype": "^3.1.3",
+                "goober": "^2.1.16"
+            },
+            "engines": {
+                "node": ">=10"
+            },
+            "peerDependencies": {
+                "react": ">=16",
+                "react-dom": ">=16"
+            }
+        },
         "node_modules/react-is": {
             "version": "19.2.3",
             "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
         "next": "14.1.0",
         "react": "^18",
         "react-dom": "^18",
+        "react-hot-toast": "^2.6.0",
         "react-is": "^19.2.3",
         "recharts": "^3.6.0",
         "use-debounce": "^10.0.6"

+ 276 - 0
src/app/admin/accounts/page.tsx

@@ -0,0 +1,276 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Plus, Search, RefreshCw, X, Lock } from 'lucide-react';
+import api from '@/lib/api';
+import { toast } from 'react-hot-toast';
+
+// 引入拆分后的组件
+import AccountTable, { Account } from '@/components/admin/accounts/AccountTable';
+import Pagination from '@/components/common/Pagination';
+
+export default function AdminAccountsPage() {
+  // === 1. 状态定义 ===
+  const [loading, setLoading] = useState<boolean>(true);
+  const [data, setData] = useState<Account[]>([]);
+  
+  // 分页与搜索
+  const [page, setPage] = useState(1); // 1-based UI
+  const [pageSize] = useState(10);
+  const [total, setTotal] = useState(0);
+  const [keyword, setKeyword] = useState('');
+
+  // 模态框状态
+  const [showAddModal, setShowAddModal] = useState(false);
+  const [showLockModal, setShowLockModal] = useState(false);
+  const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
+
+  // 表单状态
+  const [addForm, setAddForm] = useState({
+    pool_name: '',
+    username: '',
+    password: '',
+    extra_data_str: '{}'
+  });
+  const [lockDuration, setLockDuration] = useState<number>(3600);
+
+  // === 2. 数据获取 ===
+  const fetchAccounts = async (targetPage: number = page) => {
+    setLoading(true);
+    try {
+      // API 使用 0-based,前端使用 1-based
+      // const apiPage = targetPage - 1;
+
+      const res = await api.get('/api/account/list_all', {
+        params: {
+          page: targetPage,
+          size: pageSize,
+          keyword: keyword
+        }
+      });
+
+      const resData = res.data?.data || {};
+      if (resData && Array.isArray(resData.items)) {
+        setData(resData.items);
+        setTotal(resData.total || 0);
+      } else {
+        setData([]);
+        setTotal(0);
+      }
+      
+      setPage(targetPage);
+
+      // 翻页滚动优化
+      if (targetPage > 1) {
+        window.scrollTo({ top: 0, behavior: 'smooth' });
+      }
+
+    } catch (error) {
+      console.error("Fetch accounts failed", error);
+      toast.error('获取列表失败');
+      setData([]);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchAccounts(1);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const handleSearch = () => fetchAccounts(1);
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') handleSearch();
+  };
+
+  // === 3. 业务逻辑 ===
+  const handleAddAccount = async () => {
+    try {
+      let extraDataJson = {};
+      try {
+        extraDataJson = JSON.parse(addForm.extra_data_str);
+      } catch (e) {
+        toast.error('Extra Data 必须是 JSON 格式');
+        return;
+      }
+      await api.post('/api/account/add', {
+        pool_name: addForm.pool_name,
+        username: addForm.username,
+        password: addForm.password,
+        extra_data: extraDataJson
+      });
+      toast.success('添加成功');
+      setShowAddModal(false);
+      setAddForm({ pool_name: '', username: '', password: '', extra_data_str: '{}' });
+      fetchAccounts(1);
+    } catch (error) {
+      toast.error('添加失败');
+    }
+  };
+
+  const handleLockConfirm = async () => {
+    if (!selectedAccount) return;
+    try {
+      await api.post('/api/account/lock', {
+        pool_name: selectedAccount.pool_name,
+        username: selectedAccount.username,
+        duration: Number(lockDuration)
+      });
+      toast.success('锁定成功');
+      setShowLockModal(false);
+      fetchAccounts(page); // 刷新当前页
+    } catch (error) {
+      toast.error('锁定失败');
+    }
+  };
+
+  const handleDisable = async (account: Account) => {
+    if (!confirm(`确定要禁用账号 ${account.username} 吗?`)) return;
+    try {
+      await api.post('/api/account/disable', {
+        pool_name: account.pool_name,
+        username: account.username
+      });
+      toast.success('禁用成功');
+      fetchAccounts(page);
+    } catch (error) {
+      toast.error('禁用失败');
+    }
+  };
+
+  return (
+    <div className="p-4 md:p-6 space-y-6">
+      
+      {/* === 头部区域 === */}
+      <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
+        <div>
+          <h1 className="text-2xl font-bold text-slate-800">账号池管理</h1>
+          <p className="text-sm text-slate-500 mt-1">管理爬虫账号、查看锁定状态及禁用异常账号</p>
+        </div>
+        
+        <div className="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
+          {/* 搜索框 */}
+          <div className="relative w-full sm:w-auto md:w-64">
+            <input 
+              type="text" 
+              placeholder="搜索账号 / Pool..." 
+              className="w-full pl-9 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition shadow-sm"
+              value={keyword}
+              onChange={e => setKeyword(e.target.value)}
+              onKeyDown={handleKeyDown}
+            />
+            <Search size={16} className="absolute left-3 top-2.5 text-gray-400" />
+          </div>
+
+          {/* 刷新 */}
+          <button 
+            onClick={() => fetchAccounts(page)} 
+            className="flex items-center justify-center gap-2 px-4 py-2 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 text-slate-600 font-medium transition shadow-sm w-full sm:w-auto"
+            title="刷新列表"
+          >
+            <RefreshCw size={16} /> 
+            <span className="sm:hidden">刷新</span>
+          </button>
+
+          {/* 新增 */}
+          <button 
+            onClick={() => setShowAddModal(true)}
+            className="flex items-center justify-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition shadow-sm w-full sm:w-auto"
+          >
+            <Plus size={16} /> 新增
+          </button>
+        </div>
+      </div>
+
+      {/* === 表格组件 === */}
+      <AccountTable 
+        data={data}
+        loading={loading}
+        onLock={(acc) => { setSelectedAccount(acc); setShowLockModal(true); }}
+        onDisable={handleDisable}
+      />
+
+      {/* === 分页组件 === */}
+      <div className="mt-4">
+        <Pagination 
+          currentPage={page}
+          total={total}
+          pageSize={pageSize}
+          onPageChange={fetchAccounts} // 传递函数,分页组件点击时回调
+        />
+      </div>
+
+      {/* === Modals === */}
+      {/* 新增 Modal */}
+      {showAddModal && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in zoom-in-95 duration-200">
+          <div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden">
+            <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
+              <h3 className="font-bold text-lg text-slate-800">新增账号</h3>
+              <button onClick={() => setShowAddModal(false)} className="text-slate-400 hover:text-slate-600">
+                <X size={20} />
+              </button>
+            </div>
+            <div className="p-6 space-y-4 max-h-[80vh] overflow-y-auto">
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Pool Name</label>
+                <input type="text" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={addForm.pool_name} onChange={e => setAddForm({...addForm, pool_name: e.target.value})} />
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Username</label>
+                <input type="text" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={addForm.username} onChange={e => setAddForm({...addForm, username: e.target.value})} />
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Password</label>
+                <input type="text" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={addForm.password} onChange={e => setAddForm({...addForm, password: e.target.value})} />
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Extra Data (JSON)</label>
+                <textarea className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none font-mono text-xs bg-slate-50" rows={4} value={addForm.extra_data_str} onChange={e => setAddForm({...addForm, extra_data_str: e.target.value})} placeholder='{"country": "fr"}' />
+              </div>
+            </div>
+            <div className="px-6 py-4 bg-slate-50 flex justify-end gap-3 border-t">
+              <button onClick={() => setShowAddModal(false)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
+              <button onClick={handleAddAccount} className="px-4 py-2 text-sm font-bold bg-slate-900 text-white rounded-lg hover:bg-slate-800">确认添加</button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* 锁定 Modal */}
+      {showLockModal && selectedAccount && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in zoom-in-95 duration-200">
+          <div className="bg-white rounded-xl shadow-2xl w-full max-w-sm overflow-hidden">
+             <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-orange-50/50">
+              <h3 className="font-bold text-lg text-orange-900 flex items-center gap-2">
+                <Lock size={18} /> 锁定账号
+              </h3>
+              <button onClick={() => setShowLockModal(false)} className="text-slate-400 hover:text-slate-600">
+                <X size={20} />
+              </button>
+            </div>
+            <div className="p-6 space-y-4">
+              <p className="text-sm text-slate-600 bg-orange-50 p-3 rounded-lg border border-orange-100">
+                正在锁定: <span className="font-bold text-slate-900">{selectedAccount.username}</span>
+              </p>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">持续时间 (秒)</label>
+                <input type="number" min="0" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-orange-500 outline-none font-mono" value={lockDuration} onChange={e => setLockDuration(Number(e.target.value))} />
+                <div className="flex gap-2 mt-2">
+                   <button onClick={() => setLockDuration(3600)} className="flex-1 text-xs bg-slate-100 border px-2 py-1 rounded hover:bg-slate-200">1小时</button>
+                   <button onClick={() => setLockDuration(86400)} className="flex-1 text-xs bg-slate-100 border px-2 py-1 rounded hover:bg-slate-200">1天</button>
+                </div>
+              </div>
+            </div>
+            <div className="px-6 py-4 bg-slate-50 flex justify-end gap-3 border-t">
+              <button onClick={() => setShowLockModal(false)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
+              <button onClick={handleLockConfirm} className="px-4 py-2 text-sm font-bold bg-orange-600 text-white rounded-lg hover:bg-orange-700">确认锁定</button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 5 - 0
src/app/layout.tsx

@@ -2,6 +2,7 @@ import './globals.css';
 import Navbar from '@/components/Navbar';
 import Footer from '@/components/Footer';
 // 1. 引入语言上下文 Provider
+import { Toaster } from 'react-hot-toast'; 
 import { LanguageProvider } from '@/lib/i18n/LanguageContext';
 
 export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -18,6 +19,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
           
           <Footer />
         </LanguageProvider>
+        <Toaster 
+          position="top-center"
+          reverseOrder={false}
+        />
       </body>
     </html>
   );

+ 2 - 1
src/components/admin/AdminSidebar.tsx

@@ -7,7 +7,7 @@ import {
   LayoutDashboard, ShoppingBag, LifeBuoy, Settings, LogOut, 
   Activity, CreditCard, Users, ChevronLeft, ChevronRight, 
   Plane, LucideIcon, CalendarClock, LayoutGrid, CheckCheck,
-  Menu, X, Server
+  Menu, X, Server, UserCog
 } from 'lucide-react';
 
 interface MenuItem {
@@ -37,6 +37,7 @@ export default function AdminSidebar() {
     { name: '卡片管理', href: '/admin/cards', icon: LayoutGrid },
     { name: '远程服务器', href: '/admin/remote-server', icon: Server },
     { name: 'Worker 状态', href: '/admin/workers', icon: Server },
+    { name: '账号池管理', href: '/admin/accounts', icon: UserCog },
   ];
 
   useEffect(() => {

+ 161 - 0
src/components/admin/accounts/AccountTable.tsx

@@ -0,0 +1,161 @@
+import { Lock, Unlock, Timer, Loader2, Ban } from 'lucide-react';
+
+export interface Account {
+  id: number;
+  pool_name: string;
+  username: string;
+  password?: string;
+  lock_until: number;
+  extra_data: Record<string, any>;
+  status: string; // 后端返回的状态字符串,如 "active", "disabled"
+}
+
+interface AccountTableProps {
+  data: Account[];
+  loading: boolean;
+  onLock: (account: Account) => void;
+  onDisable: (account: Account) => void;
+}
+
+export default function AccountTable({ data, loading, onLock, onDisable }: AccountTableProps) {
+  // 辅助函数:计算剩余时间
+  const getRemainingTime = (until: number): string | null => {
+    if (!until) return null;
+    const now = Date.now();
+    // 兼容秒级/毫秒级
+    const untilMs = until < 1000000000000 ? until * 1000 : until;
+    if (untilMs <= now) return null;
+
+    const diffSeconds = Math.floor((untilMs - now) / 1000);
+    const days = Math.floor(diffSeconds / (3600 * 24));
+    const hours = Math.floor((diffSeconds % (3600 * 24)) / 3600);
+    const minutes = Math.floor((diffSeconds % 3600) / 60);
+    const seconds = diffSeconds % 60;
+
+    if (days > 0) return `${days}d ${hours}h`;
+    if (hours > 0) return `${hours}h ${minutes}m`;
+    if (minutes > 0) return `${minutes}m ${seconds}s`;
+    return `${seconds}s`;
+  };
+
+  if (loading) {
+    return (
+      <div className="bg-white border border-slate-200 rounded-xl shadow-sm p-20 flex justify-center items-center">
+        <div className="flex flex-col items-center text-slate-500">
+          <Loader2 className="w-8 h-8 animate-spin mb-2 text-blue-600" />
+          <span className="text-sm">加载数据中...</span>
+        </div>
+      </div>
+    );
+  }
+
+  if (data.length === 0) {
+    return (
+      <div className="bg-white border border-slate-200 rounded-xl shadow-sm p-20 flex justify-center items-center">
+        <span className="text-slate-500 text-sm">暂无数据</span>
+      </div>
+    );
+  }
+
+  return (
+    <div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
+      <div className="overflow-x-auto w-full">
+        <table className="w-full text-left text-sm whitespace-nowrap">
+          <thead className="bg-slate-50 border-b border-slate-200">
+            <tr>
+              <th className="px-6 py-4 font-semibold text-slate-700">ID</th>
+              <th className="px-6 py-4 font-semibold text-slate-700">Pool Name</th>
+              <th className="px-6 py-4 font-semibold text-slate-700">Username</th>
+              <th className="px-6 py-4 font-semibold text-slate-700">Password</th>
+              <th className="px-6 py-4 font-semibold text-slate-700">Status</th>
+              <th className="px-6 py-4 font-semibold text-slate-700">Unlock In</th>
+              <th className="px-6 py-4 font-semibold text-slate-700 text-right">Actions</th>
+            </tr>
+          </thead>
+          <tbody className="divide-y divide-slate-100">
+            {data.map((item) => {
+              // 1. 计算锁定状态
+              const remainingTime = getRemainingTime(item.lock_until);
+              const isLocked = remainingTime !== null;
+              
+              // 2. 计算禁用状态 (假设后端返回 'disabled',大小写不敏感处理)
+              const isDisabled = item.status?.toLowerCase() === 'disabled';
+
+              return (
+                <tr key={item.id} className="hover:bg-slate-50/50 transition-colors">
+                  <td className="px-6 py-4 text-slate-500 font-mono">#{item.id}</td>
+                  <td className="px-6 py-4 font-medium text-slate-900">
+                    <span className="bg-slate-100 px-2 py-1 rounded text-xs border border-slate-200">
+                      {item.pool_name}
+                    </span>
+                  </td>
+                  <td className="px-6 py-4 text-slate-700 font-medium">{item.username}</td>
+                  <td className="px-6 py-4 text-slate-600 font-mono text-xs select-all">
+                    {item.password || <span className="text-slate-300 italic">N/A</span>}
+                  </td>
+
+                  {/* --- 状态列修改 --- */}
+                  <td className="px-6 py-4">
+                    {isDisabled ? (
+                      // 优先级最高:禁用状态 (红色)
+                      <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-red-100 text-red-700 border border-red-200">
+                        <Ban size={12} /> Disabled
+                      </span>
+                    ) : isLocked ? (
+                      // 优先级中等:锁定状态 (橙色)
+                      <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-orange-100 text-orange-700 border border-orange-200">
+                        <Lock size={12} /> Locked
+                      </span>
+                    ) : (
+                      // 优先级最低:正常状态 (绿色)
+                      <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-green-100 text-green-700 border border-green-200">
+                        <Unlock size={12} /> Active
+                      </span>
+                    )}
+                  </td>
+
+                  <td className="px-6 py-4">
+                    {isLocked && !isDisabled ? (
+                      <div className="flex items-center gap-1.5 text-orange-600 font-mono font-bold bg-orange-50 px-2 py-1 rounded w-fit border border-orange-100 text-xs">
+                        <Timer size={14} className="animate-pulse" />
+                        {remainingTime}
+                      </div>
+                    ) : (
+                      <span className="text-slate-300 pl-4">-</span>
+                    )}
+                  </td>
+
+                  <td className="px-6 py-4 text-right">
+                    <div className="flex items-center justify-end gap-2">
+                      <button
+                        onClick={() => onLock(item)}
+                        // 如果已经被禁用,通常不需要再锁定了,可以禁用按钮
+                        disabled={isDisabled} 
+                        className={`p-2 rounded-lg transition ${
+                          isDisabled 
+                            ? 'text-slate-300 cursor-not-allowed' 
+                            : 'text-slate-500 hover:text-orange-600 hover:bg-orange-50'
+                        }`}
+                        title="锁定账号"
+                      >
+                        <Lock size={16} />
+                      </button>
+                      <button
+                        onClick={() => onDisable(item)}
+                        // 如果已经是禁用状态,也许需要变成“启用”按钮?这里暂时只显示禁用操作
+                        className="p-2 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
+                        title="禁用账号"
+                      >
+                        <Ban size={16} />
+                      </button>
+                    </div>
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}