fix: 让 HWPOD 工作区占据主面板
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user