前置 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
@@ -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>
|
||||
</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="text-[13px] font-semibold">变量</div>
|
||||
<span className="ops-faint text-[11px]">{env.variables.length} 项</span>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sendGraphqlRequest } from "../../send";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { KVEditor, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { VarHintInput } from "../VarHintInput";
|
||||
import { UrlPathInput } from "../UrlPathInput";
|
||||
import { AuthEditor } from "./HttpEditor";
|
||||
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 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>
|
||||
<VarHintInput
|
||||
<UrlPathInput
|
||||
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}
|
||||
onChange={(v) => patch({ url: v })}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendGraphqlRequest(node)}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { sendHttpRequest } from "../../send";
|
||||
import { activeEnvMap, useWorkspace } from "../../workspaceStore";
|
||||
import { ContextMenu, CopyButton, KVEditor, cx, type MenuItem } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { VarHintInput } from "../VarHintInput";
|
||||
import { UrlPathInput, joinUrl, useEnvBaseUrl } from "../UrlPathInput";
|
||||
import { ResponsePanel } from "./ResponsePanel";
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -55,7 +56,7 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
] as [CodeTarget, string][]
|
||||
).map(([target, label]) => ({
|
||||
label,
|
||||
onClick: () => setCodeView({ target, code: generateCode(target, data) }),
|
||||
onClick: () => setCodeView({ target, code: generateCode(target, { ...data, url: resolvedUrl }) }),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -74,9 +75,9 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<VarHintInput
|
||||
<UrlPathInput
|
||||
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}
|
||||
onChange={(v) => patch({ url: v })}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendHttpRequest(node)}
|
||||
@@ -99,7 +100,7 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
<Icon name="code" size={13} /> 代码
|
||||
</button>
|
||||
</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)" }}>
|
||||
实际地址:<span className="ops-accent font-mono">{resolvedUrl}</span>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { hasSession } from "../../send";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { KVEditor, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { VarHintInput } from "../VarHintInput";
|
||||
import { UrlPathInput } from "../UrlPathInput";
|
||||
import { LiveLog, LiveStatus } from "./liveCommon";
|
||||
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 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>
|
||||
<VarHintInput
|
||||
<UrlPathInput
|
||||
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}
|
||||
onChange={(v) => patch({ url: v })}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user