Compare commits

..

3 Commits

32 changed files with 609 additions and 31 deletions
+20
View File
@@ -0,0 +1,20 @@
# v0.1.36 — 负责人选择与手机监控体验
- 负责人安排可以选择本家庭已启用的普通成员或管理员。选择普通成员时,需要明确勾选授予本家庭管理权限;主要与备用负责人不能是同一人。
- 没有可选成员时显示原因,提供“添加本家庭管理员”和“管理本家庭成员”入口。新增管理员后返回负责人安排,保留未提交内容。
- 负责人授权、会话失效与交接记录原子保存。保留家庭范围隔离、操作者权限检查和版本冲突检查,不自动指定真实用户。
- 手机监控页优先显示画面,收起说明,压缩顶部信息。自动布局在手机上使用单列,明确选择的多画面布局继续保留。
- 全屏支持网页全屏回退,单镜头占满画面。退出全屏或离开监控页后恢复页面滚动,继续复用播放器。
- 调整手机端网络状态、管理后台链接和负责人表单,使长地址换行、按钮可触控、弹窗可以正常滚动。
## 验证
- 169 项 Python、135 项 JavaScript 回归测试通过,Vite 生产构建通过。
- Edge 隔离测试覆盖普通成员选择及显式授权、保存后回填、空家庭提示及新增管理员入口,检查 390 像素手机弹窗。
- 部署前检查实际跨站点摄像机:主码流同一播放器连续播放超过六分钟;现场路由重启后子网配置保留,子码流无需刷新自动恢复。
## 网络配置说明
GL.iNet 自带 Tailscale 管理程序会根据路由器的持久配置重新生成启动参数。仅临时设置子网路由可能被服务重启覆盖;应在路由器的 Tailscale 设置中启用所需 LAN 访问,再确认控制台已批准对应子网。参考 [GL.iNet 官方说明](https://docs.gl-inet.com/router/en/4/interface_guide/tailscale/)。本次已在授权设备上修复该配置,应用不会自动更改其他路由器。
公开源码不包含用户数据库、家庭资料或设备凭据。
+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.35
0.1.37
+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",
+2 -1
View File
@@ -5,7 +5,8 @@ import AppHeader from './components/AppHeader.vue'
import Workspace from './components/Workspace.vue'
import RuntimeOverlays from './components/RuntimeOverlays.vue'
import {loadControllers} from './compat/load.js'
import {snapshot} from './composables/bridge.js'
const failed=ref(false)
onMounted(async()=>{try{await loadControllers()}catch(error){console.error('Application startup failed',error);failed.value=true}})
</script>
<template><AppSidebar/><main><AppHeader/><div v-if="failed" class="alert alert-danger" role="alert">页面组件加载失败,请检查网络后重新打开页面。</div><Workspace/></main><RuntimeOverlays/></template>
<template><AppSidebar/><main :data-page="snapshot.page"><AppHeader/><div v-if="failed" class="alert alert-danger" role="alert">页面组件加载失败,请检查网络后重新打开页面。</div><Workspace/></main><RuntimeOverlays/></template>
+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(){
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.35';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"
+1 -1
View File
@@ -7,7 +7,7 @@
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>ONVIF 服务端口<input name="onvifPort" type="number" value="80" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="1" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">选择本家庭 / 场所的成员,分别指定主要和备用负责人。主要负责人即一家之主;选择普通成员时,需明确确认授予其本家庭管理权限。备用负责人可稍后安排。</p><p id="leadership-scope" class="fw-semibold"></p><div id="leadership-empty" class="alert alert-info" role="status" hidden>本家庭 / 场所还没有可选成员。请先添加本家庭管理员,保存账号后会自动返回这里。其他家庭的账号不会混入此列表。</div><div class="leadership-member-actions d-flex flex-wrap gap-2 mb-3"><button type="button" id="leadership-new" class="secondary">+ 添加本家庭管理员</button><button type="button" id="leadership-members" class="quiet">管理本家庭成员</button></div><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label id="leadership-grant" class="check full alert alert-warning" hidden><input type="checkbox" name="grantManagement"><span id="leadership-grant-note"></span></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><input name="profileRevision" type="hidden" value="0"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><fieldset class="full geo-picker" data-geography="" data-geo-title="所在地"></fieldset><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-cancel" class="quiet">取消本次图片选择</button><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description" role="status" aria-live="polite">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<datalist id="relationship-choices"></datalist>
+1 -1
View File
@@ -30,7 +30,7 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
</section>
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><DeviceNetworkPanel /><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label></div></div><details class="live-help"><summary>画面与清晰度说明</summary><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p></details><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像(待接入)</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
+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')
+53
View File
@@ -1872,6 +1872,59 @@ html[data-font="serif"] .scope-intro h2 {
font-family: "Noto Serif SC", "Songti SC", "SimSun", serif !important;
font-weight: 600 !important;
}
.live-help { margin: 10px 0; color: var(--ui-muted); font-size: 13px; }
.live-help summary { cursor: pointer; padding: 8px 0; }
.live-help p { margin: 8px 0; }
#page-live.wall-expanded { position: fixed; inset: 0; z-index: 10000; margin: 0; }
#page-live:is(:fullscreen,.wall-expanded) > .live-help { display: none; }
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-visible-count="1"] {
grid-template-columns: minmax(0,1fr); grid-template-rows: minmax(0,1fr);
}
.leadership-member-actions .btn { white-space: normal; }
#leadership-grant { align-items: flex-start; padding: 14px; }
#leadership-grant span { line-height: 1.7; }
@media (max-width: 767px) {
#app > main { padding-bottom: max(20px,env(safe-area-inset-bottom)) !important; }
main[data-page="live"] > header { margin-bottom: 12px !important; padding-bottom: 12px !important; }
main[data-page="live"] > header .eyebrow,
main[data-page="live"] #page-note,
main[data-page="live"] > .metrics { display: none !important; }
main[data-page="live"] .household-context { margin-bottom: 12px; flex-wrap: nowrap; }
main[data-page="live"] .household-context label { flex: 1; width: auto; font-size: 0; }
main[data-page="live"] .household-context select { font-size: 16px; width: 100%; }
main[data-page="live"] #scope-back { width: auto; min-width: 82px; }
main[data-page="live"] > .filter-row { margin: 12px 0; }
#page-live:not([hidden]) { display: flex; flex-direction: column; }
#page-live > .section-head { order: 0; margin: 6px 0 12px; }
#page-live > .live-grid { order: 1; }
#page-live > .wall-toolbar { order: 2; }
#page-live > .live-help { order: 3; }
#page-live:not(:fullscreen):not(.wall-expanded) .live-grid[data-layout="auto"] { grid-template-columns: minmax(0,1fr); }
.camera-actions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); padding: 0 12px 12px; }
.camera-actions .btn { min-height: 44px; white-space: normal; font-size: 13px; }
.camera-meta { padding: 12px; }
.camera-meta strong { font-size: 15px !important; }
.wall-toolbar .wall-paging { flex: 1; justify-content: space-between; }
.wall-toolbar .btn { min-width: 44px; min-height: 44px; }
#page-live:is(:fullscreen,.wall-expanded) { padding: max(8px,env(safe-area-inset-top)) max(8px,env(safe-area-inset-right)) max(8px,env(safe-area-inset-bottom)) max(8px,env(safe-area-inset-left)); }
#page-live:is(:fullscreen,.wall-expanded) > .wall-toolbar { order: 0; }
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="auto"] { grid-template-columns: repeat(2,minmax(0,1fr)); }
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-visible-count="1"] { grid-template-columns: minmax(0,1fr); grid-template-rows: minmax(0,1fr); }
#page-live:is(:fullscreen,.wall-expanded) .camera-actions { display: none; }
.network-target { display: grid; grid-template-columns: 76px minmax(0,1fr) auto; font-size: 12px; gap: 8px; }
.network-target code { font-size: 13px !important; }
.network-check-time { font-size: 12px; line-height: 1.7; }
.management-links { display: grid; grid-template-columns: minmax(0,1fr); }
.management-links .btn { min-height: 44px !important; font-size: 13px !important; white-space: normal; }
dialog .description,dialog label { font-size: 14px !important; }
dialog .dialog-footer { position: sticky; bottom: -20px; background: var(--ui-surface); padding: 14px 0; margin-bottom: -10px; }
dialog .dialog-footer .btn { flex: 1; min-height: 44px; white-space: normal; }
.leadership-member-actions .btn { flex: 1; min-width: 120px; }
#leadership-grant { display: flex; gap: 10px; }
#leadership-grant[hidden] { display: none; }
}
html[data-backdrop="mountains"] #app > main {
background-image:
linear-gradient(150deg, transparent 70%, #8ba58f08 70%, transparent 85%),
+18 -4
View File
@@ -165,16 +165,30 @@ class Households:
reason = a.clean_text(data.get('reason', ''), 300, True)
if owner_id == successor_id:
raise a.Problem('备用负责人必须是另一位家庭管理员')
grant = data.get('grantManagement', False)
if type(grant) is not bool:
raise a.Problem('管理员授权确认格式不正确')
promotions = []
for uid in filter(None, (owner_id, successor_id)):
account = self.owns(user, a.get_object('users', uid))
if account['role'] != 'family_admin' or account['disabled']:
raise a.Problem('负责人须为本家庭已启用的家庭管理员')
if account['disabled'] or account['role'] not in ('family_admin', 'member'):
raise a.Problem('请选择本家庭已启用的成员或家庭管理员')
if account['role'] == 'member':
if not grant:
raise a.Problem('所选成员还不是家庭管理员,请确认授予本家庭管理权限')
promotions.append(dict(account, role='family_admin'))
record = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), actor=user['username'], reason=reason,
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id)
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id,
promotedUserIds=[u['id'] for u in promotions])
home.update(ownerId=owner_id, successorId=successor_id,
leadershipRevision=home.get('leadershipRevision', 0) + 1,
leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:])
a.save_object('households', home)
# Validate both choices first, then commit role grants and succession together.
with a.DB:
for account in promotions:
a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(account), account['id']))
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (account['id'],))
a.DB.execute('UPDATE households SET body=? WHERE id=?', (json.dumps(home), home['id']))
a.audit('家庭负责人交接', user['username'] + ' / ' + home['name'])
return {'ok': True}
+22
View File
@@ -207,6 +207,28 @@ class HouseholdTests(unittest.TestCase):
self.assertEqual(saved,user)
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')])
def test_leadership_member_requires_explicit_grant_and_revokes_old_session(self):
member=self.members[self.aid]
self.assertEqual(self.leadership(ownerId=member['id'],successorId='')[0],400)
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
self.assertEqual(self.leadership(ownerId=member['id'],successorId='',grantManagement='true')[0],400)
self.assertEqual(self.leadership(ownerId=member['id'],successorId='',grantManagement=True)[0],200)
self.assertEqual(app.get_object('users',member['id'])['role'],'family_admin')
home=app.get_object('households',self.aid)
self.assertEqual(home['ownerId'],member['id'])
self.assertEqual(home['leadershipHistory'][-1]['promotedUserIds'],[member['id']])
self.assertEqual(app.DB.execute('SELECT count(*) FROM sessions WHERE user_id=?',(member['id'],)).fetchone()[0],0)
def test_leadership_grant_does_not_bypass_scope_or_partial_validation(self):
member=self.members[self.aid]
self.assertEqual(self.leadership(ownerId=member['id'],successorId=self.other['id'],grantManagement=True)[0],404)
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
self.assertEqual(app.get_object('households',self.aid).get('leadershipRevision',0),0)
disabled=dict(self.backup,disabled=True);app.save_object('users',disabled)
self.assertEqual(self.leadership(ownerId=member['id'],grantManagement=True)[0],400)
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
self.assertEqual(self.leadership(user=member,ownerId=member['id'],successorId='',grantManagement=True)[0],403)
def test_office_factory_and_custom_grouping_are_independent(self):
for kind in ('office','factory'):
code, scope=self.request('/api/households',self.root,dict(name=kind,kind=kind))
+33
View File
@@ -0,0 +1,33 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const source=fs.readFileSync('web/households.js','utf8');
test('leadership choices include enabled local members and exclude unrelated or global accounts',()=>{
const context=vm.createContext({});
vm.runInContext(source.slice(source.indexOf('function leadershipCandidates('),source.indexOf('function syncLeadershipSelection(')),context);
const rows=[{id:'a',familyId:'home',role:'member'},{id:'b',familyId:'home',role:'family_admin'},{id:'c',familyId:'home',role:'admin'},{id:'d',familyId:'else',role:'family_admin'},{id:'e',familyId:'home',role:'member',disabled:true}];
assert.equal(JSON.stringify(context.leadershipCandidates(rows,'home').map(x=>x.id)),JSON.stringify(['a','b']));
});
test('empty leadership choices have a member creation path and explicit grant control',()=>{
const markup=fs.readFileSync('frontend/src/components/RuntimeOverlays.vue','utf8');
for(const id of ['leadership-empty','leadership-scope','leadership-new','leadership-members','leadership-grant'])assert.ok(markup.includes(`id="${id}"`));
assert.match(source,/grantManagement=f\.elements\.grantManagement\.checked/);
assert.match(source,/disabled=!leadershipUsers.length/);
assert.match(source,/grantManagement\.checked=false/);
const family=fs.readFileSync('web/family.js','utf8');assert.match(family,/await openLeadership\(returnTo\)/);assert.match(family,/leadershipReturnDraft/);
});
function fullscreenFixture(native){
const classes=new Set(),stage={classList:{contains:k=>classes.has(k),add:k=>classes.add(k),remove:k=>classes.delete(k)}};
if(native)stage.requestFullscreen=async()=>{document.fullscreenElement=stage};
const button={setAttribute(k,v){this[k]=v}},document={fullscreenElement:null,body:{style:{overflow:'auto'},classList:{toggle(){}}},addEventListener(){},exitFullscreen:async()=>{document.fullscreenElement=null}};
const context=vm.createContext({document,$:id=>id==='#page-live'?stage:button});
const monitor=fs.readFileSync('web/monitor.js','utf8');
vm.runInContext(monitor.slice(monitor.indexOf("let wallBodyOverflow="),monitor.indexOf('setInterval(')),context);
return {context,document,stage,button,classes};
}
test('phone fullscreen falls back to viewport and restores scroll on exit',async()=>{
const f=fullscreenFixture(false);await f.button.onclick();assert.ok(f.classes.has('wall-expanded'));assert.equal(f.document.body.style.overflow,'hidden');assert.equal(f.button['aria-pressed'],'true');
f.context.closeWallFullscreen();assert.equal(f.classes.size,0);assert.equal(f.document.body.style.overflow,'auto');assert.equal(f.button['aria-pressed'],'false');
});
test('native fullscreen remains supported and exits without rebuilding video',async()=>{
const f=fullscreenFixture(true);await f.button.onclick();assert.equal(f.document.fullscreenElement,f.stage);assert.equal(f.classes.size,0);await f.button.onclick();assert.equal(f.document.fullscreenElement,null);
const app=fs.readFileSync('web/app.js','utf8');assert.match(app,/page!=='live'.*closeWallFullscreen\(\)/);
});
+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.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', () => {
for (const [fg, bg] of [
[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,[]);
});
+1
View File
@@ -71,6 +71,7 @@ function showPage(page, options={}) {
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
if(page!=='live'&&typeof closeWallFullscreen==='function')closeWallFullscreen();
const pageChanged=activePage!==page;activePage = page;
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal','device'].includes(page);
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
+3 -2
View File
@@ -70,7 +70,8 @@ async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id;
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱 / 传承资料</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}<small>文化与财产:${u.role!=='member'?'管理':({none:'不可访问',read:'查看',edit:'维护'}[u.heritageAccess||'none'])}</small></td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
}catch(e){toast(e.message);}
return true;
}catch(e){if(!e.stale)toast(e.message);return false;}
}
function openUser(id){
if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{};
@@ -79,7 +80,7 @@ function openUser(id){
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(siteName(s.id))}</label>`).join('');$('#user-dialog').showModal();
}
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;const saved=await api('/api/users',d),returnTo=leadershipReturnHome;leadershipReturnHome='';f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();if(returnTo&&loggedIn&&state.familyId===returnTo){await openLeadership(returnTo);const lf=$('#leadership-form');if(leadershipReturnDraft){for(const key of ['ownerId','successorId','reason'])lf.elements[key].value=leadershipReturnDraft[key]||'';leadershipReturnDraft=null;}if(!lf.elements.ownerId.value)lf.elements.ownerId.value=saved.id;else if(!lf.elements.successorId.value&&lf.elements.ownerId.value!==saved.id)lf.elements.successorId.value=saved.id;syncLeadershipSelection();}toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily;
$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
+22 -3
View File
@@ -66,25 +66,44 @@ function openHousehold(id){
if(!item&&!isPlatformAdmin())return;
nextImageJob('cover');$('#household-cover-status').dataset.error='false';fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;ZhaoGeography.fill($('#household-form'),item||{});$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='封面存入百度网盘,原图可选 30 MB 以内;浏览器会先压缩。仅向获授权账号开放。';$('#household-dialog').showModal();
}
let leadershipUsers=[],leadershipReturnHome='',leadershipReturnDraft=null;
function leadershipCandidates(rows,familyId){return rows.filter(u=>u.familyId===familyId&&!u.disabled&&['family_admin','member'].includes(u.role));}
function syncLeadershipSelection(){
const f=$('#leadership-form'),selected=[f.elements.ownerId.value,f.elements.successorId.value];
for(const option of f.elements.successorId.options)option.disabled=!!option.value&&option.value===selected[0];
if(selected[0]&&selected[0]===selected[1])f.elements.successorId.value='';
const grants=leadershipUsers.filter(u=>[f.elements.ownerId.value,f.elements.successorId.value].includes(u.id)&&u.role==='member');
$('#leadership-grant').hidden=!grants.length;f.elements.grantManagement.required=!!grants.length;
$('#leadership-grant-note').textContent=grants.length?'确认将 '+grants.map(u=>u.name).join('、')+' 设为本家庭 / 场所管理员,可管理其全部成员、区域和设备。':'';
f.querySelector('[type=submit]').disabled=!leadershipUsers.length;
}
async function openLeadership(id){
try{
if(id!==state.familyId&&!await switchHousehold(id))return;
const home=(state.households||[]).find(h=>h.id===id);
if(!home?.canAssignOwner)return;
const result=await api('/api/users');
const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled);
const f=$('#leadership-form'),options=candidates.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username)}</option>`).join('');
leadershipUsers=leadershipCandidates(result.users,id);
const f=$('#leadership-form'),options=leadershipUsers.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username+' · '+(u.role==='family_admin'?'管理员':'成员(需授权)'))}</option>`).join('');
f.elements.ownerId.innerHTML='<option value="">请选择主要负责人</option>'+options;
f.elements.successorId.innerHTML='<option value="">暂未指定</option>'+options;
fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''});
$('#leadership-scope').textContent='当前家庭 / 场所:'+home.name;
$('#leadership-empty').hidden=!!leadershipUsers.length;
f.elements.ownerId.disabled=!leadershipUsers.length;f.elements.successorId.disabled=!leadershipUsers.length;
f.elements.grantManagement.checked=false;syncLeadershipSelection();
$('#leadership-dialog').showModal();
}catch(error){if(!error.stale)toast(error.message);}
}
$('#leadership-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
try{const d=formValues(f);d.revision=Number(d.revision);d.grantManagement=f.elements.grantManagement.checked;await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
});
for(const name of ['ownerId','successorId'])$('#leadership-form').elements[name].addEventListener('change',()=>{$('#leadership-form').elements.grantManagement.checked=false;syncLeadershipSelection();});
$('#leadership-members').onclick=()=>{$('#leadership-dialog').close();showPage('users');};
$('#leadership-new').onclick=async()=>{const id=state.familyId;leadershipReturnDraft=formValues($('#leadership-form'));$('#leadership-dialog').close();if(!await loadUsers()||state.familyId!==id||!loggedIn)return;openUser();$('#user-form').elements.role.value='family_admin';leadershipReturnHome=id;};
$('#user-dialog').addEventListener('close',()=>{leadershipReturnHome='';});
$('#household-filter').onchange=async e=>{const switched=await switchHousehold(e.target.value);if(switched&&loggedIn)showPage('overview');else e.target.value=state.familyId;};
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
+21 -7
View File
@@ -16,6 +16,7 @@ function wallSync(){
const capacity=Number(layout)||6,pages=Math.max(1,Math.ceil(ordered.length/capacity));
wallPage=Math.min(wallPage,pages-1);const visible=new Set(ordered.slice(wallPage*capacity,(wallPage+1)*capacity));
$('#live-grid').dataset.layout=layout;
$('#live-grid').dataset.visibleCount=String(visible.size);
for(const card of $$('#live-grid .camera-card')){
const id=card.querySelector('video').dataset.camera;
card.style.order=ordered.indexOf(id);card.hidden=!visible.has(id);
@@ -34,16 +35,29 @@ function wallMove(id,target){
$('#wall-layout').onchange=()=>{wallPage=0;wallSave();wallSync();};
$('#wall-arrange').onchange=wallSync;
$('#wall-prev').onclick=()=>{wallPage--;wallSync();};$('#wall-next').onclick=()=>{wallPage++;wallSync();};
let wallBodyOverflow='';
function syncWallFullscreen(){
const active=document.fullscreenElement===$('#page-live')||$('#page-live').classList.contains('wall-expanded');
$('#wall-fullscreen').textContent=active?'退出全屏':'全屏监控';
$('#wall-fullscreen').setAttribute('aria-pressed',String(active));
document.body.classList.toggle('wall-fullscreen',active);
}
function closeWallFullscreen(){
const stage=$('#page-live');
if(stage.classList.contains('wall-expanded')){stage.classList.remove('wall-expanded');document.body.style.overflow=wallBodyOverflow;}
if(document.fullscreenElement===stage)document.exitFullscreen().catch(()=>{});
syncWallFullscreen();
}
$('#wall-fullscreen').onclick=async()=>{
try{if(document.fullscreenElement)await document.exitFullscreen();else await $('#page-live').requestFullscreen();}
catch{toast('浏览器未允许全屏,可按 F11 进入浏览器全屏');}
const stage=$('#page-live');
if(document.fullscreenElement===stage||stage.classList.contains('wall-expanded')){closeWallFullscreen();return;}
try{await stage.requestFullscreen();}
catch{wallBodyOverflow=document.body.style.overflow;stage.classList.add('wall-expanded');document.body.style.overflow='hidden';}
syncWallFullscreen();
};
document.addEventListener('fullscreenchange',()=>{
$('#wall-fullscreen').textContent=document.fullscreenElement?'退出全屏 · Esc':'全屏监控';
document.body.classList.toggle('wall-fullscreen',document.fullscreenElement===$('#page-live'));
});
document.addEventListener('fullscreenchange',syncWallFullscreen);
document.addEventListener('keydown',e=>{
if(e.key==='Escape'&&document.fullscreenElement===$('#page-live'))document.exitFullscreen().catch(()=>{});
if(e.key==='Escape')closeWallFullscreen();
});
setInterval(()=>{$('#wall-clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
let wallPointer=null;
+3 -3
View File
@@ -8,8 +8,8 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
@media(max-width:760px){.sidebar #logout{display:block;align-self:flex-end}.sidebar #current-user{position:absolute;right:130px;top:15px;font-size:10px}.form-grid{grid-template-columns:1fr}.family-tree ul{padding-left:12px}}
#family-detail dl{display:grid;grid-template-columns:1fr 1fr;gap:14px 28px;margin:20px 0 26px}#family-detail dl>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-bottom:1px solid var(--line);padding-bottom:10px}#family-detail dt{font-size:12px;color:var(--muted)}#family-detail dd{margin:0;font-size:13px;overflow-wrap:anywhere}@media(max-width:1000px){#family-detail dl{grid-template-columns:1fr}}
.brand-icon{width:44px;height:44px;object-fit:cover;border-radius:12px;flex-shrink:0}.wall-toolbar{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin:16px 0}.wall-toolbar select{padding:8px}.wall-paging{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}#wall-clock{margin-left:auto;color:var(--muted);font-size:12px}.live-grid[data-layout="1"]{grid-template-columns:1fr}.live-grid[data-layout="2"],.live-grid[data-layout="4"]{grid-template-columns:repeat(2,minmax(0,1fr))}.live-grid[data-layout="6"],.live-grid[data-layout="9"]{grid-template-columns:repeat(3,minmax(0,1fr))}.live-grid[data-layout="16"]{grid-template-columns:repeat(4,minmax(0,1fr))}.wall-reorder{display:none;padding:8px 12px;align-items:center;gap:8px}.arranging .wall-reorder{display:flex}.wall-reorder span{cursor:grab;margin-right:auto;font-size:12px;color:var(--muted)}.wall-reorder button{padding:4px 9px;font-size:11px}#page-live:fullscreen{background:#070f12;padding:12px;width:100vw;height:100dvh;display:flex;flex-direction:column;overflow:hidden}#page-live:fullscreen>.section-head,#page-live:fullscreen>#quality-note{display:none}#page-live:fullscreen .wall-toolbar{margin:0 0 10px;flex-shrink:0}#page-live:fullscreen .live-grid{flex:1;min-height:0;gap:8px;grid-template-rows:repeat(2,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="1"],#page-live:fullscreen .live-grid[data-layout="2"]{grid-template-rows:1fr}#page-live:fullscreen .live-grid[data-layout="9"]{grid-template-rows:repeat(3,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="16"]{grid-template-rows:repeat(4,minmax(0,1fr))}#page-live:fullscreen .camera-card{display:flex;flex-direction:column;min-height:0;position:relative}#page-live:fullscreen .video-panel{flex:1;min-height:0;aspect-ratio:auto}#page-live:fullscreen .camera-meta{padding:7px 10px;align-items:center}#page-live:fullscreen .camera-meta p,#page-live:fullscreen .camera-meta .badge{display:none}#page-live:fullscreen .camera-meta strong{font-size:12px}#page-live:fullscreen .camera-actions{position:absolute;bottom:34px;right:6px;padding:4px;background:#10191ddd;border-radius:6px;opacity:0}#page-live:fullscreen .camera-card:hover .camera-actions,#page-live:fullscreen .camera-card:focus-within .camera-actions{opacity:1}#page-live:fullscreen .camera-actions [data-edit-camera],#page-live:fullscreen .camera-actions [data-play-camera]{display:none}.ptz-panel{background:#1a3038;border:1px solid #41656c;border-radius:12px;padding:16px;display:flex;align-items:center;gap:22px;position:fixed;bottom:22px;right:22px;z-index:4;max-width:calc(100vw - 32px);box-shadow:0 12px 50px #0008}.ptz-panel p{font-size:12px;color:var(--muted);max-width:320px;margin:8px 0 0}.ptz-pad{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);gap:4px}.ptz-pad button{padding:5px;font-size:17px}.ptz-pad [data-ptz-action="up"]{grid-column:2}.ptz-pad [data-ptz-action="left"]{grid-column:1;grid-row:2}.ptz-pad [data-ptz-action="stop"]{grid-column:2;grid-row:2;background:#f0bda6;font-size:12px}.ptz-pad [data-ptz-action="right"]{grid-column:3;grid-row:2}.ptz-pad [data-ptz-action="down"]{grid-column:2;grid-row:3}.family-date{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0;min-width:0}.family-date legend{font-size:13px;color:#a5bec1}.family-date label{margin-bottom:10px}.family-date output{display:block;font-size:12px;line-height:1.7;color:var(--accent)}@media(max-width:760px){.wall-toolbar{gap:9px}#wall-clock{display:none}.camera-meta strong{font-size:12px}.camera-actions{flex-wrap:wrap;padding:0 8px 8px}.camera-meta{padding:9px}.ptz-panel{flex-wrap:wrap;gap:12px}.ptz-panel>div:first-child{flex:1;min-width:160px}.family-date{grid-column:1/-1}#page-live:fullscreen .camera-actions{opacity:1}.live-grid[data-layout="16"] .camera-meta strong{font-size:9px}}
#page-live:fullscreen .live-grid[data-layout="auto"]{grid-template-columns:repeat(3,minmax(0,1fr))}
.brand-icon{width:44px;height:44px;object-fit:cover;border-radius:12px;flex-shrink:0}.wall-toolbar{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin:16px 0}.wall-toolbar select{padding:8px}.wall-paging{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}#wall-clock{margin-left:auto;color:var(--muted);font-size:12px}.live-grid[data-layout="1"]{grid-template-columns:1fr}.live-grid[data-layout="2"],.live-grid[data-layout="4"]{grid-template-columns:repeat(2,minmax(0,1fr))}.live-grid[data-layout="6"],.live-grid[data-layout="9"]{grid-template-columns:repeat(3,minmax(0,1fr))}.live-grid[data-layout="16"]{grid-template-columns:repeat(4,minmax(0,1fr))}.wall-reorder{display:none;padding:8px 12px;align-items:center;gap:8px}.arranging .wall-reorder{display:flex}.wall-reorder span{cursor:grab;margin-right:auto;font-size:12px;color:var(--muted)}.wall-reorder button{padding:4px 9px;font-size:11px}#page-live:is(:fullscreen,.wall-expanded){background:#070f12;padding:12px;width:100vw;height:100dvh;display:flex;flex-direction:column;overflow:hidden}#page-live:is(:fullscreen,.wall-expanded)>.section-head,#page-live:is(:fullscreen,.wall-expanded)>#quality-note{display:none}#page-live:is(:fullscreen,.wall-expanded) .wall-toolbar{margin:0 0 10px;flex-shrink:0}#page-live:is(:fullscreen,.wall-expanded) .live-grid{flex:1;min-height:0;gap:8px;grid-template-rows:repeat(2,minmax(0,1fr))}#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="1"],#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="2"]{grid-template-rows:1fr}#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="9"]{grid-template-rows:repeat(3,minmax(0,1fr))}#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="16"]{grid-template-rows:repeat(4,minmax(0,1fr))}#page-live:is(:fullscreen,.wall-expanded) .camera-card{display:flex;flex-direction:column;min-height:0;position:relative}#page-live:is(:fullscreen,.wall-expanded) .video-panel{flex:1;min-height:0;aspect-ratio:auto}#page-live:is(:fullscreen,.wall-expanded) .camera-meta{padding:7px 10px;align-items:center}#page-live:is(:fullscreen,.wall-expanded) .camera-meta p,#page-live:is(:fullscreen,.wall-expanded) .camera-meta .badge{display:none}#page-live:is(:fullscreen,.wall-expanded) .camera-meta strong{font-size:12px}#page-live:is(:fullscreen,.wall-expanded) .camera-actions{position:absolute;bottom:34px;right:6px;padding:4px;background:#10191ddd;border-radius:6px;opacity:0}#page-live:is(:fullscreen,.wall-expanded) .camera-card:hover .camera-actions,#page-live:is(:fullscreen,.wall-expanded) .camera-card:focus-within .camera-actions{opacity:1}#page-live:is(:fullscreen,.wall-expanded) .camera-actions [data-edit-camera],#page-live:is(:fullscreen,.wall-expanded) .camera-actions [data-play-camera]{display:none}.ptz-panel{background:#1a3038;border:1px solid #41656c;border-radius:12px;padding:16px;display:flex;align-items:center;gap:22px;position:fixed;bottom:22px;right:22px;z-index:4;max-width:calc(100vw - 32px);box-shadow:0 12px 50px #0008}.ptz-panel p{font-size:12px;color:var(--muted);max-width:320px;margin:8px 0 0}.ptz-pad{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);gap:4px}.ptz-pad button{padding:5px;font-size:17px}.ptz-pad [data-ptz-action="up"]{grid-column:2}.ptz-pad [data-ptz-action="left"]{grid-column:1;grid-row:2}.ptz-pad [data-ptz-action="stop"]{grid-column:2;grid-row:2;background:#f0bda6;font-size:12px}.ptz-pad [data-ptz-action="right"]{grid-column:3;grid-row:2}.ptz-pad [data-ptz-action="down"]{grid-column:2;grid-row:3}.family-date{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0;min-width:0}.family-date legend{font-size:13px;color:#a5bec1}.family-date label{margin-bottom:10px}.family-date output{display:block;font-size:12px;line-height:1.7;color:var(--accent)}@media(max-width:760px){.wall-toolbar{gap:9px}#wall-clock{display:none}.camera-meta strong{font-size:12px}.camera-actions{flex-wrap:wrap;padding:0 8px 8px}.camera-meta{padding:9px}.ptz-panel{flex-wrap:wrap;gap:12px}.ptz-panel>div:first-child{flex:1;min-width:160px}.family-date{grid-column:1/-1}#page-live:is(:fullscreen,.wall-expanded) .camera-actions{opacity:1}.live-grid[data-layout="16"] .camera-meta strong{font-size:9px}}
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="auto"]{grid-template-columns:repeat(3,minmax(0,1fr))}
.wall-reorder [data-wall-drag]{touch-action:none;user-select:none;padding:5px 0}.camera-card.wall-drop-target{outline:2px solid var(--accent);outline-offset:-3px}
#camera-settings-dialog{width:min(1060px,94vw)}
@@ -19,7 +19,7 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
#camera-image-fields,#camera-video-fields{grid-template-columns:repeat(3,minmax(0,1fr))}
#camera-settings-status:empty{display:none}
#camera-settings-dialog details{margin-top:20px;color:var(--muted);font-size:13px;line-height:1.8}
#page-live:fullscreen .camera-actions [data-camera-settings]{display:none}
#page-live:is(:fullscreen,.wall-expanded) .camera-actions [data-camera-settings]{display:none}
@media(max-width:700px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr 1fr}}
@media(max-width:420px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr}}
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
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-YWYfmYO_.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-DxbM73EL.css">
<!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-XTjkr4dE.css">
</head><body><div id="app"></div></body></html>