diff --git a/RELEASE-v0.1.34.md b/RELEASE-v0.1.34.md new file mode 100644 index 0000000..fb2f1fe --- /dev/null +++ b/RELEASE-v0.1.34.md @@ -0,0 +1,18 @@ +# v0.1.34 — 人员归属、卡片布局与网络拓扑 + +- 摄像机、录像机及普通设备统一提供所有人、负责人选择器,可选择本家庭/场所的账号成员或家谱人物,也可手动填写人员或单位。人员更名会同步显示,职责登记不授予额外访问权限。 +- 家庭/场所照片卡片支持 1–4 列 × 1–4 行、分页、拖动或按钮排序,以及可选的城市分组。布局按账号保存,并检测并发修改;手机自动采用单列。 +- “我的家庭与场所”新增“赵府整体拓扑”,仅展示当前账号获授权的家庭、公司及工厂;点击场所进入其内部网络。 +- “区域与设备”新增网络拓扑视图,与设备卡片共用同一套档案。新增、编辑和周期状态更新自动反映到图中,可按区域及下级区域、设备类型、名称、人员或 IP 筛选。 +- 设备档案增加管理 IP/域名、局域网/Tailscale/公网接入方式、上级设备、有线/Wi-Fi 连接方式、Tailscale 地址及网段。服务器校验地址、网段、跨家庭引用及上级循环。 +- 图中显示设备名称、位置、网络地址和接入方式,分别标注录像机取流与录像归档。已配置摄像机的地址从真实镜头通道读取,避免出现两个互相矛盾的地址。 + +## 验证 + +162 项 Python 测试和 127 项 JavaScript 测试通过。使用独立测试数据,在 Edge 实际执行了三类设备录入、人员选择与重新打开、区域层级、负责人分类、IP 搜索、卡片排序保存、总拓扑下钻及 390 像素手机布局检查。 + +## 范围说明 + +拓扑自动生成于已登记设备和已确认的连接关系;未登记的交换机端口、AP 及物理线路仍需补充。图上的接入方式是档案属性,不会开放端口或自动授予网络权限,也不表示设备已经通过在线探测。录像机历史回放仍需设备协议适配。 + +公开源码不包含设备凭据、用户资料、业务数据库或网盘授权信息。 diff --git a/VERSION b/VERSION index 50140e3..9dd1793 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.33 +0.1.34 diff --git a/app.py b/app.py index 949bd00..fb27b90 100644 --- a/app.py +++ b/app.py @@ -263,7 +263,7 @@ def save_asset(body, actor=None): item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') for k in ('name', 'brand', 'model', 'serial', 'point', 'note')} item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family) - item.update(INVENTORY.metadata(body, get_object('assets', key))) + item.update(INVENTORY.metadata(body, get_object('assets', key), actor)) attached = [c for c in objects('cameras') if c.get('assetId') == key] if len(attached) > item['lensCount']: raise Problem('镜头数不能小于已经建立的通道数') @@ -295,7 +295,7 @@ def save_recorder(body, actor=None): if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password): raise Problem('密码格式不正确') item['password'] = password or old.get('password', '') - item.update(INVENTORY.metadata(body, old)) + item.update(INVENTORY.metadata(body, old, actor)) save_object('recorders', item) write_media_config() audit('保存录像机', item['name']) @@ -657,7 +657,7 @@ class Handler(BaseHTTPRequestHandler): self.login(data, path == '/api/setup') return self.require_auth() - if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore', + if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/households/layout', '/api/family/restore', '/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets', '/api/journal', '/api/journal/action', '/api/journal/upload', '/api/journal/finish', '/api/journal/remove-upload', '/api/journal/restore-upload'): self.require_manager() @@ -668,6 +668,8 @@ class Handler(BaseHTTPRequestHandler): self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') elif path == '/api/preferences': self.answer(APPEARANCE.save_preferences(data, self.user)) + elif path == '/api/households/layout': + self.answer(HOUSEHOLDS.save_layout(data, self.user)) elif path == '/api/baidu/config': self.require_admin(); self.answer(BAIDU.configure(data, self.user)) elif path == '/api/baidu/connect': diff --git a/households.py b/households.py index 5191e95..bdd3b72 100644 --- a/households.py +++ b/households.py @@ -113,12 +113,42 @@ class Households: def label(uid): account = self.a.get_object('users', uid) return account.get('name', '') if account else '' + inventory = [x for table in ('devices','assets','recorders') for x in self.a.objects(table)] + def network_summary(home): + devices = [x for x in inventory if self.family_of(x) == home['id'] and + (self.manager(user) or x.get('siteId') in self.site_ids(user))] + return dict(deviceCount=len(devices), networkAccesses=sorted({x.get('networkAccess','unknown') for x in devices})) return [dict({k: v for k, v in row.items() if k != 'coverData' and (k != 'leadershipHistory' or self.manager(user))}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')), + **network_summary(row), coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') or row.get('coverRef') else '', canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'], members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')), sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows] + def layout(self, user): + saved = self.a.setting('householdLayout:'+user['id'], {}) + allowed = {h['id'] for h in self.list(user)} + return dict(revision=saved.get('revision', 0), columns=saved.get('columns', 2), rows=saved.get('rows', 2), + groupCity=saved.get('groupCity', False), order=[x for x in saved.get('order', []) if x in allowed]) + + def save_layout(self, data, user): + a = self.a + with a.LOCK: + old = self.layout(user) + if set(data) != {'revision','columns','rows','groupCity','order'}: + raise a.Problem('卡片布局参数不正确') + if type(data['revision']) is not int or data['revision'] != old['revision']: + raise a.Problem('布局已被更新,请刷新后重新排列', 409) + for key in ('columns','rows'): + if type(data[key]) is not int or not 1 <= data[key] <= 4: + raise a.Problem('行数和列数须为 1 到 4') + order = data['order'] + allowed = {h['id'] for h in self.list(user)} + if type(data['groupCity']) is not bool or not isinstance(order, list) or len(order) > 1000 or any(not isinstance(x,str) or x not in allowed for x in order) or len(order) != len(set(order)): + raise a.Problem('请选择有权访问的家庭/场所,排列不能重复') + a.set_setting('householdLayout:'+user['id'], dict(data, revision=old['revision']+1)) + return self.layout(user) + def leadership(self, data, user): """Explicit handover; a backup already has management access during an emergency.""" a = self.a diff --git a/identity.py b/identity.py index 8009699..ffcd3a6 100644 --- a/identity.py +++ b/identity.py @@ -177,12 +177,16 @@ class Accounts: family = self.a.HOUSEHOLDS.scope(user) state['familyId'] = family state['households'] = self.a.HOUSEHOLDS.list(user) + state['householdLayout'] = self.a.HOUSEHOLDS.layout(user) for key in ('sites', 'assets', 'cameras', 'recorders', 'devices'): state[key] = [x for x in state.get(key, []) if self.a.HOUSEHOLDS.family_of(x) == family] + for key in ('assets', 'recorders', 'devices'): + state[key] = [self.a.INVENTORY.display(x) for x in state[key]] if user['role'] != 'admin': state['scan'] = {} state['storage'] = {} if self.a.HOUSEHOLDS.manager(user): + state['responsibilityOptions'] = self.a.INVENTORY.candidates(user) return state permitted = self.a.HOUSEHOLDS.site_ids(user) state['sites'] = [s for s in state['sites'] if s['id'] in permitted] diff --git a/inventory.py b/inventory.py index 89a8aff..1bc6902 100644 --- a/inventory.py +++ b/inventory.py @@ -1,12 +1,76 @@ """Common device responsibility fields and non-video device records.""" +import ipaddress +import re class Inventory: def __init__(self, app): self.a = app - def metadata(self, data, old=None): + def candidates(self, user): + if not self.a.HOUSEHOLDS.manager(user): + return [] + a = self.a + users = a.HOUSEHOLDS.objects(user, 'users') + if user['role'] == 'admin' and not any(u['id'] == user['id'] for u in users): + users.append(user) + result = [dict(ref='user:'+u['id'], name=u['name'], label=u['name']+' / '+u['username'], kind='账号成员') + for u in users if not u.get('disabled')] + linked = {u.get('personId') for u in users if not u.get('disabled')} + result += [dict(ref='person:'+p['id'], name=p['name'], label=p['name']+' · '+p['id'][-4:], kind='家谱人物') + for p in a.HOUSEHOLDS.objects(user, 'people') if not p.get('archived') and p['id'] not in linked] + return result + + def display(self, item): + result = dict(item) + for prefix in ('owner', 'manager'): + ref = item.get(prefix+'Ref', '') + kind, _, key = ref.partition(':') + target = self.a.get_object({'user':'users','person':'people'}.get(kind, 'users'), key) if key else None + if target and (self.a.HOUSEHOLDS.family_of(target) == self.a.HOUSEHOLDS.family_of(item) or + kind == 'user' and target.get('role') == 'admin'): + result[prefix+'Name'] = target['name'] + return result + + def metadata(self, data, old=None, user=None): old = old or {} result = {k: self.a.clean_text(data.get(k, old.get(k, '')), 80) for k in ('ownerName', 'managerName')} + candidates = {p['ref']:p for p in self.candidates(user)} + for prefix in ('owner', 'manager'): + field = prefix+'Ref' + ref = self.a.clean_text(data.get(field, old.get(field, '')), 64) + if ref: + if ref in candidates: + result[prefix+'Name'] = candidates[ref]['name'] + elif ref == old.get(field): + result[prefix+'Name'] = self.display(old).get(prefix+'Name', '') + else: + raise self.a.Problem('请选择当前家庭/场所的人员,或选择手动填写') + result[field] = ref + for field in ('managementHost','tailscaleHost'): + value = self.a.clean_text(data.get(field,old.get(field,'')),253) + if value: + try: ipaddress.ip_address(value) + except ValueError: + if re.fullmatch(r'[0-9.]+',value) or not all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?',part) for part in value.split('.')): + raise self.a.Problem('设备地址请填写 IP 或域名,不含账号、路径及端口') + result[field]=value + for field,choices in [('networkAccess',('unknown','lan','tailscale','public')),('connectionType',('unknown','wired','wifi'))]: + result[field]=data.get(field,old.get(field,'unknown')) + if result[field] not in choices:raise self.a.Problem('网络接入类型不正确') + cidr=self.a.clean_text(data.get('networkCidr',old.get('networkCidr','')),64) + if cidr: + try:cidr=str(ipaddress.ip_network(cidr,strict=True)) + except ValueError:raise self.a.Problem('网段请使用 CIDR 格式,例如 192.168.8.0/24') + result['networkCidr']=cidr + uplink=self.a.clean_text(data.get('uplinkId',old.get('uplinkId','')),32) + if uplink: + records={x['id']:x for table in ('assets','recorders','devices') for x in self.a.HOUSEHOLDS.objects(user,table)} + if uplink not in records:raise self.a.Problem('上级设备不属于当前家庭/场所') + seen={data.get('id')};node=uplink + while node: + if node in seen:raise self.a.Problem('上级设备不能指向自己或形成循环') + seen.add(node);node=records.get(node,{}).get('uplinkId') + result['uplinkId']=uplink tags = data.get('tags', old.get('tags', [])) if not isinstance(tags, list) or len(tags) > 12: raise self.a.Problem('自定义标签最多 12 个') @@ -24,7 +88,7 @@ class Inventory: if item['deviceType'] in ('摄像头', '摄像机', '录像机'): raise a.Problem('视频设备请使用摄像机或录像机档案,以保留取流与通道配置') item.update(id=key, familyId=family, siteId=a.clean_text(data.get('siteId', ''), 32), - **self.metadata(data, old)) + **self.metadata(data, old, user)) a.save_object('devices', item) a.audit('保存设备档案', item['name']) return item diff --git a/test_devices.js b/test_devices.js index 1a3ef27..359bb2f 100644 --- a/test_devices.js +++ b/test_devices.js @@ -28,3 +28,23 @@ test('camera recorder and general device records appear in one catalog without m assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before); assert.equal(m.groups(devices,[],sites).length,1); }); + +test('same-name people with different references form separate responsibility groups',()=>{ + const rows=[{id:'a',managerName:'张伟',managerRef:'user:one'},{id:'b',managerName:'张伟',managerRef:'user:two'}]; + assert.equal(m.groups(rows,['manager'],[]).length,2); +}); + +test('network topology preserves explicit uplinks and remains usable with missing or cyclic parents',()=>{ + const data=[{id:'r'},{id:'c',uplinkId:'r'},{id:'p',uplinkId:'hidden'}]; + const tree=m.topology(data);assert.deepEqual(tree.children.get('r').map(x=>x.id),['c']);assert.equal(tree.parents.get('p'),''); + assert.equal(m.topology([{id:'a',uplinkId:'b'},{id:'b',uplinkId:'a'}]).children.get('').length,2); +}); +test('network summary distinguishes recorder stream source from physical camera address',()=>{ + const n=m.network({id:'a',managementHost:'192.168.8.160',networkAccess:'tailscale'},{cameras:[{assetId:'a',host:'192.168.3.135',sourceKind:'recorder',recorderId:'r',archiveSource:'recorder',archiveRecorderId:'r'}]}); + assert.deepEqual(n.hosts,['192.168.8.160']);assert.deepEqual(n.via,['r']);assert.deepEqual(n.archiveVia,['r']);assert.equal(n.access,'tailscale'); +}); + +test('configured camera address overrides an old inventory address and is searchable',()=>{ + const rows=m.catalog({assets:[{id:'a',managementHost:'192.168.1.20'}],cameras:[{assetId:'a',host:'192.168.1.30',sourceKind:'direct'}]}); + assert.deepEqual(rows[0].networkHosts,['192.168.1.30']);assert.equal(m.matches(rows[0],'192.168.1.30',[]),true); +}); diff --git a/test_inventory.py b/test_inventory.py new file mode 100644 index 0000000..702b49d --- /dev/null +++ b/test_inventory.py @@ -0,0 +1,88 @@ +"""Responsibility references and personal household layout regressions.""" +import unittest +from test_app import app +import test_households as fixture + + +class InventoryTests(unittest.TestCase): + setUp = fixture.HouseholdTests.setUp + stop_server = fixture.HouseholdTests.stop_server + request = fixture.HouseholdTests.request + + def test_candidates_are_scoped_minimal_and_support_current_superadmin(self): + options = self.request('/api/state')[1]['responsibilityOptions'] + refs = {p['ref'] for p in options} + self.assertIn('user:'+self.members[self.aid]['id'], refs) + self.assertNotIn('user:'+self.members[self.bid]['id'], refs) + self.assertTrue(all(set(p)=={'ref','name','label','kind'} for p in options)) + root_options = app.INVENTORY.candidates(self.roots[self.bid]) + self.assertIn('user:'+self.root['id'], {p['ref'] for p in root_options}) + self.assertNotIn('responsibilityOptions', self.request('/api/state', self.members[self.aid])[1]) + + def test_reference_names_are_authoritative_and_cannot_cross_households(self): + member = self.members[self.aid] + data = dict(self.assets[self.aid],ownerRef='user:'+member['id'],ownerName='spoofed') + code, saved = self.request('/api/assets',data=data) + self.assertEqual(code,200);self.assertEqual(saved['ownerName'],member['name']) + data['managerRef']='user:'+self.members[self.bid]['id'] + self.assertEqual(self.request('/api/assets',data=data)[0],400) + app.ACCOUNTS.save(dict(app.get_object('users',member['id']),name='更新名字'),self.roots[self.aid]) + shown=self.request('/api/state')[1]['assets'][0] + self.assertEqual(shown['ownerName'],'更新名字');self.assertEqual(shown['ownerRef'],saved['ownerRef']) + self.assertEqual(self.request('/api/assets',data=dict(saved,ownerRef='',ownerName='共有单位'))[1]['ownerName'],'共有单位') + + def test_archived_or_disabled_links_are_preserved_but_not_newly_assigned(self): + member=self.members[self.aid] + saved=app.INVENTORY.save(dict(name='测试灯',deviceType='灯',managerRef='user:'+member['id']),self.owner) + app.ACCOUNTS.save(dict(app.get_object('users',member['id']),disabled=True),self.roots[self.aid]) + self.assertEqual(app.INVENTORY.save(saved,self.owner)['managerRef'],saved['managerRef']) + with self.assertRaises(app.Problem):app.INVENTORY.save(dict(name='新灯',deviceType='灯',managerRef=saved['managerRef']),self.owner) + + def test_people_and_all_device_types_share_references_without_granting_access(self): + person=dict(id='e'*16,name='家庭成员',familyId=self.aid,archived=False) + app.save_object('people',person) + ref='person:'+person['id'];self.assertIn(ref,{p['ref'] for p in app.INVENTORY.candidates(self.owner)}) + before=app.objects('users') + rows=[app.save_asset(dict(self.assets[self.aid],managerRef=ref),self.owner), + app.save_recorder(dict(name='录像机',siteId=self.sites[self.aid]['id'],host='192.168.9.50',managerRef=ref),self.owner), + app.INVENTORY.save(dict(name='灯',deviceType='灯',managerRef=ref),self.owner)] + self.assertTrue(all(r['managerName']=='家庭成员' for r in rows));self.assertEqual(before,app.objects('users')) + + def test_layout_persists_per_account_and_rejects_conflicts_and_cross_scope(self): + data=dict(revision=0,columns=3,rows=2,groupCity=False,order=[self.bid,self.aid]) + code,saved=self.request('/api/households/layout',self.root,data) + self.assertEqual(code,200);self.assertEqual(saved['order'],data['order']) + self.assertEqual(self.request('/api/state',self.root)[1]['householdLayout']['columns'],3) + self.assertEqual(app.HOUSEHOLDS.layout(self.owner)['columns'],2) + self.assertEqual(self.request('/api/households/layout',self.root,data)[0],409) + self.assertEqual(self.request('/api/households/layout',self.owner,data)[0],400) + for bad in [dict(data,revision=1,columns=0),dict(data,revision=1,rows=True),dict(data,revision=1,order=[self.aid,self.aid]),dict(data,revision=1,userId=self.owner['id'])]: + self.assertEqual(self.request('/api/households/layout',self.root,bad)[0],400) + member=self.members[self.aid] + self.assertEqual(self.request('/api/households/layout',member,dict(data,order=[self.aid]))[0],200) + + def test_topology_references_and_network_fields_validate_without_opening_access(self): + gateway=app.INVENTORY.save(dict(name='网关',deviceType='路由器',managementHost='192.168.8.1',tailscaleHost='gateway.example.ts.net',networkCidr='192.168.8.0/24',networkAccess='tailscale'),self.owner) + camera=app.save_asset(dict(self.assets[self.aid],uplinkId=gateway['id'],connectionType='wired'),self.owner) + with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,uplinkId=camera['id']),self.owner) + with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,uplinkId=gateway['id']),self.owner) + with self.assertRaises(app.Problem):app.save_asset(dict(camera,uplinkId=self.assets[self.bid]['id']),self.owner) + for bad in [dict(networkCidr='192.168.8.160/24'),dict(managementHost='http://user:secret@example.com'),dict(networkAccess='all'),dict(connectionType='anything')]: + with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,**bad),self.owner) + self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[]) + self.assertEqual(app.INVENTORY.save(dict(gateway,networkAccess='public',managementHost='example.org'),self.owner)['managementHost'],'example.org') + + def test_global_topology_summaries_count_only_authorized_devices(self): + app.INVENTORY.save(dict(name='未授权区域的设备',deviceType='路由器',networkAccess='tailscale'),self.owner) + own=self.request('/api/state',self.members[self.aid])[1]['households'] + self.assertEqual(len(own),1) + self.assertEqual(own[0]['deviceCount'],1) + self.assertNotIn('tailscale',own[0]['networkAccesses']) + managed=self.request('/api/state',self.owner)[1]['households'][0] + self.assertEqual(managed['deviceCount'],2) + self.assertIn('tailscale',managed['networkAccesses']) + overall=self.request('/api/state',self.root)[1]['households'] + self.assertEqual({h['id']:h['deviceCount'] for h in overall},{self.aid:2,self.bid:1}) + + +if __name__=='__main__':unittest.main() diff --git a/test_personalization.js b/test_personalization.js index 092935c..12a5543 100644 --- a/test_personalization.js +++ b/test_personalization.js @@ -34,13 +34,16 @@ test('backup messages distinguish local-only, network copy, and failure',()=>{ assert.match(c.backupLabel({localOk:true,networkOk:false}),/本机/);assert.doesNotMatch(c.backupLabel({localOk:true}),/网络|云端/); assert.match(c.backupLabel({error:'offline',localOk:true}),/未完成/);assert.match(c.backupLabel({networkOk:true}),/网络目录/); }); -test('place cards group by city without exposing an inline photo payload',()=>{ - const out={innerHTML:''},filter={};const c=vm.createContext({state:{familyId:'a',households:[{id:'a',name:'Home',province:'Province',city:'City A',members:1,sites:1,coverUrl:'/api/households/cover?id=a',coverData:'PRIVATE-PHOTO'},{id:'b',name:'Office',city:'City B',kind:'office',members:1,sites:0}]}, - ZhaoGeography:require('./web/geography.js'),activePage:'households',setOptions:()=>{},$:s=>s==='#household-list'?out:filter,isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'}); - vm.runInContext(part(homes,'function renderHouseholds(','async function switchHousehold('),c);c.renderHouseholds(); - assert.match(out.innerHTML,/City A/);assert.match(out.innerHTML,/City B/);assert.match(out.innerHTML,/\/api\/households\/cover\?id=a/); - assert.doesNotMatch(out.innerHTML,/PRIVATE-PHOTO|data-edit-household/); +test('place cards support saved layout, city grouping and private photo references',()=>{ + const nodes=new Map(),node=s=>{if(!nodes.has(s))nodes.set(s,{innerHTML:'',style:{setProperty(){}},setAttribute(){},addEventListener(){}});return nodes.get(s);}; + const c=vm.createContext({state:{familyId:'a',householdLayout:{revision:0,columns:2,rows:2,groupCity:true,order:['b','a']},households:[{id:'a',name:'Home',province:'Province',city:'City A',members:1,sites:1,coverUrl:'/api/households/cover?id=a',coverData:'PRIVATE-PHOTO'},{id:'b',name:'Office',city:'City B',kind:'office',members:1,sites:0}]}, + currentUser:{id:'viewer'},pageInfo:{},ZhaoGeography:require('./web/geography.js'),activePage:'households',setOptions:()=>{},$:node,$$:()=>[],isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'}); + vm.runInContext(homes.slice(0,homes.indexOf('async function switchHousehold(')),c);c.renderHouseholds(); + const out=node('#household-list');assert.match(out.innerHTML,/City A/);assert.match(out.innerHTML,/City B/);assert.match(out.innerHTML,/\/api\/households\/cover\?id=a/); + assert.doesNotMatch(out.innerHTML,/PRIVATE-PHOTO|data-edit-household/);assert.ok(out.innerHTML.indexOf('Office'){ const apply=part(source,'function applyBranding(','async function loadBranding(');assert.doesNotMatch(apply,/innerHTML/); const payload=part(source,'function brandPayload()',"\n$('#brand-form').addEventListener");assert.doesNotMatch(payload,/password|currentUser|backupRevision|networkPath/); diff --git a/web/app.js b/web/app.js index 4f86259..421b4fc 100644 --- a/web/app.js +++ b/web/app.js @@ -36,7 +36,7 @@ function clearPrivateView(){ if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{}); currentUser=null;householdScope='';stopPlayers();resetPlayback(); 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(); + 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'])$('#'+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()); diff --git a/web/chinese-theme.css b/web/chinese-theme.css index 8e99031..1213a65 100644 --- a/web/chinese-theme.css +++ b/web/chinese-theme.css @@ -344,3 +344,15 @@ html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink /* Shared geography controls and cover feedback, including phone dialogs. */ .geo-picker{min-width:0;border:1px solid var(--line,#d8d2c3);border-radius:10px;padding:14px;margin:0}.geo-picker legend{padding:0 8px}.geo-picker .geo-path{display:block;overflow-wrap:anywhere;margin-top:10px;font-size:.9rem}.geo-picker .form-grid{min-width:0}.geo-picker input,.geo-picker select{width:100%;min-width:0}#household-cover-status[data-error="true"]{color:#ad3c28;font-weight:600}#household-cover-preview{max-width:100%;max-height:240px;object-fit:contain} + +/* Personal household card wall */ +.home-layout-controls{padding:16px 20px}.home-layout-controls .device-actions{align-items:end;flex-wrap:wrap}.home-layout-controls label{margin:0;min-width:90px}.home-layout-controls label.check{min-width:0;align-self:center}.home-card-grid{display:grid;grid-template-columns:repeat(var(--home-columns,2),minmax(0,1fr));gap:20px;align-items:start}.home-card-grid .home-card{min-width:0}.home-card-grid .home-card-body{padding:18px}.home-card-grid .home-cover-content{min-height:190px;padding:18px}.home-card-grid .home-cover{min-height:190px}.home-card-grid .home-card-body dl{margin:12px 0}.home-card-grid .home-card-body .description{font-size:13px}.home-card-grid .household-actions{display:flex;flex-wrap:wrap;gap:8px}.home-card-grid .household-actions button{font-size:13px;padding:10px 12px}.home-order-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:14px}.home-order-actions span{font-size:12px;color:var(--muted)}.home-order-actions button{font-size:12px;padding:8px}.home-card[draggable=true]{cursor:grab;outline:1px dashed var(--accent);outline-offset:3px}.home-pagination{display:flex;gap:18px;justify-content:center;align-items:center;margin:24px 0;flex-wrap:wrap}.home-pagination span{font-size:13px;color:var(--muted)} +@media(max-width:1100px){.home-card-grid{grid-template-columns:repeat(min(var(--home-columns,2),2),minmax(0,1fr))}} +@media(max-width:650px){.home-card-grid{grid-template-columns:minmax(0,1fr)}.home-layout-controls{padding:12px}.home-layout-controls .device-actions{gap:10px}.home-layout-controls button{padding:10px 12px}.home-pagination{gap:10px}.home-card-grid .home-cover-content{padding:16px}} + +.device-network{min-width:0;border:1px solid var(--line);padding:16px}.device-network legend{padding:0 8px}.topology-tree,.topology-tree ul{list-style:none;padding:0;margin:20px 0}.topology-tree ul{margin-left:24px;padding-left:24px;border-left:2px solid var(--accent,#31584a)}.topology-tree li{position:relative;margin:18px 0;min-width:0}.topology-tree ul>li:before{content:"";position:absolute;left:-24px;top:28px;width:24px;border-top:2px solid var(--accent,#31584a)}.topology-node{border:1px solid var(--line);border-radius:10px;padding:20px;background:var(--paper);max-width:800px;overflow-wrap:anywhere}.topology-node h3{margin:12px 0}.topology-node dl{display:grid;gap:8px}.topology-node dl>div{display:grid;grid-template-columns:100px minmax(0,1fr);gap:10px}.topology-node dd{margin:0}.topology-node dt,.topology-node p{font-size:13px;color:var(--muted)}.topology-node .device-title{text-align:left} +@media(max-width:650px){.topology-tree ul{margin-left:8px;padding-left:12px}.topology-tree ul>li:before{left:-12px;width:12px}.topology-node{padding:14px}.topology-node dl>div{grid-template-columns:72px minmax(0,1fr)}.device-network .form-grid{grid-template-columns:1fr}} + +.platform-topology-root{border:1px solid var(--line);border-top:4px solid var(--accent,#31584a);background:var(--paper);padding:22px;text-align:center;margin:24px auto;max-width:420px;border-radius:10px}.platform-place-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:24px;border-top:2px solid var(--line);padding-top:24px}.platform-place-node{position:relative;text-align:left;white-space:normal;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--text);padding:22px}.platform-place-node:before{position:absolute;content:"";height:25px;top:-25px;left:50%;border-left:2px solid var(--line)}.platform-place-node h3{margin:16px 0}.platform-place-node p,.platform-place-node small{color:var(--muted)}.platform-place-node>span:last-child{display:block;margin-top:18px}.platform-place-node:hover{border-color:var(--accent,#31584a);background:var(--surface,#f1f2e9)} + +.topology-tree{display:flex;gap:24px;align-items:start;flex-wrap:wrap}.topology-tree>li{flex:1 1 400px}.topology-tree ul{display:flex;gap:16px;flex-wrap:wrap}.topology-tree ul>li{flex:1 1 260px;max-width:560px}.topology-node{max-width:560px;padding:16px}.device-actions #devices-card-view[aria-pressed=true],.device-actions #devices-topology-view[aria-pressed=true],.device-actions #home-card-view[aria-pressed=true],.device-actions #home-topology-view[aria-pressed=true]{background:var(--accent,#31584a);color:#fff}.home-card-grid[hidden],.home-layout-controls[hidden],.home-pagination[hidden]{display:none!important} diff --git a/web/device-model.js b/web/device-model.js index 4f6d1b7..977e0fc 100644 --- a/web/device-model.js +++ b/web/device-model.js @@ -12,10 +12,10 @@ 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'}))];} + ...(state.devices||[]).map(x=>({...x,source:'device'}))].map(x=>({...x,networkHosts:network(x,state).hosts}));} function matches(device,query,sites){ const words=String(query||'').trim().toLowerCase().split(/\s+/).filter(Boolean); - const text=[device.name,device.deviceType,device.brand,device.model,device.serial,device.ownerName,device.managerName,...(device.tags||[]),...path(sites,device.siteId).map(s=>s.name)].join(' ').toLowerCase(); + const text=[device.name,device.deviceType,device.brand,device.model,device.serial,device.ownerName,device.managerName,device.managementHost,device.host,device.tailscaleHost,device.networkCidr,...(device.networkHosts||[]),...(device.tags||[]),...path(sites,device.siteId).map(s=>s.name)].join(' ').toLowerCase(); return words.every(word=>text.includes(word)); } function groups(devices,dimensions,sites){ @@ -28,7 +28,8 @@ 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}]); + const ref=dimension==='manager'?device.managerRef:dimension==='owner'?device.ownerRef:''; + options=[...new Set(values)].map(value=>[{key:dimension+':'+(ref||value),label:value}]); } branches=branches.flatMap(branch=>options.map(option=>branch.concat(option))); } @@ -36,5 +37,19 @@ } return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN')); } - return {path,descendants,catalog,groups,matches}; + function network(device,state){ + const channels=(state.cameras||[]).filter(c=>c.assetId===device.id); + const direct=channels.filter(c=>c.sourceKind!=='recorder'&&c.host).map(c=>c.host); + const hosts=[...new Set((device.source==='recorder'?[device.host]:direct.length?direct:[device.managementHost,device.host]).filter(Boolean))]; + const via=[...new Set(channels.filter(c=>c.sourceKind==='recorder'&&c.recorderId).map(c=>c.recorderId))]; + const archiveVia=[...new Set(channels.filter(c=>c.archiveSource==='recorder'&&c.archiveRecorderId).map(c=>c.archiveRecorderId))]; + return {hosts,via,archiveVia,access:device.networkAccess||'unknown',channels}; + } + function topology(devices){ + const map=new Map(devices.map(d=>[d.id,d])),parents=new Map(); + for(const d of devices){let parent=d.uplinkId||'',cursor=parent,seen=new Set([d.id]);while(cursor&&map.has(cursor)){if(seen.has(cursor)){parent='';break;}seen.add(cursor);cursor=map.get(cursor).uplinkId;}parents.set(d.id,map.has(parent)?parent:'');} + const children=new Map();for(const d of devices){const parent=parents.get(d.id);if(!children.has(parent))children.set(parent,[]);children.get(parent).push(d);} + return {parents,children}; + } + return {path,descendants,catalog,groups,matches,network,topology}; }); diff --git a/web/devices.js b/web/devices.js index ecf591d..99b9d86 100644 --- a/web/devices.js +++ b/web/devices.js @@ -1,5 +1,14 @@ '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 `
IP/域名
${n.hosts.length?n.hosts.map(esc).join('
'):'待填写'}
网络接入
${esc(networkLabels[n.access]||networkLabels.unknown)}${d.tailscaleHost?'
'+esc(d.tailscaleHost):''}
`;} +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{const info=ZhaoDevices.network(d,state),children=tree.children.get(d.id)||[];return `
  • ${esc(d.deviceType)}

    ${esc(siteName(d.siteId))}${d.point?' · '+esc(d.point):''}

    ${networkInfo(d)}${d.networkCidr?`
    所提供网段
    ${esc(d.networkCidr)}
    `:''}
    物理连接
    ${d.uplinkId?esc(label(d.uplinkId))+' · ':''}${esc({wired:'网线连接',wifi:'Wi-Fi 连接'}[d.connectionType]||(d.uplinkId?'连接方式待确认':'上级及连接方式待确认'))}
    ${info.via.length?`

    取流经录像机:${info.via.map(label).map(esc).join('、')}

    `:''}${info.archiveVia.length?`

    录像归档:${info.archiveVia.map(label).map(esc).join('、')}

    `:''}${info.channels.length?`

    镜头码流:${info.channels.filter(c=>c.ready).length} / ${info.channels.length} 已接通

    `:'

    档案已登记,在线状态待核实

    '}
    ${children.length?`
      ${children.map(c=>nodes.get(c.id)).join('')}
    `:''}
  • `;}; + for(const d of queue.reverse())nodes.set(d.id,node(d)); + $('#device-topology').innerHTML=`

    ${esc(currentHome()?.name||'当前场所')} · 网络拓扑

    随设备新增、编辑和区域筛选自动更新。连线表示档案中已确认的上级关系;录像机取流与录像归档另行标注。未登记的交换机端口、AP 及网络设备仍需补充。

      ${(tree.children.get('')||[]).map(d=>nodes.get(d.id)).join('')||'
    • 当前范围没有已登记设备
    • '}
    `; +} const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'}; function renderDeviceGrouping(){ $('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`
    ${i+1}
    `).join(''); @@ -16,7 +25,7 @@ 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}
    ${videoDeviceActions(d)}${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(' / ')||'待填写')}
    ${networkInfo(d)}${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']; @@ -29,12 +38,35 @@ function renderDevices(){ 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)); - $('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`; + $('#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')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(' → ')||'全部平铺'} 查看`; $('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`

    ${group.labels.map(esc).join(' ')||'全部设备'} ${group.items.length} 台

    ${group.items.map(deviceCard).join('')}
    `).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。'); } -function resetDeviceView(){deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='';$('#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 resetDeviceView(){devicePresentation='cards';deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='';$('#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=''+ZhaoDevices.catalog(state).filter(d=>d.id!==item?.id).map(d=>``).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=''+['账号成员','家谱人物'].map(kind=>`${candidates.filter(p=>p.kind===kind).map(p=>``).join('')}`).join('')+(missing?``:'')+''; + 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=''+spaceOptions();fillForm(f,item);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';responsibilityFields(f,item);$('#device-dialog').showModal(); @@ -45,6 +77,7 @@ function spaceOptions(exclude=''){ } 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();}}; diff --git a/web/households.js b/web/households.js index bb0a25d..cc27c8a 100644 --- a/web/households.js +++ b/web/households.js @@ -2,15 +2,50 @@ pageInfo.households=['我的家庭与场所','家庭、办公室或工厂,分别组织,各自授权。']; const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭'); let coverSaving=false; +let homeLayoutDraft=null,homeLayoutOwner='',homeLayoutDirty=false,homeLayoutSaving=false,homePage=0,homeArrange=false,homeDragId=''; +let homePresentation='cards'; +function homeOrder(homes,order){const byId=new Map(homes.map(h=>[h.id,h]));return [...new Set([...order,...homes.map(h=>h.id)])].filter(id=>byId.has(id)).map(id=>byId.get(id));} +function homeMove(id,step,target=''){ + if(homeLayoutSaving)return; + const order=homeOrder(state.households||[],homeLayoutDraft.order).map(h=>h.id),from=order.indexOf(id),to=target?order.indexOf(target):from+step; + if(from<0||to<0||to>=order.length||from===to)return; + order.splice(from,1);order.splice(to,0,id);homeLayoutDraft.order=order;homeLayoutDirty=true;homePage=Math.floor(to/(homeLayoutDraft.columns*homeLayoutDraft.rows));renderHouseholds(); +} +function homeArrangeActions(id,homes){const index=homes.findIndex(h=>h.id===id);return homeArrange?`
    拖动卡片或移动顺序
    `:'';} function renderHouseholds(){ - const homes=state.households||[]; + const saved=state.householdLayout||{revision:0,columns:2,rows:2,groupCity:false,order:[]}; + if(homeLayoutOwner!==currentUser?.id){homeLayoutOwner=currentUser?.id||'';homeLayoutDirty=false;homeLayoutDraft=null;homePage=0;homeArrange=false;} + if(!homeLayoutDraft||!homeLayoutDirty&&!homeLayoutSaving&&homeLayoutDraft.revision!==saved.revision)homeLayoutDraft={...saved,order:[...saved.order]}; + const homes=homeOrder(state.households||[],homeLayoutDraft.order); setOptions($('#household-filter'),homes.map(h=>``).join('')); $('#household-filter').value=state.familyId||''; if(activePage!=='households')return; - const cards=h=>`
    ${h.coverUrl?`${esc(h.name)}封面照片`:''}
    ${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}${isAdmin()?``:''}

    ${esc(h.name)}

    ${esc(ZhaoGeography.describe(h)||'地区待填写')}

    ${esc(h.note||'区域与设备归属独立管理')}

    ${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}
    ${esc(h.ownerName||'待指定')}
    备用负责人
    ${esc(h.successorName||'待指定')}
    账号 / 空间
    ${h.members} 个 / ${h.sites} 处
    ${!h.successorId?'

    尚未指定备用负责人,建议提前安排可信人员接管。

    ':''}
    ${h.canAssignOwner?``:''}
    ${h.leadershipHistory?.length?`
    交接记录${h.leadershipHistory.slice().reverse().map(r=>`

    ${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}
    ${esc(r.reason)}

    `).join('')}
    `:''}
    `; - const groups=new Map();for(const h of homes){const key=[h.geography?.body&&h.geography.body!=='地球'?h.geography.body:'',h.geography?.country||h.country||'',h.province,h.city||'未填写城市/区域'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);} - $('#household-list').innerHTML=[...groups].sort(([a],[b])=>a.localeCompare(b,'zh-CN')).map(([city,rows])=>`

    ${esc(city)} ${rows.length} 个家庭或场所

    ${rows.map(cards).join('')}
    `).join(''); + $('#home-columns').value=homeLayoutDraft.columns;$('#home-rows').value=homeLayoutDraft.rows;$('#home-group-city').checked=homeLayoutDraft.groupCity; + if(homeLayoutDraft.groupCity)homeArrange=false; + $('#home-arrange').checked=homeArrange;$('#home-arrange').disabled=homeLayoutDraft.groupCity||homeLayoutSaving; + $('#home-layout-save').disabled=!homeLayoutDirty||homeLayoutSaving;$('#home-layout-reset').disabled=!homeLayoutDirty||homeLayoutSaving; + for(const id of ['home-columns','home-rows','home-group-city'])$('#'+id).disabled=homeLayoutSaving; + $('#home-layout-status').textContent=homeLayoutSaving?'正在保存布局…':homeLayoutDirty?'布局已调整,点击“保存我的布局”后下次打开会沿用。':homeLayoutDraft.groupCity?'按城市分组展示;取消分组后可自由排列卡片。':'可设置每页列数和行数;手机端自动收为单列。布局仅影响当前账号。'; + const capacity=homeLayoutDraft.columns*homeLayoutDraft.rows,pages=Math.max(1,Math.ceil(homes.length/capacity));homePage=Math.min(homePage,pages-1); + const visible=homes.slice(homePage*capacity,(homePage+1)*capacity); + $('#home-prev').disabled=homePage===0;$('#home-next').disabled=homePage>=pages-1;$('#home-page-info').textContent=`第 ${homePage+1} / ${pages} 页 · 共 ${homes.length} 个 · 每页最多 ${capacity} 张`; + $('#household-list').style.setProperty('--home-columns',homeLayoutDraft.columns); + const cards=h=>`
    ${h.coverUrl?`${esc(h.name)}封面照片`:''}
    ${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}${isAdmin()?``:''}

    ${esc(h.name)}

    ${esc(ZhaoGeography.describe(h)||'地区待填写')}

    ${homeArrangeActions(h.id,homes)}

    ${esc(h.note||'区域与设备归属独立管理')}

    ${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}
    ${esc(h.ownerName||'待指定')}
    备用负责人
    ${esc(h.successorName||'待指定')}
    账号 / 空间
    ${h.members} 个 / ${h.sites} 处
    ${!h.successorId?'

    尚未指定备用负责人,建议提前安排可信人员接管。

    ':''}
    ${h.canAssignOwner?``:''}
    ${h.leadershipHistory?.length?`
    交接记录${h.leadershipHistory.slice().reverse().map(r=>`

    ${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}
    ${esc(r.reason)}

    `).join('')}
    `:''}
    `; + const groups=new Map();for(const h of visible){const key=[h.geography?.body&&h.geography.body!=='地球'?h.geography.body:'',h.geography?.country||h.country||'',h.province,h.city||'未填写城市/区域'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);} + $('#household-list').innerHTML=homeLayoutDraft.groupCity?[...groups].map(([city,rows])=>`

    ${esc(city)}

    ${rows.map(cards).join('')}
    `).join(''):`
    ${visible.map(cards).join('')}
    `; + $('#household-list').hidden=homePresentation!=='cards';$('#home-global-topology').hidden=homePresentation!=='topology';$('.home-layout-controls').hidden=homePresentation!=='cards';$('.home-pagination').hidden=homePresentation!=='cards'; + $('#home-card-view').setAttribute('aria-pressed',String(homePresentation==='cards'));$('#home-topology-view').setAttribute('aria-pressed',String(homePresentation==='topology')); + if(homePresentation==='topology')$('#home-global-topology').innerHTML=`

    ${esc(typeof branding==='object'?branding.name:'赵府')} · 数字管家

    ${homes.length} 个已授权家庭/场所 · 点击进入内部拓扑

    此图表示平台与场所的管理关系;网络标签来自设备档案,连线不代表物理专线或实时在线状态。

    ${homes.map(h=>``).join('')}
    `; + for(const card of $$('#household-list .home-card')){card.draggable=homeArrange&&!homeLayoutSaving;card.ondragstart=e=>{if(e.target.closest('button,a,input,select')){e.preventDefault();return;}homeDragId=card.dataset.homeId;e.dataTransfer.setData('text/plain',homeDragId);};card.ondragover=e=>{if(homeArrange&&homeDragId)e.preventDefault();};card.ondrop=e=>{e.preventDefault();if(homeArrange&&homeDragId)homeMove(homeDragId,0,card.dataset.homeId);homeDragId='';};card.ondragend=()=>{homeDragId='';};} } +for(const [id,key] of [['home-columns','columns'],['home-rows','rows'],['home-group-city','groupCity']])$('#'+id).onchange=e=>{homeLayoutDraft[key]=key==='groupCity'?e.target.checked:Number(e.target.value);homePage=0;homeLayoutDirty=true;renderHouseholds();}; +$('#home-arrange').onchange=e=>{homeArrange=e.target.checked;renderHouseholds();}; +$('#home-prev').onclick=()=>{homePage=Math.max(0,homePage-1);renderHouseholds();};$('#home-next').onclick=()=>{homePage++;renderHouseholds();}; +$('#home-card-view').onclick=()=>{homePresentation='cards';renderHouseholds();};$('#home-topology-view').onclick=()=>{homePresentation='topology';renderHouseholds();}; +$('#home-global-topology').addEventListener('click',async e=>{const b=e.target.closest('[data-home-network]');if(!b)return;if(b.dataset.homeNetwork!==state.familyId&&!await switchHousehold(b.dataset.homeNetwork))return;devicePresentation='topology';showPage('objects');}); +$('#home-layout-reset').onclick=()=>{homeLayoutDirty=false;homeLayoutDraft=null;homePage=0;renderHouseholds();}; +$('#household-list').addEventListener('click',e=>{const button=e.target.closest('[data-home-move]');if(button)homeMove(button.dataset.homeMove,Number(button.dataset.step));}); +$('#home-layout-save').onclick=async()=>{if(homeLayoutSaving||!homeLayoutDirty)return;const generation=sessionGeneration;homeLayoutSaving=true;renderHouseholds();try{const saved=await api('/api/households/layout',homeLayoutDraft);if(generation!==sessionGeneration)return;state.householdLayout=saved;homeLayoutDraft={...saved,order:[...saved.order]};homeLayoutDirty=false;toast('卡片布局与顺序已保存');}catch(e){if(!e.stale)toast(e.message);}finally{homeLayoutSaving=false;if(generation===sessionGeneration)renderHouseholds();}}; async function switchHousehold(id){ if(id===state.familyId)return true; if(!isPlatformAdmin()||!id)return false; diff --git a/web/index.html b/web/index.html index 5263947..1463c0d 100644 --- a/web/index.html +++ b/web/index.html @@ -64,7 +64,7 @@
    选取并下载指定片段

    每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。

    - + @@ -90,17 +90,17 @@ - +
    赵府 · 智能数字管家独立部署 / 数据自有
    灯火可亲 · 归家有时

    欢迎回来

    使用自己的账号,进入家庭空间。

    -

    设备档案

    手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。

    +

    设备档案

    网络与连接

    保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。

    手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。

    传承资料

    这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。

    空间档案

    镜头通道配置

    直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。

    账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。

    -

    摄像头对象

    -

    录像机配置

    驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。

    +

    摄像头对象

    网络与连接

    保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。

    +

    录像机配置

    网络与连接

    保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。

    驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。

    负责人安排与交接

    先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。

    交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。

    家庭 / 场所档案

    照片仅向有权进入该家庭或场所的账号开放。

    创建后进入该归属,建立管理员,再自行划分区域和登记设备。

    账号档案

    允许查看摄像头的空间

    普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。

    diff --git a/web/steward.js b/web/steward.js index eb4df59..ba98473 100644 --- a/web/steward.js +++ b/web/steward.js @@ -53,7 +53,7 @@ function renderOverview(){ } function renderDeviceDetail(device){ if(!device){$('#device-detail').innerHTML=empty('设备暂不可用','返回设备目录重新选择');return;} - const fields=[['类型',device.deviceType],['所属场所',currentHome()?.name],['所在区域',siteName(device.siteId)],['所有人',device.ownerName||'待指定'],['负责人',device.managerName||'待指定'],['品牌 / 型号',[device.brand,device.model].filter(Boolean).join(' / ')||'待填写'],['序列号',device.serial||'待填写']]; + const fields=[['类型',device.deviceType],['所属场所',currentHome()?.name],['所在区域',siteName(device.siteId)],['所有人',device.ownerName||'待指定'],['负责人',device.managerName||'待指定'],['品牌 / 型号',[device.brand,device.model].filter(Boolean).join(' / ')||'待填写'],['序列号',device.serial||'待填写'],['IP/域名',ZhaoDevices.network(device,state).hosts.join(' / ')||'待填写'],['网络接入',networkLabels[device.networkAccess||'unknown']],['Tailscale 地址',device.tailscaleHost||'未填写']]; const attr=device.source==='camera'?'edit-asset':device.source==='recorder'?'edit-recorder':'edit-device'; $('#device-detail').innerHTML=`

    一物一档

    ${isAdmin()?``:''}
    ${fields.map(([k,v])=>`
    ${esc(k)}
    ${esc(v)}
    `).join('')}

    ${esc(device.note||'')}

    ${videoDeviceActions(device)}${device.source==='device'?'

    当前已登记档案;自动控制需按实际设备协议接入。

    ':''}${device.source==='recorder'?'

    可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。

    ':''}
    `; }