diff --git a/docs/status-jog-joystick-demo.html b/docs/status-jog-joystick-demo.html new file mode 100644 index 0000000..849c8be --- /dev/null +++ b/docs/status-jog-joystick-demo.html @@ -0,0 +1,657 @@ + + + + + + 状态页摇杆点动 Demo + + + +
+
+

Flyshot Replacement 状态监控

+
Connected
+
+
+
+
+
+
机器人
+
FANUC
+
+
+
速度倍率
+
0.70
+
+
+
运动中
+
否
+
+
+
TCP 位姿
+
120.0, 80.0, 320.0
+
+
+ +
+

直角坐标点动

+
+ + +
等待点动输入
+
+ +
+
+
+

XYZ 平移摇杆

+ mm +
+
+ + +
+ + +
+ + +
+
+ X 左右 + Y 上下 + Z 中心 +
+
+ +
+
+

WPR 姿态摇杆

+ deg +
+
+ + +
+ + +
+ + +
+
+ W 左右 + P 上下 + R 中心 +
+
+
+
+ +
+
+

请求日志

+ +
+
+
+ --:--:-- + 等待输入 + 按住任意摇杆方向后,这里会记录每次模拟 MovePose 请求。 +
+
+
+
+ + + + diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.css b/src/Flyshot.Server.Host/wwwroot/assets/status.css index 4206ed3..e17f75c 100644 --- a/src/Flyshot.Server.Host/wwwroot/assets/status.css +++ b/src/Flyshot.Server.Host/wwwroot/assets/status.css @@ -6,6 +6,9 @@ --text: #172033; --muted: #5b667a; --accent: #007c89; + --accent-dark: #005d67; + --steel: #2f3b4f; + --steel-soft: #e7ebf1; --good: #12805c; --warn: #b7791f; --bad: #b42318; @@ -190,28 +193,27 @@ dd { .jog-panel { grid-column: 1 / -1; + overflow: hidden; } -.jog-content { - padding: 16px; -} - -.jog-settings { +.jog-toolbar { display: grid; - grid-template-columns: repeat(2, minmax(180px, 1fr)); + grid-template-columns: repeat(2, minmax(180px, 1fr)) minmax(220px, 1fr); gap: 12px; - margin-bottom: 14px; + padding: 16px; + border-bottom: 1px solid var(--line); + background: #fbfcfe; } -.jog-settings label { +.step-field { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } -.jog-settings input { - min-height: 36px; +.step-field input { + min-height: 38px; width: 100%; padding: 0 10px; border: 1px solid var(--line); @@ -221,35 +223,257 @@ dd { font: inherit; } -.jog-grid { +.jog-body { display: grid; - grid-template-columns: repeat(6, minmax(64px, 1fr)); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 18px; + padding: 16px; +} + +.stick-card { + min-width: 0; + border: 1px solid #ccd4df; + border-radius: 8px; + background: linear-gradient(180deg, #ffffff 0%, #f3f6fa 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.stick-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px 0; +} + +.stick-head h3 { + margin: 0; + font-size: 16px; + font-weight: 650; +} + +.stick-head span { + color: var(--muted); + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 13px; +} + +.stick-console { + display: grid; + grid-template-columns: 72px 1fr 72px; + grid-template-rows: 72px 72px 72px; gap: 8px; + width: min(100%, 344px); + margin: 18px auto 16px; + padding: 14px; + border: 1px solid #c2cad7; + border-radius: 999px; + background: + radial-gradient(circle at 50% 50%, #ffffff 0 26%, #e6ebf2 27% 48%, #cdd5e0 49% 50%, #edf1f6 51% 100%); } .jog-button { + min-width: 44px; min-height: 44px; - padding: 0 8px; + color: var(--steel); font-weight: 650; touch-action: none; user-select: none; } +.axis-button, +.mini-button { + border: 1px solid #99a8ba; + border-radius: 999px; + background: linear-gradient(180deg, #ffffff 0%, #e5ebf2 100%); + box-shadow: 0 2px 0 #b7c1ce; +} + +.axis-button { + min-width: 0; + min-height: 64px; + padding: 0 8px; +} + +.mini-button { + padding: 0 10px; + background: var(--surface); +} + +.axis-button:hover, +.axis-button.active { + border-color: var(--accent); + background: linear-gradient(180deg, #10a4b3 0%, var(--accent) 100%); + color: #ffffff; + box-shadow: 0 2px 0 var(--accent-dark); +} + +.mini-button:hover, +.mini-button.active { + border-color: var(--warn); + background: var(--warn); + color: #ffffff; + box-shadow: 0 2px 0 #8a5a13; +} + +.jog-button:focus-visible { + outline: 3px solid rgba(0, 124, 137, 0.3); + outline-offset: 2px; +} + .jog-button.active { - filter: brightness(0.88); + transform: translateY(1px); +} + +.axis-button.up { + grid-column: 2; + grid-row: 1; +} + +.axis-button.left { + grid-column: 1; + grid-row: 2; +} + +.axis-button.right { + grid-column: 3; + grid-row: 2; +} + +.axis-button.down { + grid-column: 2; + grid-row: 3; +} + +.center-stack { + display: grid; + grid-column: 2; + grid-row: 2; + grid-template-columns: 1fr 1fr; + gap: 6px; + place-items: center; + padding: 8px; + border-radius: 999px; + background: rgba(47, 59, 79, 0.08); +} + +.hint-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0 16px 16px; + color: var(--muted); + font-size: 13px; + text-align: center; +} + +.hint-row span { + min-height: 28px; + padding: 5px 8px; + border-radius: 999px; + background: var(--steel-soft); + white-space: nowrap; } .jog-status { - min-height: 24px; - margin-top: 12px; + display: flex; + align-items: center; + justify-content: flex-end; + min-height: 38px; color: var(--muted); font-family: Consolas, "Cascadia Mono", monospace; + text-align: right; } .jog-status.error { color: var(--bad); } +.log-panel { + margin: 0 16px 16px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfe; +} + +.log-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.log-head h3 { + margin: 0; + font-size: 15px; + font-weight: 650; +} + +.clear-button { + min-height: 34px; + padding: 0 12px; + border-color: var(--line); + background: #ffffff; + color: var(--muted); +} + +.clear-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.request-log { + display: grid; + gap: 8px; + max-height: 300px; + min-height: 54px; + margin: 0; + padding: 12px 14px 14px; + overflow: auto; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 13px; +} + +.log-entry { + display: grid; + grid-template-columns: 88px 96px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 9px 10px; + border: 1px solid #e1e7ef; + border-radius: 6px; + background: #ffffff; +} + +.log-entry:first-child { + border-color: rgba(0, 124, 137, 0.35); + background: rgba(0, 124, 137, 0.04); +} + +.log-entry.error { + border-color: rgba(180, 35, 24, 0.35); + background: rgba(180, 35, 24, 0.06); +} + +.log-time, +.log-axis { + color: var(--muted); + white-space: nowrap; +} + +.log-payload { + overflow-wrap: anywhere; + color: var(--text); +} + +.log-empty { + color: var(--muted); +} + @media (max-width: 820px) { .topbar { align-items: flex-start; @@ -261,9 +485,14 @@ dd { grid-template-columns: 1fr; } - .jog-settings, - .jog-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .jog-toolbar, + .jog-body { + grid-template-columns: 1fr; + } + + .jog-status { + justify-content: flex-start; + text-align: left; } dl { @@ -279,3 +508,19 @@ dd { padding-top: 2px; } } + +@media (max-width: 520px) { + .stick-console { + grid-template-columns: 60px 1fr 60px; + grid-template-rows: 60px 60px 60px; + padding: 10px; + } + + .axis-button { + min-height: 56px; + } + + .log-entry { + grid-template-columns: 1fr; + } +} diff --git a/src/Flyshot.Server.Host/wwwroot/assets/status.js b/src/Flyshot.Server.Host/wwwroot/assets/status.js index 7720c75..5c97ffa 100644 --- a/src/Flyshot.Server.Host/wwwroot/assets/status.js +++ b/src/Flyshot.Server.Host/wwwroot/assets/status.js @@ -19,6 +19,8 @@ const fields = { linearStep: document.getElementById("linear-step"), angularStep: document.getElementById("angular-step"), jogStatus: document.getElementById("jog-status"), + requestLog: document.getElementById("request-log"), + clearLog: document.getElementById("clear-log"), jogButtons: Array.from(document.querySelectorAll(".jog-button")) }; @@ -36,9 +38,14 @@ const jogState = { timer: null, activeButton: null, inFlight: false, - lastSnapshot: null + lastSnapshot: null, + sequence: 0, + logs: [] }; +const maxJogLogs = 80; +const jogIntervalMs = 250; + function formatArray(values) { if (!Array.isArray(values) || values.length === 0) { return "--"; @@ -90,10 +97,10 @@ function clampStep(input, min, max) { } function getStepForAxis(axis) { - // 平移和姿态使用不同单位,但共享 0.1 到 10 的现场可调范围。 + // 平移和姿态使用不同单位,但共享 0.1 到 20 的现场可调范围。 return axis === "x" || axis === "y" || axis === "z" - ? clampStep(fields.linearStep, 0.1, 10) - : clampStep(fields.angularStep, 0.1, 10); + ? clampStep(fields.linearStep, 0.1, 20) + : clampStep(fields.angularStep, 0.1, 20); } function setJogStatus(message, isError) { @@ -101,6 +108,51 @@ function setJogStatus(message, isError) { fields.jogStatus.classList.toggle("error", Boolean(isError)); } +function formatDirection(axis, direction) { + return `${axis.toUpperCase()}${direction > 0 ? "+" : "-"}`; +} + +function renderJogLogs() { + fields.requestLog.replaceChildren(); + + if (jogState.logs.length === 0) { + const empty = document.createElement("div"); + empty.className = "log-empty"; + empty.textContent = "暂无请求记录"; + fields.requestLog.appendChild(empty); + return; + } + + jogState.logs.forEach(function (entry) { + const row = document.createElement("div"); + row.className = `log-entry${entry.ok ? "" : " error"}`; + + const time = document.createElement("span"); + time.className = "log-time"; + time.textContent = entry.time; + + const axis = document.createElement("span"); + axis.className = "log-axis"; + axis.textContent = `#${entry.sequence} ${entry.direction}`; + + const payload = document.createElement("span"); + payload.className = "log-payload"; + payload.textContent = `step=${entry.step} ${entry.unit}; body=${entry.body}; ${entry.result}`; + + row.append(time, axis, payload); + fields.requestLog.appendChild(row); + }); +} + +function addJogLog(entry) { + jogState.logs.unshift(entry); + if (jogState.logs.length > maxJogLogs) { + jogState.logs.length = maxJogLogs; + } + + renderJogLogs(); +} + async function loadSnapshotForJog() { const response = await fetch("/api/status/snapshot", { cache: "no-store" }); if (!response.ok) { @@ -149,25 +201,46 @@ async function sendJog(button) { } jogState.inFlight = true; + const step = getStepForAxis(axis); + const directionLabel = formatDirection(axis, direction); + const sequence = ++jogState.sequence; + let bodyText = "{}"; + let resultText = ""; + let ok = false; + try { const snapshot = await loadSnapshotForJog(); const pose = buildJogPose(snapshot, axis, direction); + bodyText = JSON.stringify(pose); const response = await fetch("/move_pose/", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(pose) + body: bodyText }); if (!response.ok) { throw new Error(`MovePose 调用失败: HTTP ${response.status}`); } - setJogStatus(`${axis.toUpperCase()}${direction > 0 ? "+" : "-"} 已发送`, false); + ok = true; + resultText = `HTTP ${response.status}`; + setJogStatus(`${directionLabel} 已发送`, false); await refreshStatus(); } catch (error) { - setJogStatus(error instanceof Error ? error.message : "点动失败", true); + resultText = error instanceof Error ? error.message : "点动失败"; + setJogStatus(resultText, true); stopJog(); } finally { + addJogLog({ + time: new Date().toLocaleTimeString(), + sequence, + direction: directionLabel, + step, + unit: axis === "x" || axis === "y" || axis === "z" ? "mm" : "deg", + body: bodyText, + result: resultText, + ok + }); jogState.inFlight = false; } } @@ -185,7 +258,7 @@ function startJog(event) { sendJog(button); jogState.timer = window.setInterval(function () { sendJog(button); - }, 250); + }, jogIntervalMs); } function stopJog() { @@ -234,10 +307,14 @@ async function refreshStatus() { fields.refresh.addEventListener("click", refreshStatus); fields.linearStep.addEventListener("change", function () { - clampStep(fields.linearStep, 0.1, 10); + clampStep(fields.linearStep, 0.1, 20); }); fields.angularStep.addEventListener("change", function () { - clampStep(fields.angularStep, 0.1, 10); + clampStep(fields.angularStep, 0.1, 20); +}); +fields.clearLog.addEventListener("click", function () { + jogState.logs = []; + renderJogLogs(); }); fields.jogButtons.forEach(function (button) { button.addEventListener("pointerdown", startJog); @@ -251,5 +328,6 @@ window.addEventListener("keyup", function (event) { stopJog(); } }); +renderJogLogs(); refreshStatus(); window.setInterval(refreshStatus, 2000); diff --git a/src/Flyshot.Server.Host/wwwroot/status.html b/src/Flyshot.Server.Host/wwwroot/status.html index 5366dd1..7239670 100644 --- a/src/Flyshot.Server.Host/wwwroot/status.html +++ b/src/Flyshot.Server.Host/wwwroot/status.html @@ -59,32 +59,69 @@

直角坐标点动

-
-
- - +
+ + +
等待点动输入
+
+
+
+
+

XYZ 平移摇杆

+ mm +
+
+ + +
+ + +
+ + +
+
+ X 左右 + Y 上下 + Z 中心 +
+
+
+
+

WPR 姿态摇杆

+ deg +
+
+ + +
+ + +
+ + +
+
+ W 左右 + P 上下 + R 中心 +
+
+
+
+
+

请求日志

+
-
- - - - - - - - - - - - +
+
暂无请求记录
-
--
diff --git a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs index 2c8c3f9..a8986fc 100644 --- a/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs +++ b/tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs @@ -35,7 +35,7 @@ public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFi } /// - /// 状态页应当提供直角坐标点动按钮,并复用现有 MovePose HTTP 接口。 + /// 状态页应当提供双摇杆直角坐标点动面板,并复用现有 MovePose HTTP 接口。 /// [Fact] public async Task GetStatusPageAssets_ExposeCartesianJogControls() @@ -47,24 +47,45 @@ public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFi var html = await htmlResponse.Content.ReadAsStringAsync(); Assert.Contains("直角坐标点动", html, StringComparison.Ordinal); + Assert.Contains("XYZ 平移摇杆", html, StringComparison.Ordinal); + Assert.Contains("WPR 姿态摇杆", html, StringComparison.Ordinal); Assert.Contains("id=\"linear-step\"", html, StringComparison.Ordinal); Assert.Contains("id=\"angular-step\"", html, StringComparison.Ordinal); + Assert.Contains("max=\"20\"", html, StringComparison.Ordinal); + Assert.Contains("请求日志", html, StringComparison.Ordinal); + Assert.Contains("id=\"request-log\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"clear-log\"", html, StringComparison.Ordinal); - foreach (var axis in new[] { "x", "y", "z", "w", "p", "r" }) + foreach (var (axis, negative, positive) in new[] + { + ("x", "X-", "X+"), + ("y", "Y-", "Y+"), + ("z", "Z-", "Z+"), + ("w", "W-", "W+"), + ("p", "P-", "P+"), + ("r", "R-", "R+") + }) { Assert.Contains($"data-axis=\"{axis}\"", html, StringComparison.Ordinal); Assert.Contains($"data-axis=\"{axis}\" data-direction=\"1\"", html, StringComparison.Ordinal); Assert.Contains($"data-axis=\"{axis}\" data-direction=\"-1\"", html, StringComparison.Ordinal); + Assert.Contains(negative, html, StringComparison.Ordinal); + Assert.Contains(positive, html, StringComparison.Ordinal); } using var scriptResponse = await client.GetAsync("/assets/status.js"); Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); var script = await scriptResponse.Content.ReadAsStringAsync(); + Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal); Assert.Contains("/move_pose/", script, StringComparison.Ordinal); Assert.Contains("cartesianPose", script, StringComparison.Ordinal); Assert.Contains("pointerdown", script, StringComparison.Ordinal); Assert.Contains("pointerup", script, StringComparison.Ordinal); + Assert.Contains("pointerleave", script, StringComparison.Ordinal); + Assert.Contains("blur", script, StringComparison.Ordinal); + Assert.Contains("request-log", script, StringComparison.Ordinal); + Assert.Contains("clear-log", script, StringComparison.Ordinal); } ///