feat(ui): refresh Vue Bootstrap dashboard and network diagrams

This commit is contained in:
Codex
2026-10-05 22:06:37 +08:00
parent 18d6613bb4
commit 436596e786
18 changed files with 403 additions and 9 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "zhao-steward-ui",
"private": true,
"version": "0.1.35",
"version": "0.1.37",
"type": "module",
"scripts": {
"build": "vite build",
+230
View File
@@ -0,0 +1,230 @@
/* Bootstrap presentation layer. Keep player geometry and account preferences intact. */
:root {
--ui-bg: #f3f6fa;
--ui-surface: #fff;
--ui-line: #e3e9ef;
--ui-muted: #5e6f7e;
--ui-sidebar: #172e35;
--ui-accent: #217964;
--ink: #253b46;
--muted: #5e6f7e;
--paper: var(--ui-surface);
--line: var(--ui-line);
--green: var(--ui-accent);
--bs-primary: #217964;
--bs-primary-rgb: 33, 121, 100;
--bs-body-color: var(--ink);
--bs-body-bg: var(--ui-bg);
--bs-border-color: var(--ui-line);
--ui-shadow: 0 3px 16px #223d5010;
--ui-soft: #f5f8fa;
}
html[data-theme="paper"] { --ui-bg: #f5f6f4; --ui-soft: #f5f7f3; }
html[data-theme="clean"] { --ui-bg: #f1f5f9; --ui-soft: #f4f7fb; }
html[data-theme="ink"] {
--ui-bg: #111e26; --ui-surface: #1a2b35; --ui-line: #30444e;
--ui-soft: #21353f; --ink: #e0ecef; --ui-muted: #a8bdc5;
--muted: #a8bdc5; --ui-shadow: 0 3px 16px #0002;
--paper: #1a2b35; --line: #30444e;
}
html, body { min-width: 0; }
body { -webkit-font-smoothing: antialiased; }
#app > main { max-width: 2000px; padding: 30px 38px 24px !important; }
main > header { margin-bottom: 26px !important; padding-bottom: 24px !important; }
#page-title { font-size: clamp(24px, 2.3vw, 32px) !important; letter-spacing: -.7px !important; }
html[data-font="serif"] #page-title { font-weight: 600 !important; }
#brand-motto { color: var(--ui-accent) !important; letter-spacing: 2.4px; }
#page-note { font-size: 13px !important; }
.header-status { gap: 9px; }
#clock { font-variant-numeric: tabular-nums; font-size: 11px; }
#service-status { padding: 6px 10px; border-radius: 20px !important; }
.sidebar { background: linear-gradient(165deg, #1d3940, #152b32 70%) !important; }
.brand { padding-bottom: 26px !important; }
.brand-icon { width: 46px !important; height: 46px !important; box-shadow: 0 3px 12px #071d2860; }
.sidebar nav button { border-radius: 10px !important; min-height: 46px; transition: background .15s, color .15s; }
.sidebar nav button.active { background: #e1f2eb !important; color: #155b48 !important; box-shadow: 0 2px 7px #0002 !important; }
.sidebar nav button.active .app-icon { opacity: 1; }
.sidebar #current-user { padding: 14px 9px 2px; }
.section-head { align-items: center; margin-bottom: 18px; }
.section-head h2 { font-size: 21px !important; }
.count-pill { background: #e7f0ec; color: #286752; border: 0; border-radius: 7px; padding: 3px 9px; font-size: 12px; }
.btn { border-radius: 9px !important; min-height: 38px; padding: 8px 14px; transition: background .15s, border-color .15s, box-shadow .15s; }
.btn-primary { --bs-btn-bg: var(--ui-accent); --bs-btn-border-color: var(--ui-accent); box-shadow: 0 3px 8px #21796416; }
.btn-primary:hover { box-shadow: 0 4px 12px #21796428; }
.btn-outline-secondary { --bs-btn-color: var(--ink); --bs-btn-border-color: var(--ui-line); background: var(--ui-surface); }
.btn-link { color: var(--ui-accent); }
html:is([data-theme="clean"],[data-theme="ink"]) :is(main,dialog) .btn-primary:not(.steward-tile):not(.device-title):not(.platform-place-node) { background: var(--ui-accent) !important; color: #fff !important; border-color: var(--ui-accent) !important; }
html:is([data-theme="clean"],[data-theme="ink"]) :is(main,dialog) .btn-outline-secondary { background: var(--ui-surface) !important; color: var(--ink) !important; }
html:is([data-theme="clean"],[data-theme="ink"]) :is(main,dialog) .btn-outline-secondary:is(.active,[aria-pressed="true"]) { background: var(--ui-soft) !important; border-color: #629987 !important; }
html:is([data-theme="clean"],[data-theme="ink"]) .btn-link { background: transparent !important; color: var(--ink) !important; }
html[data-theme="ink"] #brand-motto { color: #a7d7c4 !important; }
.btn:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid #529cce; outline-offset: 3px; }
.form-control, .form-select { border-radius: 9px !important; border-color: var(--ui-line) !important; min-height: 40px; background-color: var(--ui-surface); color: var(--ink); }
.form-control:focus, .form-select:focus { border-color: #51a18b !important; box-shadow: 0 0 0 3px #21856819 !important; }
.card, .panel { border: 1px solid var(--ui-line) !important; border-radius: 14px !important; background: var(--ui-surface); box-shadow: var(--ui-shadow) !important; }
.card { overflow: hidden; }
.card-body { padding: 24px; }
dialog { border-radius: 16px !important; box-shadow: 0 24px 100px #10293040 !important; }
dialog::backdrop { background: #132a3670; backdrop-filter: blur(4px); }
dialog h2 { font-size: 22px !important; }
.household-context, .device-context { border: 1px solid var(--ui-line) !important; background: var(--ui-surface) !important; border-radius: 12px; box-shadow: var(--ui-shadow); }
.home-insights { display: grid; grid-template-columns: 1.4fr .7fr 1fr; gap: 18px; margin: 22px 0 26px; }
.home-insights > article { border-radius: 15px; padding: 24px; min-width: 0; }
.insight-welcome { position: relative; overflow: hidden; color: #fff; background: linear-gradient(115deg,#20534d,#297562); box-shadow: 0 6px 22px #21594c1c; }
.insight-welcome::after { content: ''; position: absolute; right: -28px; bottom: -64px; width: 200px; height: 200px; border: 32px solid #fff07; border-radius: 50%; pointer-events: none; }
.insight-kicker { color: #cae4d9; font-size: 10px; letter-spacing: 2px; }
.insight-welcome h2 { color: #fff !important; font-size: 23px !important; margin: 14px 0 8px !important; }
.insight-welcome p { color: #d0e7df; font-size: 12px; margin-bottom: 22px; }
.insight-scope { display: flex; gap: 7px; align-items: center; color: #d7ebe4; font-size: 10px; }
.insight-scope .app-icon { width: 15px; height: 15px; flex-basis: 15px; }
.insight-number, .insight-distribution { background: var(--ui-surface); border: 1px solid var(--ui-line); box-shadow: var(--ui-shadow); }
.insight-number { position: relative; display: flex; flex-direction: column; justify-content: center; }
.insight-icon { position: absolute; right: 20px; top: 22px; width: 36px; height: 36px; display: grid; place-items: center; background: #e8f1fb; color: #527ec0; border-radius: 11px; }
.insight-label { color: var(--ui-muted); font-size: 12px; padding-right: 35px; }
.insight-number > strong { font-size: 40px; line-height: 1.3; font-weight: 650; margin: 10px 0; color: var(--ink); font-variant-numeric: tabular-nums; }
.insight-number strong small { font-size: 12px; font-weight: 400; margin-left: 10px; color: var(--ui-muted); }
.insight-foot { color: var(--ui-muted); font-size: 11px; }
.insight-distribution { display: flex; align-items: center; justify-content: center; gap: 20px; }
.insight-donut { width: 126px; flex: 0 0 126px; position: relative; }
.insight-donut svg { width: 100%; display: block; }
.donut-track { stroke: var(--ui-line); }
.insight-donut > div { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
.insight-donut strong { font-size: 28px; line-height: 1.3; }
.insight-donut small { font-size: 10px; color: var(--ui-muted); }
.insight-legend { flex: 1; min-width: 60px; max-width: 145px; }
.insight-legend h3 { font-size: 12px !important; margin-bottom: 13px; white-space: nowrap; }
.insight-legend > span { display: flex; align-items: center; gap: 7px; font-size: 11px; margin-top: 7px; color: var(--ui-muted); }
.insight-legend i { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; }
.insight-legend b { margin-left: auto; color: var(--ink); }
.home-toolbar { padding: 13px 16px; border: 1px solid var(--ui-line); border-radius: 12px; background: var(--ui-surface); gap: 14px; }
.home-layout-toggle { display: none; }
#home-layout-status { margin: 10px 3px 18px; font-size: 11px; }
.home-card-grid { gap: 24px; }
.home-card { transition: box-shadow .18s, border-color .18s; }
.home-card:hover { box-shadow: 0 8px 26px #223d5019 !important; border-color: #b4cac0 !important; }
.home-cover { height: 220px; background: linear-gradient(120deg,#35595b,#193a40); }
.home-cover .cover-shade { background: linear-gradient(180deg,#142b3830 0%,#10282b05 30%,#0a232bcc 100%); }
.home-cover-content { padding: 22px 25px; }
.home-cover-content h3 { font-size: 24px !important; font-weight: 600 !important; letter-spacing: .5px !important; }
.home-cover-content .badge { border: 1px solid #ffffff38; background: #f6fffaed !important; color: #27594a !important; border-radius: 6px !important; font-size: 11px; }
.home-location { font-size: 11px; color: #ebf3f0; }
.cover-edit { background: #142a3560 !important; border: 1px solid #ffffff80 !important; color: #fff !important; backdrop-filter: blur(4px); }
.home-summary { color: var(--ui-muted); font-size: 12px; }
.home-numbers { background: var(--ui-soft); border: 0; border-radius: 10px; padding: 14px 0; margin: 16px 0 20px; }
.home-numbers span { border-right: 1px solid var(--ui-line); color: var(--ui-muted); font-size: 11px; }
.home-numbers span:last-child { border: 0; }
.home-numbers b { font-size: 24px; color: var(--ink); }
.home-card-actions { border-top: 1px solid var(--ui-line); padding-top: 18px; margin-top: 18px; }
.compact-details dt { color: var(--ui-muted); }
.compact-details dd { color: var(--ink); }
.home-pagination { margin-top: 24px; font-size: 11px; }
.metrics { gap: 16px; }
.metrics > div { border: 1px solid var(--ui-line); border-radius: 12px; background: var(--ui-surface); box-shadow: var(--ui-shadow); border-top: 3px solid #438979; }
.metrics > div:nth-child(2) { border-top-color: #658cd1; }
.metrics > div:nth-child(3) { border-top-color: #b69c70; }
.metrics > div:nth-child(4) { border-top-color: #8391b5; }
.metrics strong { font-variant-numeric: tabular-nums; color: var(--ink); }
.device-card-heading .device-symbol { background: #e9f3ef; color: #287e65; border-radius: 12px; width: 44px; height: 44px; }
.device-card .card-footer { border-top: 1px solid var(--ui-line); background: var(--ui-soft); padding: 14px 22px; }
.device-card-grid { gap: 20px; }
.device-view-controls { padding: 20px 24px; }
.device-search-row { border-bottom: 1px solid var(--ui-line); padding-bottom: 18px; margin-bottom: 15px; }
.group-title { margin: 24px 0 14px; }
.device-location { color: var(--ui-muted); }
.network-map { padding: 24px; }
.network-platform { background: var(--ui-soft); border: 1px solid var(--ui-line); border-radius: 12px; padding: 20px; }
.network-platform > .app-icon { width: 30px; height: 30px; flex-basis: 30px; color: var(--ui-accent); }
.network-diagram { margin: 20px 0 28px; border: 1px solid var(--ui-line); border-radius: 12px; overflow: hidden; }
.diagram-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; background: var(--ui-surface); }
.diagram-toolbar > div { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.diagram-toolbar strong { font-size: 13px; }
.diagram-dot { width: 7px; height: 7px; border-radius: 50%; background: #4d8dbe; }
.diagram-toolbar label { display: flex; align-items: center; gap: 8px; font-size: 11px; margin: 0; }
.diagram-toolbar select { width: 86px; }
.diagram-scroll { max-height: 480px; overflow: auto; background-color: var(--ui-soft); background-image: radial-gradient(var(--ui-line) 1px, transparent 1px); background-size: 18px 18px; border-block: 1px solid var(--ui-line); }
.network-svg { display: block; }
.network-edge { stroke: #82a79c; stroke-width: 2; }
.diagram-node { cursor: pointer; outline: none; }
.diagram-node-bg { fill: var(--ui-surface); stroke: var(--ui-line); stroke-width: 1.5; }
.diagram-node:hover .diagram-node-bg, .diagram-node:focus .diagram-node-bg { stroke: #328d78; stroke-width: 2.5; }
.diagram-node-type { fill: var(--ui-muted); font-size: 10px; }
.diagram-node-title { fill: var(--ink); font-size: 14px; font-weight: 600; }
.diagram-node-ip { fill: var(--ui-muted); font-size: 11px; font-family: ui-monospace, Consolas, monospace; }
.diagram-note { margin: 0; padding: 11px 18px; font-size: 11px; color: var(--ui-muted); }
.network-details-heading { margin: 20px 0 14px; }
.platform-place-node { position: relative; text-align: left; padding: 24px; }
.platform-place-node::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; background: #438979; }
.platform-place-node:hover { border-color: #8db4a7 !important; background: var(--ui-soft); }
.network-open { color: var(--ui-accent); font-weight: 600; }
.scope-intro { border-bottom: 1px solid var(--ui-line); padding-bottom: 20px; }
.steward-tile { border-radius: 14px !important; border: 1px solid var(--ui-line) !important; border-top: 3px solid #438979 !important; box-shadow: var(--ui-shadow) !important; padding: 28px !important; background: var(--ui-surface) !important; }
.steward-tile:nth-child(2n) { border-top-color: #6c8fbc !important; }
.steward-tile:nth-child(3n) { border-top-color: #b29a72 !important; }
.steward-tile strong { font-size: 19px !important; }
.steward-tile:hover { border-color: #9dbab0 !important; box-shadow: 0 8px 22px #223d5016 !important; }
.journal-card { border-radius: 14px; box-shadow: var(--ui-shadow); }
main > footer { color: var(--ui-muted); font-size: 10px; }
html[data-density="compact"] .home-insights > article { padding: 18px; }
html[data-density="compact"] .home-cover { height: 165px; }
html[data-density="compact"] .card-body { padding: 18px; }
@media (min-width: 1100px) { html[data-layout="top"] #app > main { padding-top: 28px !important; } }
@media (max-width: 1250px) {
.home-insights { grid-template-columns: 1.2fr .75fr 1fr; gap: 12px; }
.home-insights > article { padding: 19px; }
.insight-distribution { gap: 10px; }
.insight-donut { width: 102px; flex-basis: 102px; }
.insight-welcome h2 { font-size: 20px !important; }
}
@media (max-width: 1000px) and (min-width: 761px) {
#app > main { padding: 26px 24px !important; }
.home-insights { grid-template-columns: 1fr 1fr; }
.insight-welcome { grid-column: 1/-1; }
.insight-welcome p { margin-bottom: 12px; }
}
@media (max-width: 760px) {
#app > main { padding: 20px 16px 20px !important; }
.sidebar { background: #17353b !important; }
main > header { margin-bottom: 20px !important; padding-bottom: 18px !important; }
#page-title { font-size: 25px !important; }
#page-note { font-size: 12px !important; }
.home-insights { grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0 20px; }
.home-insights > article { padding: 18px; }
.insight-welcome { grid-column: 1/-1; }
.insight-welcome h2 { margin: 7px 0 0 !important; font-size: 20px !important; }
.insight-welcome p, .insight-scope { display: none; }
.insight-number > strong { font-size: 34px; }
.insight-icon { width: 29px; height: 29px; top: 14px; right: 13px; }
.insight-distribution { flex-direction: column; gap: 5px; }
.insight-donut { width: 94px; flex-basis: auto; }
.insight-legend { width: 100%; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
.insight-legend h3 { display: none; }
.insight-legend > span { margin: 0; font-size: 10px; gap: 4px; }
.insight-number .insight-label { padding-right: 24px; font-size: 11px; }
.insight-donut strong { font-size: 25px; }
.insight-donut small { font-size: 9px; }
.home-toolbar { padding: 12px; }
.home-layout-toggle { display: inline-flex; margin-left: auto; }
.home-toolbar .home-layout-controls { display: none; }
.home-toolbar.home-options-open .home-layout-controls { display: flex; flex-basis: 100%; border-top: 1px solid var(--ui-line); padding-top: 12px; }
.home-cover { height: 200px; }
.home-cover-content { padding: 20px; }
.home-cover-content h3 { font-size: 23px !important; }
.card-body { padding: 20px; }
.home-numbers { padding: 12px 0; }
.home-card-grid { gap: 18px; }
.home-card-actions { gap: 8px; flex-wrap: wrap; }
.btn { min-height: 44px; }
.form-control, .form-select { min-height: 44px; font-size: 16px; }
.device-view-controls { padding: 16px; }
.network-map { padding: 14px; }
.network-platform { padding: 16px; }
.diagram-toolbar { padding: 12px; align-items: flex-start; }
.diagram-toolbar > div { gap: 6px; }
.diagram-toolbar > div .small { flex-basis: 100%; font-size: 10px; }
.diagram-toolbar label { flex-direction: column; align-items: flex-start; }
.diagram-toolbar select { width: 82px; }
.diagram-scroll { max-height: 360px; }
.steward-tile { padding: 22px !important; }
main[data-page="live"] > header { margin-bottom: 10px !important; padding-bottom: 12px !important; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
+1 -1
View File
@@ -15,6 +15,6 @@ export function enhance(root){
export async function loadControllers(){
enhance(document.getElementById('app'))
new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.36';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.37';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
await window.ZhaoVue.boot()
}
+2
View File
@@ -1,6 +1,8 @@
<script setup>
defineProps({ name: { type: String, default: "grid" } });
const paths = {
shield: "M12 2 3 6v6c0 5 9 10 9 10s9-5 9-10V6Z M8 12l3 3 5-6",
location: "M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0 M15 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0",
home: "m3 10 9-7 9 7v10H3Z M9 20v-7h6v7",
journal: "M5 3h14v18H5Z M8 7h8 M8 11h8 M8 15h5",
grid: "M3 3h7v7H3Z M14 3h7v7h-7Z M3 14h7v7H3Z M14 14h7v7h-7Z",
+7 -2
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch } from "vue";
import { snapshot, bridge, accessLabel } from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue";
import HomeInsights from "./HomeInsights.vue";
const draft = ref({
revision: 0,
columns: 2,
@@ -14,6 +15,7 @@ const draft = ref({
page = ref(0),
arrange = ref(false),
mode = ref("cards"),
layoutOptions = ref(false),
drag = ref(""),
message = ref("");
let owner = "";
@@ -28,6 +30,7 @@ watch(
page.value = 0;
arrange.value = false;
mode.value = "cards";
layoutOptions.value = false;
drag.value = "";
message.value = "";
}
@@ -144,7 +147,8 @@ async function enter(h, network = false) {
+ 新建家庭或场所
</button>
</div>
<div class="home-toolbar">
<HomeInsights />
<div class="home-toolbar" :class="{ 'home-options-open': layoutOptions }">
<div class="btn-group" role="group" aria-label="场所显示方式">
<button
id="home-card-view"
@@ -164,7 +168,8 @@ async function enter(h, network = false) {
<AppIcon name="network" /> 整体拓扑
</button>
</div>
<div v-show="mode === 'cards'" class="home-layout-controls">
<button v-show="mode === 'cards'" class="btn btn-outline-secondary home-layout-toggle" :aria-expanded="layoutOptions" aria-controls="home-layout-options" @click="layoutOptions = !layoutOptions">{{ layoutOptions ? '收起设置' : '布局设置' }}</button>
<div id="home-layout-options" v-show="mode === 'cards'" class="home-layout-controls">
<label
>每页
<select
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { computed } from 'vue';
import { snapshot } from '../composables/bridge.js';
import { summarizeHomes } from '../composables/insights.mjs';
import AppIcon from './AppIcon.vue';
const summary = computed(() => summarizeHomes(snapshot.value.state.households));
</script>
<template>
<section class="home-insights" aria-label="已授权场所概览">
<article class="insight-welcome">
<span class="insight-kicker">一处相连 · 各有归属</span>
<h2>家与日常,尽在掌握</h2>
<p>从一个空间出发,照看设备,也珍藏生活。</p>
<div class="insight-scope"><AppIcon name="shield" /> 仅汇总你有权访问的家庭与场所</div>
</article>
<article class="insight-number">
<span class="insight-icon"><AppIcon name="devices" /></span>
<span class="insight-label">已登记设备</span>
<strong>{{ summary.devices }}<small>台</small></strong>
<span class="insight-foot">分布在 {{ summary.areas }} 处区域</span>
</article>
<article class="insight-distribution">
<div class="insight-donut">
<svg viewBox="0 0 120 120" role="img" :aria-label="'场所分布:' + (summary.kinds.map(k => k.label + k.count + '个').join(',') || '暂无场所')">
<circle class="donut-track" cx="60" cy="60" r="48" fill="none" stroke-width="10" />
<circle v-for="kind in summary.kinds" :key="kind.key" cx="60" cy="60" r="48" fill="none" stroke-width="10" pathLength="100" :stroke="kind.color" :stroke-dasharray="`${kind.share} ${100 - kind.share}`" :stroke-dashoffset="-kind.offset" transform="rotate(-90 60 60)" />
</svg>
<div aria-hidden="true"><strong>{{ summary.total }}</strong><small>家庭与场所</small></div>
</div>
<div class="insight-legend"><h3>空间分布</h3><span v-for="kind in summary.kinds" :key="kind.key"><i :style="{ background: kind.color }"></i>{{ kind.label }}<b>{{ kind.count }}</b></span><span v-if="!summary.total">等待添加空间</span></div>
</article>
</section>
</template>
@@ -0,0 +1,31 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { deviceView, snapshot, bridge, accessLabel } from '../composables/bridge.js';
import { layoutNetwork } from '../composables/insights.mjs';
const zoom = ref(1);
const graph = computed(() => layoutNetwork(deviceView.value.rows));
watch(() => snapshot.value.session + ':' + snapshot.value.state.familyId, () => { zoom.value = 1; });
const open = node => bridge().enterDevice(node.device.source, node.device.id, 'device');
</script>
<template>
<div v-if="graph.nodes.length" class="network-diagram">
<div class="diagram-toolbar">
<div><span class="diagram-dot"></span><strong>设备连接图</strong><span class="small text-secondary">{{ graph.nodes.length }} 台设备 · {{ graph.edges.length }} 条已登记连接</span></div>
<label>缩放<select v-model.number="zoom" class="form-select form-select-sm" aria-label="网络图缩放"><option :value="0.75">75%</option><option :value="1">100%</option><option :value="1.25">125%</option></select></label>
</div>
<div class="diagram-scroll" tabindex="0" role="region" aria-label="设备连接图,可滚动查看;点击设备打开档案">
<svg :width="graph.width * zoom" :height="graph.height * zoom" :viewBox="`0 0 ${graph.width} ${graph.height}`" class="network-svg" role="group" aria-label="已登记的网络连接">
<path v-for="edge in graph.edges" :key="edge.id" :d="edge.path" class="network-edge" fill="none" />
<g v-for="node in graph.nodes" :key="node.device.id" :transform="`translate(${node.x},${node.y})`" class="diagram-node" role="button" tabindex="0" :aria-label="node.device.name + ',打开设备档案'" @click="open(node)" @keydown.enter.prevent="open(node)" @keydown.space.prevent="open(node)">
<title>{{ node.device.name }} · {{ node.device.deviceType }} · {{ (node.device.networkHosts || []).join(' / ') || 'IP 待填写' }}</title>
<rect width="244" height="88" rx="12" class="diagram-node-bg" />
<rect x="12" y="16" width="4" height="54" rx="2" :fill="node.device.source === 'camera' ? '#5c83cb' : '#258b76'" />
<text x="28" y="26" class="diagram-node-type">{{ node.device.deviceType }} · {{ accessLabel(node.device.networkAccess) }}</text>
<text x="28" y="48" class="diagram-node-title">{{ node.device.name.length > 14 ? node.device.name.slice(0, 14) + '…' : node.device.name }}</text>
<text x="28" y="70" class="diagram-node-ip">{{ (node.device.networkHosts || [])[0] || 'IP 待填写' }}</text>
</g>
</svg>
</div>
<p class="diagram-note">点击节点查看名称、位置与网络详情。未登记上级的设备独立显示。</p>
</div>
</template>
+3
View File
@@ -8,6 +8,7 @@ import {
} from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue";
import NetworkStatus from "./NetworkStatus.vue";
import NetworkDiagram from "./NetworkDiagram.vue";
const home = computed(() =>
(snapshot.value.state.households || []).find(
(h) => h.id === snapshot.value.state.familyId,
@@ -27,6 +28,8 @@ const home = computed(() =>
<p class="description">
连线来自已确认的上级设备关系。上级未填写时单独列出;不代表已自动发现所有交换机或布线。
</p>
<NetworkDiagram />
<h3 class="network-details-heading">设备与连接详情</h3>
<div class="topology-nodes">
<article
v-for="row in deviceView.rows"
+36
View File
@@ -0,0 +1,36 @@
// Display-only summaries of the records already authorized by the server.
export function summarizeHomes(homes = []) {
const kinds = [
{ key: 'home', label: '家庭', color: '#258b76', count: 0 },
{ key: 'office', label: '办公', color: '#5c83cb', count: 0 },
{ key: 'factory', label: '工厂', color: '#c69a52', count: 0 },
{ key: 'other', label: '其他', color: '#8997a9', count: 0 },
];
let devices = 0, areas = 0;
for (const home of homes) {
(kinds.find(k => k.key === (home.kind || 'home')) || kinds[3]).count++;
devices += Math.max(0, Number(home.deviceCount) || 0);
areas += Math.max(0, Number(home.sites) || 0);
}
let offset = 0;
return { total: homes.length, devices, areas, kinds: kinds.filter(k => k.count).map(k => {
const value = { ...k, share: k.count / homes.length * 100, offset };
offset += value.share;
return value;
}) };
}
export function layoutNetwork(rows = []) {
const nodes = rows.map((row, index) => ({
...row, x: 28 + Math.max(0, row.depth) * 300, y: 26 + index * 112,
}));
const byId = new Map(nodes.map(n => [n.device.id, n]));
const edges = nodes.flatMap(node => {
const parent = byId.get(node.device.uplinkId);
// Only draw an edge when the validated hierarchy confirms the parent.
if (!parent || parent.depth + 1 !== node.depth) return [];
const x = parent.x + 244, y = parent.y + 40, endY = node.y + 40;
return [{ id: node.device.id, path: `M${x},${y} H${x + 24} V${endY} H${node.x}` }];
});
return { nodes, edges, width: Math.max(640, ...nodes.map(n => n.x + 272)), height: Math.max(172, nodes.length * 112 + 26) };
}
+1
View File
@@ -4,6 +4,7 @@ import '../../web/family-map.css'
import '../../web/chinese-theme.css'
import 'bootstrap/dist/css/bootstrap.min.css'
import './theme.css'
import './bootstrap-polish.css'
import App from './App.vue'
import './composables/bridge.js'
createApp(App).mount('#app')