diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b960f23..dc92e53 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -9,7 +9,9 @@
## 对象关系
```text
-空间档案(家庭 / 公司 / 其他)
+管理归属(家庭 / 办公场所 / 工厂,独立权限)
+ └─ 自定义区域树(parentId,不限制层数,禁止循环及跨归属父节点)
+ └─ 空间档案(名称、区域类别及可选地址)
├─ 地区:省、市、区县、街道
├─ 地址:小区或园区、楼栋、单元、楼层、门牌
├─ 所属:家庭或单位
@@ -17,6 +19,7 @@
│ ├─ 镜头通道 1 → 主码流 / 子码流
│ └─ 镜头通道 2 → 主码流 / 子码流
└─ 录像机对象(地址、接入驱动、账号、空间引用)
+ └─ 其他设备档案(灯、手机、冰箱或自定类型,暂不含控制驱动)
每个镜头通道分别关联:
实时来源:直接摄像头 / 录像机
@@ -76,7 +79,7 @@
## 家庭边界与管理连续性
-账号 → 一个家庭 → 多个空间 → 设备对象 / 镜头 / 录像机。人物、亲属关系、记事和历史版本也属于一个家庭。
+账号 → 一个管理归属(家庭 / 办公场所 / 工厂)→ 多级区域 → 设备对象 / 镜头。人物、亲属关系、记事和历史版本也属于同一管理归属。兼容已有接口,数据库仍使用 `households`、`familyId` 命名。
- `X-Household-Id` 仅供超级管理员选择工作家庭;其余账号只能使用自己的家庭。所有写操作均核验所属家庭与关联对象,不能通过 body 迁移归属。
- API 状态与账号列表按家庭过滤。媒体 URL 不依赖前端过滤,每个清单、分片与下载请求均核验摄像头归属和空间授权;超级管理员是明确的特权例外。
@@ -86,3 +89,12 @@
- 任内负责人不能被直接停用、降级;先交接,再调整账号。交接后原家主保留家庭管理员,必要时由新家主或超级管理员处理其账号。
- 家谱人物的去世日期、账号长期未登录、亲属称谓均不触发权限变化。
- 首次迁移只回填 `familyId`,不修改凭据或设备路径;旧历史快照也加归属。旧版本没有此隔离能力,多家庭运行后禁止只回退代码。
+
+
+## 设备分类与区域授权
+
+`inventory.py` 保存通用设备档案以及所有人、负责人、标签。视频设备继续使用原 `assets`、`recorders`、`cameras` 结构,不迁移凭据、不修改录像配置。前端 `device-model.js` 将三类档案统一为只读目录,按每个归属保存的维度顺序分组。修改分组只改变展示,不移动设备或放宽权限。
+
+区域以 `parentId` 邻接表表示,创建/移动时在写锁内检查父节点归属、有效性与祖先循环。父链和后代集合均迭代计算,避免递归栈形成层数限制。用户的 `includeSubspaces` 默认关闭;开启后按当前区域树实时扩展 `siteIds`。修改该授权开关会撤销已有会话。人员归属是文本档案,不参与权限计算。
+
+新增接口:`POST /api/devices` 保存通用设备(要求当前归属管理员);`POST /api/device-view` 保存当前归属默认分组顺序。统一状态的 `devices` 数组也经过归属和成员区域过滤。
diff --git a/README.md b/README.md
index 3fa1c27..be483a0 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,8 @@ v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点
## 对象与空间
-- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
+- **区域与空间**:各家庭、办公场所、工厂独立设置区域树,自定义名称、类别与上级区域,不设固定层数。地址可记录省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌。
+- **统一设备管理**:摄像机、录像机与其他设备统一展示,支持按类型、区域、负责人、所有人、自定义标签调整分组顺序。灯、手机、冰箱等当前提供档案登记,实际控制需要另行接入协议。
- **摄像头对象**:实体设备的稳定 ID、品牌、型号、序列号、镜头数、安装位置和空间引用。
- **镜头通道**:每个物理镜头一个通道,关联摄像头对象,配置主、子码流。双镜头摄像头的主、子码流不能当作两个物理镜头。
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
@@ -53,7 +54,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml
```
-回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets test_households` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js test_households_ui.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
+回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets test_households` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js test_households_ui.js test_devices.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。
@@ -192,10 +193,19 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
- **家庭管理员**:管理本家庭设备、空间、家谱与普通成员;有家主后,其他管理员账号的设置由家主或超级管理员办理。
- **家庭成员**:仅能查看本家庭已授权空间的画面与录像;家谱、云台另行授权。新账号默认不获得设备和家谱权限。亲属称呼不授予权限。
-在「家庭管理」新建家庭,进入该家庭,在「账号与关系」建立家庭管理员。回到「家庭管理 → 家主与权限交接」,指定一家之主和另一位备用负责人,填写说明并保存。**备用负责人已经拥有本家庭管理权限**,家主意外无法操作时可以继续管理设备和成员;家主身份由原家主或超级管理员正式交接,原家主保留家庭管理员权限。不会依据去世日期或未登录时长自动授权。不允许停用或降级尚在任的家主、备用负责人;须先更换安排。至少保留一位启用的家庭管理员。交接使用修订号防止并发覆盖,并保留最近 50 条记录。普通成员不能读取交接说明。
+在「家庭与场所」新建家庭、办公场所或工厂,进入该家庭,在「账号与关系」建立家庭管理员。回到「家庭与场所 → 负责人安排与交接」,指定一家之主和另一位备用负责人,填写说明并保存。**备用负责人已经拥有本家庭管理权限**,家主意外无法操作时可以继续管理设备和成员;家主身份由原家主或超级管理员正式交接,原家主保留家庭管理员权限。不会依据去世日期或未登录时长自动授权。不允许停用或降级尚在任的家主、备用负责人;须先更换安排。至少保留一位启用的家庭管理员。交接使用修订号防止并发覆盖,并保留最近 50 条记录。普通成员不能读取交接说明。
家庭隔离由服务器执行:账号、空间、对象、镜头、录像机、家谱人物、关系、事件与历史版本均有家庭归属。HLS 播放清单、分片、回放、下载及设备控制均逐请求核验;修改请求检查每个关联对象的家庭。普通账号伪造家庭请求头或猜测其他家庭的对象编号也不能获得资料。超级管理员具有跨家庭权限。家庭切换先清除播放器、表单和旧请求;各家庭独立计算家谱修订号。
升级前用 SQLite backup API 备份数据库。首次启动把旧数据归入「赵府」家庭,保留原 ID、密码散列、设备连接和会话;家主与备用负责人须在界面明确指定,不从姓名或辈分猜测。已有 `admin` 角色均保留超级权限,请按实际职责设为家庭管理员。**建立第二个家庭后,不能直接回退到 v0.1.22 或更早版本**,旧版本不执行家庭隔离;应修复前进,或连同升级前数据库及部署版本一起恢复并确保后续数据另有备份。
这是单台受信主机上的应用权限隔离。各家庭的设备仍共享媒体服务和存储容量,家庭管理员不具有操作系统权限;尚未提供按家庭磁盘配额、独立网络、硬盘加密或无人审批的自动继承。
+
+
+### 多维设备分类与区域树(v0.1.23)
+
+- 在「家庭与场所」选择管理归属,再在「区域与空间」添加区域;选择上级区域即可继续细分,例如「厂区 → 车间 → 产线 → 工位」。名称、区域类别、地址按需填写,程序没有固定层数上限。不能把区域放到自身或后代下,也不能跨家庭/场所挂接。
+- 在「设备管理」登记设备。摄像机与录像机保留已有接入、通道功能,其他设备使用可自定的类型;同一设备只存一份档案。所有人、负责人是责任信息,不会自动授予账号查看或控制权限。
+- 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。
+- 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。
+- 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。
diff --git a/app.py b/app.py
index 3b5cfcb..1e21cbd 100644
--- a/app.py
+++ b/app.py
@@ -32,6 +32,7 @@ import playback
import identity
import genealogy
import households
+import inventory
import ptz
ROOT = Path(__file__).resolve().parent
@@ -75,6 +76,7 @@ class Problem(Exception):
ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
+INVENTORY = inventory.Inventory(sys.modules[__name__])
def setting(key, default=None):
@@ -165,13 +167,21 @@ def object_key(table, body):
@serialized_save
def save_site(body, actor=None):
- family = HOUSEHOLDS.prepare(actor, 'sites', body)
+ family = HOUSEHOLDS.prepare(actor, 'sites', body, (('sites', 'parentId'),))
key = object_key('sites', body)
+ parent = clean_text(body.get('parentId', ''), 32)
+ ancestor, seen = parent, {key}
+ while ancestor:
+ if ancestor in seen:
+ raise Problem('区域不能放到自己或自己的下级区域中')
+ seen.add(ancestor)
+ ancestor = (get_object('sites', ancestor) or {}).get('parentId', '')
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS}
- item.update(id=key, kind=body.get('kind', 'home'), familyId=family)
- if item['kind'] not in ('home', 'company', 'other'):
- raise Problem('请选择家庭、公司或其他用途')
+ item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
+ category=clean_text(body.get('category', ''), 40))
+ if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
+ raise Problem('请选择有效区域用途')
save_object('sites', item)
audit('保存空间', item['name'])
return item
@@ -238,6 +248,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)))
attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数')
@@ -269,6 +280,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))
save_object('recorders', item)
write_media_config()
audit('保存录像机', item['name'])
@@ -411,7 +423,7 @@ def status():
storage = dict(STORAGE)
scan = dict(SCAN)
return {'cameras': cameras, 'assets': objects('assets'), 'recorders': [public_camera(r) for r in objects('recorders')],
- 'sites': objects('sites'), 'mediaOnline': media,
+ 'sites': objects('sites'), 'devices': objects('devices'), 'mediaOnline': media,
'storage': {**setting('storage'), **storage}, 'scan': scan,
'version': (ROOT / 'VERSION').read_text().strip(), 'uptime': round(time.time() - STARTED)}
@@ -660,6 +672,10 @@ class Handler(BaseHTTPRequestHandler):
self.answer(HOUSEHOLDS.save(data, self.user))
elif path == '/api/households/leadership':
self.answer(HOUSEHOLDS.leadership(data, self.user))
+ elif path == '/api/device-view':
+ self.answer(HOUSEHOLDS.save_view(data, self.user))
+ elif path == '/api/devices':
+ self.answer(INVENTORY.save(data, self.user))
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites':
@@ -900,6 +916,7 @@ class Handler(BaseHTTPRequestHandler):
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
+ '/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
diff --git a/households.py b/households.py
index 6d8afb6..5d33b13 100644
--- a/households.py
+++ b/households.py
@@ -6,7 +6,7 @@ import secrets
class Households:
- TABLES = ('sites', 'assets', 'recorders', 'cameras', 'people', 'family_links', 'family_events', 'users')
+ TABLES = ('sites', 'assets', 'recorders', 'cameras', 'devices', 'people', 'family_links', 'family_events', 'users')
def __init__(self, app):
self.a = app
@@ -19,6 +19,7 @@ class Households:
a = self.a
with a.LOCK, a.DB:
a.DB.execute('CREATE TABLE IF NOT EXISTS households (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
+ a.DB.execute('CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
default = a.setting('defaultHousehold')
if not default:
default = secrets.token_hex(8)
@@ -69,6 +70,22 @@ class Households:
family = self.scope(user)
return [x for x in self.a.objects(table) if self.family_of(x) == family]
+ def site_ids(self, user):
+ """Optional branch grants, always restricted to the account's own organization."""
+ sites = self.objects(user, 'sites')
+ ids = {s['id'] for s in sites if s['id'] in user.get('siteIds', [])}
+ if user.get('includeSubspaces', False):
+ children = {}
+ for site in sites:
+ children.setdefault(site.get('parentId'), []).append(site['id'])
+ queue = list(ids)
+ for parent in queue:
+ for child in children.get(parent, []):
+ if child not in ids:
+ ids.add(child)
+ queue.append(child)
+ return ids
+
def prepare(self, user, table, body, references=()):
family = self.scope(user)
if body.get('familyId', family) != family:
@@ -137,8 +154,12 @@ class Households:
raise a.Problem('家庭不存在', 404)
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
raise a.Problem('只能管理自己所属的家庭', 403)
+ kind = data.get('kind', old.get('kind', 'home') if old else 'home')
+ if kind not in ('home', 'office', 'factory'):
+ raise a.Problem('请选择家庭、办公场所或工厂')
item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8),
name=a.clean_text(data.get('name', ''), 80, True),
+ kind=kind,
note=a.clean_text(data.get('note', ''), 300),
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
a.save_object('households', item)
@@ -148,3 +169,17 @@ class Households:
def revision_key(self, user):
family = self.scope(user)
return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family
+
+ def save_view(self, data, user):
+ if not self.manager(user):
+ raise self.a.Problem('需要场所管理员权限', 403)
+ groups = data.get('groups')
+ if (not isinstance(groups, list) or len(groups) > 5 or
+ any(g not in ('type', 'space', 'manager', 'owner', 'tag') for g in groups) or len(set(groups)) != len(groups)):
+ raise self.a.Problem('分组维度不可重复,请选择类型、区域、负责人、所有人或标签')
+ with self.a.LOCK:
+ home = self.a.get_object('households', self.scope(user))
+ home['deviceGrouping'] = groups
+ self.a.save_object('households', home)
+ self.a.audit('更新设备分组方式', home['name'])
+ return {'ok': True}
diff --git a/identity.py b/identity.py
index efbc04a..492c4c7 100644
--- a/identity.py
+++ b/identity.py
@@ -81,6 +81,9 @@ class Accounts:
ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False)
if not isinstance(ptz_control, bool):
raise a.Problem('云台权限格式不正确')
+ descendants = data.get('includeSubspaces', old.get('includeSubspaces', False) if old else False)
+ if not isinstance(descendants, bool):
+ raise a.Problem('下级区域授权格式不正确')
disabled = data.get('disabled', False)
if not isinstance(disabled, bool):
raise a.Problem('账号状态不正确')
@@ -113,10 +116,10 @@ class Accounts:
credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')}
user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True),
role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
- personId=person, relatedToId=related,
+ personId=person, relatedToId=related, includeSubspaces=descendants,
relationship=a.clean_text(data.get('relationship', ''), 40), **credentials)
# Revoke sessions when credentials or authorization change, not for label edits.
- if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')):
+ if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')):
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
a.save_object('users', user)
a.audit('更新账号' if old else '建立账号', username)
@@ -154,7 +157,7 @@ class Accounts:
site = self.a.get_object('sites', camera.get('siteId'))
if site:
self.a.HOUSEHOLDS.owns(user, site)
- if not self.a.HOUSEHOLDS.manager(user) and camera.get('siteId') not in user['siteIds']:
+ if not self.a.HOUSEHOLDS.manager(user) and camera.get('siteId') not in self.a.HOUSEHOLDS.site_ids(user):
raise self.a.Problem('没有这个空间的访问权限', 403)
return camera
@@ -171,16 +174,17 @@ class Accounts:
family = self.a.HOUSEHOLDS.scope(user)
state['familyId'] = family
state['households'] = self.a.HOUSEHOLDS.list(user)
- for key in ('sites', 'assets', 'cameras', 'recorders'):
- state[key] = [x for x in state[key] if self.a.HOUSEHOLDS.family_of(x) == family]
+ 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]
if user['role'] != 'admin':
state['scan'] = {}
state['storage'] = {}
if self.a.HOUSEHOLDS.manager(user):
return state
- permitted = set(user['siteIds'])
+ permitted = self.a.HOUSEHOLDS.site_ids(user)
state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted]
+ state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted]
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource')
state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
state['recorders'] = []
diff --git a/inventory.py b/inventory.py
new file mode 100644
index 0000000..89a8aff
--- /dev/null
+++ b/inventory.py
@@ -0,0 +1,30 @@
+"""Common device responsibility fields and non-video device records."""
+class Inventory:
+ def __init__(self, app):
+ self.a = app
+
+ def metadata(self, data, old=None):
+ old = old or {}
+ result = {k: self.a.clean_text(data.get(k, old.get(k, '')), 80)
+ for k in ('ownerName', 'managerName')}
+ tags = data.get('tags', old.get('tags', []))
+ if not isinstance(tags, list) or len(tags) > 12:
+ raise self.a.Problem('自定义标签最多 12 个')
+ result['tags'] = sorted({self.a.clean_text(tag, 40, True) for tag in tags})
+ return result
+
+ def save(self, data, user):
+ a = self.a
+ with a.LOCK:
+ family = a.HOUSEHOLDS.prepare(user, 'devices', data, (('sites', 'siteId'),))
+ key = a.object_key('devices', data)
+ old = a.get_object('devices', key)
+ item = {k: a.clean_text(data.get(k, ''), 300 if k == 'note' else 120, k in ('name', 'deviceType'))
+ for k in ('name', 'deviceType', 'brand', 'model', 'serial', 'note')}
+ 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))
+ a.save_object('devices', item)
+ a.audit('保存设备档案', item['name'])
+ return item
diff --git a/test_app.py b/test_app.py
index fd5e634..8248dd2 100644
--- a/test_app.py
+++ b/test_app.py
@@ -105,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
- self.assertEqual(len(assets), 20)
+ self.assertEqual(len(assets), 22)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
@@ -256,6 +256,7 @@ class WebTests(unittest.TestCase):
self.assertIn('pipe:1', command)
def test_ptz_requires_both_space_and_control_permission(self):
+ app.save_object('sites', dict(id='permitted', name='Permitted'))
camera = dict(id='e'*16, name='Test', siteId='permitted', enabled=True)
viewer = dict(role='member', siteIds=['permitted'], ptzControl=False)
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
diff --git a/test_devices.js b/test_devices.js
new file mode 100644
index 0000000..1a3ef27
--- /dev/null
+++ b/test_devices.js
@@ -0,0 +1,30 @@
+const test=require('node:test'),assert=require('node:assert/strict'),m=require('./web/device-model.js');
+const sites=[{id:'floor',name:'一楼'},{id:'bed',name:'卧室',parentId:'floor'},{id:'office',name:'办公室'}];
+const devices=[{id:'lamp',name:'卧室台灯',deviceType:'灯',siteId:'bed',managerName:'乙',ownerName:'甲',tags:['照明','常用']},{id:'desk',name:'办公灯',deviceType:'灯',siteId:'office',managerName:'甲',ownerName:'乙',tags:['照明']},{id:'phone',name:'手机',deviceType:'手机',siteId:'bed',managerName:'乙',ownerName:'乙',tags:[]}];
+test('type then area and area then type reuse the same device identities',()=>{
+ const a=m.groups(devices,['type','space'],sites),b=m.groups(devices,['space','type'],sites);
+ assert.deepEqual(a.find(g=>g.items.some(x=>x.id==='lamp')).labels,['灯','一楼','卧室']);
+ assert.deepEqual(b.find(g=>g.items.some(x=>x.id==='lamp')).labels,['一楼','卧室','灯']);
+ assert.equal(a.flatMap(g=>g.items).length,3);assert.equal(b.flatMap(g=>g.items).length,3);
+});
+test('responsible person differs from owner and supports multitag grouping',()=>{
+ assert.equal(m.groups(devices,['manager'],sites).find(g=>g.labels[0]==='乙').items.length,2);
+ assert.equal(m.groups(devices,['owner'],sites).find(g=>g.labels[0]==='乙').items.length,2);
+ assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='照明').items.length,2);
+ assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='未标记').items[0].id,'phone');
+});
+test('spaces have no fixed depth and traversal handles corrupt cycles safely',()=>{
+ const deep=Array.from({length:2500},(_,i)=>({id:String(i),name:'Area '+i,parentId:i?String(i-1):''}));
+ assert.equal(m.path(deep,'2499').length,2500);assert.equal(m.descendants(deep,'0').size,2500);
+ assert.equal(m.path([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').length,2);
+ assert.equal(m.descendants([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').size,2);
+});
+test('identical area names remain separate groups by stable identifiers',()=>{
+ const areas=[{id:'a',name:'卧室'},{id:'b',name:'卧室'}];
+ assert.equal(m.groups([{siteId:'a',id:1},{siteId:'b',id:2}],['space'],areas).length,2);
+});
+test('camera recorder and general device records appear in one catalog without mutation',()=>{
+ const state={assets:[{id:'a',name:'Camera'}],recorders:[{id:'b',name:'Recorder'}],devices:[{id:'c',deviceType:'冰箱'}]},before=JSON.stringify(state);
+ assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before);
+ assert.equal(m.groups(devices,[],sites).length,1);
+});
diff --git a/test_households.py b/test_households.py
index 7075f55..6c29b05 100644
--- a/test_households.py
+++ b/test_households.py
@@ -55,7 +55,7 @@ class HouseholdTests(unittest.TestCase):
db.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+600, user['id']))
db.commit()
def state():
- return {**{t: app.objects(t) for t in ('sites','assets','recorders','cameras')}, 'scan': {'network':'private'}, 'storage': {'maxGB':40}}
+ return {**{t: app.objects(t) for t in ('sites','assets','recorders','cameras','devices')}, 'scan': {'network':'private'}, 'storage': {'maxGB':40}}
p = patch.object(app, 'status', state); p.start(); self.addCleanup(p.stop)
self.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
@@ -207,5 +207,60 @@ class HouseholdTests(unittest.TestCase):
self.assertEqual(saved,user)
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')])
+ def test_office_factory_and_custom_grouping_are_independent(self):
+ for kind in ('office','factory'):
+ code, scope=self.request('/api/households',self.root,dict(name=kind,kind=kind))
+ self.assertEqual(code,200);self.assertEqual(scope['kind'],kind)
+ self.assertEqual(self.request('/api/device-view',data={'groups':['manager','type','space']})[0],200)
+ self.assertEqual(app.get_object('households',self.aid)['deviceGrouping'],['manager','type','space'])
+ self.assertNotIn('deviceGrouping',app.get_object('households',self.bid))
+ self.assertEqual(self.request('/api/device-view',data={'groups':[]})[0],200)
+ for groups in (['type','type'],['unknown'],'type',[{}]):
+ self.assertEqual(self.request('/api/device-view',data={'groups':groups})[0],400)
+ self.assertEqual(self.request('/api/device-view',self.members[self.aid],{'groups':['type']})[0],403)
+
+ def test_region_hierarchy_has_no_fixed_depth_and_rejects_cycles(self):
+ parent=self.sites[self.aid]
+ for n in range(160):
+ parent=app.save_site(dict(name='Region '+str(n),parentId=parent['id'],category='custom '+str(n)),self.owner)
+ self.assertEqual(len(app.HOUSEHOLDS.objects(self.owner,'sites')),161)
+ for child in (parent,self.sites[self.aid]):
+ self.assertEqual(self.request('/api/sites',data=dict(child,parentId=parent['id']))[0],400)
+ self.assertEqual(self.request('/api/sites',data=dict(parent,parentId=self.sites[self.bid]['id']))[0],404)
+
+ def test_subregion_grants_are_explicit_and_checked_per_request(self):
+ child=app.save_site(dict(name='Bedroom',parentId=self.sites[self.aid]['id']),self.owner)
+ asset=app.save_asset(dict(self.assets[self.aid],siteId=child['id']),self.owner)
+ camera=app.get_object('cameras',self.cameras[self.aid]['id'])
+ member=app.get_object('users',self.members[self.aid]['id'])
+ with self.assertRaises(app.Problem):app.ACCOUNTS.camera(member,camera)
+ app.save_object('users',dict(member,includeSubspaces=True))
+ self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),1)
+ # Moving the region to another branch immediately removes inherited access.
+ app.save_site(dict(child,parentId=''),self.owner)
+ self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),0)
+ self.assertEqual(app.get_object('assets',asset['id'])['siteId'],child['id'])
+
+ def test_generic_devices_responsibilities_and_cross_family_access(self):
+ data=dict(name='卧室台灯',deviceType='灯',siteId=self.sites[self.aid]['id'],ownerName='家人甲',managerName='家人乙',tags=['照明','常用'])
+ code,device=self.request('/api/devices',data=data)
+ self.assertEqual(code,200);self.assertEqual(device['managerName'],'家人乙')
+ self.assertEqual([d['id'] for d in self.request('/api/state')[1]['devices']],[device['id']])
+ self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[])
+ self.assertIn(self.request('/api/devices',self.other,dict(device,name='hijack'))[0],(403,404))
+ self.assertEqual(self.request('/api/devices',data=dict(data,siteId=self.sites[self.bid]['id']))[0],404)
+ self.assertEqual(self.request('/api/devices',self.members[self.aid],data)[0],403)
+ for invalid in (dict(data,tags='tag'),dict(data,deviceType='录像机'),dict(data,tags=['x']*13)):
+ self.assertEqual(self.request('/api/devices',data=invalid)[0],400)
+
+ def test_video_and_other_devices_share_responsibility_metadata(self):
+ body=dict(self.assets[self.aid],ownerName='设备所有人',managerName='设备负责人',tags=['视频'])
+ asset=app.save_asset(body,self.owner)
+ recorder=app.save_recorder(dict(name='Recorder',siteId=self.sites[self.aid]['id'],host='192.168.3.90',ownerName='设备所有人',managerName='设备负责人',tags=['视频']),self.owner)
+ self.assertEqual(asset['managerName'],recorder['managerName'])
+ self.assertEqual(asset['tags'],recorder['tags'])
+ body.pop('ownerName');body.pop('managerName');body.pop('tags')
+ self.assertEqual(app.save_asset(body,self.owner)['managerName'],'设备负责人')
+
if __name__=='__main__': unittest.main()
diff --git a/web/app.js b/web/app.js
index 522a73d..75e437f 100644
--- a/web/app.js
+++ b/web/app.js
@@ -2,14 +2,14 @@
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
-const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
-let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
+const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
+let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
let householdScope = '';
const isPlatformAdmin = () => currentUser?.role === 'admin';
const isAdmin = () => ['admin','family_admin'].includes(currentUser?.role);
-const roleName = user => user.role==='admin'?'超级管理员':user.role==='family_admin'?((state.households||[]).some(h=>h.ownerId===user.id)?'一家之主':'家庭管理员'):'家庭成员';
+const roleName = user => {const h=(state.households||[]).find(h=>h.id===state.familyId)||{},home=!h.kind||h.kind==='home';return user.role==='admin'?'超级管理员':user.role==='family_admin'?(h.ownerId===user.id?(home?'一家之主':'场所负责人'):(home?'家庭管理员':'场所管理员')):(home?'家庭成员':'场所成员');};
function applyPermissions(){
const admin=isAdmin();
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
@@ -26,18 +26,19 @@ function applyPermissions(){
}
function clearPrivateView(){
if(typeof clearSnapshot==='function')clearSnapshot();
+ if(typeof resetDeviceView==='function')resetDeviceView();
if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;householdScope='';stopPlayers();resetPlayback();
- state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
+ 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();
if(typeof clearFamilyState==='function')clearFamilyState();
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
$$('dialog form').forEach(f=>f.reset());
activePage='live';showPage('live');applyPermissions();
}
-const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
+const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['设备管理','按类型、区域、负责人或所有人,找到每一台设备。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['区域与空间','层级与名称由你定义,设备各有归属。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) {
const generation=sessionGeneration;
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:{...(data === undefined ? {} : {'Content-Type':'application/json'}),...(typeof householdScope!=='undefined'&&householdScope?{'X-Household-Id':householdScope}:{})}, body:data === undefined ? undefined : JSON.stringify(data)});
@@ -47,9 +48,9 @@ async function api(path, data) {
return result;
}
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
-function siteName(id) { return state.sites.find(s => s.id === id)?.name || '未关联空间'; }
+function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; }
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
-function filtered() { return state.cameras.filter(c => !$('#site-filter').value || c.siteId === $('#site-filter').value); }
+function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;return state.cameras.filter(c=>!range||range.has(c.siteId)); }
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
function showPage(page) {
@@ -61,7 +62,7 @@ function showPage(page) {
$('.metrics').hidden=['family','users','households'].includes(page);
$('.filter-row').hidden=['family','users','households'].includes(page);
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
- $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
+ $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['cameras','recorders'].includes(page)));
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
if (page !== 'playback') resetPlayback();
render();
@@ -70,7 +71,7 @@ function showPage(page) {
if (page === 'users') loadUsers();
}
function render() {
- $('#metric-all').textContent = state.assets.length;
+ $('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
$('#metric-ready').textContent = state.cameras.filter(c=>c.ready).length;
$('#metric-sites').textContent = state.sites.length;
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
@@ -79,7 +80,7 @@ function render() {
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
$('#banner').textContent = warning; $('#banner').hidden = !warning;
- setOptions($('#site-filter'), '' + state.sites.map(s=>``).join(''));
+ setOptions($('#site-filter'), '' + state.sites.map(s=>``).join(''));
if (activePage === 'live') renderLive();
if (activePage === 'cameras') renderCameras();
if (activePage === 'sites') renderSites();
@@ -125,8 +126,8 @@ function renderCameras() {
$('#camera-list').innerHTML=cameras.length?`
| 设备 / 位置 | 所属空间 | 地址 | 码流状态 | 录像 | |
${cameras.map(c=>`| ${esc(c.name)}${esc(c.point||'位置待完善')} | ${esc(siteName(c.siteId))} | ${esc(c.host)}:${c.port} | ${c.ready?'已接通':c.enabled?'未接通':'停用'} | ${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'} | |
`).join('')}
`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
}
function renderSites() {
- const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
- $('#site-list').innerHTML=sites.length?sites.map(s=>`${kind(s.kind)}
${esc(s.name)}
- 所属
- ${esc(s.owner||'待填写')}
- 地区
- ${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}
- 小区 / 园区
- ${esc(s.community||'待填写')}
- 楼栋门牌
- ${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}
- 摄像头
- ${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头
- 详细地址
- ${esc(s.address||'待填写')}
`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','');
+ const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
+ $('#site-list').innerHTML=sites.length?sites.map(s=>`${esc(s.category||kind(s.kind))}
${esc(s.name)}
${esc(siteName(s.id))}
- 所属
- ${esc(s.owner||'待填写')}
- 地区
- ${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}
- 小区 / 园区
- ${esc(s.community||'待填写')}
- 楼栋门牌
- ${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}
- 本级设备
- ${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头
- 详细地址
- ${esc(s.address||'待填写')}
`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','');
}
function renderStorage() {
const s=state.storage;
@@ -136,7 +137,7 @@ function renderStorage() {
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`${new Date(r.at).toLocaleString()}${esc(r.action)} ${esc(r.name)}
`).join('')||'尚无操作记录
';}catch(e){if(!e.stale)toast(e.message);} }
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
-function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
+function openSite(id) {const f=$('#site-form');f.reset();f.elements.parentId.innerHTML=''+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.archiveRecorderId.innerHTML=''+state.recorders.map(r=>``).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
@@ -154,19 +155,16 @@ window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
-function renderAssets(){
- const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
- $('#asset-list').innerHTML=assets.length?assets.map(a=>`摄像头对象
${esc(a.name)}
- 所属空间
- ${esc(siteName(a.siteId))}
- 品牌型号
- ${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}
- 物理镜头
- ${a.lensCount} 个
- 已配置通道
- ${state.cameras.filter(c=>c.assetId===a.id).length} 个
- 安装位置
- ${esc(a.point||'待填写')}
`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','');
-}
+function renderAssets(){renderDevices();}
function renderRecorders(){
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`录像机
${esc(r.name)}
- 所属空间
- ${esc(siteName(r.siteId))}
- 品牌型号
- ${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}
- 设备地址
- ${esc(r.host)}:${r.port}
- 凭据
- ${r.passwordConfigured?'已配置':'尚未填写'}
- 关联镜头
- ${state.cameras.filter(c=>c.recorderId===r.id).length} 个
- 原有录像回放
- 待型号适配
`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','');
}
-function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
-function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
+function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
+function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
-handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
+handleForm('asset-form','/api/assets',d=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(d),port:Number(d.port)}));
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
diff --git a/web/chinese-theme.css b/web/chinese-theme.css
index 7fb69b8..4686e5b 100644
--- a/web/chinese-theme.css
+++ b/web/chinese-theme.css
@@ -248,3 +248,8 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
.household-actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.household-context select{max-width:100%}.household-context label{min-width:0}
+
+.device-actions,.device-search-row,.device-group-order,.group-step{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
+.device-search-row{align-items:end}.device-search-row label{flex:1;min-width:160px}.device-group-order{margin:12px 0}.group-step{padding:7px;background:var(--paper);border:1px solid var(--line);border-radius:5px}.group-step select{width:auto;max-width:145px}.group-step button{padding:7px 11px}.group-step>span{color:var(--muted)}
+.device-group{margin:26px 0}.device-group>h3{font-family:var(--serif);font-size:20px;overflow-wrap:anywhere}.device-group>h3>span{color:var(--gold);padding:0 8px}.device-group>h3 small{font:12px var(--sans);color:var(--muted);white-space:nowrap}.device-tags{display:flex;flex-wrap:wrap;gap:6px}.device-tags span{font-size:11px;padding:3px 8px;background:var(--paper);border:1px solid var(--line)}.site-card dd,.site-card .description{overflow-wrap:anywhere;min-width:0}
+@media(max-width:600px){.device-actions{width:100%}.device-actions button{flex:1;white-space:normal}.group-step{width:100%}.group-step select{flex:1;max-width:none;min-width:0}.device-group>h3{font-size:18px}}
diff --git a/web/device-model.js b/web/device-model.js
new file mode 100644
index 0000000..2aac805
--- /dev/null
+++ b/web/device-model.js
@@ -0,0 +1,35 @@
+(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.ZhaoDevices=api;})(typeof window!=='undefined'?window:this,function(){
+ 'use strict';
+ function path(sites,id){
+ const map=new Map(sites.map(s=>[s.id,s])),seen=new Set(),result=[];
+ while(id&&map.has(id)&&!seen.has(id)){seen.add(id);const s=map.get(id);result.push(s);id=s.parentId;}
+ return result.reverse();
+ }
+ function descendants(sites,id){
+ const children=new Map();for(const s of sites){if(!children.has(s.parentId))children.set(s.parentId,[]);children.get(s.parentId).push(s.id);}
+ const found=new Set(),queue=[id];for(let i=0;i({...x,source:'camera',deviceType:'摄像机'})),
+ ...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
+ ...(state.devices||[]).map(x=>({...x,source:'device'}))];}
+ function groups(devices,dimensions,sites){
+ const result=new Map();
+ for(const device of devices){
+ let branches=[[]];
+ for(const dimension of dimensions){
+ let options;
+ if(dimension==='space'){
+ 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}]);
+ }
+ branches=branches.flatMap(branch=>options.map(option=>branch.concat(option)));
+ }
+ for(const branch of branches){const key=JSON.stringify(branch.map(x=>x.key));if(!result.has(key))result.set(key,{key,labels:branch.map(x=>x.label),items:[]});result.get(key).items.push(device);}
+ }
+ return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
+ }
+ return {path,descendants,catalog,groups};
+});
diff --git a/web/devices.js b/web/devices.js
new file mode 100644
index 0000000..fbb6eb3
--- /dev/null
+++ b/web/devices.js
@@ -0,0 +1,49 @@
+'use strict';
+let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
+const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
+function renderDeviceGrouping(){
+ $('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`${i+1}
`).join('');
+ $('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
+}
+function deviceCard(d){
+ const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
+ const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
+ const detail=d.source==='camera'?`镜头 / 通道${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道`:d.source==='recorder'?`关联镜头${state.cameras.filter(c=>c.recorderId===d.id).length} 个`:'接入状态仅登记档案,尚未接入控制';
+ return `${esc(d.deviceType)}
${esc(d.name)}
- 所在区域
- ${esc(location)}
- 所有人
- ${esc(d.ownerName||'待指定')}
- 负责人
- ${esc(d.managerName||'待指定')}
- 品牌型号
- ${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}
${detail}
${d.tags?.length?`${d.tags.map(t=>`${esc(t)}`).join('')}
`:''}`;
+}
+function renderDevices(){
+ const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
+ const signature=JSON.stringify(saved);
+ if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
+ const search=$('#device-search').value.trim().toLowerCase();
+ const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
+ setOptions($('#device-type'),''+types.map(type=>``).join(''));
+ 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)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
+ $('#device-count').textContent=`${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 openDevice(id){
+ const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
+ f.elements.siteId.innerHTML=''+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
+}
+function spaceOptions(exclude=''){
+ const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
+ return state.sites.filter(s=>!omitted.has(s.id)).map(s=>``).join('');
+}
+$('#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();}};
+$('#device-view-save').onclick=async()=>{try{await api('/api/device-view',{groups:deviceGroups});await refresh();toast('已保存为本家庭或场所的默认分组');}catch(e){if(!e.stale)toast(e.message);}};
+$('#new-device').onclick=()=>openDevice();
+handleForm('device-form','/api/devices',deviceValues);
+document.addEventListener('click',e=>{
+ const b=e.target.closest('button');if(!b)return;
+ if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
+ if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
+ if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
+});
diff --git a/web/family.js b/web/family.js
index 75523bd..c374372 100644
--- a/web/family.js
+++ b/web/family.js
@@ -67,7 +67,7 @@ for(const [name,path]of [['person','person'],['relation','link']]){
async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id;
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
- $('#user-list').innerHTML=`| 账号 | 角色 / 状态 | 人物关系 | 家谱权限 | 摄像头空间 | |
${users.map(u=>`| ${esc(u.name)}${esc(u.username)} | ${roleName(u)}${u.disabled?' · 已停用':''} | ${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}家谱:${esc(personName(u.personId))} | ${u.role!=='member'?'管理本家庭':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])} | ${u.role!=='member'?'本家庭全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')} | |
`).join('')}
`;
+ $('#user-list').innerHTML=`| 账号 | 角色 / 状态 | 人物关系 | 家谱权限 | 摄像头空间 | |
${users.map(u=>`| ${esc(u.name)}${esc(u.username)} | ${roleName(u)}${u.disabled?' · 已停用':''} | ${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}家谱:${esc(personName(u.personId))} | ${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])} | ${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))} | |
`).join('')}
`;
}catch(e){toast(e.message);}
}
function openUser(id){
@@ -75,9 +75,9 @@ function openUser(id){
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML=''+users.map(u=>``).join('');
fillForm(f,u);f.elements.password.required=!u.id;
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
- $('#user-sites').innerHTML=state.sites.map(s=>``).join('');$('#user-dialog').showModal();
+ $('#user-sites').innerHTML=state.sites.map(s=>``).join('');$('#user-dialog').showModal();
}
-$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
+$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily;
$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
diff --git a/web/households.js b/web/households.js
index 60ff3a2..464c249 100644
--- a/web/households.js
+++ b/web/households.js
@@ -1,11 +1,12 @@
'use strict';
-pageInfo.households=['家庭管理','一户一家园,资料各有归属。'];
+pageInfo.households=['家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
+const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
function renderHouseholds(){
const homes=state.households||[];
- setOptions($('#household-filter'),homes.map(h=>``).join(''));
+ setOptions($('#household-filter'),homes.map(h=>``).join(''));
$('#household-filter').value=state.familyId||'';
if(activePage!=='households')return;
- $('#household-list').innerHTML=homes.map(h=>`${h.id===state.familyId?'当前家庭':'独立家庭'}
${esc(h.name)}
${esc(h.note||'一户一家园')}
- 一家之主
- ${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('')} `:''}`).join('');
+ $('#household-list').innerHTML=homes.map(h=>`${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}
${esc(h.name)}
${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('')} `:''}`).join('');
}
async function switchHousehold(id){
if(!isPlatformAdmin()||!id||id===state.familyId)return;
@@ -29,7 +30,7 @@ async function openLeadership(id){
const result=await api('/api/users');
const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled);
const f=$('#leadership-form'),options=candidates.map(u=>``).join('');
- f.elements.ownerId.innerHTML=''+options;
+ f.elements.ownerId.innerHTML=''+options;
f.elements.successorId.innerHTML=''+options;
fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''});
$('#leadership-dialog').showModal();
@@ -37,14 +38,14 @@ async function openLeadership(id){
}
$('#leadership-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
- try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('家庭负责人已更新,交接记录已保存');}
+ try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
});
$('#household-filter').onchange=e=>switchHousehold(e.target.value);
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
- try{await api('/api/households',formValues(f));f.closest('dialog').close();await refresh();toast('家庭档案已保存');}
+ try{await api('/api/households',formValues(f));f.closest('dialog').close();await refresh();toast('归属档案已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
finally{b.disabled=false;}
});
diff --git a/web/index.html b/web/index.html
index 4cff9bc..e2ca430 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,10 +3,10 @@
赵府智家 · 家庭空间
-
+
-
+
赵氏家宅 · 起居有序
实时画面
从一个空间,看到每一个位置。
- 设备、录像与家谱按家庭隔离
+ 家庭、办公场所与工厂独立授权
-
- 家庭与公司,分别管理
+
+ 可选择任意区域,包含其下级区域
现场画面 守望
主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。
常用位置
调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。
回到那一刻 光阴
@@ -40,9 +40,9 @@
- 摄像头对象 器物
一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。
+ 一物一档 设备 · 归属
同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。
依次分组(前面的维度优先,区域按你定义的完整层级展示)
录像机 存影
统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。
- 空间档案 家宅
为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。
+ 区域与空间 自定层级
在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。
录像与存储 留存
运行状态
开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。
最近操作
家族的来处与延续 世系 · 传承
@@ -65,18 +65,19 @@
- 各有家园 家宅 · 归属
每个家庭独立管理自己的成员、空间、设备和家谱。家庭管理员只管理本家庭;超级管理员负责建立家庭与分配管理账号。
- 账号与人物关系 ACCOUNTS
这里只管理当前家庭的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。
+ 各有归属 家庭 · 办公 · 工厂
每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。
+ 账号与人物关系 ACCOUNTS
这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。
-
+
+
-
-
-
-
-
+
+
+
+
+