dev-manager-tauri/src/components/proxy/ProxyPage.tsx
2026-04-02 14:19:41 +09:00

790 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"> curlgitnpmpip </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>
{[
["登录 ShellUbuntu 终端图标)", "/etc/profile.d/", "✓ 自动"],
["非登录交互 ShellVSCode 终端)", "~/.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>
);
}