jerry 2 тижнів тому
батько
коміт
a00c86e37b

+ 123 - 36
src/app/admin/accounts/page.tsx

@@ -8,6 +8,38 @@ import { toast } from 'react-hot-toast';
 // 引入拆分后的组件
 import AccountTable, { Account } from '@/components/admin/accounts/AccountTable';
 import Pagination from '@/components/common/Pagination';
+import LocalTime from '@/components/common/LocalTime';
+
+type AccountStatus = 'active' | 'disable';
+
+const DEFAULT_EXTRA_DATA = '{}';
+
+function parseUtcDateTime(value?: string | null) {
+  if (!value) return null;
+  const normalized = value.trim().replace(' ', 'T');
+  const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(normalized);
+  const utcValue = hasTimezone ? normalized : `${normalized}Z`;
+  const timestamp = new Date(utcValue).getTime();
+
+  return Number.isNaN(timestamp) ? null : timestamp;
+}
+
+function toDatetimeLocalValue(value?: string | null) {
+  if (!value) return '';
+  const timestamp = parseUtcDateTime(value);
+  if (timestamp === null) return '';
+  const date = new Date(timestamp);
+  const offset = date.getTimezoneOffset();
+  const localDate = new Date(date.getTime() - offset * 60 * 1000);
+  return localDate.toISOString().slice(0, 16);
+}
+
+function toApiDateTime(value: string) {
+  if (!value) return null;
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return null;
+  return date.toISOString();
+}
 
 export default function AdminAccountsPage() {
   // === 1. 状态定义 ===
@@ -32,21 +64,23 @@ export default function AdminAccountsPage() {
     pool_name: '',
     username: '',
     password: '',
-    extra_data_str: '{}'
+    extra_data_str: DEFAULT_EXTRA_DATA,
+    status: 'active' as AccountStatus
   });
   const [lockDuration, setLockDuration] = useState<number>(3600);
   const [editForm, setEditForm] = useState({
+    pool_name: '',
+    username: '',
     password: '',
-    extra_data_str: '{}'
+    extra_data_str: DEFAULT_EXTRA_DATA,
+    status: 'active' as AccountStatus,
+    next_use_time: ''
   });
 
   // === 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,
@@ -94,22 +128,29 @@ export default function AdminAccountsPage() {
   // === 3. 业务逻辑 ===
   const handleAddAccount = async () => {
     try {
-      let extraDataJson = {};
+      let extraDataJson: Record<string, any> | null = null;
       try {
-        extraDataJson = JSON.parse(addForm.extra_data_str);
-      } catch (e) {
+        extraDataJson = addForm.extra_data_str.trim() ? JSON.parse(addForm.extra_data_str) : null;
+      } catch {
         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
+        password: addForm.password || null,
+        extra_data: extraDataJson,
+        status: addForm.status || null
       });
       toast.success('添加成功');
       setShowAddModal(false);
-      setAddForm({ pool_name: '', username: '', password: '', extra_data_str: '{}' });
+      setAddForm({
+        pool_name: '',
+        username: '',
+        password: '',
+        extra_data_str: DEFAULT_EXTRA_DATA,
+        status: 'active'
+      });
       fetchAccounts(1);
     } catch (error) {
       toast.error('添加失败');
@@ -119,8 +160,12 @@ export default function AdminAccountsPage() {
   const handleEditClick = (account: Account) => {
     setSelectedAccount(account);
     setEditForm({
+      pool_name: account.pool_name || '',
+      username: account.username || '',
       password: account.password || '',
-      extra_data_str: JSON.stringify(account.extra_data || {}, null, 2)
+      extra_data_str: JSON.stringify(account.extra_data || {}, null, 2),
+      status: (account.status?.toLowerCase() === 'disable' ? 'disable' : 'active') as AccountStatus,
+      next_use_time: toDatetimeLocalValue(account.next_use_time)
     });
     setShowEditModal(true);
   };
@@ -128,18 +173,22 @@ export default function AdminAccountsPage() {
   const handleEditAccount = async () => {
     if (!selectedAccount) return;
     try {
-      let extraDataJson = {};
+      let extraDataJson: Record<string, any> | null = null;
       try {
-        extraDataJson = JSON.parse(editForm.extra_data_str);
-      } catch (e) {
+        extraDataJson = editForm.extra_data_str.trim() ? JSON.parse(editForm.extra_data_str) : null;
+      } catch {
         toast.error('Extra Data 必须是 JSON 格式');
         return;
       }
-      await api.post('/api/account/add', {
-        pool_name: selectedAccount.pool_name,
-        username: selectedAccount.username,
-        password: editForm.password,
-        extra_data: extraDataJson
+      await api.put('/api/account/update', {
+        pool_name: editForm.pool_name || null,
+        username: editForm.username || null,
+        password: editForm.password || null,
+        extra_data: extraDataJson,
+        status: editForm.status || null,
+        next_use_time: toApiDateTime(editForm.next_use_time)
+      }, {
+        params: { account_id: selectedAccount.id }
       });
       toast.success('修改成功');
       setShowEditModal(false);
@@ -151,10 +200,11 @@ export default function AdminAccountsPage() {
 
   const handleLockClick = async (account: Account, unlock: boolean) => {
     try {
-      await api.post('/api/account/lock', {
-        pool_name: account.pool_name,
-        username: account.username,
-        duration: unlock ? 0 : Number(lockDuration)
+      const nextUseTime = unlock ? null : new Date(Date.now() + Number(lockDuration) * 1000).toISOString();
+      await api.put('/api/account/update', {
+        next_use_time: nextUseTime
+      }, {
+        params: { account_id: account.id }
       });
       toast.success(unlock ? '解锁成功' : '锁定成功');
       fetchAccounts(page);
@@ -163,17 +213,17 @@ export default function AdminAccountsPage() {
     }
   };
 
-  const handleDisable = async (account: Account) => {
-    if (!confirm(`确定要禁用账号 ${account.username} 吗?`)) return;
+  const handleDelete = async (account: Account) => {
+    if (!confirm(`确定要删除账号 ${account.username} 吗?此操作不可恢复。`)) return;
     try {
-      await api.post('/api/account/disable', {
-        pool_name: account.pool_name,
-        username: account.username
+      await api.delete('/api/account/delete', {
+        params: { account_id: account.id }
       });
-      toast.success('禁用成功');
-      fetchAccounts(page);
+      toast.success('删除成功');
+      const nextPage = data.length === 1 && page > 1 ? page - 1 : page;
+      fetchAccounts(nextPage);
     } catch (error) {
-      toast.error('禁用失败');
+      toast.error('删除失败');
     }
   };
 
@@ -226,7 +276,7 @@ export default function AdminAccountsPage() {
         data={data}
         loading={loading}
         onLock={(acc) => { setSelectedAccount(acc); setShowLockModal(true); }}
-        onDisable={handleDisable}
+        onDelete={handleDelete}
         onViewRegistration={(acc) => { setSelectedAccount(acc); setShowRegistrationModal(true); }}
         onEdit={handleEditClick}
       />
@@ -265,6 +315,13 @@ export default function AdminAccountsPage() {
                 <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">Status</label>
+                <select className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white" value={addForm.status} onChange={e => setAddForm({...addForm, status: e.target.value as AccountStatus})}>
+                  <option value="active">active</option>
+                  <option value="disable">disable</option>
+                </select>
+              </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"}' />
@@ -359,14 +416,44 @@ export default function AdminAccountsPage() {
             </div>
             <div className="p-6 space-y-4 max-h-[80vh] overflow-y-auto">
               <div className="text-sm text-slate-600 bg-blue-50 p-3 rounded-lg border border-blue-100">
-                Pool: <span className="font-bold text-slate-900">{selectedAccount.pool_name}</span>
+                ID: <span className="font-bold text-slate-900">#{selectedAccount.id}</span>
+                <br />
+                Created:{' '}
+                <span className="font-bold text-slate-900">
+                  {selectedAccount.created_at ? <LocalTime date={selectedAccount.created_at} /> : '-'}
+                </span>
                 <br />
-                Username: <span className="font-bold text-slate-900">{selectedAccount.username}</span>
+                Updated:{' '}
+                <span className="font-bold text-slate-900">
+                  {selectedAccount.updated_at ? <LocalTime date={selectedAccount.updated_at} /> : '-'}
+                </span>
+              </div>
+              <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={editForm.pool_name} onChange={e => setEditForm({...editForm, 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={editForm.username} onChange={e => setEditForm({...editForm, 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 font-mono" value={editForm.password} onChange={e => setEditForm({...editForm, password: e.target.value})} />
               </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Status</label>
+                <select className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white" value={editForm.status} onChange={e => setEditForm({...editForm, status: e.target.value as AccountStatus})}>
+                  <option value="active">active</option>
+                  <option value="disable">disable</option>
+                </select>
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Next Use Time</label>
+                <input type="datetime-local" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={editForm.next_use_time} onChange={e => setEditForm({...editForm, next_use_time: e.target.value})} />
+                <p className="mt-1 text-xs text-slate-500">
+                  {selectedAccount.next_use_time ? <LocalTime date={selectedAccount.next_use_time} /> : '未设置'}
+                </p>
+              </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={6} value={editForm.extra_data_str} onChange={e => setEditForm({...editForm, extra_data_str: e.target.value})} />
@@ -381,4 +468,4 @@ export default function AdminAccountsPage() {
       )}
     </div>
   );
-}
+}

+ 520 - 0
src/app/admin/proxies/page.tsx

@@ -0,0 +1,520 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Plus, Search, RefreshCw, X, Pencil, Download } from 'lucide-react';
+import api from '@/lib/api';
+import { toast } from 'react-hot-toast';
+import Pagination from '@/components/common/Pagination';
+import LocalTime from '@/components/common/LocalTime';
+import ProxyTable, { ProxyItem } from '@/components/admin/proxies/ProxyTable';
+
+type ProxyStatus = 'active' | 'disable';
+
+function parseUtcDateTime(value?: string | null) {
+  if (!value) return null;
+  const normalized = value.trim().replace(' ', 'T');
+  const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(normalized);
+  const utcValue = hasTimezone ? normalized : `${normalized}Z`;
+  const timestamp = new Date(utcValue).getTime();
+
+  return Number.isNaN(timestamp) ? null : timestamp;
+}
+
+function toDatetimeLocalValue(value?: string | null) {
+  if (!value) return '';
+  const timestamp = parseUtcDateTime(value);
+  if (timestamp === null) return '';
+  const date = new Date(timestamp);
+  const offset = date.getTimezoneOffset();
+  const localDate = new Date(date.getTime() - offset * 60 * 1000);
+  return localDate.toISOString().slice(0, 16);
+}
+
+function toApiDateTime(value: string) {
+  if (!value) return null;
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return null;
+  return date.toISOString();
+}
+
+export default function AdminProxiesPage() {
+  const [loading, setLoading] = useState(true);
+  const [data, setData] = useState<ProxyItem[]>([]);
+  const [page, setPage] = useState(1);
+  const [pageSize] = useState(10);
+  const [total, setTotal] = useState(0);
+  const [keyword, setKeyword] = useState('');
+
+  const [showAddModal, setShowAddModal] = useState(false);
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [showExportModal, setShowExportModal] = useState(false);
+  const [selectedProxy, setSelectedProxy] = useState<ProxyItem | null>(null);
+  const [selectedIds, setSelectedIds] = useState<number[]>([]);
+
+  const [addForm, setAddForm] = useState({
+    pool_name: '',
+    proto: 'http',
+    ip: '',
+    port: 8080,
+    username: '',
+    password: '',
+    status: 'active' as ProxyStatus
+  });
+
+  const [editForm, setEditForm] = useState({
+    pool_name: '',
+    proto: 'http',
+    ip: '',
+    port: 8080,
+    username: '',
+    password: '',
+    status: 'active' as ProxyStatus,
+    next_use_time: ''
+  });
+
+  const fetchProxies = async (targetPage: number = page) => {
+    setLoading(true);
+    try {
+      const res = await api.get('/api/proxy/list_all', {
+        params: {
+          page: targetPage,
+          size: pageSize,
+          keyword
+        }
+      });
+
+      const resData = res.data?.data || {};
+      if (resData && Array.isArray(resData.items)) {
+        setData(resData.items);
+        setTotal(resData.total || 0);
+        setSelectedIds((prev) => prev.filter((id) => resData.items.some((item: ProxyItem) => item.id === id)));
+      } else {
+        setData([]);
+        setTotal(0);
+        setSelectedIds([]);
+      }
+
+      setPage(targetPage);
+
+      if (targetPage > 1) {
+        window.scrollTo({ top: 0, behavior: 'smooth' });
+      }
+    } catch (error) {
+      console.error('Fetch proxies failed', error);
+      toast.error('获取代理列表失败');
+      setData([]);
+      setTotal(0);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchProxies(1);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const handleSearch = () => fetchProxies(1);
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') handleSearch();
+  };
+
+  const handleAddProxy = async () => {
+    try {
+      await api.post('/api/proxy/create', {
+        pool_name: addForm.pool_name,
+        proto: addForm.proto,
+        ip: addForm.ip,
+        port: Number(addForm.port),
+        username: addForm.username || null,
+        password: addForm.password || null,
+        status: addForm.status || null
+      });
+      toast.success('代理创建成功');
+      setShowAddModal(false);
+      setAddForm({
+        pool_name: '',
+        proto: 'http',
+        ip: '',
+        port: 8080,
+        username: '',
+        password: '',
+        status: 'active'
+      });
+      fetchProxies(1);
+    } catch (error) {
+      toast.error('代理创建失败');
+    }
+  };
+
+  const handleEditClick = (proxy: ProxyItem) => {
+    setSelectedProxy(proxy);
+    setEditForm({
+      pool_name: proxy.pool_name || '',
+      proto: proxy.proto || 'http',
+      ip: proxy.ip || '',
+      port: proxy.port || 8080,
+      username: proxy.username || '',
+      password: proxy.password || '',
+      status: (proxy.status?.toLowerCase() === 'disable' ? 'disable' : 'active') as ProxyStatus,
+      next_use_time: toDatetimeLocalValue(proxy.next_use_time)
+    });
+    setShowEditModal(true);
+  };
+
+  const handleEditProxy = async () => {
+    if (!selectedProxy) return;
+    try {
+      await api.put('/api/proxy/update', {
+        pool_name: editForm.pool_name || null,
+        proto: editForm.proto || null,
+        ip: editForm.ip || null,
+        port: Number(editForm.port) || null,
+        username: editForm.username || null,
+        password: editForm.password || null,
+        status: editForm.status || null,
+        next_use_time: toApiDateTime(editForm.next_use_time)
+      }, {
+        params: { proxy_id: selectedProxy.id }
+      });
+      toast.success('代理更新成功');
+      setShowEditModal(false);
+      fetchProxies(page);
+    } catch (error) {
+      toast.error('代理更新失败');
+    }
+  };
+
+  const handleDeleteProxy = async (proxy: ProxyItem) => {
+    if (!confirm(`确定要删除代理 ${proxy.ip}:${proxy.port} 吗?此操作不可恢复。`)) return;
+    try {
+      await api.delete('/api/proxy/delete', {
+        params: { proxy_id: proxy.id }
+      });
+      toast.success('代理删除成功');
+      const nextPage = data.length === 1 && page > 1 ? page - 1 : page;
+      fetchProxies(nextPage);
+    } catch (error) {
+      toast.error('代理删除失败');
+    }
+  };
+
+  const handleToggleSelect = (proxyId: number) => {
+    setSelectedIds((prev) => (
+      prev.includes(proxyId) ? prev.filter((id) => id !== proxyId) : [...prev, proxyId]
+    ));
+  };
+
+  const handleToggleSelectAll = () => {
+    const visibleIds = data.map((item) => item.id);
+    const allSelected = visibleIds.every((id) => selectedIds.includes(id));
+
+    setSelectedIds((prev) => (
+      allSelected
+        ? prev.filter((id) => !visibleIds.includes(id))
+        : Array.from(new Set([...prev, ...visibleIds]))
+    ));
+  };
+
+  const selectedProxies = data.filter((item) => selectedIds.includes(item.id));
+
+  const downloadTextFile = (filename: string, content: string, mimeType: string) => {
+    const blob = new Blob([content], { type: mimeType });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = filename;
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+    URL.revokeObjectURL(url);
+  };
+
+  const handleExport = (format: 'colon' | 'url' | 'json') => {
+    if (selectedProxies.length === 0) {
+      toast.error('请先选择要导出的代理');
+      return;
+    }
+
+    const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
+
+    if (format === 'colon') {
+      const content = selectedProxies.map((item) => (
+        `${item.ip}:${item.port}:${item.username || ''}:${item.password || ''}`
+      )).join('\n');
+      downloadTextFile(`proxies-colon-${timestamp}.txt`, content, 'text/plain;charset=utf-8');
+    }
+
+    if (format === 'url') {
+      const content = selectedProxies.map((item) => {
+        const auth = item.username ? `${encodeURIComponent(item.username)}:${encodeURIComponent(item.password || '')}@` : '';
+        return `${item.proto}://${auth}${item.ip}:${item.port}`;
+      }).join('\n');
+      downloadTextFile(`proxies-url-${timestamp}.txt`, content, 'text/plain;charset=utf-8');
+    }
+
+    if (format === 'json') {
+      downloadTextFile(`proxies-raw-${timestamp}.json`, JSON.stringify(selectedProxies, null, 2), 'application/json;charset=utf-8');
+    }
+
+    setShowExportModal(false);
+    toast.success(`已导出 ${selectedProxies.length} 条代理`);
+  };
+
+  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-72">
+            <input
+              type="text"
+              placeholder="搜索代理池 / IP / 协议..."
+              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={() => fetchProxies(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={() => setShowExportModal(true)}
+            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="导出代理"
+          >
+            <Download size={16} />
+            导出
+          </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>
+
+      <ProxyTable
+        data={data}
+        loading={loading}
+        selectedIds={selectedIds}
+        onToggleSelect={handleToggleSelect}
+        onToggleSelectAll={handleToggleSelectAll}
+        onEdit={handleEditClick}
+        onDelete={handleDeleteProxy}
+      />
+
+      <div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
+        <span>已选择 {selectedProxies.length} 条代理</span>
+        <button
+          onClick={() => setSelectedIds([])}
+          className="text-slate-500 hover:text-slate-700"
+          disabled={selectedProxies.length === 0}
+        >
+          清空选择
+        </button>
+      </div>
+
+      <div className="mt-4">
+        <Pagination
+          currentPage={page}
+          total={total}
+          pageSize={pageSize}
+          onPageChange={fetchProxies}
+        />
+      </div>
+
+      {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 className="grid grid-cols-2 gap-4">
+                <div>
+                  <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Proto</label>
+                  <select className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white" value={addForm.proto} onChange={(e) => setAddForm({ ...addForm, proto: e.target.value })}>
+                    <option value="http">http</option>
+                    <option value="https">https</option>
+                    <option value="socks5">socks5</option>
+                  </select>
+                </div>
+                <div>
+                  <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Port</label>
+                  <input type="number" min="1" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={addForm.port} onChange={(e) => setAddForm({ ...addForm, port: Number(e.target.value) })} />
+                </div>
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">IP</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 font-mono" value={addForm.ip} onChange={(e) => setAddForm({ ...addForm, ip: 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">Status</label>
+                <select className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white" value={addForm.status} onChange={(e) => setAddForm({ ...addForm, status: e.target.value as ProxyStatus })}>
+                  <option value="active">active</option>
+                  <option value="disable">disable</option>
+                </select>
+              </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={handleAddProxy} className="px-4 py-2 text-sm font-bold bg-slate-900 text-white rounded-lg hover:bg-slate-800">确认添加</button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {showEditModal && selectedProxy && (
+        <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-blue-50/50">
+              <h3 className="font-bold text-lg text-blue-900 flex items-center gap-2">
+                <Pencil size={18} /> 编辑代理
+              </h3>
+              <button onClick={() => setShowEditModal(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 className="text-sm text-slate-600 bg-blue-50 p-3 rounded-lg border border-blue-100">
+                ID: <span className="font-bold text-slate-900">#{selectedProxy.id}</span>
+                <br />
+                Created:{' '}
+                <span className="font-bold text-slate-900">
+                  {selectedProxy.created_at ? <LocalTime date={selectedProxy.created_at} /> : '-'}
+                </span>
+                <br />
+                Updated:{' '}
+                <span className="font-bold text-slate-900">
+                  {selectedProxy.updated_at ? <LocalTime date={selectedProxy.updated_at} /> : '-'}
+                </span>
+              </div>
+              <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={editForm.pool_name} onChange={(e) => setEditForm({ ...editForm, pool_name: e.target.value })} />
+              </div>
+              <div className="grid grid-cols-2 gap-4">
+                <div>
+                  <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Proto</label>
+                  <select className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white" value={editForm.proto} onChange={(e) => setEditForm({ ...editForm, proto: e.target.value })}>
+                    <option value="http">http</option>
+                    <option value="https">https</option>
+                    <option value="socks5">socks5</option>
+                  </select>
+                </div>
+                <div>
+                  <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Port</label>
+                  <input type="number" min="1" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: Number(e.target.value) })} />
+                </div>
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">IP</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 font-mono" value={editForm.ip} onChange={(e) => setEditForm({ ...editForm, ip: 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={editForm.username} onChange={(e) => setEditForm({ ...editForm, 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={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Status</label>
+                <select className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm bg-white" value={editForm.status} onChange={(e) => setEditForm({ ...editForm, status: e.target.value as ProxyStatus })}>
+                  <option value="active">active</option>
+                  <option value="disable">disable</option>
+                </select>
+              </div>
+              <div>
+                <label className="block text-xs font-bold uppercase text-slate-500 mb-1.5">Next Use Time</label>
+                <input type="datetime-local" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" value={editForm.next_use_time} onChange={(e) => setEditForm({ ...editForm, next_use_time: e.target.value })} />
+                <p className="mt-1 text-xs text-slate-500">
+                  {selectedProxy.next_use_time ? <LocalTime date={selectedProxy.next_use_time} /> : '未设置'}
+                </p>
+              </div>
+            </div>
+            <div className="px-6 py-4 bg-slate-50 flex justify-end gap-3 border-t">
+              <button onClick={() => setShowEditModal(false)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">取消</button>
+              <button onClick={handleEditProxy} className="px-4 py-2 text-sm font-bold bg-blue-600 text-white rounded-lg hover:bg-blue-700">保存修改</button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {showExportModal && (
+        <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={() => setShowExportModal(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">
+                当前已选择 <span className="font-bold text-slate-900">{selectedProxies.length}</span> 条代理
+              </p>
+              <button
+                onClick={() => handleExport('colon')}
+                className="w-full rounded-lg border border-slate-200 px-4 py-3 text-left hover:bg-slate-50 transition"
+              >
+                <div className="font-medium text-slate-900">导出为 `ip:port:username:password`</div>
+                <div className="text-xs text-slate-500 mt-1">适合纯文本代理池导入</div>
+              </button>
+              <button
+                onClick={() => handleExport('url')}
+                className="w-full rounded-lg border border-slate-200 px-4 py-3 text-left hover:bg-slate-50 transition"
+              >
+                <div className="font-medium text-slate-900">导出为 `proto://username:password@ip:port`</div>
+                <div className="text-xs text-slate-500 mt-1">适合 URL 形式代理配置</div>
+              </button>
+              <button
+                onClick={() => handleExport('json')}
+                className="w-full rounded-lg border border-slate-200 px-4 py-3 text-left hover:bg-slate-50 transition"
+              >
+                <div className="font-medium text-slate-900">导出原始 JSON</div>
+                <div className="text-xs text-slate-500 mt-1">保留后端返回字段,便于二次处理</div>
+              </button>
+            </div>
+            <div className="px-6 py-4 bg-slate-50 flex justify-end border-t">
+              <button onClick={() => setShowExportModal(false)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg">关闭</button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 3 - 2
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, UserCog
+  Menu, X, Server, UserCog, Network
 } from 'lucide-react';
 
 interface MenuItem {
@@ -38,6 +38,7 @@ export default function AdminSidebar() {
     { name: '服务器管理', href: '/admin/remote-server', icon: Server },
     { name: 'Worker 状态', href: '/admin/workers', icon: Server },
     { name: '账号池管理', href: '/admin/accounts', icon: UserCog },
+    { name: '代理管理', href: '/admin/proxies', icon: Network },
   ];
 
   useEffect(() => {
@@ -178,4 +179,4 @@ export default function AdminSidebar() {
       </aside>
     </>
   );
-}
+}

+ 275 - 116
src/components/admin/accounts/AccountTable.tsx

@@ -1,31 +1,67 @@
-import { Lock, Unlock, Timer, Loader2, Ban, Pencil } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { Lock, Unlock, Timer, Loader2, Ban, Pencil, Trash2, Copy } from 'lucide-react';
+import { toast } from 'react-hot-toast';
 
 export interface Account {
   id: number;
   pool_name: string;
   username: string;
   password?: string;
-  lock_until: number;
-  extra_data: Record<string, any>;
-  status: string;
+  extra_data?: Record<string, any> | null;
+  status?: string | null;
+  next_use_time?: string | null;
+  created_at?: string;
+  updated_at?: string;
 }
 
 interface AccountTableProps {
   data: Account[];
   loading: boolean;
   onLock: (account: Account) => void;
-  onDisable: (account: Account) => void;
+  onDelete: (account: Account) => void;
   onViewRegistration: (account: Account) => void;
   onEdit: (account: Account) => void;
 }
 
-export default function AccountTable({ data, loading, onLock, onDisable, onViewRegistration, onEdit }: AccountTableProps) {
-  // 辅助函数:计算剩余时间
-  const getRemainingTime = (until: number): string | null => {
-    if (!until) return null;
-    const now = Date.now();
-    // 兼容秒级/毫秒级
-    const untilMs = until < 1000000000000 ? until * 1000 : until;
+export default function AccountTable({ data, loading, onLock, onDelete, onViewRegistration, onEdit }: AccountTableProps) {
+  const [now, setNow] = useState(() => Date.now());
+
+  const handleCopy = async (value: string | null | undefined, label: string) => {
+    if (!value) {
+      toast.error(`没有可复制的${label}`);
+      return;
+    }
+
+    try {
+      await navigator.clipboard.writeText(value);
+      toast.success(`${label}已复制`);
+    } catch (error) {
+      toast.error(`${label}复制失败`);
+    }
+  };
+
+  useEffect(() => {
+    const timer = window.setInterval(() => {
+      setNow(Date.now());
+    }, 1000);
+    return () => window.clearInterval(timer);
+  }, []);
+
+  const parseUtcDateTime = (value?: string | null) => {
+    if (!value) return null;
+
+    const normalized = value.trim().replace(' ', 'T');
+    const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(normalized);
+    const utcValue = hasTimezone ? normalized : `${normalized}Z`;
+    const timestamp = new Date(utcValue).getTime();
+
+    return Number.isNaN(timestamp) ? null : timestamp;
+  };
+
+  const getRemainingTime = (nextUseTime?: string | null): string | null => {
+    if (!nextUseTime) return null;
+    const untilMs = parseUtcDateTime(nextUseTime);
+    if (untilMs === null) return null;
     if (untilMs <= now) return null;
 
     const diffSeconds = Math.floor((untilMs - now) / 1000);
@@ -40,6 +76,30 @@ export default function AccountTable({ data, loading, onLock, onDisable, onViewR
     return `${seconds}s`;
   };
 
+  const renderStatusBadge = (isDisabled: boolean, isLocked: boolean) => {
+    if (isDisabled) {
+      return (
+        <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>
+      );
+    }
+
+    if (isLocked) {
+      return (
+        <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>
+      );
+    }
+
+    return (
+      <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>
+    );
+  };
+
   if (loading) {
     return (
       <div className="bg-white border border-slate-200 rounded-xl shadow-sm p-20 flex justify-center items-center">
@@ -60,120 +120,219 @@ export default function AccountTable({ data, loading, onLock, onDisable, onViewR
   }
 
   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">账号池</th>
-              <th className="px-6 py-4 font-semibold text-slate-700">用户名</th>
-              <th className="px-6 py-4 font-semibold text-slate-700">密码</th>
-              <th className="px-6 py-4 font-semibold text-slate-700">状态</th>
-              <th className="px-6 py-4 font-semibold text-slate-700">解锁剩余时间</th>
-              <th className="px-6 py-4 font-semibold text-slate-700">注册信息</th>
-              <th className="px-6 py-4 font-semibold text-slate-700 text-right">操作</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
+    <div className="space-y-4">
+      <div className="hidden md:block 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">账号池</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">用户名</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">密码</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">状态</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">解锁剩余时间</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">注册信息</th>
+                <th className="px-6 py-4 font-semibold text-slate-700 text-right">操作</th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-slate-100">
+              {data.map((item) => {
+                const remainingTime = getRemainingTime(item.next_use_time);
+                const isLocked = remainingTime !== null;
+                const isDisabled = item.status?.toLowerCase() === 'disable';
+
+                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">
-                    {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}
+                    </td>
+                    <td className="px-6 py-4">
+                      <div className="flex items-center gap-2">
+                        <span className="text-slate-700 font-medium">{item.username}</span>
+                        <button
+                          onClick={() => handleCopy(item.username, '账号')}
+                          className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition"
+                          title="复制账号"
+                        >
+                          <Copy size={14} />
+                        </button>
                       </div>
-                    ) : (
-                      <span className="text-slate-300 pl-4">-</span>
-                    )}
-                  </td>
+                    </td>
+                    <td className="px-6 py-4 text-slate-600 font-mono text-xs select-all">
+                      {item.password ? (
+                        <div className="flex items-center gap-2">
+                          <span>{item.password}</span>
+                          <button
+                            onClick={() => handleCopy(item.password, '密码')}
+                            className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition"
+                            title="复制密码"
+                          >
+                            <Copy size={14} />
+                          </button>
+                        </div>
+                      ) : (
+                        <span className="text-slate-300 italic">N/A</span>
+                      )}
+                    </td>
+                    <td className="px-6 py-4">{renderStatusBadge(isDisabled, isLocked)}</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">
+                      <button
+                        onClick={() => onViewRegistration(item)}
+                        className="text-blue-600 hover:text-blue-800 text-xs underline"
+                        title="查看注册信息"
+                      >
+                        查看
+                      </button>
+                    </td>
+                    <td className="px-6 py-4 text-right">
+                      <div className="flex items-center justify-end gap-2">
+                        <button
+                          onClick={() => onEdit(item)}
+                          className="p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition"
+                          title="编辑账号"
+                        >
+                          <Pencil size={16} />
+                        </button>
+                        <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={() => onDelete(item)}
+                          className="p-2 text-slate-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition"
+                          title="删除账号"
+                        >
+                          <Trash2 size={16} />
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div className="md:hidden space-y-4">
+        {data.map((item) => {
+          const remainingTime = getRemainingTime(item.next_use_time);
+          const isLocked = remainingTime !== null;
+          const isDisabled = item.status?.toLowerCase() === 'disable';
 
-                  <td className="px-6 py-4">
+          return (
+            <div key={item.id} className="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
+              <div className="flex items-start justify-between gap-3 mb-4">
+                <div>
+                  <div className="text-xs font-mono text-slate-400">#{item.id}</div>
+                  <div className="mt-1 inline-flex items-center bg-slate-100 px-2 py-1 rounded text-xs border border-slate-200 text-slate-700">
+                    {item.pool_name}
+                  </div>
+                </div>
+                <div>{renderStatusBadge(isDisabled, isLocked)}</div>
+              </div>
+
+              <div className="space-y-3 bg-slate-50 p-3 rounded-lg border border-slate-100 mb-4">
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">账号</span>
+                  <div className="flex items-center gap-2 min-w-0">
+                    <span className="text-sm font-medium text-slate-800 truncate">{item.username}</span>
                     <button
-                      onClick={() => onViewRegistration(item)}
-                      className="text-blue-600 hover:text-blue-800 text-xs underline"
-                      title="查看注册信息"
+                      onClick={() => handleCopy(item.username, '账号')}
+                      className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition"
+                      title="复制账号"
                     >
-                      查看
+                      <Copy size={14} />
                     </button>
-                  </td>
-
-<td className="px-6 py-4 text-right">
-                    <div className="flex items-center justify-end gap-2">
+                  </div>
+                </div>
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">密码</span>
+                  {item.password ? (
+                    <div className="flex items-center gap-2 min-w-0">
+                      <span className="text-sm font-mono text-slate-800 truncate">{item.password}</span>
                       <button
-                        onClick={() => onEdit(item)}
-                        className="p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition"
-                        title="编辑账号"
+                        onClick={() => handleCopy(item.password, '密码')}
+                        className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition"
+                        title="复制密码"
                       >
-                        <Pencil size={16} />
-                      </button>
-                      <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} />
+                        <Copy size={14} />
                       </button>
                     </div>
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
+                  ) : (
+                    <span className="text-slate-300 italic text-sm">N/A</span>
+                  )}
+                </div>
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">剩余解锁时间</span>
+                  {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 border border-orange-100 text-xs">
+                      <Timer size={14} />
+                      {remainingTime}
+                    </div>
+                  ) : (
+                    <span className="text-slate-300 text-sm">-</span>
+                  )}
+                </div>
+              </div>
+
+              <div className="grid grid-cols-2 gap-2">
+                <button
+                  onClick={() => onViewRegistration(item)}
+                  className="flex items-center justify-center py-2.5 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium border border-slate-200"
+                >
+                  查看注册信息
+                </button>
+                <button
+                  onClick={() => onEdit(item)}
+                  className="flex items-center justify-center gap-2 py-2.5 bg-blue-50 text-blue-600 rounded-lg text-sm font-medium border border-blue-100"
+                >
+                  <Pencil size={14} /> 编辑
+                </button>
+                <button
+                  onClick={() => onLock(item)}
+                  disabled={isDisabled}
+                  className={`flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium border ${
+                    isDisabled
+                      ? 'bg-slate-100 text-slate-300 border-slate-200 cursor-not-allowed'
+                      : 'bg-orange-50 text-orange-600 border-orange-100'
+                  }`}
+                >
+                  <Lock size={14} /> 锁定
+                </button>
+                <button
+                  onClick={() => onDelete(item)}
+                  className="flex items-center justify-center gap-2 py-2.5 bg-red-50 text-red-600 rounded-lg text-sm font-medium border border-red-100"
+                >
+                  <Trash2 size={14} /> 删除
+                </button>
+              </div>
+            </div>
+          );
+        })}
       </div>
     </div>
   );
-}
+}

+ 287 - 0
src/components/admin/proxies/ProxyTable.tsx

@@ -0,0 +1,287 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Loader2, Unlock, Lock, Ban, Pencil, Trash2 } from 'lucide-react';
+
+export interface ProxyItem {
+  id: number;
+  pool_name: string;
+  proto: string;
+  ip: string;
+  port: number;
+  username?: string | null;
+  password?: string | null;
+  next_use_time?: string | null;
+  status?: string | null;
+  created_at?: string;
+  updated_at?: string;
+}
+
+interface ProxyTableProps {
+  data: ProxyItem[];
+  loading: boolean;
+  selectedIds: number[];
+  onToggleSelect: (proxyId: number) => void;
+  onToggleSelectAll: () => void;
+  onEdit: (proxy: ProxyItem) => void;
+  onDelete: (proxy: ProxyItem) => void;
+}
+
+function parseUtcDateTime(value?: string | null) {
+  if (!value) return null;
+
+  const normalized = value.trim().replace(' ', 'T');
+  const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(normalized);
+  const utcValue = hasTimezone ? normalized : `${normalized}Z`;
+  const timestamp = new Date(utcValue).getTime();
+
+  return Number.isNaN(timestamp) ? null : timestamp;
+}
+
+export default function ProxyTable({ data, loading, selectedIds, onToggleSelect, onToggleSelectAll, onEdit, onDelete }: ProxyTableProps) {
+  const [now, setNow] = useState(() => Date.now());
+
+  useEffect(() => {
+    const timer = window.setInterval(() => {
+      setNow(Date.now());
+    }, 1000);
+    return () => window.clearInterval(timer);
+  }, []);
+
+  const getRemainingTime = (nextUseTime?: string | null): string | null => {
+    if (!nextUseTime) return null;
+    const untilMs = parseUtcDateTime(nextUseTime);
+    if (untilMs === null || 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`;
+  };
+
+  const renderStatusBadge = (isDisabled: boolean, isLocked: boolean) => {
+    if (isDisabled) {
+      return (
+        <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>
+      );
+    }
+
+    if (isLocked) {
+      return (
+        <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>
+      );
+    }
+
+    return (
+      <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>
+    );
+  };
+
+  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>
+    );
+  }
+
+  const allSelected = data.length > 0 && data.every((item) => selectedIds.includes(item.id));
+
+  return (
+    <div className="space-y-4">
+      <div className="hidden md:block 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-4 py-4 font-semibold text-slate-700">
+                  <input
+                    type="checkbox"
+                    checked={allSelected}
+                    onChange={onToggleSelectAll}
+                    className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
+                    aria-label="全选代理"
+                  />
+                </th>
+                <th className="px-6 py-4 font-semibold text-slate-700">ID</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">代理池</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">协议</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">地址</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">鉴权</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">状态</th>
+                <th className="px-6 py-4 font-semibold text-slate-700">剩余时间</th>
+                <th className="px-6 py-4 font-semibold text-slate-700 text-right">操作</th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-slate-100">
+              {data.map((item) => {
+                const remainingTime = getRemainingTime(item.next_use_time);
+                const isLocked = remainingTime !== null;
+                const isDisabled = item.status?.toLowerCase() === 'disable';
+
+                return (
+                  <tr key={item.id} className="hover:bg-slate-50/50 transition-colors">
+                    <td className="px-4 py-4">
+                      <input
+                        type="checkbox"
+                        checked={selectedIds.includes(item.id)}
+                        onChange={() => onToggleSelect(item.id)}
+                        className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
+                        aria-label={`选择代理 ${item.ip}:${item.port}`}
+                      />
+                    </td>
+                    <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 uppercase font-medium text-slate-700">{item.proto}</td>
+                    <td className="px-6 py-4 font-mono text-slate-700">{item.ip}:{item.port}</td>
+                    <td className="px-6 py-4 text-slate-600">
+                      {item.username ? (
+                        <span className="font-mono text-xs">{item.username}{item.password ? ' / ******' : ''}</span>
+                      ) : (
+                        <span className="text-slate-300 italic">N/A</span>
+                      )}
+                    </td>
+                    <td className="px-6 py-4">{renderStatusBadge(isDisabled, isLocked)}</td>
+                    <td className="px-6 py-4">
+                      {isLocked ? (
+                        <div className="inline-flex items-center gap-1.5 text-orange-600 font-mono font-bold bg-orange-50 px-2 py-1 rounded border border-orange-100 text-xs">
+                          <Lock size={12} />
+                          {remainingTime}
+                        </div>
+                      ) : (
+                        <span className="text-slate-300">-</span>
+                      )}
+                    </td>
+                    <td className="px-6 py-4 text-right">
+                      <div className="flex items-center justify-end gap-2">
+                        <button
+                          onClick={() => onEdit(item)}
+                          className="p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition"
+                          title="编辑代理"
+                        >
+                          <Pencil size={16} />
+                        </button>
+                        <button
+                          onClick={() => onDelete(item)}
+                          className="p-2 text-slate-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition"
+                          title="删除代理"
+                        >
+                          <Trash2 size={16} />
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <div className="md:hidden space-y-4">
+        {data.map((item) => {
+          const remainingTime = getRemainingTime(item.next_use_time);
+          const isLocked = remainingTime !== null;
+          const isDisabled = item.status?.toLowerCase() === 'disable';
+
+          return (
+            <div key={item.id} className="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
+              <div className="flex items-start justify-between gap-3 mb-4">
+                <div className="flex items-start gap-3">
+                  <input
+                    type="checkbox"
+                    checked={selectedIds.includes(item.id)}
+                    onChange={() => onToggleSelect(item.id)}
+                    className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
+                    aria-label={`选择代理 ${item.ip}:${item.port}`}
+                  />
+                  <div>
+                    <div className="text-xs font-mono text-slate-400">#{item.id}</div>
+                    <div className="mt-1 inline-flex items-center bg-slate-100 px-2 py-1 rounded text-xs border border-slate-200 text-slate-700">
+                      {item.pool_name}
+                    </div>
+                  </div>
+                </div>
+                <div>{renderStatusBadge(isDisabled, isLocked)}</div>
+              </div>
+
+              <div className="space-y-3 bg-slate-50 p-3 rounded-lg border border-slate-100 mb-4">
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">协议</span>
+                  <span className="text-sm font-medium uppercase text-slate-800">{item.proto}</span>
+                </div>
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">地址</span>
+                  <span className="text-sm font-mono text-slate-800 text-right break-all">{item.ip}:{item.port}</span>
+                </div>
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">鉴权</span>
+                  {item.username ? (
+                    <span className="text-sm font-mono text-slate-800 text-right break-all">
+                      {item.username}{item.password ? ' / ******' : ''}
+                    </span>
+                  ) : (
+                    <span className="text-slate-300 italic text-sm">N/A</span>
+                  )}
+                </div>
+                <div className="flex items-center justify-between gap-3">
+                  <span className="text-xs text-slate-500">剩余时间</span>
+                  {isLocked ? (
+                    <div className="inline-flex items-center gap-1.5 text-orange-600 font-mono font-bold bg-orange-50 px-2 py-1 rounded border border-orange-100 text-xs">
+                      <Lock size={12} />
+                      {remainingTime}
+                    </div>
+                  ) : (
+                    <span className="text-slate-300 text-sm">-</span>
+                  )}
+                </div>
+              </div>
+
+              <div className="grid grid-cols-2 gap-2">
+                <button
+                  onClick={() => onEdit(item)}
+                  className="flex items-center justify-center gap-2 py-2.5 bg-blue-50 text-blue-600 rounded-lg text-sm font-medium border border-blue-100"
+                >
+                  <Pencil size={14} /> 编辑
+                </button>
+                <button
+                  onClick={() => onDelete(item)}
+                  className="flex items-center justify-center gap-2 py-2.5 bg-red-50 text-red-600 rounded-lg text-sm font-medium border border-red-100"
+                >
+                  <Trash2 size={14} /> 删除
+                </button>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}