diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cd33c06..12833a1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -53,6 +53,22 @@ 本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。 -下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测、多用户细分权限目前没有实现,不作为已具备的能力展示。 +下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。 配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。 + + +## 身份与家谱 + +`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。家谱目前是同一部署的一份共享家庭档案,按账号授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。 + +| API | 权限 | +| --- | --- | +| GET/POST /api/users | 管理员 | +| GET /api/family | 家谱查看或编辑 | +| POST /api/family/person | 家谱编辑 | +| POST /api/family/link | 家谱编辑 | +| GET /api/state | 按空间过滤,个人账号不返回设备连接凭据与配置 | +| GET /media/live/*、/media/playback、/api/recordings | 登录并具备对应空间权限 | + +人物与关系修订在同一数据库事务中保存;修改关系时迭代检查亲子图,拒绝循环。前端 `kinship.js` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。 diff --git a/README.md b/README.md index 7ef4f00..786b380 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh 安装脚本从系统软件源安装 FFmpeg;检测到 Intel renderD128 时安装 Intel 媒体驱动,授予服务用户 render 组权限,并校验安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。 -首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 10 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。 +首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 8 位管理密码(初始用户名为 admin)。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。 ```sh systemctl status zhaovision zhaovision-media @@ -49,7 +49,7 @@ python3 app.py mediamtx ./data/mediamtx.yml ``` -回归测试:`python3 -m unittest -v test_app` 和 `node --test test_live_player.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 +回归测试:`python3 -m unittest -v test_app` 和 `node --test test_live_player.js test_kinship.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 是单管理员模型。 @@ -69,3 +69,18 @@ mediamtx ./data/mediamtx.yml [系统架构与对象关系](ARCHITECTURE.md)。 本项目源码以 MIT 许可证发布。v0.1.x 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。 + +## 账号与家谱(v0.1.11) + +- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置、网络发现和审计接口仅管理员可用。媒体播放请求逐次检查空间权限。 +- 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。 +- 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。 +- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。 +- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。公历日期不确定时留空,将农历或口述信息写入备注。 +- 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。 +- 支持关系参照人物、搜索、祖先树、后裔树、配偶列表和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。 +- 并发编辑使用家谱修订号,旧表单提交会被拒绝,避免覆盖他人的更新。导出包含人物和关系,不含密码和设备凭据。当前导出用于保存副本,尚未提供 JSON 导入界面。 + +### 从 v0.1.10 升级 + +部署前用 SQLite backup API 保存 `/var/lib/zhaovision/vision.db`。首次启动 v0.1.11 会将原有单密码账户迁移为 `admin`,保留原密码散列,并把既有会话关联到管理员;没有固定出厂密码。新增 `users`、`people`、`family_links` 表和 `sessions.user_id` 列。以后可以直接升级;若回退到 v0.1.10,必须同时恢复升级前数据库副本,因为旧版本使用旧账户模型。人物和实际账号数据仅在受限数据目录中,不包含在公开源码内。 diff --git a/VERSION b/VERSION index 9767cc9..20f4951 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.10 +0.1.11 diff --git a/app.py b/app.py index 3c5d4e4..fb5f90b 100644 --- a/app.py +++ b/app.py @@ -27,6 +27,8 @@ import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import onvif import playback +import identity +import genealogy ROOT = Path(__file__).resolve().parent os.umask(0o077) @@ -66,6 +68,10 @@ class Problem(Exception): self.status = status +ACCOUNTS = identity.Accounts(sys.modules[__name__]) +FAMILY = genealogy.Family(sys.modules[__name__]) + + def setting(key, default=None): with LOCK: row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone() @@ -470,15 +476,22 @@ class Handler(BaseHTTPRequestHandler): def authenticated(self): token = self.token() if not re.fullmatch('[a-f0-9]{64}', token): - return False + return None with LOCK: - return bool(DB.execute('SELECT 1 FROM sessions WHERE hash=? AND expires>?', - (hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone()) + row = DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?', + (hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone() + user = get_object('users', row['user_id']) if row else None + return user if user and not user['disabled'] else None def require_auth(self): - if not self.authenticated(): + self.user = self.authenticated() + if not self.user: raise Problem('请先登录', 401) + def require_admin(self): + if self.user['role'] != 'admin': + raise Problem('此操作需要管理员权限', 403) + def body(self): if self.headers.get_content_type() != 'application/json': raise Problem('请求需要 JSON 格式') @@ -509,31 +522,29 @@ class Handler(BaseHTTPRequestHandler): ATTEMPTS[ip] = (count + 1, expiry) if count >= 12: raise Problem('尝试次数过多,请 15 分钟后重试', 429) - account = setting('account') + accounts = objects('users') password = data.get('password', '') - if not isinstance(password, str) or not 10 <= len(password) <= 128: - raise Problem('管理密码需要 10 至 128 个字符') if setup: - if account: + if accounts: raise Problem('管理员已经初始化', 409) code = data.get('code', '') expected = (DATA / 'setup-code.txt').read_text().strip() if not isinstance(code, str) or not hmac.compare_digest(code, expected): raise Problem('初始化码不正确', 403) - salt = secrets.token_hex(16) - derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex() - set_setting('account', {'salt': salt, 'hash': derived}) + account = ACCOUNTS.save({'username': 'admin', 'name': '管理员', 'role': 'admin', + 'password': password, 'familyAccess': 'edit'}, None) (DATA / 'setup-code.txt').unlink(missing_ok=True) audit('初始化管理员') else: - if not account: + if not accounts: raise Problem('请先初始化管理员', 409) - derived = hashlib.scrypt(password.encode(), salt=account['salt'].encode(), n=16384, r=8, p=1).hex() - if not hmac.compare_digest(derived, account['hash']): - raise Problem('密码不正确', 403) + account = ACCOUNTS.verify(data.get('username', ''), password) + if not account: + raise Problem('用户名或密码不正确', 403) token = secrets.token_hex(32) DB.execute('DELETE FROM sessions WHERE expires maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value): + raise a.Problem('生平或备注格式不正确') + item[key] = value.strip() + else: + item[key] = a.clean_text(value, maximum, key == 'name') + for key in ('birthDate', 'deathDate'): + if item[key]: + try: + if dt.date.fromisoformat(item[key]).isoformat() != item[key]: + raise ValueError() + except ValueError: + raise a.Problem('日期请使用公历 YYYY-MM-DD;不确定可留空,在备注记录') + if item['birthDate'] and item['deathDate'] and item['birthDate'] > item['deathDate']: + raise a.Problem('逝世日期不能早于出生日期') + item['gender'] = data.get('gender', 'unknown') + item['lifeStatus'] = data.get('lifeStatus', 'unknown') + if item['gender'] not in ('male', 'female', 'unknown') or item['lifeStatus'] not in ('alive', 'deceased', 'unknown'): + raise a.Problem('人物属性不正确') + if item['deathDate'] and item['lifeStatus'] != 'deceased': + raise a.Problem('填写逝世日期时,请选择已故') + item['archived'] = data.get('archived', False) + if not isinstance(item['archived'], bool): + raise a.Problem('归档状态不正确') + return item + + def link(self, data, old): + a = self.a + left, right = data.get('fromId'), data.get('toId') + if not isinstance(left, str) or not isinstance(right, str) or not a.get_object('people', left) or not a.get_object('people', right): + raise a.Problem('请先录入关系双方的人物') + if left == right: + raise a.Problem('不能把人物与自己建立亲属关系') + kind = data.get('kind') + if kind not in ('parent', 'spouse'): + raise a.Problem('关系类型不正确') + lineage = data.get('lineage', 'unspecified') + if lineage not in ('biological', 'adoptive', 'step', 'unspecified'): + raise a.Problem('亲子关系属性不正确') + active = data.get('active', True) + if not isinstance(active, bool): + raise a.Problem('关系状态不正确') + item = dict(id=old['id'] if old else secrets.token_hex(8), fromId=left, toId=right, kind=kind, + lineage=lineage if kind == 'parent' else 'unspecified', active=active, + note=a.clean_text(data.get('note', ''), 300)) + links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']] + if active: + for e in links: + same = (e['fromId'], e['toId']) == (left, right) + if kind == 'spouse': + same = {e['fromId'], e['toId']} == {left, right} + if e['kind'] == kind and same: + raise a.Problem('这条关系已经存在', 409) + graph = {} + for e in links + [item]: + if e['kind'] == 'parent': + graph.setdefault(e['fromId'], []).append(e['toId']) + # Iterative walk supports deep ancestry without Python recursion limits. + def reaches(start, target): + pending, seen = [start], set() + while pending: + p = pending.pop() + if p in seen: + continue + seen.add(p) + for child in graph.get(p, []): + if child == target: + return True + pending.append(child) + return False + if kind == 'parent' and reaches(right, left): + raise a.Problem('这条关系会形成祖先与后代循环,请检查方向') + for e in links + [item]: + if e['kind'] == 'spouse' and (reaches(e['fromId'], e['toId']) or reaches(e['toId'], e['fromId'])): + raise a.Problem('配偶不能同时是直系祖先或后代,请检查已有关系') + return item diff --git a/identity.py b/identity.py new file mode 100644 index 0000000..24a04bc --- /dev/null +++ b/identity.py @@ -0,0 +1,118 @@ +"""Local accounts. Passwords never appear in public account representations.""" +import hashlib +import hmac +import json +import re +import secrets + + +class Accounts: + def __init__(self, app): + self.a = app + + def initialize(self): + a = self.a + with a.LOCK: + a.DB.execute('CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, body TEXT NOT NULL)') + if 'user_id' not in [r['name'] for r in a.DB.execute('PRAGMA table_info(sessions)')]: + a.DB.execute('ALTER TABLE sessions ADD COLUMN user_id TEXT') + legacy = a.setting('account') + if legacy and not a.objects('users'): + user = dict(id=secrets.token_hex(8), username='admin', name='管理员', role='admin', + disabled=False, siteIds=[], familyAccess='edit', personId='', + relatedToId='', relationship='', **legacy) + a.DB.execute('INSERT INTO users VALUES (?,?)', (user['id'], json.dumps(user))) + a.DB.execute('UPDATE sessions SET user_id=? WHERE user_id IS NULL', (user['id'],)) + a.DB.execute("DELETE FROM settings WHERE key='account'") + a.DB.commit() + + @staticmethod + def public(user): + return {k: v for k, v in user.items() if k not in ('salt', 'hash')} if user else None + + def password(self, password): + if not isinstance(password, str) or not 8 <= len(password) <= 128: + raise self.a.Problem('密码需要 8 至 128 个字符') + salt = secrets.token_hex(16) + return {'salt': salt, 'hash': hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()} + + def verify(self, username, password): + if not isinstance(username, str) or not isinstance(password, str) or len(password) > 128: + return None + user = next((u for u in self.a.objects('users') if u['username'] == username.strip().lower()), None) + # Also perform derivation for nonexistent accounts. + salt = user['salt'] if user else '0' * 32 + derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex() + return user if user and not user['disabled'] and hmac.compare_digest(derived, user['hash']) else None + + def save(self, data, actor): + a = self.a + with a.LOCK: + old = a.get_object('users', data.get('id')) + if data.get('id') and not old: + raise a.Problem('账号不存在', 404) + uid = old['id'] if old else secrets.token_hex(8) + username = a.clean_text(data.get('username', ''), 32, True).lower() + if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,31}', username): + raise a.Problem('用户名使用 3 至 32 位英文字母、数字、点、下划线或短横线') + if any(u['username'] == username and u['id'] != uid for u in a.objects('users')): + raise a.Problem('用户名已存在', 409) + role = data.get('role', 'member') + access = data.get('familyAccess', 'none') + if role not in ('admin', 'member') or access not in ('none', 'read', 'edit'): + raise a.Problem('权限选项不正确') + sites = data.get('siteIds', []) + if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites): + raise a.Problem('请选择有效空间') + disabled = data.get('disabled', False) + if not isinstance(disabled, bool): + raise a.Problem('账号状态不正确') + if old and old['role'] == 'admin' and (role != 'admin' or disabled): + if not any(u['id'] != uid and u['role'] == 'admin' and not u['disabled'] for u in a.objects('users')): + raise a.Problem('至少保留一个启用的管理员') + if actor and actor['id'] == uid and (disabled or role != actor['role']): + raise a.Problem('不能停用或降低当前登录账号的管理权限') + related = a.clean_text(data.get('relatedToId', ''), 32) + person = a.clean_text(data.get('personId', ''), 32) + if related and related != uid and not a.get_object('users', related): + raise a.Problem('关系参照账号不存在') + if person: + if not a.get_object('people', person): + raise a.Problem('家谱人物不存在') + if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')): + raise a.Problem('这个人物已经关联其他账号') + password = data.get('password', '') + if not isinstance(password, str): + raise a.Problem('密码格式不正确') + credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')} + user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True), + role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, + personId=person, relatedToId=related, + relationship=a.clean_text(data.get('relationship', ''), 40), **credentials) + # Revoke sessions when credentials or authorization change, not for label edits. + if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username')): + a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,)) + a.save_object('users', user) + a.audit('更新账号' if old else '建立账号', username) + return self.public(user) + + def camera(self, user, camera): + if not camera: + raise self.a.Problem('摄像头不存在', 404) + if user['role'] != 'admin' and camera.get('siteId') not in user['siteIds']: + raise self.a.Problem('没有这个空间的访问权限', 403) + return camera + + def state(self, user, state): + state['user'] = self.public(user) + if user['role'] == 'admin': + return state + permitted = set(user['siteIds']) + 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] + fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource') + state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted] + state['recorders'] = [] + state['scan'] = {} + state['storage'] = {} + return state diff --git a/test_app.py b/test_app.py index 3e8ad2d..d6022e8 100644 --- a/test_app.py +++ b/test_app.py @@ -22,13 +22,14 @@ class WebTests(unittest.TestCase): @classmethod def setUpClass(cls): app.initialize() + cls.admin = app.ACCOUNTS.save({'username': 'admin', 'password': 'test-password', 'role': 'admin'}, None) cls.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler) cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) cls.thread.start() - app.DB.execute('INSERT INTO sessions VALUES (?,?)', - (hashlib.sha256(b'f' * 64).hexdigest(), time.time() + 60)) + app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', + (hashlib.sha256(b'f' * 64).hexdigest(), time.time() + 3600, cls.admin['id'])) app.DB.commit() - app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'password': 'private'}) + app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'siteId': 'test-site', 'password': 'private'}) @classmethod def tearDownClass(cls): @@ -113,6 +114,84 @@ class WebTests(unittest.TestCase): def test_password_redacted(self): self.assertNotIn('password', app.public_camera(app.get_object('cameras', 'a' * 16))) + def test_accounts_and_authorization(self): + member = app.ACCOUNTS.save({'username': 'member', 'password': 'password8', 'familyAccess': 'read'}, self.admin) + self.assertNotIn('hash', member) + self.assertNotIn('salt', member) + self.assertIsNotNone(app.ACCOUNTS.verify('member', 'password8')) + self.assertIsNone(app.ACCOUNTS.verify('member', 'incorrect')) + code, _, body = self.request('/api/users') + self.assertEqual(code, 200) + self.assertNotIn(b'"hash"', body) + token='e'*64 + app.DB.execute('INSERT INTO sessions VALUES (?,?,?)',(hashlib.sha256(token.encode()).hexdigest(),time.time()+3600,member['id'])) + app.DB.commit() + def request(path,data=None): + c=http.client.HTTPConnection(*self.server.server_address,timeout=3) + c.request('POST' if data is not None else 'GET',path,None if data is None else json.dumps(data), + {'Cookie':'vision='+token,'Content-Type':'application/json'}) + r=c.getresponse();status=r.status;r.read();c.close();return status + for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8', + '/api/recordings?camera='+'a'*16]: + self.assertEqual(request(path),403) + for path in ['/api/storage','/api/users','/api/family/person']: + self.assertEqual(request(path,{}),403) + self.assertEqual(request('/api/family'),200) + app.ACCOUNTS.save(dict(member,disabled=True),self.admin) + self.assertEqual(request('/api/family'),401) + + def test_account_constraints(self): + with self.assertRaises(app.Problem): + app.ACCOUNTS.save(dict(self.admin,disabled=True),self.admin) + with self.assertRaises(app.Problem): + app.ACCOUNTS.save({'username':'admin','password':'password8'},self.admin) + with self.assertRaises(app.Problem): + app.ACCOUNTS.save({'username':'short','password':'123'},self.admin) + + def test_family_graph_validation_and_revision(self): + def save(kind,data): + return app.FAMILY.save(kind,dict(data,revision=app.setting('familyRevision',0)),self.admin)['item'] + p=save('person',{'name':'祖辈','biography':'第一行\n第二行'}) + q=save('person',{'name':'子辈'}) + r=save('person',{'name':'孙辈'}) + first=save('link',{'fromId':p['id'],'toId':q['id'],'kind':'parent'}) + save('link',{'fromId':q['id'],'toId':r['id'],'kind':'parent'}) + for value in [{'fromId':r['id'],'toId':p['id'],'kind':'parent'}, + {'fromId':p['id'],'toId':p['id'],'kind':'parent'}, + {'fromId':p['id'],'toId':r['id'],'kind':'spouse'}, + {'fromId':p['id'],'toId':q['id'],'kind':'parent'}]: + with self.assertRaises(app.Problem):save('link',value) + with self.assertRaises(app.Problem): + app.FAMILY.save('person',{'name':'过期编辑','revision':0},self.admin) + with self.assertRaises(app.Problem): + save('person',{'name':'错误日期','birthDate':'2026-02-30'}) + with self.assertRaises(app.Problem): + save('person',{'name':'错误日期','birthDate':'2020-01-01','deathDate':'1900-01-01','lifeStatus':'deceased'}) + save('link',dict(first,active=False)) + self.assertFalse(app.get_object('family_links',first['id'])['active']) + save('link',dict(first,active=True)) + save('person',dict(p,archived=True)) + self.assertTrue(app.get_object('people',p['id'])['archived']) + save('person',dict(p,archived=False)) + + def test_legacy_migration_preserves_password_and_sessions(self): + # Exercise migration in a separate database without touching the HTTP fixture. + import sqlite3 + from pathlib import Path + with tempfile.TemporaryDirectory() as folder: + db=sqlite3.connect(Path(folder)/'migration.db');db.row_factory=sqlite3.Row + db.executescript('CREATE TABLE settings(key TEXT PRIMARY KEY,value TEXT NOT NULL);CREATE TABLE sessions(hash TEXT PRIMARY KEY,expires REAL NOT NULL);') + credentials=app.ACCOUNTS.password('old-password') + db.execute('INSERT INTO settings VALUES (?,?)',('account',json.dumps(credentials))) + db.execute('INSERT INTO sessions VALUES (?,?)',('legacy',time.time()+60));db.commit() + with patch.object(app,'DB',db): + app.ACCOUNTS.initialize();app.ACCOUNTS.initialize() + self.assertEqual(len(app.objects('users')),1) + self.assertIsNotNone(app.ACCOUNTS.verify('admin','old-password')) + self.assertEqual(db.execute('SELECT user_id FROM sessions').fetchone()[0],app.objects('users')[0]['id']) + self.assertIsNone(app.setting('account')) + db.close() + def test_playback_has_bound_and_cleanup(self): playback.SLOTS.acquire() playback.SLOTS.acquire() diff --git a/test_kinship.js b/test_kinship.js new file mode 100644 index 0000000..102d968 --- /dev/null +++ b/test_kinship.js @@ -0,0 +1,7 @@ +const test=require('node:test'),assert=require('node:assert/strict'),K=require('./web/kinship.js'); +const people=Array.from({length:10},(_,i)=>({id:String(i),gender:i===9?'female':'male',birthDate:''})); +const links=Array.from({length:8},(_,i)=>({id:String(i),fromId:String(i),toId:String(i+1),kind:'parent',active:true,lineage:'biological'})); +test('descendants are derived across generations',()=>{assert.equal(K.describe(people,links,'0','1'),'儿子');assert.equal(K.describe(people,links,'0','2'),'孙子');assert.equal(K.describe(people,links,'0','3'),'曾孙');assert.equal(K.describe(people,links,'0','4'),'玄孙');assert.equal(K.describe(people,links,'0','8'),'第 8 代后裔');}); +test('ancestors, self and disconnected people',()=>{assert.equal(K.describe(people,links,'4','0'),'高祖辈');assert.equal(K.describe(people,links,'0','0'),'本人');assert.equal(K.describe(people,links,'0','9'),'关系待补充');}); +test('siblings do not invent age order',()=>{const siblings=[...links,{fromId:'0',toId:'9',kind:'parent',active:true}];assert.equal(K.describe(people,siblings,'1','9'),'姐妹(长幼待确认)');const dated=people.map(p=>({...p,birthDate:p.id==='1'?'2000-01-01':p.id==='9'?'2002-01-01':''}));assert.equal(K.describe(dated,siblings,'1','9'),'妹妹');}); +test('inactive and adoptive relationships remain explicit',()=>{assert.equal(K.describe(people,links.map(e=>({...e,active:false})),'0','1'),'关系待补充');assert.match(K.describe(people,links.map(e=>({...e,lineage:'adoptive'})),'0','1'),/收养/);}); diff --git a/web/app.js b/web/app.js index 4a5fd23..9e833cf 100644 --- a/web/app.js +++ b/web/app.js @@ -5,11 +5,28 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'& const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他'); let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0; +let currentUser = null; +const isAdmin = () => currentUser?.role === 'admin'; +function applyPermissions(){ + const admin=isAdmin(); + $$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera]').forEach(el=>el.hidden=!admin); + $('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none'); + $('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : ''; +} +function clearPrivateView(){ + currentUser=null;stopPlayers();resetPlayback(); + state={cameras:[],assets:[],recorders:[],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'])$('#'+id).replaceChildren(); + if(typeof clearFamilyState==='function')clearFamilyState(); + $$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close()); + $$('dialog form').forEach(f=>f.reset()); + activePage='live';showPage('live');applyPermissions(); +} const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']}; async function api(path, data) { const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)}); let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); } - if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; stopPlayers(); auth(); } throw Error(result.error || '操作未完成'); } + if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; clearPrivateView(); auth(); } throw Error(result.error || '操作未完成'); } return result; } function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); } @@ -19,14 +36,19 @@ function filtered() { return state.cameras.filter(c => !$('#site-filter').value function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; } function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; } function showPage(page) { + if(!isAdmin()&&!['live','playback','family'].includes(page))return; if (activePage !== page) stopPlayers(); activePage = page; + $('.metrics').hidden=['family','users'].includes(page); + $('.filter-row').hidden=['family','users'].includes(page); $$('.page').forEach(el => el.hidden = el.id !== 'page-' + page); $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page)); $('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1]; if (page !== 'playback') resetPlayback(); render(); if (page === 'storage') loadAudit(); + if (page === 'family') loadFamily(); + if (page === 'users') loadUsers(); } function render() { $('#metric-all').textContent = state.assets.length; @@ -49,6 +71,7 @@ function render() { drawTimeline(); } if (activePage === 'storage') renderStorage(); + applyPermissions(); } function empty(title, description, button) { return `

${esc(title)}

${esc(description)}

${button || ''}
`; } function renderLive() { @@ -56,7 +79,7 @@ function renderLive() { const key = ZhaoLivePlayer.key(cameras, quality); if (key === liveKey) { updateLiveStatus(cameras); return; } stopPlayers(); liveKey = key; - $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

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

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''); + $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

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

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); for (const c of cameras.filter(c=>c.enabled)) { const video = $(`video[data-camera="${c.id}"]`); players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`)); @@ -90,8 +113,8 @@ function renderStorage() { $('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`
${esc(k)}
${esc(v)}
`).join(''); } async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`
${new Date(r.at).toLocaleString()}${esc(r.action)} ${esc(r.name)}
`).join('')||'

尚无操作记录

';}catch(e){toast(e.message);} } -async function refresh() {if(!loggedIn)return;try{state=await api('/api/state');render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}} -async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-title').textContent=setup?'建立你的视觉空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置你自己的管理密码。':'输入管理密码,进入视觉工作空间。';if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}} +async function refresh() {if(!loggedIn)return;const userId=currentUser?.id;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId)return;state=next;currentUser=state.user;render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}} +async function auth() {try{const s=await api('/api/session');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){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 openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.archiveRecorderId.innerHTML=''+state.recorders.map(r=>``).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();} function formValues(f) {return Object.fromEntries(new FormData(f).entries());} @@ -102,7 +125,7 @@ handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d) $('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}}); $('#auth-dialog').addEventListener('cancel',e=>e.preventDefault()); document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));}); -$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();resetPlayback();await auth();}; +$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();}; $('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();}; $('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}}; $('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}}; diff --git a/web/family.js b/web/family.js new file mode 100644 index 0000000..b2dfb61 --- /dev/null +++ b/web/family.js @@ -0,0 +1,74 @@ +'use strict'; +pageInfo.family=['家谱','记下亲人的故事,让世代之间有迹可循。']; +pageInfo.users=['账号与关系','每个人有自己的账号,权限与亲属关系分别设置。']; +let family={people:[],links:[],revision:0,canEdit:false}, users=[], focusPerson='',familyGeneration=0; +const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事']; +$('#relationship-choices').innerHTML=relationships.map(r=>``).join(''); +function clearFamilyState(){familyGeneration++;family={people:[],links:[],revision:0,canEdit:false};users=[];focusPerson='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;} +function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';} +function peopleOptions(blank=true){return (blank?'':'')+family.people.map(p=>``).join('');} +function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}} +async function loadFamily(){ + const generation=++familyGeneration; + try{const result=await api('/api/family');if(!loggedIn||generation!==familyGeneration)return;family=result; + if(!family.people.some(p=>p.id===focusPerson))focusPerson=family.selfId||family.people.find(p=>!p.archived)?.id||''; + setOptions($('#family-reference'),peopleOptions(false));if(!$('#family-reference').value&&family.selfId)$('#family-reference').value=family.selfId; + if(!$('#family-reference').dataset.initialized&&family.selfId){$('#family-reference').value=family.selfId;$('#family-reference').dataset.initialized='true';} + $('#family-error').textContent='';renderFamily(); + }catch(e){$('#family-error').textContent=e.message;} +} +function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');} +function renderFamily(){ + const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase(); + const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term)); + $('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'; + $('#new-person').hidden=!family.canEdit; + $('#family-people').innerHTML=people.map(p=>``).join('')||empty('从一个人物开始','可先录入自己,再逐步补齐父母、祖先和后辈。'); + const p=family.people.find(p=>p.id===focusPerson); + if(!p){$('#family-detail').innerHTML='

人物档案

选择或录入一位人物,查看关系与家谱。

';$('#family-tree').replaceChildren();return;} + const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id); + $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',p.birthDate],['逝世日期',p.deathDate],['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

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

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; + renderFamilyTree(); +} +function renderFamilyTree(){ + const root=family.people.find(p=>p.id===focusPerson);if(!root)return; + const depth=Number($('#family-depth').value),edges=family.links.filter(e=>e.active),people=new Map(family.people.map(p=>[p.id,p]));let count=0; + function branch(id,d,direction,path){ + if(++count>300)return '
  • 更多分支请切换人物查看
  • '; + const p=people.get(id);if(!p)return ''; + const node=``; + if(path.has(id))return `
  • ${node}(重复分支)
  • `; + const seen=new Set([...path,id]); + const next=edges.filter(e=>e.kind==='parent'&&(direction==='up'?e.toId:e.fromId)===id).map(e=>direction==='up'?e.fromId:e.toId); + return `
  • ${node}${d>0&&next.length?'':next.length?' 还有 '+next.length+' 位,点击此人物继续':''}
  • `; + } + const spouseIds=edges.filter(e=>e.kind==='spouse'&&(e.fromId===root.id||e.toId===root.id)).map(e=>e.fromId===root.id?e.toId:e.fromId); + $('#family-tree').innerHTML=`

    上溯祖先

    下续后裔

    配偶

    ${spouseIds.map(id=>``).join(' ')||'

    尚未记录

    '}
    `; +} +function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});$('#person-dialog').showModal();} +function openRelation(id){if(!family.canEdit)return;const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();} +for(const [name,path]of [['person','person'],['relation','link']]){ + $('#'+name+'-form').addEventListener('submit',async e=>{ + e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; + try{const d=formValues(f);d.revision=Number(d.revision);if(name==='person')d.archived=f.elements.archived.checked;else d.active=f.elements.active.checked; + const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();await loadFamily();toast('家谱已保存'); + }catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;} + }); +} +async function loadUsers(){ + if(!isAdmin())return;const id=currentUser.id; + try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f; + $('#user-list').innerHTML=`
    ${users.map(u=>``).join('')}
    账号角色 / 状态人物关系家谱权限摄像头空间
    ${esc(u.name)}${esc(u.username)}${u.role==='admin'?'管理员':'个人账号'}${u.disabled?' · 已停用':''}${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}家谱:${esc(personName(u.personId))}${u.role==='admin'?'管理':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}${u.role==='admin'?'全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}
    `; + }catch(e){toast(e.message);} +} +function openUser(id){ + if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{}; + f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML=''+users.map(u=>``).join(''); + fillForm(f,u);f.elements.password.required=!u.id; + $('#user-sites').innerHTML=state.sites.map(s=>``).join('');$('#user-dialog').showModal(); +} +$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}}); +$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily; +$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;$('#family-depth').onchange=renderFamilyTree; +$('#family-export').onclick=async()=>{try{const data=await api('/api/family');const blob=new Blob([JSON.stringify({format:'zhaofu-family',version:1,exportedAt:new Date().toISOString(),people:data.people,links:data.links},null,2)],{type:'application/json'});const link=document.createElement('a'),href=URL.createObjectURL(blob);link.href=href;link.download='zhaofu-family-'+new Date().toISOString().slice(0,10)+'.json';link.click();setTimeout(()=>URL.revokeObjectURL(href),60000);}catch(e){toast(e.message);}}; +document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.person){focusPerson=b.dataset.person;renderFamily();}if(b.dataset.editPerson)openPerson(b.dataset.editPerson);if(b.id==='add-relation')openRelation();if(b.dataset.editRelation)openRelation(b.dataset.editRelation);if(b.dataset.editUser)openUser(b.dataset.editUser);}); diff --git a/web/index.html b/web/index.html index 012664a..68e0570 100644 --- a/web/index.html +++ b/web/index.html @@ -2,25 +2,27 @@ - 赵府智家 · 视觉 - + 赵府智家 · 家庭空间 +
    -
    ZHAOFU HOME / VISION

    实时画面

    从一个空间,看到每一个位置。

    等待连接
    +
    ZHAOFU HOME / FAMILY

    实时画面

    从一个空间,看到每一个位置。

    等待连接
    摄像头—
    已接通码流—
    录像占用—
    空间档案—
    家庭与公司,分别管理
    @@ -37,13 +39,26 @@ -
    赵府智家 · 视觉系统独立部署 / 数据自有
    + + +
    赵府智家 · 家庭空间独立部署 / 数据自有
    -
    WELCOME HOME

    欢迎回来

    输入管理密码,进入视觉工作空间。

    +
    WELCOME HOME

    欢迎回来

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

    空间档案

    镜头通道配置

    直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。

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

    摄像头对象

    录像机配置

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

    +

    账号档案

    允许查看摄像头的空间

    个人账号仅能观看已选空间的实时画面和回放;管理员可以管理全部空间。

    + +

    家谱人物

    +

    连接人物关系

    每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。

    diff --git a/web/kinship.js b/web/kinship.js new file mode 100644 index 0000000..483f1b8 --- /dev/null +++ b/web/kinship.js @@ -0,0 +1,35 @@ +'use strict'; +(function(root){ + function describe(people, links, reference, target) { + const byId = new Map(people.map(p=>[p.id,p])); + if (!byId.has(reference) || !byId.has(target)) return '尚未关联'; + if (reference === target) return '本人'; + const edges=links.filter(e=>e.active), graph=new Map(); + function add(a,b,kind,lineage){if(!graph.has(a))graph.set(a,[]);graph.get(a).push({id:b,kind,lineage});} + for(const e of edges){add(e.fromId,e.toId,e.kind==='parent'?'child':'spouse',e.lineage);add(e.toId,e.fromId,e.kind==='parent'?'parent':'spouse',e.lineage);} + const queue=[{id:reference,path:[]}],seen=new Set([reference]);let path; + for(let i=0;igender==='male'?m:gender==='female'?f:u; + const type=path.map(e=>e.kind),n=path.length; + let result; + if(type.every(k=>k==='child')){ + result=n===1?word('儿子','女儿','子女'):n===2?word('孙子','孙女','孙辈'):n===3?word('曾孙','曾孙女','曾孙辈'):n===4?word('玄孙','玄孙女','玄孙辈'):`第 ${n} 代后裔`; + if(n===2 && byId.get(path[0].id)?.gender==='female')result=word('外孙','外孙女','外孙辈'); + }else if(type.every(k=>k==='parent')){ + result=n===1?word('爸爸','妈妈','父母'):n===2?(byId.get(path[0].id)?.gender==='female'?word('姥爷','姥姥','外祖辈'):byId.get(path[0].id)?.gender==='male'?word('爷爷','奶奶','祖辈'):'祖父母辈'):n===3?'曾祖辈':n===4?'高祖辈':`第 ${n} 代祖先`; + }else if(n===1 && type[0]==='spouse') result='配偶'; + else if(n===2 && type.join('/')==='parent/child'){ + const a=byId.get(reference).birthDate,b=byId.get(target).birthDate; + result=a&&b&&a!==b?(be.kind==='spouse'?'配偶':e.kind==='parent'?(byId.get(e.id)?.gender==='male'?'父亲':byId.get(e.id)?.gender==='female'?'母亲':'父母'):(byId.get(e.id)?.gender==='male'?'儿子':byId.get(e.id)?.gender==='female'?'女儿':'子女')).join(' → '); + } + return result+(path.some(e=>e.lineage==='adoptive'||e.lineage==='step')?'(含收养 / 继亲关系)':''); + } + const api={describe}; if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.ZhaoKinship=api; +})(typeof window!=='undefined'?window:globalThis); diff --git a/web/style.css b/web/style.css index 5f147bc..4d49367 100644 --- a/web/style.css +++ b/web/style.css @@ -1,2 +1,10 @@ :root{font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;color:#dae4e6;background:#10191d;font-synthesis:none;--muted:#8faaaF;--line:#2b3b40;--panel:#18252b;--accent:#74d5bb}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,.button{border:1px solid transparent;background:var(--accent);color:#153b31;padding:11px 18px;border-radius:8px;font-weight:600;cursor:pointer;white-space:nowrap;text-decoration:none;font-size:14px}button:hover,.button:hover{filter:brightness(1.08)}button:disabled{opacity:.5;cursor:wait}.secondary{background:#263a40;color:#cfe7e5;border-color:#385058}.quiet{background:transparent;border-color:var(--line);color:var(--muted);padding:9px 13px;font-size:13px}input,select{background:#111e24;border:1px solid #3a4e55;color:#e3f0ef;border-radius:7px;padding:10px 11px;min-width:0;max-width:100%;outline:none}input:focus,select:focus{border-color:var(--accent)}label{font-size:13px;color:#a5bec1;display:flex;gap:8px;align-items:center}h1,h2,h3,p{margin-top:0}h1{font-size:30px;letter-spacing:1px;margin:8px 0 8px}h2{font-size:19px;font-weight:600}h3{font-size:16px}p{line-height:1.8}small{font-weight:400}.sidebar{position:fixed;left:0;top:0;bottom:0;width:238px;background:#111e24;border-right:1px solid var(--line);padding:32px 20px;display:flex;flex-direction:column}.brand{display:flex;gap:12px;align-items:center;color:#e9f3f1;text-decoration:none;font-size:21px;font-weight:600;padding:5px 8px 40px}.brand-icon{color:var(--accent);font-size:39px}.brand small{display:block;font-size:9px;letter-spacing:1.1px;margin-top:7px;color:var(--muted)}.nav-label{color:#6b868b;font-size:11px;letter-spacing:3px;padding:14px 15px}nav{display:grid;gap:8px}nav button{text-align:left;background:transparent;color:#8fa9ae;display:flex;gap:15px;font-size:18px;padding:14px 16px}nav button span{font-size:14px}nav button.active{background:#244038;color:#9ee8d3}.side-note{margin-top:auto;padding:20px 9px;font-size:12px;color:#8ebdb1}.side-note p{color:#5e8086;font-size:11px;margin:16px 0}.dot{display:inline-block;width:7px;height:7px;background:#7bd5b5;border-radius:50%;margin-right:5px}main{margin-left:238px;padding:38px 42px 18px;max-width:1800px}header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:27px}.eyebrow{font-size:10px;letter-spacing:3px;color:#77aa9e}header p{color:var(--muted);font-size:13px;margin:0}.header-status{display:grid;justify-items:end;gap:13px;padding-top:4px;font-size:11px;color:#718f96}.badge{font-size:11px;background:#243c36;color:#8bddbd;padding:6px 10px;border-radius:5px}.badge.off{background:#372d27;color:#d7ae87}.metrics{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);background:#152329;border-radius:12px;overflow:hidden}.metrics>div{padding:21px 25px;border-right:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:10px}.metrics>div:last-child{border:0}.metrics span{font-size:12px;color:var(--muted)}.metrics strong{font-size:26px;font-weight:500}.filter-row{display:flex;gap:16px;align-items:center;margin:26px 0;color:#728c93;font-size:12px}.filter-row>button{margin-left:auto}.filter-row select{max-width:280px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:28px 0 17px}.section-head h2{margin:0}.section-head small{font-size:9px;color:#637f87;letter-spacing:2px;margin-left:12px}.section-head>div{display:flex;align-items:center;gap:16px}.live-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.camera-card{border:1px solid var(--line);border-radius:11px;background:var(--panel);overflow:hidden}.camera-card .video-panel{border:0;border-radius:0;margin:0}.video-panel{position:relative;aspect-ratio:16/9;background:#070e12;overflow:hidden;border:1px solid var(--line);border-radius:10px;margin-bottom:16px}.video-panel video{width:100%;height:100%;object-fit:contain;display:block}.video-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:13px;color:#5c7b85;font-size:13px;pointer-events:none;background:radial-gradient(ellipse at center,#142a32 0,transparent 70%)}.video-empty .lens{font-size:36px;color:#456773}.video-error{position:absolute;left:14px;right:14px;bottom:48px;background:#271d1ce6;border-radius:6px;color:#e3b7a1;padding:9px 12px;font-size:12px}.camera-meta{display:flex;justify-content:space-between;gap:12px;padding:16px 18px}.camera-meta strong{font-size:14px}.camera-meta p{color:#728f96;font-size:11px;margin:6px 0 0}.camera-meta .badge{align-self:flex-start}.camera-actions{display:flex;gap:8px;padding:0 18px 14px}.camera-actions button{font-size:11px;padding:7px 10px}.empty{grid-column:1/-1;padding:62px 20px;text-align:center;background:var(--panel);border:1px dashed #354b52;border-radius:12px;color:#819da5}.empty h3{font-size:19px;color:#c4d5d6;font-weight:500}.empty p{font-size:13px;margin-bottom:22px}.panel{padding:22px;background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:18px}.playback-controls{display:flex;align-items:end;gap:15px;flex-wrap:wrap}.playback-controls label{display:grid}.playback-controls>span{font-size:12px;color:var(--muted);margin:auto 0}.timeline-head{display:flex;justify-content:space-between;font-size:12px;margin-bottom:20px}.timeline-head span{color:var(--muted)}.legend{display:inline-block;background:var(--accent);width:15px;height:6px;margin-right:7px}#timeline{width:100%;height:76px;display:block;cursor:crosshair}.time-labels{display:flex;justify-content:space-between;font-size:10px;color:#74949c}.seek-row{margin-top:22px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}.recording-list{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.recording-item{text-align:left;background:#203139;color:#bdd5d8;font-size:12px;border:1px solid var(--line);white-space:normal}.recording-item small{display:block;color:#719499;margin-top:7px}.discovery{display:flex;align-items:center;gap:14px}.discovery>div{margin-right:auto}.discovery p{font-size:12px;color:var(--muted);margin:5px 0 0}.discovery input{width:190px}.scan-chip{display:inline-flex;gap:16px;align-items:center;margin:0 12px 14px 0;padding:12px 16px;border:1px solid var(--line);border-radius:8px;font-size:13px}.scan-chip button{padding:6px 10px;font-size:11px}.table-wrap{overflow:auto}.device-table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);font-size:13px}.device-table th{text-align:left;font-size:11px;color:#8ba9ae;font-weight:400;padding:15px}.device-table td{padding:18px 15px;border-top:1px solid var(--line)}.device-table td small{display:block;margin-top:7px;color:#75969d}.site-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.site-card{background:var(--panel);border:1px solid var(--line);padding:24px;border-radius:10px}.site-heading{display:flex;justify-content:space-between;gap:12px}.site-card h3{margin:12px 0}.site-card dl{font-size:12px}.site-card dl>div{display:flex;gap:18px;padding:7px 0}.site-card dt{color:#718d94;width:80px;flex-shrink:0}.site-card dd{margin:0;color:#b6cccf;word-break:break-word}.description{font-size:12px;color:var(--muted);line-height:1.9}.storage-grid{display:grid;grid-template-columns:1fr 1.2fr;gap:22px}.storage-grid form>label{display:grid;margin:20px 0}.storage-grid dl{font-size:13px}.storage-grid dl div{display:flex;justify-content:space-between;gap:15px;padding:15px 0;border-bottom:1px solid var(--line)}.storage-grid dt{color:var(--muted)}.storage-grid dd{margin:0}.audit-item{font-size:11px;padding:10px 0;border-top:1px solid var(--line);color:#9ab8bd}.audit-item small{display:block;color:#607f89;margin-bottom:5px}.banner{background:#493c24;border:1px solid #6b5430;color:#e1c69a;border-radius:8px;padding:13px 17px;font-size:13px;margin-bottom:22px}dialog{color:#dae4e6;background:#192a31;border:1px solid #3b555d;border-radius:15px;width:min(760px,94vw);max-height:90vh;padding:30px;box-shadow:0 25px 120px #0008}dialog::backdrop{background:#061015dd;backdrop-filter:blur(8px)}#auth-dialog{max-width:430px;padding:38px}#auth-form h2{font-size:28px;margin:18px 0 10px}#auth-form>label{display:grid;margin:22px 0}#auth-form p{color:#93afb5;font-size:13px}.dialog-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:25px}.dialog-heading h2{margin:0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.form-grid label{display:grid;gap:8px}.form-grid .full{grid-column:1/-1}.form-grid .check{display:flex}.check input{accent-color:var(--accent);width:16px;height:16px}.dialog-footer{display:flex;justify-content:flex-end;margin-top:20px;padding-top:20px;border-top:1px solid var(--line)}.form-error{font-size:12px;color:#efab97;line-height:1.6}.wide{width:100%}footer{font-size:10px;color:#54727c;display:flex;justify-content:space-between;margin-top:40px;padding-top:18px;border-top:1px solid var(--line)}#toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);border:1px solid #4a7d70;background:#1a3c32;color:#b5efd9;padding:13px 22px;border-radius:9px;z-index:5;box-shadow:0 10px 30px #0005;font-size:13px}[hidden]{display:none!important}:focus-visible{outline:2px solid var(--accent);outline-offset:3px} @media(min-width:1600px){.live-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:1100px){.sidebar{width:190px;padding:25px 14px}.brand{font-size:17px}.brand-icon{font-size:30px}.brand small{font-size:8px}main{margin-left:190px;padding:25px}.metrics>div{padding:18px 14px}.metrics strong{font-size:22px}.storage-grid{grid-template-columns:1fr}.section-head small{display:none}}@media(max-width:760px){.sidebar{position:static;width:auto;padding:18px 16px 8px;border-right:0;border-bottom:1px solid var(--line)}.brand{padding:0 3px 16px}.brand small{font-size:9px}.sidebar nav{display:flex;gap:3px;overflow:auto}.sidebar nav button{padding:10px;font-size:14px;gap:6px}.sidebar nav button span{font-size:11px}.nav-label,.side-note,.sidebar #logout{display:none}main{margin:0;padding:23px 16px}h1{font-size:25px}.header-status{font-size:9px}.metrics{grid-template-columns:1fr 1fr}.metrics>div{border-bottom:1px solid var(--line)}.metrics>div:nth-child(2){border-right:0}.live-grid,.site-grid{grid-template-columns:1fr}.filter-row{flex-wrap:wrap;gap:10px}.filter-row>span{display:none}.filter-row select{max-width:200px}.section-head{align-items:start}.section-head>div{flex-wrap:wrap;justify-content:flex-end;gap:8px}.section-head button{padding:9px 11px;font-size:12px}.section-head h2{font-size:16px}.section-head label{font-size:11px}.section-head select{padding:6px}.discovery{flex-wrap:wrap}.discovery>div{width:100%}.discovery input{flex:1}.recording-list{grid-template-columns:1fr 1fr}.timeline-head{gap:10px;font-size:10px}.form-grid{gap:13px}.form-grid label{font-size:12px}dialog{padding:23px}.form-grid .full{grid-column:1/-1}.playback-controls label{flex:1}.video-empty{font-size:11px}.camera-meta{padding:14px}.seek-row label{width:100%}#auth-dialog{padding:30px}footer{font-size:9px}} + +/* Family register and account forms */ +.sidebar{overflow-y:auto}.sidebar .brand{padding-bottom:18px}.sidebar nav{gap:3px}.sidebar nav button{padding-top:10px;padding-bottom:10px}.sidebar .side-note{padding-bottom:0}.sidebar .side-note p{display:none} +textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;border-radius:7px;padding:10px;resize:vertical;max-width:100%}textarea:focus{outline:2px solid var(--accent)}fieldset{border:1px solid var(--line);border-radius:8px;padding:15px}legend{color:var(--muted);font-size:13px}#user-sites{display:flex;flex-wrap:wrap;gap:16px}.family-toolbar{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.family-toolbar>span{margin-left:auto;font-size:12px;color:var(--muted)}.family-layout{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px;align-items:start}.family-people{display:grid;gap:10px;max-height:720px;overflow:auto}.person-card{text-align:left;display:grid;gap:8px;white-space:normal;background:var(--panel);color:#dae4e6;border:1px solid var(--line);padding:18px}.person-card.selected{border-color:var(--accent);background:#234037}.person-card span{font-size:12px;color:var(--accent)}.person-card small{font-size:11px;color:var(--muted)}#family-detail .section-head{margin-top:0}#family-detail h2{margin:10px 0}.family-prose{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px}.family-link{display:flex;gap:15px;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--line);font-size:13px}.family-link small{display:block;color:var(--muted);margin-top:7px}.family-tree{display:grid;grid-template-columns:1fr 1fr;gap:25px;overflow:auto}.family-tree ul{padding-left:20px;list-style:none;border-left:1px solid #3f6359}.family-tree li{padding:6px 0}.family-tree button{max-width:100%;white-space:normal}.family-tree small{color:var(--muted);font-size:11px}.family-spouses{grid-column:1/-1}#family-error:empty{display:none}.family-people .empty{padding:30px 12px}.family-people .empty h3{font-size:16px}#current-user{font-size:12px;margin:12px 0} +@media(max-width:900px){.family-layout{grid-template-columns:1fr}.family-people{grid-template-columns:repeat(2,minmax(0,1fr));max-height:350px}.family-tree{grid-template-columns:1fr}.family-spouses{grid-column:auto}.family-toolbar label{flex-wrap:wrap}.family-toolbar input{width:100%}} +@media(max-width:760px){.sidebar #logout{display:block;align-self:flex-end}.sidebar #current-user{position:absolute;right:130px;top:15px;font-size:10px}.form-grid{grid-template-columns:1fr}.family-tree ul{padding-left:12px}} + +#family-detail dl{display:grid;grid-template-columns:1fr 1fr;gap:14px 28px;margin:20px 0 26px}#family-detail dl>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-bottom:1px solid var(--line);padding-bottom:10px}#family-detail dt{font-size:12px;color:var(--muted)}#family-detail dd{margin:0;font-size:13px;overflow-wrap:anywhere}@media(max-width:1000px){#family-detail dl{grid-template-columns:1fr}}