Files
FlyShotHost/src/Flyshot.Server.Host/wwwroot/assets/debug.js
yunxiao.zhu 2cd42f04e5 feat(fanuc): 添加直角坐标点动功能与相关接口
* 新增 `MovePose` 方法,支持以直角坐标执行点到点移动。
* 引入 `LegacyCartesianPoseRequest` 类,处理直角位姿请求体的解析与验证。
* 更新 `LegacyHttpApiController`,实现 `/move_pose/` 路由以支持新功能。
* 增强状态快照元数据,提供机器人初始化状态与已上传轨迹信息。
* 更新前端状态页面,增加直角坐标点动控制面板与步长设置选项。
* 相关文档与测试用例同步更新,确保新功能的完整性与稳定性。
2026-05-14 17:46:42 +08:00

687 lines
24 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 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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[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();