'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:'公司',other:'其他'}[v] || '其他'); let state = {cameras:[], assets:[], recorders:[], 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; const isAdmin = () => currentUser?.role === 'admin'; function applyPermissions(){ const admin=isAdmin(); $('#account-password').hidden=!loggedIn; $$('[data-admin],nav [data-page="objects"],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="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none'); $('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : ''; } function clearPrivateView(){ if(typeof clearSnapshot==='function')clearSnapshot(); if(typeof clearCameraSettings==='function')clearCameraSettings(); sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false; if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{}); currentUser=null;stopPlayers();resetPlayback(); state={cameras:[],assets:[],recorders:[],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'])$('#'+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:['录像与存储','让记录持续,也让磁盘留有余地。']}; 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'}, 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 state.sites.find(s => s.id === id)?.name || '未关联空间'; } 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 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) { if(!isAdmin()&&!['live','playback','family'].includes(page))return; if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();} if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen(); activePage = page; $('.metrics').hidden=['family','users'].includes(page); $('.filter-row').hidden=['family','users'].includes(page); $$('.page').forEach(el => el.hidden = el.id !== 'page-' + page); $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page)); $('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1]; if (page !== 'playback') resetPlayback(); render(); if (page === 'storage') loadAudit(); if (page === 'family') loadFamily(true); if (page === 'users') loadUsers(); } function render() { $('#metric-all').textContent = state.assets.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('')); drawTimeline(); } if (activePage === 'storage') renderStorage(); applyPermissions(); if(activePage==='live'&&typeof wallSync==='function')wallSync(); } 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?'已配置,等待写入':'未开启'} |
尚无操作记录
';}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 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=>``).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 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;}});} handleForm('site-form','/api/sites'); handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),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(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.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('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()=>{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); 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=>`