fix: 让 HWPOD 工作区占据主面板

This commit is contained in:
root
2026-07-21 14:12:32 +02:00
parent 50c693fc50
commit 7e65eb91c4
5 changed files with 238 additions and 17 deletions
+5
View File
@@ -149,6 +149,11 @@ test("hwpod-compiler-cli generates Keil build and download commands from structu
assert.equal(build.payload.plan.ops[0].args.commandBinding.source, "hwpod-compiler.keil-mdk");
assert.equal(build.payload.plan.ops[0].args.commandBinding.action, "build");
const blockingBuild = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build", "--args", "{\"wait\":true}"], { now: () => NOW });
assert.equal(blockingBuild.exitCode, 0);
assert.equal(blockingBuild.payload.plan.ops[0].args.commandRuns[0].argv.at(-1), "--wait");
assert.equal(blockingBuild.payload.plan.ops[0].args.commandBinding.wait, true);
const download = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
assert.equal(download.exitCode, 0);
assert.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run", "cmd.run"]);
@@ -2,11 +2,13 @@
<script setup lang="ts">
import { BaseTree } from "@he-tree/vue";
import "@he-tree/vue/style/default.css";
import { ChevronLeft, Download, File, FileCode2, Folder, Hammer, Plug, RefreshCw, Send, Square, Terminal } from "lucide-vue-next";
import { ChevronLeft, CircleCheck, CircleX, Download, File, Folder, Hammer, LoaderCircle, Plug, RefreshCw, Send, Square, Terminal } from "lucide-vue-next";
import { computed, nextTick, onMounted, ref } from "vue";
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import ContentViewer from "@/components/common/ContentViewer.vue";
import { useHwpodStore } from "@/stores/hwpod";
import type { HwpodOperationResponse, HwpodWorkspaceEntry } from "@/types";
import { presentBuildOperation, workspacePreviewMode } from "./hwpod-workspace-presentation";
type WorkspaceNode = HwpodWorkspaceEntry & { children?: WorkspaceNode[]; loaded?: boolean };
@@ -24,7 +26,17 @@ const uartPort = ref("uart1");
const uartBaudRate = ref(115200);
const uartData = ref("");
const uartOutput = ref("");
const buildOperation = ref<HwpodOperationResponse | null>(null);
const previousBuildOperationId = ref<string | null>(null);
const terminalStatus = computed(() => hwpod.lastOperation?.status ?? "idle");
const previewMode = computed(() => workspacePreviewMode(selectedPath.value));
const activeBuildOperation = computed(() => {
if (busy.value !== "debug.build") return buildOperation.value;
const current = hwpod.lastOperation;
return current?.operationId && current.operationId !== previousBuildOperationId.value ? current : buildOperation.value;
});
const buildView = computed(() => presentBuildOperation(activeBuildOperation.value, busy.value === "debug.build"));
const device = computed(() => hwpod.deviceTopology?.items.find((item) => item.hwpodId === props.hwpodId) ?? null);
onMounted(async () => {
await loadRoot();
@@ -84,7 +96,14 @@ async function openInitialPath(path: string): Promise<void> {
}
async function runAction(intent: string, args: Record<string, unknown> = {}): Promise<void> {
await operationOutput(intent, args);
try { await operationOutput(intent, args); }
catch { /* The bounded operation panel owns the visible error. */ }
}
async function runBuild(): Promise<void> {
previousBuildOperationId.value = hwpod.lastOperation?.operationId ?? null;
buildOperation.value = null;
await runAction("debug.build", { wait: true });
}
async function confirmDownload(): Promise<void> {
@@ -113,6 +132,7 @@ async function operationOutput(intent: string, args: Record<string, unknown>): P
localError.value = null;
try {
const operation = await hwpod.executeOperation(props.hwpodId, intent, args, 900_000);
if (intent === "debug.build") buildOperation.value = operation;
const activity = record(operation.result);
if (activity.ok === false) throw new Error(errorSummary(activity) || `${intent} 执行失败`);
const response = record(activity.response);
@@ -175,9 +195,16 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
</section>
<section class="preview-pane" aria-label="文件预览">
<h2><FileCode2 :size="16" /> 文件预览</h2>
<div v-if="previewMeta" class="preview-meta"><span>{{ previewMeta.sizeBytes }} bytes</span><span v-if="previewMeta.truncated">预览已截断</span></div>
<pre v-if="preview">{{ preview }}</pre>
<ContentViewer
v-if="preview"
class="workspace-content-viewer"
:content="preview"
:mode="previewMode"
title="文件预览"
:filename="selectedPath"
:line-numbers="previewMode !== 'markdown'"
/>
<div v-else class="empty-preview">选择文件查看内容</div>
</section>
@@ -185,9 +212,25 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
<section>
<h2><Hammer :size="16" /> 调试</h2>
<div class="action-row">
<button type="button" :disabled="Boolean(busy)" @click="runAction('debug.build')"><Hammer :size="15" /> 编译</button>
<button type="button" :disabled="Boolean(busy)" @click="runBuild"><Hammer :size="15" /> 编译</button>
<button type="button" :disabled="Boolean(busy)" @click="downloadConfirmOpen = true"><Download :size="15" /> 下载</button>
</div>
<div class="build-result" :data-tone="buildView.tone" aria-live="polite">
<div class="build-result-heading">
<LoaderCircle v-if="buildView.tone === 'running'" class="spin" :size="15" />
<CircleCheck v-else-if="buildView.tone === 'ok'" :size="15" />
<CircleX v-else-if="buildView.tone === 'error'" :size="15" />
<span class="status-dot" v-else aria-hidden="true" />
<strong>{{ buildView.label }}</strong>
<span v-if="buildView.exitCode !== null">exit {{ buildView.exitCode }}</span>
</div>
<div v-if="buildView.operationId" class="build-identities">
<code :title="buildView.operationId">{{ buildView.operationId }}</code>
<code v-if="buildView.jobId">job {{ buildView.jobId }}</code>
</div>
<div v-if="buildView.tone === 'running'" class="build-progress" aria-label="编译进行中"><span /></div>
<pre v-if="buildView.log" class="build-log">{{ buildView.log }}</pre>
</div>
</section>
<section>
<h2><Terminal :size="16" /> 串口</h2>
@@ -201,30 +244,41 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
<div class="send-row"><input v-model="uartData" aria-label="串口发送内容" @keyup.enter="writeUart" /><button type="button" title="发送串口数据" :disabled="Boolean(busy) || !uartData" @click="writeUart"><Send :size="15" /></button></div>
<pre class="uart-output">{{ uartOutput || "等待串口数据" }}</pre>
</section>
<p v-if="busy" class="operation-message">{{ busy }} · {{ terminalStatus }}</p>
<p v-if="busy && busy !== 'debug.build'" class="operation-message">{{ busy }} · {{ terminalStatus }}</p>
<p v-if="localError" class="operation-error" role="alert">{{ localError }}</p>
</aside>
</div>
<footer class="workspace-statusbar" aria-label="HWPOD 工作区状态" aria-live="polite">
<span :data-tone="device?.online ? 'ok' : 'error'"><i />Node {{ device?.online ? "在线" : "离线" }}</span>
<span :data-tone="device?.available ? 'ok' : 'warn'"><i />HWPOD {{ device?.available ? "可用" : "不可用" }}</span>
<span class="status-path" :title="selectedPath">{{ selectedPath }}</span>
<span class="status-operation" :data-tone="buildView.tone"><i />{{ buildView.label }}</span>
</footer>
<ConfirmDialog :open="downloadConfirmOpen" title="下载固件" message="下载会写入当前连接的目标板。确认继续?" confirm-label="确认下载" :pending="busy === 'debug.download'" @close="downloadConfirmOpen = false" @confirm="confirmDownload" />
</section>
</template>
<style scoped>
.operations-workspace { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--console-border-strong); background: var(--console-surface-raised); }
.operations-workspace { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr) 28px; border: 1px solid var(--console-border-strong); background: var(--console-surface-raised); }
.workspace-toolbar { display: grid; min-width: 0; grid-template-columns: auto auto minmax(80px, 1fr) auto minmax(160px, auto); align-items: center; gap: 8px; min-height: 42px; padding: 5px 8px; border-bottom: 1px solid var(--console-border-strong); }
.workspace-toolbar strong, .workspace-toolbar code, .operation-state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-toolbar code, .operation-state { color: var(--console-graphite-600); font-size: 11px; }
.icon-command, .icon-actions button, .send-row button { display: inline-grid; width: 31px; height: 31px; place-items: center; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; color: var(--console-graphite-800); cursor: pointer; }
.workspace-columns { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(210px, .85fr) minmax(300px, 1.5fr) minmax(240px, .9fr); }
.file-pane, .preview-pane, .operation-pane { min-width: 0; min-height: 0; padding: 10px; overflow: auto; }
.file-pane, .preview-pane, .operation-pane { min-width: 0; min-height: 0; padding: 10px; overflow: auto; overscroll-behavior: contain; }
.file-pane, .preview-pane { border-right: 1px solid var(--console-border-strong); }
h2 { display: flex; align-items: center; gap: 6px; margin: 0 0 9px; color: var(--console-graphite-700); font-size: 12px; }
.file-tree { height: calc(100% - 28px); min-height: 280px; overflow: auto; }
.tree-row { display: flex; min-width: 0; align-items: center; gap: 6px; min-height: 30px; padding-right: 6px; color: var(--console-graphite-700); cursor: pointer; }
.tree-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-row.selected { background: var(--console-cyan-100); color: var(--console-cyan-700); }
.preview-pane pre, .uart-output { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--console-font-mono); font-size: 11px; line-height: 1.55; }
.preview-pane { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
.preview-pane :deep(.workspace-content-viewer) { height: 100%; min-height: 0; }
.preview-pane :deep(.content-viewer-body) { min-height: 0; overflow: auto; overscroll-behavior: contain; }
.preview-pane :deep(.content-viewer-markdown) { font-size: 12px; line-height: 1.65; }
.uart-output { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--console-font-mono); font-size: 11px; line-height: 1.55; }
.preview-meta { display: flex; gap: 12px; margin-bottom: 8px; color: var(--console-graphite-500); font-size: 10px; }
.empty-preview { display: grid; min-height: 180px; place-items: center; color: var(--console-graphite-500); font-size: 12px; }
.operation-pane { display: grid; align-content: start; gap: 18px; background: var(--console-surface-muted); }
@@ -233,11 +287,38 @@ h2 { display: flex; align-items: center; gap: 6px; margin: 0 0 9px; color: var(-
.operation-pane input { min-width: 0; height: 32px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; padding: 0 8px; }
.action-row, .icon-actions, .send-row { display: flex; gap: 6px; }
.action-row button { display: inline-flex; min-height: 32px; align-items: center; gap: 6px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: #fff; padding: 0 10px; color: var(--console-graphite-800); font-weight: 700; cursor: pointer; }
.build-result { display: grid; min-width: 0; gap: 6px; padding: 8px; border: 1px solid var(--console-border); border-left: 3px solid var(--console-graphite-500); background: var(--console-surface-raised); }
.build-result[data-tone="running"] { border-left-color: var(--console-cyan-700); }
.build-result[data-tone="ok"] { border-left-color: #2c6b5d; }
.build-result[data-tone="warn"] { border-left-color: var(--console-amber-500); }
.build-result[data-tone="error"] { border-left-color: var(--console-red-700); }
.build-result-heading { display: flex; min-width: 0; align-items: center; gap: 6px; font-size: 11px; }
.build-result-heading strong { min-width: 0; flex: 1; }
.build-result-heading > span { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; }
.status-dot { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--console-graphite-500); }
.build-identities { display: grid; gap: 2px; }
.build-identities code { overflow: hidden; color: var(--console-graphite-600); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.build-progress { height: 3px; overflow: hidden; background: var(--console-border); }
.build-progress span { display: block; width: 42%; height: 100%; background: var(--console-cyan-700); animation: build-progress 1.1s ease-in-out infinite alternate; }
.build-log { max-height: 180px; margin: 0; padding: 7px; overflow: auto; overscroll-behavior: contain; background: #171c1a; color: #d8eee5; font-family: var(--console-font-mono); font-size: 9px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
.spin { animation: spin .9s linear infinite; }
.send-row input { flex: 1; }
button:disabled { opacity: .45; cursor: wait; }
.uart-output { max-height: 190px; min-height: 88px; padding: 8px; overflow: auto; border: 1px solid var(--console-border-strong); background: #171c1a; color: #d8eee5; }
.operation-message { color: var(--console-cyan-700); font-size: 11px; }
.operation-error { margin: 0; padding: 8px; border-left: 3px solid var(--console-red-700); background: var(--console-red-100); color: var(--console-red-700); font-size: 11px; }
.workspace-statusbar { display: grid; min-width: 0; grid-template-columns: auto auto minmax(80px, 1fr) minmax(150px, auto); align-items: center; gap: 0; border-top: 1px solid var(--console-border-strong); background: #edf1ef; color: var(--console-graphite-700); font-family: var(--console-font-mono); font-size: 9px; }
.workspace-statusbar > span { display: flex; min-width: 0; height: 100%; align-items: center; gap: 5px; padding: 0 9px; border-right: 1px solid var(--console-border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-statusbar i { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--console-graphite-500); }
.workspace-statusbar [data-tone="ok"] i { background: #2c6b5d; }
.workspace-statusbar [data-tone="running"] i { background: var(--console-cyan-700); }
.workspace-statusbar [data-tone="warn"] i { background: var(--console-amber-500); }
.workspace-statusbar [data-tone="error"] i { background: var(--console-red-700); }
.status-path { direction: rtl; justify-content: flex-end; text-align: left; }
.status-operation { justify-content: flex-end; border-right: 0 !important; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes build-progress { from { transform: translateX(-20%); } to { transform: translateX(160%); } }
@media (max-width: 980px) { .workspace-columns { grid-template-columns: minmax(190px, .8fr) minmax(280px, 1.2fr); } .operation-pane { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--console-border-strong); } }
@media (max-width: 680px) { .workspace-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; } .workspace-toolbar code, .operation-state { grid-column: 1 / -1; } .workspace-columns { display: block; overflow: auto; } .file-pane, .preview-pane { min-height: 300px; border-right: 0; border-bottom: 1px solid var(--console-border-strong); } .operation-pane { display: grid; grid-template-columns: 1fr; } }
@media (max-width: 680px) { .workspace-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; } .workspace-toolbar code, .operation-state { grid-column: 1 / -1; } .workspace-columns { display: block; overflow: auto; } .file-pane, .preview-pane { min-height: 300px; border-right: 0; border-bottom: 1px solid var(--console-border-strong); } .operation-pane { display: grid; grid-template-columns: 1fr; } .workspace-statusbar { grid-template-columns: auto auto minmax(0, 1fr); } .status-operation { display: none !important; } }
@media (prefers-reduced-motion: reduce) { .spin, .build-progress span { animation: none; } }
</style>
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { presentBuildOperation, workspacePreviewMode } from "./hwpod-workspace-presentation";
describe("HWPOD workspace presentation", () => {
it("renders Markdown files through the mature viewer mode", () => {
expect(workspacePreviewMode("docs/README.md")).toBe("markdown");
expect(workspacePreviewMode("notes.MARKDOWN")).toBe("markdown");
expect(workspacePreviewMode("src/main.c")).toBe("text");
});
it("does not present an accepted asynchronous Keil job as completed", () => {
const view = presentBuildOperation({
ok: true,
status: "completed",
operationId: "operation-queued",
result: {
ok: true,
response: {
ok: true,
results: [{ ok: true, output: { ok: true, exitCode: 0, stdout: JSON.stringify({ accepted: true, status: "queued", job_id: "keil-1" }) } }],
},
},
});
expect(view).toMatchObject({ tone: "warn", terminal: false, jobId: "keil-1", label: "构建已排队,等待真实终态" });
});
it("projects the real Keil terminal exit code and bounded logs", () => {
const view = presentBuildOperation({
ok: true,
status: "completed",
operationId: "operation-completed",
result: {
ok: true,
response: {
ok: true,
results: [{ ok: true, output: { ok: true, exitCode: 0, stdout: JSON.stringify({ status: "completed", return_code: 0, errors: 0 }), stderr: "build started" } }],
},
},
});
expect(view).toMatchObject({ tone: "ok", terminal: true, exitCode: 0, label: "编译通过" });
expect(view.log).toContain("build started");
expect(view.log).toContain('"errors":0');
});
});
@@ -0,0 +1,87 @@
import type { HwpodOperationResponse } from "@/types";
export type WorkspacePreviewMode = "text" | "markdown";
export interface BuildOperationPresentation {
status: string;
label: string;
tone: "idle" | "running" | "ok" | "warn" | "error";
operationId: string | null;
exitCode: number | null;
jobId: string | null;
log: string;
terminal: boolean;
}
export function workspacePreviewMode(path: string): WorkspacePreviewMode {
return /\.(?:md|markdown)$/iu.test(path.trim()) ? "markdown" : "text";
}
export function presentBuildOperation(operation: HwpodOperationResponse | null, pending = false): BuildOperationPresentation {
if (!operation) {
return {
status: pending ? "submitting" : "idle",
label: pending ? "正在提交编译" : "尚未编译",
tone: pending ? "running" : "idle",
operationId: null,
exitCode: null,
jobId: null,
log: "",
terminal: false,
};
}
const activity = record(operation.result);
const response = record(activity.response);
const result = Array.isArray(response.results) ? record(response.results[0]) : {};
const output = record(result.output);
const commands = Array.isArray(output.commands) ? output.commands.map(record) : [];
const lastCommand = commands.at(-1) ?? {};
const stdout = stringValue(output.stdout) || stringValue(lastCommand.stdout);
const stderr = stringValue(output.stderr) || stringValue(lastCommand.stderr);
const parsed = parseJsonRecord(stdout);
const toolStatus = stringValue(parsed.status).toLowerCase();
const operationStatus = stringValue(operation.status).toLowerCase() || "unknown";
const exitCode = numberValue(output.exitCode, lastCommand.exitCode, parsed.return_code, parsed.returnCode);
const jobId = stringValue(parsed.job_id) || stringValue(parsed.jobId) || null;
const asyncQueued = parsed.accepted === true && ["queued", "running", "accepted"].includes(toolStatus);
const failed = operation.error != null
|| activity.ok === false
|| response.ok === false
|| result.ok === false
|| output.ok === false
|| (exitCode !== null && exitCode !== 0)
|| ["failed", "error", "canceled", "cancelled", "timed-out", "timeout"].includes(toolStatus)
|| ["failed", "canceled", "cancelled", "terminated", "timed-out", "timeout"].includes(operationStatus);
const terminal = !asyncQueued && ["completed", "succeeded", "failed", "canceled", "cancelled", "terminated", "timed-out", "timeout"].includes(operationStatus);
const running = pending || asyncQueued || ["accepted", "queued", "running", "pending", "in-progress"].includes(operationStatus);
const log = [stderr ? `stderr\n${stderr.trim()}` : "", stdout ? `stdout\n${stdout.trim()}` : ""].filter(Boolean).join("\n\n");
if (failed) return { status: toolStatus || operationStatus, label: "编译失败", tone: "error", operationId: operation.operationId, exitCode, jobId, log, terminal: true };
if (asyncQueued) return { status: toolStatus, label: "构建已排队,等待真实终态", tone: "warn", operationId: operation.operationId, exitCode, jobId, log, terminal: false };
if (running) return { status: operationStatus, label: "正在等待 Windows Keil 构建终态", tone: "running", operationId: operation.operationId, exitCode, jobId, log, terminal: false };
if (terminal) return { status: operationStatus, label: "编译通过", tone: "ok", operationId: operation.operationId, exitCode, jobId, log, terminal: true };
return { status: operationStatus, label: "编译状态未知", tone: "warn", operationId: operation.operationId, exitCode, jobId, log, terminal: false };
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function numberValue(...values: unknown[]): number | null {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) return number;
}
return null;
}
function parseJsonRecord(value: string): Record<string, unknown> {
if (!value) return {};
try { return record(JSON.parse(value)); }
catch { return {}; }
}
@@ -203,8 +203,8 @@ function text(value: unknown): string {
</script>
<template>
<section class="hwpod-console" data-testid="hwpod-console">
<PageCommandBar eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。">
<section class="hwpod-console" data-testid="hwpod-console" :data-workspace-mode="workspaceMode ? 'true' : 'false'">
<PageCommandBar v-if="!workspaceMode" eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。">
<template #actions>
<nav class="console-tabs" aria-label="HWPOD 子页面">
<button v-for="item in ([['devices', '设备资源'], ['nodes', 'HWPOD Node'], ['onboarding', '节点接入']] as const)" :key="item[0]" type="button" :data-active="tab === item[0]" :aria-current="tab === item[0] ? 'page' : undefined" @click="goTab(item[0])">
@@ -230,7 +230,7 @@ function text(value: unknown): string {
</template>
</PageCommandBar>
<StatusStrip :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
<StatusStrip v-if="!workspaceMode" :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
<AsyncBoundary
:state="boundaryState"
@@ -240,22 +240,22 @@ function text(value: unknown): string {
empty-description="owning registry 当前没有返回符合查询的 HWPOD 资源。"
@retry="refreshCurrent"
>
<aside v-if="hwpod.consoleError && hwpod.consoleStale" class="stale-warning" role="status">
<aside v-if="!workspaceMode && hwpod.consoleError && hwpod.consoleStale" class="stale-warning" role="status">
<strong>显示最近成功快照</strong>
<span>{{ hwpod.consoleError }}</span>
</aside>
<aside v-if="firstInvalidSpec" class="spec-warning" role="status">
<aside v-if="!workspaceMode && firstInvalidSpec" class="spec-warning" role="status">
<strong>{{ invalidSpecs.length }} invalid spec 未进入可用资源</strong>
<span>{{ firstInvalidSpec.blocker.code }} · {{ firstInvalidSpec.blocker.summary }}</span>
</aside>
<BoundedWorkspace class="hwpod-workspace" label="HWPOD 资源工作区">
<BoundedWorkspace class="hwpod-workspace" :class="{ 'is-operations-workspace': workspaceMode }" :label="workspaceMode ? 'HWPOD 操作工作区' : 'HWPOD 资源工作区'">
<HwpodOperationsWorkspace v-if="workspaceMode" :hwpod-id="text(route.params.hwpodId)" :initial-path="text(route.query.path) || '.'" @back="closeWorkspace" @navigate="navigateWorkspace" />
<HwpodDevicesWorkspace v-else-if="tab === 'devices'" :devices="devices" :selected-id="selectedDevice?.hwpodId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectDeviceItem" @sort="toggleSort" />
<HwpodNodesWorkspace v-else-if="tab === 'nodes'" :nodes="nodes" :selected-id="selectedNodeDetail?.nodeId || null" :view="view" :density="density" :sort="viewState.sort.value" @select="selectNodeItem" @sort="toggleSort" />
<HwpodOnboardingWorkspace v-else :metadata="hwpod.nodeUpdate" :nodes="nodes" :selected-node-id="selectedNode?.nodeId || ''" :stages="onboardingStages" :copied="copied" @copy="copySha" @node="chooseNodeId" />
</BoundedWorkspace>
<nav v-if="collectionPage && tab !== 'onboarding'" class="collection-pagination" aria-label="HWPOD 资源分页">
<nav v-if="!workspaceMode && collectionPage && tab !== 'onboarding'" class="collection-pagination" aria-label="HWPOD 资源分页">
<span>本页 {{ collectionPage.returned }} / {{ collectionPage.total }}</span>
<div>
<button type="button" :disabled="!collectionPage.previousCursor" @click="setCursor(collectionPage.previousCursor)">上一页</button>
@@ -304,7 +304,9 @@ function text(value: unknown): string {
<style scoped>
.hwpod-console { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; gap: 12px; padding: 8px; color: var(--console-graphite-900); overflow: hidden; }
.hwpod-console[data-workspace-mode="true"] { grid-template-rows: minmax(0, 1fr); gap: 0; }
.hwpod-console :deep(.console-async-boundary) { display: flex; min-height: 0; flex-direction: column; overflow: hidden; }
.hwpod-console[data-workspace-mode="true"] :deep(.console-async-indicator) { display: none; }
.hwpod-console :deep(.page-command-filters) { flex-wrap: nowrap; align-items: center; gap: 6px; padding-bottom: 1px; overflow-x: auto; overscroll-behavior-inline: contain; }
.console-tabs { display: flex; gap: 2px; width: fit-content; max-width: 100%; padding: 3px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-border); overflow-x: auto; }
.console-tabs button { min-height: 32px; border: 0; border-radius: 5px; background: var(--console-surface-raised); color: var(--console-graphite-600); padding: 0 12px; font-size: 12px; font-weight: 750; white-space: nowrap; cursor: pointer; }
@@ -322,6 +324,7 @@ function text(value: unknown): string {
.stale-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 10px; padding: 10px 13px; border: 1px solid #e4a8a2; border-left: 4px solid var(--console-red-700); border-radius: var(--console-radius-sm); background: var(--console-red-100); color: var(--console-red-700); font-size: .78rem; }
.hwpod-workspace { min-height: 0; flex: 1 1 auto; }
.hwpod-workspace :deep(.bounded-workspace-main) { padding: 12px; }
.hwpod-workspace.is-operations-workspace :deep(.bounded-workspace-main) { overflow: hidden; padding: 0; }
.hwpod-workspace :deep(.resource-stage) { display: grid; height: 100%; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
.hwpod-workspace :deep(.grid-stack) { display: grid; min-width: 0; gap: 3px; }
.hwpod-workspace :deep(.grid-stack strong), .hwpod-workspace :deep(.grid-stack code), .hwpod-workspace :deep(.grid-stack small) { min-width: 0; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }