* 新增 `MovePose` 方法,支持以直角坐标执行点到点移动。 * 引入 `LegacyCartesianPoseRequest` 类,处理直角位姿请求体的解析与验证。 * 更新 `LegacyHttpApiController`,实现 `/move_pose/` 路由以支持新功能。 * 增强状态快照元数据,提供机器人初始化状态与已上传轨迹信息。 * 更新前端状态页面,增加直角坐标点动控制面板与步长设置选项。 * 相关文档与测试用例同步更新,确保新功能的完整性与稳定性。
687 lines
24 KiB
JavaScript
687 lines
24 KiB
JavaScript
// 静态调试页通过配置 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();
|