Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/components/hwpod/HwpodOperationsWorkspace.vue
T
2026-07-21 14:12:32 +02:00

325 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- SPEC: PJ2026-010102 HWPOD 工具PJ2026-010405 云端控制台 -->
<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 { 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 };
const props = defineProps<{ hwpodId: string; initialPath?: string }>();
const emit = defineEmits<{ back: []; navigate: [path: string] }>();
const hwpod = useHwpodStore();
const tree = ref<WorkspaceNode[]>([]);
const selectedPath = ref(props.initialPath || ".");
const preview = ref("");
const previewMeta = ref<{ sizeBytes: number; truncated: boolean } | null>(null);
const busy = ref<string | null>(null);
const localError = ref<string | null>(null);
const downloadConfirmOpen = ref(false);
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();
if (props.initialPath && props.initialPath !== ".") await openInitialPath(props.initialPath);
});
async function loadRoot(): Promise<void> {
const output = await operationOutput("workspace.ls", { path: ".", maxEntries: 1000 });
tree.value = workspaceEntries(output);
}
async function selectNode(stat: { data: WorkspaceNode; open?: boolean }): Promise<void> {
const node = stat.data;
selectedPath.value = node.path;
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();
}
stat.open = true;
preview.value = "";
previewMeta.value = null;
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 };
}
async function openInitialPath(path: string): Promise<void> {
const segments = path.replace(/\\/gu, "/").split("/").filter(Boolean);
let nodes = tree.value;
let current = "";
for (let index = 0; index < segments.length; index += 1) {
current = current ? `${current}/${segments[index]}` : segments[index];
const node = nodes.find((entry) => entry.path === current);
if (!node) return;
if (index === segments.length - 1) {
await selectNode({ data: node });
return;
}
if (node.type !== "dir") return;
if (!node.loaded) {
const output = await operationOutput("workspace.ls", { path: node.path, maxEntries: 1000 });
node.children = workspaceEntries(output);
node.loaded = true;
}
nodes = node.children ?? [];
}
tree.value = [...tree.value];
}
async function runAction(intent: string, args: Record<string, unknown> = {}): Promise<void> {
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> {
downloadConfirmOpen.value = false;
await runAction("debug.download");
}
async function readUart(): Promise<void> {
const output = await operationOutput("io.uart.read", uartArgs());
uartOutput.value = typeof output.stdout === "string" ? output.stdout : JSON.stringify(output.response ?? output, null, 2);
}
async function writeUart(): Promise<void> {
const data = uartData.value;
if (!data) return;
await operationOutput("io.uart.write", { ...uartArgs(), data });
uartData.value = "";
}
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;
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);
if (response.ok === false) throw new Error(errorSummary(response) || `${intent} 执行失败`);
const result = Array.isArray(response.results) ? record(response.results[0]) : {};
if (result.ok === false) throw new Error(errorSummary(result) || `${intent} 执行失败`);
return record(result.output);
} catch (error) {
localError.value = error instanceof Error ? error.message : String(error);
throw error;
} finally {
busy.value = null;
}
}
function workspaceEntries(output: Record<string, unknown>): WorkspaceNode[] {
return Array.isArray(output.entries) ? output.entries.map((entry) => {
const value = record(entry);
const type = value.type === "dir" || value.type === "file" ? value.type : "other";
return {
name: String(value.name ?? ""),
path: String(value.path ?? value.name ?? ""),
type,
sizeBytes: value.sizeBytes == null ? null : numberValue(value.sizeBytes),
modifiedAt: typeof value.modifiedAt === "string" ? value.modifiedAt : null,
...(type === "dir" ? { children: [], loaded: false } : {}),
};
}) : [];
}
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function numberValue(value: unknown): number { return Number.isFinite(Number(value)) ? Number(value) : 0; }
function errorSummary(value: Record<string, unknown>): string { return String(record(value.error).message ?? record(value.blocker).summary ?? value.summary ?? ""); }
function blockerSummary(value: Record<string, unknown>): string { return String(record(value.blocker).summary ?? ""); }
function operationLabel(operation: HwpodOperationResponse | null): string { return operation ? `${operation.status} · ${operation.operationId}` : "尚未执行"; }
</script>
<template>
<section class="operations-workspace" data-testid="hwpod-operations-workspace">
<header class="workspace-toolbar">
<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>
<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">
<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'" />
<File v-else :size="15" />
<span>{{ node.name }}</span>
</div>
</template>
</BaseTree>
</section>
<section class="preview-pane" aria-label="文件预览">
<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>
</section>
<aside class="operation-pane" aria-label="HWPOD 操作">
<section>
<h2><Hammer :size="16" /> 调试</h2>
<div class="action-row">
<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>
<label>端口<input v-model="uartPort" /></label>
<label>波特率<input v-model.number="uartBaudRate" type="number" min="1" /></label>
<div class="icon-actions">
<button type="button" title="打开串口" :disabled="Boolean(busy)" @click="runAction('io.uart.open', uartArgs())"><Plug :size="16" /></button>
<button type="button" title="关闭串口" :disabled="Boolean(busy)" @click="runAction('io.uart.close', uartArgs())"><Square :size="15" /></button>
<button type="button" title="读取串口" :disabled="Boolean(busy)" @click="readUart"><RefreshCw :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>
</section>
<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) 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; 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 { 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); }
.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; }
.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; } .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>