✨ feat(server): 添加静态状态页与调试入口
- 将状态页、调试页改为 `wwwroot` 静态资源 - 补充调试配置接口与前端脚本 - 为兼容层、规划层和运行时补充日志 - 更新集成测试覆盖新入口
This commit is contained in:
424
src/Flyshot.Server.Host/wwwroot/assets/debug.css
Normal file
424
src/Flyshot.Server.Host/wwwroot/assets/debug.css
Normal file
@@ -0,0 +1,424 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--surface: #ffffff;
|
||||
--line: #d8dee9;
|
||||
--text: #172033;
|
||||
--muted: #5b667a;
|
||||
--accent: #007c89;
|
||||
--good: #12805c;
|
||||
--warn: #b7791f;
|
||||
--bad: #b42318;
|
||||
--get: #1f6feb;
|
||||
--post: #2da44e;
|
||||
--put: #9a6700;
|
||||
--delete: #cf222e;
|
||||
--code-bg: #f4f6fa;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: min(1280px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: rgba(0, 124, 137, 0.08);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1280px, calc(100% - 32px));
|
||||
margin: 22px auto 60px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.meta dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.meta dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meta dd {
|
||||
margin: 4px 0 0;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta dd.bad { color: var(--bad); }
|
||||
.meta dd.good { color: var(--good); }
|
||||
|
||||
.group {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
margin: 0 0 10px 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-head:hover {
|
||||
background: #fafbfd;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 60px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-weight: 650;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge.GET { background: var(--get); }
|
||||
.badge.POST { background: var(--post); }
|
||||
.badge.PUT { background: var(--put); }
|
||||
.badge.DELETE { background: var(--delete); }
|
||||
.badge.OTHER { background: var(--muted); }
|
||||
|
||||
.card-path {
|
||||
flex: 1 1 auto;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
flex: 0 1 auto;
|
||||
max-width: 50%;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-toggle {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card.collapsed .card-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) 90px;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-row .name {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.form-row .name .required {
|
||||
color: var(--bad);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.form-row input[type="text"],
|
||||
.form-row input[type="number"] {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.form-row .type {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
}
|
||||
|
||||
.body-block {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.body-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.body-label .left {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
textarea.body-editor {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--code-bg);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.response-block {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.response-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status-badge.s2xx { background: var(--good); }
|
||||
.status-badge.s3xx { background: var(--get); }
|
||||
.status-badge.s4xx { background: var(--warn); }
|
||||
.status-badge.s5xx { background: var(--bad); }
|
||||
.status-badge.error { background: var(--bad); }
|
||||
|
||||
pre.response-body,
|
||||
pre.response-headers {
|
||||
margin: 6px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
pre.response-headers {
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 12px 0;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 50vh;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.history h3 {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history h3 button {
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history li {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history li:last-child { border-bottom: 0; }
|
||||
|
||||
.history li .h-method {
|
||||
font-weight: 650;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
}
|
||||
|
||||
.history li .h-path {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history {
|
||||
position: static;
|
||||
width: auto;
|
||||
margin-top: 18px;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
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();
|
||||
214
src/Flyshot.Server.Host/wwwroot/assets/status.css
Normal file
214
src/Flyshot.Server.Host/wwwroot/assets/status.css
Normal file
@@ -0,0 +1,214 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--surface: #ffffff;
|
||||
--line: #d8dee9;
|
||||
--text: #172033;
|
||||
--muted: #5b667a;
|
||||
--accent: #007c89;
|
||||
--good: #12805c;
|
||||
--warn: #b7791f;
|
||||
--bad: #b42318;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 顶部操作区按钮和外链按钮共用同一组视觉样式,便于现场顺手跳转。 */
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: rgba(0, 124, 137, 0.08);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 22px auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric,
|
||||
section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 86px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
.dot.good {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 4px 16px 12px;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
min-height: 36px;
|
||||
margin: 0;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #edf1f7;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary,
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
dt {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
dd {
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
92
src/Flyshot.Server.Host/wwwroot/assets/status.js
Normal file
92
src/Flyshot.Server.Host/wwwroot/assets/status.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const fields = {
|
||||
connectionState: document.getElementById("connection-state"),
|
||||
stateDot: document.getElementById("state-dot"),
|
||||
robotName: document.getElementById("robot-name"),
|
||||
speedRatio: document.getElementById("speed-ratio"),
|
||||
motionState: document.getElementById("motion-state"),
|
||||
serverVersion: document.getElementById("server-version"),
|
||||
clientVersion: document.getElementById("client-version"),
|
||||
setupState: document.getElementById("setup-state"),
|
||||
enabledState: document.getElementById("enabled-state"),
|
||||
j519Status: document.getElementById("j519-status"),
|
||||
j519Sequence: document.getElementById("j519-sequence"),
|
||||
capturedAt: document.getElementById("captured-at"),
|
||||
dof: document.getElementById("dof"),
|
||||
joints: document.getElementById("joints"),
|
||||
pose: document.getElementById("pose"),
|
||||
trajectories: document.getElementById("trajectories"),
|
||||
refresh: document.getElementById("refresh")
|
||||
};
|
||||
|
||||
function formatArray(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return values.map(value => Number(value).toFixed(4)).join(", ");
|
||||
}
|
||||
|
||||
function formatNullableBool(value) {
|
||||
if (value === true) {
|
||||
return "是";
|
||||
}
|
||||
|
||||
if (value === false) {
|
||||
return "否";
|
||||
}
|
||||
|
||||
return "--";
|
||||
}
|
||||
|
||||
function formatJ519Status(snapshot) {
|
||||
if (snapshot.j519Status === null || snapshot.j519Status === undefined) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
const status = Number(snapshot.j519Status).toString(16).padStart(2, "0").toUpperCase();
|
||||
return `0x${status} accept=${formatNullableBool(snapshot.j519AcceptsCommand)} received=${formatNullableBool(snapshot.j519ReceivedCommand)} sysrdy=${formatNullableBool(snapshot.j519SystemReady)} motion=${formatNullableBool(snapshot.j519RobotInMotion)}`;
|
||||
}
|
||||
|
||||
function setDot(connectionState) {
|
||||
fields.stateDot.className = "dot";
|
||||
if (connectionState === "Connected") {
|
||||
fields.stateDot.classList.add("good");
|
||||
} else if (connectionState === "NotConfigured") {
|
||||
fields.stateDot.classList.add("bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
fields.refresh.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
const snapshot = payload.snapshot;
|
||||
|
||||
fields.connectionState.textContent = snapshot.connectionState;
|
||||
fields.robotName.textContent = payload.robotName || "--";
|
||||
fields.speedRatio.textContent = Number(snapshot.speedRatio).toFixed(2);
|
||||
fields.motionState.textContent = snapshot.isInMotion ? "是" : "否";
|
||||
fields.serverVersion.textContent = payload.serverVersion;
|
||||
fields.clientVersion.textContent = payload.clientVersion;
|
||||
fields.setupState.textContent = payload.isSetup ? "是" : "否";
|
||||
fields.enabledState.textContent = snapshot.isEnabled ? "是" : "否";
|
||||
fields.j519Status.textContent = formatJ519Status(snapshot);
|
||||
fields.j519Sequence.textContent = snapshot.j519Sequence ?? "--";
|
||||
fields.capturedAt.textContent = new Date(snapshot.capturedAt).toLocaleString();
|
||||
fields.dof.textContent = payload.degreesOfFreedom;
|
||||
fields.joints.textContent = formatArray(snapshot.jointPositions);
|
||||
fields.pose.textContent = formatArray(snapshot.cartesianPose);
|
||||
fields.trajectories.textContent = payload.uploadedTrajectories.length > 0
|
||||
? payload.uploadedTrajectories.join(", ")
|
||||
: "--";
|
||||
fields.trajectories.classList.toggle("empty", payload.uploadedTrajectories.length === 0);
|
||||
setDot(snapshot.connectionState);
|
||||
} finally {
|
||||
fields.refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
fields.refresh.addEventListener("click", refreshStatus);
|
||||
refreshStatus();
|
||||
window.setInterval(refreshStatus, 2000);
|
||||
44
src/Flyshot.Server.Host/wwwroot/debug.html
Normal file
44
src/Flyshot.Server.Host/wwwroot/debug.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Flyshot Replacement 接口调试</title>
|
||||
<link rel="stylesheet" href="/assets/debug.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>Flyshot Replacement 接口调试</h1>
|
||||
<div class="actions">
|
||||
<a class="link-button" href="/status.html">回到状态页</a>
|
||||
<a class="link-button" href="/swagger" target="_blank" rel="noopener">Swagger UI</a>
|
||||
<button id="reload-spec" type="button">重新加载 OpenAPI</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="meta">
|
||||
<dl>
|
||||
<dt>OpenAPI 文档</dt>
|
||||
<dd id="meta-spec-url">--</dd>
|
||||
<dt>API 数量</dt>
|
||||
<dd id="meta-operation-count">--</dd>
|
||||
<dt>加载状态</dt>
|
||||
<dd id="meta-status">初始化中...</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<div id="debug-console-app">
|
||||
<div class="empty-hint">正在加载接口列表...</div>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="history" id="history-panel">
|
||||
<h3>
|
||||
<span>调用历史 (本次会话)</span>
|
||||
<button type="button" id="history-clear" class="secondary">清空</button>
|
||||
</h3>
|
||||
<ul id="history-list"></ul>
|
||||
</aside>
|
||||
<script src="/assets/debug.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
64
src/Flyshot.Server.Host/wwwroot/status.html
Normal file
64
src/Flyshot.Server.Host/wwwroot/status.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Flyshot Replacement 状态监控</title>
|
||||
<link rel="stylesheet" href="/assets/status.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>Flyshot Replacement 状态监控</h1>
|
||||
<div class="actions">
|
||||
<a class="link-button" href="/debug.html" target="_blank" rel="noopener">调试接口</a>
|
||||
<button id="refresh" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="summary">
|
||||
<div class="metric">
|
||||
<div class="label">连接状态</div>
|
||||
<div class="value status-row"><span id="state-dot" class="dot"></span><span id="connection-state">--</span></div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">机器人</div>
|
||||
<div id="robot-name" class="value">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">速度倍率</div>
|
||||
<div id="speed-ratio" class="value">--</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">运动中</div>
|
||||
<div id="motion-state" class="value">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section>
|
||||
<h2>控制器</h2>
|
||||
<dl>
|
||||
<dt>服务端版本</dt><dd id="server-version">--</dd>
|
||||
<dt>客户端版本</dt><dd id="client-version">--</dd>
|
||||
<dt>已初始化</dt><dd id="setup-state">--</dd>
|
||||
<dt>已使能</dt><dd id="enabled-state">--</dd>
|
||||
<dt>J519 状态</dt><dd id="j519-status">--</dd>
|
||||
<dt>J519 序号</dt><dd id="j519-sequence">--</dd>
|
||||
<dt>采样时间</dt><dd id="captured-at">--</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>机器人</h2>
|
||||
<dl>
|
||||
<dt>自由度</dt><dd id="dof">--</dd>
|
||||
<dt>关节位置</dt><dd id="joints">--</dd>
|
||||
<dt>TCP 位姿</dt><dd id="pose">--</dd>
|
||||
<dt>已上传轨迹</dt><dd id="trajectories" class="empty">--</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/status.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user