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