Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/components/hwpod/HwpodDeviceCard.vue
T
2026-07-21 13:23:55 +02:00

131 lines
9.1 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-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>