ProxyTable.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { Loader2, Unlock, Lock, Ban, Pencil, Trash2 } from 'lucide-react';
  4. export interface ProxyItem {
  5. id: number;
  6. pool_name: string;
  7. proto: string;
  8. ip: string;
  9. port: number;
  10. username?: string | null;
  11. password?: string | null;
  12. next_use_time?: string | null;
  13. status?: string | null;
  14. created_at?: string;
  15. updated_at?: string;
  16. }
  17. interface ProxyTableProps {
  18. data: ProxyItem[];
  19. loading: boolean;
  20. selectedIds: number[];
  21. onToggleSelect: (proxyId: number) => void;
  22. onToggleSelectAll: () => void;
  23. onEdit: (proxy: ProxyItem) => void;
  24. onDelete: (proxy: ProxyItem) => void;
  25. }
  26. function parseUtcDateTime(value?: string | null) {
  27. if (!value) return null;
  28. const normalized = value.trim().replace(' ', 'T');
  29. const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(normalized);
  30. const utcValue = hasTimezone ? normalized : `${normalized}Z`;
  31. const timestamp = new Date(utcValue).getTime();
  32. return Number.isNaN(timestamp) ? null : timestamp;
  33. }
  34. export default function ProxyTable({ data, loading, selectedIds, onToggleSelect, onToggleSelectAll, onEdit, onDelete }: ProxyTableProps) {
  35. const [now, setNow] = useState(() => Date.now());
  36. useEffect(() => {
  37. const timer = window.setInterval(() => {
  38. setNow(Date.now());
  39. }, 1000);
  40. return () => window.clearInterval(timer);
  41. }, []);
  42. const getRemainingTime = (nextUseTime?: string | null): string | null => {
  43. if (!nextUseTime) return null;
  44. const untilMs = parseUtcDateTime(nextUseTime);
  45. if (untilMs === null || untilMs <= now) return null;
  46. const diffSeconds = Math.floor((untilMs - now) / 1000);
  47. const days = Math.floor(diffSeconds / (3600 * 24));
  48. const hours = Math.floor((diffSeconds % (3600 * 24)) / 3600);
  49. const minutes = Math.floor((diffSeconds % 3600) / 60);
  50. const seconds = diffSeconds % 60;
  51. if (days > 0) return `${days}d ${hours}h`;
  52. if (hours > 0) return `${hours}h ${minutes}m`;
  53. if (minutes > 0) return `${minutes}m ${seconds}s`;
  54. return `${seconds}s`;
  55. };
  56. const renderStatusBadge = (isDisabled: boolean, isLocked: boolean) => {
  57. if (isDisabled) {
  58. return (
  59. <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">
  60. <Ban size={12} /> Disabled
  61. </span>
  62. );
  63. }
  64. if (isLocked) {
  65. return (
  66. <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">
  67. <Lock size={12} /> Locked
  68. </span>
  69. );
  70. }
  71. return (
  72. <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">
  73. <Unlock size={12} /> Active
  74. </span>
  75. );
  76. };
  77. if (loading) {
  78. return (
  79. <div className="bg-white border border-slate-200 rounded-xl shadow-sm p-20 flex justify-center items-center">
  80. <div className="flex flex-col items-center text-slate-500">
  81. <Loader2 className="w-8 h-8 animate-spin mb-2 text-blue-600" />
  82. <span className="text-sm">加载数据中...</span>
  83. </div>
  84. </div>
  85. );
  86. }
  87. if (data.length === 0) {
  88. return (
  89. <div className="bg-white border border-slate-200 rounded-xl shadow-sm p-20 flex justify-center items-center">
  90. <span className="text-slate-500 text-sm">暂无数据</span>
  91. </div>
  92. );
  93. }
  94. const allSelected = data.length > 0 && data.every((item) => selectedIds.includes(item.id));
  95. return (
  96. <div className="space-y-4">
  97. <div className="hidden md:block bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
  98. <div className="overflow-x-auto w-full">
  99. <table className="w-full text-left text-sm whitespace-nowrap">
  100. <thead className="bg-slate-50 border-b border-slate-200">
  101. <tr>
  102. <th className="px-4 py-4 font-semibold text-slate-700">
  103. <input
  104. type="checkbox"
  105. checked={allSelected}
  106. onChange={onToggleSelectAll}
  107. className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
  108. aria-label="全选代理"
  109. />
  110. </th>
  111. <th className="px-6 py-4 font-semibold text-slate-700">ID</th>
  112. <th className="px-6 py-4 font-semibold text-slate-700">代理池</th>
  113. <th className="px-6 py-4 font-semibold text-slate-700">协议</th>
  114. <th className="px-6 py-4 font-semibold text-slate-700">地址</th>
  115. <th className="px-6 py-4 font-semibold text-slate-700">鉴权</th>
  116. <th className="px-6 py-4 font-semibold text-slate-700">状态</th>
  117. <th className="px-6 py-4 font-semibold text-slate-700">剩余时间</th>
  118. <th className="px-6 py-4 font-semibold text-slate-700 text-right">操作</th>
  119. </tr>
  120. </thead>
  121. <tbody className="divide-y divide-slate-100">
  122. {data.map((item) => {
  123. const remainingTime = getRemainingTime(item.next_use_time);
  124. const isLocked = remainingTime !== null;
  125. const isDisabled = item.status?.toLowerCase() === 'disable';
  126. return (
  127. <tr key={item.id} className="hover:bg-slate-50/50 transition-colors">
  128. <td className="px-4 py-4">
  129. <input
  130. type="checkbox"
  131. checked={selectedIds.includes(item.id)}
  132. onChange={() => onToggleSelect(item.id)}
  133. className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
  134. aria-label={`选择代理 ${item.ip}:${item.port}`}
  135. />
  136. </td>
  137. <td className="px-6 py-4 text-slate-500 font-mono">#{item.id}</td>
  138. <td className="px-6 py-4 font-medium text-slate-900">
  139. <span className="bg-slate-100 px-2 py-1 rounded text-xs border border-slate-200">
  140. {item.pool_name}
  141. </span>
  142. </td>
  143. <td className="px-6 py-4 uppercase font-medium text-slate-700">{item.proto}</td>
  144. <td className="px-6 py-4 font-mono text-slate-700">{item.ip}:{item.port}</td>
  145. <td className="px-6 py-4 text-slate-600">
  146. {item.username ? (
  147. <span className="font-mono text-xs">{item.username}{item.password ? ' / ******' : ''}</span>
  148. ) : (
  149. <span className="text-slate-300 italic">N/A</span>
  150. )}
  151. </td>
  152. <td className="px-6 py-4">{renderStatusBadge(isDisabled, isLocked)}</td>
  153. <td className="px-6 py-4">
  154. {isLocked ? (
  155. <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">
  156. <Lock size={12} />
  157. {remainingTime}
  158. </div>
  159. ) : (
  160. <span className="text-slate-300">-</span>
  161. )}
  162. </td>
  163. <td className="px-6 py-4 text-right">
  164. <div className="flex items-center justify-end gap-2">
  165. <button
  166. onClick={() => onEdit(item)}
  167. className="p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition"
  168. title="编辑代理"
  169. >
  170. <Pencil size={16} />
  171. </button>
  172. <button
  173. onClick={() => onDelete(item)}
  174. className="p-2 text-slate-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition"
  175. title="删除代理"
  176. >
  177. <Trash2 size={16} />
  178. </button>
  179. </div>
  180. </td>
  181. </tr>
  182. );
  183. })}
  184. </tbody>
  185. </table>
  186. </div>
  187. </div>
  188. <div className="md:hidden space-y-4">
  189. {data.map((item) => {
  190. const remainingTime = getRemainingTime(item.next_use_time);
  191. const isLocked = remainingTime !== null;
  192. const isDisabled = item.status?.toLowerCase() === 'disable';
  193. return (
  194. <div key={item.id} className="bg-white p-4 rounded-xl shadow-sm border border-slate-200">
  195. <div className="flex items-start justify-between gap-3 mb-4">
  196. <div className="flex items-start gap-3">
  197. <input
  198. type="checkbox"
  199. checked={selectedIds.includes(item.id)}
  200. onChange={() => onToggleSelect(item.id)}
  201. className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
  202. aria-label={`选择代理 ${item.ip}:${item.port}`}
  203. />
  204. <div>
  205. <div className="text-xs font-mono text-slate-400">#{item.id}</div>
  206. <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">
  207. {item.pool_name}
  208. </div>
  209. </div>
  210. </div>
  211. <div>{renderStatusBadge(isDisabled, isLocked)}</div>
  212. </div>
  213. <div className="space-y-3 bg-slate-50 p-3 rounded-lg border border-slate-100 mb-4">
  214. <div className="flex items-center justify-between gap-3">
  215. <span className="text-xs text-slate-500">协议</span>
  216. <span className="text-sm font-medium uppercase text-slate-800">{item.proto}</span>
  217. </div>
  218. <div className="flex items-center justify-between gap-3">
  219. <span className="text-xs text-slate-500">地址</span>
  220. <span className="text-sm font-mono text-slate-800 text-right break-all">{item.ip}:{item.port}</span>
  221. </div>
  222. <div className="flex items-center justify-between gap-3">
  223. <span className="text-xs text-slate-500">鉴权</span>
  224. {item.username ? (
  225. <span className="text-sm font-mono text-slate-800 text-right break-all">
  226. {item.username}{item.password ? ' / ******' : ''}
  227. </span>
  228. ) : (
  229. <span className="text-slate-300 italic text-sm">N/A</span>
  230. )}
  231. </div>
  232. <div className="flex items-center justify-between gap-3">
  233. <span className="text-xs text-slate-500">剩余时间</span>
  234. {isLocked ? (
  235. <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">
  236. <Lock size={12} />
  237. {remainingTime}
  238. </div>
  239. ) : (
  240. <span className="text-slate-300 text-sm">-</span>
  241. )}
  242. </div>
  243. </div>
  244. <div className="grid grid-cols-2 gap-2">
  245. <button
  246. onClick={() => onEdit(item)}
  247. 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"
  248. >
  249. <Pencil size={14} /> 编辑
  250. </button>
  251. <button
  252. onClick={() => onDelete(item)}
  253. 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"
  254. >
  255. <Trash2 size={14} /> 删除
  256. </button>
  257. </div>
  258. </div>
  259. );
  260. })}
  261. </div>
  262. </div>
  263. );
  264. }