fix: household leader selection and mobile monitoring experience v0.1.36
This commit is contained in:
@@ -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/)。本次已在授权设备上修复该配置,应用不会自动更改其他路由器。
|
||||
|
||||
公开源码不包含用户数据库、家庭资料或设备凭据。
|
||||
@@ -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>
|
||||
|
||||
@@ -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.36';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
|
||||
await window.ZhaoVue.boot()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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-BbCjyE-W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-r3VkLczt.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
|
||||
Reference in New Issue
Block a user