diff --git a/README.md b/README.md index 7526443..aa88f07 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 test_family_graph.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 使用管理员与个人账号分权。 @@ -121,3 +121,13 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有 ### 代理缓存兼容(v0.1.17) 入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。 + +## 逻辑一致性修复(v0.1.18) + +- 记事正文实际支持 12000 字中文、摘要支持换行;该接口的 JSON 请求上限为 128 KiB,其他接口保持 16 KiB,并拒绝无效 Content-Length。 +- 编辑已发布记事默认“保存修改”保持发布状态,Enter 保存也不会撤回。撤回为草稿使用独立按钮;首次发布时间在撤回、重发和归档恢复之间保留。 +- 遇到家谱并发修订冲突,当前输入保留;复制需要保留的内容后,可明确点击“读取最新版本(覆盖本次输入)”继续编辑。服务器保存成功而页面重新读取失败时,提示已保存,避免误以为需要再次提交。 +- 从记事跳到归档人物时自动显示归档人物。当前账号的本人标记与关系参照人物区分;变更本人关联会重新布局。 +- 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。 +- 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。 +- 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。 diff --git a/VERSION b/VERSION index 04c5555..f8bc4c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.17 +0.1.18 diff --git a/app.py b/app.py index 133fb53..4344e84 100644 --- a/app.py +++ b/app.py @@ -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 格式') - n = int(self.headers.get('Content-Length', '0')) - if not 0 < n <= 16384: + try: + n = int(self.headers.get('Content-Length', '0')) + except ValueError: + raise Problem('请求长度不正确') + if not 0 < n <= maximum: raise Problem('请求长度不正确') try: data = json.loads(self.rfile.read(n)) @@ -553,9 +556,10 @@ 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 diff --git a/genealogy.py b/genealogy.py index a0dbec8..871b4f8 100644 --- a/genealogy.py +++ b/genealogy.py @@ -51,8 +51,11 @@ class Family: 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'] + # 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))) @@ -64,7 +67,7 @@ class Family: 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), + ('sourceName', 160, False), ('sourceUrl', 2000, False)]: item[key] = a.clean_text(data.get(key, ''), maximum, required) try: @@ -72,16 +75,17 @@ class Family: 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() + 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', 'draft') + 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('记事状态不正确') @@ -127,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') @@ -148,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: diff --git a/test_app.py b/test_app.py index 5db2162..d9d6025 100644 --- a/test_app.py +++ b/test_app.py @@ -146,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) @@ -275,6 +275,57 @@ class WebTests(unittest.TestCase): 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)} diff --git a/test_ui_logic.js b/test_ui_logic.js new file mode 100644 index 0000000..1ac7701 --- /dev/null +++ b/test_ui_logic.js @@ -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); +}); diff --git a/web/app.js b/web/app.js index e35eebe..d364d9b 100644 --- a/web/app.js +++ b/web/app.js @@ -5,7 +5,7 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'& const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他'); let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0; -let currentUser = null; +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); } @@ -115,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])=>`
${esc(k)}
${esc(v)}
`).join(''); } -async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`
${new Date(r.at).toLocaleString()}${esc(r.action)} ${esc(r.name)}
`).join('')||'

尚无操作记录

';}catch(e){toast(e.message);} } -async function refresh() {if(!loggedIn)return;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=>`
${new Date(r.at).toLocaleString()}${esc(r.action)} ${esc(r.name)}
`).join('')||'

尚无操作记录

';}catch(e){if(!e.stale)toast(e.message);} } +async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}} +async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}} function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();} function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.archiveRecorderId.innerHTML=''+state.recorders.map(r=>``).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();} function formValues(f) {return Object.fromEntries(new FormData(f).entries());} diff --git a/web/date-fields.js b/web/date-fields.js index 588cb9f..28eb00a 100644 --- a/web/date-fields.js +++ b/web/date-fields.js @@ -20,11 +20,11 @@ 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,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=''; + 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)); diff --git a/web/family-events.js b/web/family-events.js index d6ada75..4c912ac 100644 --- a/web/family-events.js +++ b/web/family-events.js @@ -45,15 +45,17 @@ function editFamilyEvent(id,personId=''){ $('#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'?'保存已发布内容':'发布记事'; + $('#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||'draft'; - const result=await api('/api/family/event',data);$('#event-dialog').close();await loadFamily();familyView='timeline';renderFamily(); + 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){f.querySelector('.form-error').textContent=err.message;}finally{buttons.forEach(b=>b.disabled=false);} + }catch(err){familyFormError(f,err);}finally{buttons.forEach(b=>b.disabled=false);} }); $('#family-view-timeline').onclick=()=>{familyView='timeline';renderFamily();}; $('#family-new-event').onclick=()=>editFamilyEvent(); diff --git a/web/family-map.js b/web/family-map.js index 4b1a2e8..413fa20 100644 --- a/web/family-map.js +++ b/web/family-map.js @@ -15,7 +15,7 @@ class FamilyMap { 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]); + 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; @@ -30,7 +30,7 @@ class FamilyMap { 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); + 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?' · 已归档':''); diff --git a/web/family.js b/web/family.js index 7387d51..d126fef 100644 --- a/web/family.js +++ b/web/family.js @@ -9,14 +9,23 @@ function clearFamilyState(){closeFamilyFullscreen();familyGeneration++;family={p 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??'';}} +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(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='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit(); - }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(){ @@ -25,13 +34,13 @@ function renderFamily(){ $('#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(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。'); + $('#family-people').innerHTML=people.map(p=>``).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='

人物档案

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

';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('')||'

尚未连接亲属。

'}`; + $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

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

相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':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('')||'

尚未连接亲属。

'}`; if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id)); $('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'
◎ 本人 · 当前账号关联人物
':''}`); } @@ -44,15 +53,15 @@ function renderFamilyTree(){ $('#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 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(){ diff --git a/web/index.html b/web/index.html index 72493f0..517e991 100644 --- a/web/index.html +++ b/web/index.html @@ -72,7 +72,7 @@

家谱人物

出生日期日期不详可留空

连接人物关系

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

-

写一则家族记事

事情发生的日期 *
关联人物(可多选)

未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。

正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。

+

写一则家族记事

事情发生的日期 *
关联人物(可多选)

未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。

正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。

FAMILY CHRONICLE