Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf895110ee |
@@ -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 及物理线路仍需补充。图上的接入方式是档案属性,不会开放端口或自动授予网络权限,也不表示设备已经通过在线探测。录像机历史回放仍需设备协议适配。
|
||||
|
||||
公开源码不包含设备凭据、用户资料、业务数据库或网盘授权信息。
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+66
-2
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -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')<out.innerHTML.indexOf('Home'));
|
||||
assert.equal(node('#home-arrange').disabled,true);
|
||||
});
|
||||
|
||||
test('publication text is rendered as text and the export omits private state',()=>{
|
||||
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/);
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
+19
-4
@@ -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};
|
||||
});
|
||||
|
||||
+38
-5
@@ -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 `<div><dt>IP/域名</dt><dd>${n.hosts.length?n.hosts.map(esc).join('<br>'):'待填写'}</dd></div><div><dt>网络接入</dt><dd>${esc(networkLabels[n.access]||networkLabels.unknown)}${d.tailscaleHost?'<br>'+esc(d.tailscaleHost):''}</dd></div>`;}
|
||||
function renderTopology(devices){
|
||||
const tree=ZhaoDevices.topology(devices),all=ZhaoDevices.catalog(state),label=id=>all.find(d=>d.id===id)?.name||'关联设备待核对';
|
||||
const nodes=new Map(),queue=[...(tree.children.get('' )||[])];for(let i=0;i<queue.length;i++)queue.push(...(tree.children.get(queue[i].id)||[]));const node=d=>{const info=ZhaoDevices.network(d,state),children=tree.children.get(d.id)||[];return `<li><article class="topology-node"><span class="badge">${esc(d.deviceType)}</span><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} ›</button></h3><p>${esc(siteName(d.siteId))}${d.point?' · '+esc(d.point):''}</p><dl>${networkInfo(d)}${d.networkCidr?`<div><dt>所提供网段</dt><dd>${esc(d.networkCidr)}</dd></div>`:''}<div><dt>物理连接</dt><dd>${d.uplinkId?esc(label(d.uplinkId))+' · ':''}${esc({wired:'网线连接',wifi:'Wi-Fi 连接'}[d.connectionType]||(d.uplinkId?'连接方式待确认':'上级及连接方式待确认'))}</dd></div></dl>${info.via.length?`<p>取流经录像机:${info.via.map(label).map(esc).join('、')}</p>`:''}${info.archiveVia.length?`<p>录像归档:${info.archiveVia.map(label).map(esc).join('、')}</p>`:''}${info.channels.length?`<p>镜头码流:${info.channels.filter(c=>c.ready).length} / ${info.channels.length} 已接通</p>`:'<p class="description">档案已登记,在线状态待核实</p>'}</article>${children.length?`<ul>${children.map(c=>nodes.get(c.id)).join('')}</ul>`:''}</li>`;};
|
||||
for(const d of queue.reverse())nodes.set(d.id,node(d));
|
||||
$('#device-topology').innerHTML=`<div class="panel"><h3>${esc(currentHome()?.name||'当前场所')} · 网络拓扑</h3><p class="description">随设备新增、编辑和区域筛选自动更新。连线表示档案中已确认的上级关系;录像机取流与录像归档另行标注。未登记的交换机端口、AP 及网络设备仍需补充。</p><ul class="topology-tree">${(tree.children.get('')||[]).map(d=>nodes.get(d.id)).join('')||'<li>当前范围没有已登记设备</li>'}</ul></div>`;
|
||||
}
|
||||
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
|
||||
function renderDeviceGrouping(){
|
||||
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
|
||||
@@ -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'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id||c.archiveRecorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
|
||||
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${videoDeviceActions(d)}${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
|
||||
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${networkInfo(d)}${detail}</dl>${videoDeviceActions(d)}${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
|
||||
}
|
||||
function renderDevices(){
|
||||
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
|
||||
@@ -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=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
|
||||
}
|
||||
function resetDeviceView(){deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#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='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
|
||||
function responsibilityFields(form,item){
|
||||
for(const field of ['managementHost','tailscaleHost','networkCidr','networkAccess','connectionType'])form.elements[field].value=item?.[field]||(['networkAccess','connectionType'].includes(field)?'unknown':'');
|
||||
const uplink=form.elements.uplinkId;uplink.innerHTML='<option value="">上级设备待确认/直接接入</option>'+ZhaoDevices.catalog(state).filter(d=>d.id!==item?.id).map(d=>`<option value="${esc(d.id)}">${esc(d.name+' · '+d.deviceType)}</option>`).join('');uplink.value=item?.uplinkId||'';
|
||||
form.elements.managementHost.closest('label').hidden=form.id==='recorder-form';
|
||||
const direct=form.id==='asset-form'&&state.cameras.some(c=>c.assetId===item?.id&&c.sourceKind!=='recorder');
|
||||
form.elements.managementHost.readOnly=direct;
|
||||
form.elements.managementHost.title=direct?'此地址来自镜头通道,请在“配置镜头”内修改':'';
|
||||
if(direct)form.elements.managementHost.value=ZhaoDevices.network(item,state).hosts[0]||'';
|
||||
const candidates=state.responsibilityOptions||[];
|
||||
for(const prefix of ['owner','manager']){
|
||||
const input=form.elements[prefix+'Name'],select=form.elements[prefix+'Ref'];
|
||||
input.value=item?.[prefix+'Name']||'';
|
||||
const ref=item?.[prefix+'Ref']||'',missing=ref&&!candidates.some(p=>p.ref===ref);
|
||||
select.innerHTML='<option value="">暂未指定</option>'+['账号成员','家谱人物'].map(kind=>`<optgroup label="${kind}">${candidates.filter(p=>p.kind===kind).map(p=>`<option value="${esc(p.ref)}">${esc(p.label)}</option>`).join('')}</optgroup>`).join('')+(missing?`<option value="${esc(ref)}">${esc(input.value)}(原关联人员,保留记录)</option>`:'')+'<option value="custom">手动填写人员或单位</option>';
|
||||
select.value=ref||(input.value?'custom':'');
|
||||
input.hidden=select.value!=='custom';input.required=select.value==='custom';
|
||||
select.onchange=()=>{input.hidden=select.value!=='custom';input.required=select.value==='custom';if(select.value==='custom'){input.value='';input.focus();}else input.value=candidates.find(p=>p.ref===select.value)?.name||(select.value===ref?item?.[prefix+'Name']||'':'');};
|
||||
}
|
||||
form.elements.tags.value=(item?.tags||[]).join('、');
|
||||
}
|
||||
function deviceValues(data){const result={...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};for(const prefix of ['owner','manager']){if(result[prefix+'Ref']==='custom')result[prefix+'Ref']='';else if(!result[prefix+'Ref'])result[prefix+'Name']='';}return result;}
|
||||
function openDevice(id){
|
||||
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
|
||||
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';responsibilityFields(f,item);$('#device-dialog').showModal();
|
||||
@@ -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();}};
|
||||
|
||||
+39
-4
@@ -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?`<div class="home-order-actions"><span>拖动卡片或移动顺序</span><button class="quiet" data-home-move="${esc(id)}" data-step="-1" aria-label="${esc(homes[index].name)}前移" ${index===0?'disabled':''}>← 前移</button><button class="quiet" data-home-move="${esc(id)}" data-step="1" aria-label="${esc(homes[index].name)}后移" ${index===homes.length-1?'disabled':''}>后移 →</button></div>`:'';}
|
||||
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=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
|
||||
$('#household-filter').value=state.familyId||'';
|
||||
if(activePage!=='households')return;
|
||||
const cards=h=>`<article class="site-card home-card"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span>${isAdmin()?`<button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button>`:''}</div><h3>${esc(h.name)}</h3><p class="home-location">${esc(ZhaoGeography.describe(h)||'地区待填写')}</p></div></div><div class="home-card-body"><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">进入${esc(h.name)}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</div></article>`;
|
||||
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])=>`<section class="home-city-group"><h3>${esc(city)} <small>${rows.length} 个家庭或场所</small></h3><div class="site-grid">${rows.map(cards).join('')}</div></section>`).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=>`<article class="site-card home-card" data-home-id="${esc(h.id)}"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span>${isAdmin()?`<button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button>`:''}</div><h3>${esc(h.name)}</h3><p class="home-location">${esc(ZhaoGeography.describe(h)||'地区待填写')}</p></div></div><div class="home-card-body">${homeArrangeActions(h.id,homes)}<p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">进入${esc(h.name)}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</div></article>`;
|
||||
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])=>`<section class="home-city-group"><h3>${esc(city)}</h3><div class="home-card-grid">${rows.map(cards).join('')}</div></section>`).join(''):`<div class="home-card-grid">${visible.map(cards).join('')}</div>`;
|
||||
$('#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=`<div class="platform-topology-root"><h3>${esc(typeof branding==='object'?branding.name:'赵府')} · 数字管家</h3><p>${homes.length} 个已授权家庭/场所 · 点击进入内部拓扑</p></div><p class="description">此图表示平台与场所的管理关系;网络标签来自设备档案,连线不代表物理专线或实时在线状态。</p><div class="platform-place-grid">${homes.map(h=>`<button class="platform-place-node" data-home-network="${esc(h.id)}"><span class="badge">${esc(scopeKind(h))}</span><h3>${esc(h.name)}</h3><p>${esc(ZhaoGeography.describe(h)||'地址待填写')}</p><p>${h.deviceCount||0} 台已授权设备</p><small>${(h.networkAccesses||[]).map(x=>esc(networkLabels[x]||'接入线路待确认')).join(' · ')||'尚未登记网络设备'}</small><span>查看场所网络拓扑 →</span></button>`).join('')}</div>`;
|
||||
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;
|
||||
|
||||
+5
-5
@@ -64,7 +64,7 @@
|
||||
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
|
||||
</section>
|
||||
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查 IP、品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div class="device-actions" role="group" aria-label="设备展示方式"><button id="devices-card-view" class="quiet" aria-pressed="true">设备卡片</button><button id="devices-topology-view" class="quiet" aria-pressed="false">网络拓扑</button></div><div id="asset-list"></div><section id="device-topology" hidden aria-label="当前范围网络拓扑"></section></section>
|
||||
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
@@ -90,17 +90,17 @@
|
||||
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
|
||||
</div>
|
||||
</section>
|
||||
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div id="household-list" class="site-grid"></div></section>
|
||||
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div class="device-actions" role="group" aria-label="家庭场所展示方式"><button id="home-card-view" class="quiet" aria-pressed="true">场所卡片</button><button id="home-topology-view" class="quiet" aria-pressed="false">赵府整体拓扑</button></div><section id="home-global-topology" hidden aria-label="平台与家庭场所拓扑"></section><div class="panel home-layout-controls"><div class="device-actions"><label>每页列数<select id="home-columns"><option value="1">1 列</option><option value="2">2 列</option><option value="3">3 列</option><option value="4">4 列</option></select></label><span aria-hidden="true">×</span><label>每页行数<select id="home-rows"><option value="1">1 行</option><option value="2">2 行</option><option value="3">3 行</option><option value="4">4 行</option></select></label><label class="check"><input id="home-group-city" type="checkbox">按城市分组</label><label class="check"><input id="home-arrange" type="checkbox">调整卡片顺序</label><button id="home-layout-save" class="secondary">保存我的布局</button><button id="home-layout-reset" class="quiet">撤销调整</button></div><p id="home-layout-status" class="description" role="status">布局保存到当前账号,手机端自动适配。</p></div><div id="household-list"></div><div class="home-pagination"><button id="home-prev" class="quiet">上一页</button><span id="home-page-info" aria-live="polite"></span><button id="home-next" class="quiet">下一页</button></div></section>
|
||||
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
|
||||
<footer><span id="brand-footer">赵府 · 智能数字管家</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||
</main>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
||||
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="路由器"><option value="交换机"><option value="无线接入点"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
||||
<dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog>
|
||||
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><fieldset class="full geo-picker" data-geography="" data-geo-title="空间所在地"></fieldset><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
||||
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>ONVIF 服务端口<input name="onvifPort" type="number" value="80" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
||||
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
||||
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
|
||||
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="1" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
||||
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
|
||||
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
|
||||
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><input name="profileRevision" type="hidden" value="0"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><fieldset class="full geo-picker" data-geography="" data-geo-title="所在地"></fieldset><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-cancel" class="quiet">取消本次图片选择</button><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description" role="status" aria-live="polite">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
|
||||
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
|
||||
|
||||
+1
-1
@@ -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=`<section class="panel"><div class="section-head"><h2>一物一档</h2>${isAdmin()?`<button class="quiet" data-${attr}="${esc(device.id)}">编辑设备档案</button>`:''}</div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl><p class="description">${esc(device.note||'')}</p>${videoDeviceActions(device)}${device.source==='device'?'<p class="description">当前已登记档案;自动控制需按实际设备协议接入。</p>':''}${device.source==='recorder'?'<p class="description">可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。</p>':''}</section>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user