From 675d31c01fd8f28d4bb5459d211e976e58896630 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 18:23:25 +0800 Subject: [PATCH] Fix camera onboarding and consolidate device management for v0.1.33 --- RELEASE-v0.1.33.md | 11 +++++++++++ VERSION | 2 +- app.py | 5 +++++ onvif.py | 25 ++++++++++++++++++++++--- test_app.py | 22 ++++++++++++++++++++++ test_onvif.py | 33 +++++++++++++++++++++++++++++++++ test_steward.js | 26 ++++++++++++++++++++++++++ web/app.js | 10 +++++----- web/devices.js | 8 +++++++- web/index.html | 4 ++-- web/steward.js | 4 ++-- 11 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 RELEASE-v0.1.33.md create mode 100644 test_onvif.py diff --git a/RELEASE-v0.1.33.md b/RELEASE-v0.1.33.md new file mode 100644 index 0000000..96f671c --- /dev/null +++ b/RELEASE-v0.1.33.md @@ -0,0 +1,11 @@ +# v0.1.33 — 摄像机接入与设备入口整理 + +- 摄像机、录像机卡片增加“配置镜头”入口;镜头配置始终保留当前设备及家庭归属。 +- 监控墙统一用于观看、分屏、回放、截图与云台操作,移除重复的新建设备和接入设置按钮;设备配置集中在“区域与设备”。 +- 镜头通道新增可保存的 ONVIF 服务端口,支持实际使用 2020 端口的 TP-Link 摄像机;RTSP 端口独立配置。 +- 区分摄像机认证失败与服务器网络连接失败,兼容通过 HTTP 400 返回认证 SOAP Fault 的设备。 +- 空通道页面说明“摄像头对象”和“镜头通道”的关系:主、子码流属于同一个镜头。 + +验证:155 项 Python 测试、123 项 JavaScript 测试全部通过,并完成浏览器接入表单和入口回归检查。 + +网络连通与设备认证是接入前提;摄像机凭据和家庭配置保存在部署主机的数据目录,不包含在公开源码中。 diff --git a/VERSION b/VERSION index 28d0075..50140e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.32 +0.1.33 diff --git a/app.py b/app.py index a8899d9..949bd00 100644 --- a/app.py +++ b/app.py @@ -230,6 +230,7 @@ def save_camera(body, actor=None): item['archiveRecorderId'] = archive_recorder item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')), port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family) + item['onvifPort'] = integer(body.get('onvifPort', old.get('onvifPort', 80)), 1, 65535) HOUSEHOLDS.endpoint(actor, item['host']) for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]: path = clean_text(body.get(k, default), 500, required=True) @@ -768,6 +769,10 @@ class Handler(BaseHTTPRequestHandler): raise Problem('设备密码格式不正确') try: result = onvif.inspect(host, port, username, password) + except PermissionError: + raise Problem('摄像机认证失败,请填写设备本地账号和密码;平台接入密码可能不同', 422) + except (urllib.error.URLError, TimeoutError, OSError): + raise Problem('服务器无法连接摄像机,请检查设备地址、ONVIF 端口以及服务器到该局域网的路由', 502) except Exception: raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502) self.answer(result) diff --git a/onvif.py b/onvif.py index e064e21..cc62928 100644 --- a/onvif.py +++ b/onvif.py @@ -20,6 +20,16 @@ class NoRedirect(urllib.request.HTTPRedirectHandler): return None +def check_fault(root): + fault = root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') + if fault is None: + return + codes = [el.text or '' for el in fault.iter() if el.tag.rsplit('}', 1)[-1] in ('Value', 'faultcode')] + if any(code.rsplit(':', 1)[-1] in ('NotAuthorized', 'FailedAuthentication') for code in codes): + raise PermissionError('ONVIF authentication failed') + raise ValueError('ONVIF returned a fault; check device service and request support') + + def request(host, port, endpoint, namespace, operation, content, username, password): created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') nonce = secrets.token_bytes(16) @@ -44,10 +54,19 @@ def request(host, port, endpoint, namespace, operation, content, username, passw with opener.open(req, timeout=5) as response: root = ET.fromstring(response.read(1024 * 1024)) except urllib.error.HTTPError as exc: - exc.close() + try: + if exc.code in (401, 403): + raise PermissionError('ONVIF authentication failed') from None + try: + fault = ET.fromstring(exc.read(1024 * 1024)) + except ET.ParseError: + fault = None + if fault is not None: + check_fault(fault) + finally: + exc.close() raise - if root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') is not None: - raise ValueError('ONVIF returned a fault; check device credentials and permissions') + check_fault(root) return root diff --git a/test_app.py b/test_app.py index a8d3c4b..a02dc8c 100644 --- a/test_app.py +++ b/test_app.py @@ -547,6 +547,28 @@ class WebTests(unittest.TestCase): self.assertEqual(current['username'], 'updated') self.assertTrue(current['passwordConfigured']);self.assertNotIn('password', current) + def test_onvif_port_persists_and_is_validated_separately_from_rtsp(self): + site = app.save_site({'name': 'ONVIF port test'}) + asset = app.save_asset(dict(name='TP-Link fixture', siteId=site['id'], lensCount=1)) + with patch.object(app, 'write_media_config'): + camera = app.save_camera(dict(name='Single source', assetId=asset['id'], host='192.168.8.160', port=554, onvifPort=2020)) + self.assertEqual(camera['onvifPort'], 2020) + self.assertEqual(camera['port'], 554) + legacy_edit = dict(camera) + legacy_edit.pop('onvifPort') + self.assertEqual(app.save_camera(legacy_edit)['onvifPort'], 2020) + for invalid in (0, 65536, 'wrong'): + with self.assertRaises(app.Problem): + app.save_camera(dict(camera, onvifPort=invalid)) + + def test_onvif_auth_and_network_failures_have_different_errors(self): + data = dict(host='192.168.8.160', port=2020, username='admin', password='fixture-only') + for fault, status, text in [(PermissionError(), 422, '摄像机认证失败'), (TimeoutError(), 502, '服务器无法连接')]: + with patch.object(app.onvif, 'inspect', side_effect=fault): + code, _, body = self.request('/api/onvif', data=data) + self.assertEqual(code, status) + self.assertIn(text, json.loads(body)['error']) + def test_camera_settings_admin_only_and_errors_are_explicit(self): user = app.ACCOUNTS.save(dict(username='settings-member', password='settings-pass', siteIds=['test-site'] if app.get_object('sites','test-site') else []), self.admin) token = '3'*64 diff --git a/test_onvif.py b/test_onvif.py new file mode 100644 index 0000000..8e0706a --- /dev/null +++ b/test_onvif.py @@ -0,0 +1,33 @@ +"""Read-only ONVIF transport regression tests using synthetic responses.""" +import io +import unittest +import urllib.error +from unittest.mock import MagicMock, patch +import onvif + + +class OnvifTests(unittest.TestCase): + def test_tplink_http_400_auth_fault_is_not_a_connection_error(self): + payload = b's:Senderter:NotAuthorized' + body = io.BytesIO(payload) + error = urllib.error.HTTPError('http://192.168.8.160:2020/onvif/device_service', 400, 'Bad Request', {}, body) + opener = MagicMock() + opener.open.side_effect = error + with patch.object(onvif.urllib.request, 'build_opener', return_value=opener): + with self.assertRaises(PermissionError): + onvif.request('192.168.8.160', 2020, '/onvif/device_service', onvif.DEVICE, 'GetDeviceInformation', '', 'admin', 'fixture-only') + self.assertTrue(body.closed) + + def test_plain_auth_error_is_closed(self): + body = io.BytesIO(b'Unauthorized') + opener = MagicMock() + opener.open.side_effect = urllib.error.HTTPError('http://camera', 401, 'Unauthorized', {}, body) + with patch.object(onvif.urllib.request, 'build_opener', return_value=opener): + with self.assertRaises(PermissionError): + onvif.request('192.168.8.160', 2020, '/onvif/device_service', onvif.DEVICE, 'GetDeviceInformation', '', 'admin', 'fixture-only') + self.assertTrue(body.closed) + + def test_unsupported_operation_remains_a_fault(self): + root = onvif.ET.fromstring('ter:ActionNotSupported') + with self.assertRaises(ValueError): + onvif.check_fault(root) diff --git a/test_steward.js b/test_steward.js index 808be7d..5054f33 100644 --- a/test_steward.js +++ b/test_steward.js @@ -49,3 +49,29 @@ test('culture and property entry requires its separate permission',()=>{ assert.match(output.innerHTML,/data-family-section/);assert.doesNotMatch(output.innerHTML,/data-heritage-kind/); c.currentUser.heritageAccess='read';c.renderHeritageHome();assert.match(output.innerHTML,/data-heritage-kind/); }); + +test('video device cards expose a single configuration entry with correct scope',()=>{ + const devices=fs.readFileSync('web/devices.js','utf8'); + const c=vm.createContext({state:{cameras:[]},isAdmin:()=>true,esc:s=>String(s)}); + vm.runInContext(part(devices,'function videoDeviceActions(','function deviceCard('),c); + const camera={source:'camera',id:'a'}; + assert.match(c.videoDeviceActions(camera),/data-view-device="a".*data-device-source="camera".*data-open-device-tab="cameras"/); + assert.doesNotMatch(c.videoDeviceActions(camera),/查看画面/); + c.state.cameras=[{assetId:'a'}];assert.match(c.videoDeviceActions(camera),/查看画面/); + c.isAdmin=()=>false;assert.doesNotMatch(c.videoDeviceActions(camera),/配置镜头/); + assert.equal(c.videoDeviceActions({source:'device',id:'lamp'}),''); +}); + +test('monitor wall offers viewing without duplicate device management',()=>{ + const live=part(app,'function renderLive()','function updateLiveStatus('); + assert.doesNotMatch(live,/data-edit-camera|data-camera-settings|new-camera/); + assert.match(live,/data-view-device/); + const section=part(html,'
{ + assert.match(html,/name="onvifPort" type="number" value="80" min="1" max="65535"/); + assert.match(app,/onvifPort:Number\(d.onvifPort\)/); + assert.match(app,/port:Number\(f.elements.onvifPort.value\)/); +}); diff --git a/web/app.js b/web/app.js index d399bb4..4f86259 100644 --- a/web/app.js +++ b/web/app.js @@ -118,7 +118,7 @@ function renderLive() { const key = (currentUser?.role||'')+':'+(state.familyId||'')+':'+ZhaoLivePlayer.key(cameras, quality); if (key === liveKey) { updateLiveStatus(cameras); return; } stopPlayers(); liveKey = key; - $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
⠿ 拖动
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); + $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
⠿ 拖动
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('当前没有已配置画面','请在区域与设备中选择摄像机并配置镜头,然后回到这里集中观看。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); for (const c of cameras.filter(c=>c.enabled)) { const video = $(`video[data-camera="${c.id}"]`); players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`)); @@ -140,7 +140,7 @@ function renderCameras() { $('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测'; $('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`
${esc(s.host)} · RTSP 服务响应
`).join('') || (state.scan.at ? '

本次没有发现响应 RTSP 554 端口的设备。

' : ''); const cameras=filtered(); - $('#camera-list').innerHTML=cameras.length?`
${cameras.map(c=>``).join('')}
设备 / 位置所属空间地址码流状态录像
${esc(c.name)}${esc(c.point||'位置待完善')}${esc(siteName(c.siteId))}${esc(c.host)}:${c.port}${c.ready?'已接通':c.enabled?'未接通':'停用'}${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}
`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。'); + $('#camera-list').innerHTML=cameras.length?`
${cameras.map(c=>``).join('')}
设备 / 位置所属空间地址码流状态录像
${esc(c.name)}${esc(c.point||'位置待完善')}${esc(siteName(c.siteId))}${esc(c.host)}:${c.port}${c.ready?'已接通':c.enabled?'未接通':'停用'}${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}
`:empty('尚未配置镜头通道','摄像头档案已建立后,还需填写设备地址、账号与码流路径。每个镜头只建一个通道,主、子码流在通道内配置。',''); } function renderSites() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id)); @@ -167,11 +167,11 @@ function resetEditorForm(form){ 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),enabled:f.elements.enabled.checked,record:f.elements.record.checked})); +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'){$('#camera-form').elements.assetId.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));}); +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);}}; @@ -191,7 +191,7 @@ function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKi $('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint; 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;}}; +$('#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:Number(f.elements.onvifPort.value),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;}}; function validateNewPassword(values){ if(values.newPassword!==values.confirmPassword)throw Error('两次输入的新密码不一致'); diff --git a/web/devices.js b/web/devices.js index f19d043..ecf591d 100644 --- a/web/devices.js +++ b/web/devices.js @@ -6,11 +6,17 @@ function renderDeviceGrouping(){ $$('[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 `
${isAdmin()?``:''}${channels.length?``:''}
${!channels.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'?`
镜头 / 通道
${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道
`:d.source==='recorder'?`
关联镜头
${state.cameras.filter(c=>c.recorderId===d.id||c.archiveRecorderId===d.id).length} 个
`:'
接入状态
仅登记档案,尚未接入控制
'; - return `
${esc(d.deviceType)}${isAdmin()?``:''}

所在区域
${esc(location)}
所有人
${esc(d.ownerName||'待指定')}
负责人
${esc(d.managerName||'待指定')}
品牌型号
${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}
${detail}
${d.tags?.length?`

${d.tags.map(t=>`${esc(t)}`).join('')}

`:''}
`; + return `
${esc(d.deviceType)}${isAdmin()?``:''}

所在区域
${esc(location)}
所有人
${esc(d.ownerName||'待指定')}
负责人
${esc(d.managerName||'待指定')}
品牌型号
${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}
${detail}
${videoDeviceActions(d)}${d.tags?.length?`

${d.tags.map(t=>`${esc(t)}`).join('')}

`:''}
`; } function renderDevices(){ const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space']; diff --git a/web/index.html b/web/index.html index d2cdfb8..5263947 100644 --- a/web/index.html +++ b/web/index.html @@ -55,7 +55,7 @@
-

本场所监控墙

这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。

主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。

+

本场所监控墙

这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。

主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。