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.source, "hwpod-compiler.keil-mdk");
|
||||||
assert.equal(build.payload.plan.ops[0].args.commandBinding.action, "build");
|
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 });
|
const download = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.download"], { now: () => NOW });
|
||||||
assert.equal(download.exitCode, 0);
|
assert.equal(download.exitCode, 0);
|
||||||
assert.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run", "cmd.run"]);
|
assert.deepEqual(download.payload.plan.ops.map((op: any) => op.op), ["cmd.run", "cmd.run"]);
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BaseTree } from "@he-tree/vue";
|
import { BaseTree } from "@he-tree/vue";
|
||||||
import "@he-tree/vue/style/default.css";
|
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 { computed, nextTick, onMounted, ref } from "vue";
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
|
||||||
|
import ContentViewer from "@/components/common/ContentViewer.vue";
|
||||||
import { useHwpodStore } from "@/stores/hwpod";
|
import { useHwpodStore } from "@/stores/hwpod";
|
||||||
import type { HwpodOperationResponse, HwpodWorkspaceEntry } from "@/types";
|
import type { HwpodOperationResponse, HwpodWorkspaceEntry } from "@/types";
|
||||||
|
import { presentBuildOperation, workspacePreviewMode } from "./hwpod-workspace-presentation";
|
||||||
|
|
||||||
type WorkspaceNode = HwpodWorkspaceEntry & { children?: WorkspaceNode[]; loaded?: boolean };
|
type WorkspaceNode = HwpodWorkspaceEntry & { children?: WorkspaceNode[]; loaded?: boolean };
|
||||||
|
|
||||||
@@ -24,7 +26,17 @@ const uartPort = ref("uart1");
|
|||||||
const uartBaudRate = ref(115200);
|
const uartBaudRate = ref(115200);
|
||||||
const uartData = ref("");
|
const uartData = ref("");
|
||||||
const uartOutput = ref("");
|
const uartOutput = ref("");
|
||||||
|
const buildOperation = ref<HwpodOperationResponse | null>(null);
|
||||||
|
const previousBuildOperationId = ref<string | null>(null);
|
||||||
const terminalStatus = computed(() => hwpod.lastOperation?.status ?? "idle");
|
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 () => {
|
onMounted(async () => {
|
||||||
await loadRoot();
|
await loadRoot();
|
||||||
@@ -84,7 +96,14 @@ async function openInitialPath(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runAction(intent: string, args: Record<string, unknown> = {}): 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> {
|
async function confirmDownload(): Promise<void> {
|
||||||
@@ -113,6 +132,7 @@ async function operationOutput(intent: string, args: Record<string, unknown>): P
|
|||||||
localError.value = null;
|
localError.value = null;
|
||||||
try {
|
try {
|
||||||
const operation = await hwpod.executeOperation(props.hwpodId, intent, args, 900_000);
|
const operation = await hwpod.executeOperation(props.hwpodId, intent, args, 900_000);
|
||||||
|
if (intent === "debug.build") buildOperation.value = operation;
|
||||||
const activity = record(operation.result);
|
const activity = record(operation.result);
|
||||||
if (activity.ok === false) throw new Error(errorSummary(activity) || `${intent} 执行失败`);
|
if (activity.ok === false) throw new Error(errorSummary(activity) || `${intent} 执行失败`);
|
||||||
const response = record(activity.response);
|
const response = record(activity.response);
|
||||||
@@ -175,9 +195,16 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="preview-pane" aria-label="文件预览">
|
<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>
|
<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>
|
<div v-else class="empty-preview">选择文件查看内容</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -185,9 +212,25 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
|
|||||||
<section>
|
<section>
|
||||||
<h2><Hammer :size="16" /> 调试</h2>
|
<h2><Hammer :size="16" /> 调试</h2>
|
||||||
<div class="action-row">
|
<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>
|
<button type="button" :disabled="Boolean(busy)" @click="downloadConfirmOpen = true"><Download :size="15" /> 下载</button>
|
||||||
</div>
|
</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>
|
||||||
<section>
|
<section>
|
||||||
<h2><Terminal :size="16" /> 串口</h2>
|
<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>
|
<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>
|
<pre class="uart-output">{{ uartOutput || "等待串口数据" }}</pre>
|
||||||
</section>
|
</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>
|
<p v-if="localError" class="operation-error" role="alert">{{ localError }}</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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" />
|
<ConfirmDialog :open="downloadConfirmOpen" title="下载固件" message="下载会写入当前连接的目标板。确认继续?" confirm-label="确认下载" :pending="busy === 'debug.download'" @close="downloadConfirmOpen = false" @confirm="confirmDownload" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 { 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 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; }
|
.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; }
|
.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); }
|
.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); }
|
.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; }
|
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; }
|
.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 { 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 span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.tree-row.selected { background: var(--console-cyan-100); color: var(--console-cyan-700); }
|
.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; }
|
.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; }
|
.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); }
|
.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; }
|
.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, .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; }
|
.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; }
|
.send-row input { flex: 1; }
|
||||||
button:disabled { opacity: .45; cursor: wait; }
|
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; }
|
.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-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; }
|
.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: 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>
|
</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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="hwpod-console" data-testid="hwpod-console">
|
<section class="hwpod-console" data-testid="hwpod-console" :data-workspace-mode="workspaceMode ? 'true' : 'false'">
|
||||||
<PageCommandBar eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。">
|
<PageCommandBar v-if="!workspaceMode" eyebrow="Hardware pool" title="HWPOD 控制台" description="设备资源、主动出站 Node 与 Python 单文件接入的同一事实视图。">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<nav class="console-tabs" aria-label="HWPOD 子页面">
|
<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])">
|
<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>
|
</template>
|
||||||
</PageCommandBar>
|
</PageCommandBar>
|
||||||
|
|
||||||
<StatusStrip :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
|
<StatusStrip v-if="!workspaceMode" :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
|
||||||
|
|
||||||
<AsyncBoundary
|
<AsyncBoundary
|
||||||
:state="boundaryState"
|
:state="boundaryState"
|
||||||
@@ -240,22 +240,22 @@ function text(value: unknown): string {
|
|||||||
empty-description="owning registry 当前没有返回符合查询的 HWPOD 资源。"
|
empty-description="owning registry 当前没有返回符合查询的 HWPOD 资源。"
|
||||||
@retry="refreshCurrent"
|
@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>
|
<strong>显示最近成功快照</strong>
|
||||||
<span>{{ hwpod.consoleError }}</span>
|
<span>{{ hwpod.consoleError }}</span>
|
||||||
</aside>
|
</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>
|
<strong>{{ invalidSpecs.length }} 份 invalid spec 未进入可用资源</strong>
|
||||||
<span>{{ firstInvalidSpec.blocker.code }} · {{ firstInvalidSpec.blocker.summary }}</span>
|
<span>{{ firstInvalidSpec.blocker.code }} · {{ firstInvalidSpec.blocker.summary }}</span>
|
||||||
</aside>
|
</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" />
|
<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" />
|
<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" />
|
<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" />
|
<HwpodOnboardingWorkspace v-else :metadata="hwpod.nodeUpdate" :nodes="nodes" :selected-node-id="selectedNode?.nodeId || ''" :stages="onboardingStages" :copied="copied" @copy="copySha" @node="chooseNodeId" />
|
||||||
</BoundedWorkspace>
|
</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>
|
<span>本页 {{ collectionPage.returned }} / 共 {{ collectionPage.total }}</span>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" :disabled="!collectionPage.previousCursor" @click="setCursor(collectionPage.previousCursor)">上一页</button>
|
<button type="button" :disabled="!collectionPage.previousCursor" @click="setCursor(collectionPage.previousCursor)">上一页</button>
|
||||||
@@ -304,7 +304,9 @@ function text(value: unknown): string {
|
|||||||
|
|
||||||
<style scoped>
|
<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 { 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 :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; }
|
.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 { 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; }
|
.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; }
|
.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 { min-height: 0; flex: 1 1 auto; }
|
||||||
.hwpod-workspace :deep(.bounded-workspace-main) { padding: 12px; }
|
.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(.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) { 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; }
|
.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