feat: add scoped device catalog and unlimited custom area hierarchy

This commit is contained in:
Codex
2026-10-04 11:19:14 +08:00
parent 72a3252e25
commit 5544780bf0
16 changed files with 349 additions and 66 deletions
+14 -2
View File
@@ -9,7 +9,9 @@
## 对象关系 ## 对象关系
```text ```text
空间档案(家庭 / 公司 / 其他) 管理归属(家庭 / 办公场所 / 工厂,独立权限)
└─ 自定义区域树(parentId,不限制层数,禁止循环及跨归属父节点)
└─ 空间档案(名称、区域类别及可选地址)
├─ 地区:省、市、区县、街道 ├─ 地区:省、市、区县、街道
├─ 地址:小区或园区、楼栋、单元、楼层、门牌 ├─ 地址:小区或园区、楼栋、单元、楼层、门牌
├─ 所属:家庭或单位 ├─ 所属:家庭或单位
@@ -17,6 +19,7 @@
│ ├─ 镜头通道 1 → 主码流 / 子码流 │ ├─ 镜头通道 1 → 主码流 / 子码流
│ └─ 镜头通道 2 → 主码流 / 子码流 │ └─ 镜头通道 2 → 主码流 / 子码流
└─ 录像机对象(地址、接入驱动、账号、空间引用) └─ 录像机对象(地址、接入驱动、账号、空间引用)
└─ 其他设备档案(灯、手机、冰箱或自定类型,暂不含控制驱动)
每个镜头通道分别关联: 每个镜头通道分别关联:
实时来源:直接摄像头 / 录像机 实时来源:直接摄像头 / 录像机
@@ -76,7 +79,7 @@
## 家庭边界与管理连续性 ## 家庭边界与管理连续性
账号 → 一个家庭 → 多个空间 → 设备对象 / 镜头 / 录像机。人物、亲属关系、记事和历史版本也属于一个家庭。 账号 → 一个管理归属(家庭 / 办公场所 / 工厂)→ 多级区域 → 设备对象 / 镜头。人物、亲属关系、记事和历史版本也属于同一管理归属。兼容已有接口,数据库仍使用 `households`、`familyId` 命名。
- `X-Household-Id` 仅供超级管理员选择工作家庭;其余账号只能使用自己的家庭。所有写操作均核验所属家庭与关联对象,不能通过 body 迁移归属。 - `X-Household-Id` 仅供超级管理员选择工作家庭;其余账号只能使用自己的家庭。所有写操作均核验所属家庭与关联对象,不能通过 body 迁移归属。
- API 状态与账号列表按家庭过滤。媒体 URL 不依赖前端过滤,每个清单、分片与下载请求均核验摄像头归属和空间授权;超级管理员是明确的特权例外。 - API 状态与账号列表按家庭过滤。媒体 URL 不依赖前端过滤,每个清单、分片与下载请求均核验摄像头归属和空间授权;超级管理员是明确的特权例外。
@@ -86,3 +89,12 @@
- 任内负责人不能被直接停用、降级;先交接,再调整账号。交接后原家主保留家庭管理员,必要时由新家主或超级管理员处理其账号。 - 任内负责人不能被直接停用、降级;先交接,再调整账号。交接后原家主保留家庭管理员,必要时由新家主或超级管理员处理其账号。
- 家谱人物的去世日期、账号长期未登录、亲属称谓均不触发权限变化。 - 家谱人物的去世日期、账号长期未登录、亲属称谓均不触发权限变化。
- 首次迁移只回填 `familyId`,不修改凭据或设备路径;旧历史快照也加归属。旧版本没有此隔离能力,多家庭运行后禁止只回退代码。 - 首次迁移只回填 `familyId`,不修改凭据或设备路径;旧历史快照也加归属。旧版本没有此隔离能力,多家庭运行后禁止只回退代码。
## 设备分类与区域授权
`inventory.py` 保存通用设备档案以及所有人、负责人、标签。视频设备继续使用原 `assets`、`recorders`、`cameras` 结构,不迁移凭据、不修改录像配置。前端 `device-model.js` 将三类档案统一为只读目录,按每个归属保存的维度顺序分组。修改分组只改变展示,不移动设备或放宽权限。
区域以 `parentId` 邻接表表示,创建/移动时在写锁内检查父节点归属、有效性与祖先循环。父链和后代集合均迭代计算,避免递归栈形成层数限制。用户的 `includeSubspaces` 默认关闭;开启后按当前区域树实时扩展 `siteIds`。修改该授权开关会撤销已有会话。人员归属是文本档案,不参与权限计算。
新增接口:`POST /api/devices` 保存通用设备(要求当前归属管理员);`POST /api/device-view` 保存当前归属默认分组顺序。统一状态的 `devices` 数组也经过归属和成员区域过滤。
+13 -3
View File
@@ -8,7 +8,8 @@ v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点
## 对象与空间 ## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。 - **区域与空间**:各家庭、办公场所、工厂独立设置区域树,自定义名称、类别与上级区域,不设固定层数。地址可记录省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌。
- **统一设备管理**:摄像机、录像机与其他设备统一展示,支持按类型、区域、负责人、所有人、自定义标签调整分组顺序。灯、手机、冰箱等当前提供档案登记,实际控制需要另行接入协议。
- **摄像头对象**:实体设备的稳定 ID、品牌、型号、序列号、镜头数、安装位置和空间引用。 - **摄像头对象**:实体设备的稳定 ID、品牌、型号、序列号、镜头数、安装位置和空间引用。
- **镜头通道**:每个物理镜头一个通道,关联摄像头对象,配置主、子码流。双镜头摄像头的主、子码流不能当作两个物理镜头。 - **镜头通道**:每个物理镜头一个通道,关联摄像头对象,配置主、子码流。双镜头摄像头的主、子码流不能当作两个物理镜头。
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。 - **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
@@ -53,7 +54,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml 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 使用管理员与个人账号分权。 访问 `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 播放清单、分片、回放、下载及设备控制均逐请求核验;修改请求检查每个关联对象的家庭。普通账号伪造家庭请求头或猜测其他家庭的对象编号也不能获得资料。超级管理员具有跨家庭权限。家庭切换先清除播放器、表单和旧请求;各家庭独立计算家谱修订号。 家庭隔离由服务器执行:账号、空间、对象、镜头、录像机、家谱人物、关系、事件与历史版本均有家庭归属。HLS 播放清单、分片、回放、下载及设备控制均逐请求核验;修改请求检查每个关联对象的家庭。普通账号伪造家庭请求头或猜测其他家庭的对象编号也不能获得资料。超级管理员具有跨家庭权限。家庭切换先清除播放器、表单和旧请求;各家庭独立计算家谱修订号。
升级前用 SQLite backup API 备份数据库。首次启动把旧数据归入「赵府」家庭,保留原 ID、密码散列、设备连接和会话;家主与备用负责人须在界面明确指定,不从姓名或辈分猜测。已有 `admin` 角色均保留超级权限,请按实际职责设为家庭管理员。**建立第二个家庭后,不能直接回退到 v0.1.22 或更早版本**,旧版本不执行家庭隔离;应修复前进,或连同升级前数据库及部署版本一起恢复并确保后续数据另有备份。 升级前用 SQLite backup API 备份数据库。首次启动把旧数据归入「赵府」家庭,保留原 ID、密码散列、设备连接和会话;家主与备用负责人须在界面明确指定,不从姓名或辈分猜测。已有 `admin` 角色均保留超级权限,请按实际职责设为家庭管理员。**建立第二个家庭后,不能直接回退到 v0.1.22 或更早版本**,旧版本不执行家庭隔离;应修复前进,或连同升级前数据库及部署版本一起恢复并确保后续数据另有备份。
这是单台受信主机上的应用权限隔离。各家庭的设备仍共享媒体服务和存储容量,家庭管理员不具有操作系统权限;尚未提供按家庭磁盘配额、独立网络、硬盘加密或无人审批的自动继承。 这是单台受信主机上的应用权限隔离。各家庭的设备仍共享媒体服务和存储容量,家庭管理员不具有操作系统权限;尚未提供按家庭磁盘配额、独立网络、硬盘加密或无人审批的自动继承。
### 多维设备分类与区域树(v0.1.23)
- 在「家庭与场所」选择管理归属,再在「区域与空间」添加区域;选择上级区域即可继续细分,例如「厂区 → 车间 → 产线 → 工位」。名称、区域类别、地址按需填写,程序没有固定层数上限。不能把区域放到自身或后代下,也不能跨家庭/场所挂接。
- 在「设备管理」登记设备。摄像机与录像机保留已有接入、通道功能,其他设备使用可自定的类型;同一设备只存一份档案。所有人、负责人是责任信息,不会自动授予账号查看或控制权限。
- 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。
- 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。
- 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。
+22 -5
View File
@@ -32,6 +32,7 @@ import playback
import identity import identity
import genealogy import genealogy
import households import households
import inventory
import ptz import ptz
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
@@ -75,6 +76,7 @@ class Problem(Exception):
ACCOUNTS = identity.Accounts(sys.modules[__name__]) ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__]) FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__]) HOUSEHOLDS = households.Households(sys.modules[__name__])
INVENTORY = inventory.Inventory(sys.modules[__name__])
def setting(key, default=None): def setting(key, default=None):
@@ -165,13 +167,21 @@ def object_key(table, body):
@serialized_save @serialized_save
def save_site(body, actor=None): 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) 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, item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS} required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home'), familyId=family) item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
if item['kind'] not in ('home', 'company', 'other'): category=clean_text(body.get('category', ''), 40))
raise Problem('请选择家庭、公司或其他用途') if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
raise Problem('请选择有效区域用途')
save_object('sites', item) save_object('sites', item)
audit('保存空间', item['name']) audit('保存空间', item['name'])
return item return item
@@ -238,6 +248,7 @@ def save_asset(body, actor=None):
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')} 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(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] attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']: if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数') 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): if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
raise Problem('密码格式不正确') raise Problem('密码格式不正确')
item['password'] = password or old.get('password', '') item['password'] = password or old.get('password', '')
item.update(INVENTORY.metadata(body, old))
save_object('recorders', item) save_object('recorders', item)
write_media_config() write_media_config()
audit('保存录像机', item['name']) audit('保存录像机', item['name'])
@@ -411,7 +423,7 @@ def status():
storage = dict(STORAGE) storage = dict(STORAGE)
scan = dict(SCAN) scan = dict(SCAN)
return {'cameras': cameras, 'assets': objects('assets'), 'recorders': [public_camera(r) for r in objects('recorders')], 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, 'storage': {**setting('storage'), **storage}, 'scan': scan,
'version': (ROOT / 'VERSION').read_text().strip(), 'uptime': round(time.time() - STARTED)} '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)) self.answer(HOUSEHOLDS.save(data, self.user))
elif path == '/api/households/leadership': elif path == '/api/households/leadership':
self.answer(HOUSEHOLDS.leadership(data, self.user)) 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'): elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user)) self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites': elif path == '/api/sites':
@@ -900,6 +916,7 @@ class Handler(BaseHTTPRequestHandler):
else: else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css', 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', '/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-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css', '/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js', '/family-events.js': 'family-events.js',
+36 -1
View File
@@ -6,7 +6,7 @@ import secrets
class Households: 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): def __init__(self, app):
self.a = app self.a = app
@@ -19,6 +19,7 @@ class Households:
a = self.a a = self.a
with a.LOCK, a.DB: 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 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') default = a.setting('defaultHousehold')
if not default: if not default:
default = secrets.token_hex(8) default = secrets.token_hex(8)
@@ -69,6 +70,22 @@ class Households:
family = self.scope(user) family = self.scope(user)
return [x for x in self.a.objects(table) if self.family_of(x) == family] 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=()): def prepare(self, user, table, body, references=()):
family = self.scope(user) family = self.scope(user)
if body.get('familyId', family) != family: if body.get('familyId', family) != family:
@@ -137,8 +154,12 @@ class Households:
raise a.Problem('家庭不存在', 404) raise a.Problem('家庭不存在', 404)
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)): if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
raise a.Problem('只能管理自己所属的家庭', 403) 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), item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8),
name=a.clean_text(data.get('name', ''), 80, True), name=a.clean_text(data.get('name', ''), 80, True),
kind=kind,
note=a.clean_text(data.get('note', ''), 300), note=a.clean_text(data.get('note', ''), 300),
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat()) createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
a.save_object('households', item) a.save_object('households', item)
@@ -148,3 +169,17 @@ class Households:
def revision_key(self, user): def revision_key(self, user):
family = self.scope(user) family = self.scope(user)
return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family 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}
+10 -6
View File
@@ -81,6 +81,9 @@ class Accounts:
ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False) ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False)
if not isinstance(ptz_control, bool): if not isinstance(ptz_control, bool):
raise a.Problem('云台权限格式不正确') 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) disabled = data.get('disabled', False)
if not isinstance(disabled, bool): if not isinstance(disabled, bool):
raise a.Problem('账号状态不正确') 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')} 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), 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, 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) relationship=a.clean_text(data.get('relationship', ''), 40), **credentials)
# Revoke sessions when credentials or authorization change, not for label edits. # 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.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
a.save_object('users', user) a.save_object('users', user)
a.audit('更新账号' if old else '建立账号', username) a.audit('更新账号' if old else '建立账号', username)
@@ -154,7 +157,7 @@ class Accounts:
site = self.a.get_object('sites', camera.get('siteId')) site = self.a.get_object('sites', camera.get('siteId'))
if site: if site:
self.a.HOUSEHOLDS.owns(user, 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) raise self.a.Problem('没有这个空间的访问权限', 403)
return camera return camera
@@ -171,16 +174,17 @@ class Accounts:
family = self.a.HOUSEHOLDS.scope(user) family = self.a.HOUSEHOLDS.scope(user)
state['familyId'] = family state['familyId'] = family
state['households'] = self.a.HOUSEHOLDS.list(user) state['households'] = self.a.HOUSEHOLDS.list(user)
for key in ('sites', 'assets', 'cameras', 'recorders'): for key in ('sites', 'assets', 'cameras', 'recorders', 'devices'):
state[key] = [x for x in state[key] if self.a.HOUSEHOLDS.family_of(x) == family] state[key] = [x for x in state.get(key, []) if self.a.HOUSEHOLDS.family_of(x) == family]
if user['role'] != 'admin': if user['role'] != 'admin':
state['scan'] = {} state['scan'] = {}
state['storage'] = {} state['storage'] = {}
if self.a.HOUSEHOLDS.manager(user): if self.a.HOUSEHOLDS.manager(user):
return state 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['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['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') 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['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
state['recorders'] = [] state['recorders'] = []
+30
View File
@@ -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
+2 -1
View File
@@ -105,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store') self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body) self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) 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() version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets: for asset in assets:
self.assertTrue(asset.endswith('?v=' + version)) self.assertTrue(asset.endswith('?v=' + version))
@@ -256,6 +256,7 @@ class WebTests(unittest.TestCase):
self.assertIn('pipe:1', command) self.assertIn('pipe:1', command)
def test_ptz_requires_both_space_and_control_permission(self): 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) camera = dict(id='e'*16, name='Test', siteId='permitted', enabled=True)
viewer = dict(role='member', siteIds=['permitted'], ptzControl=False) viewer = dict(role='member', siteIds=['permitted'], ptzControl=False)
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera) with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
+30
View File
@@ -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);
});
+56 -1
View File
@@ -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.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+600, user['id']))
db.commit() db.commit()
def state(): 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) p = patch.object(app, 'status', state); p.start(); self.addCleanup(p.stop)
self.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler) 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() 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(saved,user)
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')]) 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() if __name__=='__main__': unittest.main()
+18 -20
View File
@@ -2,14 +2,14 @@
const $ = (s) => document.querySelector(s); const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)]; const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他'); const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; 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 players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0; let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
let householdScope = ''; let householdScope = '';
const isPlatformAdmin = () => currentUser?.role === 'admin'; const isPlatformAdmin = () => currentUser?.role === 'admin';
const isAdmin = () => ['admin','family_admin'].includes(currentUser?.role); 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(){ function applyPermissions(){
const admin=isAdmin(); const admin=isAdmin();
$('nav [data-page="storage"]').hidden=!isPlatformAdmin(); $('nav [data-page="storage"]').hidden=!isPlatformAdmin();
@@ -26,18 +26,19 @@ function applyPermissions(){
} }
function clearPrivateView(){ function clearPrivateView(){
if(typeof clearSnapshot==='function')clearSnapshot(); if(typeof clearSnapshot==='function')clearSnapshot();
if(typeof resetDeviceView==='function')resetDeviceView();
if(typeof clearCameraSettings==='function')clearCameraSettings(); if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false; sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{}); if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;householdScope='';stopPlayers();resetPlayback(); 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(); 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(); if(typeof clearFamilyState==='function')clearFamilyState();
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close()); $$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
$$('dialog form').forEach(f=>f.reset()); $$('dialog form').forEach(f=>f.reset());
activePage='live';showPage('live');applyPermissions(); 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) { async function api(path, data) {
const generation=sessionGeneration; 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)}); 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; return result;
} }
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); } 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 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 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 stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
function showPage(page) { function showPage(page) {
@@ -61,7 +62,7 @@ function showPage(page) {
$('.metrics').hidden=['family','users','households'].includes(page); $('.metrics').hidden=['family','users','households'].includes(page);
$('.filter-row').hidden=['family','users','households'].includes(page); $('.filter-row').hidden=['family','users','households'].includes(page);
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + 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]; $('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
if (page !== 'playback') resetPlayback(); if (page !== 'playback') resetPlayback();
render(); render();
@@ -70,7 +71,7 @@ function showPage(page) {
if (page === 'users') loadUsers(); if (page === 'users') loadUsers();
} }
function render() { 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-ready').textContent = state.cameras.filter(c=>c.ready).length;
$('#metric-sites').textContent = state.sites.length; $('#metric-sites').textContent = state.sites.length;
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB'; $('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
@@ -79,7 +80,7 @@ function render() {
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有'; $('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : ''; const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
$('#banner').textContent = warning; $('#banner').hidden = !warning; $('#banner').textContent = warning; $('#banner').hidden = !warning;
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind) + ' · ' + s.name)}</option>`).join('')); setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join(''));
if (activePage === 'live') renderLive(); if (activePage === 'live') renderLive();
if (activePage === 'cameras') renderCameras(); if (activePage === 'cameras') renderCameras();
if (activePage === 'sites') renderSites(); if (activePage === 'sites') renderSites();
@@ -125,8 +126,8 @@ function renderCameras() {
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。'); $('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
} }
function renderSites() { function renderSites() {
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value); 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=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>'); $('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
} }
function renderStorage() { function renderStorage() {
const s=state.storage; 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=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} } async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}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 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);}} 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='<option value="">顶层区域</option>'+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=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).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 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=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).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 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;}});} 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(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true}); setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
function renderAssets(){ function renderAssets(){renderDevices();}
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
$('#asset-list').innerHTML=assets.length?assets.map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
}
function renderRecorders(){ function renderRecorders(){
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value); const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>'); $('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
} }
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=>`<option value="${s.id}">${esc(s.name)}</option>`).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 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=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).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=>`<option value="${s.id}">${esc(s.name)}</option>`).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 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=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).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;} 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; $('#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;}}; $('#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;}};
+5
View File
@@ -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; } } @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} .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}}
+35
View File
@@ -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<queue.length;i++){const key=queue[i];if(found.has(key))continue;found.add(key);queue.push(...(children.get(key)||[]));}return found;
}
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'}))];}
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};
});
+49
View File
@@ -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)=>`<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('');
$('#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'?`<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).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><button class="quiet" data-${attr}="${esc(d.id)}">编辑</button></div><h3>${esc(d.name)}</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>${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'];
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'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).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=>`<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 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);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=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).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();}}
});
+3 -3
View File
@@ -67,7 +67,7 @@ for(const [name,path]of [['person','person'],['relation','link']]){
async function loadUsers(){ async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id; 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; 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=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱权限</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本家庭':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role!=='member'?'本家庭全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`; $('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱权限</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
}catch(e){toast(e.message);} }catch(e){toast(e.message);}
} }
function openUser(id){ function openUser(id){
@@ -75,9 +75,9 @@ function openUser(id){
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join(''); f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join('');
fillForm(f,u);f.elements.password.required=!u.id; fillForm(f,u);f.elements.password.required=!u.id;
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin(); f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(s.name)}</label>`).join('');$('#user-dialog').showModal(); $('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(siteName(s.id))}</label>`).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; $('#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-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();}; $('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
+7 -6
View File
@@ -1,11 +1,12 @@
'use strict'; 'use strict';
pageInfo.households=['家庭管理','一户一家园,资料各有归属。']; pageInfo.households=['家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
function renderHouseholds(){ function renderHouseholds(){
const homes=state.households||[]; const homes=state.households||[];
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(h.name)}</option>`).join('')); setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
$('#household-filter').value=state.familyId||''; $('#household-filter').value=state.familyId||'';
if(activePage!=='households')return; if(activePage!=='households')return;
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前家庭':'独立家庭'}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'一户一家园')}</p><dl><div><dt>一家之主</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)}">${h.id===state.familyId?'管理本家庭成员':'进入该家庭'}</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>`:''}</article>`).join(''); $('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><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)}">${h.id===state.familyId?'管理本归属成员':'进入该归属'}</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>`:''}</article>`).join('');
} }
async function switchHousehold(id){ async function switchHousehold(id){
if(!isPlatformAdmin()||!id||id===state.familyId)return; if(!isPlatformAdmin()||!id||id===state.familyId)return;
@@ -29,7 +30,7 @@ async function openLeadership(id){
const result=await api('/api/users'); const result=await api('/api/users');
const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled); const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled);
const f=$('#leadership-form'),options=candidates.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username)}</option>`).join(''); const f=$('#leadership-form'),options=candidates.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username)}</option>`).join('');
f.elements.ownerId.innerHTML='<option value="">请选择一家之主</option>'+options; f.elements.ownerId.innerHTML='<option value="">请选择主要负责人</option>'+options;
f.elements.successorId.innerHTML='<option value="">暂未指定</option>'+options; f.elements.successorId.innerHTML='<option value="">暂未指定</option>'+options;
fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''}); fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''});
$('#leadership-dialog').showModal(); $('#leadership-dialog').showModal();
@@ -37,14 +38,14 @@ async function openLeadership(id){
} }
$('#leadership-form').addEventListener('submit',async e=>{ $('#leadership-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; 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;} catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
}); });
$('#household-filter').onchange=e=>switchHousehold(e.target.value); $('#household-filter').onchange=e=>switchHousehold(e.target.value);
$('#new-household').onclick=()=>openHousehold(); $('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{ $('#household-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; 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;} catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
finally{b.disabled=false;} finally{b.disabled=false;}
}); });
+19 -18
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__"> <title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script> <script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/device-model.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script> <script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script> <script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script> <script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
@@ -16,10 +16,10 @@
<button data-page="live" class="active">◫ <span>实时画面</span></button> <button data-page="live" class="active">◫ <span>实时画面</span></button>
<button data-page="playback">◷ <span>录像回放</span></button> <button data-page="playback">◷ <span>录像回放</span></button>
<button data-page="family">♧ <span>家谱</span></button> <button data-page="family">♧ <span>家谱</span></button>
<button data-page="households" data-admin>⌂ <span>家庭管理</span></button> <button data-page="households" data-admin>⌂ <span>家庭与场所</span></button>
<button data-page="users" data-admin>♙ <span>账号与关系</span></button> <button data-page="users" data-admin>♙ <span>账号与关系</span></button>
<button data-page="objects">▣ <span>摄像头对象</span></button><button data-page="recorders">▤ <span>录像机</span></button><button data-page="cameras">◫ <span>镜头通道</span></button> <button data-page="objects">▣ <span>设备管理</span></button>
<button data-page="sites">⌂ <span>空间档案</span></button> <button data-page="sites">⌂ <span>区域与空间</span></button>
<button data-page="storage">▤ <span>录像与存储</span></button> <button data-page="storage">▤ <span>录像与存储</span></button>
</nav> </nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div> <div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
@@ -27,10 +27,10 @@
</aside> </aside>
<main> <main>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header> <header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="household-context" class="household-context" hidden><label>当前家庭 <select id="household-filter" aria-label="当前家庭"></select></label><span>设备、录像与家谱按家庭隔离</span></div> <div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span>家庭、办公场所与工厂独立授权</span></div>
<div id="banner" class="banner" hidden></div> <div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section> <section class="metrics"><div><span>设备</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div> <div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">可选择任意区域,包含其下级区域</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section> <section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden> <section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div> <div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
@@ -40,9 +40,9 @@
<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> <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>
<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-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><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section> <section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device">+ 其他设备</button><button data-page="cameras" class="quiet">镜头通道与接入</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></div><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 id="device-view-save" class="secondary">保存为本归属的默认视图</button></div><p id="device-count" class="description"></p></div><div id="asset-list"></div></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-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-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> <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>
<section id="page-family" class="page" hidden> <section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div> <div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
@@ -65,18 +65,19 @@
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside> <aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
</div> </div>
</section> </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 id="household-list" class="site-grid"></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> <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>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer> <footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
</main> </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="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="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="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><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="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="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><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><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>设备用户名<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">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</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="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>设备用户名<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">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</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></div><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></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><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="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="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"><div class="form-grid"><label class="full">家庭名称 *<input name="name" maxlength="80" required 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="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"><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><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="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"><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> <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="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>
<datalist id="relationship-choices"></datalist> <datalist id="relationship-choices"></datalist>
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog> <dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog> <dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>