feat: organize places and device workspaces with scoped heritage records

This commit is contained in:
Codex
2026-10-04 16:21:40 +08:00
parent 5544780bf0
commit 4ac0a0b516
18 changed files with 444 additions and 51 deletions
+13
View File
@@ -98,3 +98,16 @@
区域以 `parentId` 邻接表表示,创建/移动时在写锁内检查父节点归属、有效性与祖先循环。父链和后代集合均迭代计算,避免递归栈形成层数限制。用户的 `includeSubspaces` 默认关闭;开启后按当前区域树实时扩展 `siteIds`。修改该授权开关会撤销已有会话。人员归属是文本档案,不参与权限计算。
新增接口:`POST /api/devices` 保存通用设备(要求当前归属管理员);`POST /api/device-view` 保存当前归属默认分组顺序。统一状态的 `devices` 数组也经过归属和成员区域过滤。
## 场所、设备上下文与传承(v0.1.24)
`steward.js` 管理入口和当前设备上下文。根入口是归属列表;进入归属后才展示内部导航。`deviceCameras` 按摄像头 `assetId` 或录像机关联字段 `recorderId` / `archiveRecorderId` 选择已有授权通道,不推断不存在的镜头。通用设备没有视频页签。前端上下文缩小显示范围,后端的家庭与区域授权始终独立执行。
`heritage.py` 通过 `GET /api/heritage` 和 `POST /api/heritage` 维护 `heritage_entries`,kind 为 culture 或 asset,只允许 home 类归属。写入在全局写锁内检查归属、编辑权限、不可变类别和每条档案的 revision。关联设备需要同归属,普通编辑者还须具备该设备区域权限。文字统一转义;外链限定无嵌入凭据的 HTTP/HTTPS,浏览器用 noopener/noreferrer 打开,不由服务器抓取。
`heritageAccess` 独立于 `familyAccess`,普通账号默认 none,read 只能读,edit 可创建、编辑及归档;管理员限定于其管理归属,超级管理员可明确切换归属。权限改变撤销现有会话。新表无默认真实资料;旧账户未补写该字段也按 none 处理。
前端 `heritage.js` 用独立请求代次防止晚返回的数据覆盖其他页面;退出、切换家庭清空资料和表单。更新使用修订号防并发,归档可恢复;目前保留当前版本及最近修改人、时间,不保存该档案的逐次历史快照。所有人、保管人、意向接收人均是档案文本,不是认证标识,不参与权限或所有权计算。
当前架构边界:非超级管理员一账号一归属;财产记录与系统角色交接是独立流程;通用设备登记不代表支持控制;录像机既有历史录像仍需品牌适配。未来多归属成员表与正式交接流程须先完成明确授权模型,再扩展数据结构。
+23
View File
@@ -209,3 +209,26 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
- 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。
- 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。
- 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。
## 智能数字管家的入口与传承档案(v0.1.24)
系统从「我的家庭与场所」开始。进入一个家庭、办公室或工厂后,左侧才显示该归属下的「场所总览」「区域与设备」「成员与权限」「区域设置」。家庭另有「家庭传承」。平台存储与审计仅向超级管理员开放。
### 从设备进入功能
在「区域与设备」点击设备名称:摄像头内部提供「设备档案 / 实时画面 / 录像回放 / 镜头设置」;实时画面与回放仅列出该摄像头的已授权镜头。普通成员没有镜头设置入口。录像机同样作为设备存在,展示明确关联的通道;通用设备先提供档案。场所总览的「本场所监控墙」可集中查看本场所已授权镜头,分屏与顺序按账号、归属及设备分别保存。
切换设备会释放之前的播放与云台会话;切换归属会清空私有视图并使旧请求失效。新版入口不自动拉取所有摄像头视频。原始摄像机接入参数、媒体配置和录像策略保持原值。
### 家庭传承
- 「人物与家谱」打开关系图,「家庭记事」直接进入时间线。
- 「文化与家风」保存故事、家训、手艺、整理人、日期、正文及资料链接。
- 「家庭物品与财产档案」保存来历、所有人或共有说明、保管人、意向接收人和交接说明,可关联本家庭已有设备。
- 档案支持搜索、查看详情、编辑、归档和恢复。编辑时检查修订号,拒绝用旧表单覆盖新内容。资料按家庭隔离;仅保存文字及链接,不上传或备份链接目标文件。
- 在「成员与权限」独立设置「文化与财产档案权限」为不开放、查看或编辑。家庭管理员管理本家庭全部档案;普通成员已有的家谱权限不会自动开放财产资料。改变该权限会撤销该账号现有会话。
交接说明只是记录意愿,不自动改变财产所有权、设备责任人、账号角色或家主安排。实际系统权限交接继续走「我的家庭与场所 → 负责人安排与交接」。当前非超级管理员账号仍只属于一个家庭或场所,多归属成员身份尚未实现。
升级幂等创建 `heritage_entries` 表,不自动修改现有账号、设备或家谱。未新增授权的普通成员默认无法读取此表。仍需在升级前备份数据库。录像机历史录像适配、通用设备实际控制、物品正式交接流程尚待后续实现;本版不宣称解决摄像机源流全部卡顿问题。
+1 -1
View File
@@ -1 +1 @@
0.1.23
0.1.24
+10 -2
View File
@@ -33,6 +33,7 @@ import identity
import genealogy
import households
import inventory
import heritage
import ptz
ROOT = Path(__file__).resolve().parent
@@ -77,6 +78,7 @@ ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
INVENTORY = inventory.Inventory(sys.modules[__name__])
HERITAGE = heritage.Heritage(sys.modules[__name__])
def setting(key, default=None):
@@ -615,14 +617,14 @@ class Handler(BaseHTTPRequestHandler):
try:
path = url.urlsplit(self.path).path
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
data = self.body(131072 if path == '/api/family/event' else 16384)
data = self.body(131072 if path in ('/api/family/event','/api/heritage') else 16384)
self.validate_origin()
if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'):
'/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'):
self.require_manager()
if path == '/api/logout':
with LOCK:
@@ -674,6 +676,8 @@ class Handler(BaseHTTPRequestHandler):
self.answer(HOUSEHOLDS.leadership(data, self.user))
elif path == '/api/device-view':
self.answer(HOUSEHOLDS.save_view(data, self.user))
elif path == '/api/heritage':
self.answer(HERITAGE.save(data, self.user))
elif path == '/api/devices':
self.answer(INVENTORY.save(data, self.user))
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
@@ -830,6 +834,8 @@ class Handler(BaseHTTPRequestHandler):
self.require_manager()
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
if self.user['role'] == 'admin' or u['role'] != 'admin']})
elif path == '/api/heritage':
self.answer(HERITAGE.snapshot(self.user))
elif path == '/api/households':
self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)})
elif path == '/api/ptz/presets':
@@ -917,6 +923,7 @@ class Handler(BaseHTTPRequestHandler):
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
@@ -955,6 +962,7 @@ def initialize():
FAMILY.initialize()
ACCOUNTS.initialize()
HOUSEHOLDS.initialize()
HERITAGE.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
+78
View File
@@ -0,0 +1,78 @@
"""Scoped cultural and property records; records never transfer access or ownership."""
import datetime as dt
from urllib.parse import urlsplit
class Heritage:
def __init__(self, app):
self.a = app
def initialize(self):
with self.a.LOCK, self.a.DB:
self.a.DB.execute('CREATE TABLE IF NOT EXISTS heritage_entries (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
def require(self, user, edit=False):
a = self.a
home = a.get_object('households', a.HOUSEHOLDS.scope(user))
if home.get('kind', 'home') != 'home':
raise a.Problem('家庭传承资料属于家庭,请先进入对应家庭', 403)
if not a.HOUSEHOLDS.manager(user) and user.get('heritageAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
raise a.Problem('尚未获得文化与财产档案权限,请联系家庭管理员', 403)
def snapshot(self, user):
self.require(user)
return dict(entries=self.a.HOUSEHOLDS.objects(user, 'heritage_entries'),
canEdit=self.a.HOUSEHOLDS.manager(user) or user.get('heritageAccess') == 'edit')
def save(self, data, user):
a = self.a
with a.LOCK:
self.require(user, True)
family = a.HOUSEHOLDS.prepare(user, 'heritage_entries', data)
key = a.object_key('heritage_entries', data)
old = a.get_object('heritage_entries', key) or {}
if data.get('revision', 0) != old.get('revision', 0):
raise a.Problem('档案已被更新,请重新打开后再保存', 409)
kind = data.get('kind')
if kind not in ('culture', 'asset') or old and kind != old['kind']:
raise a.Problem('请选择文化资料或财产档案;已有档案不能变更类别')
archived = data.get('archived', False)
if not isinstance(archived, bool):
raise a.Problem('归档状态不正确')
name = a.clean_text(data.get('name', ''), 120, True)
date = a.clean_text(data.get('date', ''), 10)
if date:
try:
if dt.date.fromisoformat(date).isoformat() != date:
raise ValueError()
except ValueError:
raise a.Problem('日期格式不正确')
link = a.clean_text(data.get('sourceUrl', ''), 1500)
if link:
try:
parts = urlsplit(link)
if parts.scheme not in ('http', 'https') or not parts.hostname or parts.username or parts.password:
raise ValueError()
except ValueError:
raise a.Problem('资料链接须为不含账号密码的 HTTP 或 HTTPS 地址')
related = a.clean_text(data.get('deviceId', ''), 32)
source = data.get('deviceSource', '')
if related:
table = {'camera': 'assets', 'recorder': 'recorders', 'device': 'devices'}.get(source)
if not table:
raise a.Problem('关联设备类型不正确')
a.HOUSEHOLDS.owns(user, a.get_object(table, related))
# Ordinary record editors may reference only their own authorized areas.
device = a.get_object(table, related)
if not a.HOUSEHOLDS.manager(user) and device.get('siteId') not in a.HOUSEHOLDS.site_ids(user):
raise a.Problem('未获授权关联这台设备', 403)
fields = {k: a.clean_text(data.get(k, ''), 80) for k in ('ownerName', 'custodianName', 'successorName', 'contributor')}
item = dict(id=key, familyId=family, kind=kind, name=name, date=date, sourceUrl=link,
content=a.clean_text(data.get('content', ''), 8000),
handoverNote=a.clean_text(data.get('handoverNote', ''), 2000),
deviceId=related, deviceSource=source if related else '', archived=archived,
revision=old.get('revision', 0)+1, **fields,
updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['name'])
a.save_object('heritage_entries', item)
a.audit('保存传承资料', user['username']+' / '+kind)
return item
+5 -2
View File
@@ -64,6 +64,9 @@ class Accounts:
raise a.Problem('用户名已存在', 409)
role = data.get('role', 'member')
access = data.get('familyAccess', 'none')
heritage_access = data.get('heritageAccess', old.get('heritageAccess', 'none') if old else 'none')
if heritage_access not in ('none', 'read', 'edit'):
raise a.Problem('文化与财产档案权限不正确')
if role not in ('admin', 'family_admin', 'member') or access not in ('none', 'read', 'edit'):
raise a.Problem('权限选项不正确')
if actor and actor['role'] != 'admin' and (role == 'admin' or (old and old['role'] == 'admin')):
@@ -116,10 +119,10 @@ class Accounts:
credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')}
user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True),
role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
personId=person, relatedToId=related, includeSubspaces=descendants,
personId=person, relatedToId=related, includeSubspaces=descendants, heritageAccess=heritage_access,
relationship=a.clean_text(data.get('relationship', ''), 40), **credentials)
# Revoke sessions when credentials or authorization change, not for label edits.
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')):
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'heritageAccess', 'disabled', 'username', 'ptzControl', 'personId')):
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
a.save_object('users', user)
a.audit('更新账号' if old else '建立账号', username)
+1 -1
View File
@@ -105,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
self.assertEqual(len(assets), 22)
self.assertEqual(len(assets), 24)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
+78
View File
@@ -0,0 +1,78 @@
"""Real HTTP coverage for scoped cultural and property records."""
import json
import unittest
from test_app import app
import test_households as fixture
class HeritageTests(unittest.TestCase):
setUp = fixture.HouseholdTests.setUp
stop_server = fixture.HouseholdTests.stop_server
request = fixture.HouseholdTests.request
def create(self, **fields):
data = dict(name='家书', kind='culture'); data.update(fields)
code, row = self.request('/api/heritage', data=data)
self.assertEqual(code, 200, row)
return row
def allow(self, level):
user = app.get_object('users', self.members[self.aid]['id'])
user['heritageAccess'] = level
app.save_object('users', user)
return user
def test_genealogy_access_does_not_grant_property_access(self):
self.create()
member = self.members[self.aid]
self.assertEqual(self.request('/api/family', member)[0], 200)
self.assertEqual(self.request('/api/heritage', member)[0], 403)
self.assertEqual(self.request('/api/heritage', member, dict(name='x', kind='asset'))[0], 403)
reader = self.allow('read')
self.assertEqual(len(self.request('/api/heritage', reader)[1]['entries']), 1)
self.assertFalse(self.request('/api/heritage', reader)[1]['canEdit'])
self.assertEqual(self.request('/api/heritage', reader, dict(name='x', kind='asset'))[0], 403)
def test_cross_household_reads_edits_and_device_references_are_rejected(self):
row = self.create()
self.assertEqual(self.request('/api/heritage', self.other)[1]['entries'], [])
self.assertIn(self.request('/api/heritage', self.other, row)[0], (403,404))
self.assertEqual(self.request('/api/heritage', data=dict(name='x', kind='culture',
deviceId=self.assets[self.bid]['id'], deviceSource='camera'))[0], 404)
self.assertEqual(self.request('/api/heritage', self.owner, home=self.bid)[0], 403)
def test_revision_archive_and_restore(self):
row = self.create()
editor = self.allow('edit')
code, archived = self.request('/api/heritage', editor, dict(row, archived=True))
self.assertEqual(code, 200);self.assertEqual(archived['revision'], 2)
self.assertEqual(self.request('/api/heritage', editor, row)[0], 409)
code, restored = self.request('/api/heritage', editor, dict(archived, archived=False))
self.assertEqual(code, 200);self.assertFalse(restored['archived'])
self.assertEqual(restored['revision'], 3)
def test_validation_and_non_home_scope(self):
for values in [dict(sourceUrl='javascript:alert(1)'),dict(sourceUrl='https://user:pass@example.com'),
dict(date='2026-02-30'),dict(archived='false'),dict(kind='unsupported')]:
data=dict(name='x',kind='culture');data.update(values)
self.assertEqual(self.request('/api/heritage',data=data)[0],400)
home=app.get_object('households',self.bid)
app.HOUSEHOLDS.save(dict(home,kind='office'),self.root)
self.assertEqual(self.request('/api/heritage',self.other)[0],403)
def test_handover_intent_never_changes_accounts_or_device_ownership(self):
users=json.dumps(app.objects('users'),sort_keys=True)
devices=json.dumps(app.objects('assets'),sort_keys=True)
row=self.create(kind='asset',ownerName='甲',custodianName='乙',successorName='丙',handoverNote='记录意愿')
self.assertEqual(row['successorName'],'丙')
self.assertEqual(json.dumps(app.objects('users'),sort_keys=True),users)
self.assertEqual(json.dumps(app.objects('assets'),sort_keys=True),devices)
def test_new_permission_revokes_session_and_editor_needs_device_grant(self):
member=app.get_object('users',self.members[self.aid]['id'])
app.ACCOUNTS.save(dict(member,heritageAccess='edit'),self.root)
self.assertEqual(self.request('/api/heritage',member)[0],401)
member=app.get_object('users',member['id']);member['siteIds']=[]
app.save_object('users',member)
with self.assertRaises(app.Problem):
app.HERITAGE.save(dict(name='未授权设备',kind='asset',deviceSource='camera',deviceId=self.assets[self.aid]['id']),member)
+51
View File
@@ -0,0 +1,51 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
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];
assert.doesNotMatch(nav,/data-page="(?:live|playback)"/);
assert.match(nav,/scope-navigation/);assert.match(app,/activePage = 'households'/);
assert.match(html,/aria-label="设备功能"/);
});
test('one device exposes its lenses while place wall exposes all authorized lenses',()=>{
const c=vm.createContext({deviceContext:null});vm.runInContext(part(code,'function deviceCameras(','function enterDevice('),c);
const cameras=[{id:'1',assetId:'a'},{id:'2',assetId:'a'},{id:'3',assetId:'b'}];
assert.equal(c.deviceCameras(cameras).length,3);
assert.deepEqual(c.deviceCameras(cameras,{source:'camera',id:'a'}).map(x=>x.id),['1','2']);
assert.equal(c.deviceCameras(cameras,{source:'camera',id:'missing'}).length,0);
});
test('recorder relation is explicit and generic devices have no video operations',()=>{
const c=vm.createContext({deviceContext:null});vm.runInContext(part(code,'function deviceCameras(','function enterDevice('),c);
const cameras=[{id:'1',recorderId:'r'},{id:'2',archiveRecorderId:'r'},{id:'3',assetId:'r'}];
assert.equal(c.deviceCameras(cameras,{source:'recorder',id:'r'}).length,2);
assert.equal(c.deviceCameras(cameras,{source:'device',id:'r'}).length,0);
});
test('switching devices cancels prior playback before changing context',()=>{
const order=[],c=vm.createContext({state:{cameras:[]},ZhaoDevices:{catalog:()=>[{source:'camera',id:'a'}]},
$:()=>({value:''}),stopPlayers:()=>order.push('stop'),resetPlayback:()=>order.push('reset'),closePtz:()=>order.push('ptz'),
deviceCameras:()=>[{}],showPage:page=>order.push(page),toast:()=>{throw Error('not expected');}});
vm.runInContext(part(code,'function enterDevice(','function renderSteward('),c);c.enterDevice('camera','a');
assert.deepEqual(order,['stop','reset','ptz','live']);assert.equal(c.deviceContext.id,'a');
});
test('an unknown or unauthorized device cannot open a player',()=>{
let warned=0;const c=vm.createContext({state:{},ZhaoDevices:{catalog:()=>[]},toast:()=>warned++,stopPlayers:()=>{throw Error('must not open');}});
vm.runInContext(part(code,'function enterDevice(','function renderSteward('),c);c.enterDevice('camera','foreign');assert.equal(warned,1);
});
test('late heritage response cannot populate a different view',async()=>{
let finish,writes=0;const c=vm.createContext({heritageGeneration:0,heritageKind:'culture',activePage:'heritage-records',
$:()=>({replaceChildren(){},hidden:false,textContent:''}),api:()=>new Promise(r=>finish=r),renderHeritageRecords:()=>writes++});
const source=fs.readFileSync('web/heritage.js','utf8');
vm.runInContext(part(source,'async function loadHeritage(','function renderHeritageRecords('),c);
const pending=c.loadHeritage();c.heritageGeneration++;c.activePage='households';finish({entries:['private']});await pending;assert.equal(writes,0);
});
test('family timeline entry preserves its explicitly selected view',()=>{
assert.match(code,/familyView=b.dataset.familySection;showPage\('family',\{keepFamilyView:true\}\)/);
assert.match(app,/loadFamily\(!options.keepFamilyView\)/);
});
test('culture and property entry requires its separate permission',()=>{
const c=vm.createContext({isAdmin:()=>false,currentUser:{familyAccess:'read',heritageAccess:'none'},currentHome:()=>({name:'赵府'}),esc:s=>s,$:()=>output}),output={innerHTML:''};
vm.runInContext(part(code,'function renderHeritageHome(','function resetSteward('),c);c.renderHeritageHome();
assert.match(output.innerHTML,/data-family-section/);assert.doesNotMatch(output.innerHTML,/data-heritage-kind/);
c.currentUser.heritageAccess='read';c.renderHeritageHome();assert.match(output.innerHTML,/data-heritage-kind/);
});
+24 -16
View File
@@ -3,7 +3,7 @@ const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'households', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
let householdScope = '';
@@ -14,19 +14,21 @@ function applyPermissions(){
const admin=isAdmin();
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
$('#account-password').hidden=!loggedIn;
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
$$('[data-admin],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
$('.discovery').hidden=!isPlatformAdmin();
$('.discovery').hidden=!isPlatformAdmin()||!!deviceContext;
$('#scan-result').hidden=!isPlatformAdmin();
$('#new-household').hidden=!isPlatformAdmin();
$('#household-context').hidden=!loggedIn;
$('#household-filter').disabled=!isPlatformAdmin();
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
if(typeof renderSteward==='function')renderSteward();
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
}
function clearPrivateView(){
if(typeof clearSnapshot==='function')clearSnapshot();
if(typeof resetDeviceView==='function')resetDeviceView();
if(typeof resetSteward==='function')resetSteward();
if(typeof resetHeritage==='function')resetHeritage();
if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
@@ -36,7 +38,7 @@ function clearPrivateView(){
if(typeof clearFamilyState==='function')clearFamilyState();
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
$$('dialog form').forEach(f=>f.reset());
activePage='live';showPage('live');applyPermissions();
activePage='households';showPage('households');applyPermissions();
}
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['设备管理','按类型、区域、负责人或所有人,找到每一台设备。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['区域与空间','层级与名称由你定义,设备各有归属。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) {
@@ -50,25 +52,31 @@ async function api(path, data) {
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; }
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;return state.cameras.filter(c=>!range||range.has(c.siteId)); }
function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const cameras=state.cameras.filter(c=>!range||range.has(c.siteId));return typeof deviceCameras==='function'?deviceCameras(cameras):cameras; }
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) {
if(!isAdmin()&&!['live','playback','family'].includes(page))return;
function showPage(page, options={}) {
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records'].includes(page))return;
if(['heritage','family','heritage-records'].includes(page)&&currentHome()?.kind&&currentHome().kind!=='home')return;
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage'].includes(page);
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;
$('.metrics').hidden=['family','users','households'].includes(page);
$('.filter-row').hidden=['family','users','households'].includes(page);
$('.metrics').hidden=['family','users','households','heritage','heritage-records'].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'&&['cameras','recorders'].includes(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)));
$('#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 (page === 'storage') loadAudit();
if (page === 'family') loadFamily(true);
if (page === 'family') loadFamily(!options.keepFamilyView);
if (page === 'users') loadUsers();
if(page==='heritage-records')loadHeritage();
}
function render() {
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
@@ -101,7 +109,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-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`));
@@ -146,21 +154,21 @@ 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(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){showPage('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));});
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();};
$('#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];}};
window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',()=>{showPage('households');auth();},{once:true});
function renderAssets(){renderDevices();}
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)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
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 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;
+8
View File
@@ -253,3 +253,11 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
.device-search-row{align-items:end}.device-search-row label{flex:1;min-width:160px}.device-group-order{margin:12px 0}.group-step{padding:7px;background:var(--paper);border:1px solid var(--line);border-radius:5px}.group-step select{width:auto;max-width:145px}.group-step button{padding:7px 11px}.group-step>span{color:var(--muted)}
.device-group{margin:26px 0}.device-group>h3{font-family:var(--serif);font-size:20px;overflow-wrap:anywhere}.device-group>h3>span{color:var(--gold);padding:0 8px}.device-group>h3 small{font:12px var(--sans);color:var(--muted);white-space:nowrap}.device-tags{display:flex;flex-wrap:wrap;gap:6px}.device-tags span{font-size:11px;padding:3px 8px;background:var(--paper);border:1px solid var(--line)}.site-card dd,.site-card .description{overflow-wrap:anywhere;min-width:0}
@media(max-width:600px){.device-actions{width:100%}.device-actions button{flex:1;white-space:normal}.group-step{width:100%}.group-step select{flex:1;max-width:none;min-width:0}.device-group>h3{font-size:18px}}
/* Contextual steward navigation and one-device workspaces. */
#scope-navigation{border-left:1px solid #6d8270;margin:12px 0 12px 10px;padding-left:9px}#scope-navigation .nav-label{padding:10px 12px;overflow-wrap:anywhere}
.device-context{padding:18px 22px;margin:20px 0;border:1px solid var(--line);border-left:4px solid var(--accent);background:var(--panel)}.device-context p{color:var(--muted);overflow-wrap:anywhere;margin:0 0 14px}.device-context [role=group]{display:flex;flex-wrap:wrap;gap:8px}.device-context button.active{color:var(--paper);background:var(--accent)}
.device-title{border:0;padding:0;background:transparent;color:var(--ink);font:inherit;text-align:left;box-shadow:none;white-space:normal;overflow-wrap:anywhere}.device-title:hover{color:var(--accent);background:transparent}.device-title span{color:var(--accent)}
.scope-intro{padding:28px 0}.scope-intro h2{font:500 32px var(--serif);margin:12px 0}.scope-intro p{color:var(--muted);line-height:1.8}.steward-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.steward-tile{display:flex;flex-direction:column;text-align:left;gap:12px;min-height:190px;padding:28px;border:1px solid var(--line);border-top:3px solid var(--accent);background:var(--panel);color:var(--ink);white-space:normal;box-shadow:0 3px 12px #22332208}.steward-tile strong{font-size:23px;font-family:var(--serif);font-weight:500}.steward-tile span,.steward-tile small{color:var(--muted);line-height:1.7}.steward-tile:hover{background:var(--paper);border-color:var(--accent)}
#device-detail dl,#heritage-detail dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}#device-detail dl>div,#heritage-detail dl>div{border-bottom:1px solid var(--line);padding-bottom:12px}#device-detail dt,#heritage-detail dt{font-size:12px;color:var(--muted);margin-bottom:6px}#device-detail dd,#heritage-detail dd{margin:0;overflow-wrap:anywhere}.heritage-prose{white-space:pre-wrap;line-height:1.9;overflow-wrap:anywhere;margin:24px 0}#heritage-detail{margin-top:24px}.heritage-controls{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin-bottom:22px}.heritage-controls label{min-width:0}.household-context{flex-wrap:wrap}
@media(max-width:720px){.steward-grid{grid-template-columns:1fr}.steward-tile{min-height:150px;padding:22px}.device-context{padding:16px}.device-context button{flex:1 1 40%;min-width:0}#device-detail dl,#heritage-detail dl{grid-template-columns:1fr}#scope-navigation{display:flex;flex-wrap:wrap;border:0;margin:0;padding:0;width:100%}#scope-navigation .nav-label{width:100%;padding:8px 4px}.scope-intro h2{font-size:26px}}
+2 -2
View File
@@ -8,8 +8,8 @@ function renderDeviceGrouping(){
function deviceCard(d){
const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span><button class="quiet" data-${attr}="${esc(d.id)}">编辑</button></div><h3>${esc(d.name)}</h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id||c.archiveRecorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
}
function renderDevices(){
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
+1 -1
View File
@@ -67,7 +67,7 @@ for(const [name,path]of [['person','person'],['relation','link']]){
async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id;
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱权限</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱 / 传承资料</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}<small>文化与财产:${u.role!=='member'?'管理':({none:'不可访问',read:'查看',edit:'维护'}[u.heritageAccess||'none'])}</small></td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
}catch(e){toast(e.message);}
}
function openUser(id){
+53
View File
@@ -0,0 +1,53 @@
'use strict';
let heritageKind='culture',heritageData={entries:[],canEdit:false},heritageGeneration=0,heritageSelection='';
pageInfo['heritage-records']=['传承档案','记录资料来源、物品来历与交接意愿。'];
function resetHeritage(){heritageGeneration++;heritageData={entries:[],canEdit:false};heritageSelection='';$('#heritage-list').replaceChildren();$('#heritage-detail').replaceChildren();$('#heritage-detail').hidden=true;$('#heritage-error').textContent='';$('#heritage-new').hidden=true;}
async function loadHeritage(){
const generation=++heritageGeneration,kind=heritageKind;
$('#heritage-list').replaceChildren();$('#heritage-detail').hidden=true;$('#heritage-new').hidden=true;
$('#heritage-records-title').textContent=kind==='culture'?'文化与家风':'家庭物品与财产档案';
$('#heritage-error').textContent='正在读取…';
try{
const data=await api('/api/heritage');
if(generation!==heritageGeneration||activePage!=='heritage-records'||kind!==heritageKind)return;
heritageData=data;$('#heritage-error').textContent='';renderHeritageRecords();
}catch(e){if(!e.stale&&generation===heritageGeneration&&activePage==='heritage-records'){$('#heritage-error').textContent=e.message;heritageData={entries:[],canEdit:false};}}
}
function renderHeritageRecords(){
$('#heritage-new').hidden=!heritageData.canEdit;
const q=$('#heritage-search').value.trim().toLowerCase();
const rows=heritageData.entries.filter(r=>r.kind===heritageKind&&(!r.archived||$('#heritage-archived').checked)&&(!q||[r.name,r.content,r.contributor,r.ownerName,r.custodianName,r.successorName].join(' ').toLowerCase().includes(q)));
$('#heritage-list').innerHTML=rows.length?rows.map(r=>`<article class="site-card"><span class="badge">${r.archived?'已归档':r.kind==='culture'?'文化资料':'财产档案'}</span><h3><button class="device-title" data-heritage-detail="${esc(r.id)}">${esc(r.name)} ›</button></h3><p class="description">${esc((r.content||'尚未填写详细内容').slice(0,120))}</p><small>${esc(r.date||'日期待记')} · ${esc(r.contributor||r.updatedBy||'')}</small></article>`).join(''):empty('从一份档案开始','记录来历、故事与相关人员,资料仅向获授权的本家庭成员开放。');
}
function showHeritageRecord(id){
const r=heritageData.entries.find(x=>x.id===id);if(!r)return;
heritageSelection=id;const device=ZhaoDevices.catalog(state).find(d=>d.id===r.deviceId&&d.source===r.deviceSource);
const fields=[['相关日期',r.date],['讲述 / 整理',r.contributor],...(r.kind==='asset'?[['所有人 / 共有说明',r.ownerName],['保管人',r.custodianName],['意向接收人',r.successorName]]:[])];
$('#heritage-detail').hidden=false;
$('#heritage-detail').innerHTML=`<div class="section-head"><h2>${esc(r.name)}</h2>${heritageData.canEdit?`<button data-heritage-edit="${esc(r.id)}">编辑档案</button>`:''}<button class="quiet" id="heritage-detail-close">收起详情</button></div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v||'未填写')}</dd></div>`).join('')}</dl><div class="heritage-prose">${esc(r.content||'')}</div>${r.sourceUrl?`<p><a href="${esc(r.sourceUrl)}" target="_blank" rel="noopener noreferrer">打开相关资料或报道 ↗</a></p>`:''}${device?`<button class="quiet" data-view-device="${esc(device.id)}" data-device-source="${device.source}">查看关联设备:${esc(device.name)}</button>`:''}${r.kind==='asset'?`<h3>交接意愿与说明</h3><div class="heritage-prose">${esc(r.handoverNote||'尚未记录')}</div><p class="description">档案中的交接意愿不自动改变所有权、保管安排或账号权限。</p>`:''}<p class="description">最近整理:${esc(r.updatedBy)} · ${esc(new Date(r.updatedAt).toLocaleString('zh-CN'))} · 版本 ${r.revision}</p>`;
}
function openHeritage(id){
if(!heritageData.canEdit)return;
const r=heritageData.entries.find(x=>x.id===id)||{kind:heritageKind,revision:0};
const f=$('#heritage-form');
f.elements.deviceReference.innerHTML='<option value="">不关联设备</option>'+ZhaoDevices.catalog(state).map(d=>`<option value="${d.source}:${esc(d.id)}">${esc(d.name+' · '+siteName(d.siteId))}</option>`).join('');
fillForm(f,r);f.elements.deviceReference.value=r.deviceId?r.deviceSource+':'+r.deviceId:'';
$('#heritage-form-title').textContent=r.kind==='culture'?'文化资料':'物品与财产档案';
$$('[data-heritage-asset]').forEach(el=>el.hidden=r.kind!=='asset');
$('#heritage-dialog').showModal();
}
$('#heritage-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';
try{const data=formValues(f),[source,id]=(data.deviceReference||'').split(':');delete data.deviceReference;
data.revision=Number(data.revision);data.archived=f.elements.archived.checked;data.deviceSource=source||'';data.deviceId=id||'';
await api('/api/heritage',data);f.closest('dialog').close();await loadHeritage();toast('档案已保存');
}catch(e){if(!e.stale)f.querySelector('.form-error').textContent=e.message;}finally{b.disabled=false;}
});
$('#heritage-new').onclick=()=>openHeritage();$('#heritage-reload').onclick=loadHeritage;
$('#heritage-search').oninput=renderHeritageRecords;$('#heritage-archived').onchange=renderHeritageRecords;
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
if(b.dataset.heritageKind){resetHeritage();heritageKind=b.dataset.heritageKind;$('#heritage-search').value='';$('#heritage-archived').checked=false;showPage('heritage-records');}
if(b.dataset.heritageDetail)showHeritageRecord(b.dataset.heritageDetail);
if(b.dataset.heritageEdit)openHeritage(b.dataset.heritageEdit);
if(b.id==='heritage-detail-close'){$('#heritage-detail').hidden=true;heritageSelection='';}
});
+4 -4
View File
@@ -1,12 +1,12 @@
'use strict';
pageInfo.households=['家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
pageInfo.households=['我的家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
function renderHouseholds(){
const homes=state.households||[];
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
$('#household-filter').value=state.familyId||'';
if(activePage!=='households')return;
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">${h.id===state.familyId?'管理本归属成员':'进入该归属'}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</article>`).join('');
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span>${isAdmin()?`<button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button>`:''}</div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">进入${esc(h.name)}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</article>`).join('');
}
async function switchHousehold(id){
if(!isPlatformAdmin()||!id||id===state.familyId)return;
@@ -41,7 +41,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=e=>switchHousehold(e.target.value);
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
@@ -53,5 +53,5 @@ 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('users');}
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');}
});
+23 -19
View File
@@ -2,48 +2,51 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<title>赵府 · 智能数字管家</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/device-model.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script><script defer src="/steward.js?v=__ASSET_VERSION__"></script><script defer src="/heritage.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>家和 · 岁岁安宁</small></span></a>
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府<small>智能数字管家</small></span></a>
<div class="nav-label">家宅 · 起居</div>
<nav aria-label="主导航">
<button data-page="live" class="active">◫ <span>实时画面</span></button>
<button data-page="playback">◷ <span>录像回放</span></button>
<button data-page="family">♧ <span>家谱</span></button>
<button data-page="households" data-admin>⌂ <span>家庭与场所</span></button>
<button data-page="users" data-admin>♙ <span>账号与关系</span></button>
<button data-page="objects">▣ <span>设备管理</span></button>
<button data-page="sites">⌂ <span>区域与空间</span></button>
<button data-page="storage">▤ <span>录像与存储</span></button>
<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="overview">◫ <span>场所总览</span></button>
<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="storage">▤ <span>平台存储与审计</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span>家庭、办公场所与工厂独立授权</span></div>
<div id="banner" class="banner" hidden></div>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">我的家庭与场所</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span id="household-context-note"></span><button id="scope-back" class="quiet">返回场所</button></div>
<div id="device-context" class="device-context" hidden><p id="device-breadcrumb"></p><div role="group" aria-label="设备功能"><button class="quiet" data-device-tab="device">设备档案</button><button class="quiet" data-device-tab="live">实时画面</button><button class="quiet" data-device-tab="playback">录像回放</button><button class="quiet" data-device-tab="cameras">镜头与设置</button></div></div><div id="banner" class="banner" hidden></div>
<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-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></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 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-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">+ 其他设备</button><button data-page="cameras" class="quiet">镜头通道与接入</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 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 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-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>
<section id="page-heritage-records" class="page" hidden><div class="section-head"><h2 id="heritage-records-title">文化与家风</h2><div><button class="quiet" data-page="heritage">返回家庭传承</button><button id="heritage-new">+ 新建档案</button></div></div><div class="panel heritage-controls"><label>查找档案<input id="heritage-search" type="search" placeholder="名称、内容、相关人员"></label><label class="check"><input id="heritage-archived" type="checkbox">包含归档资料</label><button id="heritage-reload" class="quiet">重新载入</button></div><p id="heritage-error" class="form-error" role="status"></p><div id="heritage-list" class="site-grid"></div><aside id="heritage-detail" class="panel" hidden></aside></section>
<section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
@@ -67,17 +70,18 @@
</section>
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div id="household-list" class="site-grid"></div></section>
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
<footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
<footer><span>赵府 · 智能数字管家</span><span id="version">独立部署 / 数据自有</span></footer>
</main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<datalist id="relationship-choices"></datalist>
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
+3 -3
View File
@@ -1,11 +1,11 @@
'use strict';
let wallOwner='',wallOrder=[],wallPage=0,wallDragging='',ptzCamera='',ptzGeneration=0;
const WALL_LAYOUTS={auto:[0,0],1:[1,1],2:[2,1],4:[2,2],6:[3,2],9:[3,3],16:[4,4]};
function wallStorageKey(){return 'zhaofu-monitor-'+(currentUser?.id||'guest');}
function wallStorageKey(){return 'zhaofu-monitor-'+(currentUser?.id||'guest')+'-'+(state.familyId||'')+'-'+(deviceContext?.id||'wall');}
function wallSave(){try{localStorage.setItem(wallStorageKey(),JSON.stringify({order:wallOrder,layout:$('#wall-layout').value}));}catch{}}
function wallSync(){
if(wallOwner!==(currentUser?.id||'')){
wallOwner=currentUser?.id||'';wallPage=0;let saved={};try{saved=JSON.parse(localStorage.getItem(wallStorageKey()))||{};}catch{}
if(wallOwner!==wallStorageKey()){
wallOwner=wallStorageKey();wallPage=0;let saved={};try{saved=JSON.parse(localStorage.getItem(wallStorageKey()))||{};}catch{}
wallOrder=Array.isArray(saved.order)?saved.order.filter(x=>/^[a-f0-9]{16}$/.test(x)):[];
$('#wall-layout').value=Object.hasOwn(WALL_LAYOUTS,saved.layout)?saved.layout:'auto';
}
+66
View File
@@ -0,0 +1,66 @@
'use strict';
let deviceContext=null,scopeEntered=false;
Object.assign(pageInfo,{overview:['场所总览','人、区域与设备,在这里各有归属。'],device:['设备档案','查看这台设备的位置、责任人与功能。'],heritage:['家庭传承','把人物、记忆、文化与物品的来历留存下来。']});
function currentHome(){return (state.households||[]).find(h=>h.id===state.familyId);}
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;
if(context.source==='camera')return cameras.filter(c=>c.assetId===context.id);
if(context.source==='recorder')return cameras.filter(c=>c.recorderId===context.id||c.archiveRecorderId===context.id);
return [];
}
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;
showPage(tab==='device'&&source==='camera'&&deviceCameras(state.cameras).length?'live':tab);
}
function renderSteward(){
const home=currentHome(),device=selectedDevice(),within=loggedIn&&scopeEntered&&!!home;
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-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||'场所');
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)){
$('#page-title').textContent=device.name;
$('#page-note').textContent=pageInfo[activePage][0]+' · '+(home?.name||'');
}
if(activePage==='live')$('#live-heading').textContent=device?'实时画面 · 镜头':'本场所监控墙';
if(activePage==='overview')renderOverview();
if(activePage==='device')renderDeviceDetail(device);
if(activePage==='heritage')renderHeritageHome();
const homeKind=!home?.kind||home.kind==='home';
$('nav [data-page="heritage"]').hidden=!homeKind||(!isAdmin()&&currentUser?.familyAccess==='none'&&currentUser?.heritageAccess!=='read'&&currentUser?.heritageAccess!=='edit');
}
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>`;
}
function renderDeviceDetail(device){
if(!device){$('#device-detail').innerHTML=empty('设备暂不可用','返回设备目录重新选择');return;}
const fields=[['类型',device.deviceType],['所属场所',currentHome()?.name],['所在区域',siteName(device.siteId)],['所有人',device.ownerName||'待指定'],['负责人',device.managerName||'待指定'],['品牌 / 型号',[device.brand,device.model].filter(Boolean).join(' / ')||'待填写'],['序列号',device.serial||'待填写']];
const attr=device.source==='camera'?'edit-asset':device.source==='recorder'?'edit-recorder':'edit-device';
$('#device-detail').innerHTML=`<section class="panel"><div class="section-head"><h2>一物一档</h2>${isAdmin()?`<button class="quiet" data-${attr}="${esc(device.id)}">编辑设备档案</button>`:''}</div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl><p class="description">${esc(device.note||'')}</p>${device.source==='device'?'<p class="description">当前已登记档案;自动控制需按实际设备协议接入。</p>':''}${device.source==='recorder'?'<p class="description">可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。</p>':''}</section>`;
}
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>`;
}
function resetSteward(){deviceContext=null;scopeEntered=false;for(const id of ['scope-overview','device-detail','heritage-home'])$('#'+id).replaceChildren();}
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
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.dataset.familySection){familyView=b.dataset.familySection;showPage('family',{keepFamilyView:true});}
});