diff --git a/README.md b/README.md index 9213816..0a8f0ba 100644 --- a/README.md +++ b/README.md @@ -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`。测试使用临时数据库和模拟播放器,不连接真实设备。 访问 `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,27 @@ 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 备份数据库。还原备份会回退备份后的数据,旧版本看不到记事表;应保留升级后的数据库备份,不以旧版本界面作为记事恢复工具。源码中不含真实家族记事和人物信息。 diff --git a/VERSION b/VERSION index c34958a..e8e277f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.15 +0.1.16 diff --git a/app.py b/app.py index 1512dc2..2133b97 100644 --- a/app.py +++ b/app.py @@ -560,7 +560,7 @@ class Handler(BaseHTTPRequestHandler): 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 +579,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 +784,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'} diff --git a/genealogy.py b/genealogy.py index 45753a5..a0dbec8 100644 --- a/genealogy.py +++ b/genealogy.py @@ -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,63 @@ 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']) + if item['status'] == 'published': + item['publishedAt'] = old.get('publishedAt', item['updatedAt']) if old else 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), + ('summary', 400, False), ('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') + body = data.get('body', '') + if not isinstance(body, str) or len(body) > 12000 or any(ord(c) < 32 and c not in '\r\n\t' for c in body): + raise a.Problem('记事正文格式不正确,最多 12000 字') + item['body'] = body.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', '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)} diff --git a/test_app.py b/test_app.py index 850b7d3..fb75585 100644 --- a/test_app.py +++ b/test_app.py @@ -232,6 +232,44 @@ 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_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"}')) diff --git a/test_family_graph.js b/test_family_graph.js new file mode 100644 index 0000000..ea12ff7 --- /dev/null +++ b/test_family_graph.js @@ -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{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').ym.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);}); diff --git a/web/app.js b/web/app.js index db7be6e..e35eebe 100644 --- a/web/app.js +++ b/web/app.js @@ -39,6 +39,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 +49,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() { diff --git a/web/date-fields.js b/web/date-fields.js index 6d0a682..588cb9f 100644 --- a/web/date-fields.js +++ b/web/date-fields.js @@ -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,14 +16,14 @@ 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(); } -function syncDateFields(strict=false){ - syncLifeStatus();for(const field of $$('[data-date-field]'))if(!field.hidden)dateHint(field,strict); +function syncDateFields(strict=false,scope=$('#person-form')){ + syncLifeStatus();for(const field of scope.querySelectorAll('[data-date-field]'))if(!field.hidden)dateHint(field,strict); if($('[data-date-field="deathDate"]').hidden)$('#person-form').elements.deathDate.value=''; } for(const field of $$('[data-date-field]')){ diff --git a/web/family-events.js b/web/family-events.js new file mode 100644 index 0000000..d6ada75 --- /dev/null +++ b/web/family-events.js @@ -0,0 +1,71 @@ +'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'),''+peopleOptions(false)); + const years=[...new Set((family.events||[]).map(e=>e.eventDate.slice(0,4)))].sort().reverse(); + setOptions($('#family-event-year'),''+years.map(y=>``).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?`

${esc(next)}年

`:'';year=next; + return `${heading}`; + }).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 `

相关重大记事 ${events.length}

${family.canEdit?``:''}
${(familyView==='timeline'?events.slice(0,3):[]).map(e=>``).join('')||(familyView==='timeline'?'

尚无已发布的相关记事。

':'')}
`; +} +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=`
${esc(eventStatus(e))}

${esc(e.title)}

${e.summary?`

${esc(e.summary)}

`:''}
关联人物${e.personIds.length?e.personIds.map(id=>``).join(''):'全家记事'}
${esc(e.body||'此条记事暂未填写详细正文。')}
${safeSource?`阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗`:e.sourceName?`

资料来源:${esc(e.sourceName)}

`:''}

最近整理:${esc(new Date(e.updatedAt).toLocaleString())}

${family.canEdit?``:''}`; + $('#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=>``).join('')||'

家谱尚无人名,此条记事将记为全家记事。

'; + $('#event-person-search').value='';f.querySelector('[value=published]').textContent=e?.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||'draft'; + const result=await api('/api/family/event',data);$('#event-dialog').close();await loadFamily();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){f.querySelector('.form-error').textContent=err.message;}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);} +}); diff --git a/web/family-graph.js b/web/family-graph.js new file mode 100644 index 0000000..4ace6cf --- /dev/null +++ b/web/family-graph.js @@ -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{ + 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[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); diff --git a/web/family-map.css b/web/family-map.css new file mode 100644 index 0000000..90ee03d --- /dev/null +++ b/web/family-map.css @@ -0,0 +1,8 @@ +.family-workspace{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:18px;align-items:start}.family-canvas-panel{background:#17262b;border:1px solid #39504f;border-radius:14px;overflow:hidden;min-width:0}.family-map-heading{padding:22px 22px 16px;display:flex;justify-content:space-between;align-items:center;gap:14px}.family-map-heading h2{font-size:23px;margin:8px 0 0;letter-spacing:2px}.family-map-heading .eyebrow{font-size:9px}.family-view-tabs{display:flex;border:1px solid var(--line);border-radius:8px;padding:3px;flex-shrink:0}.family-view-tabs button{border:0;padding:8px 12px;border-radius:5px;font-size:12px}.family-view-tabs [aria-pressed=true]{background:#30574c;color:#b8f2df}.family-map-controls{padding:0 20px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.family-map-controls button{font-size:12px;padding:7px 10px}.family-zoom-controls{display:flex;gap:5px;align-items:center;margin-right:auto}.family-zoom-controls output{font-size:11px;min-width:38px;text-align:center;color:var(--muted)}.family-map-viewport{height:clamp(440px,65vh,740px);overflow:auto;background-color:#e8eeea;background-image:radial-gradient(#b5c5bb 1px,transparent 1px);background-size:22px 22px;position:relative;overscroll-behavior:contain;cursor:grab;scrollbar-color:#82988b #dce5de;scrollbar-width:thin;outline-offset:-3px}.family-map-viewport.panning{cursor:grabbing;user-select:none}.family-map-space{position:relative;min-width:100%;min-height:100%}.family-map-surface{position:absolute;left:0;top:0;transform-origin:0 0}.family-map-lines{position:absolute;inset:0;overflow:visible;pointer-events:none}.family-edge{fill:none;stroke:#86a299;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.family-edge.spouse{stroke:#bd9050;stroke-dasharray:5 4;stroke-width:2}.family-edge.nonbiological{stroke:#9987b4;stroke-dasharray:4 3}.family-edge.focused{stroke-width:2.6;stroke:#337b66}.family-edge.spouse.focused{stroke:#ab791e}.family-edge.nonbiological.focused{stroke:#7b55a2}.family-branch-label{position:absolute;color:#536c60;font-size:12px;letter-spacing:1px;pointer-events:none}.family-node{position:absolute;border:1px solid #c4d0c7;border-radius:12px;background:#fffef9;color:#273e33;padding:13px 15px;text-align:left;white-space:normal;box-shadow:0 4px 11px #263c3010;display:flex;flex-direction:column;gap:4px;overflow:visible;cursor:pointer;transition:box-shadow .15s,border-color .15s}.family-node:hover{filter:none;box-shadow:0 6px 20px #30583d30;border-color:#5b9179}.family-node:focus-visible{outline:3px solid #205a48;outline-offset:4px}.family-node>strong{font-size:16px;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:30px}.family-node-top{position:absolute;right:12px;top:12px}.family-node-avatar{display:flex;width:28px;height:28px;align-items:center;justify-content:center;background:#e9f0e8;border-radius:9px;font-family:'Noto Serif SC','SimSun',serif;color:#48745c;font-size:17px}.family-node-flags{position:absolute;right:0;top:36px;display:grid;gap:4px;justify-items:end;white-space:nowrap}.family-node-flags>small{font-size:9px;color:#748375}.family-node .family-node-relation{display:block;font-size:11px;color:#577868;max-width:120px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.family-node>small{font-size:10px;color:#7a8a80;margin-top:auto}.family-node.self{background:#f6d795;border:2px solid #c49337;box-shadow:0 5px 18px #b487242e}.family-node.self .family-node-avatar{background:#fbe8bd;color:#82561f}.family-node.self>strong{color:#5d3d15}.family-node.self .family-node-relation,.family-node.self>small{color:#7d622f}.self-badge{display:inline-block;border-radius:4px;background:#996e2b;color:#fff4d7;padding:2px 5px;font-size:9px;font-weight:600;letter-spacing:.5px}.family-node.selected{outline:3px solid #3d9175;outline-offset:4px}.family-node.search-hit:after{content:'匹配';position:absolute;left:12px;top:-12px;padding:2px 6px;border-radius:4px;background:#315f8d;color:white;font-size:9px;box-shadow:0 2px 4px #0002}.family-node.archived{border-style:dashed}.family-map-footer{padding:15px 20px;border-top:1px solid #a4b7a41f}.family-map-legend{display:flex;gap:15px;flex-wrap:wrap;font-size:11px;color:#a5bbb4;margin-bottom:9px}.family-map-legend>span{display:flex;align-items:center;gap:6px}.family-map-legend i{display:inline-block;width:19px;height:2px;background:#86a299}.family-map-legend i.legend-self{width:11px;height:11px;background:#f6d795;border-radius:3px}.family-map-legend i.legend-selected{width:11px;height:11px;background:transparent;border:2px solid #64bc9d;border-radius:3px}.family-map-legend i.legend-spouse{background:none;border-top:2px dashed #bd9050}.family-map-legend i.legend-adoptive{background:none;border-top:2px dashed #aa98c4}.family-map-footer .description{font-size:11px;margin:0;line-height:1.8}.family-map-footer #family-map-note:not(:empty){padding:8px 0;color:#c9bc9a}.family-inspector{position:sticky;top:18px;max-height:calc(100vh - 36px);overflow:auto;margin:0;padding:20px;min-width:0;scrollbar-width:thin}.family-inspector.is-self{border-color:#b08848;background:linear-gradient(145deg,#343323,#18272a 55%)}.family-self-heading{color:#e8c780;font-size:12px;margin-bottom:18px}.family-back-to-map{display:block;margin:0 0 18px auto;font-size:11px}#family-detail .section-head{gap:12px;flex-wrap:wrap}#family-detail .section-head>div{display:block}#family-detail .section-head h2{font-size:23px;overflow-wrap:anywhere}#family-detail dl{grid-template-columns:1fr;gap:12px;margin:18px 0 24px}#family-detail dl>div{grid-template-columns:76px minmax(0,1fr);gap:9px}#family-detail h3{font-size:14px;margin-top:24px}#family-detail .family-link{align-items:flex-start;overflow-wrap:anywhere;font-size:12px}#family-detail .family-link .quiet{padding:5px 8px;font-size:11px}#family-detail .family-prose{font-size:13px}.family-register{grid-template-columns:repeat(auto-fill,minmax(180px,1fr));max-height:clamp(440px,65vh,740px);overflow:auto;padding:20px;align-content:start;background:#132226}.person-card.self{background:#463e27;border-color:#b28f50;color:#f1dca7}.person-card.self span:not(.self-badge){color:#e0c995}.person-card.self small{color:#b8a98b}.person-card.selected{outline:2px solid #63b89c;outline-offset:-2px}.family-map-viewport>.empty{border:0;background:transparent;color:#5c7567;height:100%;display:flex;flex-direction:column;justify-content:center;margin:0}.family-map-viewport>.empty h3{color:#314d3d}.family-toolbar #family-search{width:190px}.family-toolbar #family-reference{max-width:180px}.family-toolbar{gap:12px}.family-workspace:is(:fullscreen,.family-expanded){width:100vw;height:100dvh;background:#10191d;padding:16px;align-items:stretch;overflow:auto}.family-workspace:is(:fullscreen,.family-expanded) .family-canvas-panel{height:100%;display:flex;flex-direction:column}.family-workspace:is(:fullscreen,.family-expanded) .family-map-viewport{flex:1;height:auto;min-height:200px}.family-workspace:is(:fullscreen,.family-expanded) .family-register{max-height:none;flex:1}.family-workspace:is(:fullscreen,.family-expanded) .family-inspector{position:static;max-height:100%;overflow:auto}.family-workspace:is(:fullscreen,.family-expanded) .family-map-heading{padding:14px 20px}.family-workspace:is(:fullscreen,.family-expanded) .family-map-footer{padding:10px 20px} +@media(max-width:1250px){.family-workspace{grid-template-columns:minmax(0,1fr) 270px}.family-map-heading{align-items:flex-start;flex-wrap:wrap}.family-map-controls{gap:6px}.family-map-controls button{padding:7px 8px}.family-inspector{padding:16px}.family-toolbar>span{margin-left:0}} +@media(max-width:1000px){.family-workspace{grid-template-columns:1fr}.family-inspector{position:static;max-height:none}.family-map-viewport{height:560px}.family-register{grid-template-columns:repeat(auto-fill,minmax(180px,1fr));max-height:560px}.family-workspace:is(:fullscreen,.family-expanded){display:flex;flex-direction:column;gap:10px}.family-workspace:is(:fullscreen,.family-expanded) .family-canvas-panel{min-height:65vh;flex:1;flex-shrink:0}.family-workspace:is(:fullscreen,.family-expanded) .family-inspector{max-height:35vh;flex-shrink:0}} +@media(max-width:600px){#page-family>.section-head{align-items:flex-start;flex-wrap:wrap}#page-family>.section-head>div{gap:7px;flex-wrap:wrap}#page-family>.section-head button{font-size:12px;padding:8px 10px}.family-toolbar{padding:15px}.family-toolbar label{width:100%;display:flex;justify-content:space-between;flex-wrap:nowrap}.family-toolbar label.check{justify-content:flex-start}.family-toolbar input,.family-toolbar #family-search,.family-toolbar #family-reference{width:60%;max-width:60%}.family-toolbar input[type=checkbox]{width:16px}.family-map-heading{padding:17px 15px}.family-map-heading h2{font-size:21px}.family-map-controls{padding:0 12px 14px}.family-map-footer{padding:13px 15px}.family-map-legend{gap:10px}.family-map-viewport{height:470px}.family-register{grid-template-columns:1fr 1fr;padding:12px;gap:10px}.family-register .person-card{padding:12px}.family-workspace:is(:fullscreen,.family-expanded){padding:8px}.family-map-footer>.description:last-child{font-size:10px}} +.family-map-heading{flex-wrap:wrap}.family-view-tabs{max-width:100%;flex-wrap:wrap}.family-view-tabs button{font-size:11px}.family-event-intro{display:flex;justify-content:space-between;gap:15px;padding:8px 22px 12px;align-items:flex-start}.family-event-intro h3{font-size:16px;line-height:1.7;margin:0 0 7px}.family-event-intro p{margin:0}.family-event-intro button{font-size:12px;padding:9px 12px}.family-event-filters{display:flex;gap:12px;flex-wrap:wrap;padding:15px 22px;background:#101e2366;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.family-event-filters label{display:flex;font-size:12px;gap:7px;flex-wrap:wrap}.family-event-filters input:not([type=checkbox]){width:170px}.family-event-filters select{max-width:180px;font-size:12px;padding:8px}.family-event-filters .check{width:100%;font-size:11px}.family-event-filters button{font-size:11px;padding:7px 10px}.family-event-filters #family-event-state-label{margin-right:auto}#family-event-count{padding:16px 22px 0;margin-bottom:0}.family-timeline{padding:0 22px 28px;max-height:680px;overflow:auto;scrollbar-width:thin}.timeline-year{font-family:Georgia,'SimSun',serif;color:#a4d6c3;font-size:27px;margin:30px 0 18px;letter-spacing:1px}.timeline-year span{font-family:inherit;font-size:12px;color:var(--muted);margin-left:8px}.timeline-event{display:flex;align-items:flex-start;gap:18px;position:relative;width:100%;background:transparent;color:inherit;text-align:left;padding:0 0 24px;border-radius:0;border:0;white-space:normal}.timeline-event:hover{filter:none}.timeline-event:before{content:'';position:absolute;left:85px;top:10px;bottom:-10px;border-left:1px solid #466659}.timeline-event:after{content:'';position:absolute;left:81px;top:6px;width:9px;height:9px;border-radius:50%;background:#7eccac;box-shadow:0 0 0 4px #1c342d}.timeline-event>time{font-size:12px;font-weight:400;color:#9bb3ae;flex:0 0 70px;margin-top:1px}.timeline-event-body{margin-left:12px;flex:1;min-width:0;border:1px solid #345248;border-radius:10px;padding:17px 19px;background:#20352f;display:flex;flex-direction:column;gap:11px}.timeline-event:hover .timeline-event-body{border-color:#71ad94;background:#264037}.timeline-event-body>strong{font-size:17px;line-height:1.6;color:#dfebe5;overflow-wrap:anywhere}.timeline-event-status{font-size:9px;color:#9fc8b7;letter-spacing:1px}.timeline-summary{font-size:12px;color:#a4beb2;line-height:1.8;font-weight:400;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;white-space:pre-line;overflow-wrap:anywhere}.timeline-event-body>small{font-size:11px;color:#acc9b5;font-weight:400}.timeline-read{font-size:11px;color:#83d6b5;font-weight:400}.timeline-event.draft .timeline-event-body{border-style:dashed;background:#282e30}.timeline-event.draft:after{background:#aea087}.event-associated{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:22px 0}.event-associated button{font-size:12px}.event-detail-meta{display:flex;gap:15px;justify-content:space-between;align-items:flex-start;margin:20px 0;color:#97baac;font-size:12px}.event-detail-meta time{line-height:1.8}#event-detail-title{font-size:26px;line-height:1.6;overflow-wrap:anywhere}.event-lead{border-left:3px solid #8cb8a1;background:#233a32;padding:14px 18px;white-space:pre-wrap;font-size:14px;overflow-wrap:anywhere}.event-article{font-size:15px;line-height:2;margin:28px 0;color:#c9d9d1}.event-source{display:inline-block;white-space:normal;overflow-wrap:anywhere;line-height:1.7;margin:8px 0}.event-updated{border-top:1px solid var(--line);padding-top:18px;margin-top:22px}#event-person-choices{display:flex;gap:12px;flex-wrap:wrap;max-height:170px;overflow:auto;padding:14px 0}#event-person-choices label{display:flex;flex-direction:row;align-items:center}.event-editor-actions{gap:12px}.person-events{border-top:1px solid var(--line);margin-top:25px;padding-top:15px}.person-events .section-head{gap:8px}.person-events .section-head h3{margin:0!important;font-size:13px!important}.person-events .section-head small{letter-spacing:0;margin-left:4px}.person-events .section-head button{font-size:10px;padding:6px 7px}.person-event-link{display:flex;flex-direction:column;gap:6px;border:0;background:transparent;color:#c0d6c9;text-align:left;padding:10px 0;margin-bottom:8px;white-space:normal;line-height:1.7;font-size:12px}.person-event-link time{font-size:10px;color:#87a495}.family-workspace:is(:fullscreen,.family-expanded) #family-events{overflow:auto}.family-workspace:is(:fullscreen,.family-expanded) .family-timeline{max-height:none} +@media(max-width:600px){.family-event-intro{flex-wrap:wrap;padding:8px 15px 15px}.family-event-filters{padding:15px;gap:10px}.family-event-filters label{font-size:11px}.family-event-filters .check{line-height:1.8;flex-wrap:nowrap}.family-timeline{padding:0 15px 22px}.timeline-event{gap:8px}.timeline-event>time{font-size:10px;flex-basis:55px}.timeline-event:before{left:62px}.timeline-event:after{left:58px}.timeline-event-body{padding:13px;margin-left:9px}.timeline-event-body>strong{font-size:15px}.family-view-tabs button{padding:8px 10px}#event-detail-title{font-size:22px}.event-detail-meta{flex-wrap:wrap}.event-editor-actions{flex-wrap:wrap}} + +.family-workspace.family-expanded{position:fixed;inset:0;z-index:8;} diff --git a/web/family-map.js b/web/family-map.js new file mode 100644 index 0000000..4b1a2e8 --- /dev/null +++ b/web/family-map.js @@ -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]); + 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?`
${g.sections.map(s=>`
${esc(s.label)}
`).join('')}${g.nodes.map(n=>``).join('')}
`: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=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=`${self?'本人':''}${p.archived?'归档':''}${p.lifeStatus==='deceased'?'已故':''}${esc(p.name)}${esc(relation)}${esc(p.birthDate?p.birthDate.slice(0,4)+' 年生':'生年待考')}`; + }); + 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}); + } +} diff --git a/web/family.js b/web/family.js index 6f54323..7387d51 100644 --- a/web/family.js +++ b/web/family.js @@ -1,50 +1,50 @@ '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=>``).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?'':'')+family.people.map(p=>``).join('');} function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}} -async function loadFamily(){ +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(); + $('#family-error').textContent='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit(); }catch(e){$('#family-error').textContent=e.message;} } function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');} function renderFamily(){ const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase(); const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term)); - $('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'; + $('#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=>``).join('')||empty('从一个人物开始','可先录入自己,再逐步补齐父母、祖先和后辈。'); + $('#family-people').innerHTML=people.map(p=>``).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。'); + renderFamilyTree(); const p=family.people.find(p=>p.id===focusPerson); - if(!p){$('#family-detail').innerHTML='

人物档案

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

';$('#family-tree').replaceChildren();return;} + $('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p); + if(!p){$('#family-detail').innerHTML='

人物档案

点击图中的人物查看生平,或录入第一位人物。

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

${esc(p.name)}

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

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

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

生平与记载

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

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

亲属关系

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

尚未连接亲属。

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

    上溯祖先

      ${branch(root.id,depth,'up',new Set())}

    下续后裔

      ${branch(root.id,depth,'down',new Set())}

    配偶

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

    尚未记录

    '}
    `; + $('#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';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']]){ @@ -69,6 +69,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);}); diff --git a/web/index.html b/web/index.html index 6986008..3f8b468 100644 --- a/web/index.html +++ b/web/index.html @@ -2,8 +2,8 @@ - 赵府智家 · 家庭空间 - + 赵府智家 · 家庭空间 +