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