fix: 优化 HWPOD 工作区视觉反馈
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
<script setup lang="ts">
|
||||
import { BaseTree } from "@he-tree/vue";
|
||||
import "@he-tree/vue/style/default.css";
|
||||
import { ChevronLeft, CircleCheck, CircleX, Download, File, Folder, Hammer, LoaderCircle, Plug, RefreshCw, Send, Square, Terminal } from "lucide-vue-next";
|
||||
import { ChevronLeft, CircleCheck, CircleX, Code2, Download, File, Folder, Hammer, LayoutPanelTop, 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 LoadingState from "@/components/common/LoadingState.vue";
|
||||
import { useHwpodStore } from "@/stores/hwpod";
|
||||
import type { HwpodOperationResponse, HwpodWorkspaceEntry } from "@/types";
|
||||
import { presentBuildOperation, workspacePreviewMode } from "./hwpod-workspace-presentation";
|
||||
@@ -20,6 +21,10 @@ const selectedPath = ref(props.initialPath || ".");
|
||||
const preview = ref("");
|
||||
const previewMeta = ref<{ sizeBytes: number; truncated: boolean } | null>(null);
|
||||
const busy = ref<string | null>(null);
|
||||
const rootLoading = ref(true);
|
||||
const previewLoading = ref(false);
|
||||
const loadingDirectoryPath = ref<string | null>(null);
|
||||
const buildResultMode = ref<"summary" | "raw">("summary");
|
||||
const localError = ref<string | null>(null);
|
||||
const downloadConfirmOpen = ref(false);
|
||||
const uartPort = ref("uart1");
|
||||
@@ -28,6 +33,7 @@ const uartData = ref("");
|
||||
const uartOutput = ref("");
|
||||
const buildOperation = ref<HwpodOperationResponse | null>(null);
|
||||
const previousBuildOperationId = ref<string | null>(null);
|
||||
let previewRequestId = 0;
|
||||
const terminalStatus = computed(() => hwpod.lastOperation?.status ?? "idle");
|
||||
const previewMode = computed(() => workspacePreviewMode(selectedPath.value));
|
||||
const activeBuildOperation = computed(() => {
|
||||
@@ -39,13 +45,20 @@ const buildView = computed(() => presentBuildOperation(activeBuildOperation.valu
|
||||
const device = computed(() => hwpod.deviceTopology?.items.find((item) => item.hwpodId === props.hwpodId) ?? null);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoot();
|
||||
if (props.initialPath && props.initialPath !== ".") await openInitialPath(props.initialPath);
|
||||
try {
|
||||
await loadRoot();
|
||||
if (props.initialPath && props.initialPath !== ".") await openInitialPath(props.initialPath);
|
||||
} catch { /* Local loading surfaces own the visible error. */ }
|
||||
});
|
||||
|
||||
async function loadRoot(): Promise<void> {
|
||||
const output = await operationOutput("workspace.ls", { path: ".", maxEntries: 1000 });
|
||||
tree.value = workspaceEntries(output);
|
||||
rootLoading.value = true;
|
||||
try {
|
||||
const output = await operationOutput("workspace.ls", { path: ".", maxEntries: 1000 }, false);
|
||||
tree.value = workspaceEntries(output);
|
||||
} finally {
|
||||
rootLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectNode(stat: { data: WorkspaceNode; open?: boolean }): Promise<void> {
|
||||
@@ -54,11 +67,16 @@ async function selectNode(stat: { data: WorkspaceNode; open?: boolean }): Promis
|
||||
emit("navigate", node.path);
|
||||
if (node.type === "dir") {
|
||||
if (!node.loaded) {
|
||||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 });
|
||||
node.children = workspaceEntries(output);
|
||||
node.loaded = true;
|
||||
tree.value = [...tree.value];
|
||||
await nextTick();
|
||||
loadingDirectoryPath.value = node.path;
|
||||
try {
|
||||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 }, false);
|
||||
node.children = workspaceEntries(output);
|
||||
node.loaded = true;
|
||||
tree.value = [...tree.value];
|
||||
await nextTick();
|
||||
} finally {
|
||||
loadingDirectoryPath.value = null;
|
||||
}
|
||||
}
|
||||
stat.open = true;
|
||||
preview.value = "";
|
||||
@@ -66,10 +84,17 @@ async function selectNode(stat: { data: WorkspaceNode; open?: boolean }): Promis
|
||||
return;
|
||||
}
|
||||
if (node.type !== "file") return;
|
||||
const output = await operationOutput("workspace.cat", { path: node.path, maxBytes: 262144 });
|
||||
if (output.previewable === false) throw new Error(blockerSummary(output) || "该文件不支持文本预览");
|
||||
preview.value = typeof output.content === "string" ? output.content : "";
|
||||
previewMeta.value = { sizeBytes: numberValue(output.sizeBytes), truncated: output.truncated === true };
|
||||
const requestId = ++previewRequestId;
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const output = await operationOutput("workspace.cat", { path: node.path, maxBytes: 262144 }, false);
|
||||
if (requestId !== previewRequestId) return;
|
||||
if (output.previewable === false) throw new Error(blockerSummary(output) || "该文件不支持文本预览");
|
||||
preview.value = typeof output.content === "string" ? output.content : "";
|
||||
previewMeta.value = { sizeBytes: numberValue(output.sizeBytes), truncated: output.truncated === true };
|
||||
} finally {
|
||||
if (requestId === previewRequestId) previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openInitialPath(path: string): Promise<void> {
|
||||
@@ -86,7 +111,7 @@ async function openInitialPath(path: string): Promise<void> {
|
||||
}
|
||||
if (node.type !== "dir") return;
|
||||
if (!node.loaded) {
|
||||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 });
|
||||
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 }, false);
|
||||
node.children = workspaceEntries(output);
|
||||
node.loaded = true;
|
||||
}
|
||||
@@ -127,8 +152,8 @@ function uartArgs(): Record<string, unknown> {
|
||||
return { port: uartPort.value, baudRate: uartBaudRate.value };
|
||||
}
|
||||
|
||||
async function operationOutput(intent: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
busy.value = intent;
|
||||
async function operationOutput(intent: string, args: Record<string, unknown>, trackBusy = true): Promise<Record<string, unknown>> {
|
||||
if (trackBusy) busy.value = intent;
|
||||
localError.value = null;
|
||||
try {
|
||||
const operation = await hwpod.executeOperation(props.hwpodId, intent, args, 900_000);
|
||||
@@ -144,7 +169,7 @@ async function operationOutput(intent: string, args: Record<string, unknown>): P
|
||||
localError.value = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
busy.value = null;
|
||||
if (trackBusy && busy.value === intent) busy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,17 +201,19 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
|
||||
<button class="icon-command" type="button" title="返回设备资源" @click="emit('back')"><ChevronLeft :size="17" /></button>
|
||||
<strong>{{ hwpodId }}</strong>
|
||||
<code>{{ selectedPath }}</code>
|
||||
<button class="icon-command" type="button" title="刷新工作区" :disabled="Boolean(busy)" @click="loadRoot"><RefreshCw :size="16" /></button>
|
||||
<button class="icon-command" type="button" title="刷新工作区" :disabled="rootLoading" @click="loadRoot"><RefreshCw :class="{ spin: rootLoading }" :size="16" /></button>
|
||||
<span class="operation-state" :data-status="terminalStatus">{{ operationLabel(hwpod.lastOperation) }}</span>
|
||||
</header>
|
||||
|
||||
<div class="workspace-columns">
|
||||
<section class="file-pane" aria-label="工作区文件">
|
||||
<h2>工作区</h2>
|
||||
<BaseTree v-model="tree" class="file-tree" :virtualization="true" :virtualization-prerender-count="30" :default-open="false" aria-label="HWPOD 工作区文件树" @click:node="selectNode">
|
||||
<div class="pane-heading"><h2>工作区</h2><LoadingState v-if="rootLoading && tree.length" label="刷新中" compact /></div>
|
||||
<LoadingState v-if="rootLoading && !tree.length" class="pane-loading" label="正在加载工作区" />
|
||||
<BaseTree v-else v-model="tree" class="file-tree" :virtualization="true" :virtualization-prerender-count="30" :default-open="false" aria-label="HWPOD 工作区文件树" @click:node="selectNode">
|
||||
<template #default="{ node, stat, indentStyle }">
|
||||
<div class="tree-row" :class="{ selected: selectedPath === node.path }" :style="indentStyle">
|
||||
<Folder v-if="node.type === 'dir'" :size="15" :fill="stat.open ? 'currentColor' : 'none'" />
|
||||
<LoaderCircle v-if="loadingDirectoryPath === node.path" class="spin" :size="15" />
|
||||
<Folder v-else-if="node.type === 'dir'" :size="15" :fill="stat.open ? 'currentColor' : 'none'" />
|
||||
<File v-else :size="15" />
|
||||
<span>{{ node.name }}</span>
|
||||
</div>
|
||||
@@ -194,18 +221,21 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
|
||||
</BaseTree>
|
||||
</section>
|
||||
|
||||
<section class="preview-pane" aria-label="文件预览">
|
||||
<section class="preview-pane" aria-label="文件预览" :aria-busy="previewLoading ? 'true' : 'false'">
|
||||
<div v-if="previewMeta" class="preview-meta"><span>{{ previewMeta.sizeBytes }} bytes</span><span v-if="previewMeta.truncated">预览已截断</span></div>
|
||||
<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 class="preview-stage">
|
||||
<ContentViewer
|
||||
v-if="preview"
|
||||
class="workspace-content-viewer"
|
||||
:content="preview"
|
||||
:mode="previewMode"
|
||||
title="文件预览"
|
||||
:filename="selectedPath"
|
||||
:line-numbers="previewMode !== 'markdown'"
|
||||
/>
|
||||
<div v-else-if="!previewLoading" class="empty-preview">选择文件查看内容</div>
|
||||
<div v-if="previewLoading" class="preview-loading"><LoadingState label="正在加载文件内容" compact /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="operation-pane" aria-label="HWPOD 操作">
|
||||
@@ -216,20 +246,38 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
|
||||
<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 class="build-result-toolbar">
|
||||
<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>
|
||||
</div>
|
||||
<div class="result-mode-switch" role="group" aria-label="编译结果显示方式">
|
||||
<button type="button" title="结构化结果" :data-active="buildResultMode === 'summary'" @click="buildResultMode = 'summary'"><LayoutPanelTop :size="13" />结果</button>
|
||||
<button type="button" title="原始输出" :data-active="buildResultMode === 'raw'" @click="buildResultMode = 'raw'"><Code2 :size="13" />原始</button>
|
||||
</div>
|
||||
</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 v-if="buildResultMode === 'summary'" class="build-summary">
|
||||
<p v-if="buildView.message">{{ buildView.message }}</p>
|
||||
<dl v-if="buildView.exitCode !== null || buildView.warningCount !== null || buildView.errorCount !== null || buildView.elapsedMs !== null" class="build-facts">
|
||||
<div v-if="buildView.exitCode !== null"><dt>退出码</dt><dd>{{ buildView.exitCode }}</dd></div>
|
||||
<div v-if="buildView.errorCount !== null"><dt>错误</dt><dd>{{ buildView.errorCount }}</dd></div>
|
||||
<div v-if="buildView.warningCount !== null"><dt>告警</dt><dd>{{ buildView.warningCount }}</dd></div>
|
||||
<div v-if="buildView.elapsedMs !== null"><dt>耗时</dt><dd>{{ (buildView.elapsedMs / 1000).toFixed(2) }}s</dd></div>
|
||||
</dl>
|
||||
<ul v-if="buildView.artifacts.length" class="build-artifacts">
|
||||
<li v-for="artifact in buildView.artifacts" :key="`${artifact.kind}:${artifact.path}`"><strong>{{ artifact.kind }}</strong><code :title="artifact.path">{{ artifact.path }}</code></li>
|
||||
</ul>
|
||||
<p v-if="!buildView.message && buildView.exitCode === null && !buildView.artifacts.length" class="build-empty">编译后在此显示结构化结果</p>
|
||||
</div>
|
||||
<pre v-else class="build-log">{{ buildView.log || "暂无原始输出" }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
@@ -270,17 +318,23 @@ function operationLabel(operation: HwpodOperationResponse | null): string { retu
|
||||
.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; }
|
||||
.pane-heading { display: flex; min-height: 28px; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.pane-heading :deep(.loading-state) { color: var(--console-cyan-700); font-size: 10px; }
|
||||
.pane-loading { height: calc(100% - 28px); }
|
||||
.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 { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
|
||||
.preview-stage { position: relative; min-width: 0; min-height: 0; 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; }
|
||||
.preview-loading { position: absolute; z-index: 3; inset: 0; display: grid; place-items: center; background: rgb(246 248 246 / 84%); backdrop-filter: blur(1px); }
|
||||
.preview-loading :deep(.loading-state) { padding: 8px 12px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-cyan-700); box-shadow: var(--console-shadow-sm); }
|
||||
.operation-pane { display: grid; align-content: start; gap: 18px; background: var(--console-surface-muted); }
|
||||
.operation-pane section { display: grid; gap: 8px; }
|
||||
.operation-pane label { display: grid; gap: 4px; color: var(--console-graphite-600); font-size: 10px; font-weight: 700; }
|
||||
@@ -292,14 +346,31 @@ h2 { display: flex; align-items: center; gap: 6px; margin: 0 0 9px; color: var(-
|
||||
.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-toolbar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.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; }
|
||||
.result-mode-switch { display: inline-flex; flex: 0 0 auto; padding: 2px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-border); }
|
||||
.result-mode-switch button { display: inline-flex; min-height: 24px; align-items: center; gap: 4px; border: 0; border-radius: 3px; background: transparent; padding: 0 7px; color: var(--console-graphite-600); font-size: 9px; font-weight: 750; cursor: pointer; }
|
||||
.result-mode-switch button[data-active="true"] { background: var(--console-surface-raised); color: var(--console-graphite-900); box-shadow: 0 1px 2px rgb(28 38 34 / 12%); }
|
||||
.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-summary { display: grid; min-width: 0; gap: 7px; }
|
||||
.build-summary > p { margin: 0; color: var(--console-graphite-700); font-size: 10px; line-height: 1.45; }
|
||||
.build-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border: 1px solid var(--console-border); }
|
||||
.build-facts div { min-width: 0; padding: 5px 6px; border-right: 1px solid var(--console-border); background: var(--console-surface-muted); }
|
||||
.build-facts div:last-child { border-right: 0; }
|
||||
.build-facts dt, .build-facts dd { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.build-facts dt { color: var(--console-graphite-500); font-size: 8px; font-weight: 750; }
|
||||
.build-facts dd { margin-top: 2px; color: var(--console-graphite-900); font-family: var(--console-font-mono); font-size: 10px; font-weight: 750; }
|
||||
.build-artifacts { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.build-artifacts li { display: grid; min-width: 0; grid-template-columns: 54px minmax(0, 1fr); align-items: center; gap: 6px; padding: 4px 6px; border: 1px solid var(--console-border); background: #fff; }
|
||||
.build-artifacts strong { color: var(--console-cyan-700); font-size: 8px; }
|
||||
.build-artifacts code { overflow: hidden; color: var(--console-graphite-700); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.build-empty { color: var(--console-graphite-500) !important; }
|
||||
.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; }
|
||||
@@ -319,6 +390,6 @@ button:disabled { opacity: .45; cursor: wait; }
|
||||
@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; } .workspace-statusbar { grid-template-columns: auto auto minmax(0, 1fr); } .status-operation { display: none !important; } }
|
||||
@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; } .build-facts { grid-template-columns: repeat(2, minmax(0, 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>
|
||||
|
||||
@@ -26,6 +26,10 @@ describe("HWPOD workspace presentation", () => {
|
||||
});
|
||||
|
||||
it("projects the real Keil terminal exit code and bounded logs", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ event: "started", command: "build", mode: "wait" }),
|
||||
JSON.stringify({ status: "completed", return_code: 0, errors: 0, warning_count: 2, elapsed_ms: 7165, message: "Build completed successfully", result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf", build_log: "F:\\build.log" } }, null, 2),
|
||||
].join("\n");
|
||||
const view = presentBuildOperation({
|
||||
ok: true,
|
||||
status: "completed",
|
||||
@@ -34,12 +38,17 @@ describe("HWPOD workspace presentation", () => {
|
||||
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" } }],
|
||||
results: [{ ok: true, output: { ok: true, exitCode: 0, stdout, stderr: "build started" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(view).toMatchObject({ tone: "ok", terminal: true, exitCode: 0, label: "编译通过" });
|
||||
expect(view).toMatchObject({ tone: "ok", terminal: true, exitCode: 0, label: "编译通过", message: "Build completed successfully", warningCount: 2, errorCount: 0, elapsedMs: 7165 });
|
||||
expect(view.artifacts).toEqual([
|
||||
{ kind: "HEX", path: "F:\\out.hex" },
|
||||
{ kind: "AXF", path: "F:\\out.axf" },
|
||||
{ kind: "构建日志", path: "F:\\build.log" },
|
||||
]);
|
||||
expect(view.log).toContain("build started");
|
||||
expect(view.log).toContain('"errors":0');
|
||||
expect(view.log).toContain('"errors": 0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface BuildOperationPresentation {
|
||||
exitCode: number | null;
|
||||
jobId: string | null;
|
||||
log: string;
|
||||
message: string;
|
||||
warningCount: number | null;
|
||||
errorCount: number | null;
|
||||
elapsedMs: number | null;
|
||||
artifacts: Array<{ kind: string; path: string }>;
|
||||
terminal: boolean;
|
||||
}
|
||||
|
||||
@@ -27,6 +32,11 @@ export function presentBuildOperation(operation: HwpodOperationResponse | null,
|
||||
exitCode: null,
|
||||
jobId: null,
|
||||
log: "",
|
||||
message: "",
|
||||
warningCount: null,
|
||||
errorCount: null,
|
||||
elapsedMs: null,
|
||||
artifacts: [],
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
@@ -39,11 +49,21 @@ export function presentBuildOperation(operation: HwpodOperationResponse | null,
|
||||
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 parsed = parseLastJsonRecord(stdout);
|
||||
const parsedResult = record(parsed.result);
|
||||
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 message = stringValue(parsed.message) || stringValue(parsedResult.message);
|
||||
const warningCount = numberValue(parsed.warning_count, parsed.warningCount, parsedResult.warning_count, parsedResult.warningCount);
|
||||
const errorCount = numberValue(parsed.error_count, parsed.errorCount, parsed.errors, parsedResult.error_count, parsedResult.errorCount, parsedResult.errors);
|
||||
const elapsedMs = numberValue(parsed.elapsed_ms, parsed.elapsedMs, parsedResult.elapsed_ms, parsedResult.elapsedMs);
|
||||
const artifacts = [
|
||||
["HEX", parsedResult.hex_file ?? parsedResult.hexFile ?? parsed.hex_file ?? parsed.hexFile],
|
||||
["AXF", parsedResult.axf_file ?? parsedResult.axfFile ?? parsed.axf_file ?? parsed.axfFile],
|
||||
["构建日志", parsedResult.log_file ?? parsedResult.logFile ?? parsedResult.build_log ?? parsedResult.buildLog ?? parsed.log_file ?? parsed.logFile ?? parsed.build_log ?? parsed.buildLog],
|
||||
].flatMap(([kind, path]) => typeof path === "string" && path.trim() ? [{ kind: String(kind), path: path.trim() }] : []);
|
||||
const asyncQueued = parsed.accepted === true && ["queued", "running", "accepted"].includes(toolStatus);
|
||||
const failed = operation.error != null
|
||||
|| activity.ok === false
|
||||
@@ -57,11 +77,12 @@ export function presentBuildOperation(operation: HwpodOperationResponse | null,
|
||||
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 };
|
||||
const detail = { operationId: operation.operationId, exitCode, jobId, log, message, warningCount, errorCount, elapsedMs, artifacts };
|
||||
if (failed) return { status: toolStatus || operationStatus, label: "编译失败", tone: "error", ...detail, terminal: true };
|
||||
if (asyncQueued) return { status: toolStatus, label: "构建已排队,等待真实终态", tone: "warn", ...detail, terminal: false };
|
||||
if (running) return { status: operationStatus, label: "正在等待 Windows Keil 构建终态", tone: "running", ...detail, terminal: false };
|
||||
if (terminal) return { status: operationStatus, label: "编译通过", tone: "ok", ...detail, terminal: true };
|
||||
return { status: operationStatus, label: "编译状态未知", tone: "warn", ...detail, terminal: false };
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
@@ -80,8 +101,15 @@ function numberValue(...values: unknown[]): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonRecord(value: string): Record<string, unknown> {
|
||||
function parseLastJsonRecord(value: string): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try { return record(JSON.parse(value)); }
|
||||
catch { return {}; }
|
||||
catch {
|
||||
let start = value.lastIndexOf("{");
|
||||
while (start >= 0) {
|
||||
try { return record(JSON.parse(value.slice(start).trim())); }
|
||||
catch { start = value.lastIndexOf("{", start - 1); }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,9 +230,12 @@ function text(value: unknown): string {
|
||||
</template>
|
||||
</PageCommandBar>
|
||||
|
||||
<StatusStrip v-if="!workspaceMode" :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
|
||||
<BoundedWorkspace v-if="workspaceMode" class="hwpod-workspace is-operations-workspace" label="HWPOD 操作工作区">
|
||||
<HwpodOperationsWorkspace :hwpod-id="text(route.params.hwpodId)" :initial-path="text(route.query.path) || '.'" @back="closeWorkspace" @navigate="navigateWorkspace" />
|
||||
</BoundedWorkspace>
|
||||
|
||||
<AsyncBoundary
|
||||
v-else
|
||||
:state="boundaryState"
|
||||
:has-content="hasContent"
|
||||
:error="hwpod.consoleError || 'HWPOD typed topology 当前不可用。'"
|
||||
@@ -240,22 +243,21 @@ function text(value: unknown): string {
|
||||
empty-description="owning registry 当前没有返回符合查询的 HWPOD 资源。"
|
||||
@retry="refreshCurrent"
|
||||
>
|
||||
<aside v-if="!workspaceMode && hwpod.consoleError && hwpod.consoleStale" class="stale-warning" role="status">
|
||||
<aside v-if="hwpod.consoleError && hwpod.consoleStale" class="stale-warning" role="status">
|
||||
<strong>显示最近成功快照</strong>
|
||||
<span>{{ hwpod.consoleError }}</span>
|
||||
</aside>
|
||||
<aside v-if="!workspaceMode && firstInvalidSpec" class="spec-warning" role="status">
|
||||
<aside v-if="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" :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" />
|
||||
<BoundedWorkspace class="hwpod-workspace" label="HWPOD 资源工作区">
|
||||
<HwpodDevicesWorkspace v-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="!workspaceMode && collectionPage && tab !== 'onboarding'" class="collection-pagination" aria-label="HWPOD 资源分页">
|
||||
<nav v-if="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>
|
||||
@@ -264,6 +266,8 @@ function text(value: unknown): string {
|
||||
</nav>
|
||||
</AsyncBoundary>
|
||||
|
||||
<StatusStrip v-if="!workspaceMode" :items="statusItems" label="HWPOD typed topology 状态" live="polite" />
|
||||
|
||||
<BaseDrawer :open="Boolean(selectedDevice) && !workspaceMode" :title="selectedDevice?.name || 'HWPOD 详情'" description="HWPOD API 返回的 typed DTO。" @close="closeInspector">
|
||||
<template v-if="selectedDevice">
|
||||
<dl class="resource-details">
|
||||
@@ -303,23 +307,32 @@ function text(value: unknown): string {
|
||||
</template>
|
||||
|
||||
<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: 44px minmax(0, 1fr) 28px; align-content: stretch; gap: 6px; 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; }
|
||||
.hwpod-console :deep(.page-command-bar) { min-height: 44px; grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 4px 6px 4px 12px; }
|
||||
.hwpod-console :deep(.page-command-bar::before) { width: 3px; }
|
||||
.hwpod-console :deep(.page-command-copy) { display: block; }
|
||||
.hwpod-console :deep(.page-command-eyebrow), .hwpod-console :deep(.page-command-description) { display: none; }
|
||||
.hwpod-console :deep(.page-command-title) { font-size: 16px; line-height: 1; white-space: nowrap; }
|
||||
.hwpod-console :deep(.page-command-actions) { grid-column: 3; grid-row: 1; }
|
||||
.hwpod-console :deep(.page-command-filters) { grid-column: 2; grid-row: 1; flex-wrap: nowrap; align-items: center; justify-content: flex-end; gap: 5px; padding-bottom: 0; overflow-x: auto; overscroll-behavior-inline: contain; }
|
||||
.hwpod-console :deep(.console-status-strip) { height: 28px; min-height: 28px; border-radius: 0; box-shadow: none; }
|
||||
.hwpod-console :deep(.console-status-item) { min-width: 120px; min-height: 26px; grid-template-columns: auto auto; grid-template-rows: 1fr; justify-content: start; gap: 6px; padding: 0 9px 0 14px; }
|
||||
.hwpod-console :deep(.console-status-item::before) { top: auto; bottom: auto; left: 6px; width: 5px; height: 5px; border-radius: 50%; }
|
||||
.hwpod-console :deep(.console-status-item dt), .hwpod-console :deep(.console-status-item dd) { font-size: 9px; }
|
||||
.console-tabs { display: flex; gap: 2px; width: fit-content; max-width: 100%; padding: 2px; 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: 28px; border: 0; border-radius: 4px; background: var(--console-surface-raised); color: var(--console-graphite-600); padding: 0 10px; font-size: 11px; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||
.console-tabs button[data-active="true"] { color: #fff; background: var(--console-graphite-900); }
|
||||
.console-search { display: flex; width: min(340px, 100%); min-width: min(340px, 100%); align-items: center; gap: 6px; }
|
||||
.console-search label, .status-filter { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; font-weight: 760; letter-spacing: 0; text-transform: uppercase; }
|
||||
.console-search input { height: 32px; min-width: 0; flex: 1; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 9px; font-size: 11px; }
|
||||
.console-search button { height: 32px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); padding: 0 9px; font-size: 11px; font-weight: 750; cursor: pointer; }
|
||||
.console-search input { height: 28px; min-width: 0; flex: 1; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 8px; font-size: 10px; }
|
||||
.console-search button { height: 28px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); color: var(--console-cyan-700); padding: 0 8px; font-size: 10px; font-weight: 750; cursor: pointer; }
|
||||
.status-filter { display: flex; align-items: center; gap: 7px; }
|
||||
.status-filter select { height: 32px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 8px; color: var(--console-graphite-800); font-size: 11px; }
|
||||
.probe-button { height: 32px; border: 1px solid var(--console-amber-500); border-radius: var(--console-radius-sm); background: var(--console-amber-100); color: var(--console-amber-700); padding: 0 9px; font-size: 11px; font-weight: 750; cursor: pointer; white-space: nowrap; }
|
||||
.hwpod-console :deep(.view-mode-switch) { height: 32px; flex: 0 0 auto; }
|
||||
.hwpod-console :deep(.view-mode-switch button) { height: 28px; min-height: 28px; padding-block: 0; font-size: 11px; }
|
||||
.status-filter select { height: 28px; border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 0 7px; color: var(--console-graphite-800); font-size: 10px; }
|
||||
.probe-button { height: 28px; border: 1px solid var(--console-amber-500); border-radius: var(--console-radius-sm); background: var(--console-amber-100); color: var(--console-amber-700); padding: 0 8px; font-size: 10px; font-weight: 750; cursor: pointer; white-space: nowrap; }
|
||||
.hwpod-console :deep(.view-mode-switch) { height: 28px; flex: 0 0 auto; }
|
||||
.hwpod-console :deep(.view-mode-switch button) { height: 24px; min-height: 24px; padding-block: 0; font-size: 10px; }
|
||||
.spec-warning { display: flex; gap: 8px 20px; align-items: center; margin-bottom: 10px; padding: 10px 13px; border: 1px solid #dfc47d; border-left: 4px solid #aa7a18; border-radius: 7px; background: #fff8df; color: #6b5317; 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; }
|
||||
@@ -346,6 +359,7 @@ dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||
.collection-pagination div { display: flex; gap: 6px; }
|
||||
.collection-pagination button { border: 1px solid var(--console-border-strong); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); color: var(--console-graphite-800); padding: 6px 10px; font-weight: 700; cursor: pointer; }
|
||||
.collection-pagination button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
@media (max-width: 1280px) { .hwpod-console { grid-template-rows: auto minmax(0, 1fr) 28px; } .hwpod-console :deep(.page-command-bar) { grid-template-columns: minmax(0, 1fr) auto; } .hwpod-console :deep(.page-command-actions) { grid-column: 2; } .hwpod-console :deep(.page-command-filters) { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; } }
|
||||
@media (max-width: 680px) { .hwpod-console { padding: 2px; } .console-tabs { width: 100%; } .console-tabs button { flex: 1 0 auto; } .console-search { width: 100%; min-width: 100%; } .status-filter { flex: 0 0 auto; } .spec-warning, .stale-warning { align-items: flex-start; flex-direction: column; } .hwpod-workspace :deep(.bounded-workspace-main) { padding: 8px; } }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user