feat: add scoped device catalog and unlimited custom area hierarchy
This commit is contained in:
+18
-20
@@ -2,14 +2,14 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => [...document.querySelectorAll(s)];
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
|
||||
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
|
||||
const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
|
||||
let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
|
||||
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
|
||||
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
|
||||
let householdScope = '';
|
||||
const isPlatformAdmin = () => currentUser?.role === 'admin';
|
||||
const isAdmin = () => ['admin','family_admin'].includes(currentUser?.role);
|
||||
const roleName = user => user.role==='admin'?'超级管理员':user.role==='family_admin'?((state.households||[]).some(h=>h.ownerId===user.id)?'一家之主':'家庭管理员'):'家庭成员';
|
||||
const roleName = user => {const h=(state.households||[]).find(h=>h.id===state.familyId)||{},home=!h.kind||h.kind==='home';return user.role==='admin'?'超级管理员':user.role==='family_admin'?(h.ownerId===user.id?(home?'一家之主':'场所负责人'):(home?'家庭管理员':'场所管理员')):(home?'家庭成员':'场所成员');};
|
||||
function applyPermissions(){
|
||||
const admin=isAdmin();
|
||||
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
|
||||
@@ -26,18 +26,19 @@ function applyPermissions(){
|
||||
}
|
||||
function clearPrivateView(){
|
||||
if(typeof clearSnapshot==='function')clearSnapshot();
|
||||
if(typeof resetDeviceView==='function')resetDeviceView();
|
||||
if(typeof clearCameraSettings==='function')clearCameraSettings();
|
||||
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
|
||||
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
|
||||
currentUser=null;householdScope='';stopPlayers();resetPlayback();
|
||||
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
|
||||
state={cameras:[],assets:[],recorders:[],devices:[],sites:[],storage:{},scan:{}};
|
||||
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list','household-list'])$('#'+id).replaceChildren();
|
||||
if(typeof clearFamilyState==='function')clearFamilyState();
|
||||
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
|
||||
$$('dialog form').forEach(f=>f.reset());
|
||||
activePage='live';showPage('live');applyPermissions();
|
||||
}
|
||||
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
|
||||
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['设备管理','按类型、区域、负责人或所有人,找到每一台设备。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['区域与空间','层级与名称由你定义,设备各有归属。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
|
||||
async function api(path, data) {
|
||||
const generation=sessionGeneration;
|
||||
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:{...(data === undefined ? {} : {'Content-Type':'application/json'}),...(typeof householdScope!=='undefined'&&householdScope?{'X-Household-Id':householdScope}:{})}, body:data === undefined ? undefined : JSON.stringify(data)});
|
||||
@@ -47,9 +48,9 @@ async function api(path, data) {
|
||||
return result;
|
||||
}
|
||||
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||
function siteName(id) { return state.sites.find(s => s.id === id)?.name || '未关联空间'; }
|
||||
function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; }
|
||||
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
|
||||
function filtered() { return state.cameras.filter(c => !$('#site-filter').value || c.siteId === $('#site-filter').value); }
|
||||
function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;return state.cameras.filter(c=>!range||range.has(c.siteId)); }
|
||||
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
|
||||
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
|
||||
function showPage(page) {
|
||||
@@ -61,7 +62,7 @@ function showPage(page) {
|
||||
$('.metrics').hidden=['family','users','households'].includes(page);
|
||||
$('.filter-row').hidden=['family','users','households'].includes(page);
|
||||
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['cameras','recorders'].includes(page)));
|
||||
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
|
||||
if (page !== 'playback') resetPlayback();
|
||||
render();
|
||||
@@ -70,7 +71,7 @@ function showPage(page) {
|
||||
if (page === 'users') loadUsers();
|
||||
}
|
||||
function render() {
|
||||
$('#metric-all').textContent = state.assets.length;
|
||||
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
|
||||
$('#metric-ready').textContent = state.cameras.filter(c=>c.ready).length;
|
||||
$('#metric-sites').textContent = state.sites.length;
|
||||
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
|
||||
@@ -79,7 +80,7 @@ function render() {
|
||||
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
|
||||
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
|
||||
$('#banner').textContent = warning; $('#banner').hidden = !warning;
|
||||
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind) + ' · ' + s.name)}</option>`).join(''));
|
||||
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join(''));
|
||||
if (activePage === 'live') renderLive();
|
||||
if (activePage === 'cameras') renderCameras();
|
||||
if (activePage === 'sites') renderSites();
|
||||
@@ -125,8 +126,8 @@ function renderCameras() {
|
||||
$('#camera-list').innerHTML=cameras.length?`<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>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
|
||||
}
|
||||
function renderSites() {
|
||||
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
}
|
||||
function renderStorage() {
|
||||
const s=state.storage;
|
||||
@@ -136,7 +137,7 @@ function renderStorage() {
|
||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
|
||||
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
|
||||
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
|
||||
function openSite(id) {const f=$('#site-form');f.reset();f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
|
||||
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
|
||||
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
||||
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
|
||||
@@ -154,19 +155,16 @@ window.addEventListener('beforeunload',stopPlayers);
|
||||
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
|
||||
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
|
||||
|
||||
function renderAssets(){
|
||||
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
|
||||
$('#asset-list').innerHTML=assets.length?assets.map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
|
||||
}
|
||||
function renderAssets(){renderDevices();}
|
||||
function renderRecorders(){
|
||||
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
|
||||
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
|
||||
}
|
||||
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
|
||||
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
|
||||
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
|
||||
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
|
||||
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
|
||||
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
|
||||
handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
|
||||
handleForm('asset-form','/api/assets',d=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(d),port:Number(d.port)}));
|
||||
|
||||
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
|
||||
|
||||
|
||||
@@ -248,3 +248,8 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
|
||||
|
||||
.household-actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.household-context select{max-width:100%}.household-context label{min-width:0}
|
||||
|
||||
.device-actions,.device-search-row,.device-group-order,.group-step{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.device-search-row{align-items:end}.device-search-row label{flex:1;min-width:160px}.device-group-order{margin:12px 0}.group-step{padding:7px;background:var(--paper);border:1px solid var(--line);border-radius:5px}.group-step select{width:auto;max-width:145px}.group-step button{padding:7px 11px}.group-step>span{color:var(--muted)}
|
||||
.device-group{margin:26px 0}.device-group>h3{font-family:var(--serif);font-size:20px;overflow-wrap:anywhere}.device-group>h3>span{color:var(--gold);padding:0 8px}.device-group>h3 small{font:12px var(--sans);color:var(--muted);white-space:nowrap}.device-tags{display:flex;flex-wrap:wrap;gap:6px}.device-tags span{font-size:11px;padding:3px 8px;background:var(--paper);border:1px solid var(--line)}.site-card dd,.site-card .description{overflow-wrap:anywhere;min-width:0}
|
||||
@media(max-width:600px){.device-actions{width:100%}.device-actions button{flex:1;white-space:normal}.group-step{width:100%}.group-step select{flex:1;max-width:none;min-width:0}.device-group>h3{font-size:18px}}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.ZhaoDevices=api;})(typeof window!=='undefined'?window:this,function(){
|
||||
'use strict';
|
||||
function path(sites,id){
|
||||
const map=new Map(sites.map(s=>[s.id,s])),seen=new Set(),result=[];
|
||||
while(id&&map.has(id)&&!seen.has(id)){seen.add(id);const s=map.get(id);result.push(s);id=s.parentId;}
|
||||
return result.reverse();
|
||||
}
|
||||
function descendants(sites,id){
|
||||
const children=new Map();for(const s of sites){if(!children.has(s.parentId))children.set(s.parentId,[]);children.get(s.parentId).push(s.id);}
|
||||
const found=new Set(),queue=[id];for(let i=0;i<queue.length;i++){const key=queue[i];if(found.has(key))continue;found.add(key);queue.push(...(children.get(key)||[]));}return found;
|
||||
}
|
||||
function catalog(state){return [
|
||||
...(state.assets||[]).map(x=>({...x,source:'camera',deviceType:'摄像机'})),
|
||||
...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
|
||||
...(state.devices||[]).map(x=>({...x,source:'device'}))];}
|
||||
function groups(devices,dimensions,sites){
|
||||
const result=new Map();
|
||||
for(const device of devices){
|
||||
let branches=[[]];
|
||||
for(const dimension of dimensions){
|
||||
let options;
|
||||
if(dimension==='space'){
|
||||
const parts=path(sites,device.siteId);options=[parts.length?parts.map(s=>({key:'space:'+s.id,label:s.name})):[{key:'space:none',label:'未指定区域'}]];
|
||||
}else{
|
||||
const values=dimension==='tag'?(device.tags?.length?device.tags:['未标记']):[dimension==='type'?device.deviceType:dimension==='manager'?(device.managerName||'负责人待指定'):(device.ownerName||'所有人待指定')];
|
||||
options=[...new Set(values)].map(value=>[{key:dimension+':'+value,label:value}]);
|
||||
}
|
||||
branches=branches.flatMap(branch=>options.map(option=>branch.concat(option)));
|
||||
}
|
||||
for(const branch of branches){const key=JSON.stringify(branch.map(x=>x.key));if(!result.has(key))result.set(key,{key,labels:branch.map(x=>x.label),items:[]});result.get(key).items.push(device);}
|
||||
}
|
||||
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
|
||||
}
|
||||
return {path,descendants,catalog,groups};
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
|
||||
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
|
||||
function renderDeviceGrouping(){
|
||||
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
|
||||
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
|
||||
}
|
||||
function deviceCard(d){
|
||||
const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
|
||||
const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
|
||||
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
|
||||
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span><button class="quiet" data-${attr}="${esc(d.id)}">编辑</button></div><h3>${esc(d.name)}</h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
|
||||
}
|
||||
function renderDevices(){
|
||||
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
|
||||
const signature=JSON.stringify(saved);
|
||||
if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
|
||||
const search=$('#device-search').value.trim().toLowerCase();
|
||||
const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
|
||||
setOptions($('#device-type'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).join(''));
|
||||
const category=$('#device-type').value;
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;
|
||||
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
|
||||
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
|
||||
$('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
|
||||
}
|
||||
function resetDeviceView(){deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
|
||||
function responsibilityFields(form,item){for(const key of ['ownerName','managerName'])form.elements[key].value=item?.[key]||'';form.elements.tags.value=(item?.tags||[]).join('、');}
|
||||
function deviceValues(data){return {...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};}
|
||||
function openDevice(id){
|
||||
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
|
||||
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
|
||||
}
|
||||
function spaceOptions(exclude=''){
|
||||
const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
|
||||
return state.sites.filter(s=>!omitted.has(s.id)).map(s=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).join('');
|
||||
}
|
||||
$('#device-search').oninput=renderDevices;$('#device-type').onchange=renderDevices;
|
||||
$('#device-group-order').addEventListener('change',e=>{if(e.target.dataset.groupIndex!==undefined){deviceGroups[Number(e.target.dataset.groupIndex)]=e.target.value;renderDeviceGrouping();renderDevices();}});
|
||||
$('#device-group-add').onclick=()=>{const next=Object.keys(deviceDimensions).find(k=>!deviceGroups.includes(k));if(next){deviceGroups.push(next);renderDeviceGrouping();renderDevices();}};
|
||||
$('#device-view-save').onclick=async()=>{try{await api('/api/device-view',{groups:deviceGroups});await refresh();toast('已保存为本家庭或场所的默认分组');}catch(e){if(!e.stale)toast(e.message);}};
|
||||
$('#new-device').onclick=()=>openDevice();
|
||||
handleForm('device-form','/api/devices',deviceValues);
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
|
||||
if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
|
||||
if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
|
||||
});
|
||||
+3
-3
@@ -67,7 +67,7 @@ for(const [name,path]of [['person','person'],['relation','link']]){
|
||||
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])}</td><td>${u.role!=='member'?'本家庭全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
|
||||
$('#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])}</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);}
|
||||
}
|
||||
function openUser(id){
|
||||
@@ -75,9 +75,9 @@ function openUser(id){
|
||||
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join('');
|
||||
fillForm(f,u);f.elements.password.required=!u.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(s.name)}</label>`).join('');$('#user-dialog').showModal();
|
||||
$('#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;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;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;}});
|
||||
$('#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();};
|
||||
|
||||
+7
-6
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
pageInfo.households=['家庭管理','一户一家园,资料各有归属。'];
|
||||
pageInfo.households=['家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
|
||||
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
|
||||
function renderHouseholds(){
|
||||
const homes=state.households||[];
|
||||
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(h.name)}</option>`).join(''));
|
||||
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
|
||||
$('#household-filter').value=state.familyId||'';
|
||||
if(activePage!=='households')return;
|
||||
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前家庭':'独立家庭'}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'一户一家园')}</p><dl><div><dt>一家之主</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排家人接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">${h.id===state.familyId?'管理本家庭成员':'进入该家庭'}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">家主与权限交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</article>`).join('');
|
||||
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">${h.id===state.familyId?'管理本归属成员':'进入该归属'}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</article>`).join('');
|
||||
}
|
||||
async function switchHousehold(id){
|
||||
if(!isPlatformAdmin()||!id||id===state.familyId)return;
|
||||
@@ -29,7 +30,7 @@ async function openLeadership(id){
|
||||
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('');
|
||||
f.elements.ownerId.innerHTML='<option value="">请选择一家之主</option>'+options;
|
||||
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-dialog').showModal();
|
||||
@@ -37,14 +38,14 @@ async function openLeadership(id){
|
||||
}
|
||||
$('#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);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;}
|
||||
});
|
||||
$('#household-filter').onchange=e=>switchHousehold(e.target.value);
|
||||
$('#new-household').onclick=()=>openHousehold();
|
||||
$('#household-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{await api('/api/households',formValues(f));f.closest('dialog').close();await refresh();toast('家庭档案已保存');}
|
||||
try{await api('/api/households',formValues(f));f.closest('dialog').close();await refresh();toast('归属档案已保存');}
|
||||
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
|
||||
finally{b.disabled=false;}
|
||||
});
|
||||
|
||||
+19
-18
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
|
||||
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/device-model.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
@@ -16,10 +16,10 @@
|
||||
<button data-page="live" class="active">◫ <span>实时画面</span></button>
|
||||
<button data-page="playback">◷ <span>录像回放</span></button>
|
||||
<button data-page="family">♧ <span>家谱</span></button>
|
||||
<button data-page="households" data-admin>⌂ <span>家庭管理</span></button>
|
||||
<button data-page="households" data-admin>⌂ <span>家庭与场所</span></button>
|
||||
<button data-page="users" data-admin>♙ <span>账号与关系</span></button>
|
||||
<button data-page="objects">▣ <span>摄像头对象</span></button><button data-page="recorders">▤ <span>录像机</span></button><button data-page="cameras">◫ <span>镜头通道</span></button>
|
||||
<button data-page="sites">⌂ <span>空间档案</span></button>
|
||||
<button data-page="objects">▣ <span>设备管理</span></button>
|
||||
<button data-page="sites">⌂ <span>区域与空间</span></button>
|
||||
<button data-page="storage">▤ <span>录像与存储</span></button>
|
||||
</nav>
|
||||
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
|
||||
@@ -27,10 +27,10 @@
|
||||
</aside>
|
||||
<main>
|
||||
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<div id="household-context" class="household-context" hidden><label>当前家庭 <select id="household-filter" aria-label="当前家庭"></select></label><span>设备、录像与家谱按家庭隔离</span></div>
|
||||
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span>家庭、办公场所与工厂独立授权</span></div>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
|
||||
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
|
||||
<section class="metrics"><div><span>设备</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
|
||||
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">可选择任意区域,包含其下级区域</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><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-playback" class="page" hidden>
|
||||
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
|
||||
@@ -40,9 +40,9 @@
|
||||
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
|
||||
</section>
|
||||
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>器物</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device">+ 其他设备</button><button data-page="cameras" class="quiet">镜头通道与接入</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="名称、负责人、所有人、标签"></label></div><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button id="device-view-save" class="secondary">保存为本归属的默认视图</button></div><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
|
||||
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>家宅</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
<section id="page-family" class="page" hidden>
|
||||
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
|
||||
@@ -65,18 +65,19 @@
|
||||
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
|
||||
</div>
|
||||
</section>
|
||||
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有家园 <small>家宅 · 归属</small></h2><button id="new-household">+ 新建家庭</button></div><p class="description">每个家庭独立管理自己的成员、空间、设备和家谱。家庭管理员只管理本家庭;超级管理员负责建立家庭与分配管理账号。</p><div id="household-list" class="site-grid"></div></section>
|
||||
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
|
||||
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div id="household-list" class="site-grid"></div></section>
|
||||
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
|
||||
<footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||
</main>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
||||
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></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="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><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="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
||||
<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>设备用户名<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">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</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="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></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></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="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"><div class="form-grid"><label class="full">家庭名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、王家"></label><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="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"><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>
|
||||
<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="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><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>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><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="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"><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><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="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>
|
||||
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
|
||||
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
|
||||
|
||||
Reference in New Issue
Block a user