Add verified camera settings, family version recovery and account self service

This commit is contained in:
Codex
2026-10-04 04:52:39 +08:00
parent 56a24c80e2
commit 96f0ef31fc
18 changed files with 969 additions and 37 deletions
+17 -3
View File
@@ -9,11 +9,13 @@ let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGenera
const isAdmin = () => currentUser?.role === 'admin';
function applyPermissions(){
const admin=isAdmin();
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera]').forEach(el=>el.hidden=!admin);
$('#account-password').hidden=!loggedIn;
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
}
function clearPrivateView(){
if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;stopPlayers();resetPlayback();
@@ -85,7 +87,7 @@ function renderLive() {
const key = ZhaoLivePlayer.key(cameras, quality);
if (key === liveKey) { updateLiveStatus(cameras); return; }
stopPlayers(); liveKey = key;
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`);
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
@@ -107,7 +109,7 @@ function renderCameras() {
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
const cameras=filtered();
$('#camera-list').innerHTML=cameras.length?`<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>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
$('#camera-list').innerHTML=cameras.length?`<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>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
}
function renderSites() {
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
@@ -170,3 +172,15 @@ $('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').el
handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
function validateNewPassword(values){
if(values.newPassword!==values.confirmPassword)throw Error('两次输入的新密码不一致');
if(values.newPassword===values.currentPassword)throw Error('新密码不能与当前密码相同');
if(values.newPassword.length<8||values.newPassword.length>128)throw Error('密码需要 8 至 128 个字符');
}
$('#account-password').onclick=()=>{if(!loggedIn)return;const f=$('#password-form');f.reset();f.querySelector('.form-error').textContent='';$('#password-account').textContent='当前账号:'+currentUser.username;$('#password-dialog').showModal();};
$('#password-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{const data=formValues(f);validateNewPassword(data);await api('/api/account/password',{currentPassword:data.currentPassword,newPassword:data.newPassword});clearPrivateView();await auth();toast('密码已修改,请用新密码重新登录');}
catch(err){if(!err.stale)f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
let cameraSettingsContext=null,cameraSettingsGeneration=0;
const videoLabels={resolution:'分辨率',fps:'帧率(帧/秒)',bitrate:'码率上限(kbps)',quality:'画质等级',gop:'关键帧间隔(帧)'};
function clearCameraSettings(){cameraSettingsGeneration++;cameraSettingsContext=null;for(const id of ['camera-settings-info','camera-image-fields','camera-video-fields','camera-settings-status'])$('#'+id).replaceChildren();}
function settingsInput(key,label,value,rule){
const enumLabel=v=>key==='dayNight'?({AUTO:'自动日夜',ON:'日间(红外截止)',OFF:'夜间(红外通过)'})[v]||v:({ON:'开启',OFF:'关闭',AUTO:'自动',MANUAL:'手动'})[v]||v;
return `<label>${esc(label)}${rule.choices?`<select name="${esc(key)}">${rule.choices.map(v=>`<option value="${esc(v)}" ${v===value?'selected':''}>${esc(enumLabel(v))}</option>`).join('')}</select>`:`<input name="${esc(key)}" type="number" value="${esc(value)}" min="${rule.min}" max="${rule.max}" step="${['fps','bitrate','gop'].includes(key)?'1':'any'}" required><small>${rule.min}–${rule.max}</small>`}</label>`;
}
function renderCameraSettings(data){
const d=data.device;
$('#camera-settings-info').innerHTML=`<dl>${[['厂商',d.Manufacturer],['型号',d.Model],['固件',d.FirmwareVersion],['镜头视频源',data.source],['码流配置',data.profile],['设备报告编码',data.video.codec]].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v||'未提供')}</dd></div>`).join('')}</dl>`;
$('#camera-image-fields').innerHTML=Object.entries(data.imaging).map(([k,r])=>settingsInput(k,r.label,r.value,r)).join('')||'<p class="description">设备未提供可调图像参数。</p>';
$('#camera-video-fields').innerHTML=Object.entries(data.video.options).map(([k,r])=>settingsInput(k,videoLabels[k],data.video.values[k],r)).join('')||'<p class="description">设备未提供可调码流参数。</p>';
$('#camera-settings-status').textContent=data.notes.join(';');
$('#camera-image-form button[type=submit]').disabled=!Object.keys(data.imaging).length;
$('#camera-video-form button[type=submit]').disabled=!Object.keys(data.video.options).length;
}
async function loadCameraSettings(){
if(!cameraSettingsContext)return;const context=cameraSettingsContext,generation=++cameraSettingsGeneration;
context.settings=null;context.quality=$('#camera-settings-quality').value;
$('#camera-settings-status').textContent='正在读取设备实际参数…';
$$('#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;
try{const result=await api('/api/camera/settings?'+new URLSearchParams({camera:context.id,quality:context.quality}));
if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;context.settings=result;renderCameraSettings(result);
}catch(e){if(!e.stale&&generation===cameraSettingsGeneration)$('#camera-settings-status').textContent=e.message;}
finally{if(generation===cameraSettingsGeneration)$('#camera-settings-reload').disabled=false;}
}
async function openCameraSettings(id){
if(!isAdmin())return;clearCameraSettings();cameraSettingsContext={id,settings:null,quality:'main'};
$('#camera-settings-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像机')+' · 参数设置';$('#camera-settings-quality').value='main';$('#camera-settings-quality').disabled=false;
$('#camera-settings-dialog').showModal();await loadCameraSettings();
}
function changedCameraValues(form,section,settings){
const rules=section==='imaging'?settings.imaging:settings.video.options,values={};
for(const [key,rule]of Object.entries(rules)){
const value=rule.choices?form.elements[key].value:Number(form.elements[key].value);
if(value!==(section==='imaging'?rule.value:settings.video.values[key]))values[key]=value;
}
return values;
}
async function saveCameraSettings(form,section){
const context=cameraSettingsContext;if(!context?.settings)return;
const values=changedCameraValues(form,section,context.settings);if(!Object.keys(values).length){toast('参数没有变化');return;}
const generation=++cameraSettingsGeneration;
$$('#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;$('#camera-settings-quality').disabled=true;
$('#camera-settings-status').textContent='正在保存并读取设备返回值…';
try{const result=await api('/api/camera/settings',{camera:context.id,quality:context.quality,revision:context.settings.revision,section,values});
if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;
context.settings=result.settings;if(result.settings)renderCameraSettings(result.settings);$('#camera-settings-status').textContent=result.message;toast(result.message);
}catch(e){if(!e.stale&&generation===cameraSettingsGeneration){context.settings=null;$('#camera-settings-status').textContent=e.message+'。请点击“重新读取”核对设备状态。';}}
finally{if(generation===cameraSettingsGeneration){$('#camera-settings-reload').disabled=false;$('#camera-settings-quality').disabled=false;}}
}
for(const [id,section]of [['camera-image-form','imaging'],['camera-video-form','video']])$('#'+id).addEventListener('submit',e=>{e.preventDefault();saveCameraSettings(e.currentTarget,section);});
$('#camera-settings-reload').onclick=loadCameraSettings;$('#camera-settings-quality').onchange=loadCameraSettings;
$('#camera-settings-dialog').addEventListener('close',clearCameraSettings);
document.addEventListener('click',e=>{const b=e.target.closest('[data-camera-settings]');if(b)openCameraSettings(b.dataset.cameraSettings);});
+1 -1
View File
@@ -35,7 +35,7 @@ function openFamilyEvent(id){
const e=(family.events||[]).find(e=>e.id===id);if(!e)return;
// All content is plain text. External reports are links, never embedded HTML.
const safeSource=/^https?:\/\//i.test(e.sourceUrl||'')?e.sourceUrl:'';
$('#event-detail-content').innerHTML=`<div class="event-detail-meta"><time>${esc(ZhaoCalendar.describe(e.eventDate))}</time><span class="badge">${esc(eventStatus(e))}</span></div><h2 id="event-detail-title">${esc(e.title)}</h2>${e.summary?`<p class="event-lead">${esc(e.summary)}</p>`:''}<div class="event-associated"><span class="description">关联人物</span>${e.personIds.length?e.personIds.map(id=>`<button class="quiet" data-event-person="${esc(id)}">${esc(personName(id))} ↗</button>`).join(''):'<span>全家记事</span>'}</div><div class="family-prose event-article">${esc(e.body||'此条记事暂未填写详细正文。')}</div>${safeSource?`<a class="button secondary event-source" href="${esc(safeSource)}" target="_blank" rel="noopener noreferrer">阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗</a>`:e.sourceName?`<p class="description">资料来源:${esc(e.sourceName)}</p>`:''}<p class="description event-updated">最近整理:${esc(new Date(e.updatedAt).toLocaleString())}</p>${family.canEdit?`<button data-edit-event="${e.id}">编辑这条记事</button>`:''}`;
$('#event-detail-content').innerHTML=`<div class="event-detail-meta"><time>${esc(ZhaoCalendar.describe(e.eventDate))}</time><span class="badge">${esc(eventStatus(e))}</span></div><h2 id="event-detail-title">${esc(e.title)}</h2>${e.summary?`<p class="event-lead">${esc(e.summary)}</p>`:''}<div class="event-associated"><span class="description">关联人物</span>${e.personIds.length?e.personIds.map(id=>`<button class="quiet" data-event-person="${esc(id)}">${esc(personName(id))} ↗</button>`).join(''):'<span>全家记事</span>'}</div><div class="family-prose event-article">${esc(e.body||'此条记事暂未填写详细正文。')}</div>${safeSource?`<a class="button secondary event-source" href="${esc(safeSource)}" target="_blank" rel="noopener noreferrer">阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗</a>`:e.sourceName?`<p class="description">资料来源:${esc(e.sourceName)}</p>`:''}<p class="description event-updated">最近整理:${esc(new Date(e.updatedAt).toLocaleString())}</p>${family.canEdit?`<button data-edit-event="${e.id}">编辑这条记事</button><button class="quiet" data-history-kind="event" data-history-id="${e.id}">修改记录</button>`:''}`;
$('#event-detail-dialog').showModal();
}
function editFamilyEvent(id,personId=''){
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const historyFields={name:'姓名',alias:'别名',gender:'性别',lifeStatus:'在世情况',birthDate:'出生日期',deathDate:'去世日期',birthplace:'籍贯',biography:'生平',note:'备注',archived:'归档',fromId:'关系一方',toId:'关系另一方',kind:'关系类型',lineage:'亲子属性',active:'有效关系',title:'记事标题',eventDate:'发生日期',summary:'摘要',body:'正文',personIds:'关联人物',status:'发布状态',sourceName:'资料来源',sourceUrl:'报道链接'};
let historyContext=null,historySelection=null,historyGeneration=0,historyDetailGeneration=0;
function clearFamilyHistory(){historyGeneration++;historyDetailGeneration++;historyContext=null;historySelection=null;for(const id of ['history-list','history-detail','history-error'])$('#'+id).replaceChildren();}
function historyValue(key,value){
if(value===undefined||value===null||value==='')return '未填写';
if(key==='personIds')return value.length?value.map(personName).join('、'):'全家';
if(key==='fromId'||key==='toId')return personName(value);
if(typeof value==='boolean')return value?'是':'否';
const labels={male:'男',female:'女',unknown:'待确认',alive:'在世',deceased:'已故',draft:'草稿',published:'已发布',parent:'父母 → 子女',spouse:'配偶',biological:'亲生',adoptive:'收养',step:'继亲',unspecified:'未注明'};
return ['gender','lifeStatus','status','kind','lineage'].includes(key)?labels[value]||String(value):String(value);
}
async function openFamilyHistory(kind,id){
if(!family.canEdit)return;clearFamilyHistory();
if($('#event-detail-dialog').open)$('#event-detail-dialog').close();
historyContext={kind,id,revision:family.revision,nextBefore:null};
$('#history-title').textContent='修改记录 · '+({person:personName(id),link:'亲属关系',event:family.events.find(e=>e.id===id)?.title||'家族记事'})[kind];
$('#history-dialog').showModal();await loadFamilyHistory();
}
async function loadFamilyHistory(more=false){
if(!historyContext)return;const context=historyContext,generation=++historyGeneration,b=$('#history-more');b.disabled=true;
try{const result=await api('/api/family/history?'+new URLSearchParams({kind:context.kind,id:context.id,...(more&&context.nextBefore?{before:context.nextBefore}:{})}));
if(!loggedIn||generation!==historyGeneration||context!==historyContext)return;
context.nextBefore=result.nextBefore;if(!more)context.revision=result.revision;
const html=result.items.map(c=>`<button class="history-entry quiet" data-history-change="${c.revision}"><strong>版本 ${c.revision} · ${c.restoredFrom?'恢复旧版':c.created?'创建记录':'修改记录'}</strong><span>${esc(c.actor)} · ${esc(new Date(c.at).toLocaleString())}</span><small>${esc(c.fields.map(f=>historyFields[f]||f).join('、')||'内容未变化')}</small></button>`).join('');
if(more)$('#history-list').insertAdjacentHTML('beforeend',html);else $('#history-list').innerHTML=html||'<p class="description">暂无修改记录。此功能从本次升级开始记录;升级前的内容会在下一次修改时保留。</p>';
b.hidden=!context.nextBefore;$('#history-error').textContent='';
}catch(e){if(!e.stale&&generation===historyGeneration)$('#history-error').textContent=e.message;}finally{if(generation===historyGeneration)b.disabled=false;}
}
async function showFamilyChange(revision){
const context=historyContext,generation=++historyDetailGeneration;historySelection=null;$('#history-detail').textContent='正在读取版本…';
try{const c=await api('/api/family/change?revision='+revision);
if(!loggedIn||generation!==historyDetailGeneration||context!==historyContext)return;
historySelection=c;
const keys=Object.keys(historyFields).filter(k=>k in c.after||k in (c.before||{}));
$('#history-detail').innerHTML=`<h3>版本 ${c.revision} 的内容对照</h3><p class="description">恢复只更新这条记录。已发生的后续修改仍可在历史中查阅。</p><div class="history-columns">${['before','after'].map(side=>`<section><h4>${side==='before'?'本次修改前':'本次修改后'}</h4>${c[side]?`<dl>${keys.map(k=>`<div class="${JSON.stringify(c.before?.[k])!==JSON.stringify(c.after[k])?'history-changed':''}"><dt>${esc(historyFields[k])}</dt><dd>${esc(historyValue(k,c[side][k]))}</dd></div>`).join('')}</dl><button class="secondary" data-history-prepare="${side}">选择恢复${side==='before'?'修改前':'此版本'}</button>`:'<p>创建前没有记录。</p>'}</section>`).join('')}</div><div id="history-confirm" hidden></div>`;
$('#history-error').textContent='';
}catch(e){if(!e.stale&&generation===historyDetailGeneration)$('#history-detail').textContent=e.message;}
}
function prepareFamilyRestore(side){
const c=historySelection;if(!c?.[side])return;const value=c[side],box=$('#history-confirm');
box.hidden=false;box.innerHTML=`<p>将用版本 ${c.revision} ${side==='before'?'修改前':'修改后'}的内容替换这条记录的当前内容。${c.kind==='event'?'恢复后记事状态:'+esc(eventStatus(value))+'。':''}</p><button data-history-restore="${side}">确认恢复并生成新版本</button><button class="quiet" id="history-cancel-restore">取消恢复</button>`;box.scrollIntoView({block:'nearest'});
}
async function restoreFamilyChange(side){
const context=historyContext,c=historySelection;if(!context||!c?.[side])return;
const generation=historyDetailGeneration;$$('#history-confirm button').forEach(b=>b.disabled=true);
try{await api('/api/family/restore',{changeRevision:c.revision,side,revision:context.revision});
if(!loggedIn||context!==historyContext)return;
historyDetailGeneration++;historySelection=null;$('#history-detail').replaceChildren();
await loadFamilyHistory();const loaded=await loadFamily();if(loggedIn)toast(loaded?'已恢复,原有版本仍保留在修改记录中':'已恢复,读取最新家谱失败,请重新载入');
}catch(e){if(!e.stale&&context===historyContext){$('#history-error').textContent=e.message;if(e.status===409)$('#history-error').append(document.createTextNode('。请关闭并重新打开修改记录,核对当前内容后再选择恢复。'));}}
finally{if(generation===historyDetailGeneration)$$('#history-confirm button').forEach(b=>b.disabled=false);}
}
$('#history-more').onclick=()=>loadFamilyHistory(true);
$('#history-dialog').addEventListener('close',clearFamilyHistory);
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
if(b.dataset.historyKind)openFamilyHistory(b.dataset.historyKind,b.dataset.historyId);
if(b.dataset.historyChange)showFamilyChange(Number(b.dataset.historyChange));
if(b.dataset.historyPrepare)prepareFamilyRestore(b.dataset.historyPrepare);
if(b.dataset.historyRestore)restoreFamilyChange(b.dataset.historyRestore);
if(b.id==='history-cancel-restore')$('#history-confirm').hidden=true;
});
+12
View File
@@ -6,3 +6,15 @@
@media(max-width:600px){.family-event-intro{flex-wrap:wrap;padding:8px 15px 15px}.family-event-filters{padding:15px;gap:10px}.family-event-filters label{font-size:11px}.family-event-filters .check{line-height:1.8;flex-wrap:nowrap}.family-timeline{padding:0 15px 22px}.timeline-event{gap:8px}.timeline-event>time{font-size:10px;flex-basis:55px}.timeline-event:before{left:62px}.timeline-event:after{left:58px}.timeline-event-body{padding:13px;margin-left:9px}.timeline-event-body>strong{font-size:15px}.family-view-tabs button{padding:8px 10px}#event-detail-title{font-size:22px}.event-detail-meta{flex-wrap:wrap}.event-editor-actions{flex-wrap:wrap}}
.family-workspace.family-expanded{position:fixed;inset:0;z-index:8;}
/* Versions remain readable on a small screen, including long family narratives. */
#history-dialog{width:min(1040px,94vw)}
#history-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;max-height:260px;overflow:auto;margin:16px 0}
.history-entry{text-align:left;display:flex;flex-direction:column;align-items:flex-start;gap:6px;white-space:normal}
.history-entry small,.history-entry span{color:var(--muted);font-size:12px}
.history-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.history-columns section{min-width:0}.history-columns dd{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto}
.history-columns dl>div{padding:10px;border-bottom:1px solid #30434a}.history-changed{background:#27443e66;border-radius:8px}
#history-confirm{padding:16px;border:1px solid #74d0b7;border-radius:12px;margin-top:18px}
#history-confirm button{margin:6px}#history-error:empty{display:none}
@media(max-width:650px){.history-columns{grid-template-columns:1fr}#history-list{max-height:200px}}
+2 -2
View File
@@ -5,7 +5,7 @@ let family={people:[],links:[],events:[],revision:0,canEdit:false}, users=[], fo
const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
function clearFamilyState(){closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
@@ -40,7 +40,7 @@ function renderFamily(){
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
$('#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)]]:[]),['籍贯 / 出生地',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>'}`;
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>`);
}
+6 -1
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<aside class="sidebar">
@@ -19,7 +21,7 @@
<button data-page="storage">▤ <span>录像与存储</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><button id="logout" class="quiet">退出登录</button>
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
@@ -74,6 +76,9 @@
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
<div id="toast" role="status" hidden></div>
</body>
</html>