ccf10af5bd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
131 lines
9.1 KiB
Vue
131 lines
9.1 KiB
Vue
<!--
|
||
SPEC: PJ2026-010405 云端控制台。
|
||
Implementation reference: draft-2026-07-13-p0-cloud-console.
|
||
Responsibility: 将 typed HWPOD 四要素状态投影为有界 PCB 卡片,并遵守 reduced-motion。
|
||
-->
|
||
<script setup lang="ts">
|
||
import { computed } from "vue";
|
||
import type { HwpodDeviceTopologyItem } from "@/types";
|
||
|
||
const props = withDefaults(
|
||
defineProps<{ device: HwpodDeviceTopologyItem; selected?: boolean }>(),
|
||
{ selected: false },
|
||
);
|
||
const emit = defineEmits<{ select: [id: string] }>();
|
||
|
||
const statusLabel = computed(() => ({
|
||
online: "在线",
|
||
offline: "节点离线",
|
||
}[props.device.status] ?? props.device.status));
|
||
|
||
function linkState(kind: "target" | "workspace" | "debug" | "io"): string {
|
||
if (!props.device.online) return "offline";
|
||
if (kind === "workspace" && props.device.availability.ok === false) return "blocked";
|
||
const prefix = kind === "debug" ? "debug." : kind === "io" ? "io." : "";
|
||
if (prefix && props.device.missingCapabilities.some((capability) => capability.startsWith(prefix))) return "mismatch";
|
||
if (props.device.busy) return "active";
|
||
return props.device.available ? "ready" : "pending";
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<article
|
||
class="pcb-card"
|
||
role="group"
|
||
:aria-label="`${device.name} HWPOD 设备卡片`"
|
||
:aria-current="selected ? 'true' : undefined"
|
||
:data-selected="selected ? 'true' : 'false'"
|
||
:data-status="device.status"
|
||
:data-testid="`hwpod-device-${device.hwpodId}`"
|
||
>
|
||
<header class="pcb-card__header">
|
||
<div>
|
||
<span class="pcb-card__eyebrow">{{ device.nodeName }}</span>
|
||
<h2>{{ device.name }}</h2>
|
||
</div>
|
||
<span class="pcb-status" :data-status="device.status"><i aria-hidden="true" />{{ statusLabel }}</span>
|
||
</header>
|
||
|
||
<svg class="pcb-map" viewBox="0 0 520 220" role="img" :aria-label="`${device.name} HWPOD 四要素状态图`">
|
||
<defs>
|
||
<pattern :id="`grid-${device.hwpodId}`" width="16" height="16" patternUnits="userSpaceOnUse">
|
||
<path d="M 16 0 L 0 0 0 16" class="pcb-grid-line" />
|
||
</pattern>
|
||
</defs>
|
||
<rect x="8" y="8" width="504" height="204" rx="20" class="pcb-board" />
|
||
<rect x="8" y="8" width="504" height="204" rx="20" :fill="`url(#grid-${device.hwpodId})`" />
|
||
<path d="M260 110 H128 V56 H84" class="pcb-trace" :data-state="linkState('target')" />
|
||
<path d="M260 110 H128 V164 H84" class="pcb-trace" :data-state="linkState('workspace')" />
|
||
<path d="M260 110 H392 V56 H436" class="pcb-trace" :data-state="linkState('debug')" />
|
||
<path d="M260 110 H392 V164 H436" class="pcb-trace" :data-state="linkState('io')" />
|
||
<rect x="210" y="76" width="100" height="68" rx="12" class="pcb-chip" />
|
||
<text x="260" y="104" text-anchor="middle" class="pcb-chip-label">HWPOD</text>
|
||
<text x="260" y="124" text-anchor="middle" class="pcb-chip-id">{{ device.hwpodId.slice(0, 18) }}</text>
|
||
<g class="pcb-pad" :data-state="linkState('target')"><circle cx="66" cy="56" r="20" /><text x="66" y="61" text-anchor="middle">TGT</text></g>
|
||
<g class="pcb-pad" :data-state="linkState('workspace')"><circle cx="66" cy="164" r="20" /><text x="66" y="169" text-anchor="middle">WS</text></g>
|
||
<g class="pcb-pad" :data-state="linkState('debug')"><circle cx="454" cy="56" r="20" /><text x="454" y="61" text-anchor="middle">DBG</text></g>
|
||
<g class="pcb-pad" :data-state="linkState('io')"><circle cx="454" cy="164" r="20" /><text x="454" y="169" text-anchor="middle">IO</text></g>
|
||
</svg>
|
||
|
||
<dl class="element-grid">
|
||
<div><dt>目标板</dt><dd>{{ device.elements.target.label }}</dd><small>{{ device.elements.target.mcu || '未声明 MCU' }}</small></div>
|
||
<div><dt>工作区</dt><dd>{{ device.elements.workspace.label }}</dd><small>{{ device.elements.workspace.toolchain || '未声明工具链' }}</small></div>
|
||
<div><dt>调试探头</dt><dd>{{ device.elements.debugProbe.label }}</dd><small>{{ device.elements.debugProbe.type || '未声明类型' }}</small></div>
|
||
<div><dt>IO 探头</dt><dd>{{ device.elements.ioProbe.label }}</dd><small>{{ device.elements.ioProbe.kind }}</small></div>
|
||
</dl>
|
||
|
||
<p v-if="device.blockers[0]" class="pcb-blocker"><strong>{{ device.blockers[0].code }}</strong>{{ device.blockers[0].summary }}</p>
|
||
<footer>
|
||
<span>能力覆盖 {{ device.capabilityCoverage.matched }}/{{ device.capabilityCoverage.declared }} · 上报 {{ device.capabilityCoverage.actual }}</span>
|
||
<span v-if="device.operation">{{ device.operation.status }} · {{ device.operation.opCount }} ops</span>
|
||
<button
|
||
type="button"
|
||
:aria-pressed="selected"
|
||
@click="emit('select', device.hwpodId)"
|
||
>{{ selected ? '已选择' : '查看详情' }}</button>
|
||
</footer>
|
||
</article>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.pcb-card { position: relative; min-width: 0; border: 1px solid #cbd2cf; border-top: 3px solid #2e6b67; border-radius: 8px; background: #fbfaf6; box-shadow: 0 12px 28px rgb(20 34 32 / 8%); overflow: hidden; transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
|
||
.pcb-card[data-selected="true"] { border-color: #1f7469; border-top-color: #145f57; box-shadow: 0 0 0 3px rgb(31 116 105 / 18%), 0 16px 34px rgb(20 55 48 / 14%); transform: translateY(-1px); }
|
||
.pcb-card[data-selected="true"] footer button { border-color: #145f57; background: #dff2ed; color: #145f57; }
|
||
.pcb-card[data-status="offline"] { border-top-color: #8d453b; }
|
||
.pcb-card__header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; padding: 18px 20px 12px; }
|
||
.pcb-card__header h2 { margin: 3px 0 0; color: #152421; font-size: 1.08rem; letter-spacing: 0; overflow-wrap: anywhere; }
|
||
.pcb-card__eyebrow { color: #687572; font: 600 .72rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||
.pcb-status { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; padding: 5px 9px; border: 1px solid #b7c6c1; border-radius: 999px; color: #244943; background: #eef7f2; font-size: .76rem; font-weight: 700; }
|
||
.pcb-status i { width: 7px; height: 7px; border-radius: 50%; background: currentcolor; }
|
||
.pcb-status[data-status="offline"] { color: #7b332c; background: #fff0ed; border-color: #ddb2ac; }
|
||
.pcb-map { display: block; width: calc(100% - 28px); max-height: 220px; margin: 0 14px; }
|
||
.pcb-board { fill: #142c28; stroke: #31514a; stroke-width: 2; }
|
||
.pcb-grid-line { fill: none; stroke: rgb(137 184 168 / 9%); stroke-width: 1; }
|
||
.pcb-trace { fill: none; stroke: #789087; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
|
||
.pcb-trace[data-state="ready"] { stroke: #50c5a4; }
|
||
.pcb-trace[data-state="active"] { stroke: #efad51; stroke-dasharray: 8 8; animation: trace-flow .75s linear infinite; }
|
||
.pcb-trace[data-state="mismatch"] { stroke: #e5ca54; stroke-dasharray: 5 6; }
|
||
.pcb-trace[data-state="blocked"], .pcb-trace[data-state="offline"] { stroke: #bf675d; }
|
||
.pcb-chip { fill: #0d1917; stroke: #7ea398; stroke-width: 2; }
|
||
.pcb-chip-label { fill: #eef9f4; font: 700 15px/1 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: 0; }
|
||
.pcb-chip-id { fill: #91aaa2; font: 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||
.pcb-pad circle { fill: #213d37; stroke: #789087; stroke-width: 3; }
|
||
.pcb-pad text { fill: #d7e5df; font: 700 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||
.pcb-pad[data-state="ready"] circle { stroke: #50c5a4; }
|
||
.pcb-pad[data-state="active"] circle { stroke: #efad51; }
|
||
.pcb-pad[data-state="mismatch"] circle { stroke: #e5ca54; }
|
||
.pcb-pad[data-state="blocked"] circle, .pcb-pad[data-state="offline"] circle { stroke: #bf675d; }
|
||
.element-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 12px 18px; border: 1px solid #d6dcda; background: #d6dcda; }
|
||
.element-grid div { min-width: 0; padding: 10px 12px; background: #f7f6f1; }
|
||
.element-grid dt { color: #73807c; font-size: .67rem; font-weight: 800; letter-spacing: 0; text-transform: uppercase; }
|
||
.element-grid dd { margin: 4px 0 2px; color: #172b27; font-size: .84rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.element-grid small { display: block; color: #6b7673; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.pcb-blocker { margin: 12px 18px; padding: 10px 12px; border-left: 3px solid #aa5d37; background: #fff4e9; color: #5f3a29; font-size: .78rem; line-height: 1.45; }
|
||
.pcb-blocker strong { display: block; margin-bottom: 3px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||
footer { display: flex; align-items: center; gap: 12px; padding: 12px 18px 16px; color: #697571; font-size: .74rem; }
|
||
footer button { margin-left: auto; border: 1px solid #446c63; border-radius: 7px; background: #183d36; color: #f4fbf8; padding: 7px 11px; font-weight: 700; cursor: pointer; }
|
||
@keyframes trace-flow { to { stroke-dashoffset: -16; } }
|
||
@media (prefers-reduced-motion: reduce) { .pcb-card { transition: none; } .pcb-card[data-selected="true"] { transform: none; } .pcb-trace { animation: none !important; } }
|
||
@media (max-width: 560px) { .element-grid { grid-template-columns: 1fr; } .pcb-card__header { padding-inline: 14px; } footer { align-items: flex-start; flex-wrap: wrap; } }
|
||
</style>
|