前置 URL:环境切换自动拼接服务地址(借鉴 Apifox)
- 环境新增「前置 URL」字段;环境管理弹窗独立配置区 + 说明
- URL 输入改为一体胶囊:前缀块(环境徽标 + 域名,点击进环境管理)+ 分隔线 + 相对路径输入
- 路径以 / 开头(或空)自动拼接当前环境前置 URL;完整 https:// 地址不受影响
- 光标 / 全选只作用于路径,不再误编辑 {{baseUrl}} 文本
- 覆盖 HTTP / GraphQL / SSE 编辑器与发送链路(含自动化测试执行器、代码生成用完整地址)
- 存量数据一次性迁移:{{baseUrl}}/path → /path,baseUrl 变量 → 环境字段(启动 / 导入 / 备份恢复均生效)
- 顶栏切换环境即换前缀;docs 06-env 增前置 URL 章节
- 验证:48 前端用例全绿;本地 E2E(迁移/发送 200/环境切换前缀联动/胶囊几何对齐)+ 近景视觉评审通过
This commit is contained in:
1 parent
1bcd7be2ac
commit
ce6d2d1eff
12 files changed
+260
-35
No files matched your search
@@ -1,3 +1,20 @@
|
|||||||
|
## 前置 URL
|
||||||
|
|
||||||
|
环境可配置**前置 URL**(如 `https://api.example.com`)。配置后,请求里只需写相对路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
前置 URL:https://api.example.com
|
||||||
|
请求路径:/graphql
|
||||||
|
实际请求:https://api.example.com/graphql
|
||||||
|
```
|
||||||
|
|
||||||
|
- URL 输入框会以**前缀块 + 路径**的形式一体展示,前缀部分不可误编辑(点击可跳转环境管理);
|
||||||
|
- **切换环境即自动换成对应服务地址**,无需改请求;
|
||||||
|
- 路径以 `/` 开头(或为空)才启用拼接;写完整 `https://…` 地址的请求不受影响;
|
||||||
|
- 未配置前置 URL 的环境,请求仍可写完整 URL 或用 `{{变量名}}`。
|
||||||
|
|
||||||
|
> 历史数据自动迁移:旧的 `{{baseUrl}}/path` 会被自动转为「前置 URL + /path」,`baseUrl` 变量写入环境的前置 URL 字段。
|
||||||
|
|
||||||
## 变量语法
|
## 变量语法
|
||||||
|
|
||||||
在 URL、Params、Headers、Cookies、Body、鉴权字段中,使用双花括号引用变量:
|
在 URL、Params、Headers、Cookies、Body、鉴权字段中,使用双花括号引用变量:
|
||||||
|
|||||||
@@ -474,6 +474,41 @@
|
|||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- URL 胶囊输入(前置 URL 模式) ---------- */
|
||||||
|
.url-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: visible;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.url-pill-prefix {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
align-self: stretch;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
max-width: 46%;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 0.5rem 0 0 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.url-pill-prefix:hover { color: var(--accent-strong); }
|
||||||
|
.url-pill-divider { width: 1px; align-self: stretch; background: var(--border); flex-shrink: 0; }
|
||||||
|
.url-pill-input {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0.4rem 0.7rem !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 自动化测试 ---------- */
|
/* ---------- 自动化测试 ---------- */
|
||||||
.test-assert-row {
|
.test-assert-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function defaultRequestData(kind: RequestKind) {
|
|||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "{{baseUrl}}/",
|
url: "/",
|
||||||
query: [],
|
query: [],
|
||||||
headers: [],
|
headers: [],
|
||||||
cookies: [],
|
cookies: [],
|
||||||
@@ -51,7 +51,7 @@ export function defaultRequestData(kind: RequestKind) {
|
|||||||
case "graphql":
|
case "graphql":
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
url: "{{baseUrl}}/graphql",
|
url: "/graphql",
|
||||||
headers: [kv("Content-Type", "application/json")],
|
headers: [kv("Content-Type", "application/json")],
|
||||||
auth: { ...noAuth },
|
auth: { ...noAuth },
|
||||||
query: "query {\n \n}",
|
query: "query {\n \n}",
|
||||||
@@ -67,7 +67,7 @@ export function defaultRequestData(kind: RequestKind) {
|
|||||||
case "sse":
|
case "sse":
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
url: "{{baseUrl}}/events",
|
url: "/events",
|
||||||
headers: [kv("Accept", "text/event-stream")],
|
headers: [kv("Accept", "text/event-stream")],
|
||||||
query: [],
|
query: [],
|
||||||
auth: { ...noAuth },
|
auth: { ...noAuth },
|
||||||
@@ -164,8 +164,9 @@ export function createCollection(
|
|||||||
export function createEnvironment(
|
export function createEnvironment(
|
||||||
name: string,
|
name: string,
|
||||||
vars: { key: string; value: string }[] = [],
|
vars: { key: string; value: string }[] = [],
|
||||||
|
baseUrl = "",
|
||||||
): Environment {
|
): Environment {
|
||||||
return { id: newId(), name, variables: vars.map((v) => kv(v.key, v.value)) };
|
return { id: newId(), name, baseUrl, variables: vars.map((v) => kv(v.key, v.value)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首次启动的演示集合:覆盖全部协议,开箱即用 */
|
/** 首次启动的演示集合:覆盖全部协议,开箱即用 */
|
||||||
@@ -253,8 +254,8 @@ export function createSeedWorkspace(): WorkspaceData {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const envs = [
|
const envs = [
|
||||||
createEnvironment("正式", [{ key: "baseUrl", value: "https://httpbin.org" }, { key: "token", value: "demo-token" }]),
|
createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"),
|
||||||
createEnvironment("本地", [{ key: "baseUrl", value: "http://127.0.0.1:8787" }, { key: "token", value: "dev-token" }]),
|
createEnvironment("本地", [{ key: "token", value: "dev-token" }], "http://127.0.0.1:8787"),
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export type Collection = {
|
|||||||
export type Environment = {
|
export type Environment = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 前置 URL:请求路径以 / 开头时自动拼接(随环境切换) */
|
||||||
|
baseUrl?: string;
|
||||||
variables: KVRow[];
|
variables: KVRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* URL 输入(前置 URL 模式):
|
||||||
|
* 当前环境配置了前置 URL 且值为相对路径(/ 开头或空)时,
|
||||||
|
* 展示「前缀块 + 分隔线 + 路径输入」的一体胶囊 —— 借鉴 Apifox 前置 URL。
|
||||||
|
* 路径输入只编辑相对部分,光标 / 全选不会碰到服务地址。
|
||||||
|
*/
|
||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import { useWorkspace } from "../workspaceStore";
|
||||||
|
import { cx } from "./common";
|
||||||
|
import { EnvBadge } from "./EnvBadge";
|
||||||
|
import { VarHintInput } from "./VarHintInput";
|
||||||
|
|
||||||
|
export function useEnvBaseUrl(): { baseUrl: string; envId: string | null; envName: string } {
|
||||||
|
const envId = useWorkspace((s) => s.activeEnvironmentId);
|
||||||
|
const env = useWorkspace((s) => (envId ? s.environments.find((e) => e.id === envId) : undefined));
|
||||||
|
return {
|
||||||
|
baseUrl: env?.baseUrl?.trim() ?? "",
|
||||||
|
envId: envId ?? null,
|
||||||
|
envName: env?.name ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前置 URL 与相对路径拼接(供编辑器展示「实际地址」与代码生成复用) */
|
||||||
|
export function joinUrl(baseUrl: string, path: string): string {
|
||||||
|
const p = path.trim();
|
||||||
|
if (!baseUrl || (p !== "" && !p.startsWith("/"))) return path;
|
||||||
|
const b = baseUrl.replace(/\/+$/, "");
|
||||||
|
if (p === "") return `${b}/`;
|
||||||
|
return p.startsWith("/") ? `${b}${p}` : `${b}/${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UrlPathInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
onKeyDown,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}) {
|
||||||
|
const { baseUrl, envId, envName } = useEnvBaseUrl();
|
||||||
|
const setDialog = useWorkspace((s) => s.setDialog);
|
||||||
|
const usePrefix = !!baseUrl && (value === "" || value.trimStart().startsWith("/"));
|
||||||
|
|
||||||
|
if (!usePrefix) {
|
||||||
|
return (
|
||||||
|
<VarHintInput
|
||||||
|
className={className}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展示 host(去协议),完整地址放 title
|
||||||
|
const display = baseUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("url-pill", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="url-pill-prefix"
|
||||||
|
title={`前置 URL(${envName || "当前环境"}):${baseUrl}\n点击管理环境`}
|
||||||
|
onClick={() => setDialog({ type: "environments" })}
|
||||||
|
>
|
||||||
|
{envId && <EnvBadge envId={envId} name={envName} size={14} />}
|
||||||
|
<span className="truncate">{display}</span>
|
||||||
|
</button>
|
||||||
|
<span className="url-pill-divider" />
|
||||||
|
<VarHintInput
|
||||||
|
className="url-pill-input"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder ?? "/path"}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -320,6 +320,19 @@ export function EnvironmentsDialog() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="ops-label">前置 URL</label>
|
||||||
|
<input
|
||||||
|
className="ops-input font-mono"
|
||||||
|
value={env.baseUrl ?? ""}
|
||||||
|
onChange={(e) => updateEnvironment(env.id, { baseUrl: e.target.value })}
|
||||||
|
placeholder="https://api.example.com(请求路径以 / 开头时自动拼接)"
|
||||||
|
/>
|
||||||
|
<div className="ops-faint mt-1 text-[11px] leading-5">
|
||||||
|
请求中只写相对路径(如 <b className="font-mono">/graphql</b>),切换环境即自动换成对应服务地址;留空则请求需写完整 URL。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<div className="text-[13px] font-semibold">变量</div>
|
<div className="text-[13px] font-semibold">变量</div>
|
||||||
<span className="ops-faint text-[11px]">{env.variables.length} 项</span>
|
<span className="ops-faint text-[11px]">{env.variables.length} 项</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { sendGraphqlRequest } from "../../send";
|
|||||||
import { useWorkspace } from "../../workspaceStore";
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
import { KVEditor, cx } from "../common";
|
import { KVEditor, cx } from "../common";
|
||||||
import { Icon } from "../icons";
|
import { Icon } from "../icons";
|
||||||
import { VarHintInput } from "../VarHintInput";
|
import { UrlPathInput } from "../UrlPathInput";
|
||||||
import { AuthEditor } from "./HttpEditor";
|
import { AuthEditor } from "./HttpEditor";
|
||||||
import { ResponsePanel } from "./ResponsePanel";
|
import { ResponsePanel } from "./ResponsePanel";
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ export function GraphqlEditor({ node }: { node: GraphqlApiNode }) {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
<div className="flex shrink-0 flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}>POST <Icon name="hexagon" size={12} /> GraphQL</span>
|
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}>POST <Icon name="hexagon" size={12} /> GraphQL</span>
|
||||||
<VarHintInput
|
<UrlPathInput
|
||||||
className="ops-input flex-1 font-mono text-[13px]"
|
className="ops-input flex-1 font-mono text-[13px]"
|
||||||
placeholder="https://api.example.com/graphql 或 {{baseUrl}}/graphql"
|
placeholder="https://api.example.com/graphql 或 /graphql"
|
||||||
value={data.url}
|
value={data.url}
|
||||||
onChange={(v) => patch({ url: v })}
|
onChange={(v) => patch({ url: v })}
|
||||||
onKeyDown={(e) => e.key === "Enter" && sendGraphqlRequest(node)}
|
onKeyDown={(e) => e.key === "Enter" && sendGraphqlRequest(node)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { sendHttpRequest } from "../../send";
|
|||||||
import { activeEnvMap, useWorkspace } from "../../workspaceStore";
|
import { activeEnvMap, useWorkspace } from "../../workspaceStore";
|
||||||
import { ContextMenu, CopyButton, KVEditor, cx, type MenuItem } from "../common";
|
import { ContextMenu, CopyButton, KVEditor, cx, type MenuItem } from "../common";
|
||||||
import { Icon } from "../icons";
|
import { Icon } from "../icons";
|
||||||
import { VarHintInput } from "../VarHintInput";
|
import { UrlPathInput, joinUrl, useEnvBaseUrl } from "../UrlPathInput";
|
||||||
import { ResponsePanel } from "./ResponsePanel";
|
import { ResponsePanel } from "./ResponsePanel";
|
||||||
|
|
||||||
type BodyTab = "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data";
|
type BodyTab = "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data";
|
||||||
@@ -36,7 +36,8 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
|||||||
const [codeView, setCodeView] = useState<{ target: CodeTarget; code: string } | null>(null);
|
const [codeView, setCodeView] = useState<{ target: CodeTarget; code: string } | null>(null);
|
||||||
|
|
||||||
const env = activeEnvMap();
|
const env = activeEnvMap();
|
||||||
const resolvedUrl = replaceVariables(data.url, env);
|
const { baseUrl: envBase } = useEnvBaseUrl();
|
||||||
|
const resolvedUrl = joinUrl(envBase, replaceVariables(data.url, env));
|
||||||
|
|
||||||
const patch = (p: Partial<HttpRequestData>) => update(node.id, p);
|
const patch = (p: Partial<HttpRequestData>) => update(node.id, p);
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
|||||||
] as [CodeTarget, string][]
|
] as [CodeTarget, string][]
|
||||||
).map(([target, label]) => ({
|
).map(([target, label]) => ({
|
||||||
label,
|
label,
|
||||||
onClick: () => setCodeView({ target, code: generateCode(target, data) }),
|
onClick: () => setCodeView({ target, code: generateCode(target, { ...data, url: resolvedUrl }) }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,9 +75,9 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<VarHintInput
|
<UrlPathInput
|
||||||
className="ops-input flex-1 font-mono text-[13px]"
|
className="ops-input flex-1 font-mono text-[13px]"
|
||||||
placeholder="https://api.example.com/v1/resource 或 {{baseUrl}}/path"
|
placeholder="https://api.example.com/v1/resource 或 /path"
|
||||||
value={data.url}
|
value={data.url}
|
||||||
onChange={(v) => patch({ url: v })}
|
onChange={(v) => patch({ url: v })}
|
||||||
onKeyDown={(e) => e.key === "Enter" && sendHttpRequest(node)}
|
onKeyDown={(e) => e.key === "Enter" && sendHttpRequest(node)}
|
||||||
@@ -99,7 +100,7 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
|||||||
<Icon name="code" size={13} /> 代码
|
<Icon name="code" size={13} /> 代码
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{data.url.includes("{{") && (
|
{(data.url.includes("{{") || (envBase && (data.url === "" || data.url.trimStart().startsWith("/")))) && (
|
||||||
<div className="ops-faint px-4 py-1 text-[11px]" style={{ borderBottom: "1px solid var(--border)" }}>
|
<div className="ops-faint px-4 py-1 text-[11px]" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
实际地址:<span className="ops-accent font-mono">{resolvedUrl}</span>
|
实际地址:<span className="ops-accent font-mono">{resolvedUrl}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { hasSession } from "../../send";
|
|||||||
import { useWorkspace } from "../../workspaceStore";
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
import { KVEditor, cx } from "../common";
|
import { KVEditor, cx } from "../common";
|
||||||
import { Icon } from "../icons";
|
import { Icon } from "../icons";
|
||||||
import { VarHintInput } from "../VarHintInput";
|
import { UrlPathInput } from "../UrlPathInput";
|
||||||
import { LiveLog, LiveStatus } from "./liveCommon";
|
import { LiveLog, LiveStatus } from "./liveCommon";
|
||||||
import { AuthEditor } from "./HttpEditor";
|
import { AuthEditor } from "./HttpEditor";
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ export function SseEditor({ node }: { node: SseApiNode }) {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2.5">
|
<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">
|
<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="radio" size={13} /> SSE</span>
|
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}><Icon name="radio" size={13} /> SSE</span>
|
||||||
<VarHintInput
|
<UrlPathInput
|
||||||
className="ops-input flex-1 font-mono text-[13px]"
|
className="ops-input flex-1 font-mono text-[13px]"
|
||||||
placeholder="https://stream.example.com/events"
|
placeholder="https://stream.example.com/events 或 /events"
|
||||||
value={data.url}
|
value={data.url}
|
||||||
onChange={(v) => patch({ url: v })}
|
onChange={(v) => patch({ url: v })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+22
-7
@@ -20,7 +20,7 @@ import {
|
|||||||
type StreamSession,
|
type StreamSession,
|
||||||
} from "@opsxapi/transport";
|
} from "@opsxapi/transport";
|
||||||
import type { WsSession } from "@opsxapi/core";
|
import type { WsSession } from "@opsxapi/core";
|
||||||
import { activeEnvMap, useWorkspace } from "./workspaceStore";
|
import { activeEnvBaseUrl, activeEnvMap, useWorkspace } from "./workspaceStore";
|
||||||
|
|
||||||
type Session = { close(): void };
|
type Session = { close(): void };
|
||||||
|
|
||||||
@@ -96,11 +96,26 @@ function buildMultipart(rows: HttpRequestData["bodyForm"], env: Record<string, s
|
|||||||
return parts.join("");
|
return parts.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 相对路径(/ 开头)拼接当前环境前置 URL,其余原样解析变量 */
|
||||||
|
export function resolveRequestUrl(
|
||||||
|
url: string,
|
||||||
|
env: Record<string, string>,
|
||||||
|
baseUrlPrefix?: string,
|
||||||
|
): string {
|
||||||
|
const resolved = replaceVariables(url, env);
|
||||||
|
const prefix = baseUrlPrefix?.trim();
|
||||||
|
if (prefix && (url === "" || url.trimStart().startsWith("/"))) {
|
||||||
|
return `${prefix.replace(/\/+$/, "")}${resolved === "" ? "/" : resolved.startsWith("/") ? resolved : `/${resolved}`}`;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHttpSpec(
|
export function buildHttpSpec(
|
||||||
data: HttpRequestData,
|
data: HttpRequestData,
|
||||||
env: Record<string, string>,
|
env: Record<string, string>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
mock?: MockSettings,
|
mock?: MockSettings,
|
||||||
|
baseUrlPrefix?: string,
|
||||||
): HttpSpec {
|
): HttpSpec {
|
||||||
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
|
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
|
||||||
const headers = { ...kvMap(data.headers, env), ...authH };
|
const headers = { ...kvMap(data.headers, env), ...authH };
|
||||||
@@ -137,7 +152,7 @@ export function buildHttpSpec(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
method: data.method,
|
method: data.method,
|
||||||
url: replaceVariables(data.url, env),
|
url: resolveRequestUrl(data.url, env, baseUrlPrefix),
|
||||||
headers,
|
headers,
|
||||||
query: { ...kvMap(data.query, env, mock), ...authQ },
|
query: { ...kvMap(data.query, env, mock), ...authQ },
|
||||||
body,
|
body,
|
||||||
@@ -196,8 +211,8 @@ async function sendHttpLike(
|
|||||||
export function sendHttpRequest(node: RequestNode & { kind: "http" }): void {
|
export function sendHttpRequest(node: RequestNode & { kind: "http" }): void {
|
||||||
const env = activeEnvMap();
|
const env = activeEnvMap();
|
||||||
const { timeoutMs, mock } = useWorkspace.getState().settings;
|
const { timeoutMs, mock } = useWorkspace.getState().settings;
|
||||||
const spec = buildHttpSpec(node.data, env, timeoutMs, mock);
|
const spec = buildHttpSpec(node.data, env, timeoutMs, mock, activeEnvBaseUrl());
|
||||||
void sendHttpLike(node.id, node.name, spec, node.data.url);
|
void sendHttpLike(node.id, node.name, spec, spec.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): void {
|
export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): void {
|
||||||
@@ -221,13 +236,13 @@ export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): voi
|
|||||||
if (!("content-type" in headers)) headers["Content-Type"] = "application/json";
|
if (!("content-type" in headers)) headers["Content-Type"] = "application/json";
|
||||||
const spec: HttpSpec = {
|
const spec: HttpSpec = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: replaceVariables(data.url, env),
|
url: resolveRequestUrl(data.url, env, activeEnvBaseUrl()),
|
||||||
headers,
|
headers,
|
||||||
query: {},
|
query: {},
|
||||||
body: { mode: "json", raw: payload },
|
body: { mode: "json", raw: payload },
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
};
|
};
|
||||||
void sendHttpLike(node.id, node.name, spec, data.url);
|
void sendHttpLike(node.id, node.name, spec, spec.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- WebSocket ---------------- */
|
/* ---------------- WebSocket ---------------- */
|
||||||
@@ -289,7 +304,7 @@ export function connectSse(node: RequestNode & { kind: "sse" }): void {
|
|||||||
}
|
}
|
||||||
const env = activeEnvMap();
|
const env = activeEnvMap();
|
||||||
const data = node.data;
|
const data = node.data;
|
||||||
const url = replaceVariables(data.url, env);
|
const url = resolveRequestUrl(data.url, env, activeEnvBaseUrl());
|
||||||
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
|
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
|
||||||
const headers = { ...kvMap(data.headers, env), ...authH };
|
const headers = { ...kvMap(data.headers, env), ...authH };
|
||||||
const query = { ...kvMap(data.query, env), ...authQ };
|
const query = { ...kvMap(data.query, env), ...authQ };
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
} from "@opsxapi/core";
|
} from "@opsxapi/core";
|
||||||
import { evaluateAssertion, extractVariables } from "@opsxapi/core";
|
import { evaluateAssertion, extractVariables } from "@opsxapi/core";
|
||||||
import { buildHttpSpec } from "./send";
|
import { buildHttpSpec } from "./send";
|
||||||
import { activeEnvMap, useWorkspace } from "./workspaceStore";
|
import { activeEnvBaseUrl, activeEnvMap, useWorkspace } from "./workspaceStore";
|
||||||
|
|
||||||
async function getTransportFn() {
|
async function getTransportFn() {
|
||||||
const { detectTransport } = await import("@opsxapi/transport");
|
const { detectTransport } = await import("@opsxapi/transport");
|
||||||
@@ -55,7 +55,7 @@ export async function runTestCase(caseId: string, trigger: TestRunTrigger): Prom
|
|||||||
|
|
||||||
const env = activeEnvMap();
|
const env = activeEnvMap();
|
||||||
const { timeoutMs, mock } = useWorkspace.getState().settings;
|
const { timeoutMs, mock } = useWorkspace.getState().settings;
|
||||||
const spec: HttpSpec = buildHttpSpec(node.data, env, timeoutMs, mock);
|
const spec: HttpSpec = buildHttpSpec(node.data, env, timeoutMs, mock, activeEnvBaseUrl());
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
const res = await http(spec);
|
const res = await http(spec);
|
||||||
|
|||||||
@@ -229,16 +229,49 @@ function normalizeAiSettings(raw: unknown): { providers: AiProvider[]; activePro
|
|||||||
return { providers, activeProviderId };
|
return { providers, activeProviderId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一次性迁移到「前置 URL」模式:
|
||||||
|
* - 环境变量 baseUrl → 环境 baseUrl 字段(变量移除)
|
||||||
|
* - 请求 URL 的 {{baseUrl}}/ 前缀剥离为相对路径(发送时按当前环境拼接)
|
||||||
|
*/
|
||||||
|
function migrateBaseUrl(
|
||||||
|
collections: Collection[],
|
||||||
|
environments: Environment[],
|
||||||
|
): { collections: Collection[]; environments: Environment[] } {
|
||||||
|
const envs = environments.map((e) => {
|
||||||
|
const v = e.variables.find((x) => x.key === "baseUrl");
|
||||||
|
if (!e.baseUrl && v?.value) {
|
||||||
|
return { ...e, baseUrl: v.value, variables: e.variables.filter((x) => x.key !== "baseUrl") };
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fix = (nodes: CollectionNode[]): CollectionNode[] =>
|
||||||
|
nodes.map((n) => {
|
||||||
|
if (n.type === "folder") return { ...n, children: fix(n.children) };
|
||||||
|
if (n.type === "request" && (n.kind === "http" || n.kind === "graphql" || n.kind === "sse")) {
|
||||||
|
const raw = (n.data as { url?: string }).url ?? "";
|
||||||
|
if (/^\s*\{\{\s*baseUrl\s*\}\}/.test(raw)) {
|
||||||
|
const path = raw.replace(/^\s*\{\{\s*baseUrl\s*\}\}\/?/, "");
|
||||||
|
return { ...n, data: { ...n.data, url: path.startsWith("/") ? path : `/${path}` } } as CollectionNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { collections: collections.map((c) => ({ ...c, children: fix(c.children) })), environments: envs };
|
||||||
|
}
|
||||||
|
|
||||||
function loadPersisted(): WorkspaceData | null {
|
function loadPersisted(): WorkspaceData | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const data = JSON.parse(raw) as Partial<WorkspaceData>;
|
const data = JSON.parse(raw) as Partial<WorkspaceData>;
|
||||||
if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null;
|
if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null;
|
||||||
|
const migrated = migrateBaseUrl(sanitizeCollections(data.collections), data.environments);
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
collections: sanitizeCollections(data.collections),
|
collections: migrated.collections,
|
||||||
environments: data.environments,
|
environments: migrated.environments,
|
||||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||||
history: Array.isArray(data.history) ? data.history : [],
|
history: Array.isArray(data.history) ? data.history : [],
|
||||||
settings: {
|
settings: {
|
||||||
@@ -280,6 +313,13 @@ function schedulePersist(state: WorkspaceStore): void {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前启用环境的前置 URL */
|
||||||
|
export function activeEnvBaseUrl(): string {
|
||||||
|
const s = useWorkspace.getState();
|
||||||
|
const env = s.environments.find((e) => e.id === s.activeEnvironmentId);
|
||||||
|
return env?.baseUrl?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
export function applyThemeToDocument(theme: ThemeMode): void {
|
export function applyThemeToDocument(theme: ThemeMode): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
@@ -432,31 +472,40 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
importCollections(collections, envVars) {
|
importCollections(collections, envVars) {
|
||||||
const state = get();
|
const state = get();
|
||||||
const existingNames = new Set(state.collections.map((c) => c.name));
|
const existingNames = new Set(state.collections.map((c) => c.name));
|
||||||
const renamed = collections.map((c) => {
|
// 导入的 {{baseUrl}} 前缀统一转为相对路径,服务地址进环境前置 URL
|
||||||
|
const { collections: stripped, environments: _e, ..._rest } = migrateBaseUrl(collections, []);
|
||||||
|
void _e; void _rest;
|
||||||
|
const renamed = stripped.map((c) => {
|
||||||
if (!existingNames.has(c.name)) return c;
|
if (!existingNames.has(c.name)) return c;
|
||||||
let i = 2;
|
let i = 2;
|
||||||
while (existingNames.has(`${c.name} ${i}`)) i++;
|
while (existingNames.has(`${c.name} ${i}`)) i++;
|
||||||
return { ...c, name: `${c.name} ${i}` };
|
return { ...c, name: `${c.name} ${i}` };
|
||||||
});
|
});
|
||||||
// 环境变量并入当前环境
|
// 环境变量并入当前环境(baseUrl 变量优先写入前置 URL 字段)
|
||||||
let environments = state.environments;
|
let environments = state.environments;
|
||||||
let activeEnvironmentId = state.activeEnvironmentId;
|
let activeEnvironmentId = state.activeEnvironmentId;
|
||||||
if (envVars.length) {
|
if (envVars.length) {
|
||||||
|
const baseVar = envVars.find((v) => v.key === "baseUrl");
|
||||||
|
const plainVars = envVars.filter((v) => v.key !== "baseUrl" || baseVar?.value === "");
|
||||||
const active = environments.find((e) => e.id === activeEnvironmentId) ?? environments[0];
|
const active = environments.find((e) => e.id === activeEnvironmentId) ?? environments[0];
|
||||||
if (active) {
|
if (active) {
|
||||||
const known = new Set(active.variables.map((v) => v.key));
|
const known = new Set(active.variables.map((v) => v.key));
|
||||||
for (const v of envVars) {
|
for (const v of plainVars) {
|
||||||
if (!known.has(v.key)) {
|
if (!known.has(v.key)) {
|
||||||
active.variables.push(kv(v.key, v.value));
|
active.variables.push(kv(v.key, v.value));
|
||||||
known.add(v.key);
|
known.add(v.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
environments = environments.map((e) => (e.id === active.id ? { ...active } : e));
|
const patched = baseVar?.value
|
||||||
|
? { ...active, baseUrl: active.baseUrl || baseVar.value, variables: active.variables.filter((v) => v.key !== "baseUrl") }
|
||||||
|
: { ...active };
|
||||||
|
environments = environments.map((e) => (e.id === patched.id ? patched : e));
|
||||||
} else {
|
} else {
|
||||||
const env: Environment = {
|
const env: Environment = {
|
||||||
id: newId(),
|
id: newId(),
|
||||||
name: "默认环境",
|
name: "默认环境",
|
||||||
variables: envVars.map((v) => kv(v.key, v.value)),
|
baseUrl: baseVar?.value || "",
|
||||||
|
variables: plainVars.map((v) => kv(v.key, v.value)),
|
||||||
};
|
};
|
||||||
environments = [...environments, env];
|
environments = [...environments, env];
|
||||||
activeEnvironmentId = env.id;
|
activeEnvironmentId = env.id;
|
||||||
@@ -467,7 +516,10 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
return renamed.map((c) => c.id);
|
return renamed.map((c) => c.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
loadWorkspaceData(data) {
|
loadWorkspaceData(rawData) {
|
||||||
|
// 兼容旧备份:{{baseUrl}} 前缀 → 前置 URL 模式
|
||||||
|
const migrated = migrateBaseUrl(rawData.collections, rawData.environments);
|
||||||
|
const data = { ...rawData, collections: migrated.collections, environments: migrated.environments };
|
||||||
set({
|
set({
|
||||||
collections: data.collections,
|
collections: data.collections,
|
||||||
environments: data.environments,
|
environments: data.environments,
|
||||||
|
|||||||
Reference in new issue
Block a user