790 lines
32 KiB
TypeScript
790 lines
32 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
detectV2raynDbPath, getActiveV2raynNode, scanV2raynNodes, buildShareLink,
|
||
syncWslProxy, getWslProxyStatus, clearWslProxy,
|
||
testWslProxyIp, testWinProxyIp,
|
||
getSettings, updateSettings,
|
||
CONFIG_TYPE_LABEL,
|
||
type ActiveV2raynNode, type V2raynNode, type WslProxyStatus,
|
||
} from "../../lib/commands";
|
||
import { useUIStore } from "../../store/ui";
|
||
|
||
const inputCls = "flex-1 border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:ring-2 focus:ring-blue-200";
|
||
|
||
// ── Win 代理配置卡片 ──────────────────────────────────────────────────────────
|
||
|
||
function WinProxyCard({ onPathSaved }: { onPathSaved: (path: string) => void }) {
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
const qc = useQueryClient();
|
||
|
||
const { data: settings } = useQuery({
|
||
queryKey: ["settings", undefined],
|
||
queryFn: () => getSettings(undefined),
|
||
staleTime: 30000,
|
||
});
|
||
|
||
const [dbPath, setDbPath] = useState("");
|
||
const [detecting, setDetecting] = useState(false);
|
||
const [loadingNode, setLoadingNode] = useState(false);
|
||
const [activeNode, setActiveNode] = useState<ActiveV2raynNode | null>(null);
|
||
|
||
const saved = settings?.v2rayn_db_path ?? "";
|
||
|
||
useEffect(() => {
|
||
if (saved) { setDbPath(saved); onPathSaved(saved); }
|
||
}, [saved]);
|
||
|
||
const handleDetect = async () => {
|
||
setDetecting(true);
|
||
try {
|
||
const found = await detectV2raynDbPath();
|
||
if (found) {
|
||
setDbPath(found);
|
||
showToast(`已找到:${found}`);
|
||
} else {
|
||
showToast("未自动找到,请手动浏览选择文件");
|
||
}
|
||
} finally { setDetecting(false); }
|
||
};
|
||
|
||
const handleBrowse = async () => {
|
||
const selected = await openDialog({
|
||
multiple: false,
|
||
filters: [{ name: "v2rayN 数据库", extensions: ["db"] }],
|
||
});
|
||
if (selected) setDbPath(selected as string);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!dbPath.trim()) { showToast("❌ 路径不能为空"); return; }
|
||
await updateSettings({ v2rayn_db_path: dbPath.trim() }, undefined);
|
||
qc.invalidateQueries({ queryKey: ["settings", undefined] });
|
||
onPathSaved(dbPath.trim());
|
||
showToast("已保存 ✓");
|
||
};
|
||
|
||
const handleReadNode = async () => {
|
||
const path = dbPath.trim() || saved;
|
||
if (!path) { showToast("❌ 请先配置 v2rayN 路径"); return; }
|
||
setLoadingNode(true);
|
||
try {
|
||
const node = await getActiveV2raynNode(path);
|
||
setActiveNode(node);
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally { setLoadingNode(false); }
|
||
};
|
||
|
||
const isChanged = dbPath.trim() !== saved;
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base">🪟</span>
|
||
<p className="text-sm font-semibold text-gray-700">Windows 代理(v2rayN)</p>
|
||
</div>
|
||
|
||
{/* 路径配置 */}
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={dbPath}
|
||
onChange={(e) => setDbPath(e.target.value)}
|
||
placeholder="v2rayN guiNDB.db 路径"
|
||
className={inputCls}
|
||
/>
|
||
<button
|
||
onClick={handleDetect}
|
||
disabled={detecting}
|
||
className="px-3 py-2 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 shrink-0 transition-colors"
|
||
>
|
||
{detecting ? "扫描中…" : "扫描"}
|
||
</button>
|
||
<button
|
||
onClick={handleBrowse}
|
||
className="px-3 py-2 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 shrink-0 transition-colors"
|
||
>
|
||
📁
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-gray-400">
|
||
通常位于:<code className="font-mono bg-gray-50 px-1 rounded">%APPDATA%\v2rayN\guiConfigs\guiNDB.db</code>
|
||
</p>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!isChanged || !dbPath.trim()}
|
||
className="px-4 py-1.5 rounded-lg text-xs bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors shrink-0 ml-2"
|
||
>
|
||
保存路径
|
||
</button>
|
||
</div>
|
||
{saved && (
|
||
<div className="flex items-center gap-1.5 text-xs text-green-600">
|
||
<span className="w-2 h-2 rounded-full bg-green-400 shrink-0" />
|
||
已配置:<span className="font-mono truncate">{saved}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 当前节点 */}
|
||
<div className="border-t border-gray-100 pt-3 flex items-center gap-3">
|
||
<button
|
||
onClick={handleReadNode}
|
||
disabled={loadingNode || (!dbPath.trim() && !saved)}
|
||
className="px-3 py-2 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40 shrink-0 transition-colors"
|
||
>
|
||
{loadingNode ? "读取中…" : "读取当前节点"}
|
||
</button>
|
||
{activeNode ? (
|
||
<div className="flex-1 flex items-center gap-2 px-3 py-2 bg-blue-50 rounded-lg border border-blue-100 min-w-0">
|
||
<span className="text-xs font-semibold text-blue-700 shrink-0">
|
||
{CONFIG_TYPE_LABEL[activeNode.config_type] ?? "?"}
|
||
</span>
|
||
<span className="text-xs font-medium text-blue-800 truncate">{activeNode.remarks}</span>
|
||
<span className="text-xs text-blue-400 font-mono shrink-0">
|
||
{activeNode.address}:{activeNode.port}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-gray-400">点击读取 v2rayN 当前激活节点</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── WSL 代理同步卡片 ──────────────────────────────────────────────────────────
|
||
|
||
function WslSyncCard({ dbPath }: { dbPath: string }) {
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
const [status, setStatus] = useState<WslProxyStatus | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [clearing, setClearing] = useState(false);
|
||
|
||
const refreshStatus = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const s = await getWslProxyStatus();
|
||
setStatus(s);
|
||
} catch {
|
||
setStatus(null);
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
// 不再自动刷新 — WSL 状态检测较慢,由用户手动点击「刷新状态」触发
|
||
|
||
const handleSync = async () => {
|
||
setSyncing(true);
|
||
try {
|
||
const msg = await syncWslProxy(dbPath || undefined);
|
||
showToast(`✓ ${msg}`);
|
||
await refreshStatus();
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally { setSyncing(false); }
|
||
};
|
||
|
||
const handleClear = async () => {
|
||
setClearing(true);
|
||
try {
|
||
await clearWslProxy();
|
||
showToast("WSL 代理配置已清除");
|
||
await refreshStatus();
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally { setClearing(false); }
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base">🐧</span>
|
||
<p className="text-sm font-semibold text-gray-700">WSL 代理同步</p>
|
||
<button
|
||
onClick={refreshStatus}
|
||
disabled={loading}
|
||
className="ml-auto text-xs text-gray-400 hover:text-gray-600 disabled:opacity-40 transition-colors"
|
||
>
|
||
{loading ? "检测中…" : "刷新状态"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 状态指示 */}
|
||
{status === null ? (
|
||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-gray-50 border border-dashed border-gray-200">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" />
|
||
<span className="text-xs text-gray-400">WSL 状态未知(点击刷新状态)</span>
|
||
</div>
|
||
) : status.configured ? (
|
||
<div className="space-y-2">
|
||
{/* 多个警告可并存,由上到下优先级降低 */}
|
||
{status.stale && (
|
||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-yellow-50 border border-yellow-300">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-400 shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-xs font-semibold text-yellow-700">⚠ IP 已变更,需重新同步</span>
|
||
<span className="text-xs text-yellow-600 ml-2 font-mono">
|
||
{status.configured_ip ?? "?"} → {status.current_ip ?? "?"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{status.bashrc_ok === false && (
|
||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-orange-50 border border-orange-300">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-orange-400 shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-xs font-semibold text-orange-700">⚠ 非登录 Shell 代理链接已断开</span>
|
||
<span className="text-xs text-orange-600 ml-2">/etc/bash.bashrc source 行缺失,请重新同步</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!status.stale && status.bashrc_ok !== false && (
|
||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-green-50 border border-green-200">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-green-400 shrink-0 animate-pulse" />
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-xs font-semibold text-green-700">已配置</span>
|
||
{status.port && (
|
||
<span className="text-xs text-green-600 ml-2 font-mono">
|
||
http_proxy=http://{status.current_ip ?? "win-host"}:{status.port}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-gray-50 border border-dashed border-gray-200">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" />
|
||
<span className="text-xs text-gray-500">未配置,点击「同步到 WSL」完成设置</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleSync}
|
||
disabled={syncing || !dbPath}
|
||
className="flex-1 py-2.5 rounded-xl text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors"
|
||
>
|
||
{syncing ? "同步中…" : "同步到 WSL"}
|
||
</button>
|
||
{status?.configured && (
|
||
<button
|
||
onClick={handleClear}
|
||
disabled={clearing}
|
||
className="px-4 py-2.5 rounded-xl text-sm border border-red-200 text-red-500 hover:bg-red-50 disabled:opacity-40 transition-colors"
|
||
>
|
||
{clearing ? "清除中…" : "清除"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{!dbPath && (
|
||
<p className="text-xs text-amber-500">⚠ 请先完成上方「Win 代理配置」并保存路径</p>
|
||
)}
|
||
|
||
{/* 说明 */}
|
||
<div className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 space-y-1.5">
|
||
<p className="text-xs font-medium text-gray-500">同步后的效果</p>
|
||
<p className="text-xs text-gray-400">
|
||
写入 <code className="font-mono bg-white px-1 rounded border border-gray-100">/etc/profile.d/v2rayn-proxy.sh</code>,新开终端自动生效
|
||
</p>
|
||
<p className="text-xs text-gray-400">
|
||
当前终端立即生效:<code className="font-mono bg-white px-1 rounded border border-gray-100">source /etc/profile.d/v2rayn-proxy.sh</code>
|
||
</p>
|
||
<p className="text-xs text-gray-400">适用于 curl、git、npm、pip 等命令行工具</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 代理一致性测试卡片 ────────────────────────────────────────────────────────
|
||
|
||
type IpState = { status: "idle" } | { status: "loading" } | { status: "ok"; ip: string } | { status: "error"; msg: string };
|
||
|
||
function ProxyTestCard() {
|
||
const [winState, setWinState] = useState<IpState>({ status: "idle" });
|
||
const [wslState, setWslState] = useState<IpState>({ status: "idle" });
|
||
const [testing, setTesting] = useState(false);
|
||
|
||
const runTest = async () => {
|
||
setTesting(true);
|
||
setWinState({ status: "loading" });
|
||
setWslState({ status: "loading" });
|
||
|
||
const [winResult, wslResult] = await Promise.allSettled([
|
||
testWinProxyIp(),
|
||
testWslProxyIp(),
|
||
]);
|
||
|
||
setWinState(
|
||
winResult.status === "fulfilled"
|
||
? { status: "ok", ip: winResult.value }
|
||
: { status: "error", msg: String(winResult.reason) },
|
||
);
|
||
setWslState(
|
||
wslResult.status === "fulfilled"
|
||
? { status: "ok", ip: wslResult.value }
|
||
: { status: "error", msg: String(wslResult.reason) },
|
||
);
|
||
setTesting(false);
|
||
};
|
||
|
||
const winIp = winState.status === "ok" ? winState.ip : null;
|
||
const wslIp = wslState.status === "ok" ? wslState.ip : null;
|
||
const bothDone = winState.status !== "idle" && winState.status !== "loading"
|
||
&& wslState.status !== "idle" && wslState.status !== "loading";
|
||
const consistent = bothDone && winIp !== null && wslIp !== null && winIp === wslIp;
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base">🔍</span>
|
||
<p className="text-sm font-semibold text-gray-700">代理一致性测试</p>
|
||
</div>
|
||
<button
|
||
onClick={runTest}
|
||
disabled={testing}
|
||
className="px-4 py-2 rounded-lg text-xs font-semibold bg-gray-800 text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||
>
|
||
{testing ? "测试中…" : "开始测试"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 结果行 */}
|
||
<div className="space-y-2">
|
||
{(
|
||
[
|
||
{ label: "🪟 Windows", state: winState },
|
||
{ label: "🐧 WSL", state: wslState },
|
||
] as const
|
||
).map(({ label, state }) => (
|
||
<div key={label} className="flex items-center gap-3 px-4 py-3 rounded-xl bg-gray-50 border border-gray-100">
|
||
<span className="text-xs font-medium text-gray-500 w-20 shrink-0">{label}</span>
|
||
{state.status === "idle" && (
|
||
<span className="text-xs text-gray-300">点击测试</span>
|
||
)}
|
||
{state.status === "loading" && (
|
||
<span className="text-xs text-gray-400 animate-pulse">请求中…</span>
|
||
)}
|
||
{state.status === "ok" && (
|
||
<code className="text-xs font-mono text-gray-700 bg-white px-2 py-0.5 rounded border border-gray-100">
|
||
{state.ip}
|
||
</code>
|
||
)}
|
||
{state.status === "error" && (
|
||
<span className="text-xs text-red-500 truncate">{state.msg}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 对比结果 */}
|
||
{bothDone && (
|
||
<div className={`flex items-center gap-2 px-4 py-3 rounded-xl border ${
|
||
consistent
|
||
? "bg-green-50 border-green-200"
|
||
: winIp && wslIp
|
||
? "bg-red-50 border-red-200"
|
||
: "bg-amber-50 border-amber-200"
|
||
}`}>
|
||
{consistent ? (
|
||
<>
|
||
<span className="text-green-500 font-bold">✓</span>
|
||
<span className="text-xs font-semibold text-green-700">出口 IP 一致,代理效果相同</span>
|
||
</>
|
||
) : winIp && wslIp ? (
|
||
<>
|
||
<span className="text-red-500 font-bold">✗</span>
|
||
<span className="text-xs font-semibold text-red-700">出口 IP 不一致,请重新同步代理</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="text-amber-500">⚠</span>
|
||
<span className="text-xs text-amber-700">部分测试失败,请检查错误信息</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-gray-400">
|
||
两侧均通过 <code className="font-mono">api.ipify.org</code> 获取出口 IP,测试约需 5-15 秒
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 节点列表(可选,用于查看/复制)────────────────────────────────────────────
|
||
|
||
const TYPE_COLOR: Record<number, string> = {
|
||
1: "bg-blue-100 text-blue-700",
|
||
3: "bg-purple-100 text-purple-700",
|
||
5: "bg-green-100 text-green-700",
|
||
6: "bg-orange-100 text-orange-700",
|
||
7: "bg-pink-100 text-pink-700",
|
||
};
|
||
|
||
function NodeListCard({ dbPath }: { dbPath: string }) {
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
const [nodes, setNodes] = useState<V2raynNode[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
const handleScan = async () => {
|
||
if (!dbPath) { showToast("❌ 请先设置 v2rayN 路径"); return; }
|
||
setLoading(true);
|
||
try {
|
||
const result = await scanV2raynNodes(dbPath);
|
||
setNodes(result);
|
||
setExpanded(true);
|
||
} catch (e) {
|
||
showToast(`❌ ${e}`);
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
const copyLink = async (node: V2raynNode) => {
|
||
const link = buildShareLink(node);
|
||
if (!link) { showToast("❌ 暂不支持此协议"); return; }
|
||
await navigator.clipboard.writeText(link).then(
|
||
() => showToast(`已复制 ${node.remarks} ✓`),
|
||
() => showToast("❌ 复制失败"),
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-gray-700">节点列表</p>
|
||
<div className="flex items-center gap-2">
|
||
{nodes.length > 0 && (
|
||
<button
|
||
onClick={() => setExpanded(!expanded)}
|
||
className="text-xs text-gray-400 hover:text-gray-600"
|
||
>
|
||
{expanded ? "收起" : `展开 ${nodes.length} 个`}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={handleScan}
|
||
disabled={loading || !dbPath}
|
||
className="px-3 py-1.5 rounded-lg text-xs bg-gray-100 text-gray-600 hover:bg-gray-200 disabled:opacity-40 transition-colors"
|
||
>
|
||
{loading ? "读取中…" : "读取全部节点"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-400">可查看所有节点并复制 share link</p>
|
||
|
||
{expanded && nodes.length > 0 && (
|
||
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
||
{nodes.map((node) => (
|
||
<div
|
||
key={node.index_id}
|
||
className="flex items-center gap-3 px-3 py-2.5 bg-gray-50 rounded-lg border border-gray-100 group"
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-medium text-gray-700 truncate">{node.remarks}</span>
|
||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium shrink-0 ${TYPE_COLOR[node.config_type] ?? "bg-gray-100 text-gray-600"}`}>
|
||
{CONFIG_TYPE_LABEL[node.config_type] ?? "Unknown"}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs font-mono text-gray-400 mt-0.5">{node.address}:{node.port}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => copyLink(node)}
|
||
className="opacity-0 group-hover:opacity-100 px-2 py-1 rounded text-xs border border-blue-200 text-blue-600 hover:bg-blue-50 transition-all"
|
||
>
|
||
复制链接
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 交互教程 ──────────────────────────────────────────────────────────────────
|
||
|
||
function CodeBlock({ code }: { code: string }) {
|
||
const showToast = useUIStore((s) => s.showToast);
|
||
return (
|
||
<div className="relative group">
|
||
<pre className="font-mono bg-gray-900 text-green-300 rounded-lg px-4 py-3 text-xs leading-relaxed whitespace-pre overflow-x-auto">
|
||
{code}
|
||
</pre>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(code).then(() => showToast("已复制 ✓"))}
|
||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 px-2 py-1 rounded text-xs bg-gray-700 text-gray-300 hover:bg-gray-600 transition-all"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type Section = {
|
||
id: string;
|
||
icon: string;
|
||
title: string;
|
||
tag?: string;
|
||
content: React.ReactNode;
|
||
};
|
||
|
||
const GUIDE_SECTIONS: Section[] = [
|
||
{
|
||
id: "concept",
|
||
icon: "💡",
|
||
title: "核心概念:出口 IP 才是判断标准",
|
||
content: (
|
||
<div className="space-y-3 text-xs text-gray-600 leading-relaxed">
|
||
<p>WSL 和 Windows 各有自己的 IP,但这些都是"内部地址",不重要。</p>
|
||
<div className="bg-gray-50 rounded-lg p-3 font-mono text-xs space-y-1">
|
||
<p><span className="text-gray-400">你的机器</span> → <span className="text-blue-600">v2rayN (Win)</span> → <span className="text-green-600 font-semibold">36.50.x.x(出口 IP)</span> → 目标网站</p>
|
||
<p><span className="text-gray-400">WSL</span>{" "}→ <span className="text-blue-600">v2rayN (Win)</span> → <span className="text-green-600 font-semibold">36.50.x.x(出口 IP)</span> → 目标网站</p>
|
||
</div>
|
||
<p>两边出口 IP 相同 = 代理效果一致。用本应用的「代理一致性测试」验证。</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: "why-simple",
|
||
icon: "🔗",
|
||
title: "为什么不需要在 WSL 装 v2rayA",
|
||
tag: "经验教训",
|
||
content: (
|
||
<div className="space-y-2 text-xs text-gray-600 leading-relaxed">
|
||
<p>v2rayA 方案很复杂:需要安装、配置账号、对接 REST API,还有各种坑。</p>
|
||
<p>其实 v2rayN 已经开了 <code className="bg-gray-100 px-1 rounded font-mono">AllowLAN = true</code>,WSL 可以直接通过 Windows 宿主机 IP 访问它:</p>
|
||
<CodeBlock code={`# WSL 里获取 Windows 宿主机 IP
|
||
ip route | grep default | awk '{print $3}'
|
||
# 通常是 172.x.x.1
|
||
|
||
# 直接通过 Windows v2rayN 代理访问网络
|
||
export http_proxy="http://172.x.x.1:10808"
|
||
curl https://api.ipify.org # 返回的就是代理出口 IP`} />
|
||
<p className="text-green-700 font-medium">结论:WSL 只需要设置环境变量,不需要安装任何代理软件。</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: "quickpath",
|
||
icon: "⚡",
|
||
title: "最佳捷径:三步完成配置",
|
||
tag: "推荐流程",
|
||
content: (
|
||
<div className="space-y-4 text-xs text-gray-600">
|
||
<div className="space-y-1">
|
||
<p className="font-semibold text-gray-700">① 确认 v2rayN 开启了 AllowLAN</p>
|
||
<p>v2rayN → 参数设置 → Core 类型设置 → 确认「允许来自局域网的连接」已勾选</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="font-semibold text-gray-700">② 本应用一键同步</p>
|
||
<p>「Win 代理配置」设好路径 → 点「同步到 WSL」→ 自动写入代理配置</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="font-semibold text-gray-700">③ 重开终端或手动 source</p>
|
||
<CodeBlock code={`source ~/.bashrc
|
||
# 验证
|
||
echo $http_proxy # 应有值
|
||
curl https://api.ipify.org # 应返回代理出口 IP`} />
|
||
</div>
|
||
<div className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-2">
|
||
<p className="text-blue-700">重装后恢复:装好 v2rayN → 装好本应用 → 点「同步到 WSL」,完成。</p>
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: "pitfall-binary",
|
||
icon: "⚠️",
|
||
title: "坑:工具是 Windows 程序,WSL 代理对它无效",
|
||
tag: "常见陷阱",
|
||
content: (
|
||
<div className="space-y-3 text-xs text-gray-600">
|
||
<p>在 WSL 里敲命令,不代表它就是 Linux 程序。路径以 <code className="bg-gray-100 px-1 rounded font-mono">/mnt/c/</code> 开头的都是 Windows 程序,走 Windows 网络栈,WSL 的 <code className="bg-gray-100 px-1 rounded font-mono">http_proxy</code> 对它完全无效。</p>
|
||
<p className="font-semibold text-gray-700">快速诊断:</p>
|
||
<CodeBlock code={`which opencode # 看路径
|
||
# /mnt/c/... → Windows 程序 ❌(WSL 代理无效)
|
||
# /usr/... 或 /home/... → Linux 程序 ✓
|
||
|
||
file $(which opencode) # 更准确
|
||
# ELF 64-bit → Linux 原生 ✓
|
||
# PE32 / Windows → Windows 程序 ❌`} />
|
||
<p className="font-semibold text-gray-700">解决方案:安装 Linux 原生版本</p>
|
||
<CodeBlock code={`# 以 opencode 为例——找到已装的 Linux 版位置
|
||
find ~/.npm-global -name 'opencode' -type f
|
||
# 建 symlink 覆盖 Windows 版(/usr/local/bin 优先级高于 Windows PATH)
|
||
sudo ln -sf /path/to/linux/opencode /usr/local/bin/opencode
|
||
# 验证
|
||
file $(which opencode) # 应为 ELF`} />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: "pitfall-shell",
|
||
icon: "⚠️",
|
||
title: "坑:代理变量只在部分 Shell 类型生效",
|
||
tag: "常见陷阱",
|
||
content: (
|
||
<div className="space-y-3 text-xs text-gray-600">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs border-collapse">
|
||
<thead>
|
||
<tr className="bg-gray-50">
|
||
<th className="text-left px-3 py-2 border border-gray-100 font-medium text-gray-700">Shell 类型</th>
|
||
<th className="text-left px-3 py-2 border border-gray-100 font-medium text-gray-700">加载来源</th>
|
||
<th className="text-left px-3 py-2 border border-gray-100 font-medium text-gray-700">代理是否自动生效</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[
|
||
["登录 Shell(Ubuntu 终端图标)", "/etc/profile.d/", "✓ 自动"],
|
||
["非登录交互 Shell(VSCode 终端)", "~/.bashrc", "✓ 自动(同步时已写入)"],
|
||
["非交互 Shell(脚本、子进程)", "不加载任何配置", "需继承父进程环境"],
|
||
].map(([type, source, result]) => (
|
||
<tr key={type}>
|
||
<td className="px-3 py-2 border border-gray-100">{type}</td>
|
||
<td className="px-3 py-2 border border-gray-100 font-mono text-gray-500">{source}</td>
|
||
<td className={`px-3 py-2 border border-gray-100 font-medium ${result.startsWith("✓") ? "text-green-600" : "text-amber-600"}`}>{result}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p>本应用同步时会同时写入 <code className="bg-gray-100 px-1 rounded font-mono">/etc/profile.d/</code>、<code className="bg-gray-100 px-1 rounded font-mono">/etc/bash.bashrc</code>、<code className="bg-gray-100 px-1 rounded font-mono">~/.bashrc</code>,覆盖所有场景。</p>
|
||
<p className="font-semibold text-gray-700">如果当前终端没生效(旧会话):</p>
|
||
<CodeBlock code={`source ~/.bashrc
|
||
echo $http_proxy # 验证是否有值`} />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: "diagnose",
|
||
icon: "🔍",
|
||
title: "快速诊断清单",
|
||
content: (
|
||
<div className="space-y-3 text-xs text-gray-600">
|
||
<p>遇到 WSL 里无法联网时,按顺序排查:</p>
|
||
<CodeBlock code={`# 1. 代理变量是否存在?
|
||
echo $http_proxy
|
||
# 空 → source ~/.bashrc 或重开终端
|
||
|
||
# 2. 代理能通么?
|
||
curl -s --max-time 5 --proxy "$http_proxy" https://api.ipify.org
|
||
# 超时/失败 → v2rayN 节点挂了,切换节点后重新同步
|
||
|
||
# 3. 工具是 Linux 还是 Windows 程序?
|
||
file $(which 工具名)
|
||
# Windows PE → 找 Linux 原生版或用 WSLENV 传参
|
||
|
||
# 4. 出口 IP 是否与 Windows 一致?
|
||
# 用本应用「代理一致性测试」按钮验证`} />
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
function ProxyGuideCard() {
|
||
const [open, setOpen] = useState(false);
|
||
const [activeId, setActiveId] = useState<string | null>(null);
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||
{/* 标题栏 */}
|
||
<button
|
||
onClick={() => setOpen(!open)}
|
||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base">📖</span>
|
||
<p className="text-sm font-semibold text-gray-700">WSL 代理配置指南</p>
|
||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 border border-blue-100">
|
||
经验沉淀
|
||
</span>
|
||
</div>
|
||
<span className={`text-gray-400 text-xs transition-transform ${open ? "rotate-180" : ""}`}>▼</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="border-t border-gray-100">
|
||
{/* 章节导航 */}
|
||
<div className="flex gap-1 px-5 py-3 overflow-x-auto border-b border-gray-50 bg-gray-50">
|
||
{GUIDE_SECTIONS.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => setActiveId(activeId === s.id ? null : s.id)}
|
||
className={`shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||
activeId === s.id
|
||
? "bg-blue-600 text-white"
|
||
: "bg-white border border-gray-200 text-gray-600 hover:border-blue-300 hover:text-blue-600"
|
||
}`}
|
||
>
|
||
{s.icon} {s.title.split(":")[0].split(":")[0].replace(/^.*?[::]\s*/, "").substring(0, 16) || s.title.substring(0, 16)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 内容区 */}
|
||
<div className="divide-y divide-gray-50">
|
||
{GUIDE_SECTIONS.map((s) => (
|
||
<div key={s.id}>
|
||
<button
|
||
onClick={() => setActiveId(activeId === s.id ? null : s.id)}
|
||
className="w-full flex items-center gap-3 px-5 py-3.5 hover:bg-gray-50 transition-colors text-left"
|
||
>
|
||
<span className="text-sm">{s.icon}</span>
|
||
<span className="flex-1 text-xs font-semibold text-gray-700">{s.title}</span>
|
||
{s.tag && (
|
||
<span className={`text-xs px-2 py-0.5 rounded-full shrink-0 ${
|
||
s.tag === "经验教训" ? "bg-amber-50 text-amber-600 border border-amber-100" :
|
||
s.tag === "推荐流程" ? "bg-green-50 text-green-600 border border-green-100" :
|
||
s.tag === "常见陷阱" ? "bg-red-50 text-red-600 border border-red-100" :
|
||
"bg-gray-100 text-gray-500"
|
||
}`}>
|
||
{s.tag}
|
||
</span>
|
||
)}
|
||
<span className={`text-gray-400 text-xs transition-transform shrink-0 ${activeId === s.id ? "rotate-180" : ""}`}>▼</span>
|
||
</button>
|
||
{activeId === s.id && (
|
||
<div className="px-5 pb-5 pt-1">{s.content}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 主页面 ────────────────────────────────────────────────────────────────────
|
||
|
||
export function ProxyPage() {
|
||
const [savedDbPath, setSavedDbPath] = useState("");
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto px-6 py-6 space-y-4 pb-12">
|
||
<div>
|
||
<h1 className="text-lg font-semibold text-gray-800">代理管理</h1>
|
||
<p className="text-sm text-gray-400 mt-0.5">
|
||
读取 Windows v2rayN 代理配置,一键同步到 WSL 环境变量
|
||
</p>
|
||
</div>
|
||
|
||
<WinProxyCard onPathSaved={setSavedDbPath} />
|
||
<WslSyncCard dbPath={savedDbPath} />
|
||
<ProxyTestCard />
|
||
<NodeListCard dbPath={savedDbPath} />
|
||
<ProxyGuideCard />
|
||
</div>
|
||
);
|
||
}
|