✨ feat(server): 添加静态状态页与调试入口
- 将状态页、调试页改为 `wwwroot` 静态资源 - 补充调试配置接口与前端脚本 - 为兼容层、规划层和运行时补充日志 - 更新集成测试覆盖新入口
This commit is contained in:
670
src/Flyshot.Server.Host/wwwroot/assets/debug.js
Normal file
670
src/Flyshot.Server.Host/wwwroot/assets/debug.js
Normal file
@@ -0,0 +1,670 @@
|
||||
// 静态调试页通过配置 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: []
|
||||
};
|
||||
|
||||
/** 简单的 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;
|
||||
}
|
||||
|
||||
/** 读取本端点最近一次输入;解析失败则当作空。 */
|
||||
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 = 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();
|
||||
Reference in New Issue
Block a user