Files
FlyShotHost/src/Flyshot.Server.Host/wwwroot/assets/debug.js
yunxiao.zhu 5193f537b0 ✨ feat(planning): 添加离线飞拍规划能力
* 固化离线飞拍轨迹加载、保存和规划结果。

* 真机初始化失败时保留模型加载后的离线规划能力。

* 更新状态接口、调试页和回归测试覆盖。
2026-06-24 11:40:07 +08:00

1011 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 静态调试页通过配置 API 获取实际 Swagger JSON 地址,避免硬编码路由前缀。
const DEBUG_CONFIG_URL = "/api/debug/config";
const STORAGE_PREFIX = "flyshot.debug.";
const HISTORY_LIMIT = 10;
const STATIC_DOCS_ELEMENT_ID = "debug-static-docs";
const groupTitleByPrefix = [
{ match: function (op) { return ["/healthz", "/status", "/debug", "/api/status/snapshot"].indexOf(op.path) >= 0; }, title: "基础状态" },
{ match: function (op) { return ["/setup_robot/", "/init_mpc_robot", "/connect_robot/", "/enable_robot/", "/disable_robot/", "/disconnect_robot/", "/stop_move/"].indexOf(op.path) >= 0; }, title: "初始化与维护" },
{ match: function (op) { return ["/move_joint/", "/move_pose/", "/set_tcp/", "/get_tcp/", "/set_io/", "/get_io/", "/get_joint_position/", "/get_pose", "/robot_info/"].indexOf(op.path) >= 0; }, title: "机器人操作" },
{ match: function () { return true; }, title: "轨迹与飞拍" }
];
const state = {
spec: null,
operations: [],
history: [],
staticDocsByKey: loadStaticDocs()
};
const requestBodySamples = {
"POST /init_mpc_robot": {
robot_name: "FANUC_LR_Mate_200iD",
robot_ip: "192.168.10.101"
},
"POST /move_pose/": {
x: 100.0,
y: 200.0,
z: 300.0,
w: 0.0,
p: 45.0,
r: 0.0
},
"POST /save_traj_info/": {
name: "demo-flyshot",
method: "icsp"
}
};
const offlinePlanningState = {
result: null,
page: 1,
pageSize: 50
};
/** 简单的 escape:把任意字符串安全嵌入 textContent 之外的位置时使用。 */
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[ch];
});
}
/** 为 METHOD PATH 文档块构造统一索引键。 */
function buildOperationKey(method, path) {
return String(method || "").toUpperCase() + " " + String(path || "");
}
/** 读取 debug.html 中内嵌的静态说明 JSON,并建立 METHOD PATH 索引。 */
function loadStaticDocs() {
const docNode = document.getElementById(STATIC_DOCS_ELEMENT_ID);
if (!docNode) return {};
try {
const parsed = JSON.parse(docNode.textContent || "{}");
const indexed = {};
Object.keys(parsed).forEach(function (key) {
indexed[key] = parsed[key];
});
return indexed;
} catch (error) {
console.warn("读取静态调试说明失败:", error);
return {};
}
}
/** 解析 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,统一处理 $ref / oneOf / anyOf / allOf。 */
function resolveSchema(schema, depth) {
depth = depth || 0;
if (!schema || depth > 4) return null;
if (schema.$ref) {
return resolveSchema(resolveRef(schema.$ref), depth + 1);
}
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return resolveSchema(schema.oneOf[0], depth + 1);
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return resolveSchema(schema.anyOf[0], depth + 1);
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
const merged = { type: "object", properties: {}, required: [] };
schema.allOf.forEach(function (subSchema) {
const resolved = resolveSchema(subSchema, depth + 1);
if (!resolved) return;
if (resolved.properties) Object.assign(merged.properties, resolved.properties);
if (Array.isArray(resolved.required)) {
resolved.required.forEach(function (name) {
if (merged.required.indexOf(name) < 0) merged.required.push(name);
});
}
});
return merged;
}
return schema;
}
/** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */
function buildSampleFromSchema(schema, depth) {
depth = depth || 0;
// 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。
if (!schema || depth > 4) return null;
const resolvedSchema = resolveSchema(schema, depth);
if (!resolvedSchema) return null;
const type = resolvedSchema.type || (resolvedSchema.properties ? "object" : "string");
switch (type) {
case "object": {
const result = {};
const props = resolvedSchema.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 (resolvedSchema.enum && resolvedSchema.enum.length > 0) return resolvedSchema.enum[0];
return "";
}
}
/** 把 schema.type 翻译成 input type 与展示文本。 */
function inputKindForType(schema) {
if (!schema) return { kind: "text", label: "string" };
const resolvedSchema = resolveSchema(schema, 0) || schema;
const type = resolvedSchema.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 };
}
/** 把 schema 转成适合字段表展示的简短类型文本。 */
function describeSchemaType(schema) {
const resolvedSchema = resolveSchema(schema, 0);
if (!resolvedSchema) return "unknown";
if (resolvedSchema.type === "array") {
const itemType = describeSchemaType(resolvedSchema.items);
return itemType === "unknown" ? "array" : "array<" + itemType + ">";
}
if (resolvedSchema.type) return resolvedSchema.type;
if (resolvedSchema.properties) return "object";
return "unknown";
}
/** 当静态说明缺失时,从普通 object schema 推导字段表。 */
function buildFieldDocsFromSchema(schema) {
const resolvedSchema = resolveSchema(schema, 0);
if (!resolvedSchema) return [];
const schemaType = resolvedSchema.type || (resolvedSchema.properties ? "object" : "");
if (schemaType !== "object" || !resolvedSchema.properties) return [];
const requiredSet = new Set(resolvedSchema.required || []);
return Object.keys(resolvedSchema.properties).map(function (name) {
const propertySchema = resolvedSchema.properties[name];
return {
name: name,
type: describeSchemaType(propertySchema),
required: requiredSet.has(name),
description: propertySchema && propertySchema.description ? propertySchema.description : "暂无说明"
};
});
}
/** 规范化静态字段说明,避免缺字段时前端渲染异常。 */
function normalizeFieldDocs(fieldDocs) {
if (!Array.isArray(fieldDocs)) return [];
return fieldDocs.map(function (fieldDoc) {
return {
name: fieldDoc && fieldDoc.name ? String(fieldDoc.name) : "",
type: fieldDoc && fieldDoc.type ? String(fieldDoc.type) : "unknown",
required: Boolean(fieldDoc && fieldDoc.required),
description: fieldDoc && fieldDoc.description ? String(fieldDoc.description) : "暂无说明"
};
}).filter(function (fieldDoc) {
return fieldDoc.name !== "";
});
}
/** 把 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;
}
const operationKey = buildOperationKey(method, path);
const staticDoc = state.staticDocsByKey[operationKey] || null;
const staticFieldDocs = staticDoc ? normalizeFieldDocs(staticDoc.requestBodyFields) : [];
const schemaFieldDocs = staticFieldDocs.length === 0 ? buildFieldDocsFromSchema(bodySchema) : [];
operations.push({
method: method.toUpperCase(),
path: path,
summary: op.summary || "",
description: op.description || "",
tags: op.tags || [],
parameters: parameters,
bodySchema: bodySchema,
staticDoc: staticDoc,
mergedSummary: staticDoc && staticDoc.summary ? staticDoc.summary : "暂无说明",
mergedDescription: staticDoc && staticDoc.description ? staticDoc.description : "暂无说明",
requestBodyFieldDocs: staticFieldDocs.length > 0 ? staticFieldDocs : schemaFieldDocs
});
});
});
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) {
const operationKey = op.method + " " + op.path;
return requestBodySamples[operationKey] || 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;
};
}
/** 渲染请求体字段说明表;静态说明缺失时退回 schema 推导。 */
function renderRequestBodyFieldDocs(container, op) {
if (!op.bodySchema && op.requestBodyFieldDocs.length === 0) return;
const wrapper = document.createElement("div");
wrapper.className = "body-block";
const title = document.createElement("div");
title.className = "body-label";
const left = document.createElement("div");
left.className = "left";
left.textContent = "请求体字段说明";
title.appendChild(left);
wrapper.appendChild(title);
if (op.requestBodyFieldDocs.length === 0) {
const empty = document.createElement("div");
empty.style.color = "var(--muted)";
empty.style.fontSize = "13px";
empty.textContent = "暂无说明";
wrapper.appendChild(empty);
container.appendChild(wrapper);
return;
}
const table = document.createElement("table");
table.style.width = "100%";
table.style.borderCollapse = "collapse";
table.style.fontSize = "13px";
const headerRow = document.createElement("tr");
["字段名", "类型", "必填", "说明"].forEach(function (text) {
const th = document.createElement("th");
th.textContent = text;
th.style.textAlign = "left";
th.style.padding = "6px 8px";
th.style.borderBottom = "1px solid rgba(148, 163, 184, 0.25)";
th.style.color = "var(--muted)";
headerRow.appendChild(th);
});
table.appendChild(headerRow);
op.requestBodyFieldDocs.forEach(function (fieldDoc) {
const row = document.createElement("tr");
[
fieldDoc.name,
fieldDoc.type,
fieldDoc.required ? "是" : "否",
fieldDoc.description || "暂无说明"
].forEach(function (value, index) {
const cell = document.createElement("td");
cell.style.padding = "8px";
cell.style.verticalAlign = "top";
cell.style.borderBottom = "1px solid rgba(148, 163, 184, 0.12)";
if (index === 0 || index === 1) {
cell.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
}
cell.textContent = value;
row.appendChild(cell);
});
table.appendChild(row);
});
wrapper.appendChild(table);
container.appendChild(wrapper);
}
/** 渲染请求体编辑器,返回收集函数。 */
function renderBodyEditor(container, op, savedBody) {
if (!op.bodySchema) return function () { return null; };
// 字段说明先于 JSON 编辑器出现,便于现场先看约定再改样例。
renderRequestBodyFieldDocs(container, op);
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";
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.mergedSummary;
summary.title = op.mergedSummary;
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";
const desc = document.createElement("div");
desc.style.color = "var(--muted)";
desc.style.marginBottom = "10px";
desc.style.fontSize = "13px";
desc.textContent = op.mergedDescription;
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);
/** 在卡片内渲染 HTTP 响应摘要、头和正文。 */
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);
});
// 固定输出顺序与现场操作顺序一致。
const orderedTitles = [];
["基础状态", "初始化与维护", "机器人操作", "轨迹与飞拍"].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);
});
}
/** 调用 JSON API,并在非 2xx 时把服务端 detail 作为异常抛出。 */
async function requestJson(url, init) {
const response = await fetch(url, init || {});
const text = await response.text();
let payload = null;
if (text) {
try { payload = JSON.parse(text); } catch (error) { payload = { detail: text }; }
}
if (!response.ok) {
throw new Error(payload && payload.detail ? payload.detail : "HTTP " + response.status);
}
return payload;
}
/** 刷新固定 RobotConfig.json 中的飞拍轨迹下拉列表。 */
async function refreshOfflineTrajectoryList() {
const select = document.getElementById("offline-trajectory-select");
const payload = await requestJson("/list_flyShotTraj/");
const names = payload.flyshot_trajs || [];
select.innerHTML = "";
names.forEach(function (name) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
/** 按页渲染最终发送点,避免大点列一次性创建过多 DOM。 */
function renderOfflineSendPage() {
const result = offlinePlanningState.result;
const body = document.querySelector("#offline-send-table tbody");
body.innerHTML = "";
if (!result) {
document.getElementById("offline-page-info").textContent = "0 / 0";
return;
}
const points = result.finalSendPoints || [];
const pageCount = Math.max(1, Math.ceil(points.length / offlinePlanningState.pageSize));
offlinePlanningState.page = Math.min(Math.max(1, offlinePlanningState.page), pageCount);
const start = (offlinePlanningState.page - 1) * offlinePlanningState.pageSize;
points.slice(start, start + offlinePlanningState.pageSize).forEach(function (point) {
const row = document.createElement("tr");
[
point.sampleIndex,
Number(point.sendTime).toFixed(6),
Number(point.trajectoryTime).toFixed(6),
(point.jointsDegrees || []).map(function (value) { return Number(value).toFixed(4); }).join(", "),
point.ioMask,
point.ioValue
].forEach(function (value) {
const cell = document.createElement("td");
cell.textContent = value;
row.appendChild(cell);
});
body.appendChild(row);
});
document.getElementById("offline-page-info").textContent = offlinePlanningState.page + " / " + pageCount;
}
/** 展示离线规划摘要、触发绑定、最终发送点和导出路径。 */
function renderOfflinePlanningResult(result) {
offlinePlanningState.result = result;
offlinePlanningState.page = 1;
document.getElementById("offline-planning-summary").innerHTML =
"<dl class=\"offline-summary\">" +
"<dt>运行结果</dt><dd>" + escapeHtml(result.isValid ? "有效" : "无效") + "</dd>" +
"<dt>规划时长</dt><dd>" + Number(result.planningDurationSeconds).toFixed(6) + " s</dd>" +
"<dt>最终发送时长</dt><dd>" + Number(result.finalSendDurationSeconds).toFixed(6) + " s</dd>" +
"<dt>关键/稠密/发送点</dt><dd>" + result.plannedWaypointCount + " / " + result.densePointCount + " / " + result.finalSendPointCount + "</dd>" +
"<dt>速度倍率</dt><dd>" + result.speedRatio + "</dd>" +
"</dl>";
document.getElementById("offline-shot-events").textContent =
"ShotEvents / TriggerBindings\n" + JSON.stringify({
shotEvents: result.shotEvents,
triggerBindings: result.triggerBindings
}, null, 2);
document.getElementById("offline-artifact-paths").textContent =
"导出文件\n" + JSON.stringify(result.artifactPaths, null, 2);
renderOfflineSendPage();
}
/** 初始化离线规划操作面板。 */
function initializeOfflinePlanningPanel() {
const status = document.getElementById("offline-planning-status");
document.getElementById("offline-load-model").addEventListener("click", async function () {
try {
const robotName = document.getElementById("offline-robot-name").value.trim();
await requestJson("/setup_robot/?robot_name=" + encodeURIComponent(robotName), { method: "POST" });
status.textContent = "模型已加载,当前可离线规划。";
} catch (error) {
status.textContent = "模型加载失败: " + error.message;
}
});
document.getElementById("offline-refresh-trajectories").addEventListener("click", async function () {
try { await refreshOfflineTrajectoryList(); status.textContent = "轨迹列表已刷新。"; }
catch (error) { status.textContent = "刷新失败: " + error.message; }
});
document.getElementById("offline-upload").addEventListener("click", async function () {
try {
const body = document.getElementById("offline-upload-json").value;
await requestJson("/upload_flyshot/", { method: "POST", headers: { "Content-Type": "application/json" }, body: body });
await refreshOfflineTrajectoryList();
status.textContent = "轨迹已上传到固定 RobotConfig.json。";
} catch (error) {
status.textContent = "上传失败: " + error.message;
}
});
document.getElementById("offline-generate").addEventListener("click", async function () {
try {
const name = document.getElementById("offline-trajectory-select").value;
const method = document.getElementById("offline-planning-method").value;
status.textContent = "正在生成最终发送轨迹...";
const result = await requestJson("/save_traj_info/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name, method: method })
});
renderOfflinePlanningResult(result);
status.textContent = "离线规划完成,未向机器人发送命令。";
} catch (error) {
status.textContent = "离线规划失败: " + error.message;
}
});
document.getElementById("offline-prev-page").addEventListener("click", function () {
offlinePlanningState.page--;
renderOfflineSendPage();
});
document.getElementById("offline-next-page").addEventListener("click", function () {
offlinePlanningState.page++;
renderOfflineSendPage();
});
refreshOfflineTrajectoryList().catch(function (error) {
status.textContent = "轨迹列表加载失败: " + error.message;
});
}
/** 加载 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();
initializeOfflinePlanningPanel();
loadSpecAndRender();