Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a77fcc6671 |
@@ -1,5 +1,18 @@
|
||||
# 赵府智家 · 视觉系统
|
||||
|
||||
## v0.1.30 · 日常操作流程与手机网页
|
||||
|
||||
- 家庭或办公、工厂是权限归属;内部区域可分多级。设备提供按区域、类型、负责人三个快捷视图,并保留自定义组合。搜索支持“卧室 灯”等多词组合,涵盖区域、品牌、人员与标签。
|
||||
- 总览补充记事入口;区域管理收进设备页;设备返回列表,家谱与家族记事返回家庭传承。区分个人日常记事与家族重大记事,办公场所不显示家族传承。
|
||||
- 修复编辑后新建残留隐藏记录编号的问题。新建区域可选择新增下级,设备和录像机继承当前选中的区域。
|
||||
- 记事保存后清空本次输入、附件和旧筛选,重新加载并定位保存结果。补记往事也可定位到对应分页;空页自动回到有效页。保存中锁定表单,失败保留草稿;另写、退出或切换家庭前提示未保存输入。
|
||||
- 设置中心分为我的偏好、网盘连接、品牌与外观、数据库备份,后三项仅超级管理员可配置。录像机原有历史录像明确标注待接入,避免查询后才发现不可用。
|
||||
- 手机使用可展开的顶部菜单,选择页面后自动收起。表单单列、输入字号 16px、按钮至少 44px;修复设备弹窗、媒体预览和筛选操作在窄屏的布局。
|
||||
|
||||
验证:138 项 Python 回归、109 项 JavaScript 回归。通过 Edge Computer Use 验证管理员和普通成员、设备查找与编辑后新建、记事保存清空与定位、照片预览和语音播放、草稿取消清空、设置分区、办公场所切换。响应式检查覆盖 360、390、430、768px;手机浏览器引擎、软键盘和真机录音仍需 Android/iOS 实机复核。流程测试使用独立的本地合成数据,未向正式家庭写入测试记事或设备。
|
||||
|
||||
范围:灯、冰箱等仍是设备档案,自动控制需接入实际协议;录像机原有历史录像接口仍待接入。浏览器预览测试不能替代长时间监控稳定性或设备全部厂商功能的验证。
|
||||
|
||||
## v0.1.29 · 记事预览、完整地域与云备份保留
|
||||
|
||||
- 记事照片默认显示,点击放大;语音、视频自动加载播放信息,提供播放、暂停、进度和音量控制,不自动出声。页面原生播放器按需缓冲,图片延迟解码;媒体仍保存在百度网盘,由同源鉴权接口转发,服务器不保存个人媒体文件。
|
||||
|
||||
@@ -71,6 +71,10 @@ class Journal:
|
||||
entries.sort(key=lambda e: (e['occurredOn'], e['createdAt'], e['id']), reverse=True)
|
||||
try: page = max(0, min(100000, int(query.get('page', '0'))))
|
||||
except ValueError: raise a.Problem('页码不正确')
|
||||
if query.get('focus'):
|
||||
index = next((i for i, entry in enumerate(entries) if entry['id'] == query['focus']), None)
|
||||
if index is not None: page = index // 20
|
||||
page = min(page, max(0, (len(entries)-1)//20))
|
||||
media = {m['id']: m for m in a.HOUSEHOLDS.objects(user, 'journal_media')}
|
||||
likes = a.HOUSEHOLDS.objects(user, 'journal_likes')
|
||||
comments = a.HOUSEHOLDS.objects(user, 'journal_comments')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
|
||||
const source=name=>fs.readFileSync('web/'+name,'utf8');
|
||||
function part(file,start,end){const s=source(file);return s.slice(s.indexOf(start),s.indexOf(end,s.indexOf(start)+start.length));}
|
||||
const devices=require('./web/device-model.js');
|
||||
const areas=[{id:'floor',name:'一楼'},{id:'room',name:'卧室',parentId:'floor'}];
|
||||
const lamp={name:'台灯',deviceType:'灯',siteId:'room',brand:'小米',managerName:'爸爸',tags:['阅读']};
|
||||
test('device search combines area, type, brand, responsible person and tags',()=>{
|
||||
for(const q of ['卧室 灯','一楼 小米','爸爸 阅读',' 卧室 台灯 '])assert.ok(devices.matches(lamp,q,areas),q);
|
||||
assert.ok(!devices.matches(lamp,'厨房 灯',areas));assert.ok(devices.matches(lamp,'',areas));
|
||||
});
|
||||
test('grouping views retain devices and their location',()=>{
|
||||
const before=JSON.stringify(lamp);
|
||||
for(const dimensions of [['space'],['type','space'],['manager','type'],['owner','tag']])assert.equal(devices.groups([lamp],dimensions,areas).flatMap(g=>g.items).length,1);
|
||||
assert.equal(JSON.stringify(lamp),before);
|
||||
});
|
||||
test('editor reset clears old identities and revisions after editing',()=>{
|
||||
const fields=[{name:'id',value:'old'},{name:'familyRevision',value:'9'},{name:'revision',value:'3'}];
|
||||
let resets=0;const c=vm.createContext({});vm.runInContext(part('app.js','function resetEditorForm(','function formValues('),c);
|
||||
c.resetEditorForm({reset(){resets++;},querySelectorAll(){return fields;}});
|
||||
assert.equal(resets,1);assert.deepEqual(fields.map(f=>f.value),['','0','0']);
|
||||
});
|
||||
test('back buttons follow place, device and family hierarchy',()=>{
|
||||
const c=vm.createContext({});vm.runInContext(part('steward.js','function scopeReturn(','function selectedDevice('),c);
|
||||
for(const [page,device,target] of [['overview',null,'households'],['live',{},'objects'],['sites',null,'objects'],['family',null,'heritage'],['heritage-records',null,'heritage'],['journal',null,'overview']])assert.equal(c.scopeReturn(page,device).page,target);
|
||||
});
|
||||
function settingsContext(admin){
|
||||
const nodes={};const c=vm.createContext({settingsSection:'preferences',isPlatformAdmin:()=>admin,$:id=>nodes[id]??={},$$:()=>[]});
|
||||
vm.runInContext(part('personalization.js','function showSettingsSection(','document.addEventListener'),c);return {c,nodes};
|
||||
}
|
||||
test('settings show one section at a time',()=>{
|
||||
const {c,nodes}=settingsContext(true);
|
||||
for(const section of ['preferences','baidu','brand','backup']){c.showSettingsSection(section);assert.equal(Object.values(nodes).filter(n=>!n.hidden).length,1);assert.equal(c.settingsSection,section);}
|
||||
});
|
||||
test('members cannot select platform settings',()=>{
|
||||
const {c,nodes}=settingsContext(false);for(const section of ['baidu','brand','backup','unknown']){c.showSettingsSection(section);assert.equal(c.settingsSection,'preferences');assert.equal(nodes['#backup-panel'].hidden,true);}
|
||||
});
|
||||
function journalContext(){
|
||||
const fields=Object.fromEntries(['text','occurredOn','category','visibility'].map(k=>[k,{value:k==='visibility'?'private':''}]));
|
||||
const nodes={'#journal-form':{elements:fields}};
|
||||
const c=vm.createContext({journalSelectedMedia:new Set(),journalBaseline:'',journalUpload:false,journalTracks:null,journalSaving:false,journalPage:3,confirm:()=>false,toast:()=>{},$:id=>nodes[id]??={value:''}});
|
||||
vm.runInContext(part('journal.js','function journalDraftSignature(','const journalId='),c);return {c,fields,nodes};
|
||||
}
|
||||
test('saved entry filter reset clears searches and dates but retains privacy',()=>{
|
||||
const {c,fields,nodes}=journalContext();for(const id of ['journal-search','journal-category','journal-from','journal-to','journal-person'])c.$('#'+id).value='old';
|
||||
c.resetJournalFilters('mine');assert.equal(c.journalPage,0);assert.equal(nodes['#journal-search'].value,'');assert.equal(nodes['#journal-person-label'].hidden,true);assert.equal(fields.visibility.value,'private');
|
||||
});
|
||||
test('dirty drafts and pending saves are protected',()=>{
|
||||
const {c,fields}=journalContext();c.journalBaseline=c.journalDraftSignature();assert.equal(c.journalHasDraft(),false);
|
||||
fields.text.value='未保存';assert.equal(c.journalHasDraft(),true);assert.equal(c.confirmJournalDiscard(),false);
|
||||
c.confirm=()=>true;assert.equal(c.confirmJournalDiscard(),true);c.journalSaving=true;assert.equal(c.confirmJournalDiscard(),false);
|
||||
});
|
||||
test('empty feed explains private visibility and filtered emptiness',()=>{
|
||||
const {c}=journalContext();c.$('#journal-mode').value='feed';assert.match(c.journalEmptyMessage(),/仅自己可见/);
|
||||
c.$('#journal-search').value='missing';assert.match(c.journalEmptyMessage(),/清除筛选/);
|
||||
});
|
||||
|
||||
test('mobile navigation toggles an accessible control without changing scope',()=>{
|
||||
let opened=false;const button={setAttribute(k,v){this[k]=v;},textContent:''};
|
||||
const c=vm.createContext({$:id=>id==='.sidebar'?{classList:{toggle(k,v){opened=v;}}}:button});
|
||||
vm.runInContext(part('app.js','function setMobileMenu(',"$('#mobile-nav-toggle').onclick"),c);
|
||||
c.setMobileMenu(true);assert.equal(opened,true);assert.equal(button['aria-expanded'],'true');
|
||||
c.setMobileMenu(false);assert.equal(opened,false);assert.equal(button['aria-expanded'],'false');
|
||||
});
|
||||
test('mobile forms use readable inputs, touch targets and bounded dialogs',()=>{
|
||||
const css=source('chinese-theme.css'),html=source('index.html');
|
||||
assert.match(css,/font-size:16px!important;min-height:44px/);
|
||||
assert.match(css,/max-height:90dvh/);assert.match(css,/sidebar:not\(\.mobile-open\)>nav/);
|
||||
assert.match(html,/aria-controls="main-navigation"/);
|
||||
});
|
||||
+1
-1
@@ -31,7 +31,7 @@ test('successful save clears draft attachment ids, stops recorder, and reloads f
|
||||
const js=fs.readFileSync('web/journal.js','utf8');
|
||||
const fn=js.slice(js.indexOf('function newJournal'),js.indexOf('async function loadJournal'));
|
||||
assert.match(fn,/journalDraftMedia.clear\(\)/);assert.match(fn,/journalSelectedMedia.clear\(\)/);assert.match(fn,/stopJournalVoice\(\)/);assert.match(fn,/journal-library'\).open=false/);
|
||||
assert.match(js,/newJournal\(\);\$\('#journal-mode'\).value='mine';journalPage=0;await loadJournal\(\)/);
|
||||
assert.match(js,/newJournal\(\);resetJournalFilters\('mine'\);await loadJournal\(saved.id\)/);
|
||||
assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/);
|
||||
});
|
||||
test('attachment preview uses a closable dialog and deletion has a restore path',()=>{
|
||||
|
||||
@@ -58,6 +58,25 @@ class JournalTests(unittest.TestCase):
|
||||
self.assertEqual(self.request('/api/journal', self.actor, dict(old,text='修改'))[0], 200)
|
||||
self.assertEqual(self.request('/api/journal', self.actor, old)[0], 409)
|
||||
|
||||
def test_saved_backdated_entry_focus_and_page_clamp(self):
|
||||
for _ in range(21): self.saved()
|
||||
body = self.note(); body['occurredOn'] = '2000-01-01'
|
||||
key = app.JOURNAL.save(self.actor, body)['id']
|
||||
focused = app.JOURNAL.snapshot(self.actor, dict(mode='mine', focus=key))
|
||||
self.assertEqual(focused['page'], 1)
|
||||
self.assertIn(key, [e['id'] for e in focused['items']])
|
||||
self.assertEqual(app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99'))['page'], 1)
|
||||
empty = app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99', search='不存在的关键词'))
|
||||
self.assertEqual(empty['page'], 0)
|
||||
self.assertEqual(empty['items'], [])
|
||||
|
||||
def test_focus_does_not_reveal_private_or_foreign_entries(self):
|
||||
private = self.saved()
|
||||
for user in (self.owner, self.root, self.other):
|
||||
result = app.JOURNAL.snapshot(user, dict(mode='feed', focus=private))
|
||||
self.assertEqual(result['total'], 0)
|
||||
self.assertEqual(result['items'], [])
|
||||
|
||||
def test_like_set_is_idempotent_and_privacy_change_removes_all_interactions(self):
|
||||
key = self.saved(visibility='household'); data = dict(id=key,action='like',liked=True)
|
||||
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0], 200)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ const test=require('node:test'),assert=require('node:assert/strict'),fs=require(
|
||||
const code=fs.readFileSync('web/steward.js','utf8'),html=fs.readFileSync('web/index.html','utf8'),app=fs.readFileSync('web/app.js','utf8');
|
||||
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
|
||||
test('navigation starts at places and contains no global camera functions',()=>{
|
||||
const nav=html.match(/<nav aria-label="主导航">([\s\S]*?)<\/nav>/)[1];
|
||||
const nav=html.match(/<nav[^>]*aria-label="主导航">([\s\S]*?)<\/nav>/)[1];
|
||||
assert.doesNotMatch(nav,/data-page="(?:live|playback)"/);
|
||||
assert.match(nav,/scope-navigation/);assert.match(app,/activePage = 'households'/);
|
||||
assert.match(html,/aria-label="设备功能"/);
|
||||
|
||||
+23
-12
@@ -58,25 +58,28 @@ function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendant
|
||||
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
|
||||
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
|
||||
function showPage(page, options={}) {
|
||||
if(typeof journalSaving!=='undefined'&&journalSaving&&page!==activePage){toast('记事正在保存,请稍候。');return;}
|
||||
setMobileMenu(false);
|
||||
if(typeof leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal();
|
||||
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
|
||||
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings','journal'].includes(page))return;
|
||||
if(['heritage','family','heritage-records'].includes(page)&¤tHome()?.kind&¤tHome().kind!=='home')return;
|
||||
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings','journal'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
|
||||
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page);
|
||||
if(typeof scopeEntered!=='undefined'){if(page==='households')scopeEntered=false;else if(!['storage','settings'].includes(page))scopeEntered=true;}
|
||||
if(page==='storage'&&!isPlatformAdmin())return;
|
||||
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
|
||||
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
|
||||
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
|
||||
activePage = page;
|
||||
const pageChanged=activePage!==page;activePage = page;
|
||||
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal'].includes(page);
|
||||
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
|
||||
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['sites','device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
|
||||
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
|
||||
if (page !== 'playback') resetPlayback();
|
||||
render();
|
||||
if(page==='playback')$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';
|
||||
if(pageChanged)window.scrollTo({top:0,left:0,behavior:'instant'});
|
||||
if(page==='playback'){$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';playbackSourceHint();}
|
||||
if (page === 'storage') loadAudit();
|
||||
if (page === 'family') loadFamily(!options.keepFamilyView);
|
||||
if (page === 'users') loadUsers();
|
||||
@@ -115,7 +118,7 @@ function renderLive() {
|
||||
const key = (currentUser?.role||'')+':'+(state.familyId||'')+':'+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-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-view-device="${c.assetId}" data-device-source="camera">摄像头详情</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-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-view-device="${c.assetId}" data-device-source="camera">摄像头详情</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-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`));
|
||||
@@ -141,7 +144,7 @@ function renderCameras() {
|
||||
}
|
||||
function renderSites() {
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><button class="quiet" data-child-site="${esc(s.id)}">+ 新增下级区域</button><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
}
|
||||
function renderStorage() {
|
||||
const s=state.storage;
|
||||
@@ -151,8 +154,16 @@ function renderStorage() {
|
||||
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,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}if(typeof onAppearanceSession==='function')onAppearanceSession(s.preferences);}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
|
||||
function openSite(id) {const f=$('#site-form');f.reset();f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);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 openSite(id,parentId='') {const f=$('#site-form');resetEditorForm(f);f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(!item){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}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');resetEditorForm(f);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 setMobileMenu(open){$('.sidebar').classList.toggle('mobile-open',open);$('#mobile-nav-toggle').setAttribute('aria-expanded',String(open));$('#mobile-nav-toggle').textContent=open?'收起菜单':'☰ 菜单';}
|
||||
$('#mobile-nav-toggle').onclick=()=>setMobileMenu($('#mobile-nav-toggle').getAttribute('aria-expanded')!=='true');
|
||||
document.addEventListener('keydown',event=>{if(event.key==='Escape'&&$('#mobile-nav-toggle').getAttribute('aria-expanded')==='true'){setMobileMenu(false);$('#mobile-nav-toggle').focus();}});
|
||||
function resetEditorForm(form){
|
||||
form.reset();
|
||||
// Hidden input values become reset defaults in browsers; always clear record identity explicitly.
|
||||
for(const field of form.querySelectorAll('input[type="hidden"]'))field.value=field.name.toLowerCase().includes('revision')?'0':'';
|
||||
}
|
||||
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
||||
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
|
||||
handleForm('site-form','/api/sites');
|
||||
@@ -160,8 +171,8 @@ handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enable
|
||||
handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit);
|
||||
$('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
|
||||
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera'){openCamera();if(deviceContext?.source==='camera'){$('#camera-form').elements.assetId.value=deviceContext.id;syncEndpoint();}}if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){const c=state.cameras.find(c=>c.id===b.dataset.playCamera);if(c)enterDevice('camera',c.assetId,'playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();};
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera'){openCamera();if(deviceContext?.source==='camera'){$('#camera-form').elements.assetId.value=deviceContext.id;syncEndpoint();}}if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.childSite)openSite('',b.dataset.childSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){const c=state.cameras.find(c=>c.id===b.dataset.playCamera);if(c)enterDevice('camera',c.assetId,'playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||
$('#logout').onclick=async()=>{if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return;await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();};
|
||||
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();};
|
||||
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
|
||||
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
|
||||
@@ -174,8 +185,8 @@ function renderRecorders(){
|
||||
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
|
||||
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
|
||||
}
|
||||
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(!a&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
|
||||
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
|
||||
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(!a&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
|
||||
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(!r&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
|
||||
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
|
||||
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
|
||||
handleForm('asset-form','/api/assets',d=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(d),port:Number(d.port)}));
|
||||
|
||||
@@ -319,3 +319,25 @@ html[data-theme=ink] body{background-image:radial-gradient(ellipse at 90% 0,#263
|
||||
.journal-preview .journal-photo img{max-height:65vh;min-height:0;aspect-ratio:auto}.journal-preview .journal-photo{pointer-events:none}.journal-preview .journal-photo-hint{display:none}.journal-preview video{max-height:65vh}
|
||||
html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink] .journal-audio{background:#293f37;border-color:#436152}html[data-theme=ink] .journal-audio-label{color:#d5e2ce}
|
||||
@media(max-width:600px){.journal-upload-grid{grid-template-columns:1fr}.journal-preview{padding:16px}.journal-preview h2{font-size:17px}.journal-preview header button{flex-shrink:0}.journal-media{grid-template-columns:1fr}}
|
||||
/* Compact mobile navigation and touch-friendly forms. */
|
||||
.mobile-nav-toggle{display:none}
|
||||
@media(max-width:760px){
|
||||
.sidebar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:14px 16px;height:auto;min-height:0}
|
||||
.sidebar .brand{min-width:0;padding:0;gap:10px}.sidebar .brand>span{min-width:0}.sidebar #brand-name{font-size:23px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar .brand-icon{width:38px;height:44px}.sidebar #brand-subtitle{font-size:11px;letter-spacing:1px}
|
||||
.sidebar .mobile-nav-toggle{display:block;margin:0;min-height:44px;color:#f7f3e5;border-color:#8faaa0;padding:9px 14px}
|
||||
.sidebar:not(.mobile-open)>nav,.sidebar:not(.mobile-open)>#current-user,.sidebar:not(.mobile-open)>#account-password,.sidebar:not(.mobile-open)>#logout{display:none}
|
||||
.sidebar>nav,.sidebar>#current-user{grid-column:1/-1}.sidebar.mobile-open>nav{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:0;padding:8px 0 0}.sidebar #scope-navigation{display:contents}.sidebar #scope-navigation[hidden]{display:none}.sidebar nav button{width:100%;text-align:left;min-height:44px;white-space:normal;margin:0;font-size:14px}
|
||||
.sidebar.mobile-open>#current-user{margin:0;padding:12px 0 0}.sidebar.mobile-open>#account-password,.sidebar.mobile-open>#logout{display:block;min-height:44px;margin:0}
|
||||
main{padding:22px 16px}main>header{margin-bottom:22px}.header-status #clock{display:none}
|
||||
input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{font-size:16px!important;min-height:44px}button,.button{min-height:44px;touch-action:manipulation}
|
||||
.form-grid{grid-template-columns:1fr}.form-grid>*{min-width:0}.form-grid .wide{grid-column:1/-1}
|
||||
.section-head{flex-wrap:wrap;gap:12px}.section-head>div{max-width:100%}.section-head button{white-space:normal}
|
||||
.household-context{gap:10px;padding:12px}.household-context label{width:100%;display:flex;flex-wrap:wrap}.household-context select{flex:1;min-width:0}.household-context #household-context-note{display:none}
|
||||
.filter-row{gap:10px;flex-wrap:wrap}.filter-row label{width:100%;display:grid;grid-template-columns:1fr}.filter-row select{width:100%}
|
||||
.settings-sections,.view-presets{gap:8px}.settings-sections button,.view-presets button{flex:1 1 100px;white-space:normal}
|
||||
.device-actions,.device-search-row{gap:10px}.device-search-row{display:grid;grid-template-columns:1fr}
|
||||
dialog{width:calc(100% - 24px);max-width:calc(100% - 24px);max-height:90vh;max-height:90dvh;padding:18px;overscroll-behavior:contain}dialog header,dialog .dialog-heading{gap:12px;align-items:flex-start}dialog h2{font-size:22px}dialog footer{flex-wrap:wrap}
|
||||
.journal-compose,.journal-entry{padding:18px}.journal-filters{gap:12px}.journal-filters label{flex-basis:100%;min-width:0}.journal-add-media input{max-width:100%;width:100%}.journal-add-media>label{width:100%}.journal-preview{padding:16px}.journal-preview .journal-photo img,.journal-preview video{max-height:60vh;max-height:60dvh}.journal-upload-card{padding:12px}.journal-media audio{min-width:0}.journal-comments form label{min-width:0;flex-basis:100%}
|
||||
.device-context [role=group]{display:flex;flex-wrap:wrap;gap:8px}.device-context button{flex:1 1 42%;white-space:normal}.metrics{margin-bottom:20px}
|
||||
}
|
||||
@media(max-width:760px){.sidebar .brand{grid-column:1;max-width:100%;width:auto}.sidebar>.mobile-nav-toggle{grid-column:2;grid-row:1;width:auto;max-width:110px}.sidebar .brand-icon{flex-shrink:0}.sidebar .brand>span{overflow:hidden}.sidebar #brand-name{font-size:21px}}
|
||||
|
||||
+6
-1
@@ -13,6 +13,11 @@
|
||||
...(state.assets||[]).map(x=>({...x,source:'camera',deviceType:'摄像机'})),
|
||||
...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
|
||||
...(state.devices||[]).map(x=>({...x,source:'device'}))];}
|
||||
function matches(device,query,sites){
|
||||
const words=String(query||'').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const text=[device.name,device.deviceType,device.brand,device.model,device.serial,device.ownerName,device.managerName,...(device.tags||[]),...path(sites,device.siteId).map(s=>s.name)].join(' ').toLowerCase();
|
||||
return words.every(word=>text.includes(word));
|
||||
}
|
||||
function groups(devices,dimensions,sites){
|
||||
const result=new Map();
|
||||
for(const device of devices){
|
||||
@@ -31,5 +36,5 @@
|
||||
}
|
||||
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
|
||||
}
|
||||
return {path,descendants,catalog,groups};
|
||||
return {path,descendants,catalog,groups,matches};
|
||||
});
|
||||
|
||||
+7
-2
@@ -3,6 +3,7 @@ let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
|
||||
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
|
||||
function renderDeviceGrouping(){
|
||||
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
|
||||
$$('[data-group-preset]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.groupPreset===deviceGroups.join(','))));
|
||||
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
|
||||
}
|
||||
function deviceCard(d){
|
||||
@@ -15,12 +16,13 @@ function renderDevices(){
|
||||
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
|
||||
const signature=JSON.stringify(saved);
|
||||
if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
|
||||
$('#device-clear').hidden=!($('#site-filter').value||$('#device-type').value||$('#device-search').value);
|
||||
const search=$('#device-search').value.trim().toLowerCase();
|
||||
const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
|
||||
setOptions($('#device-type'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).join(''));
|
||||
const category=$('#device-type').value;
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;
|
||||
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
|
||||
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&ZhaoDevices.matches(d,search,state.sites));
|
||||
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
|
||||
$('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
|
||||
}
|
||||
@@ -29,12 +31,14 @@ function responsibilityFields(form,item){for(const key of ['ownerName','managerN
|
||||
function deviceValues(data){return {...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};}
|
||||
function openDevice(id){
|
||||
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
|
||||
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
|
||||
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';responsibilityFields(f,item);$('#device-dialog').showModal();
|
||||
}
|
||||
function spaceOptions(exclude=''){
|
||||
const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
|
||||
return state.sites.filter(s=>!omitted.has(s.id)).map(s=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).join('');
|
||||
}
|
||||
function clearDeviceFilters(){$('#site-filter').value='';$('#device-type').value='';$('#device-search').value='';renderDevices();}
|
||||
$('#device-clear').onclick=clearDeviceFilters;
|
||||
$('#device-search').oninput=renderDevices;$('#device-type').onchange=renderDevices;
|
||||
$('#device-group-order').addEventListener('change',e=>{if(e.target.dataset.groupIndex!==undefined){deviceGroups[Number(e.target.dataset.groupIndex)]=e.target.value;renderDeviceGrouping();renderDevices();}});
|
||||
$('#device-group-add').onclick=()=>{const next=Object.keys(deviceDimensions).find(k=>!deviceGroups.includes(k));if(next){deviceGroups.push(next);renderDeviceGrouping();renderDevices();}};
|
||||
@@ -43,6 +47,7 @@ $('#new-device').onclick=()=>openDevice();
|
||||
handleForm('device-form','/api/devices',deviceValues);
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.groupPreset){deviceGroups=b.dataset.groupPreset.split(',');renderDeviceGrouping();renderDevices();}
|
||||
if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
|
||||
if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
|
||||
if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ function personLocation(p,prefix){return ['Country','Province','City','District'
|
||||
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 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){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){
|
||||
if(error.stale)return;
|
||||
const box=form.querySelector('.form-error');box.textContent=error.message;
|
||||
|
||||
+7
-5
@@ -11,12 +11,14 @@ function renderHouseholds(){
|
||||
$('#household-list').innerHTML=[...groups].sort(([a],[b])=>a.localeCompare(b,'zh-CN')).map(([city,rows])=>`<section class="home-city-group"><h3>${esc(city)} <small>${rows.length} 个家庭或场所</small></h3><div class="site-grid">${rows.map(cards).join('')}</div></section>`).join('');
|
||||
}
|
||||
async function switchHousehold(id){
|
||||
if(!isPlatformAdmin()||!id||id===state.familyId)return;
|
||||
if(id===state.familyId)return true;
|
||||
if(!isPlatformAdmin()||!id)return false;
|
||||
if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return false;
|
||||
const user=currentUser;
|
||||
// Invalidate outstanding API responses, video players and dialogs before changing scope.
|
||||
clearPrivateView();
|
||||
householdScope=id;currentUser=user;loggedIn=true;
|
||||
await auth();
|
||||
await auth();return loggedIn;
|
||||
}
|
||||
function openHousehold(id){
|
||||
if(!isAdmin())return;
|
||||
@@ -26,7 +28,7 @@ function openHousehold(id){
|
||||
}
|
||||
async function openLeadership(id){
|
||||
try{
|
||||
if(id!==state.familyId)await switchHousehold(id);
|
||||
if(id!==state.familyId&&!await switchHousehold(id))return;
|
||||
const home=(state.households||[]).find(h=>h.id===id);
|
||||
if(!home?.canAssignOwner)return;
|
||||
const result=await api('/api/users');
|
||||
@@ -43,7 +45,7 @@ $('#leadership-form').addEventListener('submit',async e=>{
|
||||
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
|
||||
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
|
||||
});
|
||||
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
|
||||
$('#household-filter').onchange=async e=>{const switched=await switchHousehold(e.target.value);if(switched&&loggedIn)showPage('overview');else e.target.value=state.familyId;};
|
||||
$('#new-household').onclick=()=>openHousehold();
|
||||
$('#household-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(imageJobPending('cover')){$('#household-cover-status').textContent='照片仍在读取,请完成后保存。';return;}const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
@@ -55,7 +57,7 @@ document.addEventListener('click',async e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.editHousehold)openHousehold(b.dataset.editHousehold);
|
||||
if(b.dataset.leadership)await openLeadership(b.dataset.leadership);
|
||||
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');}
|
||||
if(b.dataset.enterHousehold){const switched=await switchHousehold(b.dataset.enterHousehold);if(switched&&loggedIn)showPage('overview');}
|
||||
});
|
||||
|
||||
$('#household-cover-file').onchange=async e=>{const job=nextImageJob('cover'),generation=sessionGeneration;imageJobs.get('cover').pending=true;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){if(generation===sessionGeneration&&imageJobCurrent('cover',job))$('#household-cover-status').textContent=e.message;}finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}};
|
||||
|
||||
+8
-8
@@ -12,8 +12,9 @@
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/"><img id="brand-image" class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span><span id="brand-name">赵府</span><small id="brand-subtitle">智能数字管家</small></span></a>
|
||||
<button id="mobile-nav-toggle" class="quiet mobile-nav-toggle" aria-expanded="false" aria-controls="main-navigation" type="button">☰ 菜单</button>
|
||||
<div class="nav-label">家宅 · 起居</div>
|
||||
<nav aria-label="主导航">
|
||||
<nav id="main-navigation" aria-label="主导航">
|
||||
<button data-page="households" class="active">⌂ <span>我的家庭与场所</span></button>
|
||||
<div id="scope-navigation" hidden><div id="scope-nav-name" class="nav-label"></div>
|
||||
<button data-page="journal">✎ <span>记事与阅历</span></button>
|
||||
@@ -21,7 +22,6 @@
|
||||
<button data-page="objects">▣ <span>区域与设备</span></button>
|
||||
<button data-page="heritage">♧ <span>家庭传承</span></button>
|
||||
<button data-page="users" data-admin>♙ <span>成员与权限</span></button>
|
||||
<button data-page="sites">⌂ <span>区域设置</span></button>
|
||||
</div>
|
||||
<button data-page="settings">⚙ <span>设置中心</span></button>
|
||||
<button data-page="storage">▤ <span>平台存储与审计</span></button>
|
||||
@@ -36,8 +36,8 @@
|
||||
<section class="metrics"><div><span>设备</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
|
||||
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">可选择任意区域,包含其下级区域</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
|
||||
<section id="page-settings" class="page" hidden>
|
||||
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p>
|
||||
<form id="preferences-form" class="panel"><h3>我的后台</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
|
||||
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p><div class="settings-sections" role="group" aria-label="设置分类"><button class="quiet" data-settings-section="preferences" aria-pressed="true">我的偏好</button><button class="quiet" data-settings-section="baidu" data-platform-setting hidden>网盘连接</button><button class="quiet" data-settings-section="brand" data-platform-setting hidden>品牌与外观</button><button class="quiet" data-settings-section="backup" data-platform-setting hidden>数据库备份</button></div>
|
||||
<form id="preferences-form" class="panel"><h3>我的偏好</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
|
||||
<section id="baidu-admin" class="panel" hidden><div class="section-head"><h3>百度网盘 · 统一存储</h3><span class="badge">仅超级管理员配置</span></div><p class="description">连接一个网盘账号,保存各用户上传的照片、视频和语音。系统按用户、家庭和记事可见范围授权;网盘账号持有人本身可以管理网盘中的文件。服务器仅转发分片,不保存媒体原文件。</p><form id="baidu-config"><div class="form-grid"><label>AppKey<input name="appKey" autocomplete="off" maxlength="150" required></label><label>SecretKey<input name="secretKey" type="password" autocomplete="new-password" maxlength="150"></label><label>百度后台应用名称<input name="appFolder" value="智家" required maxlength="40"></label><label>OAuth 回调地址<input name="redirectUri" type="url" placeholder="https://你的域名/api/baidu/callback" required maxlength="400"></label></div><p class="description">回调地址须与百度后台完全一致。SecretKey 留空保持已保存值,SignKey 不需要填写。当前开发者应用仅限个人场景,适用范围以百度后台为准。</p><p id="baidu-config-status" role="status"></p><button type="submit">保存网盘应用配置</button></form><hr><p id="baidu-status" role="status"></p><div class="device-actions"><button id="baidu-connect">连接/重新授权百度网盘</button><button id="baidu-disconnect" class="quiet">解除本机连接</button></div><p class="description">解除连接不会删除云端文件。临时媒体地址由后台按需获取,令牌不发送给浏览器。网盘授权凭据单独保存在服务器受限目录,不包含在数据库备份或公开源码中。</p></section>
|
||||
<div id="settings-private" hidden>
|
||||
<form id="brand-form" class="panel"><h3>发布与品牌配置</h3><p class="description">仅超级管理员可修改。名称、Logo、浏览器图标及登录文案会公开显示;适用于整套系统。已有个人偏好优先于平台默认。</p>
|
||||
@@ -45,26 +45,26 @@
|
||||
<div class="brand-image-grid"><div><img id="brand-logo-preview" alt="Logo 预览" src="/zhao-icon.png"><label>更换系统 Logo<input id="brand-logo-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-logo-reset" class="quiet">使用默认 Logo</button></div><div><img id="brand-icon-preview" alt="浏览器图标预览" src="/zhao-icon.png"><label>更换浏览器图标<input id="brand-icon-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-icon-reset" class="quiet">使用默认图标</button></div></div><p class="description">支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。</p>
|
||||
<h3>新账号与跟随平台账号的默认外观</h3><div id="brand-defaults" class="form-grid"></div><p id="brand-status" role="status"></p><div class="device-actions"><button type="submit">保存发布配置</button><button type="button" id="brand-export" class="quiet">导出发布配置</button><label class="config-import">导入配置到表单<input id="brand-import" type="file" accept="application/json,.json"></label></div><p class="description">导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。</p>
|
||||
</form>
|
||||
<section class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label class="check full"><input name="baidu" type="checkbox">同时上传到已连接的百度网盘</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>本机/挂载目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度网盘保留最近一个自然月;新备份上传并校验成功后才清理本应用的过期副本。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
|
||||
<section id="backup-panel" class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label class="check full"><input name="baidu" type="checkbox">同时上传到已连接的百度网盘</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>本机/挂载目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度网盘保留最近一个自然月;新备份上传并校验成功后才清理本应用的过期副本。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
|
||||
</div>
|
||||
</section>
|
||||
<section id="page-journal" class="page" hidden>
|
||||
<div class="scope-intro"><span class="eyebrow">岁月有迹 · 日常成章</span><h2>把生活,写成自己的来时路</h2><p>与家人分享近况,也为自己留下一份阅历。</p></div>
|
||||
<form id="journal-form" class="panel journal-compose"><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="clientId"><label>此刻想记下什么?<textarea name="text" rows="4" maxlength="5000" placeholder="今天的心情、一段往事,或一个值得记住的时刻…"></textarea></label><div class="form-grid"><label>记事日期<input name="occurredOn" type="date" required></label><label>阅历分类<select name="category"><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>谁可以看到<select name="visibility"><option value="private">仅自己可见</option><option value="household">当前家庭/场所成员</option></select></label></div><div class="journal-add-media"><label class="quiet">+ 照片、视频或语音<input id="journal-files" disabled type="file" accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,audio/mpeg,audio/mp4,audio/aac,audio/wav,audio/ogg,audio/webm,.m4a"></label><button id="journal-record" disabled type="button" class="quiet">● 直接录音</button><button id="journal-stop-record" type="button" hidden>结束录音并上传</button><button id="journal-cancel-upload" type="button" class="quiet" hidden>暂停上传</button></div><p id="journal-cloud-note" class="description"></p><p class="description">每条最多 9 个附件,单个文件最多 256 MB;一次上传一个文件。录音须通过 HTTPS 并允许麦克风,最长 5 分钟。上传完成后默认选中。保存后清空本次输入;再次使用可展开“我的附件库”。</p><div id="journal-upload-list" class="journal-upload-grid"></div><details id="journal-library" class="journal-library"><summary>我的附件库(<span id="journal-library-count">0</span>)</summary><p class="description">已上传的附件可预览、删除或再次用于记事。</p><div id="journal-library-list" class="journal-upload-grid"></div></details><details id="journal-trash" class="journal-trash" hidden><summary>已删除附件(<span id="journal-trash-count">0</span>)</summary><p class="description">删除仅整理附件库,已发布的记事与网盘原文件保留。恢复后可再次使用。</p><div id="journal-trash-list"></div></details><p id="journal-status" role="status" aria-live="polite"></p><div class="device-actions"><button id="journal-save" type="submit">保存记事</button><button id="journal-new" type="button" class="quiet">另写一条</button></div></form>
|
||||
<form id="journal-filter" class="panel journal-filters"><label>查看方式<select id="journal-mode"><option value="feed">家庭/场所动态</option><option value="mine">我的个人阅历</option><option value="person">某个人的阅历</option></select></label><label id="journal-person-label" hidden>人物<select id="journal-person"></select></label><label>分类<select id="journal-category"><option value="">全部分类</option><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>关键词<input id="journal-search" type="search" maxlength="120" placeholder="在文字中查找"></label><label>开始日期<input id="journal-from" type="date"></label><label>结束日期<input id="journal-to" type="date"></label><button type="submit">查询阅历</button></form><p class="description">个人阅历保留同一条记事的文字、附件和互动;查看他人时只展示其分享给当前家庭/场所的内容。删除记事或移出附件列表不会删除网盘原文件。</p><div class="section-head"><p id="journal-query-status" role="status"></p><div><button id="journal-prev" class="quiet">上一页</button><button id="journal-next" class="quiet">下一页</button></div></div><div id="journal-list" class="journal-feed"></div>
|
||||
<form id="journal-filter" class="panel journal-filters"><label>查看方式<select id="journal-mode"><option value="feed">家庭/场所动态</option><option value="mine">我的个人阅历</option><option value="person">某个人的阅历</option></select></label><label id="journal-person-label" hidden>人物<select id="journal-person"></select></label><label>分类<select id="journal-category"><option value="">全部分类</option><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>关键词<input id="journal-search" type="search" maxlength="120" placeholder="在文字中查找"></label><label>开始日期<input id="journal-from" type="date"></label><label>结束日期<input id="journal-to" type="date"></label><button type="submit">查询阅历</button><button id="journal-filter-clear" type="button" class="quiet">清除筛选</button></form><p class="description">个人阅历保留同一条记事的文字、附件和互动;查看他人时只展示其分享给当前家庭/场所的内容。删除记事或移出附件列表不会删除网盘原文件。</p><div class="section-head"><p id="journal-query-status" role="status"></p><div><button id="journal-prev" class="quiet">上一页</button><button id="journal-next" class="quiet">下一页</button></div></div><div id="journal-list" class="journal-feed"></div>
|
||||
</section>
|
||||
|
||||
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
|
||||
<section id="page-playback" class="page" hidden>
|
||||
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
|
||||
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
|
||||
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像(待接入)</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
|
||||
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
|
||||
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
|
||||
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
|
||||
</section>
|
||||
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="名称、负责人、所有人、标签"></label></div><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
|
||||
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
|
||||
+41
-15
@@ -2,6 +2,21 @@
|
||||
pageInfo.journal=['个人记事','一言一照,都是阅历。私藏或分享,由自己决定。'];
|
||||
let journalGeneration=0,journalData=null,journalPage=0,journalUpload=false,journalRequest=null,journalRecorder=null,journalTracks=null,journalVoice=[],journalVoiceTimer=null,journalConfigRevision=0;
|
||||
const journalDraftMedia=new Set(),journalSelectedMedia=new Set();
|
||||
let journalBaseline='',journalSaving=false;
|
||||
function journalDraftSignature(){const f=$('#journal-form');return JSON.stringify([f.elements.text.value,f.elements.occurredOn.value,f.elements.category.value,f.elements.visibility.value,[...journalSelectedMedia].sort()]);}
|
||||
function journalHasDraft(){return journalUpload||!!journalTracks||!!journalBaseline&&journalDraftSignature()!==journalBaseline;}
|
||||
function confirmJournalDiscard(){if(journalSaving){toast('记事正在保存,请稍候。');return false;}return !journalHasDraft()||confirm('当前记事尚未保存,确定放弃本次输入吗?已上传的附件仍保留在附件库中。');}
|
||||
function resetJournalFilters(mode=$('#journal-mode').value){
|
||||
for(const id of ['journal-category','journal-search','journal-from','journal-to'])$('#'+id).value='';
|
||||
$('#journal-mode').value=mode;if(mode!=='person')$('#journal-person').value='';
|
||||
$('#journal-person-label').hidden=mode!=='person';journalPage=0;
|
||||
}
|
||||
function journalEmptyMessage(){
|
||||
if(['journal-category','journal-search','journal-from','journal-to'].some(id=>$('#'+id).value))return '没有符合筛选条件的记事,可以点击“清除筛选”再查看。';
|
||||
if($('#journal-mode').value==='feed')return '还没有分享给当前家庭/场所的动态。仅自己可见的记事,请切换到“我的个人阅历”。';
|
||||
if($('#journal-mode').value==='person')return $('#journal-person').value?'此人暂无分享给当前家庭/场所的记事。':'请先选择要查看的人物。';
|
||||
return '还没有个人记事,写下今天,也可以补记从前。';
|
||||
}
|
||||
const journalId=()=>[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const journalToday=()=>{const d=new Date();return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');};
|
||||
function journalBusy(value){journalUpload=value;$('#journal-save').disabled=value;$('#journal-new').disabled=value;for(const control of $('#journal-filter').elements)control.disabled=value;$('#journal-prev').disabled=value||!journalData?.page;$('#journal-next').disabled=value||!journalData||(journalData.page+1)*20>=journalData.total;$('#journal-record').disabled=value||!journalData?.connection.connected||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;}
|
||||
@@ -54,23 +69,24 @@ function leaveJournal(){
|
||||
$('#journal-list').replaceChildren();$('#journal-files').disabled=false;$('#journal-save').disabled=false;
|
||||
}
|
||||
function clearJournalForm(){const f=$('#journal-form');f.reset();for(const key of ['id','revision','clientId'])f.elements[key].value='';}
|
||||
function resetJournal(){leaveJournal();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
|
||||
function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();}
|
||||
async function loadJournal(){
|
||||
const g=++journalGeneration;if(!loggedIn)return;
|
||||
function resetJournal(){leaveJournal();journalBaseline='';journalSaving=false;journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
|
||||
function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();journalBaseline=journalDraftSignature();}
|
||||
async function loadJournal(focus=''){
|
||||
if(journalSaving)return;const g=++journalGeneration;if(!loggedIn)return;
|
||||
if(!$('#journal-form').elements.clientId.value)newJournal();
|
||||
$('#journal-person-label').hidden=$('#journal-mode').value!=='person';
|
||||
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage)});
|
||||
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage),focus});
|
||||
$('#journal-query-status').textContent='正在读取记事…';
|
||||
try{
|
||||
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;
|
||||
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;journalPage=result.page;
|
||||
const selected=$('#journal-person').value;$('#journal-person').innerHTML='<option value="">请选择人物</option>'+Object.entries(result.people).map(([id,name])=>`<option value="${esc(id)}">${esc(name)}</option>`).join('');$('#journal-person').value=selected;
|
||||
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:'暂时没有记事。写下今天,也可以补记从前。';
|
||||
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:journalEmptyMessage();
|
||||
$('#journal-prev').disabled=!result.page;$('#journal-next').disabled=(result.page+1)*20>=result.total;
|
||||
$('#journal-cloud-note').textContent=result.connection.connected&&!result.connection.reconnect?'附件保存到后台统一连接的百度网盘;可见范围由系统控制。':'文字可直接保存。照片、视频、语音需由超级管理员先连接百度网盘。';
|
||||
$('#journal-files').disabled=journalUpload||!result.connection.connected||result.connection.reconnect;
|
||||
$('#journal-record').disabled=$('#journal-files').disabled||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;
|
||||
renderJournalUploads();renderJournalEntries();
|
||||
if(focus){const card=$$('#journal-list [data-entry]').find(el=>el.dataset.entry===focus);if(card){card.classList.add('just-saved');card.scrollIntoView({behavior:'smooth',block:'center'});}}
|
||||
}catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;}
|
||||
}
|
||||
function journalUploadCard(m,library=false){
|
||||
@@ -99,17 +115,23 @@ function renderJournalEntries(){
|
||||
}
|
||||
function journalComment(entry,c){return `<p class="journal-comment"><strong>${esc(c.authorName)}</strong> ${esc(c.text)} <small>${esc(new Date(c.createdAt).toLocaleString())}</small>${c.canDelete?` <button class="quiet" data-delete-comment="${esc(c.id)}" data-journal-entry="${esc(entry.id)}">删除评论</button>`:''}</p>`;}
|
||||
$('#journal-form').addEventListener('submit',async event=>{
|
||||
event.preventDefault();if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
|
||||
const f=event.currentTarget,g=journalGeneration,button=$('#journal-save');button.disabled=true;
|
||||
try{await api('/api/journal',{...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)});if(!journalValid(g))return;newJournal();$('#journal-mode').value='mine';journalPage=0;await loadJournal();journalMessage('已保存,动态与个人阅历使用同一条记录。');}
|
||||
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{if(journalValid(g)||activePage==='journal')button.disabled=false;}
|
||||
event.preventDefault();if(journalSaving)return;if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
|
||||
const f=event.currentTarget,g=journalGeneration,session=sessionGeneration;
|
||||
const payload={...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)};
|
||||
const controls=[...f.elements,...$('#journal-filter').elements],disabled=controls.map(el=>el.disabled);
|
||||
const unlock=()=>controls.forEach((el,i)=>el.disabled=disabled[i]);journalSaving=true;controls.forEach(el=>el.disabled=true);
|
||||
try{const saved=await api('/api/journal',payload);if(!journalValid(g))return;journalSaving=false;unlock();newJournal();resetJournalFilters('mine');await loadJournal(saved.id);journalMessage('已保存,输入已清空;下方已重新加载个人阅历。');toast('记事已保存,正在展示保存结果');}
|
||||
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{journalSaving=false;if(session===sessionGeneration)unlock();}
|
||||
});
|
||||
$('#journal-new').onclick=newJournal;
|
||||
$('#journal-filter').onsubmit=e=>{e.preventDefault();journalPage=0;loadJournal();};
|
||||
$('#journal-new').onclick=()=>{if(!journalSaving&&confirmJournalDiscard())newJournal();};
|
||||
$('#journal-form').addEventListener('input',()=>{if(!journalUpload&&!journalSaving)journalMessage(journalHasDraft()?'当前输入尚未保存。':'默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');});
|
||||
$('#journal-filter-clear').onclick=()=>{resetJournalFilters();loadJournal();};
|
||||
$('#journal-filter').onsubmit=e=>{e.preventDefault();if($('#journal-from').value&&$('#journal-to').value&&$('#journal-from').value>$('#journal-to').value){$('#journal-query-status').textContent='开始日期不能晚于结束日期。';return;}journalPage=0;loadJournal();};
|
||||
$('#journal-mode').onchange=()=>{$('#journal-person-label').hidden=$('#journal-mode').value!=='person';journalPage=0;loadJournal();};
|
||||
for(const id of ['journal-person','journal-category'])$('#'+id).onchange=()=>{journalPage=0;loadJournal();};
|
||||
$('#journal-prev').onclick=()=>{journalPage--;loadJournal();};$('#journal-next').onclick=()=>{journalPage++;loadJournal();};
|
||||
async function uploadJournalFile(file,resume=''){
|
||||
if(!file||journalUpload)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
|
||||
if(!file||journalUpload||journalSaving)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
|
||||
journalBusy(true);const g=journalGeneration;$('#journal-save').disabled=true;$('#journal-files').disabled=true;$('#journal-cancel-upload').hidden=false;
|
||||
try{
|
||||
const hashes=await ZhaoFileHash.blocks(file,(n,total)=>journalMessage('正在计算文件校验:'+Math.round(n/total*100)+'%'),()=>journalValid(g)&&journalUpload);
|
||||
@@ -148,7 +170,8 @@ document.addEventListener('change',e=>{if(e.target.dataset.journalResume){upload
|
||||
document.addEventListener('click',async event=>{
|
||||
const b=event.target.closest('button');if(!b)return;const g=journalGeneration;
|
||||
if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;}
|
||||
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;}
|
||||
if(b.dataset.journalEdit){if(journalSaving||!confirmJournalDiscard())return;const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);journalBaseline=journalDraftSignature();f.scrollIntoView({behavior:'smooth'});return;}
|
||||
if(journalSaving&&(b.dataset.useUpload||b.dataset.detachUpload||b.dataset.removeUpload||b.dataset.restoreUpload))return;
|
||||
if(b.dataset.useUpload){journalDraftMedia.add(b.dataset.useUpload);journalSelectedMedia.add(b.dataset.useUpload);renderJournalUploads();return;}
|
||||
if(b.dataset.detachUpload){journalDraftMedia.delete(b.dataset.detachUpload);journalSelectedMedia.delete(b.dataset.detachUpload);renderJournalUploads();return;}
|
||||
if(b.dataset.previewMedia){previewJournalMedia(b.dataset.previewMedia);return;}
|
||||
@@ -188,6 +211,7 @@ document.addEventListener('submit',async event=>{
|
||||
});
|
||||
async function loadBaiduSettings(){
|
||||
const g=appearanceGeneration;$('#baidu-admin').hidden=!isPlatformAdmin();if(!isPlatformAdmin())return;
|
||||
showSettingsSection();
|
||||
try{const [config,status]=await Promise.all([api('/api/baidu/config'),api('/api/baidu/status')]);if(g!==appearanceGeneration||activePage!=='settings')return;
|
||||
const f=$('#baidu-config');for(const k of ['appKey','appFolder','redirectUri'])f.elements[k].value=config[k];f.elements.secretKey.value='';f.elements.secretKey.placeholder=config.hasSecret?'已保存,留空保持原值':'填写 SecretKey';journalConfigRevision=config.revision;
|
||||
$('#baidu-status').textContent=status.connected?(status.reconnect?'网盘授权已失效,请重新连接。':'已连接统一网盘:'+status.account):'尚未连接百度网盘';$('#baidu-connect').disabled=!config.configured;$('#baidu-disconnect').disabled=!status.connected;
|
||||
@@ -196,4 +220,6 @@ async function loadBaiduSettings(){
|
||||
$('#baidu-config').onsubmit=async e=>{e.preventDefault();const g=appearanceGeneration,f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api('/api/baidu/config',{...formValues(f),revision:journalConfigRevision});if(g!==appearanceGeneration)return;f.elements.secretKey.value='';await loadBaiduSettings();$('#baidu-config-status').textContent='应用配置已保存,下一步连接百度网盘。';}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-config-status').textContent=e.message;}finally{b.disabled=false;}};
|
||||
$('#baidu-connect').onclick=async()=>{try{const result=await api('/api/baidu/connect',{});const address=new URL(result.url);if(address.origin!=='https://openapi.baidu.com')throw Error('授权地址不正确');location.assign(result.url);}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
|
||||
$('#baidu-disconnect').onclick=async()=>{if(!confirm('解除本机网盘连接后,媒体将暂时无法访问;网盘文件保留。确认解除?'))return;try{await api('/api/baidu/disconnect',{});loadBaiduSettings();}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
|
||||
window.addEventListener('beforeunload',event=>{if(loggedIn&&journalHasDraft()){event.preventDefault();event.returnValue='';}});
|
||||
|
||||
document.addEventListener('DOMContentLoaded',()=>{const result=new URLSearchParams(location.search).get('baidu');if(result){history.replaceState(null,'',location.pathname);toast(result==='ok'?'网盘授权已保存,请在设置中心查看连接状态。':'授权未完成,请检查回调地址、原登录状态,并重新连接。');}},{once:true});
|
||||
|
||||
@@ -5,6 +5,7 @@ const initialDefaults={theme:'paper',layout:'sidebar',density:'comfortable',back
|
||||
let branding={name:'赵府',subtitle:'智能数字管家',motto:'赵氏家宅 · 起居有序',loginTitle:'欢迎回来',loginNote:'输入用户名和密码,进入家庭空间。',footer:'赵府 · 智能数字管家',logoUrl:'/zhao-icon.png',iconUrl:'/zhao-icon.png',defaults:initialDefaults};
|
||||
let personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),personalRevision=0,appearanceOwner='',appearanceGeneration=0,settingsTimer=null;
|
||||
let brandDraft=null,brandLogo='',brandIcon='',backupRevision=0,coverDraft;
|
||||
let settingsSection='preferences';
|
||||
const imageJobs=new Map();
|
||||
function nextImageJob(key){const value=(imageJobs.get(key)?.value||0)+1;imageJobs.set(key,{value,pending:false});return value;}
|
||||
function imageJobCurrent(key,value){return imageJobs.get(key)?.value===value;}
|
||||
@@ -59,8 +60,15 @@ function fillBrandForm(value){
|
||||
function renderBrandImages(){
|
||||
$('#brand-logo-preview').src=brandLogo||'/zhao-icon.png';$('#brand-icon-preview').src=brandIcon||'/zhao-icon.png';
|
||||
}
|
||||
function showSettingsSection(section=settingsSection){
|
||||
settingsSection=['preferences','baidu','brand','backup'].includes(section)&&(section==='preferences'||isPlatformAdmin())?section:'preferences';
|
||||
for(const [key,id] of Object.entries({preferences:'preferences-form',baidu:'baidu-admin',brand:'brand-form',backup:'backup-panel'}))$('#'+id).hidden=key!==settingsSection;
|
||||
$$('[data-settings-section]').forEach(b=>{b.hidden=b.hasAttribute('data-platform-setting')&&!isPlatformAdmin();b.setAttribute('aria-pressed',String(b.dataset.settingsSection===settingsSection));});
|
||||
}
|
||||
document.addEventListener('click',event=>{const b=event.target.closest('[data-settings-section]');if(b)showSettingsSection(b.dataset.settingsSection);});
|
||||
async function loadSettings(){
|
||||
const generation=++appearanceGeneration;$('#settings-error').textContent='正在读取…';$('#settings-private').hidden=true;
|
||||
showSettingsSection();
|
||||
try{
|
||||
const [preferences,config,backup]=await Promise.all([api('/api/preferences'),isPlatformAdmin()?api('/api/appearance'):null,isPlatformAdmin()?api('/api/backups'):null]);
|
||||
if(generation!==appearanceGeneration||activePage!=='settings')return;
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
let playbackRange=null;
|
||||
function playbackSourceHint(){if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
|
||||
|
||||
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
|
||||
function resetPlayback(){
|
||||
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
|
||||
@@ -49,7 +51,7 @@ function makeClip(){
|
||||
}catch(e){$('#clip-status').textContent=e.message;}
|
||||
}
|
||||
$('#query-recordings').onclick=queryRecordings;
|
||||
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
|
||||
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback;
|
||||
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
|
||||
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
|
||||
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
|
||||
|
||||
+12
-6
@@ -2,6 +2,12 @@
|
||||
let deviceContext=null,scopeEntered=false;
|
||||
Object.assign(pageInfo,{overview:['场所总览','人、区域与设备,在这里各有归属。'],device:['设备档案','查看这台设备的位置、责任人与功能。'],heritage:['家庭传承','把人物、记忆、文化与物品的来历留存下来。']});
|
||||
function currentHome(){return (state.households||[]).find(h=>h.id===state.familyId);}
|
||||
function scopeReturn(page,device){
|
||||
if(page==='overview')return {page:'households',label:'所有家庭与场所'};
|
||||
if(device||page==='sites')return {page:'objects',label:'返回区域与设备'};
|
||||
if(['family','heritage-records'].includes(page))return {page:'heritage',label:'返回家庭传承'};
|
||||
return {page:'overview',label:'返回场所总览'};
|
||||
}
|
||||
function selectedDevice(){return deviceContext?ZhaoDevices.catalog(state).find(d=>d.id===deviceContext.id&&d.source===deviceContext.source):null;}
|
||||
function deviceCameras(cameras,context=deviceContext){
|
||||
if(!context)return cameras;
|
||||
@@ -13,7 +19,7 @@ function enterDevice(source,id,tab='device'){
|
||||
const device=ZhaoDevices.catalog(state).find(d=>d.source===source&&d.id===id);
|
||||
if(!device){toast('该设备不存在或未获授权');return;}
|
||||
stopPlayers();resetPlayback();if(typeof closePtz==='function')closePtz();
|
||||
$('#site-filter').value='';deviceContext={source,id};scopeEntered=true;
|
||||
const returnSite=$('#site-filter').value;$('#site-filter').value='';deviceContext={source,id,returnSite};scopeEntered=true;
|
||||
showPage(tab==='device'&&source==='camera'&&deviceCameras(state.cameras).length?'live':tab);
|
||||
}
|
||||
function renderSteward(){
|
||||
@@ -21,12 +27,12 @@ function renderSteward(){
|
||||
if(deviceContext&&!device&&loggedIn){deviceContext=null;stopPlayers();resetPlayback();showPage('objects');return;}
|
||||
$('#scope-navigation').hidden=!within;
|
||||
$('#scope-nav-name').textContent=home?.name||'';
|
||||
$('#household-context').hidden=!within;
|
||||
$('#household-context').hidden=!within||['settings','storage'].includes(activePage);
|
||||
$('#household-context-note').textContent=home?scopeKind(home)+' · '+home.name:'';
|
||||
$('#device-context').hidden=!device||!['device','live','playback','cameras'].includes(activePage);
|
||||
$('#wall-context').hidden=activePage!=='live'||!!device;
|
||||
$('#device-breadcrumb').textContent=device?[home?.name,siteName(device.siteId),device.name].filter(Boolean).join(' / '):'';
|
||||
$('#scope-back').textContent='返回 '+(home?.name||'场所');
|
||||
$('#scope-back').textContent=scopeReturn(activePage,device).label;
|
||||
const hasVideo=device&&['camera','recorder'].includes(device.source);
|
||||
$$('[data-device-tab]').forEach(b=>{b.hidden=b.dataset.deviceTab!=='device'&&(!hasVideo||b.dataset.deviceTab==='cameras'&&!isAdmin());b.classList.toggle('active',b.dataset.deviceTab===activePage);});
|
||||
if(device&&['device','live','playback','cameras'].includes(activePage)){
|
||||
@@ -43,7 +49,7 @@ function renderSteward(){
|
||||
function renderOverview(){
|
||||
const home=currentHome();if(!home){$('#scope-overview').replaceChildren();return;}
|
||||
const devices=ZhaoDevices.catalog(state),homeKind=!home.kind||home.kind==='home';
|
||||
$('#scope-overview').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(scopeKind(home))} · 日常有序</span><h2>${esc(home.name)}</h2><p>${esc(home.note||'从每一处空间,照看每一件事物。')}</p></div><div class="steward-grid"><button class="steward-tile" data-page="objects"><strong>区域与设备</strong><span>${devices.length} 台设备 · ${state.sites.length} 处区域</span><small>按位置、类型或负责人查找,再进入具体设备</small></button><button class="steward-tile" data-open-wall><strong>本场所监控墙</strong><span>${state.cameras.length} 个已授权镜头</span><small>集中观看、排列或全屏;回放可进入对应摄像头</small></button>${homeKind&&(isAdmin()||currentUser?.familyAccess!=='none'||['read','edit'].includes(currentUser?.heritageAccess))?'<button class="steward-tile" data-page="heritage"><strong>家庭传承</strong><span>人物 · 记事 · 文化 · 物品</span><small>从家谱追溯记忆,也保存资料与物品来历</small></button>':''}${isAdmin()?'<button class="steward-tile" data-page="users"><strong>成员与权限</strong><span>访问范围 · 管理职责</span><small>亲属关系、责任归属与系统权限分别设置</small></button>':''}</div>`;
|
||||
$('#scope-overview').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(scopeKind(home))} · 日常有序</span><h2>${esc(home.name)}</h2><p>${esc(home.note||'从每一处空间,照看每一件事物。')}</p></div><div class="steward-grid"><button class="steward-tile" data-page="journal"><strong>记事与阅历</strong><span>写日常 · 看动态 · 查阅历</span><small>文字、照片和录音;每条记事自行选择谁可以看</small></button><button class="steward-tile" data-page="objects"><strong>区域与设备</strong><span>${devices.length} 台设备 · ${state.sites.length} 处区域</span><small>按位置、类型或负责人查找,再进入具体设备</small></button><button class="steward-tile" data-open-wall><strong>本场所监控墙</strong><span>${state.cameras.length} 个已授权镜头</span><small>集中观看、排列或全屏;回放可进入对应摄像头</small></button>${homeKind&&(isAdmin()||currentUser?.familyAccess!=='none'||['read','edit'].includes(currentUser?.heritageAccess))?'<button class="steward-tile" data-page="heritage"><strong>家庭传承</strong><span>人物 · 记事 · 文化 · 物品</span><small>从家谱追溯记忆,也保存资料与物品来历</small></button>':''}${isAdmin()?'<button class="steward-tile" data-page="users"><strong>成员与权限</strong><span>访问范围 · 管理职责</span><small>亲属关系、责任归属与系统权限分别设置</small></button>':''}</div>`;
|
||||
}
|
||||
function renderDeviceDetail(device){
|
||||
if(!device){$('#device-detail').innerHTML=empty('设备暂不可用','返回设备目录重新选择');return;}
|
||||
@@ -53,7 +59,7 @@ function renderDeviceDetail(device){
|
||||
}
|
||||
function renderHeritageHome(){
|
||||
const canFamily=isAdmin()||currentUser?.familyAccess!=='none',canHeritage=isAdmin()||['read','edit'].includes(currentUser?.heritageAccess);
|
||||
$('#heritage-home').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(currentHome()?.name||'')} · 薪火相传</span><h2>留住来处,也照看未来</h2><p>人物、记事、文化资料与家庭物品分别保存,关联同一个家庭。</p></div><div class="steward-grid">${canFamily?'<button class="steward-tile" data-family-section="tree"><strong>人物与家谱</strong><span>亲属关系 · 人物档案</span><small>从祖辈到后辈,查看全家关系</small></button><button class="steward-tile" data-family-section="timeline"><strong>家庭记事</strong><span>时间线 · 相关人物</span><small>追溯重要时刻与详细记载</small></button>':''}${canHeritage?'<button class="steward-tile" data-heritage-kind="culture"><strong>文化与家风</strong><span>家训 · 故事 · 手艺 · 资料</span><small>记录来源、整理人和相关内容</small></button><button class="steward-tile" data-heritage-kind="asset"><strong>家庭物品与财产档案</strong><span>来历 · 保管 · 交接意愿</span><small>档案记录不自动改变所有权或系统权限</small></button>':'<p class="description">文化与财产档案须由家庭管理员另行授权。</p>'}</div>`;
|
||||
$('#heritage-home').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(currentHome()?.name||'')} · 薪火相传</span><h2>留住来处,也照看未来</h2><p>这里整理家谱、家族重大记事、文化和物品来历。个人近况与日常分享,请进入“记事与阅历”。</p></div><div class="steward-grid">${canFamily?'<button class="steward-tile" data-family-section="tree"><strong>人物与家谱</strong><span>亲属关系 · 人物档案</span><small>从祖辈到后辈,查看全家关系</small></button><button class="steward-tile" data-family-section="timeline"><strong>家族重大记事</strong><span>时间线 · 相关人物</span><small>追溯重要时刻与详细记载</small></button>':''}${canHeritage?'<button class="steward-tile" data-heritage-kind="culture"><strong>文化与家风</strong><span>家训 · 故事 · 手艺 · 资料</span><small>记录来源、整理人和相关内容</small></button><button class="steward-tile" data-heritage-kind="asset"><strong>家庭物品与财产档案</strong><span>来历 · 保管 · 交接意愿</span><small>档案记录不自动改变所有权或系统权限</small></button>':'<p class="description">文化与财产档案须由家庭管理员另行授权。</p>'}</div>`;
|
||||
}
|
||||
function resetSteward(){deviceContext=null;scopeEntered=false;for(const id of ['scope-overview','device-detail','heritage-home'])$('#'+id).replaceChildren();}
|
||||
document.addEventListener('click',e=>{
|
||||
@@ -61,6 +67,6 @@ document.addEventListener('click',e=>{
|
||||
if(b.dataset.viewDevice)enterDevice(b.dataset.deviceSource,b.dataset.viewDevice);
|
||||
if(b.dataset.deviceTab)showPage(b.dataset.deviceTab);
|
||||
if(b.hasAttribute('data-open-wall')){deviceContext=null;showPage('live');}
|
||||
if(b.id==='scope-back')showPage('overview');
|
||||
if(b.id==='scope-back'){const target=scopeReturn(activePage,selectedDevice()).page;if(target==='objects'&&deviceContext)$('#site-filter').value=deviceContext.returnSite||'';showPage(target);}
|
||||
if(b.dataset.familySection){familyView=b.dataset.familySection;showPage('family',{keepFamilyView:true});}
|
||||
});
|
||||
|
||||
@@ -26,3 +26,9 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
|
||||
/* v0.1.21: presets and recording tools */
|
||||
.ptz-panel{width:620px;display:grid;grid-template-columns:1fr auto;max-height:80dvh;overflow:auto;align-items:start}.ptz-panel>div:first-child{grid-column:1}.ptz-panel .ptz-pad{grid-column:2;grid-row:1/3}#ptz-presets{grid-column:1/-1;min-width:0;border-top:1px solid var(--line);padding-top:12px}#ptz-presets .section-head{margin:0;gap:10px}#ptz-presets p{max-width:none;line-height:1.6}#preset-count{color:var(--muted);font-size:12px}.preset-row{display:flex;align-items:center;gap:5px;padding:9px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}.preset-row strong{font-size:13px;flex:1;overflow-wrap:anywhere}.preset-row button{padding:5px 8px;font-size:12px}#preset-form{display:flex;align-items:end;gap:10px;margin-top:12px}#preset-form label{flex:1;margin:0}#preset-name{width:100%}#preset-confirmation{padding:12px;background:#553b31;border-radius:8px;margin-top:10px}#ptz-close{justify-self:start}.playback-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:12px 0}.playback-tools .check{margin:0}.clip-editor{border-top:1px solid var(--line);padding-top:12px}.clip-editor summary{cursor:pointer;margin-bottom:14px}#snapshot-dialog{width:min(1000px,92vw)}#snapshot-image{width:100%;max-height:65vh;object-fit:contain;background:#000;border-radius:8px}#play-position{font-size:13px;color:var(--muted)}
|
||||
@media(max-width:600px){.ptz-panel{right:12px;bottom:12px;padding:12px;gap:12px}.ptz-panel .ptz-pad{grid-column:2}#preset-form{flex-wrap:wrap}#preset-form label{flex-basis:100%}}
|
||||
/* Frequent tasks stay visible; advanced grouping and platform settings are opened on demand. */
|
||||
.settings-sections,.view-presets{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 22px}
|
||||
.settings-sections button[aria-pressed="true"],.view-presets button[aria-pressed="true"]{background:var(--accent,#30594e);color:var(--paper,#fff);border-color:var(--accent,#30594e)}
|
||||
.group-custom{margin:16px 0}.group-custom summary{cursor:pointer;padding:10px 0}
|
||||
.device-search-row{align-items:end}.device-search-row>button{align-self:end}
|
||||
.journal-entry.just-saved{outline:2px solid var(--accent,#30594e);outline-offset:4px;scroll-margin-block:24px}
|
||||
|
||||
Reference in New Issue
Block a user