Files
FlyShotHost/src/Flyshot.Server.Host/wwwroot/assets/debug.js
yunxiao.zhu 070d2ba5c0 ✨ feat(server): 增加混淆发布链路与调试页静态说明
- 新增 Obfuscar 混淆发布脚本、发布测试与产物隐私检查
- 调整 VS Code 发布任务和宿主发布配置,统一走混淆流程
- 为 debug 页面内嵌静态接口说明,并补充对应集成测试
- 更新 README 与设计计划文档,记录跨平台发布约束
2026-06-22 15:55:34 +08:00

858 lines
30 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 = [
// 基础与状态分组:探活和状态快照两个固定 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: [],
staticDocsByKey: loadStaticDocs()
};
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];
});
}
/** 为 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);
});
// 固定输出顺序:基础与状态在前,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();