Compare commits

...

2 Commits

Author SHA1 Message Date
Codex 91915b9d90 fix(ui): preserve readable camera labels in fullscreen 2026-10-05 22:12:47 +08:00
Codex 436596e786 feat(ui): refresh Vue Bootstrap dashboard and network diagrams 2026-10-05 22:06:37 +08:00
18 changed files with 409 additions and 9 deletions
+20
View File
@@ -0,0 +1,20 @@
# v0.1.37 — Vue 与 Bootstrap 界面升级
## 外观与体验
- 基于现有 Vue 3、Bootstrap 5 架构升级:深色导航、墨绿主色、浅色内容区,统一卡片、按钮、表单、弹窗与信息层次。
- 新增 Vue 场所概览组件,显示已授权场所的设备总量、区域数量与场所类型环形图。统计直接取自服务端已授权记录。
- 家庭照片卡片增加清晰的封面文字遮罩与统计分区,保留个人排序、行列、分页和城市分组。
- 网络拓扑新增可缩放、可点击的 Vue SVG 连接图;支持鼠标和键盘进入设备档案。只绘制已登记且通过现有层级校验的连接,不推测未确认的布线。
- 手机端使用单列卡片、可收起的布局设置和触控表单;弹窗保留可滚动内容与可见保存按钮。
- 保留个人配色、顶部或侧边导航、内容疏密及标题字体偏好;修正深色主题的按钮对比度。
- 全屏监控使用独立的深色工具栏与名称底色,避免浅色后台样式影响名称可读性。
- 图表在浏览器中绘制,不增加外部图表服务或新的后端数据采集。继续复用现有监控播放器。
## 验证
- Vite 生产构建通过;169 项 Python、138 项 JavaScript 回归测试通过。
- Edge Computer Use 在隔离数据下验证家庭分页、布局保存、档案弹窗、网络图缩放、键盘跳转及手机菜单。
- 390 与 320 像素视口检查无页面横向溢出,手机表单输入为 16px;检查浅色和深色主题。
本版未迁移业务数据库。公开源码不含用户照片、数据库或设备凭据。
+1 -1
View File
@@ -1 +1 @@
0.1.36 0.1.37
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "zhao-steward-ui", "name": "zhao-steward-ui",
"private": true, "private": true,
"version": "0.1.35", "version": "0.1.37",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
+234
View File
@@ -0,0 +1,234 @@
/* 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); }
#page-live:is(:fullscreen,.wall-expanded) .camera-meta { background: #172e35 !important; color: #e8f3ef !important; }
#page-live:is(:fullscreen,.wall-expanded) .camera-meta strong { color: #e8f3ef !important; }
#page-live:is(:fullscreen,.wall-expanded) .wall-toolbar { background: #172e35 !important; border-color: #45616a !important; }
#page-live:is(:fullscreen,.wall-expanded) .form-select { background-color: #213b44 !important; color: #e8f3ef !important; border-color: #64818b !important; }
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(){ export async function loadControllers(){
enhance(document.getElementById('app')) 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}) 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() await window.ZhaoVue.boot()
} }
+2
View File
@@ -1,6 +1,8 @@
<script setup> <script setup>
defineProps({ name: { type: String, default: "grid" } }); defineProps({ name: { type: String, default: "grid" } });
const paths = { 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", home: "m3 10 9-7 9 7v10H3Z M9 20v-7h6v7",
journal: "M5 3h14v18H5Z M8 7h8 M8 11h8 M8 15h5", journal: "M5 3h14v18H5Z M8 7h8 M8 11h8 M8 15h5",
grid: "M3 3h7v7H3Z M14 3h7v7h-7Z M3 14h7v7H3Z M14 14h7v7h-7Z", 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 { computed, ref, watch } from "vue";
import { snapshot, bridge, accessLabel } from "../composables/bridge.js"; import { snapshot, bridge, accessLabel } from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue"; import AppIcon from "./AppIcon.vue";
import HomeInsights from "./HomeInsights.vue";
const draft = ref({ const draft = ref({
revision: 0, revision: 0,
columns: 2, columns: 2,
@@ -14,6 +15,7 @@ const draft = ref({
page = ref(0), page = ref(0),
arrange = ref(false), arrange = ref(false),
mode = ref("cards"), mode = ref("cards"),
layoutOptions = ref(false),
drag = ref(""), drag = ref(""),
message = ref(""); message = ref("");
let owner = ""; let owner = "";
@@ -28,6 +30,7 @@ watch(
page.value = 0; page.value = 0;
arrange.value = false; arrange.value = false;
mode.value = "cards"; mode.value = "cards";
layoutOptions.value = false;
drag.value = ""; drag.value = "";
message.value = ""; message.value = "";
} }
@@ -144,7 +147,8 @@ async function enter(h, network = false) {
+ 新建家庭或场所 + 新建家庭或场所
</button> </button>
</div> </div>
<div class="home-toolbar"> <HomeInsights />
<div class="home-toolbar" :class="{ 'home-options-open': layoutOptions }">
<div class="btn-group" role="group" aria-label="场所显示方式"> <div class="btn-group" role="group" aria-label="场所显示方式">
<button <button
id="home-card-view" id="home-card-view"
@@ -164,7 +168,8 @@ async function enter(h, network = false) {
<AppIcon name="network" /> 整体拓扑 <AppIcon name="network" /> 整体拓扑
</button> </button>
</div> </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 <label
>每页 >每页
<select <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"; } from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue"; import AppIcon from "./AppIcon.vue";
import NetworkStatus from "./NetworkStatus.vue"; import NetworkStatus from "./NetworkStatus.vue";
import NetworkDiagram from "./NetworkDiagram.vue";
const home = computed(() => const home = computed(() =>
(snapshot.value.state.households || []).find( (snapshot.value.state.households || []).find(
(h) => h.id === snapshot.value.state.familyId, (h) => h.id === snapshot.value.state.familyId,
@@ -27,6 +28,8 @@ const home = computed(() =>
<p class="description"> <p class="description">
连线来自已确认的上级设备关系。上级未填写时单独列出;不代表已自动发现所有交换机或布线。 连线来自已确认的上级设备关系。上级未填写时单独列出;不代表已自动发现所有交换机或布线。
</p> </p>
<NetworkDiagram />
<h3 class="network-details-heading">设备与连接详情</h3>
<div class="topology-nodes"> <div class="topology-nodes">
<article <article
v-for="row in deviceView.rows" 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 '../../web/chinese-theme.css'
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import './theme.css' import './theme.css'
import './bootstrap-polish.css'
import App from './App.vue' import App from './App.vue'
import './composables/bridge.js' import './composables/bridge.js'
createApp(App).mount('#app') createApp(App).mount('#app')
+16
View File
@@ -21,6 +21,22 @@ test('Vue theme builds locally after Bootstrap without remote fonts', () => {
assert.doesNotMatch(fs.readFileSync('frontend/src/theme.css','utf8'), /@import|https?:|@font-face/i); assert.doesNotMatch(fs.readFileSync('frontend/src/theme.css','utf8'), /@import|https?:|@font-face/i);
assert.match(fs.readFileSync('frontend/index.html','utf8'), /name="viewport"/); assert.match(fs.readFileSync('frontend/index.html','utf8'), /name="viewport"/);
}); });
test('Bootstrap polish keeps readable light and dark foregrounds and avoids remote assets', () => {
const polish=fs.readFileSync('frontend/src/bootstrap-polish.css','utf8');
const light=polish.match(/:root\s*\{([^}]+)\}/)[1];
const value=name=>light.match(new RegExp('--'+name+':\\s*(#[0-9a-f]{6})'))[1];
for(const bg of ['#ffffff','#f3f6fa','#f5f6f4']) {
assert.ok(contrast(value('ink'),bg)>=4.5);
assert.ok(contrast(value('ui-muted'),bg)>=4.5);
}
for(const bg of [value('ui-accent'),'#397359'])assert.ok(contrast('#ffffff',bg)>=4.5);
assert.ok(contrast('#a8bdc5','#1a2b35')>=4.5);
assert.doesNotMatch(polish,/@import|https?:|@font-face/);
assert.match(polish,/prefers-reduced-motion/);
assert.match(polish,/home-options-open/);
assert.match(polish,/#page-live:is\(:fullscreen,\.wall-expanded\) \.camera-meta \{[^}]+background: #172e35 !important; color: #e8f3ef !important/);
});
test('primary text, muted text and actions have at least 4.5:1 contrast', () => { test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
for (const [fg, bg] of [ for (const [fg, bg] of [
[token('ink'), token('paper')], [token('muted'), token('paper')], [token('ink'), token('paper')], [token('muted'), token('paper')],
+19
View File
@@ -0,0 +1,19 @@
const test=require('node:test'),assert=require('node:assert/strict');
const model=()=>import('./frontend/src/composables/insights.mjs');
test('home summary uses only supplied authorized records and handles empty values',async()=>{
const {summarizeHomes}=await model();
assert.deepEqual(summarizeHomes(),{total:0,devices:0,areas:0,kinds:[]});
const value=summarizeHomes([{kind:'home',deviceCount:4,sites:1},{kind:'office',deviceCount:2,sites:3},{kind:'home',deviceCount:-1}]);
assert.equal(value.devices,6);assert.equal(value.areas,4);assert.equal(value.total,3);
assert.equal(value.kinds.reduce((n,k)=>n+k.count,0),3);
assert.ok(Math.abs(value.kinds.reduce((n,k)=>n+k.share,0)-100)<0.001);
assert.equal(value.kinds[1].offset,value.kinds[0].share);
});
test('network diagram draws only known parent connections',async()=>{
const {layoutNetwork}=await model();
const rows=[{device:{id:'r'},depth:0},{device:{id:'c',uplinkId:'r'},depth:1},{device:{id:'x',uplinkId:'missing'},depth:0},{device:{id:'cycle',uplinkId:'cycle'},depth:0}];
const graph=layoutNetwork(rows);
assert.equal(graph.nodes.length,4);assert.deepEqual(graph.edges.map(e=>e.id),['c']);
assert.ok(graph.nodes.every(n=>n.x>=0&&n.y>=0&&n.x+244<=graph.width&&n.y+88<=graph.height));
assert.deepEqual(layoutNetwork().edges,[]);
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-BbCjyE-W.js"></script> <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-DuGUtT0D.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-r3VkLczt.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-XTjkr4dE.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>