Fix family publishing, concurrent edits and session consistency
This commit is contained in:
@@ -49,7 +49,7 @@ python3 app.py
|
|||||||
mediamtx ./data/mediamtx.yml
|
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 使用管理员与个人账号分权。
|
访问 `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)
|
### 代理缓存兼容(v0.1.17)
|
||||||
|
|
||||||
入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。
|
入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。
|
||||||
|
|
||||||
|
## 逻辑一致性修复(v0.1.18)
|
||||||
|
|
||||||
|
- 记事正文实际支持 12000 字中文、摘要支持换行;该接口的 JSON 请求上限为 128 KiB,其他接口保持 16 KiB,并拒绝无效 Content-Length。
|
||||||
|
- 编辑已发布记事默认“保存修改”保持发布状态,Enter 保存也不会撤回。撤回为草稿使用独立按钮;首次发布时间在撤回、重发和归档恢复之间保留。
|
||||||
|
- 遇到家谱并发修订冲突,当前输入保留;复制需要保留的内容后,可明确点击“读取最新版本(覆盖本次输入)”继续编辑。服务器保存成功而页面重新读取失败时,提示已保存,避免误以为需要再次提交。
|
||||||
|
- 从记事跳到归档人物时自动显示归档人物。当前账号的本人标记与关系参照人物区分;变更本人关联会重新布局。
|
||||||
|
- 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。
|
||||||
|
- 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。
|
||||||
|
- 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。
|
||||||
|
|||||||
@@ -493,11 +493,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.user['role'] != 'admin':
|
if self.user['role'] != 'admin':
|
||||||
raise Problem('此操作需要管理员权限', 403)
|
raise Problem('此操作需要管理员权限', 403)
|
||||||
|
|
||||||
def body(self):
|
def body(self, maximum=16384):
|
||||||
if self.headers.get_content_type() != 'application/json':
|
if self.headers.get_content_type() != 'application/json':
|
||||||
raise Problem('请求需要 JSON 格式')
|
raise Problem('请求需要 JSON 格式')
|
||||||
n = int(self.headers.get('Content-Length', '0'))
|
try:
|
||||||
if not 0 < n <= 16384:
|
n = int(self.headers.get('Content-Length', '0'))
|
||||||
|
except ValueError:
|
||||||
|
raise Problem('请求长度不正确')
|
||||||
|
if not 0 < n <= maximum:
|
||||||
raise Problem('请求长度不正确')
|
raise Problem('请求长度不正确')
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.rfile.read(n))
|
data = json.loads(self.rfile.read(n))
|
||||||
@@ -553,9 +556,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
try:
|
try:
|
||||||
self.validate_origin()
|
|
||||||
path = url.urlsplit(self.path).path
|
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'):
|
if path in ('/api/login', '/api/setup'):
|
||||||
self.login(data, path == '/api/setup')
|
self.login(data, path == '/api/setup')
|
||||||
return
|
return
|
||||||
|
|||||||
+27
-8
@@ -51,8 +51,11 @@ class Family:
|
|||||||
if kind == 'event':
|
if kind == 'event':
|
||||||
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
|
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
|
||||||
createdBy=old.get('createdBy', user['username']) if old else user['username'])
|
createdBy=old.get('createdBy', user['username']) if old else user['username'])
|
||||||
if item['status'] == 'published':
|
# Withdrawal and restoration must retain the first publication time.
|
||||||
item['publishedAt'] = old.get('publishedAt', item['updatedAt']) if old else item['updatedAt']
|
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.
|
# 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 ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
|
||||||
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
|
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
|
||||||
@@ -64,7 +67,7 @@ class Family:
|
|||||||
a = self.a
|
a = self.a
|
||||||
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
||||||
for key, maximum, required in [('title', 160, True), ('eventDate', 10, True),
|
for key, maximum, required in [('title', 160, True), ('eventDate', 10, True),
|
||||||
('summary', 400, False), ('sourceName', 160, False),
|
('sourceName', 160, False),
|
||||||
('sourceUrl', 2000, False)]:
|
('sourceUrl', 2000, False)]:
|
||||||
item[key] = a.clean_text(data.get(key, ''), maximum, required)
|
item[key] = a.clean_text(data.get(key, ''), maximum, required)
|
||||||
try:
|
try:
|
||||||
@@ -72,16 +75,17 @@ class Family:
|
|||||||
raise ValueError()
|
raise ValueError()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD')
|
raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD')
|
||||||
body = data.get('body', '')
|
for key, maximum, label in [('summary', 400, '摘要'), ('body', 12000, '正文')]:
|
||||||
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):
|
value = data.get(key, '')
|
||||||
raise a.Problem('记事正文格式不正确,最多 12000 字')
|
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):
|
||||||
item['body'] = body.strip()
|
raise a.Problem(f'记事{label}格式不正确,最多 {maximum} 字')
|
||||||
|
item[key] = value.strip()
|
||||||
item['personIds'] = data.get('personIds', [])
|
item['personIds'] = data.get('personIds', [])
|
||||||
if (not isinstance(item['personIds'], list) or len(item['personIds']) > 200
|
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'])):
|
or any(not isinstance(p, str) or not a.get_object('people', p) for p in item['personIds'])):
|
||||||
raise a.Problem('请选择有效的关联人物,最多 200 位')
|
raise a.Problem('请选择有效的关联人物,最多 200 位')
|
||||||
item['personIds'] = list(dict.fromkeys(item['personIds']))
|
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)
|
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):
|
if item['status'] not in ('draft', 'published') or not isinstance(item['archived'], bool):
|
||||||
raise a.Problem('记事状态不正确')
|
raise a.Problem('记事状态不正确')
|
||||||
@@ -127,8 +131,22 @@ class Family:
|
|||||||
item['archived'] = data.get('archived', False)
|
item['archived'] = data.get('archived', False)
|
||||||
if not isinstance(item['archived'], bool):
|
if not isinstance(item['archived'], bool):
|
||||||
raise a.Problem('归档状态不正确')
|
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
|
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):
|
def link(self, data, old):
|
||||||
a = self.a
|
a = self.a
|
||||||
left, right = data.get('fromId'), data.get('toId')
|
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,
|
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,
|
lineage=lineage if kind == 'parent' else 'unspecified', active=active,
|
||||||
note=a.clean_text(data.get('note', ''), 300))
|
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']]
|
links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']]
|
||||||
if active:
|
if active:
|
||||||
for e in links:
|
for e in links:
|
||||||
|
|||||||
+52
-1
@@ -146,7 +146,7 @@ class WebTests(unittest.TestCase):
|
|||||||
for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8',
|
for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8',
|
||||||
'/api/recordings?camera='+'a'*16]:
|
'/api/recordings?camera='+'a'*16]:
|
||||||
self.assertEqual(request(path),403)
|
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(path,{}),403)
|
||||||
self.assertEqual(request('/api/family'),200)
|
self.assertEqual(request('/api/family'),200)
|
||||||
app.ACCOUNTS.save(dict(member,disabled=True),self.admin)
|
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)
|
event=app.FAMILY.event(dict(good,sourceUrl='https://example.org/report'),None)
|
||||||
self.assertEqual(event['sourceUrl'],'https://example.org/report')
|
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):
|
def test_event_endpoint_and_static_modules(self):
|
||||||
self.assertEqual(self.request('/api/family/event',False,{'title':'Denied'})[0],401)
|
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)}
|
payload={'title':'API event','eventDate':'2026-01-01','body':'Test only','status':'published','revision':app.setting('familyRevision',0)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+8
-5
@@ -5,7 +5,7 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'&
|
|||||||
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
|
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
|
||||||
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
|
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 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';
|
const isAdmin = () => currentUser?.role === 'admin';
|
||||||
function applyPermissions(){
|
function applyPermissions(){
|
||||||
const admin=isAdmin();
|
const admin=isAdmin();
|
||||||
@@ -14,6 +14,7 @@ function applyPermissions(){
|
|||||||
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
|
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
|
||||||
}
|
}
|
||||||
function clearPrivateView(){
|
function clearPrivateView(){
|
||||||
|
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
|
||||||
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
|
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
|
||||||
currentUser=null;stopPlayers();resetPlayback();
|
currentUser=null;stopPlayers();resetPlayback();
|
||||||
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
|
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
|
||||||
@@ -25,9 +26,11 @@ function clearPrivateView(){
|
|||||||
}
|
}
|
||||||
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
|
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
|
||||||
async function api(path, data) {
|
async function api(path, data) {
|
||||||
|
const generation=sessionGeneration;
|
||||||
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
|
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
|
||||||
let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); }
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||||
@@ -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]??'';
|
if (!$('#storage-form').contains(document.activeElement)) for(const k of ['retentionDays','maxGB','reserveGB']) $('#storage-form').elements[k].value=s[k]??'';
|
||||||
$('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
|
$('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
|
||||||
}
|
}
|
||||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){toast(e.message);} }
|
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
|
||||||
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id;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 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() {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 auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
|
||||||
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
|
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
|
||||||
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
|
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
|
||||||
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
||||||
|
|||||||
+3
-3
@@ -20,11 +20,11 @@ function initDateFields(scope=$('#person-form')){
|
|||||||
for(const field of scope.querySelectorAll('[data-date-field]')){
|
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-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false;
|
||||||
field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field);
|
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')){
|
function syncDateFields(strict=false,scope=$('#person-form')){
|
||||||
syncLifeStatus();for(const field of scope.querySelectorAll('[data-date-field]'))if(!field.hidden)dateHint(field,strict);
|
if(scope.id==='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'&&$('[data-date-field="deathDate"]').hidden)scope.elements.deathDate.value='';
|
||||||
}
|
}
|
||||||
for(const field of $$('[data-date-field]')){
|
for(const field of $$('[data-date-field]')){
|
||||||
field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field));
|
field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field));
|
||||||
|
|||||||
@@ -45,15 +45,17 @@ function editFamilyEvent(id,personId=''){
|
|||||||
$('#event-editor-title').textContent=e?'编辑家族记事':'写一则家族记事';
|
$('#event-editor-title').textContent=e?'编辑家族记事':'写一则家族记事';
|
||||||
const selected=new Set(e?.personIds||(personId?[personId]:[]));
|
const selected=new Set(e?.personIds||(personId?[personId]:[]));
|
||||||
$('#event-person-choices').innerHTML=family.people.map(p=>`<label class="check"><input type="checkbox" name="personIds" value="${esc(p.id)}" ${selected.has(p.id)?'checked':''}>${esc(p.name)}${p.archived?'(已归档)':''}</label>`).join('')||'<p class="description">家谱尚无人名,此条记事将记为全家记事。</p>';
|
$('#event-person-choices').innerHTML=family.people.map(p=>`<label class="check"><input type="checkbox" name="personIds" value="${esc(p.id)}" ${selected.has(p.id)?'checked':''}>${esc(p.name)}${p.archived?'(已归档)':''}</label>`).join('')||'<p class="description">家谱尚无人名,此条记事将记为全家记事。</p>';
|
||||||
$('#event-person-search').value='';f.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();
|
initDateFields(f);if($('#event-detail-dialog').open)$('#event-detail-dialog').close();$('#event-dialog').showModal();
|
||||||
}
|
}
|
||||||
$('#event-form').addEventListener('submit',async e=>{
|
$('#event-form').addEventListener('submit',async e=>{
|
||||||
e.preventDefault();const f=e.currentTarget,buttons=[...f.querySelectorAll('[type=submit]')];buttons.forEach(b=>b.disabled=true);
|
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';
|
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();await loadFamily();familyView='timeline';renderFamily();
|
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'?'记事已发布到家族时间线':'草稿已保存');
|
$('#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-view-timeline').onclick=()=>{familyView='timeline';renderFamily();};
|
||||||
$('#family-new-event').onclick=()=>editFamilyEvent();
|
$('#family-new-event').onclick=()=>editFamilyEvent();
|
||||||
|
|||||||
+2
-2
@@ -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%';}
|
reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
|
||||||
render(data) {
|
render(data) {
|
||||||
this.data=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) {
|
if(key!==this.key) {
|
||||||
this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data);
|
this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data);
|
||||||
const g=this.graph;
|
const g=this.graph;
|
||||||
@@ -30,7 +30,7 @@ class FamilyMap {
|
|||||||
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
|
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 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 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.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.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
|
||||||
b.title=p.name+' · '+relation+(p.archived?' · 已归档':'');
|
b.title=p.name+' · '+relation+(p.archived?' · 已归档':'');
|
||||||
|
|||||||
+16
-7
@@ -9,14 +9,23 @@ function clearFamilyState(){closeFamilyFullscreen();familyGeneration++;family={p
|
|||||||
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
||||||
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
||||||
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
|
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){
|
async function loadFamily(resetView=false){
|
||||||
const generation=++familyGeneration;
|
const generation=++familyGeneration;
|
||||||
try{const result=await api('/api/family');if(!loggedIn||generation!==familyGeneration)return;family=result;
|
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||'';
|
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;
|
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';}
|
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();
|
$('#family-error').textContent='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit();return true;
|
||||||
}catch(e){$('#family-error').textContent=e.message;}
|
}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 relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
|
||||||
function renderFamily(){
|
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-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;
|
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
|
||||||
$('#new-person').hidden=!family.canEdit;
|
$('#new-person').hidden=!family.canEdit;
|
||||||
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。');
|
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。');
|
||||||
renderFamilyTree();
|
renderFamilyTree();
|
||||||
const p=family.people.find(p=>p.id===focusPerson);
|
const p=family.people.find(p=>p.id===focusPerson);
|
||||||
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
|
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
|
||||||
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
|
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
|
||||||
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
|
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
|
||||||
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
|
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
|
||||||
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
|
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
|
||||||
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
|
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
|
||||||
}
|
}
|
||||||
@@ -44,15 +53,15 @@ function renderFamilyTree(){
|
|||||||
$('#family-view-tree').setAttribute('aria-pressed',String(familyView==='tree'));$('#family-view-list').setAttribute('aria-pressed',String(familyView==='list'));
|
$('#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});
|
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 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();}
|
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']]){
|
for(const [name,path]of [['person','person'],['relation','link']]){
|
||||||
$('#'+name+'-form').addEventListener('submit',async e=>{
|
$('#'+name+'-form').addEventListener('submit',async e=>{
|
||||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||||
try{const d=formValues(f);d.revision=Number(d.revision);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;
|
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('家谱已保存');
|
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){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}
|
}catch(err){familyFormError(f,err);}finally{b.disabled=false;}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function loadUsers(){
|
async function loadUsers(){
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@
|
|||||||
<datalist id="relationship-choices"></datalist>
|
<datalist id="relationship-choices"></datalist>
|
||||||
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
|
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
|
||||||
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
|
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
|
||||||
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" value="draft" class="secondary">保存草稿</button><button type="submit" value="published">发布记事</button></div></form></dialog>
|
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
|
||||||
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
|
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
|
||||||
<div id="toast" role="status" hidden></div>
|
<div id="toast" role="status" hidden></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user