// 静态调试页通过配置 API 获取实际 Swagger JSON 地址,避免硬编码路由前缀。 const DEBUG_CONFIG_URL = "/api/debug/config"; const STORAGE_PREFIX = "flyshot.debug."; const HISTORY_LIMIT = 10; const groupTitleByPrefix = [ // 基础与状态分组:探活和状态快照两个固定 API 路径 { match: function (op) { return op.path === "/healthz" || op.path === "/api/status/snapshot"; }, title: "基础与状态" }, // 默认兜底:剩余全部走 ControllerClient 兼容分组 { match: function () { return true; }, title: "ControllerClient 兼容" } ]; const state = { spec: null, operations: [], history: [] }; const requestBodySamples = { "POST /move_pose/": { x: 100.0, y: 200.0, z: 300.0, w: 0.0, p: 45.0, r: 0.0 } }; /** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */ function escapeHtml(value) { return String(value).replace(/[&<>"']/g, function (ch) { return { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch]; }); } /** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */ function resolveRef(ref) { if (!ref || !state.spec) return null; const parts = ref.replace(/^#\//, "").split("/"); let cursor = state.spec; for (const part of parts) { if (cursor && Object.prototype.hasOwnProperty.call(cursor, part)) { cursor = cursor[part]; } else { return null; } } return cursor; } /** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */ function buildSampleFromSchema(schema, depth) { depth = depth || 0; // 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。 if (!schema || depth > 4) return null; if (schema.$ref) { const resolved = resolveRef(schema.$ref); return resolved ? buildSampleFromSchema(resolved, depth + 1) : null; } // 部分 schema 只标 oneOf/anyOf/allOf,挑第一个分支即可,调试场景够用。 if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return buildSampleFromSchema(schema.oneOf[0], depth + 1); if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return buildSampleFromSchema(schema.anyOf[0], depth + 1); if (Array.isArray(schema.allOf) && schema.allOf.length > 0) { const merged = {}; schema.allOf.forEach(function (sub) { const value = buildSampleFromSchema(sub, depth + 1); if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value); }); return merged; } const type = schema.type || (schema.properties ? "object" : "string"); switch (type) { case "object": { const result = {}; const props = schema.properties || {}; Object.keys(props).forEach(function (key) { result[key] = buildSampleFromSchema(props[key], depth + 1); }); return result; } case "array": return []; case "integer": case "number": return 0; case "boolean": return false; case "string": default: if (schema.enum && schema.enum.length > 0) return schema.enum[0]; return ""; } } /** 把 schema.type 翻译成 input type 与展示文本。 */ function inputKindForType(schema) { if (!schema) return { kind: "text", label: "string" }; const type = schema.type || "string"; if (type === "boolean") return { kind: "checkbox", label: "boolean" }; if (type === "integer" || type === "number") return { kind: "number", label: type }; return { kind: "text", label: type }; } /** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表。 */ function extractOperations(spec) { const operations = []; const paths = spec.paths || {}; Object.keys(paths).forEach(function (path) { const pathItem = paths[path] || {}; ["get", "post", "put", "delete", "patch", "options", "head"].forEach(function (method) { const op = pathItem[method]; if (!op) return; const parameters = (op.parameters || []).filter(function (p) { return p.in === "query" || p.in === "path"; }); let bodySchema = null; if (op.requestBody && op.requestBody.content) { const json = op.requestBody.content["application/json"]; if (json && json.schema) bodySchema = json.schema; } operations.push({ method: method.toUpperCase(), path: path, summary: op.summary || "", description: op.description || "", tags: op.tags || [], parameters: parameters, bodySchema: bodySchema }); }); }); return operations; } /** 选择分组:优先用第一条匹配的 groupTitleByPrefix 规则,OpenAPI tag 留作兜底。 */ function pickGroup(op) { for (const rule of groupTitleByPrefix) { if (rule.match(op)) return rule.title; } if (op.tags && op.tags.length > 0) return op.tags[0]; return "其它"; } /** localStorage key 必须避免冲突,使用 method:path 复合键。 */ function storageKey(op) { return STORAGE_PREFIX + op.method + ":" + op.path; } /** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */ function getRequestBodySample(op) { return requestBodySamples[op.method + " " + op.path] || null; } /** 读取本端点最近一次输入;解析失败则当作空。 */ function loadInputs(op) { try { const raw = window.localStorage.getItem(storageKey(op)); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } /** 保存本端点最近一次输入;写入失败时静默忽略,避免影响调试体验。 */ function saveInputs(op, payload) { try { window.localStorage.setItem(storageKey(op), JSON.stringify(payload)); } catch (e) { // localStorage 可能被禁用或满载,忽略写入失败。 } } /** 拼接最终请求 URL(含 query 串与 path 参数替换)。 */ function buildRequestUrl(op, paramValues) { let path = op.path; const queryPairs = []; op.parameters.forEach(function (param) { const raw = paramValues[param.name]; if (raw === undefined || raw === null || raw === "") return; if (param.in === "path") { path = path.replace("{" + param.name + "}", encodeURIComponent(raw)); } else if (param.in === "query") { queryPairs.push(encodeURIComponent(param.name) + "=" + encodeURIComponent(raw)); } }); return path + (queryPairs.length > 0 ? "?" + queryPairs.join("&") : ""); } /** 生成与浏览器请求等价的 curl 命令,便于复制到终端复现。 */ function buildCurlCommand(op, requestUrl, body) { const parts = ["curl", "-X", op.method, JSON.stringify(window.location.origin + requestUrl)]; if (body !== null && body !== undefined && body !== "") { parts.push("-H", "\"Content-Type: application/json\""); parts.push("--data-raw", JSON.stringify(body)); } return parts.join(" "); } /** 渲染参数输入表单,返回收集函数。 */ function renderParameterRows(container, op, savedValues) { if (op.parameters.length === 0) return function () { return {}; }; const inputs = {}; op.parameters.forEach(function (param) { const row = document.createElement("div"); row.className = "form-row"; const nameNode = document.createElement("div"); nameNode.className = "name"; nameNode.textContent = param.name + " (" + param.in + ")"; if (param.required) { const requiredMark = document.createElement("span"); requiredMark.className = "required"; requiredMark.textContent = "*"; nameNode.appendChild(requiredMark); } row.appendChild(nameNode); const kind = inputKindForType(param.schema); const inputNode = document.createElement("input"); inputNode.type = kind.kind; if (kind.kind === "checkbox") { inputNode.checked = savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name) ? Boolean(savedValues[param.name]) : Boolean(param.schema && param.schema.default); } else { let initial = ""; if (savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name)) { initial = String(savedValues[param.name]); } else if (param.schema && param.schema.default !== undefined) { initial = String(param.schema.default); } inputNode.value = initial; if (kind.kind === "number") inputNode.step = "any"; } row.appendChild(inputNode); const typeNode = document.createElement("div"); typeNode.className = "type"; typeNode.textContent = kind.label; row.appendChild(typeNode); container.appendChild(row); inputs[param.name] = { node: inputNode, kind: kind.kind, schema: param.schema }; }); return function collect() { const collected = {}; Object.keys(inputs).forEach(function (key) { const item = inputs[key]; if (item.kind === "checkbox") { collected[key] = item.node.checked; } else { const raw = item.node.value; if (raw === "") { collected[key] = ""; } else if (item.kind === "number") { const num = Number(raw); collected[key] = Number.isNaN(num) ? raw : num; } else { collected[key] = raw; } } }); return collected; }; } /** 渲染请求体编辑器,返回收集函数。 */ function renderBodyEditor(container, op, savedBody) { if (!op.bodySchema) return function () { return null; }; const block = document.createElement("div"); block.className = "body-block"; const labelRow = document.createElement("div"); labelRow.className = "body-label"; const left = document.createElement("div"); left.className = "left"; left.textContent = "请求体 (application/json)"; labelRow.appendChild(left); const formatBtn = document.createElement("button"); formatBtn.type = "button"; formatBtn.className = "secondary"; formatBtn.textContent = "格式化 JSON"; labelRow.appendChild(formatBtn); block.appendChild(labelRow); const textarea = document.createElement("textarea"); textarea.className = "body-editor"; textarea.spellcheck = false; let initialText; if (savedBody !== undefined && savedBody !== null) { initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2); } else { const sample = getRequestBodySample(op) || buildSampleFromSchema(op.bodySchema, 0); initialText = sample === null ? "" : JSON.stringify(sample, null, 2); } textarea.value = initialText; block.appendChild(textarea); formatBtn.addEventListener("click", function () { try { const parsed = JSON.parse(textarea.value || "null"); textarea.value = parsed === null ? "" : JSON.stringify(parsed, null, 2); } catch (e) { window.alert("JSON 解析失败: " + e.message); } }); container.appendChild(block); return function collect() { return textarea.value; }; } /** 把 HTTP 状态码翻译成颜色徽标 class。 */ function statusBadgeClass(status) { if (status >= 200 && status < 300) return "s2xx"; if (status >= 300 && status < 400) return "s3xx"; if (status >= 400 && status < 500) return "s4xx"; if (status >= 500) return "s5xx"; return "error"; } /** 把响应头展开成可读字符串。 */ function formatHeaders(headers) { const lines = []; headers.forEach(function (value, key) { lines.push(key + ": " + value); }); return lines.join("\n"); } /** 在历史面板顶部追加一条记录,超过上限则丢弃尾部。 */ function pushHistory(entry) { state.history.unshift(entry); if (state.history.length > HISTORY_LIMIT) state.history.length = HISTORY_LIMIT; renderHistory(); } function renderHistory() { const list = document.getElementById("history-list"); list.innerHTML = ""; if (state.history.length === 0) { const empty = document.createElement("li"); empty.textContent = "暂无调用记录"; empty.style.color = "var(--muted)"; empty.style.gridTemplateColumns = "1fr"; list.appendChild(empty); return; } state.history.forEach(function (entry) { const li = document.createElement("li"); const method = document.createElement("span"); method.className = "h-method"; method.textContent = entry.method; method.style.color = entry.method === "GET" ? "var(--get)" : entry.method === "POST" ? "var(--post)" : "var(--muted)"; const path = document.createElement("span"); path.className = "h-path"; path.title = entry.url; path.textContent = entry.url; const meta = document.createElement("span"); meta.style.color = "var(--muted)"; meta.textContent = (entry.status || "ERR") + " · " + entry.elapsedMs + "ms"; li.appendChild(method); li.appendChild(path); li.appendChild(meta); list.appendChild(li); }); } /** 渲染单个端点的卡片。 */ function renderOperationCard(op) { const card = document.createElement("section"); card.className = "card collapsed"; const head = document.createElement("div"); head.className = "card-head"; const badge = document.createElement("span"); badge.className = "badge " + (["GET", "POST", "PUT", "DELETE"].indexOf(op.method) >= 0 ? op.method : "OTHER"); badge.textContent = op.method; head.appendChild(badge); const path = document.createElement("span"); path.className = "card-path"; path.textContent = op.path; head.appendChild(path); const summary = document.createElement("span"); summary.className = "card-summary"; summary.textContent = op.summary; summary.title = op.summary; head.appendChild(summary); const toggle = document.createElement("span"); toggle.className = "card-toggle"; toggle.textContent = "展开 ▾"; head.appendChild(toggle); head.addEventListener("click", function () { const collapsed = card.classList.toggle("collapsed"); toggle.textContent = collapsed ? "展开 ▾" : "收起 ▴"; }); card.appendChild(head); const body = document.createElement("div"); body.className = "card-body"; // 描述(来自 XML summary)独立成一段 if (op.summary) { const desc = document.createElement("div"); desc.style.color = "var(--muted)"; desc.style.marginBottom = "10px"; desc.style.fontSize = "13px"; desc.textContent = op.summary; body.appendChild(desc); } const saved = loadInputs(op) || {}; // 参数区 let collectParams = function () { return {}; }; if (op.parameters.length > 0) { const paramsContainer = document.createElement("div"); paramsContainer.className = "params"; body.appendChild(paramsContainer); collectParams = renderParameterRows(paramsContainer, op, saved.params); } // 请求体区 const collectBody = renderBodyEditor(body, op, saved.body); // 操作按钮 const buttonRow = document.createElement("div"); buttonRow.className = "button-row"; const sendBtn = document.createElement("button"); sendBtn.type = "button"; sendBtn.textContent = "发送"; const resetBtn = document.createElement("button"); resetBtn.type = "button"; resetBtn.className = "secondary"; resetBtn.textContent = "重置"; const curlBtn = document.createElement("button"); curlBtn.type = "button"; curlBtn.className = "secondary"; curlBtn.textContent = "复制 curl"; buttonRow.appendChild(sendBtn); buttonRow.appendChild(resetBtn); buttonRow.appendChild(curlBtn); body.appendChild(buttonRow); // 响应面板 const responseBlock = document.createElement("div"); responseBlock.className = "response-block"; responseBlock.style.display = "none"; body.appendChild(responseBlock); function renderResponse(payload) { responseBlock.style.display = "block"; responseBlock.innerHTML = ""; const summaryRow = document.createElement("div"); summaryRow.className = "response-summary"; const statusBadge = document.createElement("span"); statusBadge.className = "status-badge " + statusBadgeClass(payload.status || 0); statusBadge.textContent = payload.status ? payload.status + " " + (payload.statusText || "") : "请求失败"; summaryRow.appendChild(statusBadge); const elapsed = document.createElement("span"); elapsed.style.color = "var(--muted)"; elapsed.textContent = payload.elapsedMs + " ms · " + payload.url; summaryRow.appendChild(elapsed); responseBlock.appendChild(summaryRow); if (payload.error) { const pre = document.createElement("pre"); pre.className = "response-body"; pre.textContent = payload.error; responseBlock.appendChild(pre); return; } const headersDetails = document.createElement("details"); const headersSummary = document.createElement("summary"); headersSummary.textContent = "响应头"; headersDetails.appendChild(headersSummary); const headersPre = document.createElement("pre"); headersPre.className = "response-headers"; headersPre.textContent = payload.headers; headersDetails.appendChild(headersPre); responseBlock.appendChild(headersDetails); const bodyPre = document.createElement("pre"); bodyPre.className = "response-body"; bodyPre.textContent = payload.bodyText; responseBlock.appendChild(bodyPre); } sendBtn.addEventListener("click", async function () { sendBtn.disabled = true; const params = collectParams(); const rawBody = collectBody(); saveInputs(op, { params: params, body: rawBody }); const requestUrl = buildRequestUrl(op, params); const init = { method: op.method, headers: {} }; // 仅 POST/PUT/PATCH/DELETE 才认为可能携带 body;对没有 bodySchema 的方法直接跳过。 const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0; if (methodAllowsBody && op.bodySchema && rawBody !== null && rawBody !== undefined && rawBody !== "") { init.headers["Content-Type"] = "application/json"; init.body = rawBody; } const startedAt = performance.now(); try { const response = await fetch(requestUrl, init); const elapsedMs = Math.round(performance.now() - startedAt); const text = await response.text(); const contentType = response.headers.get("content-type") || ""; let bodyText = text; if (contentType.indexOf("application/json") >= 0) { try { bodyText = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { bodyText = text; } } renderResponse({ status: response.status, statusText: response.statusText, headers: formatHeaders(response.headers), bodyText: bodyText, url: requestUrl, elapsedMs: elapsedMs }); pushHistory({ method: op.method, url: requestUrl, status: response.status, elapsedMs: elapsedMs }); } catch (err) { const elapsedMs = Math.round(performance.now() - startedAt); renderResponse({ error: String(err && err.message ? err.message : err), url: requestUrl, elapsedMs: elapsedMs }); pushHistory({ method: op.method, url: requestUrl, status: 0, elapsedMs: elapsedMs }); } finally { sendBtn.disabled = false; } }); resetBtn.addEventListener("click", function () { try { window.localStorage.removeItem(storageKey(op)); } catch (e) { /* 忽略 */ } // 直接重新渲染当前卡片:替换原 DOM 节点。 const refreshed = renderOperationCard(op); refreshed.classList.remove("collapsed"); refreshed.querySelector(".card-toggle").textContent = "收起 ▴"; card.parentNode.replaceChild(refreshed, card); }); curlBtn.addEventListener("click", function () { const params = collectParams(); const rawBody = collectBody(); const requestUrl = buildRequestUrl(op, params); const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0; const bodyForCurl = methodAllowsBody && op.bodySchema ? rawBody : null; const command = buildCurlCommand(op, requestUrl, bodyForCurl); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(command).then(function () { curlBtn.textContent = "已复制 ✓"; window.setTimeout(function () { curlBtn.textContent = "复制 curl"; }, 1500); }).catch(function () { window.prompt("复制失败,手动复制:", command); }); } else { window.prompt("复制失败,手动复制:", command); } }); card.appendChild(body); return card; } /** 把 operation 列表按分组渲染到主区域。 */ function renderGroups(operations) { const root = document.getElementById("debug-console-app"); root.innerHTML = ""; if (operations.length === 0) { const empty = document.createElement("div"); empty.className = "empty-hint"; empty.textContent = "OpenAPI 文档中没有任何端点。"; root.appendChild(empty); return; } const grouped = new Map(); operations.forEach(function (op) { const groupTitle = pickGroup(op); if (!grouped.has(groupTitle)) grouped.set(groupTitle, []); grouped.get(groupTitle).push(op); }); // 固定输出顺序:基础与状态在前,ControllerClient 兼容在后,其余按字典序。 const orderedTitles = []; ["基础与状态", "ControllerClient 兼容"].forEach(function (title) { if (grouped.has(title)) orderedTitles.push(title); }); Array.from(grouped.keys()).sort().forEach(function (title) { if (orderedTitles.indexOf(title) < 0) orderedTitles.push(title); }); orderedTitles.forEach(function (title) { const ops = grouped.get(title); ops.sort(function (a, b) { if (a.path === b.path) return a.method.localeCompare(b.method); return a.path.localeCompare(b.path); }); const section = document.createElement("section"); section.className = "group"; const heading = document.createElement("h2"); heading.textContent = title + " (" + ops.length + ")"; section.appendChild(heading); ops.forEach(function (op) { section.appendChild(renderOperationCard(op)); }); root.appendChild(section); }); } /** 加载 OpenAPI 文档并渲染。 */ async function loadSpecAndRender() { const metaSpec = document.getElementById("meta-spec-url"); const metaCount = document.getElementById("meta-operation-count"); const metaStatus = document.getElementById("meta-status"); metaSpec.textContent = "正在读取调试配置..."; metaStatus.textContent = "正在拉取 OpenAPI 文档..."; metaStatus.className = ""; try { const configResponse = await fetch(DEBUG_CONFIG_URL, { cache: "no-store" }); if (!configResponse.ok) throw new Error("调试配置 HTTP " + configResponse.status + " " + configResponse.statusText); const config = await configResponse.json(); const swaggerJsonUrl = config.swaggerJsonUrl; if (!swaggerJsonUrl) throw new Error("调试配置缺少 swaggerJsonUrl"); metaSpec.textContent = swaggerJsonUrl; const response = await fetch(swaggerJsonUrl, { cache: "no-store" }); if (!response.ok) throw new Error("HTTP " + response.status + " " + response.statusText); const spec = await response.json(); state.spec = spec; state.operations = extractOperations(spec); metaCount.textContent = state.operations.length; metaStatus.textContent = "已加载"; metaStatus.className = "good"; renderGroups(state.operations); } catch (err) { metaStatus.textContent = "加载失败: " + (err && err.message ? err.message : err); metaStatus.className = "bad"; metaCount.textContent = "0"; const root = document.getElementById("debug-console-app"); root.innerHTML = ""; const errBlock = document.createElement("div"); errBlock.className = "empty-hint"; errBlock.textContent = "无法加载 OpenAPI 文档,请确认 Swagger:Enabled = true 且 " + DEBUG_CONFIG_URL + " 可访问。"; root.appendChild(errBlock); } } document.getElementById("reload-spec").addEventListener("click", loadSpecAndRender); document.getElementById("history-clear").addEventListener("click", function () { state.history.length = 0; renderHistory(); }); renderHistory(); loadSpecAndRender();