Compare commits

..

3 Commits

15 changed files with 667 additions and 65 deletions
+40 -2
View File
@@ -49,7 +49,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml
```
回归测试:`python3 -m unittest -v test_app test_ptz` 和 `node --test test_live_player.js test_kinship.js test_calendar.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
回归测试:`python3 -m unittest -v test_app test_ptz` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.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 使用管理员与个人账号分权。
@@ -78,7 +78,7 @@ mediamtx ./data/mediamtx.yml
- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。
- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。
- 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。
- 支持关系参照人物、搜索、祖先树、后裔树、配偶列表和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。
- 支持关系参照人物、搜索、全家关系图、人物名册和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。
- 并发编辑使用家谱修订号,旧表单提交会被拒绝,避免覆盖他人的更新。导出包含人物和关系,不含密码和设备凭据。当前导出用于保存副本,尚未提供 JSON 导入界面。
### 从 v0.1.10 升级
@@ -93,3 +93,41 @@ mediamtx ./data/mediamtx.yml
- 管理员具有云台权限;个人账号需同时具备空间访问和独立的云台控制权限。新账号默认不允许转动。两个镜头如果指向同一 PTZ 节点,控制的是同一物理云台,不能据镜头数量推断独立电机。
- 人物默认在世;只在“不在世”时显示去世日期,旧人物的“待确认”状态保持原样。出生/去世各使用一个日期输入,选择历法,农历可勾选闰月;数据库统一保存公历日期,避免两份日期不一致。使用浏览器 ICU Chinese calendar,不联网。公历转农历范围 1901–2100,农历录入范围 1901–2099。更早祖先生卒日期可填公历并保留原始记载;不对古代历法作精确推断。
- 站点与标签页图标为“赵”字的甲骨文风格原创设计,并非考古字形摹本。参考字库及生成说明见 [ICON.md](ICON.md)。
## 完整关系图与家族记事(v0.1.16)
### 看全家
进入“家谱”默认打开完整关系图,包含有效的亲子和配偶关系;归档人物默认隐藏,可勾选显示。每位人物只出现一次,不按代数截断。父母在上、子女在下,配偶在不违反亲子方向时并列;跨层配偶和复杂多重关系保留连线及提示。尚未建立关系的人单独展示,不推测亲属身份。
暖金色和“本人”标识只用于当前账号关联的家谱人物,由管理员在“账号与关系 → 关联家谱人物”设置。关系参照和当前选中人物可另外切换,不改变本人身份。管理员未关联人物时会提示,不把其他人误标为本人。支持点击档案、名册、人物搜索、放大缩小、拖动空白处、滚动条、查看全谱、定位本人、全屏和 Esc 退出。浏览器拒绝原生全屏时使用网页内铺满显示。
### 记事的完整流程
1. 在“家族时间线 → 写一则记事”,或人物档案“记一件事”开始记录。
2. 填写标题、发生日期、摘要、正文,并可关联多位人物、附报道来源和 HTTP/HTTPS 链接。发生日期支持公历或农历输入,沿用日期换算范围,统一存储公历。
3. 可保存草稿,编辑完成后发布。发布范围是拥有家谱权限的账号,不是互联网匿名访问。家谱编辑者及管理员可查看和维护草稿、已发布内容、归档记录;只读成员仅能查看未归档的已发布记事。
4. 时间线默认从近到远,按发生年份分组,支持年份、人物、关键词、顺序和发布状态筛选。人物筛选可额外包括其直接父母、配偶和子女;每条记事按关联人物追溯,全家记事在“全家”范围显示。
5. 点击记事阅读详情或跳转原始报道;点击关联人物返回图谱和档案。人物档案可以再进入该人物的时间线,形成双向浏览。关系图默认不展开记事内容。
6. 归档保留数据;在“已归档”打开记录并编辑,取消归档后保存即可恢复。记事与人物、关系共用修订号,并发旧表单拒绝覆盖新版本。正文是纯文本,不执行 HTML 或嵌入第三方页面。
JSON 导出升级为格式 version 2,包含人物、关系和当前账号有权看到的记事。导出用于保存副本,尚无导入界面。外部报道只保存链接和用户填写的内容,不自动抓取网页;链接失效时,已填写的正文仍保存在本机。
### 升级及数据
启动时幂等创建 `family_events` 表,无需修改既有人物、账号、摄像头或录像记录。部署前使用 SQLite backup API 备份数据库。还原备份会回退备份后的数据,旧版本看不到记事表;应保留升级后的数据库备份,不以旧版本界面作为记事恢复工具。源码中不含真实家族记事和人物信息。
### 代理缓存兼容(v0.1.17)
入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。
## 逻辑一致性修复(v0.1.18)
- 记事正文实际支持 12000 字中文、摘要支持换行;该接口的 JSON 请求上限为 128 KiB,其他接口保持 16 KiB,并拒绝无效 Content-Length。
- 编辑已发布记事默认“保存修改”保持发布状态,Enter 保存也不会撤回。撤回为草稿使用独立按钮;首次发布时间在撤回、重发和归档恢复之间保留。
- 遇到家谱并发修订冲突,当前输入保留;复制需要保留的内容后,可明确点击“读取最新版本(覆盖本次输入)”继续编辑。服务器保存成功而页面重新读取失败时,提示已保存,避免误以为需要再次提交。
- 从记事跳到归档人物时自动显示归档人物。当前账号的本人标记与关系参照人物区分;变更本人关联会重新布局。
- 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。
- 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。
- 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。
+1 -1
View File
@@ -1 +1 @@
0.1.15
0.1.18
+16 -7
View File
@@ -493,11 +493,14 @@ class Handler(BaseHTTPRequestHandler):
if self.user['role'] != 'admin':
raise Problem('此操作需要管理员权限', 403)
def body(self):
def body(self, maximum=16384):
if self.headers.get_content_type() != 'application/json':
raise Problem('请求需要 JSON 格式')
try:
n = int(self.headers.get('Content-Length', '0'))
if not 0 < n <= 16384:
except ValueError:
raise Problem('请求长度不正确')
if not 0 < n <= maximum:
raise Problem('请求长度不正确')
try:
data = json.loads(self.rfile.read(n))
@@ -553,14 +556,15 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
self.validate_origin()
path = url.urlsplit(self.path).path
data = self.body()
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
data = self.body(131072 if path == '/api/family/event' else 16384)
self.validate_origin()
if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path not in ('/api/logout', '/api/family/person', '/api/family/link', '/api/ptz'):
if path not in ('/api/logout', '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'):
self.require_admin()
if path == '/api/logout':
with LOCK:
@@ -579,7 +583,7 @@ class Handler(BaseHTTPRequestHandler):
self.answer(result)
elif path == '/api/users':
self.answer(ACCOUNTS.save(data, self.user))
elif path in ('/api/family/person', '/api/family/link'):
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites':
self.answer(save_site(data))
@@ -784,6 +788,9 @@ class Handler(BaseHTTPRequestHandler):
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css',
'/family-events.js': 'family-events.js',
'/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js',
'/zhao-icon.png': 'zhao-icon.png',
'/vendor/hls.min.js': 'vendor/hls.min.js'}
@@ -791,6 +798,8 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('页面不存在', 404)
file = ROOT / 'web' / files[path]
raw = file.read_bytes()
if path == '/':
raw = raw.replace(b'__ASSET_VERSION__', (ROOT / 'VERSION').read_bytes().strip())
self.send_response(200)
self.send_header('Content-Type', (mimetypes.guess_type(file.name)[0] or 'application/octet-stream') + '; charset=utf-8')
self.send_header('Content-Length', str(len(raw)))
@@ -798,7 +807,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'same-origin')
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
self.send_header('Cache-Control', 'no-cache')
self.send_header('Cache-Control', 'no-store' if path == '/' else 'no-cache')
self.end_headers()
self.wfile.write(raw)
except Problem as e:
+72 -4
View File
@@ -2,6 +2,7 @@
import datetime as dt
import json
import secrets
from urllib.parse import urlsplit
class Family:
@@ -13,6 +14,7 @@ class Family:
self.a.DB.executescript('''
CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL);
''')
self.a.DB.commit()
@@ -23,9 +25,12 @@ class Family:
def snapshot(self, user):
self.authorize(user)
with self.a.LOCK:
can_edit = user['role'] == 'admin' or user.get('familyAccess') == 'edit'
return {'revision': self.a.setting('familyRevision', 0), 'people': self.a.objects('people'),
'links': self.a.objects('family_links'),
'canEdit': user['role'] == 'admin' or user.get('familyAccess') == 'edit',
'events': [e for e in self.a.objects('family_events')
if can_edit or (e.get('status') == 'published' and not e.get('archived'))],
'canEdit': can_edit,
'selfId': user.get('personId', '')}
def save(self, kind, data, user):
@@ -35,19 +40,67 @@ class Family:
revision = a.setting('familyRevision', 0)
if type(data.get('revision')) is not int or data['revision'] != revision:
raise a.Problem('家谱已被更新,请重新载入后再编辑', 409)
table = 'people' if kind == 'person' else 'family_links'
if kind not in ('person', 'link', 'event'):
raise a.Problem('家谱记录类型不存在', 404)
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}[kind]
old = a.get_object(table, data.get('id'))
if data.get('id') and not old:
raise a.Problem('家谱记录不存在', 404)
item = self.person(data, old) if kind == 'person' else self.link(data, old)
item = {'person': self.person, 'link': self.link, 'event': self.event}[kind](data, old)
item.update(updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
if kind == 'event':
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
createdBy=old.get('createdBy', user['username']) if old else user['username'])
# Withdrawal and restoration must retain the first publication time.
if old and old.get('publishedAt'):
item['publishedAt'] = old['publishedAt']
elif item['status'] == 'published':
item['publishedAt'] = item['updatedAt']
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
a.DB.commit()
a.audit('更新家谱人物' if kind == 'person' else '更新家谱关系', user['username'])
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
return {'item': item, 'revision': revision + 1}
def event(self, data, old):
a = self.a
item = {'id': old['id'] if old else secrets.token_hex(8)}
for key, maximum, required in [('title', 160, True), ('eventDate', 10, True),
('sourceName', 160, False),
('sourceUrl', 2000, False)]:
item[key] = a.clean_text(data.get(key, ''), maximum, required)
try:
if dt.date.fromisoformat(item['eventDate']).isoformat() != item['eventDate']:
raise ValueError()
except ValueError:
raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD')
for key, maximum, label in [('summary', 400, '摘要'), ('body', 12000, '正文')]:
value = data.get(key, '')
if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
raise a.Problem(f'记事{label}格式不正确,最多 {maximum} 字')
item[key] = value.strip()
item['personIds'] = data.get('personIds', [])
if (not isinstance(item['personIds'], list) or len(item['personIds']) > 200
or any(not isinstance(p, str) or not a.get_object('people', p) for p in item['personIds'])):
raise a.Problem('请选择有效的关联人物,最多 200 位')
item['personIds'] = list(dict.fromkeys(item['personIds']))
item['status'] = data.get('status', old.get('status', 'draft') if old else 'draft')
item['archived'] = data.get('archived', old.get('archived', False) if old else False)
if item['status'] not in ('draft', 'published') or not isinstance(item['archived'], bool):
raise a.Problem('记事状态不正确')
if item['sourceUrl']:
try:
parsed = urlsplit(item['sourceUrl'])
if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password:
raise ValueError()
parsed.port
except ValueError:
raise a.Problem('报道链接请填写完整的 http 或 https 地址,不可包含账号密码')
if item['status'] == 'published' and not (item['body'] or item['summary'] or item['sourceUrl']):
raise a.Problem('发布前请填写记事正文、摘要或报道链接')
return item
def person(self, data, old):
a = self.a
item = {'id': old['id'] if old else secrets.token_hex(8)}
@@ -78,8 +131,22 @@ class Family:
item['archived'] = data.get('archived', False)
if not isinstance(item['archived'], bool):
raise a.Problem('归档状态不正确')
if not old or item['birthDate'] != old.get('birthDate', ''):
self.check_birth_order([e for e in a.objects('family_links')
if item['id'] in (e['fromId'], e['toId'])], item)
return item
def check_birth_order(self, links, proposed_person=None):
people = {p['id']: p for p in self.a.objects('people')}
if proposed_person:
people[proposed_person['id']] = proposed_person
for edge in links:
if not edge.get('active') or edge['kind'] != 'parent' or edge.get('lineage') != 'biological':
continue
parent, child = people.get(edge['fromId']), people.get(edge['toId'])
if parent and child and parent.get('birthDate') and child.get('birthDate') and parent['birthDate'] >= child['birthDate']:
raise self.a.Problem('亲生父母的出生日期必须早于子女,请核对日期或亲子属性')
def link(self, data, old):
a = self.a
left, right = data.get('fromId'), data.get('toId')
@@ -99,6 +166,7 @@ class Family:
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))
self.check_birth_order([item])
links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']]
if active:
for e in links:
+102 -1
View File
@@ -72,6 +72,18 @@ class WebTests(unittest.TestCase):
self.assertEqual(re.findall(r'<option value="(.*?)">(.*?)</option>', select),
[('hd', '主码流'), ('compat', '子码流')])
def test_asset_urls_follow_release_version(self):
code, headers, body = self.request('/', False)
self.assertEqual(code, 200)
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
self.assertEqual(len(assets), 13)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
self.assertEqual(self.request(asset, False)[0], 200)
def test_origin_rejected(self):
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
@@ -134,7 +146,7 @@ class WebTests(unittest.TestCase):
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']:
for path in ['/api/storage','/api/users','/api/family/person','/api/family/event']:
self.assertEqual(request(path,{}),403)
self.assertEqual(request('/api/family'),200)
app.ACCOUNTS.save(dict(member,disabled=True),self.admin)
@@ -232,6 +244,95 @@ class WebTests(unittest.TestCase):
with self.assertRaises(app.Problem):app.FAMILY.person({'name':'Example','deathDate':'2020-01-01'},None)
self.assertEqual(app.FAMILY.person({'name':'Example','lifeStatus':'deceased','deathDate':'2020-01-01'},None)['deathDate'],'2020-01-01')
def test_family_event_draft_publish_archive_and_restore(self):
def save(data):
return app.FAMILY.save('event',dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
p=app.FAMILY.save('person',{'name':'Event person','revision':app.setting('familyRevision',0)},self.admin)['item']
reader={'role':'member','familyAccess':'read','personId':p['id']}
e=save({'title':'First event','eventDate':'2024-02-10','summary':'Summary','body':'Line one\nLine two','personIds':[p['id'],p['id']]})
self.assertEqual(e['status'],'draft');self.assertEqual(e['personIds'],[p['id']])
self.assertNotIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
e=save(dict(e,status='published'))
self.assertIn('publishedAt',e)
self.assertIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
e=save(dict(e,archived=True))
self.assertNotIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
self.assertIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(self.admin)['events']])
e=save(dict(e,archived=False))
self.assertIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
with self.assertRaises(app.Problem):app.FAMILY.save('event',dict(e,revision=0),self.admin)
with self.assertRaises(app.Problem):app.FAMILY.save('event',dict(e,revision=app.setting('familyRevision',0)),reader)
with self.assertRaises(app.Problem):app.FAMILY.snapshot({'role':'member','familyAccess':'none'})
def test_family_event_validation(self):
good={'title':'Event','eventDate':'2020-02-29','body':'Text','status':'published'}
for changes in [{'eventDate':'2023-02-29'},{'title':''},{'sourceUrl':'javascript:alert(1)'},
{'sourceUrl':'https://user:secret@example.com'},{'personIds':['missing']},
{'body':'x'*12001},{'status':'public'},{'archived':'false'},{'personIds':'wrong'},
{'body':'','summary':'','sourceUrl':''}]:
with self.subTest(changes=list(changes)):
with self.assertRaises(app.Problem):app.FAMILY.event(dict(good,**changes),None)
event=app.FAMILY.event(dict(good,sourceUrl='https://example.org/report'),None)
self.assertEqual(event['sourceUrl'],'https://example.org/report')
def test_event_accepts_full_chinese_body_and_multiline_summary(self):
payload={'title':'中文长文','eventDate':'2026-10-03','body':'家'*12000,'summary':'第一段\n第二段',
'status':'published','revision':app.setting('familyRevision',0)}
self.assertGreater(len(json.dumps(payload).encode()),16384)
code,_,body=self.request('/api/family/event',data=payload)
self.assertEqual(code,200)
saved=json.loads(body)['item']
self.assertEqual(saved['body'],payload['body'])
self.assertEqual(saved['summary'],payload['summary'])
for changes in [{'body':'家'*12001},{'summary':'家'*401},{'summary':'bad\x00text'}]:
invalid=dict(payload,**changes,revision=app.setting('familyRevision',0))
self.assertEqual(self.request('/api/family/event',data=invalid)[0],400)
def test_event_keeps_publication_history_and_omitted_status(self):
def save(data):return app.FAMILY.save('event',dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
event=save({'title':'记事','eventDate':'2026-10-03','body':'正文','status':'published'})
published=event['publishedAt'];created=event['createdAt']
edited=dict(event,title='更新标题');edited.pop('status');edited.pop('publishedAt')
event=save(edited)
self.assertEqual(event['status'],'published');self.assertEqual(event['publishedAt'],published)
event=save(dict(event,status='draft'))
self.assertEqual(event['publishedAt'],published)
reader={'role':'member','familyAccess':'read'}
self.assertNotIn(event['id'],[e['id'] for e in app.FAMILY.snapshot(reader)['events']])
event=save(dict(event,status='published'))
self.assertEqual(event['publishedAt'],published);self.assertEqual(event['createdAt'],created)
def test_biological_birth_order_checked_on_link_and_person_changes(self):
def save(kind,data):return app.FAMILY.save(kind,dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
parent=save('person',{'name':'Parent','birthDate':'1980-01-01'})
child=save('person',{'name':'Child','birthDate':'2000-01-01'})
relation=save('link',{'fromId':parent['id'],'toId':child['id'],'kind':'parent','lineage':'biological'})
for person,date in [(parent,'2000-01-01'),(parent,'2001-01-01'),(child,'1979-01-01')]:
revision=app.setting('familyRevision',0)
with self.assertRaises(app.Problem):save('person',dict(person,birthDate=date))
self.assertEqual(app.setting('familyRevision',0),revision)
self.assertEqual(app.get_object('people',person['id'])['birthDate'],person['birthDate'])
save('link',dict(relation,active=False))
save('person',dict(parent,birthDate='2001-01-01'))
with self.assertRaises(app.Problem):save('link',dict(relation,active=True))
save('link',dict(relation,active=True,lineage='adoptive'))
def test_request_body_limit_is_bounded_and_invalid_length_is_400(self):
from email.message import Message
from types import SimpleNamespace
for length in ['invalid','-1','0','131073']:
headers=Message();headers['Content-Type']='application/json';headers['Content-Length']=length
fake=SimpleNamespace(headers=headers,rfile=io.BytesIO(b'{}'))
with self.assertRaises(app.Problem) as caught:app.Handler.body(fake,131072)
self.assertEqual(caught.exception.status,400)
def test_event_endpoint_and_static_modules(self):
self.assertEqual(self.request('/api/family/event',False,{'title':'Denied'})[0],401)
payload={'title':'API event','eventDate':'2026-01-01','body':'Test only','status':'published','revision':app.setting('familyRevision',0)}
self.assertEqual(self.request('/api/family/event',data=payload)[0],200)
for path in ['/family-graph.js','/family-map.js','/family-map.css','/family-events.js']:
self.assertEqual(self.request(path,False)[0],200)
def test_empty_recording_day_is_not_service_failure(self):
error = app.urllib.error.HTTPError('http://localhost/list', 404, 'Not Found', {},
io.BytesIO(b'{"error":"no recording segments found"}'))
+12
View File
@@ -0,0 +1,12 @@
const test=require('node:test'),assert=require('node:assert/strict'),G=require('./web/family-graph.js');
const person=(id,extra={})=>({id,name:id,birthDate:'',...extra});
const edge=(fromId,toId,kind='parent',extra={})=>({id:fromId+toId,fromId,toId,kind,active:true,...extra});
function valid(g,count){assert.equal(g.nodes.length,count);assert.equal(new Set(g.nodes.map(n=>n.id)).size,count);for(const n of g.nodes){assert.ok(Number.isFinite(n.x)&&Number.isFinite(n.y));assert.ok(n.x>=0&&n.y>=0&&n.x+n.width<=g.width&&n.y+n.height<=g.height);}for(let i=0;i<g.nodes.length;i++)for(let j=i+1;j<g.nodes.length;j++){const a=g.nodes[i],b=g.nodes[j];assert.ok(a.x+a.width<=b.x||b.x+b.width<=a.x||a.y+a.height<=b.y||b.y+b.height<=a.y,'nodes overlap');}}
test('both parents, spouses, siblings, children appear once in one complete graph',()=>{const p=['grandfather','grandmother','father','mother','self','sister','partner','son'].map(x=>person(x));const e=[edge('grandfather','grandmother','spouse'),edge('grandfather','father'),edge('grandmother','father'),edge('father','mother','spouse'),edge('father','self'),edge('mother','self'),edge('father','sister'),edge('mother','sister'),edge('self','partner','spouse'),edge('self','son'),edge('partner','son')];const g=G.layout(p,e,{selfId:'self'});valid(g,8);assert.equal(g.paths.length,e.length);const m=new Map(g.nodes.map(n=>[n.id,n]));assert.equal(m.get('father').y,m.get('mother').y);assert.equal(m.get('self').y,m.get('sister').y);assert.ok(m.get('father').y<m.get('self').y);assert.ok(m.get('son').y>m.get('self').y);});
test('archives, inactive edges, missing endpoints are explicit filters',()=>{const p=[person('a'),person('b',{archived:true}),person('c')],e=[edge('a','b'),edge('a','c','parent',{active:false}),edge('missing','a')];const g=G.layout(p,e);valid(g,2);assert.equal(g.paths.length,0);valid(G.layout(p,e,{showArchived:true}),3);});
test('disconnected family branches and isolated people are retained',()=>{const p=['a','b','c','d','e'].map(x=>person(x)),g=G.layout(p,[edge('a','b'),edge('c','d')],{selfId:'c'});valid(g,5);assert.equal(g.components,3);assert.equal(g.nodes[0].id,'c');});
test('cross-generation spouse constraints fall back without changing parent order',()=>{const p=['a','b','c','d'].map(x=>person(x));const g=G.layout(p,[edge('a','b','spouse'),edge('c','d','spouse'),edge('a','c'),edge('d','b')]);valid(g,4);assert.ok(g.warnings.length);const m=new Map(g.nodes.map(n=>[n.id,n]));assert.ok(m.get('c').y>m.get('a').y);assert.ok(m.get('b').y>m.get('d').y);});
test('ancestry cycles from malformed data terminate with warning',()=>{const g=G.layout([person('a'),person('b')],[edge('a','b'),edge('b','a')]);valid(g,2);assert.ok(g.warnings.length);});
test('deep ancestry has no recursive limit and no silent truncation',()=>{const p=Array.from({length:1000},(_,i)=>person(String(i)));const e=p.slice(1).map((p,i)=>edge(String(i),p.id));const g=G.layout(p,e);assert.equal(g.nodes.length,1000);assert.equal(g.paths.length,999);assert.ok(g.nodes[999].y>g.nodes[0].y);});
test('empty and one-person family have valid bounds',()=>{valid(G.layout([],[]),0);valid(G.layout([person('self')],[],{selfId:'self'}),1);});
test('layout never mutates stored people or relationships',()=>{const p=[person('a'),person('b')],e=[edge('a','b')],before=JSON.stringify([p,e]);G.layout(p,e);assert.equal(JSON.stringify([p,e]),before);});
+74
View File
@@ -0,0 +1,74 @@
// Run actual UI functions in isolated contexts; no live accounts or browser state.
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const source=name=>fs.readFileSync('web/'+name,'utf8');
function section(file,start,end){const s=source(file),a=s.indexOf(start),b=s.indexOf(end,a+start.length);assert.ok(a>=0&&b>a);return s.slice(a,b);}
const apiSource=section('app.js','async function api(','function toast(');
const refreshSource=section('app.js','async function refresh(','async function auth(');
function context(extra={}){return vm.createContext({sessionGeneration:0,refreshGeneration:0,authGeneration:0,loggedIn:true,currentUser:{id:'a'},state:{},Error,...extra});}
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};}
test('late successful response cannot restore private data after account switch',async()=>{
const pending=deferred(),c=context({fetch:()=>pending.promise});vm.runInContext(apiSource,c);
const request=c.api('/api/family');c.sessionGeneration++;c.currentUser={id:'b'};
pending.resolve({ok:true,json:async()=>({privatePerson:'previous account'})});
await assert.rejects(request,e=>e.stale===true);
});
test('old unauthorized response cannot log out a new session',async()=>{
const pending=deferred();let clears=0;const c=context({fetch:()=>pending.promise,clearPrivateView:()=>clears++,auth:()=>{}});vm.runInContext(apiSource,c);
const request=c.api('/api/state');c.sessionGeneration++;
pending.resolve({ok:false,status:401,json:async()=>({error:'old session'})});
await assert.rejects(request,e=>e.stale===true);assert.equal(clears,0);
});
test('API errors retain conflict status for explicit reload workflow',async()=>{
const c=context({fetch:async()=>({ok:false,status:409,json:async()=>({error:'家谱已被更新'})})});vm.runInContext(apiSource,c);
await assert.rejects(c.api('/api/family/person',{}),e=>e.status===409);
});
test('late state refresh cannot overwrite newer state',async()=>{
const first=deferred(),second=deferred(),requests=[first,second];let renders=0;
const c=context({api:()=>requests.shift().promise,render:()=>renders++});vm.runInContext(refreshSource,c);
const a=c.refresh(),b=c.refresh();second.resolve({version:'new',user:{id:'a'}});await b;
first.resolve({version:'old',user:{id:'a'}});await a;
assert.equal(c.state.version,'new');assert.equal(renders,1);
});
test('late refresh failure cannot cover a newer successful screen',async()=>{
const first=deferred(),second=deferred(),requests=[first,second];let banners=0;
const c=context({api:()=>requests.shift().promise,render:()=>{},$:()=>{banners++;return {};}});vm.runInContext(refreshSource,c);
const a=c.refresh(),b=c.refresh();second.resolve({user:{id:'a'}});await b;first.reject(Error('old failure'));await a;assert.equal(banners,0);
});
test('another tab changing the shared login clears the previous private view',async()=>{
let clears=0;const c=context({api:async()=>({user:{id:'b'},cameras:[]}),render:()=>{},
clearPrivateView:()=>{clears++;c.loggedIn=false;c.currentUser=null;}});
vm.runInContext(refreshSource,c);await c.refresh();assert.equal(clears,1);assert.equal(c.currentUser.id,'b');assert.equal(c.loggedIn,true);
});
test('expired session clears private view when session endpoint itself returns 200',async()=>{
const fields=new Map();const $=s=>{if(!fields.has(s))fields.set(s,{elements:{username:{}},showModal(){this.open=true;}});return fields.get(s);};
let clears=0;const c=context({api:async()=>({authenticated:false,user:null,setupRequired:false}),$,
clearPrivateView:()=>{clears++;c.currentUser=null;c.loggedIn=false;},applyPermissions:()=>{},refresh:()=>{},toast:()=>{}});
vm.runInContext(section('app.js','async function auth(','function openSite('),c);await c.auth();
assert.equal(clears,1);assert.equal(c.loggedIn,false);assert.equal($('#auth-dialog').open,true);
});
test('archived person reached from an event is revealed before graph location',()=>{
const toggle={checked:false};let rendered=false,located='';
const c=context({family:{people:[{id:'ancestor',archived:true}]},$:()=>toggle,window:{innerWidth:1200},document:{},
renderFamily:()=>{assert.equal(toggle.checked,true);rendered=true;},familyMap:{locate:id=>located=id}});
vm.runInContext(section('family.js','function selectFamilyPerson(','function openPerson('),c);c.selectFamilyPerson('ancestor',true);
assert.ok(rendered);assert.equal(c.familyView,'tree');assert.equal(located,'ancestor');
});
test('event default save preserves published state and exposes explicit withdrawal',()=>{
const controls=new Map(),form={dataset:{},querySelector:s=>{if(!controls.has(s))controls.set(s,{});return controls.get(s);}};
const elements=new Map([['#event-form',form]]);const $=s=>{if(!elements.has(s))elements.set(s,{showModal(){},close(){}});return elements.get(s);};
const c=context({family:{canEdit:true,revision:3,people:[],events:[{id:'published',status:'published',personIds:[]}]},$,fillForm:()=>{},initDateFields:()=>{},esc:String});
vm.runInContext(section('family-events.js','function editFamilyEvent(',"$('#event-form').addEventListener"),c);
c.editFamilyEvent('published');assert.equal(form.querySelector('[data-save-event]').value,'published');assert.equal(form.querySelector('[data-save-event]').textContent,'保存修改');assert.equal(form.querySelector('[data-publish-event]').hidden,true);assert.equal(form.querySelector('[data-withdraw-event]').hidden,false);
c.editFamilyEvent();assert.equal(form.querySelector('[data-save-event]').value,'draft');assert.equal(form.querySelector('[data-withdraw-event]').hidden,true);
});
test('event date validation never clears a separately edited person death date',()=>{
const c=context({$:()=>{throw Error('unexpected person form access');},syncLifeStatus:()=>{throw Error('unexpected life state change');},dateHint:()=>{}});
vm.runInContext(section('date-fields.js','function syncDateFields(','for(const field of $$'),c);
c.syncDateFields(true,{id:'event-form',querySelectorAll:()=>[]});
});
test('older family request errors cannot repopulate logged-out view',async()=>{
const pending=deferred();let messages=0;const c=context({familyGeneration:0,api:()=>pending.promise,$:()=>{messages++;return {};}});
vm.runInContext(section('family.js','async function loadFamily(','function relationText('),c);
const p=c.loadFamily();c.loggedIn=false;c.familyGeneration++;pending.reject(Error('late error'));assert.equal(await p,false);assert.equal(messages,0);
});
+10 -6
View File
@@ -5,7 +5,7 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&
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;
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
const isAdmin = () => currentUser?.role === 'admin';
function applyPermissions(){
const admin=isAdmin();
@@ -14,6 +14,7 @@ function applyPermissions(){
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
}
function clearPrivateView(){
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
@@ -25,9 +26,11 @@ function clearPrivateView(){
}
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) {
const generation=sessionGeneration;
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, 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; clearPrivateView(); auth(); } throw Error(result.error || '操作未完成'); }
if(generation!==sessionGeneration)throw Object.assign(Error('登录会话已变化,请重新操作'),{stale:true});
if (!r.ok) { if (r.status === 401 && loggedIn) { clearPrivateView(); auth(); } throw Object.assign(Error(result.error || '操作未完成'),{status:r.status}); }
return result;
}
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
@@ -39,6 +42,7 @@ function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#l
function showPage(page) {
if(!isAdmin()&&!['live','playback','family'].includes(page))return;
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
activePage = page;
$('.metrics').hidden=['family','users'].includes(page);
$('.filter-row').hidden=['family','users'].includes(page);
@@ -48,7 +52,7 @@ function showPage(page) {
if (page !== 'playback') resetPlayback();
render();
if (page === 'storage') loadAudit();
if (page === 'family') loadFamily();
if (page === 'family') loadFamily(true);
if (page === 'users') loadUsers();
}
function render() {
@@ -114,9 +118,9 @@ function renderStorage() {
if (!$('#storage-form').contains(document.activeElement)) for(const k of ['retentionDays','maxGB','reserveGB']) $('#storage-form').elements[k].value=s[k]??'';
$('#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])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
}
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){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);}}
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 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 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());}
+7 -7
View File
@@ -3,7 +3,7 @@ function dateHint(field,strict=false){
const input=field.querySelector('[data-date-value]'),hint=field.querySelector('[data-date-hint]'),hidden=field.querySelector('input[type=hidden]');
const mode=field.querySelector('[data-calendar]').value,value=input.value.trim(),leap=field.querySelector('[data-date-leap]').checked;
field.querySelector('[data-leap-label]').hidden=mode!=='lunar';input.setCustomValidity('');
if(!value){hidden.value='';hint.textContent='日期不详可留空';return;}
if(!value){hidden.value='';hint.textContent=input.required?'请选择事情发生的日期':'日期不详可留空';return;}
try{
const solar=mode==='solar'?(ZhaoCalendar.solarDate(value),value):ZhaoCalendar.solar(value,leap);
hidden.value=solar;
@@ -16,15 +16,15 @@ function syncLifeStatus(){
field.querySelectorAll('input,select').forEach(e=>e.disabled=!deceased);
if(!deceased)field.querySelector('[data-date-value]').setCustomValidity('');
}
function initDateFields(){
for(const field of $$('[data-date-field]')){
function initDateFields(scope=$('#person-form')){
for(const field of scope.querySelectorAll('[data-date-field]')){
field.querySelector('[data-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false;
field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field);
}syncLifeStatus();
}if(scope.id==='person-form')syncLifeStatus();
}
function syncDateFields(strict=false){
syncLifeStatus();for(const field of $$('[data-date-field]'))if(!field.hidden)dateHint(field,strict);
if($('[data-date-field="deathDate"]').hidden)$('#person-form').elements.deathDate.value='';
function syncDateFields(strict=false,scope=$('#person-form')){
if(scope.id==='person-form')syncLifeStatus();for(const field of scope.querySelectorAll('[data-date-field]'))if(!field.hidden)dateHint(field,strict);
if(scope.id==='person-form'&&$('[data-date-field="deathDate"]').hidden)scope.elements.deathDate.value='';
}
for(const field of $$('[data-date-field]')){
field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field));
+73
View File
@@ -0,0 +1,73 @@
'use strict';
function resetFamilyEvents(purge=false){
for(const id of ['family-event-person','family-event-year','family-event-search','event-person-search'])$('#'+id).value='';
if(purge){$('#family-event-person').replaceChildren();$('#family-event-year').replaceChildren();}
$('#family-event-relatives').checked=false;$('#family-event-state').value='published';$('#family-event-order').value='desc';
for(const id of ['family-timeline','event-detail-content','event-person-choices','family-event-count'])$('#'+id).replaceChildren();
}
function eventStatus(e){return e.archived?'已归档':e.status==='draft'?'草稿':'已发布';}
function eventPeople(e){return e.personIds.map(id=>personName(id)).join('、')||'全家记事';}
function filteredFamilyEvents(){
const person=$('#family-event-person').value,year=$('#family-event-year').value,term=$('#family-event-search').value.trim().toLowerCase();
const status=family.canEdit?$('#family-event-state').value:'published',ids=new Set(person?[person]:[]);
if(person&&$('#family-event-relatives').checked)for(const e of family.links.filter(e=>e.active)){if(e.fromId===person)ids.add(e.toId);if(e.toId===person)ids.add(e.fromId);}
return (family.events||[]).filter(e=>(status==='all'||(status==='archived'?e.archived:!e.archived&&e.status===status))&&(!year||e.eventDate.startsWith(year+'-'))&&(!person||e.personIds.some(id=>ids.has(id)))&&[e.title,e.summary,e.body,e.sourceName].join(' ').toLowerCase().includes(term)).sort((a,b)=>($('#family-event-order').value==='asc'?1:-1)*a.eventDate.localeCompare(b.eventDate)||a.id.localeCompare(b.id));
}
function renderFamilyEvents(){
setOptions($('#family-event-person'),'<option value="">全家</option>'+peopleOptions(false));
const years=[...new Set((family.events||[]).map(e=>e.eventDate.slice(0,4)))].sort().reverse();
setOptions($('#family-event-year'),'<option value="">所有年份</option>'+years.map(y=>`<option value="${esc(y)}">${esc(y)} 年</option>`).join(''));
$('#family-new-event').hidden=!family.canEdit;$('#family-event-state-label').hidden=!family.canEdit;
$('#family-event-relatives').disabled=!$('#family-event-person').value;
const events=filteredFamilyEvents();$('#family-event-count').textContent=`${events.length} 条记事`+($('#family-event-person').value?' · '+personName($('#family-event-person').value)+($('#family-event-relatives').checked?'及相关亲人':''):' · 全家');
let year='';
$('#family-timeline').innerHTML=events.map(e=>{
const next=e.eventDate.slice(0,4),heading=next!==year?`<h3 class="timeline-year">${esc(next)}<span>年</span></h3>`:'';year=next;
return `${heading}<button class="timeline-event ${e.status==='draft'?'draft':''}" data-view-event="${e.id}"><time datetime="${esc(e.eventDate)}">${esc(e.eventDate.slice(5).replace('-','月'))}日</time><span class="timeline-event-body"><span class="timeline-event-status">${esc(eventStatus(e))}</span><strong>${esc(e.title)}</strong><span class="timeline-summary">${esc(e.summary||e.body.slice(0,130)||'查看原始报道')}</span><small>${esc(eventPeople(e))}</small><span class="timeline-read">查看详情${e.sourceUrl?' · 附报道':''} →</span></span></button>`;
}).join('')||empty('这里还没有记事','可调整筛选条件,或写下第一件值得记住的事。');
}
function showPersonEvents(id){familyView='timeline';renderFamily();$('#family-event-person').value=id;$('#family-event-year').value='';$('#family-event-search').value='';$('#family-event-relatives').checked=false;$('#family-event-state').value='published';renderFamilyEvents();$('#family-events').scrollIntoView({behavior:'smooth',block:'start'});}
function personEventSummary(id){
const events=(family.events||[]).filter(e=>e.personIds.includes(id)&&!e.archived&&e.status==='published').sort((a,b)=>b.eventDate.localeCompare(a.eventDate));
return `<section class="person-events"><div class="section-head"><h3>相关重大记事 <small>${events.length}</small></h3>${family.canEdit?`<button class="quiet" data-new-person-event="${id}">+ 记一件事</button>`:''}</div>${(familyView==='timeline'?events.slice(0,3):[]).map(e=>`<button class="person-event-link" data-view-event="${e.id}"><time>${esc(e.eventDate)}</time><span>${esc(e.title)}</span></button>`).join('')||(familyView==='timeline'?'<p class="description">尚无已发布的相关记事。</p>':'')}<button class="quiet" data-person-events="${id}">在时间线追溯 →</button></section>`;
}
function openFamilyEvent(id){
const e=(family.events||[]).find(e=>e.id===id);if(!e)return;
// All content is plain text. External reports are links, never embedded HTML.
const safeSource=/^https?:\/\//i.test(e.sourceUrl||'')?e.sourceUrl:'';
$('#event-detail-content').innerHTML=`<div class="event-detail-meta"><time>${esc(ZhaoCalendar.describe(e.eventDate))}</time><span class="badge">${esc(eventStatus(e))}</span></div><h2 id="event-detail-title">${esc(e.title)}</h2>${e.summary?`<p class="event-lead">${esc(e.summary)}</p>`:''}<div class="event-associated"><span class="description">关联人物</span>${e.personIds.length?e.personIds.map(id=>`<button class="quiet" data-event-person="${esc(id)}">${esc(personName(id))} ↗</button>`).join(''):'<span>全家记事</span>'}</div><div class="family-prose event-article">${esc(e.body||'此条记事暂未填写详细正文。')}</div>${safeSource?`<a class="button secondary event-source" href="${esc(safeSource)}" target="_blank" rel="noopener noreferrer">阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗</a>`:e.sourceName?`<p class="description">资料来源:${esc(e.sourceName)}</p>`:''}<p class="description event-updated">最近整理:${esc(new Date(e.updatedAt).toLocaleString())}</p>${family.canEdit?`<button data-edit-event="${e.id}">编辑这条记事</button>`:''}`;
$('#event-detail-dialog').showModal();
}
function editFamilyEvent(id,personId=''){
if(!family.canEdit)return;
const e=(family.events||[]).find(e=>e.id===id),f=$('#event-form');
fillForm(f,{...(e||{}),revision:family.revision});
$('#event-editor-title').textContent=e?'编辑家族记事':'写一则家族记事';
const selected=new Set(e?.personIds||(personId?[personId]:[]));
$('#event-person-choices').innerHTML=family.people.map(p=>`<label class="check"><input type="checkbox" name="personIds" value="${esc(p.id)}" ${selected.has(p.id)?'checked':''}>${esc(p.name)}${p.archived?'(已归档)':''}</label>`).join('')||'<p class="description">家谱尚无人名,此条记事将记为全家记事。</p>';
$('#event-person-search').value='';f.dataset.status=e?.status||'draft';
f.querySelector('[data-save-event]').value=f.dataset.status;f.querySelector('[data-save-event]').textContent=e?'保存修改':'保存草稿';
f.querySelector('[data-publish-event]').hidden=f.dataset.status==='published';f.querySelector('[data-withdraw-event]').hidden=f.dataset.status!=='published';
initDateFields(f);if($('#event-detail-dialog').open)$('#event-detail-dialog').close();$('#event-dialog').showModal();
}
$('#event-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,buttons=[...f.querySelectorAll('[type=submit]')];buttons.forEach(b=>b.disabled=true);
try{syncDateFields(true,f);const data=formValues(f);data.revision=Number(data.revision);data.personIds=[...f.querySelectorAll('[name=personIds]:checked')].map(el=>el.value);data.archived=f.elements.archived.checked;data.status=e.submitter?.value||f.dataset.status||'draft';
const result=await api('/api/family/event',data);$('#event-dialog').close();if(!await loadFamily()){if(loggedIn)toast('记事已保存,读取最新资料失败,请重新载入');return;}familyView='timeline';renderFamily();
$('#family-event-person').value='';$('#family-event-year').value='';$('#family-event-search').value='';$('#family-event-state').value=data.archived?'archived':data.status;renderFamilyEvents();openFamilyEvent(result.item.id);toast(data.archived?'记事已归档,可在“已归档”中恢复':data.status==='published'?'记事已发布到家族时间线':'草稿已保存');
}catch(err){familyFormError(f,err);}finally{buttons.forEach(b=>b.disabled=false);}
});
$('#family-view-timeline').onclick=()=>{familyView='timeline';renderFamily();};
$('#family-new-event').onclick=()=>editFamilyEvent();
for(const id of ['family-event-person','family-event-year','family-event-order','family-event-relatives','family-event-state'])$('#'+id).onchange=renderFamilyEvents;
$('#family-event-search').oninput=renderFamilyEvents;
$('#event-person-search').oninput=()=>{const term=$('#event-person-search').value.trim().toLowerCase();$('#event-person-choices').querySelectorAll('label').forEach(l=>l.hidden=!l.textContent.toLowerCase().includes(term));};
$('#family-event-clear').onclick=()=>{resetFamilyEvents();renderFamilyEvents();};
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.viewEvent)openFamilyEvent(b.dataset.viewEvent);
if(b.dataset.editEvent)editFamilyEvent(b.dataset.editEvent);
if(b.dataset.newPersonEvent)editFamilyEvent('',b.dataset.newPersonEvent);
if(b.dataset.personEvents)showPersonEvents(b.dataset.personEvents);
if(b.dataset.eventPerson){$('#event-detail-dialog').close();selectFamilyPerson(b.dataset.eventPerson,true);}
});
+97
View File
@@ -0,0 +1,97 @@
'use strict';
(function(root) {
const W=184,H=104,GAP=34,ROW=166,PAD=36;
// People appear once. Spouse groups share a row when this preserves the parent DAG.
function layout(inputPeople,inputLinks,{selfId='',showArchived=false}={}) {
const people=inputPeople.filter(p=>showArchived||!p.archived),byId=new Map(people.map(p=>[p.id,p]));
const links=inputLinks.filter(e=>e.active&&byId.has(e.fromId)&&byId.has(e.toId)&&e.fromId!==e.toId);
const adjacency=new Map(people.map(p=>[p.id,[]]));
links.forEach(e=>{adjacency.get(e.fromId).push(e.toId);adjacency.get(e.toId).push(e.fromId);});
const seen=new Set(),components=[];
for(const p of people) {
if(seen.has(p.id))continue;
const ids=[p.id];seen.add(p.id);
for(let i=0;i<ids.length;i++)for(const next of adjacency.get(ids[i]))if(!seen.has(next)){seen.add(next);ids.push(next);}
components.push(ids);
}
const order=(a,b)=>{
const x=byId.get(a),y=byId.get(b);
return (x.birthDate||'9999').localeCompare(y.birthDate||'9999')||x.name.localeCompare(y.name,'zh-CN')||a.localeCompare(b);
};
components.sort((a,b)=>Number(b.includes(selfId))-Number(a.includes(selfId))||b.length-a.length||order(a[0],b[0]));
const nodes=[],sections=[],warnings=[];let offset=PAD,maxWidth=400;
const connected=components.filter(c=>c.length>1),isolated=components.filter(c=>c.length===1).map(c=>c[0]);
for(const [componentIndex,ids] of connected.entries()) {
const idSet=new Set(ids),edges=links.filter(e=>idSet.has(e.fromId));
function ranked(alignSpouses) {
const roots=new Map(ids.map(id=>[id,id]));
function find(id){let r=id;while(roots.get(r)!==r)r=roots.get(r);while(roots.get(id)!==id){const next=roots.get(id);roots.set(id,r);id=next;}return r;}
if(alignSpouses)for(const e of edges.filter(e=>e.kind==='spouse'))roots.set(find(e.toId),find(e.fromId));
const units=new Map();for(const id of ids){const key=find(id);if(!units.has(key))units.set(key,{id:key,ids:[],rank:0,out:new Set(),incoming:new Set()});units.get(key).ids.push(id);}
for(const e of edges.filter(e=>e.kind==='parent')) {
const a=units.get(find(e.fromId)),b=units.get(find(e.toId));if(a===b)return null;
a.out.add(b.id);b.incoming.add(a.id);
}
const degree=new Map([...units.values()].map(u=>[u.id,u.incoming.size]));
const queue=[...units.values()].filter(u=>!degree.get(u.id));
for(let i=0;i<queue.length;i++)for(const id of queue[i].out){const u=units.get(id);u.rank=Math.max(u.rank,queue[i].rank+1);degree.set(id,degree.get(id)-1);if(!degree.get(id))queue.push(u);}
return queue.length===units.size?units:null;
}
let units=ranked(true);
if(!units){units=ranked(false);warnings.push('部分配偶跨层排列,请以亲子连线确认关系。');}
if(!units) {
// The API rejects ancestry cycles; imported/malformed input must still terminate.
units=new Map(ids.map(id=>[id,{id,ids:[id],rank:0,out:new Set(),incoming:new Set()}]));
warnings.push('检测到循环关系,请核对人物关系。');
}
const rows=[];
for(const u of units.values()){u.ids.sort(order);u.width=u.ids.length*W+(u.ids.length-1)*GAP;(rows[u.rank]??=[]).push(u);}
rows.forEach(row=>row.sort((a,b)=>order(a.ids[0],b.ids[0])));
// Barycentric passes keep sibling branches together without changing generations.
const positions=new Map();
function measure(){for(const row of rows){let x=0;for(const u of row){positions.set(u.id,x+u.width/2);x+=u.width+GAP*2;}}}
for(let pass=0;pass<4;pass++) {
measure();const indices=rows.map((_,i)=>i);if(pass%2)indices.reverse();
for(const i of indices)rows[i].sort((a,b)=>{
const center=u=>{const ns=[...(pass%2?u.out:u.incoming)];return ns.length?ns.reduce((n,id)=>n+positions.get(id),0)/ns.length:positions.get(u.id);};
return center(a)-center(b)||order(a.ids[0],b.ids[0]);
});
}
const widths=rows.map(row=>row.reduce((n,u)=>n+u.width,0)+Math.max(0,row.length-1)*GAP*2);
const width=Math.max(...widths)+PAD*2+70,height=rows.length*ROW+20;
sections.push({x:PAD,y:offset,width:width-PAD*2,height,label:`家族分支 ${componentIndex+1} · ${ids.length} 位人物`});
rows.forEach((row,rank)=>{
let x=PAD+(width-PAD*2-70-widths[rank])/2;
for(const u of row){for(const id of u.ids){nodes.push({id,x,y:offset+42+rank*ROW,width:W,height:H,rank,component:componentIndex});x+=W+GAP;}x+=GAP;}
});
for(const node of nodes.filter(n=>n.component===componentIndex))node.corridor=width-PAD-46;
offset+=height+48;maxWidth=Math.max(maxWidth,width);
}
if(isolated.length) {
isolated.sort((a,b)=>Number(b===selfId)-Number(a===selfId)||order(a,b));
const beside=connected.length===1&&isolated.length<=4;
const columns=beside?1:Math.min(5,isolated.length),width=columns*(W+GAP)-GAP+PAD*2;
const x=beside?maxWidth:PAD,y=beside?PAD:offset,height=Math.ceil(isolated.length/columns)*150+42;
sections.push({x,y,width:width-PAD*2,height,label:people.length===1?'从自己开始,连接每一代亲人':'待连接的人物'});
isolated.forEach((id,i)=>nodes.push({id,x:x+i%columns*(W+GAP),y:y+42+Math.floor(i/columns)*150,width:W,height:H,rank:0,component:-1}));
offset=Math.max(offset,y+height);maxWidth=beside?maxWidth+width:Math.max(maxWidth,width);
}
const located=new Map(nodes.map(n=>[n.id,n]));
const paths=links.map((e,i)=>{
const a=located.get(e.fromId),b=located.get(e.toId);let d;
if(e.kind==='parent') {
const sx=a.x+W/2,sy=a.y+H,tx=b.x+W/2,ty=b.y;
if(b.rank===a.rank+1){const mid=(sy+ty)/2+(i%3-1)*9;d=`M${sx},${sy} V${mid} H${tx} V${ty}`;}
else {const x=(a.corridor||maxWidth-PAD)+(i%5)*8;d=`M${sx},${sy} V${sy+18} H${x} V${ty-18} H${tx} V${ty}`;}
}else{
const [left,right]=a.x<=b.x?[a,b]:[b,a];
if(a.y===b.y&&right.x-left.x<=W+GAP+1)d=`M${left.x+W},${left.y+H/2} H${right.x}`;
else if(a.y===b.y){const top=a.y-16-(i%3)*8;d=`M${a.x+W/2},${a.y} V${top} H${b.x+W/2} V${b.y}`;}
else {const x=(a.corridor||maxWidth-PAD)+(i%5)*8;d=`M${a.x+W},${a.y+H/2} H${x} V${b.y+H/2} H${b.x+W}`;}
}
return {...e,d};
});
return {nodes,paths,sections,width:maxWidth,height:Math.max(220,offset+PAD),warnings:[...new Set(warnings)],components:components.length};
}
const api={layout};if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.ZhaoFamilyGraph=api;
})(typeof window!=='undefined'?window:globalThis);
File diff suppressed because one or more lines are too long
+73
View File
@@ -0,0 +1,73 @@
'use strict';
class FamilyMap {
constructor(viewport,onSelect) {
this.viewport=viewport;this.onSelect=onSelect;this.scale=1;this.autoFit=true;this.key='';this.data=null;
viewport.addEventListener('click',e=>{const b=e.target.closest('[data-map-person]');if(b)this.onSelect(b.dataset.mapPerson);});
viewport.addEventListener('pointerdown',e=>{
if(e.pointerType!=='mouse'||e.button!==0||e.target.closest('button'))return;
this.drag={x:e.clientX,y:e.clientY,left:viewport.scrollLeft,top:viewport.scrollTop};viewport.setPointerCapture(e.pointerId);viewport.classList.add('panning');e.preventDefault();
});
viewport.addEventListener('pointermove',e=>{if(!this.drag)return;viewport.scrollLeft=this.drag.left+this.drag.x-e.clientX;viewport.scrollTop=this.drag.top+this.drag.y-e.clientY;});
const release=()=>{this.drag=null;viewport.classList.remove('panning');};
viewport.addEventListener('pointerup',release);viewport.addEventListener('pointercancel',release);viewport.addEventListener('lostpointercapture',release);
this.resize=new ResizeObserver(()=>{if(this.autoFit)this.fit();else this.applyScale();});this.resize.observe(viewport);
}
reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
render(data) {
this.data=data;
const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
if(key!==this.key) {
this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data);
const g=this.graph;
this.viewport.innerHTML=g.nodes.length?`<div class="family-map-space"><div class="family-map-surface"><svg class="family-map-lines" width="${g.width}" height="${g.height}" aria-hidden="true"><defs><marker id="family-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L8 4 L0 8 Z" fill="#679d93"/></marker></defs>${g.paths.map(e=>`<path class="family-edge ${e.kind==='spouse'?'spouse':e.lineage==='adoptive'||e.lineage==='step'?'nonbiological':'parent'}" data-from="${esc(e.fromId)}" data-to="${esc(e.toId)}" d="${e.d}" ${e.kind==='parent'?'marker-end="url(#family-arrow)"':''}><title>${esc(data.people.find(p=>p.id===e.fromId)?.name)}${e.kind==='parent'?' → 子女:':' ↔ 配偶:'}${esc(data.people.find(p=>p.id===e.toId)?.name)}${e.lineage==='adoptive'?'(收养)':e.lineage==='step'?'(继亲)':''}</title></path>`).join('')}</svg>${g.sections.map(s=>`<div class="family-branch-label">${esc(s.label)}</div>`).join('')}${g.nodes.map(n=>`<button type="button" class="family-node" data-map-person="${esc(n.id)}"></button>`).join('')}</div></div>`:empty('家谱从一个人开始','先录入本人,再连接父母、配偶与子女。');
const surface=this.viewport.querySelector('.family-map-surface');
if(surface){surface.style.width=g.width+'px';surface.style.height=g.height+'px';
surface.querySelectorAll('.family-branch-label').forEach((b,i)=>{b.style.left=g.sections[i].x+'px';b.style.top=g.sections[i].y+'px';});
surface.querySelectorAll('[data-map-person]').forEach((b,i)=>{const n=g.nodes[i];Object.assign(b.style,{left:n.x+'px',top:n.y+'px',width:n.width+'px',height:n.height+'px'});});}
this.autoFit=true;requestAnimationFrame(()=>this.fit());
}
const byId=new Map(data.people.map(p=>[p.id,p]));
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId;
const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term);
const relation=p.id===data.reference&&!self?'参照人物':ZhaoKinship.describe(data.people,data.links,data.reference,p.id);
b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived);
b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
b.title=p.name+' · '+relation+(p.archived?' · 已归档':'');
b.innerHTML=`<span class="family-node-top"><span class="family-node-avatar" aria-hidden="true">${esc(Array.from(p.name)[0]||'人')}</span><span class="family-node-flags">${self?'<span class="self-badge">本人</span>':''}${p.archived?'<small>归档</small>':''}${p.lifeStatus==='deceased'?'<small>已故</small>':''}</span></span><strong>${esc(p.name)}</strong><span class="family-node-relation">${esc(relation)}</span><small>${esc(p.birthDate?p.birthDate.slice(0,4)+' 年生':'生年待考')}</small>`;
});
this.viewport.querySelectorAll('.family-edge').forEach(e=>e.classList.toggle('focused',e.dataset.from===data.focusId||e.dataset.to===data.focusId));
const messages=[...this.graph.warnings];
if(!data.selfId)messages.unshift('当前账号尚未关联家谱人物,可在“账号与关系”中设置。');
else if(!this.graph.nodes.some(n=>n.id===data.selfId))messages.unshift('本人已归档,勾选“显示归档人物”后可以定位。');
if(data.people.filter(p=>!p.archived).length===1)messages.push('目前仅录入一位人物,连接亲属后会自动展开家谱。');
$('#family-map-note').textContent=messages.join(' ');
$('#family-locate-self').disabled=!this.graph.nodes.some(n=>n.id===data.selfId);
}
applyScale() {
const v=this.viewport,g=this.graph,space=v.querySelector('.family-map-space'),surface=v.querySelector('.family-map-surface');
if(!g||!space||!v.clientWidth)return;
const w=Math.max(v.clientWidth,g.width*this.scale+32),h=Math.max(v.clientHeight,g.height*this.scale+32);
space.style.width=w+'px';space.style.height=h+'px';
this.left=(w-g.width*this.scale)/2;this.top=(h-g.height*this.scale)/2;
surface.style.transform=`translate(${this.left}px,${this.top}px) scale(${this.scale})`;
$('#family-zoom-value').textContent=Math.round(this.scale*100)+'%';
}
fit() {
const v=this.viewport,g=this.graph;if(!g||!v.clientWidth||!v.clientHeight)return;
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(v.clientWidth-40)/g.width,(v.clientHeight-40)/g.height));
this.applyScale();v.scrollTo(0,0);
}
zoom(factor) {
if(!this.graph)return;
const v=this.viewport,x=(v.scrollLeft+v.clientWidth/2-(this.left||0))/this.scale,y=(v.scrollTop+v.clientHeight/2-(this.top||0))/this.scale;
this.autoFit=false;this.scale=Math.max(.001,Math.min(2,this.scale*factor));this.applyScale();
v.scrollTo(x*this.scale+this.left-v.clientWidth/2,y*this.scale+this.top-v.clientHeight/2);
}
locate(id) {
const n=this.graph?.nodes.find(n=>n.id===id);if(!n)return;
this.autoFit=false;this.scale=Math.max(this.scale,.9);this.applyScale();
this.viewport.scrollTo((n.x+n.width/2)*this.scale+this.left-this.viewport.clientWidth/2,(n.y+n.height/2)*this.scale+this.top-this.viewport.clientHeight/2);
const b=[...this.viewport.querySelectorAll('[data-map-person]')].find(b=>b.dataset.mapPerson===id);b?.focus({preventScroll:true});
}
}
+60 -30
View File
@@ -1,58 +1,67 @@
'use strict';
pageInfo.family=['家谱','记下亲人的故事,让世代之间有迹可循。'];
pageInfo.users=['账号与关系','每个人有自己的账号,权限与亲属关系分别设置。'];
let family={people:[],links:[],revision:0,canEdit:false}, users=[], focusPerson='',familyGeneration=0;
let family={people:[],links:[],events:[],revision:0,canEdit:false}, users=[], focusPerson='',familyGeneration=0,familyView='tree';
const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).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 clearFamilyState(){closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).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(){
function familyFormError(form,error){
if(error.stale)return;
const box=form.querySelector('.form-error');box.textContent=error.message;
if(error.status!==409||!error.message.startsWith('家谱已被更新'))return;
box.append(document.createTextNode('。本次输入仍保留,请先复制需要保留的内容,再读取最新版本。'));
const button=document.createElement('button');button.type='button';button.className='quiet';button.textContent='读取最新版本(覆盖本次输入)';
button.onclick=async()=>{button.disabled=true;try{if(await loadFamily()){const id=form.elements.id.value;form.closest('dialog').close();if(form.id==='person-form')openPerson(id);else if(form.id==='relation-form')openRelation(id);else editFamilyEvent(id);}}finally{button.disabled=false;}};
box.append(button);
}
async function loadFamily(resetView=false){
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||'';
if(resetView===true||!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;}
$('#family-error').textContent='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit();return true;
}catch(e){if(!e.stale&&loggedIn&&generation===familyGeneration)$('#family-error').textContent=e.message;return false;}
}
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+' 条关系';
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':'');
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
$('#new-person').hidden=!family.canEdit;
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''}" data-person="${p.id}"><strong>${esc(p.name)}</strong><span>${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty('从一个人物开始','可先录入自己,再逐步补齐父母、祖先和后辈。');
const p=family.people.find(p=>p.id===focusPerson);
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">选择或录入一位人物,查看关系与家谱。</p>';$('#family-tree').replaceChildren();return;}
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。');
renderFamilyTree();
const p=family.people.find(p=>p.id===focusPerson);
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
}
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 '<li>更多分支请切换人物查看</li>';
const p=people.get(id);if(!p)return '';
const node=`<button class="quiet" data-person="${id}">${esc(p.name)}${p.archived?' · 已归档':''}</button>`;
if(path.has(id))return `<li>${node}(重复分支)</li>`;
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 `<li>${node}${d>0&&next.length?'<ul>'+next.map(child=>branch(child,d-1,direction,seen)).join('')+'</ul>':next.length?' <small>还有 '+next.length+' 位,点击此人物继续</small>':''}</li>`;
}
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=`<div><h3>上溯祖先</h3><ul>${branch(root.id,depth,'up',new Set())}</ul></div><div><h3>下续后裔</h3><ul>${branch(root.id,depth,'down',new Set())}</ul></div><div class="family-spouses"><h3>配偶</h3>${spouseIds.map(id=>`<button class="quiet" data-person="${id}">${esc(personName(id))}</button>`).join(' ')||'<p class="description">尚未记录</p>'}</div>`;
$('#family-events').hidden=familyView!=='timeline';$('#family-map-footer').hidden=familyView==='timeline';$('.family-toolbar').hidden=familyView==='timeline';
$('#family-view-title').textContent=({tree:'全家关系图',list:'人物名册',timeline:'家族时间线'})[familyView];
$('#family-view-timeline').setAttribute('aria-pressed',String(familyView==='timeline'));
if(typeof renderFamilyEvents==='function')renderFamilyEvents();
$('#family-tree').hidden=familyView!=='tree';$('#family-people').hidden=familyView!=='list';$('#family-map-controls').hidden=familyView!=='tree';
$('#family-view-tree').setAttribute('aria-pressed',String(familyView==='tree'));$('#family-view-list').setAttribute('aria-pressed',String(familyView==='list'));
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
}
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();}
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');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'){syncDateFields(true);Object.assign(d,formValues(f));d.revision=Number(d.revision);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;}
const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();const loaded=await loadFamily();if(loggedIn)toast(loaded?'家谱已保存':'家谱已保存,读取最新资料失败,请重新载入');
}catch(err){familyFormError(f,err);}finally{b.disabled=false;}
});
}
async function loadUsers(){
@@ -69,6 +78,27 @@ function openUser(id){
}
$('#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;}});
$('#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);});
$('#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-zoom-in').onclick=()=>familyMap.zoom(1.25);$('#family-zoom-out').onclick=()=>familyMap.zoom(.8);$('#family-fit').onclick=()=>familyMap.fit();
$('#family-locate-self').onclick=()=>selectFamilyPerson(family.selfId,true);
$('#family-find').onclick=()=>{const term=$('#family-search').value.trim().toLowerCase(),matches=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));if(!matches.length)return;const index=matches.findIndex(p=>p.id===focusPerson);selectFamilyPerson(matches[(index+1)%matches.length].id,true);};
let familyBodyOverflow='';
function syncFamilyFullscreen(){
const expanded=document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded');
$('#family-fullscreen').textContent=expanded?'退出全屏 · Esc':'全屏家谱';familyMap.fit();
}
function closeFamilyFullscreen(){
if($('#family-workspace').classList.contains('family-expanded')){$('#family-workspace').classList.remove('family-expanded');document.body.style.overflow=familyBodyOverflow;}
if(document.fullscreenElement===$('#family-workspace'))document.exitFullscreen().catch(()=>{});
syncFamilyFullscreen();
}
$('#family-fullscreen').onclick=async()=>{
if(document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded')){closeFamilyFullscreen();return;}
try{await $('#family-workspace').requestFullscreen();}catch{familyBodyOverflow=document.body.style.overflow;document.body.style.overflow='hidden';$('#family-workspace').classList.add('family-expanded');}
syncFamilyFullscreen();
};
document.addEventListener('fullscreenchange',syncFamilyFullscreen);
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!document.querySelector('dialog[open]'))closeFamilyFullscreen();});
$('#family-export').onclick=async()=>{try{const data=await api('/api/family');const blob=new Blob([JSON.stringify({format:'zhaofu-family',version:2,exportedAt:new Date().toISOString(),people:data.people,links:data.links,events:data.events||[]},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)selectFamilyPerson(b.dataset.person);if(b.id==='family-show-person')selectFamilyPerson(focusPerson,true);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);});
+21 -6
View File
@@ -2,8 +2,8 @@
<html lang="zh-CN">
<head>
<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="#10191d"><link rel="stylesheet" href="/style.css">
<script defer src="/vendor/hls.min.js"></script><script defer src="/live-player.js"></script><script defer src="/kinship.js"></script><script defer src="/calendar.js"></script><script defer src="/app.js"></script><script defer src="/monitor.js"></script><script defer src="/date-fields.js"></script><script defer src="/family.js"></script>
<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="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.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>
</head>
<body>
<aside class="sidebar">
@@ -41,11 +41,24 @@
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</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>
<div class="section-head"><h2>家族的来处与延续 <small>FAMILY REGISTER</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">一个账号可以录入全家人物。先录人物,再连接父母、子女与配偶;祖先不需要登录账号。日期可选公历或农历,系统自动换算;不确定的信息可以留空。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<div id="family-error" class="form-error" role="status"></div>
<div class="family-layout"><div id="family-people" class="family-people"></div><div id="family-detail" class="panel"></div></div>
<div class="panel"><div class="section-head"><h3>以选中人物为中心的家谱</h3><label>展开代数<select id="family-depth"><option value="2">2 代</option><option value="4" selected>4 代</option><option value="8">8 代</option></select></label></div><p class="description">点击树中的姓名查看人物。配偶单独列出;同一个祖先可出现在不同分支。复杂旁系关系以关系路径表示,长幼按已填写的出生日期判断。</p><div id="family-tree" class="family-tree"></div></div>
<div id="family-workspace" class="family-workspace">
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
<div class="family-map-heading"><div><span class="eyebrow">FAMILY CONNECTIONS</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
<div id="family-people" class="family-people family-register" hidden></div>
<section id="family-events" hidden aria-label="家族重大记事">
<div class="family-event-intro"><div><h3>把值得记住的事,留给后来的人</h3><p class="description">按事情发生的日期排列,可关联多位亲人,并附上详细记载或报道链接。</p></div><button id="family-new-event">+ 写一则记事</button></div>
<div class="family-event-filters"><label>关联人物<select id="family-event-person"><option value="">全家</option></select></label><label>年份<select id="family-event-year"><option value="">所有年份</option></select></label><label>排列<select id="family-event-order"><option value="desc">从近到远</option><option value="asc">从远到近</option></select></label><label>搜索记事<input id="family-event-search" type="search" placeholder="标题、摘要或正文"></label><label class="check"><input id="family-event-relatives" type="checkbox">包括此人的父母、配偶和子女</label><label id="family-event-state-label">状态<select id="family-event-state"><option value="published">已发布</option><option value="draft">草稿箱</option><option value="archived">已归档</option><option value="all">全部</option></select></label><button id="family-event-clear" class="quiet">清除筛选</button></div>
<p id="family-event-count" class="description" role="status"></p><div id="family-timeline" class="family-timeline"></div>
</section>
<div id="family-map-footer" class="family-map-footer"><div class="family-map-legend"><span><i class="legend-self"></i>本人</span><span><i class="legend-selected"></i>正在查看</span><span><i class="legend-parent"></i>父母 → 子女</span><span><i class="legend-spouse"></i>配偶</span><span><i class="legend-adoptive"></i>收养 / 继亲</span></div><p id="family-map-note" class="description" role="status"></p><span class="description">拖动空白处移动 · 点击人物查看档案 · 关系称谓按参照人物计算</span></div>
</section>
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
</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>
@@ -59,6 +72,8 @@
<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="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="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" 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="记事日期" required 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="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<div id="toast" role="status" hidden></div>
</body>
</html>