Files
ucvl-home-vision/web/devices.js
T

100 lines
14 KiB
JavaScript
Raw Normal View History

'use strict';
let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
let devicePresentation='cards';
const networkLabels={unknown:'接入线路待确认',lan:'局域网直连',tailscale:'Tailscale 跨网',public:'公网接入'};
function networkInfo(d){const n=ZhaoDevices.network(d,state);return `<div><dt>IP/域名</dt><dd>${n.hosts.length?n.hosts.map(esc).join('<br>'):'待填写'}</dd></div><div><dt>网络接入</dt><dd>${esc(networkLabels[n.access]||networkLabels.unknown)}${d.tailscaleHost?'<br>'+esc(d.tailscaleHost):''}</dd></div>`;}
function renderTopology(devices){
const tree=ZhaoDevices.topology(devices),all=ZhaoDevices.catalog(state),label=id=>all.find(d=>d.id===id)?.name||'关联设备待核对';
const nodes=new Map(),queue=[...(tree.children.get('' )||[])];for(let i=0;i<queue.length;i++)queue.push(...(tree.children.get(queue[i].id)||[]));const node=d=>{const info=ZhaoDevices.network(d,state),children=tree.children.get(d.id)||[];return `<li><article class="topology-node"><span class="badge">${esc(d.deviceType)}</span><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} ›</button></h3><p>${esc(siteName(d.siteId))}${d.point?' · '+esc(d.point):''}</p><dl>${networkInfo(d)}${d.networkCidr?`<div><dt>所提供网段</dt><dd>${esc(d.networkCidr)}</dd></div>`:''}<div><dt>物理连接</dt><dd>${d.uplinkId?esc(label(d.uplinkId))+' · ':''}${esc({wired:'网线连接',wifi:'Wi-Fi 连接'}[d.connectionType]||(d.uplinkId?'连接方式待确认':'上级及连接方式待确认'))}</dd></div></dl>${info.via.length?`<p>取流经录像机:${info.via.map(label).map(esc).join('、')}</p>`:''}${info.archiveVia.length?`<p>录像归档:${info.archiveVia.map(label).map(esc).join('、')}</p>`:''}${info.channels.length?`<p>镜头码流:${info.channels.filter(c=>c.ready).length} / ${info.channels.length} 已接通</p>`:'<p class="description">档案已登记,在线状态待核实</p>'}</article>${children.length?`<ul>${children.map(c=>nodes.get(c.id)).join('')}</ul>`:''}</li>`;};
for(const d of queue.reverse())nodes.set(d.id,node(d));
$('#device-topology').innerHTML=`<div class="panel"><h3>${esc(currentHome()?.name||'当前场所')} · 网络拓扑</h3><p class="description">随设备新增、编辑和区域筛选自动更新。连线表示档案中已确认的上级关系;录像机取流与录像归档另行标注。未登记的交换机端口、AP 及网络设备仍需补充。</p><ul class="topology-tree">${(tree.children.get('')||[]).map(d=>nodes.get(d.id)).join('')||'<li>当前范围没有已登记设备</li>'}</ul></div>`;
}
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('');
$$('[data-group-preset]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.groupPreset===deviceGroups.join(','))));
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
}
function videoDeviceActions(d){
if(!['camera','recorder'].includes(d.source))return '';
const channels=state.cameras.filter(c=>d.source==='camera'?c.assetId===d.id:c.recorderId===d.id||c.archiveRecorderId===d.id);
const target=`data-view-device="${esc(d.id)}" data-device-source="${d.source}"`;
return `<div class="camera-actions">${isAdmin()?`<button class="secondary" ${target} data-open-device-tab="cameras">配置镜头</button>`:''}${channels.length?`<button class="quiet" ${target} data-open-device-tab="live">查看画面</button><button class="quiet" ${target} data-open-device-tab="playback">录像回放</button>`:''}</div>${!channels.length?'<p class="description">尚未接入画面,请先配置镜头通道。</p>':''}`;
}
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||c.archiveRecorderId===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>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></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>${networkInfo(d)}${detail}</dl>${videoDeviceActions(d)}${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();}
$('#device-clear').hidden=!($('#site-filter').value||$('#device-type').value||$('#device-search').value);
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)&&ZhaoDevices.matches(d,search,state.sites));
$('#asset-list').hidden=devicePresentation!=='cards';$('#device-topology').hidden=devicePresentation!=='topology';
$('#devices-card-view').setAttribute('aria-pressed',String(devicePresentation==='cards'));$('#devices-topology-view').setAttribute('aria-pressed',String(devicePresentation==='topology'));
if(devicePresentation==='topology'&&!window.ZhaoVue)renderTopology(devices);
$('.view-presets').hidden=devicePresentation==='topology';$('.group-custom').hidden=devicePresentation==='topology';
$('#device-count').textContent=devicePresentation==='topology'?`${devices.length} 台设备 · 网络连接视图`:`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
if(window.ZhaoVue){
const tree=ZhaoDevices.topology(devices),catalog=ZhaoDevices.catalog(state),rows=[],stack=(tree.children.get('')||[]).slice().reverse().map(device=>({device,depth:0}));
const name=id=>catalog.find(d=>d.id===id)?.name||'关联设备待确认';
while(stack.length){const row=stack.pop(),info=ZhaoDevices.network(row.device,state);rows.push({...row,parentName:row.device.uplinkId?name(row.device.uplinkId):'',via:info.via.map(name),archiveVia:info.archiveVia.map(name)});stack.push(...(tree.children.get(row.device.id)||[]).slice().reverse().map(device=>({device,depth:row.depth+1})));}
window.ZhaoVue.setDevices({devices,groups:ZhaoDevices.groups(devices,deviceGroups,state.sites),rows,mode:devicePresentation});return;
}
$('#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(){devicePresentation='cards';deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
function responsibilityFields(form,item){
for(const field of ['managementHost','tailscaleHost','networkCidr','networkAccess','connectionType'])form.elements[field].value=item?.[field]||(['networkAccess','connectionType'].includes(field)?'unknown':'');
const uplink=form.elements.uplinkId;uplink.innerHTML='<option value="">上级设备待确认/直接接入</option>'+ZhaoDevices.catalog(state).filter(d=>d.id!==item?.id).map(d=>`<option value="${esc(d.id)}">${esc(d.name+' · '+d.deviceType)}</option>`).join('');uplink.value=item?.uplinkId||'';
form.elements.managementHost.closest('label').hidden=form.id==='recorder-form';
const direct=form.id==='asset-form'&&state.cameras.some(c=>c.assetId===item?.id&&c.sourceKind!=='recorder');
form.elements.managementHost.readOnly=direct;
form.elements.managementHost.title=direct?'此地址来自镜头通道,请在“配置镜头”内修改':'';
if(direct)form.elements.managementHost.value=ZhaoDevices.network(item,state).hosts[0]||'';
const candidates=state.responsibilityOptions||[];
for(const prefix of ['owner','manager']){
const input=form.elements[prefix+'Name'],select=form.elements[prefix+'Ref'];
input.value=item?.[prefix+'Name']||'';
const ref=item?.[prefix+'Ref']||'',missing=ref&&!candidates.some(p=>p.ref===ref);
select.innerHTML='<option value="">暂未指定</option>'+['账号成员','家谱人物'].map(kind=>`<optgroup label="${kind}">${candidates.filter(p=>p.kind===kind).map(p=>`<option value="${esc(p.ref)}">${esc(p.label)}</option>`).join('')}</optgroup>`).join('')+(missing?`<option value="${esc(ref)}">${esc(input.value)}(原关联人员,保留记录)</option>`:'')+'<option value="custom">手动填写人员或单位</option>';
select.value=ref||(input.value?'custom':'');
input.hidden=select.value!=='custom';input.required=select.value==='custom';
select.onchange=()=>{input.hidden=select.value!=='custom';input.required=select.value==='custom';if(select.value==='custom'){input.value='';input.focus();}else input.value=candidates.find(p=>p.ref===select.value)?.name||(select.value===ref?item?.[prefix+'Name']||'':'');};
}
form.elements.tags.value=(item?.tags||[]).join('、');
}
function deviceValues(data){const result={...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};for(const prefix of ['owner','manager']){if(result[prefix+'Ref']==='custom')result[prefix+'Ref']='';else if(!result[prefix+'Ref'])result[prefix+'Name']='';}return result;}
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);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';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('');
}
function clearDeviceFilters(){$('#site-filter').value='';$('#device-type').value='';$('#device-search').value='';renderDevices();}
$('#device-clear').onclick=clearDeviceFilters;
$('#devices-card-view').onclick=()=>{devicePresentation='cards';renderDevices();};$('#devices-topology-view').onclick=()=>{devicePresentation='topology';renderDevices();};
$('#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.groupPreset){deviceGroups=b.dataset.groupPreset.split(',');renderDeviceGrouping();renderDevices();}
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();}}
});