import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router"; import { useDataProvider, useList, useOne } from "@refinedev/core"; import { Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader, PageState, Select, StatusPill, Table, conflictMessage, } from "./lib/ui.jsx"; import { useTitle } from "./lib/hooks.js"; const createInitial = { protocol: "socks5", host: "", port: "", username: "", password: "", expected_public_ip: "", expected_region: "", }; const healthText = { unchecked: "未检测", healthy: "健康", unhealthy: "不健康", disabled: "已停用", }; const protocolOptions = ["http", "https", "socks4", "socks5"].map((value) => ({ value, label: value, })); function ExitHealthPill({ exit }) { const known = Object.hasOwn(healthText, exit.health_status); const tone = exit.health_status === "healthy" ? "success" : exit.health_status === "disabled" ? "neutral" : exit.health_status === "unchecked" ? "warning" : "danger"; return ( ); } function ExitActions({ exit, busy, onAction, onEdit, onDelete }) { const disabled = busy; return (
{exit.health_status === "disabled" ? ( ) : ( )}
); } function ExitCreateModal({ open, onClose, onSubmit, busy, error, initialValue = createInitial, title = "创建网络出口", submitLabel = "创建网络出口", }) { const [form, setForm] = useState({ ...createInitial, ...initialValue }); useEffect(() => { if (open) setForm({ ...createInitial, ...initialValue, port: initialValue.port ? String(initialValue.port) : "", }); }, [open, initialValue]); const update = (key, value) => setForm((current) => ({ ...current, [key]: value })); const port = Number(form.port); const credentialsValid = !form.password || !!form.username; const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535 && credentialsValid; async function submit(event) { event.preventDefault(); if (!valid) return; const created = await onSubmit({ protocol: form.protocol, host: form.host.trim(), port, username: form.username, password: form.password, expected_public_ip: form.expected_public_ip.trim(), expected_region: form.expected_region.trim(), }); if (created) { setForm(createInitial); onClose(); } } return ( } >
{error ? ( {conflictMessage( error, "出口地址或认证信息与现有资源冲突;表单内容已保留。", )} ) : null}
update("host", event.target.value)} placeholder="如 proxy.example" /> update("port", event.target.value)} /> update("username", event.target.value)} /> update("password", event.target.value)} invalid={!credentialsValid} /> update("expected_public_ip", event.target.value) } /> update("expected_region", event.target.value) } />
); } function ExitTable({ exits, accountsByExit, bindingsError, busy, onAction, onEdit, onDelete, }) { const columns = [ { header: "出口", width: "22%", render: (exit) => (
{exit.id}

{exit.protocol}://{exit.host}:{exit.port}

), }, { header: "状态", width: "9%", render: (exit) => , }, { header: "用户名", width: "10%", render: (exit) => ( {exit.username || "无"} ), }, { header: "密码", width: "10%", render: (exit) => ( {exit.password || "无"} ), }, { header: "出口IP", width: "13%", render: (exit) => ( {exit.observed_public_ip || "尚无观测"} ), }, { header: "最近检测", width: "15%", render: (exit) => exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString("zh-CN") : "未检测", }, { header: "绑定账号", width: "13%", render: (exit) => { if (bindingsError) return 状态未知; const accounts = accountsByExit.get(exit.id); return ( {accounts?.length ? accounts.join("、") : "未绑定"} ); }, }, { header: "操作", width: "8%", align: "right", render: (exit) => ( ), }, ]; return ( exit.id} minWidth="1100px" /> ); } export function NetworkExitList() { const dataProvider = useDataProvider()("default"); const [createOpen, setCreateOpen] = useState(false); const [busy, setBusy] = useState(""); const [createError, setCreateError] = useState(null); const [notice, setNotice] = useState(null); const [disabling, setDisabling] = useState(null); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const { result, query } = useList({ resource: "network-exits" }); const error = query.error; const isPending = query.isPending; const exits = result.data ?? []; const { result: bindingResult, query: browsersQuery } = useList({ resource: "browsers", queryOptions: { retry: false }, }); const browsers = bindingResult.data ?? []; const bindingsError = browsersQuery.error; const refetchBrowsers = browsersQuery.refetch; const accountsByExit = useMemo(() => { const map = new Map(); for (const runtime of browsers) { if (!runtime.network_exit_id || !runtime.account_id) continue; map.set(runtime.network_exit_id, [ ...(map.get(runtime.network_exit_id) || []), runtime.account_id, ]); } return map; }, [browsers]); useTitle("CreatorHub · 网络出口"); async function createExit(data) { setBusy("create"); setCreateError(null); try { await dataProvider.create({ resource: "network-exits", variables: data }); await query.refetch(); setNotice({ variant: "success", text: "网络出口已创建;检测通过后才可用于账号绑定。", }); return true; } catch (reason) { setCreateError(reason); return false; } finally { setBusy(""); } } async function updateExit(exit, data) { setBusy(`edit:${exit.id}`); setNotice(null); try { await dataProvider.update({ resource: "network-exits", id: exit.id, variables: data, }); await query.refetch(); setEditing(null); setNotice({ variant: "success", text: "网络出口已更新,需重新检测后才能使用。", }); return true; } catch (reason) { setNotice({ variant: "destructive", text: conflictMessage(reason, "出口当前不能修改。"), }); return false; } finally { setBusy(""); } } async function deleteExit(exit) { setBusy(`delete:${exit.id}`); setNotice(null); try { await dataProvider.delete({ resource: "network-exits", id: exit.id }); await query.refetch(); setDeleting(null); setNotice({ variant: "success", text: "网络出口已删除。" }); } catch (reason) { setNotice({ variant: "destructive", text: conflictMessage(reason, "出口仍有绑定或运行实例,不能删除。"), }); } finally { setBusy(""); } } async function runAction(exit, action) { setBusy(exit.id); setNotice(null); try { await dataProvider.networkExitAction(exit.id, action); await Promise.all([query.refetch(), refetchBrowsers()]); setNotice({ variant: "success", text: action === "check" ? "出口检测完成。" : action === "enable" ? "出口已启用;重新检测通过后才能绑定。" : "出口已停用;绑定账号不会自动恢复。", }); } catch (reason) { setNotice({ variant: "destructive", text: conflictMessage( reason, action === "check" ? "已停用的出口不能检测。" : "出口当前不能停用。", ), }); } finally { setBusy(""); } } return (
{notice ? ( {notice.text} ) : null} {error ? ( {error.message} ) : null} {bindingsError ? ( 环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""} :无法读取账号绑定,状态暂时未知。 ) : null} action === "disable" ? setDisabling(exit) : runAction(exit, action) } onEdit={(exit) => setEditing(exit)} onDelete={(exit) => setDeleting(exit)} /> { setCreateOpen(false); setCreateError(null); }} onSubmit={createExit} busy={busy === "create"} error={createError} /> setEditing(null)} onSubmit={(data) => updateExit(editing, data)} busy={busy === `edit:${editing?.id}`} error={null} initialValue={editing || createInitial} title="编辑网络出口" submitLabel="保存网络出口" /> setDeleting(null)} onConfirm={() => deleteExit(deleting)} title="删除网络出口" confirmLabel="确认删除" busy={busy === `delete:${deleting?.id}`} body={`删除网络出口 ${deleting?.id}?有绑定或运行实例时会拒绝。`} /> setDisabling(null)} onConfirm={() => { const exit = disabling; setDisabling(null); runAction(exit, "disable"); }} title="停用网络出口" confirmLabel="确认停用" busy={busy === disabling?.id} body={`停用网络出口 ${disabling?.id}?绑定账号将暂停,排队任务进入 hold。`} />
); } export function NetworkExitDetail() { const { id } = useParams(); const { result, error, isPending } = useOne({ resource: "network-exits", id, queryOptions: { retry: false }, }); const exit = result; useTitle("CreatorHub · 出口详情"); if (isPending) return ; if (error || !exit) return ( {error?.message || "网络出口不存在"} ); return (
{exit.protocol}://{exit.host}:{exit.port} } > {exit.id}], [ "健康 / 版本", {healthText[exit.health_status] ?? exit.health_status} ·{" "} {exit.version} , ], [ "用户名", {exit.username || "无"}, ], [ "密码", {exit.password || "无"}, ], [ "出口IP", {exit.observed_public_ip || "尚无观测"} , ], [ "最近检测", exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString("zh-CN") : "未检测", ], ]} />
); }