'use strict'; const $ = (s) => document.querySelector(s); const $$ = (s) => [...document.querySelectorAll(s)]; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他'); let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'households', 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 => {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(); $('#account-password').hidden=!loggedIn; $$('[data-admin],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin); $('nav [data-page="storage"]').hidden=!isPlatformAdmin(); $('.discovery').hidden=!isPlatformAdmin()||!!deviceContext; $('#scan-result').hidden=!isPlatformAdmin(); $('#new-household').hidden=!isPlatformAdmin(); $('#household-context').hidden=!loggedIn; $('#household-filter').disabled=!isPlatformAdmin(); if(typeof renderSteward==='function')renderSteward(); $('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : ''; } function clearPrivateView(){ if(typeof resetJournal==='function')resetJournal(); if(typeof resetAppearance==='function')resetAppearance(); if(typeof clearSnapshot==='function')clearSnapshot(); if(typeof resetDeviceView==='function')resetDeviceView(); if(typeof resetSteward==='function')resetSteward(); if(typeof resetHeritage==='function')resetHeritage(); 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:[],devices:[],sites:[],storage:{},scan:{}}; window.ZhaoVue?.clear(); 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','device-topology','home-global-topology']){if(!window.ZhaoVue||!['asset-list','household-list','device-topology','home-global-topology'].includes(id))$('#'+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='households';showPage('households');applyPermissions(); } 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)}); let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); } if(generation!==sessionGeneration)throw Object.assign(Error('登录会话已变化,请重新操作'),{stale:true}); if (!r.ok) { if (r.status === 401 && loggedIn) { clearPrivateView(); auth(); } throw Object.assign(Error(result.error || '操作未完成'),{status:r.status}); } return result; } function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); } function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; } function address(s) { return [ZhaoGeography.describe(s),s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; } function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const cameras=state.cameras.filter(c=>!range||range.has(c.siteId));return typeof deviceCameras==='function'?deviceCameras(cameras):cameras; } 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, options={}) { if(typeof journalSaving!=='undefined'&&journalSaving&&page!==activePage){toast('记事正在保存,请稍候。');return;} setMobileMenu(false); if(typeof leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal(); if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings(); if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings','journal'].includes(page))return; if(['heritage','family','heritage-records'].includes(page)&¤tHome()?.kind&¤tHome().kind!=='home')return; if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings','journal'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null; if(typeof scopeEntered!=='undefined'){if(page==='households')scopeEntered=false;else if(!['storage','settings'].includes(page))scopeEntered=true;} if(page==='storage'&&!isPlatformAdmin())return; 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; $$('.page').forEach(el => el.hidden = el.id !== 'page-' + page); $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['sites','device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page))); $('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1]; if (page !== 'playback') resetPlayback(); render(); if(pageChanged)window.scrollTo({top:0,left:0,behavior:'instant'}); if(page==='playback'){$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';playbackSourceHint();} if (page === 'storage') loadAudit(); if (page === 'family') loadFamily(!options.keepFamilyView); if (page === 'users') loadUsers(); if(page==='heritage-records')loadHeritage(); if(page==='settings'&&typeof loadSettings==='function')loadSettings(); if(page==='journal'&&typeof loadJournal==='function')loadJournal(); } function render() { $('#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'; $('#service-status').textContent = state.mediaOnline ? '视频服务已连接' : '视频服务未连接'; $('#service-status').classList.toggle('off', !state.mediaOnline); $('#version').textContent = state.version ? 'v' + state.version + ' / 独立部署 · 数据自有' : '独立部署 / 数据自有'; const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : ''; $('#banner').textContent = warning; $('#banner').hidden = !warning; setOptions($('#site-filter'), '' + state.sites.map(s=>``).join('')); if (activePage === 'live') renderLive(); if (activePage === 'cameras') renderCameras(); if (activePage === 'sites') renderSites(); if (activePage === 'objects') renderAssets(); if (activePage === 'recorders') renderRecorders(); if (activePage === 'playback') { setOptions($('#play-camera'), filtered().map(c=>``).join('')); renderRecordingStatus(); drawTimeline(); } if (activePage === 'storage') renderStorage(); if(typeof renderHouseholds==='function')renderHouseholds(); applyPermissions(); if(activePage==='live'&&typeof wallSync==='function')wallSync(); window.ZhaoVue?.publish?.(); } function empty(title, description, button) { return `
${esc(description)}
${button || ''}本次没有发现响应 RTSP 554 端口的设备。
' : ''); const cameras=filtered(); $('#camera-list').innerHTML=cameras.length?`| 设备 / 位置 | 所属空间 | 地址 | 码流状态 | 录像 | |
|---|---|---|---|---|---|
| ${esc(c.name)}${esc(c.point||'位置待完善')} | ${esc(siteName(c.siteId))} | ${esc(c.host)}:${c.port} | ${c.ready?'已接通':c.enabled?'未接通':'停用'} | ${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'} |
${esc(siteName(s.id))}
尚无操作记录
';}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();}if(typeof onAppearanceSession==='function')onAppearanceSession(s.preferences);}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}} function openSite(id,parentId='') {const f=$('#site-form');resetEditorForm(f);f.elements.parentId.innerHTML=''+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(!item){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;ZhaoGeography.fill(f,item||{});$('#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');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.archiveRecorderId.innerHTML=''+state.recorders.map(r=>``).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>``).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 setMobileMenu(open){$('.sidebar').classList.toggle('mobile-open',open);$('#mobile-nav-toggle').setAttribute('aria-expanded',String(open));$('#mobile-nav-toggle').textContent=open?'收起菜单':'☰ 菜单';} $('#mobile-nav-toggle').onclick=()=>setMobileMenu($('#mobile-nav-toggle').getAttribute('aria-expanded')!=='true'); document.addEventListener('keydown',event=>{if(event.key==='Escape'&&$('#mobile-nav-toggle').getAttribute('aria-expanded')==='true'){setMobileMenu(false);$('#mobile-nav-toggle').focus();}}); function resetEditorForm(form){ form.reset(); // Hidden input values become reset defaults in browsers; always clear record identity explicitly. for(const field of form.querySelectorAll('input[type="hidden"]'))field.value=field.name.toLowerCase().includes('revision')?'0':''; } function formValues(f) {return {...Object.fromEntries(new FormData(f).entries()),...(typeof ZhaoGeography!=='undefined'?ZhaoGeography.collect(f):{})};} 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;}});} handleForm('site-form','/api/sites'); handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),onvifPort:Number(d.onvifPort),enabled:f.elements.enabled.checked,record:f.elements.record.checked})); handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit); $('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}}); $('#auth-dialog').addEventListener('cancel',e=>e.preventDefault()); document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera'){openCamera();if(deviceContext?.source==='camera'){const f=$('#camera-form');f.elements.assetId.value=deviceContext.id;f.elements.sourceKind.value='direct';f.elements.recorderId.value='';f.elements.archiveSource.value='local';syncEndpoint();}else if(deviceContext?.source==='recorder'){$('#camera-form').elements.recorderId.value=deviceContext.id;syncEndpoint();}}if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.childSite)openSite('',b.dataset.childSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){const c=state.cameras.find(c=>c.id===b.dataset.playCamera);if(c)enterDevice('camera',c.assetId,'playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));}); $('#logout').onclick=async()=>{if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return;await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();}; $('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();}; $('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}}; $('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}}; window.addEventListener('beforeunload',stopPlayers); setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000); async function bootApplication(){showPage('households');if(typeof loadBranding==='function')await loadBranding();await auth();} setInterval(refresh,12000);if(!window.ZhaoVue)document.addEventListener('DOMContentLoaded',bootApplication,{once:true}); 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=>`