Files
opsxapi/src/components/ProxyErrorBanner.tsx
T
logan 2bb4ef7a95 桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)
- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
2026-10-16 03:03:36 +08:00

199 lines
5.6 KiB
TypeScript

/**
* 代理错误分层条:proxy | target | auth(产品/设计 P0 不砍)
* 线框:中文 LayerTag 芯片「代理 / 目标 / 鉴权」;unknown 中性灰
*/
import { openExternal } from "../openExternal";
import { useWorkspace } from "../workspaceStore";
import { cx } from "./common";
export type ProxyErrorLayer = "proxy" | "target" | "auth" | "unknown";
export type ClassifiedError = {
layer: ProxyErrorLayer;
title: string;
detail: string;
actions: { label: string; onClick: () => void; primary?: boolean }[];
};
const LAYER_LABEL: Record<ProxyErrorLayer, string> = {
proxy: "代理",
target: "目标",
auth: "鉴权",
unknown: "未知",
};
export function classifyRequestError(message: string): ClassifiedError {
const m = message.toLowerCase();
if (
m.includes("proxy") ||
m.includes("econnrefused") ||
m.includes("8787") ||
m.includes("failed to fetch") ||
m.includes("networkerror") ||
m.includes("gin-proxy") ||
m.includes("load failed")
) {
return {
layer: "proxy",
title: "代理层失败",
detail: message,
actions: [
{
label: "检查本地代理",
primary: true,
onClick: () => void openExternal("http://127.0.0.1:8787/health"),
},
{
label: "切换桌面直连",
primary: true,
onClick: () => {
useWorkspace.getState().patchSettings({ transportMode: "tauri-native" });
},
},
{
label: "查看通道说明",
onClick: () => useWorkspace.getState().setDialog({ type: "settings" }),
},
],
};
}
if (
m.includes("401") ||
m.includes("403") ||
m.includes("unauthorized") ||
m.includes("forbidden") ||
m.includes("token") ||
m.includes("鉴权") ||
(m.includes("auth") && !m.includes("author"))
) {
return {
layer: "auth",
title: "鉴权层失败",
detail: message,
actions: [
{
label: "检查 Token / Auth",
primary: true,
onClick: () => {
useWorkspace.getState().setDialog({ type: "environments" });
},
},
],
};
}
if (
m.includes("timeout") ||
m.includes("enotfound") ||
m.includes("404") ||
m.includes("502") ||
m.includes("503") ||
m.includes("ssl") ||
m.includes("certificate") ||
m.includes("target") ||
m.includes("dns")
) {
return {
layer: "target",
title: "目标服务失败",
detail: message,
actions: [
{
label: "检查 BaseURL / VPN",
primary: true,
onClick: () => useWorkspace.getState().setDialog({ type: "environments" }),
},
],
};
}
return {
layer: "unknown",
title: "请求失败(请按层排查)",
detail: message,
actions: [
{
label: "查代理 :8787",
primary: true,
onClick: () => void openExternal("http://127.0.0.1:8787/health"),
},
{
label: "切换桌面直连",
primary: true,
onClick: () => useWorkspace.getState().patchSettings({ transportMode: "tauri-native" }),
},
{
label: "查环境 / BaseURL",
onClick: () => useWorkspace.getState().setDialog({ type: "environments" }),
},
],
};
}
const BANNER_STYLE: Record<ProxyErrorLayer, string> = {
proxy: "border-amber-500/40 bg-amber-500/10 text-amber-50",
target: "border-sky-500/40 bg-sky-500/10 text-sky-50",
auth: "border-rose-500/40 bg-rose-500/10 text-rose-50",
unknown: "border-zinc-500/40 bg-zinc-500/10 text-zinc-100",
};
const TAG_STYLE: Record<ProxyErrorLayer, string> = {
proxy: "bg-amber-500/25 text-amber-100 ring-amber-400/40",
target: "bg-sky-500/25 text-sky-100 ring-sky-400/40",
auth: "bg-rose-500/25 text-rose-100 ring-rose-400/40",
unknown: "bg-zinc-500/30 text-zinc-200 ring-zinc-400/40",
};
function LayerTag({ layer }: { layer: ProxyErrorLayer }) {
return (
<span
className={cx(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-wide ring-1 ring-inset",
TAG_STYLE[layer],
)}
>
{LAYER_LABEL[layer]}
</span>
);
}
export function ProxyErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
const info = classifyRequestError(message);
return (
<div className={cx("mb-3 rounded-lg border px-3 py-2.5 text-[13px]", BANNER_STYLE[info.layer])}>
<div className="mb-1.5 flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<LayerTag layer={info.layer} />
<span className="font-semibold">{info.title}</span>
</div>
{onDismiss && (
<button type="button" className="ops-btn ops-btn-ghost px-1 py-0 text-xs opacity-70" onClick={onDismiss}>
✕
</button>
)}
</div>
<div className="whitespace-pre-wrap opacity-90">{info.detail}</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
{info.actions.map((a) =>
a.primary ? (
<button key={a.label} type="button" className="ops-btn ops-btn-secondary text-xs" onClick={a.onClick}>
{a.label}
</button>
) : (
<button
key={a.label}
type="button"
className="text-[12px] underline-offset-2 opacity-80 hover:underline hover:opacity-100"
onClick={a.onClick}
>
{a.label}
</button>
),
)}
</div>
</div>
);
}