diff --git a/apps/server/internal/docs/content/06-env.md b/apps/server/internal/docs/content/06-env.md index 235175a..f1dcde2 100644 --- a/apps/server/internal/docs/content/06-env.md +++ b/apps/server/internal/docs/content/06-env.md @@ -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、鉴权字段中,使用双花括号引用变量: diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a7f9474..a6c17a4 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -474,6 +474,41 @@ 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 { display: flex; diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index b922c50..df5e0ed 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -39,7 +39,7 @@ export function defaultRequestData(kind: RequestKind) { return { kind, method: "GET", - url: "{{baseUrl}}/", + url: "/", query: [], headers: [], cookies: [], @@ -51,7 +51,7 @@ export function defaultRequestData(kind: RequestKind) { case "graphql": return { kind, - url: "{{baseUrl}}/graphql", + url: "/graphql", headers: [kv("Content-Type", "application/json")], auth: { ...noAuth }, query: "query {\n \n}", @@ -67,7 +67,7 @@ export function defaultRequestData(kind: RequestKind) { case "sse": return { kind, - url: "{{baseUrl}}/events", + url: "/events", headers: [kv("Accept", "text/event-stream")], query: [], auth: { ...noAuth }, @@ -164,8 +164,9 @@ export function createCollection( export function createEnvironment( name: string, vars: { key: string; value: string }[] = [], + baseUrl = "", ): 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 = [ - createEnvironment("正式", [{ key: "baseUrl", value: "https://httpbin.org" }, { key: "token", value: "demo-token" }]), - createEnvironment("本地", [{ key: "baseUrl", value: "http://127.0.0.1:8787" }, { key: "token", value: "dev-token" }]), + createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"), + createEnvironment("本地", [{ key: "token", value: "dev-token" }], "http://127.0.0.1:8787"), ]; return { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 75e2368..64d9e34 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -208,6 +208,8 @@ export type Collection = { export type Environment = { id: string; name: string; + /** 前置 URL:请求路径以 / 开头时自动拼接(随环境切换) */ + baseUrl?: string; variables: KVRow[]; }; diff --git a/packages/ui/src/components/UrlPathInput.tsx b/packages/ui/src/components/UrlPathInput.tsx new file mode 100644 index 0000000..b9dff68 --- /dev/null +++ b/packages/ui/src/components/UrlPathInput.tsx @@ -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) => void; + ariaLabel?: string; +}) { + const { baseUrl, envId, envName } = useEnvBaseUrl(); + const setDialog = useWorkspace((s) => s.setDialog); + const usePrefix = !!baseUrl && (value === "" || value.trimStart().startsWith("/")); + + if (!usePrefix) { + return ( + + ); + } + + // 展示 host(去协议),完整地址放 title + const display = baseUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + + return ( +
+ + + +
+ ); +} diff --git a/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx b/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx index af4af32..aa1e558 100644 --- a/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx +++ b/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx @@ -320,6 +320,19 @@ export function EnvironmentsDialog() { +
+ + updateEnvironment(env.id, { baseUrl: e.target.value })} + placeholder="https://api.example.com(请求路径以 / 开头时自动拼接)" + /> +
+ 请求中只写相对路径(如 /graphql),切换环境即自动换成对应服务地址;留空则请求需写完整 URL。 +
+
+
变量
{env.variables.length} 项 diff --git a/packages/ui/src/components/editors/GraphqlEditor.tsx b/packages/ui/src/components/editors/GraphqlEditor.tsx index 010dfa6..8fddac6 100644 --- a/packages/ui/src/components/editors/GraphqlEditor.tsx +++ b/packages/ui/src/components/editors/GraphqlEditor.tsx @@ -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 }) {
POST GraphQL - patch({ url: v })} onKeyDown={(e) => e.key === "Enter" && sendGraphqlRequest(node)} diff --git a/packages/ui/src/components/editors/HttpEditor.tsx b/packages/ui/src/components/editors/HttpEditor.tsx index 744a918..da75f76 100644 --- a/packages/ui/src/components/editors/HttpEditor.tsx +++ b/packages/ui/src/components/editors/HttpEditor.tsx @@ -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) => 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 }) { ))} - patch({ url: v })} onKeyDown={(e) => e.key === "Enter" && sendHttpRequest(node)} @@ -99,7 +100,7 @@ export function HttpEditor({ node }: { node: HttpApiNode }) { 代码
- {data.url.includes("{{") && ( + {(data.url.includes("{{") || (envBase && (data.url === "" || data.url.trimStart().startsWith("/")))) && (
实际地址:{resolvedUrl}
diff --git a/packages/ui/src/components/editors/SseEditor.tsx b/packages/ui/src/components/editors/SseEditor.tsx index 2683ee5..26edf14 100644 --- a/packages/ui/src/components/editors/SseEditor.tsx +++ b/packages/ui/src/components/editors/SseEditor.tsx @@ -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 }) {
SSE - patch({ url: v })} /> diff --git a/packages/ui/src/send.ts b/packages/ui/src/send.ts index 6857a1b..8a405a3 100644 --- a/packages/ui/src/send.ts +++ b/packages/ui/src/send.ts @@ -20,7 +20,7 @@ import { type StreamSession, } from "@opsxapi/transport"; import type { WsSession } from "@opsxapi/core"; -import { activeEnvMap, useWorkspace } from "./workspaceStore"; +import { activeEnvBaseUrl, activeEnvMap, useWorkspace } from "./workspaceStore"; type Session = { close(): void }; @@ -96,11 +96,26 @@ function buildMultipart(rows: HttpRequestData["bodyForm"], env: Record, + 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( data: HttpRequestData, env: Record, timeoutMs: number, mock?: MockSettings, + baseUrlPrefix?: string, ): HttpSpec { const { headers: authH, query: authQ } = authHeaders(data.auth, env); const headers = { ...kvMap(data.headers, env), ...authH }; @@ -137,7 +152,7 @@ export function buildHttpSpec( return { method: data.method, - url: replaceVariables(data.url, env), + url: resolveRequestUrl(data.url, env, baseUrlPrefix), headers, query: { ...kvMap(data.query, env, mock), ...authQ }, body, @@ -196,8 +211,8 @@ async function sendHttpLike( export function sendHttpRequest(node: RequestNode & { kind: "http" }): void { const env = activeEnvMap(); const { timeoutMs, mock } = useWorkspace.getState().settings; - const spec = buildHttpSpec(node.data, env, timeoutMs, mock); - void sendHttpLike(node.id, node.name, spec, node.data.url); + const spec = buildHttpSpec(node.data, env, timeoutMs, mock, activeEnvBaseUrl()); + void sendHttpLike(node.id, node.name, spec, spec.url); } 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"; const spec: HttpSpec = { method: "POST", - url: replaceVariables(data.url, env), + url: resolveRequestUrl(data.url, env, activeEnvBaseUrl()), headers, query: {}, body: { mode: "json", raw: payload }, timeoutMs, }; - void sendHttpLike(node.id, node.name, spec, data.url); + void sendHttpLike(node.id, node.name, spec, spec.url); } /* ---------------- WebSocket ---------------- */ @@ -289,7 +304,7 @@ export function connectSse(node: RequestNode & { kind: "sse" }): void { } const env = activeEnvMap(); 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 = { ...kvMap(data.headers, env), ...authH }; const query = { ...kvMap(data.query, env), ...authQ }; diff --git a/packages/ui/src/testRunner.ts b/packages/ui/src/testRunner.ts index e69599e..10d299a 100644 --- a/packages/ui/src/testRunner.ts +++ b/packages/ui/src/testRunner.ts @@ -13,7 +13,7 @@ import type { } from "@opsxapi/core"; import { evaluateAssertion, extractVariables } from "@opsxapi/core"; import { buildHttpSpec } from "./send"; -import { activeEnvMap, useWorkspace } from "./workspaceStore"; +import { activeEnvBaseUrl, activeEnvMap, useWorkspace } from "./workspaceStore"; async function getTransportFn() { const { detectTransport } = await import("@opsxapi/transport"); @@ -55,7 +55,7 @@ export async function runTestCase(caseId: string, trigger: TestRunTrigger): Prom const env = activeEnvMap(); 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(); try { const res = await http(spec); diff --git a/packages/ui/src/workspaceStore.ts b/packages/ui/src/workspaceStore.ts index e14aafc..436636e 100644 --- a/packages/ui/src/workspaceStore.ts +++ b/packages/ui/src/workspaceStore.ts @@ -229,16 +229,49 @@ function normalizeAiSettings(raw: unknown): { providers: AiProvider[]; activePro 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 { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw) as Partial; if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null; + const migrated = migrateBaseUrl(sanitizeCollections(data.collections), data.environments); return { version: 2, - collections: sanitizeCollections(data.collections), - environments: data.environments, + collections: migrated.collections, + environments: migrated.environments, activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null, history: Array.isArray(data.history) ? data.history : [], settings: { @@ -280,6 +313,13 @@ function schedulePersist(state: WorkspaceStore): void { }, 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 { if (typeof document === "undefined") return; document.documentElement.dataset.theme = theme; @@ -432,31 +472,40 @@ export const useWorkspace = create((set, get) => { importCollections(collections, envVars) { const state = get(); 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; let i = 2; while (existingNames.has(`${c.name} ${i}`)) i++; return { ...c, name: `${c.name} ${i}` }; }); - // 环境变量并入当前环境 + // 环境变量并入当前环境(baseUrl 变量优先写入前置 URL 字段) let environments = state.environments; let activeEnvironmentId = state.activeEnvironmentId; 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]; if (active) { 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)) { active.variables.push(kv(v.key, v.value)); 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 { const env: Environment = { id: newId(), 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]; activeEnvironmentId = env.id; @@ -467,7 +516,10 @@ export const useWorkspace = create((set, get) => { 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({ collections: data.collections, environments: data.environments,