feat: local Bootstrap Vue ECharts dashboards and universal genealogy write scope

This commit is contained in:
Codex
2026-10-08 22:41:19 +08:00
parent d8826152c6
commit fb87ae4de9
33 changed files with 980 additions and 110 deletions
+36 -8
View File
@@ -9,6 +9,20 @@ function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamil
function personLocation(p,prefix){return [ZhaoGeography.describe(p,prefix),...['Town','Community','Address'].map(k=>p[prefix+k])].filter(Boolean).join(' · ');}
function personSearch(p){return [p.name,p.alias,p.birthplace,personLocation(p,'native'),personLocation(p,'birth')].join(' ').toLowerCase();}
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
function familyEditable(kind,id){
if(!family.canEdit)return false;
if(!family.editScope)return true;
const allowed=new Set(family.editScope.editablePersonIds||[]);
if(kind==='person')return allowed.has(id);
const row=(kind==='link'?family.links:family.events||[]).find(x=>x.id===id);
return !!row&&(kind==='link'?[row.fromId,row.toId]:row.personIds||[]).every(key=>allowed.has(key));
}
function familyScopeLabel(id){
const scope=family.editScope;if(!scope)return '';
const n=scope.levels?.[id],position=n===0?'与本人同代':n<0?`本人上 ${-n} 代`:n>0?`本人下 ${n} 代`:'与本人的世系尚未连接';
return position+' · '+(familyEditable('person',id)?'可维护':Number.isFinite(n)&&Math.abs(n)>scope.limit?'只读,超出五代范围':'只读');
}
function editablePeopleOptions(){return family.people.filter(p=>familyEditable('person',p.id)).map(p=>`<option value="${p.id}">${esc(p.name)}</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){resetEditorForm(form);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){
@@ -35,14 +49,17 @@ function renderFamily(){
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&personSearch(p).includes(term));
$('#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;
$('#new-person').hidden=!family.canEdit&&!family.editScope?.canCreateSelf;
$('#new-person').textContent=family.editScope?.canCreateSelf?'+ 建立我的人物档案':'+ 录入亲属';
$('#family-edit-note').textContent=family.editScope?.note||'';
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。');
renderFamilyTree();
const p=family.people.find(p=>p.id===focusPerson);
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</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)]]:[]),['籍贯(祖籍)',personLocation(p,'native')],['出生地',personLocation(p,'birth')],...(p.birthplace?[['原地域记录',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><button class="quiet" data-history-kind="link" data-history-id="${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>${familyEditable('person',p.id)?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</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)]]:[]),['籍贯(祖籍)',personLocation(p,'native')],['出生地',personLocation(p,'birth')],...(p.birthplace?[['原地域记录',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>${familyEditable('person',p.id)?'<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>${familyEditable('link',e.id)?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
$('#family-detail').insertAdjacentHTML('afterbegin',`<p class="alert alert-light border">${esc(familyScopeLabel(p.id))}</p>`);
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
}
@@ -56,27 +73,38 @@ function renderFamilyTree(){
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
}
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});ZhaoGeography.fill($('#person-form'),p);$('#person-legacy-place').hidden=!p.birthplace;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 openPerson(id){
const self=!id&&family.editScope?.canCreateSelf;
if(id?!familyEditable('person',id):!family.canEdit&&!self)return;
const p=family.people.find(p=>p.id===id)||{},f=$('#person-form');
f.elements.relativeId.innerHTML=editablePeopleOptions();
fillForm(f,{...p,revision:family.revision});f.dataset.asSelf=String(!!self);
$('#person-relative-fields').hidden=!!id||!!self;f.elements.relativeId.required=!id&&!self;
if(!id&&!self)f.elements.relativeId.value=familyEditable('person',focusPerson)?focusPerson:family.selfId;
$('#person-self-note').hidden=!self;
if(self)f.elements.name.value=currentUser?.name||'';
ZhaoGeography.fill(f,p);$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();
}
function openRelation(id){if(!family.canEdit||id&&!familyEditable('link',id))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();const loaded=await loadFamily();if(loggedIn)toast(loaded?'家谱已保存':'家谱已保存,读取最新资料失败,请重新载入');
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;d.asSelf=f.dataset.asSelf==='true';}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();if(d.asSelf){await auth();if(loggedIn){showPage('family');toast('本人人物已建立,维护范围已生效');}}else{const loaded=await loadFamily();if(loggedIn)toast(loaded?'家谱已保存':'家谱已保存,读取最新资料失败,请重新载入');}
}catch(err){familyFormError(f,err);}finally{b.disabled=false;}
});
}
async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id;
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱 / 传承资料</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}<small>文化与财产:${u.role!=='member'?'管理':({none:'不可访问',read:'查看',edit:'维护'}[u.heritageAccess||'none'])}</small></td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱 / 传承资料</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'||u.familyAccess==='edit'?(u.personId?'维护本人上下五代':'查看 · 待关联本人'):({none:'不可访问',read:'查看'}[u.familyAccess])}<small>文化与财产:${u.role!=='member'?'管理':({none:'不可访问',read:'查看',edit:'维护'}[u.heritageAccess||'none'])}</small></td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
return true;
}catch(e){if(!e.stale)toast(e.message);return false;}
}
function openUser(id){
if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{};
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join('');
fillForm(f,u);f.elements.password.required=!u.id;
fillForm(f,u);f.elements.password.required=!u.id;f.elements.personId.disabled=!!u.personId;
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(siteName(s.id))}</label>`).join('');$('#user-dialog').showModal();
}