桌面应用图标替换为 B-brackets 设计(深藏青 + 白/蓝花括号)
- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成: icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo - 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效 - 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
This commit is contained in:
1 parent
71a6d986a4
commit
39f4d5addc
161 files changed
+3289
-515
No files matched your search
@@ -40,6 +40,7 @@ export type {
|
||||
HistoryEntry,
|
||||
ThemeMode,
|
||||
WorkspaceSettings,
|
||||
TransportMode,
|
||||
WorkspaceData,
|
||||
ImportResult,
|
||||
MockRule,
|
||||
|
||||
@@ -258,10 +258,14 @@ export type MockSettings = {
|
||||
rules: MockRule[];
|
||||
};
|
||||
|
||||
export type TransportMode = "auto" | "gin-proxy" | "browser" | "tauri-native";
|
||||
|
||||
export type WorkspaceSettings = {
|
||||
theme: ThemeMode;
|
||||
/** 请求超时(毫秒) */
|
||||
timeoutMs: number;
|
||||
/** 发信通道:auto 按环境探测 */
|
||||
transportMode?: TransportMode;
|
||||
mock: MockSettings;
|
||||
ai: AiSettings;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { wsSmart } from "./wsSmart";
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core";
|
||||
import { browserTransport } from "./browser";
|
||||
|
||||
/**
|
||||
* HTTP goes through Gin POST /v1/proxy/http.
|
||||
* WS (MVP): falls back to browser WebSocket — custom headers require
|
||||
* desktop/Gin tunnel later (Week 2).
|
||||
* WS: no custom headers → browser WebSocket; with headers → Gin WS tunnel (wsSmart).
|
||||
*/
|
||||
export function ginProxyTransport(baseUrl: string): Transport {
|
||||
const root = baseUrl.replace(/\/$/, "");
|
||||
@@ -24,12 +24,15 @@ export function ginProxyTransport(baseUrl: string): Transport {
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
// TODO(week2): custom headers need desktop/Gin WS tunnel.
|
||||
// For MVP, fall back to browser WebSocket (no custom headers).
|
||||
console.warn(
|
||||
"[opsxapi/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
|
||||
const headers = spec.headers ?? {};
|
||||
if (Object.keys(headers).length === 0) {
|
||||
return browserTransport.ws(spec);
|
||||
}
|
||||
// Custom handshake headers → Gin WS tunnel
|
||||
return wsSmart(
|
||||
{ url: spec.url, headers, protocols: spec.protocols },
|
||||
{ ginBase: root },
|
||||
);
|
||||
return browserTransport.ws(spec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export { browserTransport } from "./browser";
|
||||
export { ginProxyTransport } from "./ginProxy";
|
||||
export { tauriNativeTransport } from "./tauriNative";
|
||||
export { tauriNativeTransport, isTauriRuntime } from "./tauriNative";
|
||||
export { detectTransport } from "./detect";
|
||||
export type { TransportKind, DetectOptions } from "./detect";
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@ async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T
|
||||
}
|
||||
}
|
||||
|
||||
export function isTauriRuntime(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
type WsEventPayload = {
|
||||
sessionId: string;
|
||||
kind: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const tauriNativeTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
return invoke<HttpResult>("http_send", { spec });
|
||||
@@ -21,20 +32,37 @@ export const tauriNativeTransport: Transport = {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
let sessionId: string | null = null;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let closed = false;
|
||||
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
emitStatus("connecting");
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unlisten = await listen<WsEventPayload>("ws-event", (ev) => {
|
||||
const p = ev.payload;
|
||||
if (!sessionId || p.sessionId !== sessionId) return;
|
||||
if (p.kind === "message") {
|
||||
messageCbs.forEach((cb) => cb(p.data));
|
||||
return;
|
||||
}
|
||||
if (p.kind === "status") {
|
||||
emitStatus(p.data);
|
||||
if (p.data === "closed" || p.data === "error") {
|
||||
// keep listening until explicit close()
|
||||
}
|
||||
}
|
||||
});
|
||||
sessionId = await invoke<string>("ws_connect", { spec });
|
||||
emitStatus("open");
|
||||
// Full event listen pipeline is Week 2; stub keeps UI usable.
|
||||
// open status also arrives via event; emit early for snappy UI
|
||||
} catch (e) {
|
||||
emitStatus("error");
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
emitStatus("closed");
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -57,11 +85,17 @@ export const tauriNativeTransport: Transport = {
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (!sessionId) return;
|
||||
void invoke("ws_close", { sessionId }).finally(() => {
|
||||
sessionId = null;
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
const id = sessionId;
|
||||
sessionId = null;
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
if (!id) {
|
||||
emitStatus("closed");
|
||||
});
|
||||
return;
|
||||
}
|
||||
void invoke("ws_close", { sessionId: id }).finally(() => emitStatus("closed"));
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -36,6 +36,8 @@ export function OpsxWorkspace() {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const activeTabId = useWorkspace((s) => s.activeTabId);
|
||||
const dialog = useWorkspace((s) => s.dialog);
|
||||
const toast = useWorkspace((s) => s.toast);
|
||||
const clearToast = useWorkspace((s) => s.clearToast);
|
||||
const openTab = useWorkspace((s) => s.openTab);
|
||||
const view = useWorkspace((s) => s.view);
|
||||
|
||||
@@ -110,6 +112,34 @@ export function OpsxWorkspace() {
|
||||
})()}
|
||||
{dialog?.type === "auth" && <AuthDialog />}
|
||||
{dialog?.type === "team" && <TeamDialog />}
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className="ops-toast"
|
||||
role="status"
|
||||
data-tone={toast.tone}
|
||||
onClick={() => clearToast()}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 80,
|
||||
maxWidth: 420,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--panel)",
|
||||
color: "var(--text)",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,.35)",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,13 +111,38 @@ export type MockEndpointPayload = {
|
||||
delayMs: number;
|
||||
};
|
||||
|
||||
export function publishMock(token: string, workspaceId: number, endpoints: MockEndpointPayload[]) {
|
||||
return call<{ ok: boolean; count: number; base: string }>(`/workspaces/${workspaceId}/mock`, token, {
|
||||
export function publishMock(
|
||||
token: string,
|
||||
workspaceId: number,
|
||||
endpoints: MockEndpointPayload[],
|
||||
ttlSeconds = 0,
|
||||
) {
|
||||
return call<{ ok: boolean; count: number; base: string; expiresAt: number }>(
|
||||
`/workspaces/${workspaceId}/mock`,
|
||||
token,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ endpoints, ttlSeconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 本机联调发布:不绑账号,仅本机 Gin 接受。 */
|
||||
export function publishLocalMock(endpoints: MockEndpointPayload[], ttlSeconds = 0) {
|
||||
return call<{ ok: boolean; count: number; base: string; token: string; expiresAt: number }>("/mock/local", null, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ endpoints }),
|
||||
body: JSON.stringify({ endpoints, ttlSeconds }),
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeMock(token: string, workspaceId: number) {
|
||||
return call<{ ok: boolean }>(`/workspaces/${workspaceId}/mock`, token, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function revokeLocalMock() {
|
||||
return call<{ ok: boolean }>("/mock/local", null, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function saveWorkspace(token: string, id: number, data: string) {
|
||||
return call<{ updatedAt: number }>(`/workspaces/${id}`, token, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* 最左活动栏(借鉴 Apifox):空间切换(本地/个人/团队)+ 主导航 + 底部账号与工具。
|
||||
* 顶栏因此只保留与当前编辑相关的操作。
|
||||
* 最左活动栏:主导航 + 底部账号。
|
||||
* 本地/团队工作区只在底部登录/头像菜单切换(设计师线框 B);不再显示顶部「本」字标。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||
import { EnvBadge } from "./EnvBadge";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
|
||||
export function ActivityRail() {
|
||||
const view = useWorkspace((s) => s.view);
|
||||
const setView = useWorkspace((s) => s.setView);
|
||||
const sidebarSide = useWorkspace((s) => s.sidebarSide);
|
||||
const setSidebarSide = useWorkspace((s) => s.setSidebarSide);
|
||||
const account = useWorkspace((s) => s.account);
|
||||
const switchSpace = useWorkspace((s) => s.switchSpace);
|
||||
@@ -19,46 +17,13 @@ export function ActivityRail() {
|
||||
const syncNow = useWorkspace((s) => s.syncNow);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
const [spaceMenu, setSpaceMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const activeSpace = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
|
||||
const spaceName = activeSpace ? `${activeSpace.teamId !== null ? "团队 · " : ""}${activeSpace.name}` : "本地空间";
|
||||
|
||||
const spaceItems: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Icon name="shield-check" size={13} className="ops-faint" />
|
||||
<span className="truncate">本地空间</span>
|
||||
{account.activeSpaceId === null && <span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>}
|
||||
</span>
|
||||
),
|
||||
onClick: () => void switchSpace(null),
|
||||
},
|
||||
...account.spaces.map((sp) => ({
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Icon name={sp.teamId !== null ? "library" : "shield-check"} size={13} className="ops-faint" />
|
||||
<span className="truncate">{sp.teamId !== null ? `团队 · ${sp.name}` : sp.name}</span>
|
||||
{sp.id === account.activeSpaceId ? (
|
||||
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
|
||||
) : (
|
||||
<span className="ops-faint ml-auto pl-3 text-[10px]">
|
||||
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => void switchSpace(sp.id),
|
||||
})),
|
||||
{ type: "sep" as const },
|
||||
{
|
||||
label: "团队管理…",
|
||||
icon: <Icon name="library" size={14} />,
|
||||
onClick: () => setDialog({ type: "team" }),
|
||||
},
|
||||
];
|
||||
const isTeam = activeSpace?.teamId != null;
|
||||
const spaceName = activeSpace
|
||||
? `${isTeam ? "团队 · " : ""}${activeSpace.name}`
|
||||
: "本地工作区";
|
||||
|
||||
const siteItem: MenuItem = {
|
||||
label: (
|
||||
@@ -71,8 +36,54 @@ export function ActivityRail() {
|
||||
onClick: () => window.open("/home", "_blank", "noopener,noreferrer"),
|
||||
};
|
||||
|
||||
const workspaceItems: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="rail-ws-dot local" aria-hidden />
|
||||
<span className="truncate">本地工作区</span>
|
||||
{account.activeSpaceId === null && (
|
||||
<span className="ml-auto pl-3 text-[11px] ops-success">● 当前</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => void switchSpace(null),
|
||||
},
|
||||
...account.spaces.map((sp) => {
|
||||
const team = sp.teamId != null;
|
||||
return {
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={cx("rail-ws-dot", team ? "team" : "local")} aria-hidden />
|
||||
<span className="truncate">{team ? `团队 · ${sp.name}` : sp.name}</span>
|
||||
{sp.id === account.activeSpaceId ? (
|
||||
<span className="ml-auto pl-3 text-[11px] ops-success">● 当前</span>
|
||||
) : (
|
||||
<span className="ops-faint ml-auto pl-3 text-[11px]">
|
||||
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => void switchSpace(sp.id),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const accountItems: MenuItem[] = account.user
|
||||
? [
|
||||
...workspaceItems,
|
||||
{ type: "sep" as const },
|
||||
{
|
||||
label: "环境变量",
|
||||
icon: <Icon name="settings" size={14} />,
|
||||
onClick: () => setDialog({ type: "environments" }),
|
||||
},
|
||||
{
|
||||
label: "账号设置",
|
||||
icon: <Icon name="settings" size={14} />,
|
||||
onClick: () => setDialog({ type: "settings" }),
|
||||
},
|
||||
...(account.activeSpaceId !== null
|
||||
? [
|
||||
{
|
||||
@@ -94,7 +105,7 @@ export function ActivityRail() {
|
||||
{ type: "sep" as const },
|
||||
siteItem,
|
||||
{
|
||||
label: "退出登录",
|
||||
label: "退出",
|
||||
icon: <Icon name="circle-x" size={14} />,
|
||||
onClick: () => void authLogout(),
|
||||
},
|
||||
@@ -106,42 +117,44 @@ export function ActivityRail() {
|
||||
onClick: () => setDialog({ type: "auth" }),
|
||||
},
|
||||
{ type: "sep" as const },
|
||||
{
|
||||
label: "本地工作区",
|
||||
icon: <Icon name="shield-check" size={14} />,
|
||||
onClick: () => void switchSpace(null),
|
||||
},
|
||||
{
|
||||
label: "环境变量",
|
||||
icon: <Icon name="settings" size={14} />,
|
||||
onClick: () => setDialog({ type: "environments" }),
|
||||
},
|
||||
{ type: "sep" as const },
|
||||
siteItem,
|
||||
];
|
||||
|
||||
const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void }[] = [
|
||||
const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void; soon?: boolean }[] = [
|
||||
{
|
||||
id: "api",
|
||||
icon: "globe",
|
||||
label: "接口",
|
||||
active: view === "api" && sidebarSide === "api",
|
||||
active: view === "api",
|
||||
onClick: () => {
|
||||
setView("api");
|
||||
setSidebarSide("api");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
icon: "clock",
|
||||
label: "历史",
|
||||
active: view === "api" && sidebarSide === "history",
|
||||
onClick: () => {
|
||||
setView("api");
|
||||
setSidebarSide("history");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "testing",
|
||||
icon: "list-checks",
|
||||
label: "测试",
|
||||
active: view === "testing",
|
||||
onClick: () => setView("testing"),
|
||||
soon: true,
|
||||
},
|
||||
];
|
||||
|
||||
const syncTitle = !account.user
|
||||
? "登录 / 注册,开启云端个人与团队空间"
|
||||
: `${account.user.name}(${account.user.email})\n当前空间:${spaceName}${
|
||||
? "登录后可切换团队工作区;未登录也可继续用本地"
|
||||
: `${account.user.name}(${account.user.email})\n当前:${spaceName}${
|
||||
account.syncing
|
||||
? "\n同步中…"
|
||||
: account.syncError
|
||||
@@ -156,62 +169,43 @@ export function ActivityRail() {
|
||||
className="ops-panel flex w-14 shrink-0 flex-col items-center gap-1 py-2"
|
||||
style={{ borderRight: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* 空间切换(本地 / 个人 / 团队组织);徽标统一用彩色首字标,与顶栏品牌 Logo 区分 */}
|
||||
<button
|
||||
className="rail-space"
|
||||
title={`当前空间:${spaceName}\n点击切换本地 / 个人 / 团队`}
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSpaceMenu((m) => (m ? null : { x: rect.right + 4, y: rect.top }));
|
||||
}}
|
||||
>
|
||||
<EnvBadge
|
||||
envId={activeSpace ? String(activeSpace.id) : "local"}
|
||||
name={spaceName}
|
||||
size={26}
|
||||
/>
|
||||
<Icon name="chevron-down" size={10} className="ops-faint" />
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px w-8" style={{ background: "var(--border)" }} />
|
||||
|
||||
{/* 主导航 */}
|
||||
{/* 主导航:不再放顶部「本」字标 / 空间切换 */}
|
||||
{navs.map((n) => (
|
||||
<button key={n.id} className={cx("rail-item", n.active && "active")} title={n.label} onClick={n.onClick}>
|
||||
<button
|
||||
key={n.id}
|
||||
className={cx("rail-item", n.active && "active", n.soon && "opacity-35 grayscale")}
|
||||
title={n.soon ? `${n.label} · 即将推出` : n.label}
|
||||
onClick={n.onClick}
|
||||
>
|
||||
<Icon name={n.icon} size={17} />
|
||||
<span className="text-[10px]">{n.label}</span>
|
||||
<span className="text-[11px]">{n.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="rail-item"
|
||||
title="Mock 数据:@表达式 + 智能匹配规则"
|
||||
className="rail-item opacity-35 grayscale"
|
||||
title="Mock · 即将推出"
|
||||
onClick={() => setDialog({ type: "settings", tab: "mock" })}
|
||||
>
|
||||
<Icon name="flask-conical" size={17} />
|
||||
<span className="text-[10px]">Mock</span>
|
||||
<span className="text-[11px]">Mock</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 底部:文档 / 设置 / 账号(主题切换在 设置 → 外观,低频操作不占栏位) */}
|
||||
<a className="rail-item" href="/docs" target="_blank" rel="noopener noreferrer" title="用户文档">
|
||||
<a className="rail-item opacity-35 grayscale" href="/docs" target="_blank" rel="noopener noreferrer" title="文档 · 即将推出">
|
||||
<Icon name="book-open" size={16} />
|
||||
<span className="text-[10px]">文档</span>
|
||||
<span className="text-[11px]">文档</span>
|
||||
</a>
|
||||
<button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}>
|
||||
<Icon name="settings" size={16} />
|
||||
<span className="text-[10px]">设置</span>
|
||||
<span className="text-[11px]">设置</span>
|
||||
</button>
|
||||
<button
|
||||
className={cx("rail-item", !account.user && "rail-login")}
|
||||
title={syncTitle}
|
||||
onClick={(e) => {
|
||||
if (!account.user) {
|
||||
setDialog({ type: "auth" });
|
||||
return;
|
||||
}
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setAccountMenu((m) => (m ? null : { x: rect.right + 4, y: rect.bottom + 4 }));
|
||||
setAccountMenu((m) => (m ? null : { x: rect.right + 4, y: Math.max(8, rect.bottom - 240) }));
|
||||
}}
|
||||
>
|
||||
<span className="relative">
|
||||
@@ -222,6 +216,15 @@ export function ActivityRail() {
|
||||
) : (
|
||||
<Icon name="shield-check" size={17} />
|
||||
)}
|
||||
{account.user ? (
|
||||
<span
|
||||
className={cx(
|
||||
"rail-ws-badge",
|
||||
account.activeSpaceId === null ? "local" : isTeam ? "team" : "cloud",
|
||||
)}
|
||||
title={spaceName}
|
||||
/>
|
||||
) : null}
|
||||
{account.user && account.activeSpaceId !== null && (
|
||||
<span
|
||||
className={cx(
|
||||
@@ -231,13 +234,9 @@ export function ActivityRail() {
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="max-w-11 truncate text-[10px]">{account.user ? account.user.name : "登录"}</span>
|
||||
<span className="max-w-11 truncate text-[11px]">{account.user ? account.user.name : "登录"}</span>
|
||||
</button>
|
||||
|
||||
<ContextMenu
|
||||
menu={spaceMenu ? { x: spaceMenu.x, y: spaceMenu.y, items: spaceItems } : null}
|
||||
onClose={() => setSpaceMenu(null)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={accountMenu ? { x: accountMenu.x, y: accountMenu.y, items: accountItems } : null}
|
||||
onClose={() => setAccountMenu(null)}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function NewMenu({
|
||||
<KindIcon kind={p.kind} size={26} />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[12.5px] font-semibold">{KIND_COLORS[p.kind].label}</span>
|
||||
<span className="ops-faint block truncate text-[10px]">{p.desc}</span>
|
||||
<span className="ops-faint block truncate text-[11px]">{p.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -80,7 +80,7 @@ export function NewMenu({
|
||||
>
|
||||
<KindIcon kind="folder" size={26} />
|
||||
<span className="text-[12.5px] font-semibold">新建目录</span>
|
||||
<span className="ops-faint ml-auto pr-1 text-[10px]">接口分组</span>
|
||||
<span className="ops-faint ml-auto pr-1 text-[11px]">接口分组</span>
|
||||
</button>
|
||||
<button
|
||||
className="new-card new-card-full"
|
||||
@@ -93,7 +93,7 @@ export function NewMenu({
|
||||
<Icon name="library" size={15} />
|
||||
</span>
|
||||
<span className="text-[12.5px] font-semibold">选择位置新建…</span>
|
||||
<span className="ops-faint ml-auto pr-1 text-[10px]">指定集合 / 目录</span>
|
||||
<span className="ops-faint ml-auto pr-1 text-[11px]">指定集合 / 目录</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 代理错误分层条:proxy | target | auth(产品/设计 P0 不砍)
|
||||
* 线框:中文 LayerTag 芯片「代理 / 目标 / 鉴权」;unknown 中性灰
|
||||
*/
|
||||
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: () => window.open("http://127.0.0.1:8787/health", "_blank"),
|
||||
},
|
||||
{
|
||||
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: () => window.open("http://127.0.0.1:8787/health", "_blank"),
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { replayHistory } from "../send";
|
||||
/**
|
||||
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ export function Sidebar() {
|
||||
const history = useWorkspace((s) => s.history);
|
||||
const clearHistory = useWorkspace((s) => s.clearHistory);
|
||||
const openTab = useWorkspace((s) => s.openTab);
|
||||
// 由活动栏驱动:接口树 / 历史记录 两个展示面共用本侧栏
|
||||
// 接口树 / 历史在侧栏分段切换;活动栏只保留「接口」入口,避免语义叠层
|
||||
const side = useWorkspace((s) => s.sidebarSide);
|
||||
const setSide = useWorkspace((s) => s.setSidebarSide);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
||||
@@ -57,23 +58,36 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
{history.slice(0, 50).map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
className="ops-tree-row w-full !py-1 text-left"
|
||||
onClick={() => {
|
||||
if (h.nodeId && findNode(useWorkspace.getState().collections, h.nodeId)) {
|
||||
openTab(h.nodeId);
|
||||
}
|
||||
}}
|
||||
title={`${h.label}\n${h.url}`}
|
||||
>
|
||||
<span className="ops-faint w-14 shrink-0 text-[10px]">{formatTime(h.at)}</span>
|
||||
{h.method && <span className={`ops-method-${h.method} w-12 shrink-0 text-[10px] font-bold`}>{h.method}</span>}
|
||||
<span className="truncate text-[11px]">{h.label}</span>
|
||||
{h.status !== undefined && (
|
||||
<span className={cx("ml-auto text-[10px]", h.ok ? "ops-success" : "ops-danger")}>{h.status}</span>
|
||||
<div key={h.id} className="ops-tree-row group flex w-full items-center !py-1 text-left">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left"
|
||||
onClick={() => {
|
||||
if (h.nodeId && findNode(useWorkspace.getState().collections, h.nodeId)) {
|
||||
openTab(h.nodeId);
|
||||
}
|
||||
}}
|
||||
title={`${h.label}\n${h.url}`}
|
||||
>
|
||||
<span className="ops-faint w-14 shrink-0 text-[11px]">{formatTime(h.at)}</span>
|
||||
{h.method && <span className={`ops-method-${h.method} w-12 shrink-0 text-[11px] font-bold`}>{h.method}</span>}
|
||||
<span className="truncate text-[11px]">{h.label}</span>
|
||||
{h.status !== undefined && (
|
||||
<span className={cx("ml-auto text-[11px]", h.ok ? "ops-success" : "ops-danger")}>{h.status}</span>
|
||||
)}
|
||||
</button>
|
||||
{h.nodeId && (
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost ml-1 shrink-0 px-1.5 py-0.5 text-[11px] opacity-0 group-hover:opacity-100"
|
||||
title="打开并重放"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
replayHistory(h);
|
||||
}}
|
||||
>
|
||||
重放
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{history.length > 0 && (
|
||||
<button className="ops-btn ops-btn-ghost ops-danger mt-2 w-full py-1 text-[11px]" onClick={clearHistory}>
|
||||
@@ -178,6 +192,7 @@ function TreeRow({
|
||||
onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void;
|
||||
}) {
|
||||
const activeTabId = useWorkspace((s) => s.activeTabId);
|
||||
const dirtyIds = useWorkspace((s) => s.dirtyIds);
|
||||
const expanded = useWorkspace((s) => s.expanded);
|
||||
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
|
||||
const openTab = useWorkspace((s) => s.openTab);
|
||||
@@ -246,7 +261,18 @@ function TreeRow({
|
||||
return items;
|
||||
};
|
||||
|
||||
const label = <span className="truncate">{node.name}</span>;
|
||||
const label = (
|
||||
<span className="flex min-w-0 items-center gap-1 truncate">
|
||||
<span className="truncate">{node.name}</span>
|
||||
{requestNode && dirtyIds[node.id] ? (
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
title="未保存"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
direction?: "horizontal" | "vertical";
|
||||
initialRatio?: number;
|
||||
storageKey?: string;
|
||||
minPx?: number;
|
||||
className?: string;
|
||||
children: [ReactNode, ReactNode];
|
||||
};
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) {
|
||||
return Math.min(hi, Math.max(lo, n));
|
||||
}
|
||||
|
||||
export function SplitPane({
|
||||
direction = "vertical",
|
||||
initialRatio = 0.45,
|
||||
storageKey,
|
||||
minPx = 120,
|
||||
className = "",
|
||||
children,
|
||||
}: Props) {
|
||||
const [a, b] = children;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [ratio, setRatio] = useState(() => {
|
||||
if (storageKey) {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const n = raw ? Number(raw) : NaN;
|
||||
if (Number.isFinite(n) && n > 0.1 && n < 0.9) return n;
|
||||
}
|
||||
return initialRatio;
|
||||
});
|
||||
const dragging = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey) localStorage.setItem(storageKey, String(ratio));
|
||||
}, [ratio, storageKey]);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
if (!dragging.current || !rootRef.current) return;
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
if (direction === "vertical") {
|
||||
const y = e.clientY - rect.top;
|
||||
const min = minPx / rect.height;
|
||||
setRatio(clamp(y / rect.height, min, 1 - min));
|
||||
} else {
|
||||
const x = e.clientX - rect.left;
|
||||
const min = minPx / rect.width;
|
||||
setRatio(clamp(x / rect.width, min, 1 - min));
|
||||
}
|
||||
},
|
||||
[direction, minPx],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", stop);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
};
|
||||
}, [onPointerMove, stop]);
|
||||
|
||||
const vert = direction === "vertical";
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`flex min-h-0 min-w-0 flex-1 ${vert ? "flex-col" : "flex-row"} ${className}`}
|
||||
>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden" style={vert ? { height: `${ratio * 100}%` } : { width: `${ratio * 100}%` }}>
|
||||
{a}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={vert ? "horizontal" : "vertical"}
|
||||
title="拖动调整;双击复位"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
document.body.style.cursor = vert ? "row-resize" : "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}}
|
||||
onDoubleClick={() => setRatio(initialRatio)}
|
||||
className={
|
||||
vert
|
||||
? "group relative z-10 h-2 shrink-0 cursor-row-resize"
|
||||
: "group relative z-10 w-2 shrink-0 cursor-col-resize"
|
||||
}
|
||||
style={{ background: "transparent" }}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
vert
|
||||
? "absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-[var(--border)] transition group-hover:h-1 group-hover:bg-[var(--accent)]"
|
||||
: "absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[var(--border)] transition group-hover:w-1 group-hover:bg-[var(--accent)]"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">{b}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function TabBar() {
|
||||
const setActiveTab = useWorkspace((s) => s.setActiveTab);
|
||||
const closeTab = useWorkspace((s) => s.closeTab);
|
||||
const closeOtherTabs = useWorkspace((s) => s.closeOtherTabs);
|
||||
const dirtyIds = useWorkspace((s) => s.dirtyIds);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
|
||||
const nodes = openTabIds
|
||||
@@ -72,6 +73,13 @@ export function TabBar() {
|
||||
<span title={KIND_LABELS[node.kind]}><KindIcon kind={node.kind} size={14} /></span>
|
||||
) : null}
|
||||
<span className="max-w-48 truncate">{node.name}</span>
|
||||
{dirtyIds[node.id] && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
title="未保存"
|
||||
/>
|
||||
)}
|
||||
{busy && <span className="ops-dot ops-dot-connecting" />}
|
||||
<button
|
||||
className={cx(
|
||||
|
||||
@@ -157,7 +157,7 @@ export function TestingView() {
|
||||
onClick={() => setSelected(c.id)}
|
||||
>
|
||||
<span className="truncate text-[12px] font-medium">{c.name}</span>
|
||||
<span className="ops-faint ml-auto shrink-0 text-[10px]">{c.steps.length} 步</span>
|
||||
<span className="ops-faint ml-auto shrink-0 text-[11px]">{c.steps.length} 步</span>
|
||||
{sched && <Icon name="calendar-clock" size={11} className="ops-accent" />}
|
||||
{last && (
|
||||
<Icon
|
||||
@@ -370,7 +370,7 @@ function StepEditor({
|
||||
))}
|
||||
</select>
|
||||
{node && (
|
||||
<span className={cx("ops-chip", `ops-method-${node.method}`)} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||
<span className={cx("ops-chip", `ops-method-${node.method}`)} style={{ padding: "1px 8px", fontSize: 11 }}>
|
||||
{node.method}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
/**
|
||||
* 顶栏(瘦身版):品牌 + 新建/导入/导出 + 环境切换。
|
||||
* 空间切换、导航、账号、主题、文档、设置均在左侧活动栏(ActivityRail)。
|
||||
* 顶栏(瘦身版):品牌 + 新建/导入/导出 + 通道。
|
||||
* 工作区 / 环境变量 / 账号均在左侧活动栏底部登录/头像菜单。
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, type MenuItem } from "./common";
|
||||
import { EnvBadge } from "./EnvBadge";
|
||||
import { Icon } from "./icons";
|
||||
import { Logo } from "./Logo";
|
||||
import { NewMenu } from "./NewMenu";
|
||||
|
||||
export function TopBar() {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const environments = useWorkspace((s) => s.environments);
|
||||
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
|
||||
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
|
||||
const transportMode = useWorkspace((s) => s.settings.transportMode ?? "auto");
|
||||
const patchSettings = useWorkspace((s) => s.patchSettings);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
const addRequest = useWorkspace((s) => s.addRequest);
|
||||
const addFolder = useWorkspace((s) => s.addFolder);
|
||||
|
||||
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [envMenu, setEnvMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "OpsxAPI · 接口工作台";
|
||||
@@ -35,37 +32,7 @@ export function TopBar() {
|
||||
else addRequest(firstCollection.id, null, kind as never);
|
||||
};
|
||||
|
||||
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
|
||||
|
||||
const envItems: MenuItem[] = [
|
||||
...environments.map((env) => ({
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<EnvBadge envId={env.id} name={env.name} size={20} />
|
||||
<span className="truncate">{env.name}</span>
|
||||
{env.id === activeEnvironmentId ? (
|
||||
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
|
||||
) : (
|
||||
<span className="ops-faint ml-auto pl-3 text-[10px]">
|
||||
{env.variables.filter((v) => v.enabled && v.key).length} 变量
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => setActiveEnvironment(env.id),
|
||||
})),
|
||||
{ type: "sep" as const },
|
||||
{
|
||||
label: "不使用环境",
|
||||
icon: <Icon name="circle-slash" size={14} />,
|
||||
onClick: () => setActiveEnvironment(null),
|
||||
},
|
||||
{
|
||||
label: "管理环境…",
|
||||
icon: <Icon name="settings" size={14} />,
|
||||
onClick: () => setDialog({ type: "environments" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -73,13 +40,13 @@ export function TopBar() {
|
||||
style={{ zIndex: 30 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
title="支持协议:HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT"
|
||||
>
|
||||
<Logo size={26} />
|
||||
<span className="text-[14px] font-bold tracking-tight">OpsxAPI</span>
|
||||
</div>
|
||||
<span className="ops-chip hidden whitespace-nowrap xl:inline-flex" style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||
HTTP · GraphQL · WS · SSE · Socket.IO · MQTT
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -113,27 +80,21 @@ export function TopBar() {
|
||||
|
||||
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||
|
||||
<button
|
||||
className="ops-envswitch"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setEnvMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
|
||||
}}
|
||||
title="切换环境"
|
||||
>
|
||||
{activeEnv ? (
|
||||
<>
|
||||
<EnvBadge envId={activeEnv.id} name={activeEnv.name} size={18} />
|
||||
<span className="max-w-28 truncate">{activeEnv.name}</span>
|
||||
<span className="ops-faint text-[10px]">
|
||||
{activeEnv.variables.filter((v) => v.enabled && v.key).length} 变量
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="ops-faint">不使用环境</span>
|
||||
)}
|
||||
<Icon name="chevron-down" size={13} className="ops-faint" />
|
||||
</button>
|
||||
<label className="ops-faint hidden items-center gap-1 text-[11px] lg:flex" title="发信通道(Run Mode)">
|
||||
通道
|
||||
<select
|
||||
className="ops-input py-1 text-xs"
|
||||
style={{ width: 110 }}
|
||||
value={transportMode}
|
||||
onChange={(e) => patchSettings({ transportMode: e.target.value as typeof transportMode })}
|
||||
>
|
||||
<option value="auto">自动</option>
|
||||
<option value="gin-proxy">Web 代理</option>
|
||||
<option value="browser">浏览器直连</option>
|
||||
<option value="tauri-native">桌面直连</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
|
||||
{newMenu && (
|
||||
@@ -145,10 +106,6 @@ export function TopBar() {
|
||||
onClose={() => setNewMenu(null)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
|
||||
onClose={() => setEnvMenu(null)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={
|
||||
exportMenu
|
||||
|
||||
@@ -250,6 +250,7 @@ export function KVEditor({
|
||||
valuePlaceholder = "Value",
|
||||
withDescription = false,
|
||||
compact = false,
|
||||
emptyHint,
|
||||
}: {
|
||||
rows: KVRow[];
|
||||
onChange?: (rows: KVRow[]) => void;
|
||||
@@ -260,6 +261,8 @@ export function KVEditor({
|
||||
valuePlaceholder?: string;
|
||||
withDescription?: boolean;
|
||||
compact?: boolean;
|
||||
/** 空表时的引导文案(如「添加鉴权 Header」) */
|
||||
emptyHint?: string;
|
||||
}) {
|
||||
const update = (id: string, patch: Partial<KVRow>) => {
|
||||
if (onUpdate) onUpdate(id, patch);
|
||||
@@ -328,6 +331,15 @@ export function KVEditor({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && emptyHint && (
|
||||
<div
|
||||
className="ops-faint cursor-pointer rounded-md border border-dashed px-3 py-2 text-[12px]"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
onClick={add}
|
||||
>
|
||||
{emptyHint}
|
||||
</div>
|
||||
)}
|
||||
<button className="ops-btn ops-btn-ghost px-2 py-1" onClick={add}>
|
||||
<Icon name="plus" size={13} /> 添加
|
||||
</button>
|
||||
|
||||
@@ -387,7 +387,7 @@ export function TeamDialog() {
|
||||
<Icon name="library" size={13} />
|
||||
</span>
|
||||
<span className="text-[13px] font-medium">{t.name}</span>
|
||||
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 11 }}>
|
||||
{t.role === "owner" ? "创建者" : "成员"}
|
||||
</span>
|
||||
<span className="ops-faint ml-auto text-[11px]">
|
||||
@@ -468,17 +468,17 @@ export function TeamDialog() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="ops-user-avatar" style={{ width: 20, height: 20, fontSize: 10 }}>
|
||||
<span className="ops-user-avatar" style={{ width: 20, height: 20, fontSize: 11 }}>
|
||||
{m.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[12px] font-medium">
|
||||
{m.name}
|
||||
{m.userId === me?.id && <span className="ops-faint ml-1 text-[10px]">(我)</span>}
|
||||
{m.userId === me?.id && <span className="ops-faint ml-1 text-[11px]">(我)</span>}
|
||||
</span>
|
||||
<span className="ops-faint block truncate text-[10px]">{m.email}</span>
|
||||
<span className="ops-faint block truncate text-[11px]">{m.email}</span>
|
||||
</span>
|
||||
<span className="ops-chip shrink-0" style={{ padding: "1px 7px", fontSize: 10 }}>
|
||||
<span className="ops-chip shrink-0" style={{ padding: "1px 7px", fontSize: 11 }}>
|
||||
{m.role === "owner" ? "创建者" : "成员"}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 pl-1">
|
||||
@@ -486,12 +486,12 @@ export function TeamDialog() {
|
||||
<SpecChip key={s} label={s} />
|
||||
))}
|
||||
{splitSpecialty(m.specialty).length > 3 && (
|
||||
<span className="ops-faint text-[10px]" title={m.specialty}>
|
||||
<span className="ops-faint text-[11px]" title={m.specialty}>
|
||||
+{splitSpecialty(m.specialty).length - 3}
|
||||
</span>
|
||||
)}
|
||||
{splitSpecialty(m.specialty).length === 0 && (
|
||||
<span className="ops-faint text-[10px]">未设置擅长</span>
|
||||
<span className="ops-faint text-[11px]">未设置擅长</span>
|
||||
)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
@@ -529,7 +529,7 @@ export function TeamDialog() {
|
||||
</div>
|
||||
{(inviteEmail[t.id] ?? "").trim() !== "" && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="ops-faint text-[10px]">擅长:</span>
|
||||
<span className="ops-faint text-[11px]">擅长:</span>
|
||||
{SPECIALTY_PRESETS.map((p) => {
|
||||
const picked = splitSpecialty(inviteSpecialty[t.id] ?? "").includes(p);
|
||||
return (
|
||||
|
||||
@@ -165,7 +165,7 @@ export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSave
|
||||
<span className="ops-faint mt-0.5 text-[11px] font-bold">{i + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium">{stepName(s.nodeId)}</div>
|
||||
<div className="ops-faint text-[10px]">
|
||||
<div className="ops-faint text-[11px]">
|
||||
{s.assertions.length} 条断言
|
||||
{s.extracts.length > 0 && ` · 提取 ${s.extracts.map((e) => e.varName).join(", ")}`}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
/**
|
||||
* Mock 链接弹窗:把当前接口发布到本地 Mock 服务(/mock/{空间ID}{路径}),
|
||||
* 发布后可复制 URL、浏览器试一试;响应体支持 @表达式按次随机生成。
|
||||
* Mock 本机联调弹窗:发布到本机 Mock 服务(/mock/{token}{路径})。
|
||||
* 默认不绑云端账号;响应体支持 @表达式按次随机生成。
|
||||
*/
|
||||
import { smartFillJson } from "@opsxapi/core";
|
||||
import type { HttpApiNode, MockSettings } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { publishMock } from "../../account";
|
||||
import { useEffect, useState } from "react";
|
||||
import { publishLocalMock, publishMock, revokeLocalMock, revokeMock } from "../../account";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
|
||||
const LOCAL_TOKEN_KEY = "opsxapi.localMockToken.v1";
|
||||
|
||||
function loadLocalToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(LOCAL_TOKEN_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocalToken(token: string) {
|
||||
try {
|
||||
if (!token) localStorage.removeItem(LOCAL_TOKEN_KEY);
|
||||
else localStorage.setItem(LOCAL_TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
const account = useWorkspace((s) => s.account);
|
||||
const settings = useWorkspace((s) => s.settings);
|
||||
@@ -17,16 +36,17 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
const space = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
|
||||
const useCloud = !!(account.token && space);
|
||||
const [localToken, setLocalToken] = useState(loadLocalToken);
|
||||
const [status, setStatus] = useState(200);
|
||||
const [delayMs, setDelayMs] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(0);
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// 路径:取请求 URL 的 path 部分(前置 URL 模式下即相对路径)
|
||||
const urlPath = extractPath(node.data.url);
|
||||
const method = node.data.method;
|
||||
|
||||
// 模板:JSON Body(智能填充烘焙 @表达式);非 JSON 用空对象
|
||||
let template = "{}";
|
||||
if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) {
|
||||
template = node.data.bodyRaw;
|
||||
@@ -36,24 +56,34 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const mockSeg = space?.mockToken ?? (space ? String(space.id) : "");
|
||||
const mockSeg = useCloud
|
||||
? space?.mockToken ?? (space ? String(space.id) : "")
|
||||
: localToken;
|
||||
const mockUrl = mockSeg ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${mockSeg}${urlPath}` : "";
|
||||
|
||||
useEffect(() => {
|
||||
setLocalToken(loadLocalToken());
|
||||
}, []);
|
||||
|
||||
const publish = async () => {
|
||||
if (!account.token || !space) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
// 全量发布:集合内全部 HTTP 接口(同空间共用一组 Mock 端点)
|
||||
const endpoints = collectEndpoints(collections, settings);
|
||||
// 当前接口应用弹窗选择的状态码与延迟
|
||||
const mine = endpoints.find((e) => e.method === method && e.path === urlPath);
|
||||
if (mine) {
|
||||
mine.status = status;
|
||||
mine.delayMs = delayMs;
|
||||
}
|
||||
await publishMock(account.token, space.id, endpoints);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到 /mock/${space.id}` });
|
||||
if (useCloud && account.token && space) {
|
||||
await publishMock(account.token, space.id, endpoints, ttlSeconds);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(云端空间)` });
|
||||
} else {
|
||||
const r = await publishLocalMock(endpoints, ttlSeconds);
|
||||
saveLocalToken(r.token);
|
||||
setLocalToken(r.token);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(本地空间)` });
|
||||
}
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
@@ -62,7 +92,10 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (!mockUrl) return;
|
||||
if (!mockUrl) {
|
||||
setResult({ ok: false, message: "请先发布,生成本机联调地址" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
@@ -76,19 +109,41 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
if (useCloud && account.token && space) {
|
||||
await revokeMock(account.token, space.id);
|
||||
} else {
|
||||
await revokeLocalMock();
|
||||
saveLocalToken("");
|
||||
setLocalToken("");
|
||||
}
|
||||
setResult({ ok: true, message: "已作废:旧链接立即失效,需重新发布才可用" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Mock 链接"
|
||||
title="本机联调地址"
|
||||
onClose={() => setDialog(null)}
|
||||
width="620px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">本地 Mock 服务 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||||
<span className="ops-faint text-[11px]">仅本机 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||||
<div className="flex gap-2">
|
||||
<button className="ops-btn ops-btn-secondary" onClick={preview} disabled={!space || busy}>
|
||||
<button className="ops-btn ops-btn-ghost ops-danger" onClick={revoke} disabled={busy || !mockUrl} title="作废当前 token,旧链接立刻 4xx">
|
||||
下线 / 作废
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={preview} disabled={busy || !mockUrl}>
|
||||
<Icon name="play" size={13} /> 试一试
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-primary" onClick={publish} disabled={!space || busy}>
|
||||
<button className="ops-btn ops-btn-primary" onClick={publish} disabled={busy}>
|
||||
<Icon name="upload" size={13} /> {busy ? "发布中…" : "发布 / 更新"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -96,83 +151,84 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
>
|
||||
<div className="space-y-3.5">
|
||||
{!account.user ? (
|
||||
<div className="space-y-3">
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>Mock 链接挂在云端空间下(按空间隔离),请先登录并切换到个人 / 团队空间。</span>
|
||||
</div>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "auth" })}>
|
||||
<Icon name="shield-check" size={13} /> 登录 / 注册
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>
|
||||
Mock 默认只在本机 <code>127.0.0.1:8787</code> 提供联调,不上官网/文档站。
|
||||
{useCloud ? "当前走云端空间隔离。" : "当前本地空间,无需登录即可发布。"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">本机联调地址</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="ops-input flex-1 font-mono !text-[12px]"
|
||||
readOnly
|
||||
value={mockUrl || "发布后显示本机地址"}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
disabled={!mockUrl}
|
||||
onClick={() => {
|
||||
if (!mockUrl) return;
|
||||
void navigator.clipboard.writeText(mockUrl);
|
||||
setResult({ ok: true, message: "已复制本机联调地址" });
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
) : !space ? (
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>当前在本地空间。请通过顶栏账号胶囊切换到「我的空间」或团队空间后再发布 Mock。</span>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前集合全部 HTTP 接口
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="ops-label">Mock 地址(前端联调直接请求它)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="ops-input flex-1 font-mono !text-[12px]" readOnly value={mockUrl} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary !px-2"
|
||||
title="复制"
|
||||
onClick={() => void navigator.clipboard.writeText(mockUrl)}
|
||||
>
|
||||
<Icon name="copy" size={13} />
|
||||
</button>
|
||||
{method === "GET" && (
|
||||
<a className="ops-btn ops-btn-ghost !px-2" href={mockUrl} target="_blank" rel="noopener noreferrer" title="浏览器打开">
|
||||
<Icon name="external-link" size={13} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前空间集合内全部 HTTP 接口
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="ops-label">响应状态码</label>
|
||||
<select className="ops-input" value={status} onChange={(e) => setStatus(Number(e.target.value))}>
|
||||
{[200, 201, 204, 400, 401, 403, 404, 429, 500].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">延迟(毫秒,模拟慢接口)</label>
|
||||
<input
|
||||
className="ops-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={delayMs}
|
||||
onChange={(e) => setDelayMs(Math.max(0, Math.min(5000, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">响应模板(@表达式每次请求随机生成)</label>
|
||||
<textarea className="ops-code min-h-36 w-full resize-y !text-[12px]" readOnly value={template} />
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<label className="block space-y-1">
|
||||
<span className="ops-label">有效期(可选 TTL)</span>
|
||||
<select className="ops-input w-full" value={ttlSeconds} onChange={(e) => setTtlSeconds(Number(e.target.value) || 0)}>
|
||||
<option value={0}>不过期</option>
|
||||
<option value={3600}>1 小时</option>
|
||||
<option value={21600}>6 小时</option>
|
||||
<option value={86400}>24 小时</option>
|
||||
<option value={604800}>7 天</option>
|
||||
</select>
|
||||
<div className="ops-faint text-[11px]">到期后旧链接自动失效;作废可立刻让旧 token 4xx。</div>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="ops-label">状态码</span>
|
||||
<input
|
||||
className="ops-input w-full"
|
||||
type="number"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(Number(e.target.value) || 200)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="ops-label">延迟 ms</span>
|
||||
<input
|
||||
className="ops-input w-full"
|
||||
type="number"
|
||||
value={delayMs}
|
||||
onChange={(e) => setDelayMs(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="ops-label mb-1">响应模板预览</div>
|
||||
<pre className={cx("ops-code max-h-40 overflow-auto text-[11px]")}>{template.slice(0, 2000)}</pre>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className={cx("rounded-lg px-3 py-2 text-[12px] font-mono", result.ok ? "ops-success" : "ops-danger")}
|
||||
style={{ border: "1px solid var(--border)" }}>
|
||||
<div className={cx("rounded border px-2.5 py-1.5 text-[12px]", result.ok ? "ops-hint" : "")} style={!result.ok ? { color: "var(--ops-danger)" } : undefined}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
@@ -182,11 +238,16 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
|
||||
function extractPath(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return "/";
|
||||
const m = /^https?:\/\/[^/]+(\/[^\s?#]*)?/.exec(trimmed);
|
||||
if (m) return m[1] || "/";
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
try {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return new URL(url).pathname || "/";
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
const q = url.indexOf("?");
|
||||
const path = (q >= 0 ? url.slice(0, q) : url).trim() || "/";
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
function collectEndpoints(
|
||||
@@ -194,9 +255,14 @@ function collectEndpoints(
|
||||
settings: { mock: MockSettings },
|
||||
): { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] {
|
||||
const out: { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] = [];
|
||||
const seen = new Map<string, number>(); // method+path 去重(保留靠后的目录项)
|
||||
const seen = new Map<string, number>();
|
||||
const walk = (nodes: unknown[]): void => {
|
||||
for (const n of nodes as { type?: string; kind?: string; children?: unknown[]; data?: { url?: string; method?: string; bodyMode?: string; bodyRaw?: string } }[]) {
|
||||
for (const n of nodes as {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
children?: unknown[];
|
||||
data?: { url?: string; method?: string; bodyMode?: string; bodyRaw?: string };
|
||||
}[]) {
|
||||
if (n.type === "folder") {
|
||||
walk(n.children ?? []);
|
||||
} else if (n.type === "request" && n.kind === "http" && n.data) {
|
||||
|
||||
@@ -86,7 +86,7 @@ export function NewItemDialog({
|
||||
<div className="mt-1.5 text-xs font-semibold">
|
||||
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
|
||||
</div>
|
||||
<div className="ops-faint mt-0.5 text-[10px]">{k.desc}</div>
|
||||
<div className="ops-faint mt-0.5 text-[11px]">{k.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -915,7 +915,7 @@ function AiSettingsPane() {
|
||||
onChange={(e) => updateAiProvider(pr.id, { name: e.target.value })}
|
||||
placeholder="名称"
|
||||
/>
|
||||
<span className={cx("ops-chip", isActive && "ops-chip-active")} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||
<span className={cx("ops-chip", isActive && "ops-chip-active")} style={{ padding: "1px 8px", fontSize: 11 }}>
|
||||
{isActive ? "当前使用" : "未选用"}
|
||||
</span>
|
||||
{!isActive && (
|
||||
@@ -940,7 +940,7 @@ function AiSettingsPane() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="ops-faint text-[10px]">Base URL</label>
|
||||
<label className="ops-faint text-[11px]">Base URL</label>
|
||||
<input
|
||||
className="ops-input ops-input-sm w-full font-mono"
|
||||
value={pr.baseUrl}
|
||||
@@ -949,7 +949,7 @@ function AiSettingsPane() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-faint text-[10px]">模型</label>
|
||||
<label className="ops-faint text-[11px]">模型</label>
|
||||
<input
|
||||
className="ops-input ops-input-sm w-full font-mono"
|
||||
value={pr.model}
|
||||
@@ -958,7 +958,7 @@ function AiSettingsPane() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="ops-faint text-[10px]">API Key(本机保存,Ollama 可留空)</label>
|
||||
<label className="ops-faint text-[11px]">API Key(本机保存,Ollama 可留空)</label>
|
||||
<input
|
||||
className="ops-input ops-input-sm w-full font-mono"
|
||||
type="password"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { activeEnvMap, useWorkspace } from "../../workspaceStore";
|
||||
import { ContextMenu, CopyButton, KVEditor, cx, type MenuItem } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { UrlPathInput, joinUrl, useEnvBaseUrl } from "../UrlPathInput";
|
||||
import { SplitPane } from "../SplitPane";
|
||||
import { ResponsePanel } from "./ResponsePanel";
|
||||
|
||||
type BodyTab = "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data";
|
||||
@@ -30,6 +31,8 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
const data = node.data;
|
||||
const update = useWorkspace((s) => s.updateRequestData);
|
||||
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
|
||||
const saveRequest = useWorkspace((s) => s.saveRequest);
|
||||
const dirty = useWorkspace((s) => Boolean(s.dirtyIds[node.id]));
|
||||
const runtime = useWorkspace((s) => s.runtime[node.id]);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
const [tab, setTab] = useState<ReqTab>("Params");
|
||||
@@ -90,6 +93,14 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
>
|
||||
<Icon name="play" size={13} /> {runtime?.loading ? "发送中…" : "发送"}
|
||||
</button>
|
||||
<button
|
||||
className={cx("ops-btn ops-btn-secondary min-w-[72px]")}
|
||||
disabled={!dirty}
|
||||
onClick={() => saveRequest(node.id)}
|
||||
title={dirty ? "保存到集合树" : "已保存"}
|
||||
>
|
||||
{dirty ? "● 保存" : "已保存"}
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
onClick={(e) => {
|
||||
@@ -103,9 +114,9 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
onClick={() => setDialog({ type: "mocklink", nodeId: node.id })}
|
||||
title="发布到本地 Mock 服务,获取可分享的 Mock 地址"
|
||||
title="发布到本机 Mock 服务,获取本机联调地址"
|
||||
>
|
||||
<Icon name="globe" size={13} /> Mock 链接
|
||||
<Icon name="globe" size={13} /> 本机联调
|
||||
</button>
|
||||
</div>
|
||||
{(data.url.includes("{{") || (envBase && (data.url === "" || data.url.trimStart().startsWith("/")))) && (
|
||||
@@ -114,39 +125,40 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 请求 tabs */}
|
||||
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
{REQ_TABS.map((t) => (
|
||||
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
|
||||
{t}
|
||||
{counts[t] ? <span className="ops-count">{counts[t]}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ops-scroll shrink-0 p-3.5" style={{ maxHeight: "42%", minHeight: 120 }}>
|
||||
{tab === "Params" && (
|
||||
<KVEditor rows={data.query} onChange={(rows: KVRow[]) => patch({ query: rows })} withDescription />
|
||||
)}
|
||||
{tab === "Headers" && (
|
||||
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
|
||||
)}
|
||||
{tab === "Cookies" && (
|
||||
<KVEditor rows={data.cookies} onChange={(rows: KVRow[]) => patch({ cookies: rows })} />
|
||||
)}
|
||||
{tab === "Auth" && <AuthEditor data={data} patch={patch} />}
|
||||
{tab === "Body" && <BodyEditor data={data} patch={patch} />}
|
||||
{tab === "说明" && (
|
||||
<textarea
|
||||
className="ops-code min-h-40 w-full resize-y"
|
||||
placeholder="接口说明(支持 Markdown)"
|
||||
value={node.description ?? ""}
|
||||
onChange={(e) => updateDesc(node.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResponsePanel nodeId={node.id} />
|
||||
<SplitPane direction="vertical" initialRatio={0.45} storageKey={`opsxapi.http.split.${node.id}`} minPx={100}>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="ops-toolbar flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
{REQ_TABS.map((t) => (
|
||||
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
|
||||
{t}
|
||||
{counts[t] ? <span className="ops-count">{counts[t]}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ops-scroll min-h-0 flex-1 p-3.5">
|
||||
{tab === "Params" && (
|
||||
<KVEditor rows={data.query} onChange={(rows: KVRow[]) => patch({ query: rows })} withDescription />
|
||||
)}
|
||||
{tab === "Headers" && (
|
||||
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
|
||||
)}
|
||||
{tab === "Cookies" && (
|
||||
<KVEditor rows={data.cookies} onChange={(rows: KVRow[]) => patch({ cookies: rows })} />
|
||||
)}
|
||||
{tab === "Auth" && <AuthEditor data={data} patch={patch} />}
|
||||
{tab === "Body" && <BodyEditor data={data} patch={patch} />}
|
||||
{tab === "说明" && (
|
||||
<textarea
|
||||
className="ops-code min-h-40 w-full resize-y"
|
||||
placeholder="接口说明(支持 Markdown)"
|
||||
value={node.description ?? ""}
|
||||
onChange={(e) => updateDesc(node.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsePanel nodeId={node.id} />
|
||||
</SplitPane>
|
||||
|
||||
<ContextMenu
|
||||
menu={codeMenu ? { x: codeMenu.x, y: codeMenu.y, items: codeItems } : null}
|
||||
@@ -249,6 +261,34 @@ export function AuthEditor({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.mode !== "none" && <AuthPreview auth={auth} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthPreview({ auth }: { auth: HttpRequestData["auth"] }) {
|
||||
const env = activeEnvMap();
|
||||
let line = "";
|
||||
if (auth.mode === "bearer" && auth.bearer) {
|
||||
const token = replaceVariables(auth.bearer, env);
|
||||
const masked = token.length <= 8 ? "••••" : `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||
line = `Authorization: Bearer ${masked}`;
|
||||
} else if (auth.mode === "basic") {
|
||||
const user = replaceVariables(auth.basicUser ?? "", env);
|
||||
line = `Authorization: Basic ${user}:••••`;
|
||||
} else if (auth.mode === "apikey" && auth.apiKeyName) {
|
||||
const value = replaceVariables(auth.apiKeyValue ?? "", env);
|
||||
const masked = value.length <= 6 ? "••••" : `${value.slice(0, 3)}…`;
|
||||
line =
|
||||
auth.apiKeyIn === "query"
|
||||
? `?${auth.apiKeyName}=${masked}`
|
||||
: `${auth.apiKeyName}: ${masked}`;
|
||||
}
|
||||
if (!line) return null;
|
||||
return (
|
||||
<div className="ops-faint rounded-md border border-dashed px-2.5 py-2 font-mono text-[11px]" style={{ borderColor: "var(--border)" }}>
|
||||
<div className="mb-0.5 text-[11px] uppercase tracking-wide opacity-70">鉴权预览(已套环境变量)</div>
|
||||
<div className="text-[12px] text-[var(--text)]">{line}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { CopyButton, JsonView, cx, formatBytes } from "../common";
|
||||
import { ProxyErrorBanner } from "../ProxyErrorBanner";
|
||||
|
||||
export function ResponsePanel({ nodeId }: { nodeId: string }) {
|
||||
const runtime = useWorkspace((s) => s.runtime[nodeId]);
|
||||
@@ -57,9 +58,11 @@ export function ResponsePanel({ nodeId }: { nodeId: string }) {
|
||||
<div className="flex items-center gap-3 pr-1 text-xs">
|
||||
{result ? (
|
||||
<>
|
||||
<span>
|
||||
状态{" "}
|
||||
<b className={result.status < 400 ? "ops-success" : "ops-danger"}>{result.status}</b>
|
||||
<span className={cx(
|
||||
"ops-status-pill",
|
||||
result.status >= 500 ? "ops-status-5xx" : result.status >= 400 ? "ops-status-4xx" : result.status >= 300 ? "ops-status-3xx" : "ops-status-2xx",
|
||||
)}>
|
||||
{result.status}
|
||||
</span>
|
||||
<span className="ops-dim">
|
||||
耗时 <b>{result.timeMs} ms</b>
|
||||
@@ -83,12 +86,10 @@ export function ResponsePanel({ nodeId }: { nodeId: string }) {
|
||||
<div className="ops-scroll min-h-0 flex-1 p-3">
|
||||
{loading && <div className="ops-dim animate-pulse py-6 text-center text-sm">请求发送中…</div>}
|
||||
{!loading && error && (
|
||||
<div
|
||||
className="ops-msg ops-msg-err mb-3 !font-sans text-[13px]"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
<ProxyErrorBanner
|
||||
message={error}
|
||||
onDismiss={() => setRuntime(nodeId, { error: null })}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && !result && (
|
||||
<div className="ops-faint grid h-full place-items-center text-sm">
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
/**
|
||||
* WebSocket 工作台。
|
||||
* WebSocket 工作台:左鉴权/命令 · 右发送/日志(设计师 P0)
|
||||
*/
|
||||
import type { KVRow, WebsocketApiNode, WebsocketRequestData } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { connectWs, wsSend } from "../../send";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { SplitPane } from "../SplitPane";
|
||||
import { KVEditor, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { VarHintInput } from "../VarHintInput";
|
||||
import { Composer, LiveLog, LiveStatus } from "./liveCommon";
|
||||
|
||||
const TABS = ["消息", "握手配置", "说明"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
const COMMANDS = [
|
||||
{ id: "ping", label: "Ping", body: "{\n \"op\": \"ping\"\n}" },
|
||||
{ id: "auth", label: "Auth", body: "{\n \"op\": \"auth\",\n \"token\": \"{{token}}\"\n}" },
|
||||
{ id: "sub", label: "Subscribe", body: "{\n \"op\": \"subscribe\",\n \"args\": [\"topic\"]\n}" },
|
||||
{ id: "unsub", label: "Unsubscribe", body: "{\n \"op\": \"unsubscribe\",\n \"args\": [\"topic\"]\n}" },
|
||||
];
|
||||
|
||||
export function WebsocketEditor({ node }: { node: WebsocketApiNode }) {
|
||||
const data = node.data;
|
||||
const update = useWorkspace((s) => s.updateRequestData);
|
||||
const updateRequestData = useWorkspace((s) => s.updateRequestData);
|
||||
const saveRequest = useWorkspace((s) => s.saveRequest);
|
||||
const dirty = useWorkspace((s) => Boolean(s.dirtyIds[node.id]));
|
||||
const runtime = useWorkspace((s) => s.runtime[node.id]);
|
||||
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
|
||||
const [tab, setTab] = useState<Tab>("消息");
|
||||
const [input, setInput] = useState('{\n "op": "ping"\n}');
|
||||
const updateNodeDescription = useWorkspace((s) => s.updateNodeDescription);
|
||||
const [input, setInput] = useState("{\n \"op\": \"ping\"\n}");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showDesc, setShowDesc] = useState(false);
|
||||
|
||||
const patch = (p: Partial<WebsocketRequestData>) => update(node.id, p);
|
||||
const patch = (p: Partial<WebsocketRequestData>) => updateRequestData(node.id, p);
|
||||
const connected = runtime?.status === "open";
|
||||
|
||||
const websocat = useMemo(() => {
|
||||
const headers = (data.headers ?? [])
|
||||
.filter((r) => r.enabled && r.key.trim())
|
||||
.map((r) => `-H ${JSON.stringify(`${r.key}: ${r.value}`)}`)
|
||||
.join(" ");
|
||||
return `websocat ${JSON.stringify(data.url || "wss://")}${headers ? ` ${headers}` : ""}`;
|
||||
}, [data.url, data.headers]);
|
||||
|
||||
const applyCmd = (body: string, direct: boolean) => {
|
||||
setInput(body);
|
||||
if (direct && connected) wsSend(node.id, body);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2.5">
|
||||
{/* 连接栏 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}><Icon name="zap" size={13} /> WS</span>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="ops-toolbar flex shrink-0 flex-wrap items-center gap-2 px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}>
|
||||
<Icon name="zap" size={13} /> WS
|
||||
</span>
|
||||
<VarHintInput
|
||||
className="ops-input flex-1 font-mono text-[13px]"
|
||||
placeholder="wss://echo.websocket.org"
|
||||
@@ -42,59 +67,93 @@ export function WebsocketEditor({ node }: { node: WebsocketApiNode }) {
|
||||
<Icon name={connected ? "unplug" : "plug-zap"} size={13} />
|
||||
{runtime?.status === "connecting" ? "连接中…" : connected ? "断开" : "连接"}
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary min-w-[72px]"
|
||||
disabled={!dirty}
|
||||
onClick={() => saveRequest(node.id)}
|
||||
title={dirty ? "保存到集合树" : "已保存"}
|
||||
>
|
||||
{dirty ? "● 保存" : "已保存"}
|
||||
</button>
|
||||
<LiveStatus nodeId={node.id} />
|
||||
<button className="ops-btn ops-btn-ghost text-xs" onClick={() => setShowDesc((v) => !v)}>
|
||||
说明
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* tabs */}
|
||||
<div className="flex shrink-0 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
{TABS.map((t) => (
|
||||
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "消息" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="min-h-0 flex-1">
|
||||
<LiveLog nodeId={node.id} />
|
||||
</div>
|
||||
<Composer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
disabled={!connected}
|
||||
onSend={() => wsSend(node.id, input)}
|
||||
sendLabel="发送消息"
|
||||
/>
|
||||
</div>
|
||||
{showDesc && (
|
||||
<textarea
|
||||
className="ops-code mx-3 mt-2 min-h-20 shrink-0 resize-y"
|
||||
placeholder="接口说明(支持 Markdown)"
|
||||
value={node.description ?? ""}
|
||||
onChange={(e) => updateNodeDescription(node.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "握手配置" && (
|
||||
<div className="ops-scroll max-w-2xl space-y-4 p-1">
|
||||
<SplitPane direction="horizontal" initialRatio={0.36} storageKey={`opsxapi.ws.split.h.${node.id}`} minPx={220}>
|
||||
<aside className="ops-scroll flex h-full flex-col gap-3 p-3" style={{ background: "var(--panel)" }}>
|
||||
<div>
|
||||
<div className="ops-label mb-1.5">鉴权 / 握手 Headers</div>
|
||||
<KVEditor rows={data.headers ?? []} onChange={(rows: KVRow[]) => patch({ headers: rows })} emptyHint="点击添加鉴权 Header,例如 Authorization: Bearer {{token}}" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">子协议(逗号分隔)</label>
|
||||
<input
|
||||
className="ops-input font-mono"
|
||||
placeholder="graphql-transport-ws, json"
|
||||
value={data.protocols}
|
||||
value={data.protocols ?? ""}
|
||||
onChange={(e) => patch({ protocols: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">握手 Header(浏览器直连不支持自定义 Header,将自动走 Gin 隧道)</label>
|
||||
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
|
||||
<div className="ops-label mb-1.5">快捷命令</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{COMMANDS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className="ops-btn ops-btn-secondary justify-start text-left text-xs"
|
||||
title="点击填入;Shift+点击直接发送"
|
||||
onClick={(e) => applyCmd(c.body, e.shiftKey)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[11px]">点击填入发送区 · Shift+点击直发</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button className="ops-btn ops-btn-ghost px-0 text-xs" onClick={() => setShowAdvanced((v) => !v)}>
|
||||
{showAdvanced ? "收起高级" : "高级 · websocat 命令"}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
<textarea className="ops-code min-h-20 w-full resize-y text-[11px]" readOnly value={websocat} />
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary text-xs"
|
||||
onClick={() => navigator.clipboard.writeText(websocat)}
|
||||
>
|
||||
复制命令
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{tab === "说明" && (
|
||||
<textarea
|
||||
className="ops-code min-h-40 w-full resize-y"
|
||||
placeholder="接口说明(支持 Markdown)"
|
||||
value={node.description ?? ""}
|
||||
onChange={(e) => updateDesc(node.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<SplitPane direction="vertical" initialRatio={0.38} storageKey={`opsxapi.ws.split.v.${node.id}`} minPx={100}>
|
||||
<div className="flex h-full min-h-0 flex-col p-2">
|
||||
<Composer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
disabled={!connected} disabledHint="连接后可发送"
|
||||
onSend={() => wsSend(node.id, input)}
|
||||
sendLabel="发送消息"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-col p-2 pt-0">
|
||||
<LiveLog nodeId={node.id} />
|
||||
</div>
|
||||
</SplitPane>
|
||||
</SplitPane>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,86 @@
|
||||
/**
|
||||
* 实时协议编辑器共用件:消息日志 / 发送器 / 状态栏。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { StatusDot, cx, formatTime } from "../common";
|
||||
|
||||
/** 稳定空数组:避免 useSyncExternalStore 因新引用无限重渲染 */
|
||||
const EMPTY_MESSAGES: never[] = [];
|
||||
|
||||
type Filter = "all" | "out" | "in" | "sys" | "error";
|
||||
|
||||
export function LiveLog({ nodeId }: { nodeId: string }) {
|
||||
const messages = useWorkspace((s) => s.runtime[nodeId]?.messages ?? EMPTY_MESSAGES);
|
||||
const clearLive = useWorkspace((s) => s.clearLive);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [pinned, setPinned] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "all") return messages;
|
||||
if (filter === "sys") return messages.filter((m) => m.dir !== "in" && m.dir !== "out" && m.dir !== "error");
|
||||
return messages.filter((m) => m.dir === filter);
|
||||
}, [messages, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [messages.length]);
|
||||
if (!pinned && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [filtered.length, pinned]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setPinned(!atBottom);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-card flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="ops-card relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-3 py-2"
|
||||
className="flex shrink-0 flex-wrap items-center justify-between gap-2 px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="text-xs font-semibold">消息日志 · {messages.length}</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="text-xs font-semibold">消息日志 · {filtered.length}/{messages.length}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(
|
||||
[
|
||||
["all", "全部"],
|
||||
["out", "发送"],
|
||||
["in", "接收"],
|
||||
["sys", "系统"],
|
||||
["error", "错误"],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
className={cx("ops-filter-chip", filter === k && "ops-filter-chip-active")}
|
||||
onClick={() => setFilter(k)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button className="ops-btn ops-btn-ghost py-0.5" onClick={() => clearLive(nodeId)}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={ref} className="ops-scroll min-h-0 flex-1 space-y-1.5 p-3" style={{ background: "var(--code-bg)" }}>
|
||||
{messages.length === 0 && (
|
||||
<div
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
className="ops-scroll min-h-0 flex-1 space-y-1.5 p-3"
|
||||
style={{ background: "var(--code-bg)" }}
|
||||
>
|
||||
{filtered.length === 0 && (
|
||||
<div className="ops-faint grid h-full place-items-center text-xs">连接后收发消息会显示在这里</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={cx("ops-msg", `ops-msg-${m.dir === "in" ? "in" : m.dir === "out" ? "out" : m.dir === "error" ? "err" : "sys"}`)}>
|
||||
<div className="mb-0.5 flex items-center gap-2 text-[10px] uppercase tracking-wide opacity-60">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cx(
|
||||
"ops-msg",
|
||||
`ops-msg-${m.dir === "in" ? "in" : m.dir === "out" ? "out" : m.dir === "error" ? "err" : "sys"}`,
|
||||
)}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-60">
|
||||
<span>{formatTime(m.at)}</span>
|
||||
<span className="rounded px-1" style={{ background: "var(--hover)" }}>
|
||||
{m.dir === "in" ? "接收" : m.dir === "out" ? "发送" : m.dir === "error" ? "错误" : "系统"}
|
||||
@@ -47,6 +91,17 @@ export function LiveLog({ nodeId }: { nodeId: string }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pinned && messages.length > 0 && (
|
||||
<button
|
||||
className="ops-btn ops-btn-primary absolute bottom-3 right-3 text-xs shadow"
|
||||
onClick={() => {
|
||||
setPinned(false);
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}}
|
||||
>
|
||||
回到底部
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +113,7 @@ export function Composer({
|
||||
disabled,
|
||||
placeholder = "消息内容(JSON / 文本)",
|
||||
sendLabel = "发送",
|
||||
disabledHint = "连接后可发送",
|
||||
extra,
|
||||
}: {
|
||||
value: string;
|
||||
@@ -66,14 +122,15 @@ export function Composer({
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
sendLabel?: string;
|
||||
disabledHint?: string;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ops-card flex shrink-0 flex-col overflow-hidden">
|
||||
<div className="ops-card flex h-full min-h-0 shrink-0 flex-col overflow-hidden">
|
||||
{extra}
|
||||
<div className="p-2.5">
|
||||
<div className="min-h-0 flex-1 p-2.5">
|
||||
<textarea
|
||||
className="ops-code min-h-24 w-full resize-y"
|
||||
className="ops-code h-full min-h-24 w-full resize-none"
|
||||
spellCheck={false}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
@@ -99,7 +156,9 @@ export function Composer({
|
||||
>
|
||||
格式化 JSON
|
||||
</button>
|
||||
<span className="ops-faint ml-auto text-[11px]">⌘/Ctrl + Enter 快速发送</span>
|
||||
<span className="ops-faint ml-auto text-[11px]">
|
||||
{disabled && disabledHint ? disabledHint : "⌘/Ctrl + Enter 快速发送"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -115,7 +174,8 @@ export function LiveStatus({ nodeId, extraStats }: { nodeId: string; extraStats?
|
||||
reconnecting: "重连中",
|
||||
error: "错误",
|
||||
};
|
||||
const elapsed = runtime?.startedAt && status === "open" ? Math.round((Date.now() - runtime.startedAt) / 1000) : null;
|
||||
const elapsed =
|
||||
runtime?.startedAt && status === "open" ? Math.round((Date.now() - runtime.startedAt) / 1000) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
|
||||
+100
-16
@@ -3,18 +3,19 @@
|
||||
* 实时会话注册表不进 React 状态,只把消息/状态写入 store。
|
||||
*/
|
||||
import type {
|
||||
HistoryEntry,
|
||||
GraphqlRequestData,
|
||||
HttpRequestData,
|
||||
HttpSpec,
|
||||
MockSettings,
|
||||
RequestNode,
|
||||
} from "@opsxapi/core";
|
||||
import { applyMockToJson, applyMockToValue, replaceVariables } from "@opsxapi/core";
|
||||
import { applyMockToJson, applyMockToValue, findNode, replaceVariables } from "@opsxapi/core";
|
||||
import {
|
||||
connectMqtt,
|
||||
connectSocketIo,
|
||||
openSse,
|
||||
wsSmart,
|
||||
wsSmart, isTauriRuntime, tauriNativeTransport,
|
||||
type MqttSession,
|
||||
type SocketIoSession,
|
||||
type StreamSession,
|
||||
@@ -162,7 +163,11 @@ export function buildHttpSpec(
|
||||
|
||||
async function getTransport() {
|
||||
const { detectTransport } = await import("@opsxapi/transport");
|
||||
const { kind, transport } = detectTransport({ ginBaseUrl: "" });
|
||||
const mode = useWorkspace.getState().settings.transportMode ?? "auto";
|
||||
const { kind, transport } = detectTransport({
|
||||
ginBaseUrl: "",
|
||||
force: mode === "auto" ? undefined : mode,
|
||||
});
|
||||
return { kind, transport };
|
||||
}
|
||||
|
||||
@@ -171,6 +176,7 @@ async function sendHttpLike(
|
||||
label: string,
|
||||
spec: HttpSpec,
|
||||
url: string,
|
||||
opts?: { fromReplay?: boolean },
|
||||
): Promise<void> {
|
||||
const store = useWorkspace.getState();
|
||||
store.setRuntime(id, { loading: true, error: null });
|
||||
@@ -178,6 +184,7 @@ async function sendHttpLike(
|
||||
const { transport } = await getTransport();
|
||||
try {
|
||||
const result = await transport.http(spec);
|
||||
const durationMs = Math.round(performance.now() - started) || result.timeMs;
|
||||
useWorkspace.getState().setRuntime(id, { loading: false, result });
|
||||
useWorkspace.getState().addHistory({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -187,10 +194,19 @@ async function sendHttpLike(
|
||||
method: spec.method,
|
||||
url,
|
||||
status: result.status,
|
||||
durationMs: result.timeMs,
|
||||
durationMs,
|
||||
ok: result.status < 400,
|
||||
nodeId: id,
|
||||
});
|
||||
if (opts?.fromReplay) {
|
||||
const ok = result.status < 400;
|
||||
useWorkspace.getState().showToast(
|
||||
ok
|
||||
? `重放成功 · HTTP ${result.status} · ${durationMs}ms`
|
||||
: `重放完成 · HTTP ${result.status}(非 2xx)· ${durationMs}ms`,
|
||||
ok ? "success" : "error",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
useWorkspace.getState().setRuntime(id, { loading: false, error: message });
|
||||
@@ -204,15 +220,20 @@ async function sendHttpLike(
|
||||
ok: false,
|
||||
nodeId: id,
|
||||
});
|
||||
void started;
|
||||
if (opts?.fromReplay) {
|
||||
useWorkspace.getState().showToast(`重放失败:${message.slice(0, 120)}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sendHttpRequest(node: RequestNode & { kind: "http" }): void {
|
||||
export function sendHttpRequest(
|
||||
node: RequestNode & { kind: "http" },
|
||||
opts?: { fromReplay?: boolean },
|
||||
): void {
|
||||
const env = activeEnvMap();
|
||||
const { timeoutMs, mock } = useWorkspace.getState().settings;
|
||||
const spec = buildHttpSpec(node.data, env, timeoutMs, mock, activeEnvBaseUrl());
|
||||
void sendHttpLike(node.id, node.name, spec, spec.url);
|
||||
void sendHttpLike(node.id, node.name, spec, spec.url, opts);
|
||||
}
|
||||
|
||||
export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): void {
|
||||
@@ -247,6 +268,52 @@ export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): voi
|
||||
|
||||
/* ---------------- WebSocket ---------------- */
|
||||
|
||||
|
||||
/** 历史一键重放:打开原请求并再次发送/连接 */
|
||||
export function replayHistory(entry: HistoryEntry): void {
|
||||
const store = useWorkspace.getState();
|
||||
if (!entry.nodeId) {
|
||||
store.showToast("无法重放:该记录未关联请求节点", "error");
|
||||
return;
|
||||
}
|
||||
const found = findNode(store.collections, entry.nodeId);
|
||||
if (!found || found.node.type !== "request") {
|
||||
store.showToast(`无法重放「${entry.label}」:原请求已删除或不可用`, "error");
|
||||
return;
|
||||
}
|
||||
const node = found.node as RequestNode;
|
||||
store.openTab(node.id);
|
||||
store.showToast(`正在重放「${entry.label}」…`, "info");
|
||||
if (node.kind === "http") {
|
||||
sendHttpRequest(node as RequestNode & { kind: "http" }, { fromReplay: true });
|
||||
return;
|
||||
}
|
||||
if (node.kind === "websocket") {
|
||||
connectWs(node as RequestNode & { kind: "websocket" });
|
||||
// 连接态变化由 runtime 驱动;此处给即时反馈
|
||||
window.setTimeout(() => {
|
||||
const st = useWorkspace.getState().runtime[node.id]?.status;
|
||||
if (st === "open") {
|
||||
useWorkspace.getState().showToast(`重放连接成功「${entry.label}」`, "success");
|
||||
} else if (st === "error" || st === "closed") {
|
||||
useWorkspace.getState().showToast(`重放连接未成功「${entry.label}」`, "error");
|
||||
}
|
||||
}, 1600);
|
||||
return;
|
||||
}
|
||||
if (node.kind === "sse") {
|
||||
connectSse(node as RequestNode & { kind: "sse" });
|
||||
window.setTimeout(() => {
|
||||
const st = useWorkspace.getState().runtime[node.id]?.status;
|
||||
if (st === "open") {
|
||||
useWorkspace.getState().showToast(`重放 SSE 已连接「${entry.label}」`, "success");
|
||||
} else if (st === "error" || st === "closed") {
|
||||
useWorkspace.getState().showToast(`重放 SSE 未成功「${entry.label}」`, "error");
|
||||
}
|
||||
}, 1600);
|
||||
}
|
||||
}
|
||||
|
||||
export function connectWs(node: RequestNode & { kind: "websocket" }): void {
|
||||
const store = useWorkspace.getState();
|
||||
if (sessions.has(node.id)) {
|
||||
@@ -263,16 +330,33 @@ export function connectWs(node: RequestNode & { kind: "websocket" }): void {
|
||||
.filter(Boolean);
|
||||
|
||||
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
|
||||
store.pushLive(node.id, "system", `正在连接 ${url}${Object.keys(headers).length ? "(自定义 Header 走 Gin 隧道)" : ""}`);
|
||||
const headerCount = Object.keys(headers).length;
|
||||
const useTauri = isTauriRuntime();
|
||||
store.pushLive(
|
||||
node.id,
|
||||
"system",
|
||||
useTauri
|
||||
? `正在连接 ${url}${headerCount ? "(桌面原生 · 自定义 Header)" : "(桌面原生)"}`
|
||||
: `正在连接 ${url}${headerCount ? "(自定义 Header 走 Gin 隧道)" : ""}`,
|
||||
);
|
||||
|
||||
const session = wsSmart({ url, headers, protocols }, {
|
||||
ginBase: "",
|
||||
onInfo: (info) => {
|
||||
if (info.mode === "tunnel") {
|
||||
store.pushLive(node.id, "system", `已通过 Gin 隧道建立(会话 ${info.sessionId?.slice(0, 8)}…)`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const session = useTauri
|
||||
? tauriNativeTransport.ws({ url, headers, protocols })
|
||||
: wsSmart(
|
||||
{ url, headers, protocols },
|
||||
{
|
||||
ginBase: "",
|
||||
onInfo: (info) => {
|
||||
if (info.mode === "tunnel") {
|
||||
store.pushLive(
|
||||
node.id,
|
||||
"system",
|
||||
`已通过 Gin 隧道建立(会话 ${info.sessionId?.slice(0, 8)}…)`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
sessions.set(node.id, session);
|
||||
session.onStatus((s) => {
|
||||
useWorkspace.getState().setRuntime(node.id, { status: s });
|
||||
|
||||
@@ -82,6 +82,14 @@ export type RequestRuntime = {
|
||||
startedAt: number | null;
|
||||
};
|
||||
|
||||
export type ToastTone = "info" | "error" | "success";
|
||||
|
||||
export type ToastState = {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: ToastTone;
|
||||
};
|
||||
|
||||
export type Dialog =
|
||||
| { type: "new"; collectionId: string; parentId: string | null }
|
||||
| { type: "import" }
|
||||
@@ -123,6 +131,8 @@ type PersistedShape = Pick<
|
||||
type WorkspaceStore = PersistedShape & {
|
||||
openTabIds: string[];
|
||||
activeTabId: string | null;
|
||||
/** 编辑未点 Save 的请求 id */
|
||||
dirtyIds: Record<string, true>;
|
||||
search: string;
|
||||
expanded: Record<string, boolean>;
|
||||
dialog: Dialog | null;
|
||||
@@ -166,6 +176,7 @@ type WorkspaceStore = PersistedShape & {
|
||||
moveNodeTo(id: string, collectionId: string, parentId: string | null): void;
|
||||
addFolder(collectionId: string, parentId: string | null, name?: string): string;
|
||||
addRequest(collectionId: string, parentId: string | null, kind: RequestKind, name?: string): string;
|
||||
saveRequest(id: string): void;
|
||||
updateRequestData<K extends RequestKind>(
|
||||
id: string,
|
||||
patch: Partial<Extract<RequestNode, { kind: K }>["data"]>,
|
||||
@@ -190,6 +201,7 @@ type WorkspaceStore = PersistedShape & {
|
||||
removeEnvVariable(envId: string, rowId: string): void;
|
||||
|
||||
setTheme(theme: ThemeMode): void;
|
||||
patchSettings(patch: Partial<WorkspaceSettings>): void;
|
||||
setTimeoutMs(ms: number): void;
|
||||
setMockOption(key: "enabled" | "smart", value: boolean): void;
|
||||
updateMockRule(ruleId: string, patch: Partial<MockRule>): void;
|
||||
@@ -221,6 +233,9 @@ type WorkspaceStore = PersistedShape & {
|
||||
setSearch(term: string): void;
|
||||
toggleExpanded(id: string, force?: boolean): void;
|
||||
setDialog(dialog: Dialog | null): void;
|
||||
toast: ToastState | null;
|
||||
showToast(message: string, tone?: ToastTone): void;
|
||||
clearToast(): void;
|
||||
};
|
||||
|
||||
/** 自愈历史坏数据:请求节点缺 data 时按 kind 补默认值(保名称与说明) */
|
||||
@@ -409,7 +424,7 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
environments: [],
|
||||
activeEnvironmentId: null,
|
||||
history: [],
|
||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||
settings: { theme: "dark", timeoutMs: 30000, transportMode: "auto" as const, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||
testCases: [],
|
||||
testRuns: [],
|
||||
schedules: [],
|
||||
@@ -428,9 +443,11 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
dirtyIds: {},
|
||||
search: "",
|
||||
expanded: {},
|
||||
dialog: null,
|
||||
toast: null,
|
||||
|
||||
init() {
|
||||
const data = loadPersisted() ?? createSeedWorkspace();
|
||||
@@ -671,9 +688,17 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
return node.id;
|
||||
},
|
||||
|
||||
saveRequest(id) {
|
||||
const dirtyIds = { ...get().dirtyIds };
|
||||
delete dirtyIds[id];
|
||||
set({ dirtyIds });
|
||||
persist();
|
||||
},
|
||||
|
||||
updateRequestData(id, patch) {
|
||||
set({
|
||||
collections: get().collections.map((c) => patchRequestIn(c, id, patch)),
|
||||
dirtyIds: { ...get().dirtyIds, [id]: true },
|
||||
});
|
||||
persist();
|
||||
},
|
||||
@@ -681,6 +706,7 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
updateNodeDescription(id, description) {
|
||||
set({
|
||||
collections: get().collections.map((c) => descRequestIn(c, id, description)),
|
||||
dirtyIds: { ...get().dirtyIds, [id]: true },
|
||||
});
|
||||
persist();
|
||||
},
|
||||
@@ -747,6 +773,7 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
schedules: data.schedules ?? [],
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
dirtyIds: {},
|
||||
});
|
||||
applyThemeToDocument(data.settings.theme);
|
||||
persist();
|
||||
@@ -1001,6 +1028,12 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
persist();
|
||||
},
|
||||
|
||||
patchSettings(patch) {
|
||||
set({ settings: { ...get().settings, ...patch } });
|
||||
if (patch.theme) applyThemeToDocument(patch.theme);
|
||||
persist();
|
||||
},
|
||||
|
||||
setTimeoutMs(ms) {
|
||||
set({ settings: { ...get().settings, timeoutMs: ms } });
|
||||
persist();
|
||||
@@ -1133,6 +1166,17 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
setDialog(dialog) {
|
||||
set({ dialog });
|
||||
},
|
||||
showToast(message, tone = "info") {
|
||||
const id = Date.now();
|
||||
set({ toast: { id, message, tone } });
|
||||
window.setTimeout(() => {
|
||||
const cur = get().toast;
|
||||
if (cur && cur.id === id) set({ toast: null });
|
||||
}, 3200);
|
||||
},
|
||||
clearToast() {
|
||||
set({ toast: null });
|
||||
},
|
||||
|
||||
runtime: {} as Record<string, RequestRuntime>,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user