Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eef46f6ec0 | |||
| 427fd51e14 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
data/
|
||||
/data/
|
||||
recordings/
|
||||
runtime/
|
||||
*.db*
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# 赵府智家 · 视觉系统
|
||||
|
||||
## v0.1.32 · 真实网盘验收与保存反馈
|
||||
|
||||
- 修复家庭档案已保存、后续列表刷新失败时提示不可见的问题;立即提示保存结果,刷新失败单独提示,无需重复提交。
|
||||
- 网盘设置中展示封面、平台外观、个人记事、数据库备份四类存储说明;修正“全部本地存储”的旧文案。
|
||||
- 异常国家编码类型统一返回字段校验错误,不再触发服务器异常,失败保留原档案。
|
||||
- 真实百度网盘验收:在应用内“验收资料”目录上传 100867 字节合成图片,清空进程缓存后读回校验一致;重复上传返回相同文件 ID。正式家庭的已上传封面也通过独立回读及浏览器刷新、重新打开验证。验收不修改正式家庭档案。
|
||||
- 手机宽度检查覆盖已上传封面与编辑对话框。自动文件选择器仍受 Edge 扩展文件 URL 权限限制,不将接口上传或用户自行上传等同为自动选文件验收。
|
||||
|
||||
验证:150 项 Python、120 项 JavaScript 回归通过。真实网盘和正式封面回读校验通过;360px 网盘说明、390px 封面及对话框无横向溢出。上线前备份数据库,发布源码后部署。
|
||||
|
||||
## v0.1.31 · 地域分级选择与网盘图片
|
||||
|
||||
- 家庭/场所、空间和家谱籍贯/出生地共用地域控件:中国省市区联动、世界国家/地区选择、自定义历史地名或其他天体。更换上级清空下级;服务端以编码校验父子关系。旧地名无法匹配时原样保留为自定义,权限仍按家庭/场所划分。
|
||||
- 离线内置中国 2023-06-30 区划快照,来源 [Administrative-divisions-of-China](https://github.com/modood/Administrative-divisions-of-China),该项目不再更新。港澳台下级及新设区划可手填或自定义;世界下级行政区不声称完整收录。国家/地区名称来源 [Unicode CLDR](https://github.com/unicode-org/cldr-json),许可证随数据打包于 web/data/GEOGRAPHY-LICENSES.txt。
|
||||
- 封面支持 30 MiB 内、8000 万像素内的 PNG/JPEG/WebP 原图,由浏览器缩小至最长 960px、至多 600 KiB 后上传。读取失败阻止保存;服务端确认网盘上传并回读校验后才保存引用。保存失败保持原档案,再次打开按持久化引用加载。
|
||||
- 所有新选择的封面、Logo、图标存百度网盘。数据库只保存 provider/fsId/remotePath/size/sha256;短时图片缓存仅在内存,最多 16 MiB、10 分钟。文字、人员与家庭权限仍由本系统管理;图片地址经鉴权代理,不暴露网盘凭据。默认内置标识仍是程序资源。
|
||||
- 网盘分类:`家庭与场所/<实例>/<家庭>/封面`、`平台外观/<实例>/platform/Logo或图标`、`个人记事/<实例>/<家庭>/<用户>/照片或视频或语音`。数据库备份保留原独立目录与一个自然月云端保留策略。旧记事附件链接继续有效,不移动或删除云端原文件。
|
||||
- 支持显式迁移旧封面和品牌图;每张上传回读校验成功后才移除数据库中的内嵌图片。已产生的历史数据库备份按原保留策略自然过期。外观导出保留文字与版式,已上传网盘图片不嵌入导出文件。
|
||||
|
||||
验证:148 项 Python、116 项 JavaScript 回归通过。Edge 实测中国联动清空、世界地址保存与回填、自定义火星路径、390px 对话框无横向溢出、接口上传后刷新重开封面。自动文件选择受 Edge 扩展“允许访问文件 URL”权限限制;大图读取/压缩与失败阻止保存由单元测试覆盖,图片上传回读由接口测试覆盖,不将这些测试混同为真实文件选择器上传成功。
|
||||
|
||||
## v0.1.30 · 日常操作流程与手机网页
|
||||
|
||||
- 家庭或办公、工厂是权限归属;内部区域可分多级。设备提供按区域、类型、负责人三个快捷视图,并保留自定义组合。搜索支持“卧室 灯”等多词组合,涵盖区域、品牌、人员与标签。
|
||||
|
||||
@@ -31,10 +31,12 @@ import onvif
|
||||
import playback
|
||||
import identity
|
||||
import genealogy
|
||||
import geography
|
||||
import households
|
||||
import inventory
|
||||
import heritage
|
||||
import appearance
|
||||
import cloud_images
|
||||
import backups
|
||||
import baidu
|
||||
import journal
|
||||
@@ -87,6 +89,7 @@ APPEARANCE = appearance.Appearance(sys.modules[__name__])
|
||||
BACKUPS = backups.Backups(sys.modules[__name__])
|
||||
BAIDU = baidu.Baidu(sys.modules[__name__])
|
||||
JOURNAL = journal.Journal(sys.modules[__name__])
|
||||
CLOUD_IMAGES = cloud_images.CloudImages(sys.modules[__name__])
|
||||
|
||||
|
||||
def setting(key, default=None):
|
||||
@@ -190,6 +193,7 @@ def save_site(body, actor=None):
|
||||
required=k == 'name') for k in SITE_FIELDS}
|
||||
item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
|
||||
category=clean_text(body.get('category', ''), 40))
|
||||
geography.apply(item, body, get_object('sites', key), clean_text, Problem)
|
||||
if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
|
||||
raise Problem('请选择有效区域用途')
|
||||
save_object('sites', item)
|
||||
@@ -893,8 +897,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.answer(APPEARANCE.public())
|
||||
return
|
||||
if path in ('/brand-logo.png', '/brand-icon.png'):
|
||||
value = APPEARANCE.config().get('logoData' if path == '/brand-logo.png' else 'iconData', '')
|
||||
raw = base64.b64decode(value.split(',', 1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
|
||||
kind='logo' if path == '/brand-logo.png' else 'icon'
|
||||
config=APPEARANCE.config();value=config.get(kind+'Data','')
|
||||
raw = CLOUD_IMAGES.get(config[kind+'Ref']) if config.get(kind+'Ref') else base64.b64decode(value.split(',',1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
|
||||
self.image(raw, public=True)
|
||||
return
|
||||
if path == '/api/session':
|
||||
@@ -942,7 +947,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.answer(APPEARANCE.preferences(self.user))
|
||||
elif path == '/api/appearance':
|
||||
self.require_admin()
|
||||
self.answer(APPEARANCE.config())
|
||||
self.answer(APPEARANCE.editable())
|
||||
elif path == '/api/backups':
|
||||
self.require_admin()
|
||||
self.answer(BACKUPS.status())
|
||||
@@ -950,8 +955,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
home = get_object('households', query.get('id'))
|
||||
if not home or self.user['role'] != 'admin' and home['id'] != HOUSEHOLDS.scope(self.user):
|
||||
raise Problem('封面不存在或未获授权', 404)
|
||||
if not home.get('coverData'): raise Problem('尚未上传封面', 404)
|
||||
self.image(base64.b64decode(home['coverData'].split(',', 1)[1]))
|
||||
if not home.get('coverRef') and not home.get('coverData'): raise Problem('尚未上传封面', 404)
|
||||
self.image(CLOUD_IMAGES.get(home['coverRef']) if home.get('coverRef') else base64.b64decode(home['coverData'].split(',', 1)[1]))
|
||||
elif path == '/api/users':
|
||||
self.require_manager()
|
||||
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
|
||||
@@ -1047,6 +1052,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
|
||||
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js',
|
||||
'/journal.js': 'journal.js', '/file-hash.js': 'file-hash.js',
|
||||
'/geography.js': 'geography.js', '/data/geography-data.js': 'data/geography-data.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',
|
||||
|
||||
+18
-4
@@ -89,8 +89,12 @@ class Appearance:
|
||||
value = self.config()
|
||||
return {**{k: value[k] for k in TEXT_LIMITS}, 'defaults': value['defaults'],
|
||||
'revision': value['revision'],
|
||||
'logoUrl': '/brand-logo.png?v='+str(value['revision']) if value.get('logoData') else '/zhao-icon.png',
|
||||
'iconUrl': '/brand-icon.png?v='+str(value['revision']) if value.get('iconData') else '/zhao-icon.png'}
|
||||
'logoUrl': '/brand-logo.png?v='+str(value['revision']) if value.get('logoData') or value.get('logoRef') else '/zhao-icon.png',
|
||||
'iconUrl': '/brand-icon.png?v='+str(value['revision']) if value.get('iconData') or value.get('iconRef') else '/zhao-icon.png'}
|
||||
|
||||
def editable(self):
|
||||
value=self.config();public=self.public()
|
||||
return dict({k:v for k,v in value.items() if k not in ('logoData','iconData','logoRef','iconRef')},logoUrl=public['logoUrl'],iconUrl=public['iconUrl'])
|
||||
|
||||
def validate_values(self, values, inherit=False):
|
||||
if not isinstance(values, dict) or set(values) != set(CHOICES):
|
||||
@@ -130,10 +134,20 @@ class Appearance:
|
||||
if icon:
|
||||
try: png_bytes(icon)
|
||||
except ValueError as error: raise a.Problem(str(error))
|
||||
result = dict(fields, defaults=defaults, logoData=logo, iconData=icon, revision=old['revision']+1)
|
||||
result=dict(fields,defaults=defaults,revision=old['revision']+1)
|
||||
for field in ('logo','icon'):
|
||||
for suffix in ('Data','Ref'):
|
||||
if field+suffix in old:result[field+suffix]=old[field+suffix]
|
||||
for field,kind,value in [('logo','Logo',logo),('icon','图标',icon)]:
|
||||
if field+'Data' not in data:continue
|
||||
ref=a.CLOUD_IMAGES.put(value,'platform',kind,256*1024) if value else None
|
||||
result.pop(field+'Data',None);result.pop(field+'Ref',None)
|
||||
if ref:result[field+'Ref']=ref
|
||||
with a.LOCK:
|
||||
self.check_revision(data,self.config())
|
||||
a.set_setting('appearance', result)
|
||||
a.audit('更新发布与品牌配置', actor['username'])
|
||||
return result
|
||||
return self.editable()
|
||||
|
||||
def preferences(self, actor):
|
||||
saved = self.a.setting('preferences:'+actor['id'], {'revision': 0, 'values': dict.fromkeys(CHOICES, 'default')})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Baidu-hosted UI images. DB stores references; only a bounded RAM cache holds bytes."""
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from appearance import png_bytes
|
||||
|
||||
|
||||
class CloudImages:
|
||||
def __init__(self, app):
|
||||
self.a = app
|
||||
self.guard = threading.RLock()
|
||||
self.cache = OrderedDict()
|
||||
self.limit = 16*1024*1024
|
||||
|
||||
def remember(self, ref, raw):
|
||||
with self.guard:
|
||||
now=time.monotonic()
|
||||
for k,(until,_) in list(self.cache.items()):
|
||||
if until<now:self.cache.pop(k)
|
||||
self.cache[ref['sha256']] = (now+600,raw)
|
||||
self.cache.move_to_end(ref['sha256'])
|
||||
while sum(len(v[1]) for v in self.cache.values())>self.limit:self.cache.popitem(last=False)
|
||||
|
||||
def get(self, ref):
|
||||
with self.guard:
|
||||
found=self.cache.get(ref['sha256'])
|
||||
if found and found[0]>time.monotonic():return found[1]
|
||||
with self.a.BAIDU.open_file('shared',ref) as response:
|
||||
raw=response.read(ref['size']+1)
|
||||
if len(raw)!=ref['size'] or hashlib.sha256(raw).hexdigest()!=ref['sha256']:
|
||||
raise self.a.Problem('网盘图片校验未通过,请重新上传',502)
|
||||
self.remember(ref,raw)
|
||||
return raw
|
||||
|
||||
def put(self, value, scope, kind, maximum=2*1024*1024):
|
||||
if kind not in ('封面','Logo','图标') or not re.fullmatch(r'[a-f0-9]{16}|platform',scope):
|
||||
raise self.a.Problem('图片归属不正确')
|
||||
try:raw=png_bytes(value,maximum)
|
||||
except ValueError as error:raise self.a.Problem(str(error).replace('Logo',kind))
|
||||
a=self.a
|
||||
a.BAIDU.token('shared')
|
||||
with a.LOCK:
|
||||
instance=a.setting('cloud_instance')
|
||||
if not instance:instance=secrets.token_hex(8);a.set_setting('cloud_instance',instance)
|
||||
digest=hashlib.sha256(raw).hexdigest()
|
||||
category='平台外观' if scope=='platform' else '家庭与场所'
|
||||
remote='/apps/'+a.BAIDU.public_config()['appFolder']+'/'+category+'/'+instance+'/'+scope+'/'+kind+'/'+digest+'.png'
|
||||
md5=hashlib.md5(raw).hexdigest();hashes=[md5]
|
||||
pre=a.BAIDU.prepare(remote,len(raw),hashes)
|
||||
result=pre.get('existing')
|
||||
if not result:
|
||||
needed=pre.get('block_list',[0])
|
||||
if not isinstance(needed,list) or any(type(i) is not int or i!=0 for i in needed):raise a.Problem('网盘图片分片信息不正确',502)
|
||||
if 0 in needed:a.BAIDU.send_block(remote,pre['uploadid'],0,raw,md5)
|
||||
result=a.BAIDU.complete(dict(remotePath=remote,size=len(raw),uploadId=pre['uploadid'],hashes=hashes))
|
||||
if not result.get('fs_id') or result.get('path')!=remote or result.get('size')!=len(raw):
|
||||
raise a.Problem('网盘尚未确认图片,请重试',502)
|
||||
ref=dict(provider='baidu',fsId=str(result['fs_id']),remotePath=remote,size=len(raw),sha256=digest,mime='image/png')
|
||||
# Verify by reading the remote file before committing the reference, including retries.
|
||||
with a.BAIDU.open_file('shared',ref) as response:
|
||||
check=response.read(len(raw)+1)
|
||||
if check!=raw:raise a.Problem('网盘图片回读校验失败,原有封面未改变',502)
|
||||
self.remember(ref,raw)
|
||||
return ref
|
||||
|
||||
def migrate_legacy(self):
|
||||
"""Explicit admin maintenance; replace each reference only after cloud verification."""
|
||||
a=self.a;result=[]
|
||||
for home in a.objects('households'):
|
||||
value=home.get('coverData')
|
||||
if not value:continue
|
||||
ref=self.put(value,home['id'],'封面')
|
||||
with a.LOCK:
|
||||
current=a.get_object('households',home['id'])
|
||||
if current.get('coverData')!=value:continue
|
||||
current.pop('coverData',None);current['coverRef']=ref
|
||||
current['profileRevision']=current.get('profileRevision',0)+1
|
||||
current['coverRevision']=ref['sha256'][:16];a.save_object('households',current)
|
||||
result.append('household:'+home['id'])
|
||||
for kind,field in [('Logo','logo'),('图标','icon')]:
|
||||
old=a.APPEARANCE.config();value=old.get(field+'Data')
|
||||
if not value:continue
|
||||
ref=self.put(value,'platform',kind,256*1024)
|
||||
with a.LOCK:
|
||||
current=a.APPEARANCE.config()
|
||||
if current.get(field+'Data')!=value:continue
|
||||
current.pop(field+'Data',None);current[field+'Ref']=ref
|
||||
current['revision']+=1;a.set_setting('appearance',current)
|
||||
result.append('brand:'+field)
|
||||
return result
|
||||
@@ -1,4 +1,5 @@
|
||||
"""A private family register, independent from login accounts and camera roles."""
|
||||
import geography
|
||||
import datetime as dt
|
||||
import json
|
||||
import secrets
|
||||
@@ -186,6 +187,8 @@ class Family:
|
||||
value = data.get(key, (old or {}).get(key, ''))
|
||||
if not isinstance(value, str): raise a.Problem('地域信息须为文字')
|
||||
item[key] = a.clean_text(value, 200 if field == 'Address' else 80)
|
||||
for prefix in ('native', 'birth'):
|
||||
geography.apply(item, data, old, a.clean_text, a.Problem, prefix)
|
||||
for key in ('birthDate', 'deathDate'):
|
||||
if item[key]:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Versioned offline location paths. Administrative geography is not an ACL boundary."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DATA = json.loads((Path(__file__).parent/'web/data/geography.json').read_text(encoding='utf8'))
|
||||
|
||||
|
||||
def normalize(value, clean):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('地域信息格式不正确')
|
||||
mode = value.get('mode')
|
||||
if mode not in ('china', 'world', 'custom'):
|
||||
raise ValueError('请选择中国、世界或自定义地域')
|
||||
result = dict(version=1, mode=mode, dataset=DATA['version'], body='地球', countryCode='', country='',
|
||||
provinceCode='', cityCode='', districtCode='', province='', city='', district='', path='')
|
||||
if mode == 'china':
|
||||
result.update(countryCode='CN', country='中国')
|
||||
children = DATA['china']
|
||||
manual = False
|
||||
for field in ('province', 'city', 'district'):
|
||||
code = value.get(field+'Code', '')
|
||||
if not isinstance(code, str): raise ValueError('地域编码格式不正确')
|
||||
if manual:
|
||||
result[field] = clean(value.get(field, ''), 100)
|
||||
continue
|
||||
if not code:
|
||||
if any(value.get(k+'Code') for k in ('city','district') if ('province','city','district').index(k)>('province','city','district').index(field)):
|
||||
raise ValueError('请先选择上级地区')
|
||||
continue
|
||||
item = next((x for x in children if x['code']==code), None)
|
||||
if not item: raise ValueError('地区不属于所选上级,请重新选择')
|
||||
result[field+'Code'], result[field] = item['code'], item['name']
|
||||
manual = item.get('manual', False)
|
||||
children = item.get('children', [])
|
||||
elif mode == 'world':
|
||||
code = value.get('countryCode', '')
|
||||
if not isinstance(code, str) or code not in DATA['countries']: raise ValueError('请选择国家或地区')
|
||||
if code == 'CN': raise ValueError('中国地区请使用中国分级选择')
|
||||
result.update(countryCode=code, country=DATA['countries'][code])
|
||||
for field in ('province','city','district'):
|
||||
result[field] = clean(value.get(field, ''), 100)
|
||||
else:
|
||||
result['body'] = clean(value.get('body', '地球'), 100, True)
|
||||
for field in ('country','province','city','district'):
|
||||
result[field] = clean(value.get(field, ''), 100)
|
||||
result['path'] = clean(value.get('path', ''), 1000)
|
||||
return result
|
||||
|
||||
|
||||
def apply(item, data, old, clean, problem, prefix=''):
|
||||
key = prefix+'Geography' if prefix else 'geography'
|
||||
if key not in data:
|
||||
if key in (old or {}):
|
||||
previous = old[key]
|
||||
names = {f:prefix+f.title() if prefix else f for f in ('country','province','city','district')}
|
||||
if any(k in data and data[k] != old.get(k,'') for k in names.values()):
|
||||
# An older client can edit text but cannot keep now-inconsistent codes.
|
||||
item[key] = normalize(dict(mode='custom',body=previous.get('body','地球'),
|
||||
path=previous.get('path',''),**{f:data.get(k,old.get(k,'')) for f,k in names.items()}),clean)
|
||||
else: item[key] = previous
|
||||
return
|
||||
try: result = normalize(data[key], clean)
|
||||
except ValueError as error: raise problem(str(error))
|
||||
item[key] = result
|
||||
for field in ('country','province','city','district'):
|
||||
item[prefix+field.title() if prefix else field] = result[field]
|
||||
+19
-5
@@ -5,6 +5,7 @@ import re
|
||||
import secrets
|
||||
import hashlib
|
||||
from appearance import png_bytes
|
||||
import geography
|
||||
|
||||
|
||||
class Households:
|
||||
@@ -113,7 +114,7 @@ class Households:
|
||||
account = self.a.get_object('users', uid)
|
||||
return account.get('name', '') if account else ''
|
||||
return [dict({k: v for k, v in row.items() if k != 'coverData' and (k != 'leadershipHistory' or self.manager(user))}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
|
||||
coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') else '',
|
||||
coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') or row.get('coverRef') else '',
|
||||
canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'],
|
||||
members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')),
|
||||
sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows]
|
||||
@@ -170,17 +171,30 @@ class Households:
|
||||
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
|
||||
for field in ('province','city','district'):
|
||||
item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60)
|
||||
geography.apply(item, data, old, a.clean_text, a.Problem)
|
||||
if 'coverData' in data:
|
||||
cover = data['coverData']
|
||||
if not isinstance(cover, str): raise a.Problem('封面图片格式不正确')
|
||||
if cover:
|
||||
try: png_bytes(cover, 2*1024*1024)
|
||||
except ValueError as error: raise a.Problem(str(error).replace('Logo', '封面'))
|
||||
item['coverData'] = cover
|
||||
item['coverRevision'] = hashlib.sha256(cover.encode()).hexdigest()[:16]
|
||||
a.save_object('households', item)
|
||||
# Network I/O must not hold the database lock or stall unrelated requests.
|
||||
if 'coverData' in data:
|
||||
ref=a.CLOUD_IMAGES.put(data['coverData'],item['id'],'封面') if data['coverData'] else None
|
||||
item.pop('coverData',None);item.pop('coverRef',None)
|
||||
if ref:item['coverRef']=ref
|
||||
item['coverRevision']=ref['sha256'][:16] if ref else 'default'
|
||||
with a.LOCK:
|
||||
current=a.get_object('households',item['id'])
|
||||
if (current or {}).get('profileRevision',0)!=(old or {}).get('profileRevision',0):
|
||||
raise a.Problem('上传期间档案已变化,请重新打开后保存',409)
|
||||
# Preserve unrelated management/grouping updates made during the upload.
|
||||
committed=dict(current or {},**{k:v for k,v in item.items() if k not in ('leadershipHistory','ownerId','successorId','leadershipRevision','deviceGrouping')})
|
||||
if 'coverData' in data:committed.pop('coverData',None);committed.pop('coverRef',None)
|
||||
if item.get('coverRef'):committed['coverRef']=item['coverRef']
|
||||
a.save_object('households', committed)
|
||||
a.audit('保存家庭', item['name'])
|
||||
return item
|
||||
return committed
|
||||
|
||||
def revision_key(self, user):
|
||||
family = self.scope(user)
|
||||
|
||||
+2
-1
@@ -199,7 +199,8 @@ class Journal:
|
||||
instance = a.setting('cloud_instance')
|
||||
if not instance:
|
||||
instance = secrets.token_hex(8); a.set_setting('cloud_instance', instance)
|
||||
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/资料/'+instance+'/'+family+'/'+key+'/'+name
|
||||
category = '照片' if mime.startswith('image/') else '视频' if mime.startswith('video/') else '语音'
|
||||
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/个人记事/'+instance+'/'+family+'/'+user['id']+'/'+category+'/'+key+'/'+name
|
||||
item = dict(id=key, familyId=family, ownerId=user['id'], name=name, mime=mime, size=size,
|
||||
status='receiving', received=0, progress=0, error='', remotePath=remote, hashes=hashes,
|
||||
at=dt.datetime.now(dt.timezone.utc).isoformat())
|
||||
|
||||
+1
-1
@@ -107,7 +107,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), 27)
|
||||
self.assertEqual(len(assets), 29)
|
||||
version = (app.ROOT / 'VERSION').read_text().strip()
|
||||
for asset in assets:
|
||||
self.assertTrue(asset.endswith('?v=' + version))
|
||||
|
||||
+5
-2
@@ -17,7 +17,10 @@ def image_data():
|
||||
|
||||
|
||||
class AppearanceTests(unittest.TestCase):
|
||||
setUp=fixture.HouseholdTests.setUp
|
||||
def setUp(self):
|
||||
fixture.HouseholdTests.setUp(self)
|
||||
from test_cloud_images import install_cloud_fixture
|
||||
install_cloud_fixture(self,app)
|
||||
stop_server=fixture.HouseholdTests.stop_server
|
||||
request=fixture.HouseholdTests.request
|
||||
|
||||
@@ -74,7 +77,7 @@ class AppearanceTests(unittest.TestCase):
|
||||
self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed)
|
||||
self.assertIn('/api/households/cover?',listed['coverUrl'])
|
||||
saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称',profileRevision=1))[1]
|
||||
self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data())
|
||||
self.assertEqual(saved['city'],'烟台市');self.assertNotIn('coverData',saved);self.assertTrue(saved['coverRef']['fsId'])
|
||||
for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]:
|
||||
c=http.client.HTTPConnection(*self.server.server_address)
|
||||
c.request('GET',listed['coverUrl'],headers={'Cookie':'vision='+self.tokens[user['id']]})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import io
|
||||
import hashlib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from test_app import app
|
||||
import test_households as fixture
|
||||
from cloud_images import CloudImages
|
||||
|
||||
|
||||
def install_cloud_fixture(test, app):
|
||||
"""Exercise the real image store over an isolated in-memory Baidu transport."""
|
||||
files={};blocks={}
|
||||
def prepare(remote,size,hashes):
|
||||
if remote in files:return {'existing':dict(fs_id=files[remote][0],path=remote,size=len(files[remote][1]))}
|
||||
return {'uploadid':'mock-upload','block_list':[0]}
|
||||
def block(remote,upload,index,raw,md5):
|
||||
assert hashlib.md5(raw).hexdigest()==md5;blocks[remote]=raw
|
||||
def complete(item):
|
||||
remote=item['remotePath'];files[remote]=(len(files)+1,blocks[remote])
|
||||
return dict(fs_id=files[remote][0],path=remote,size=len(files[remote][1]))
|
||||
def open_file(key,item,byte_range=''):
|
||||
return io.BytesIO(files[item['remotePath']][1])
|
||||
for obj,name,value in [(app,'CLOUD_IMAGES',CloudImages(app)),(app.BAIDU,'token',lambda *a:'fixture'),
|
||||
(app.BAIDU,'public_config',lambda:dict(appFolder='测试应用')),(app.BAIDU,'prepare',prepare),
|
||||
(app.BAIDU,'send_block',block),(app.BAIDU,'complete',complete),(app.BAIDU,'open_file',open_file)]:
|
||||
p=patch.object(obj,name,value);p.start();test.addCleanup(p.stop)
|
||||
return files
|
||||
|
||||
|
||||
class CloudImageTests(unittest.TestCase):
|
||||
stop_server=fixture.HouseholdTests.stop_server
|
||||
request=fixture.HouseholdTests.request
|
||||
def setUp(self):
|
||||
fixture.HouseholdTests.setUp(self)
|
||||
self.files=install_cloud_fixture(self,app)
|
||||
|
||||
def test_cover_is_uploaded_checked_and_db_contains_only_reference(self):
|
||||
from test_appearance import image_data
|
||||
result=app.HOUSEHOLDS.save(dict(id=self.aid,name='云封面',coverData=image_data()),self.root)
|
||||
self.assertNotIn('coverData',result)
|
||||
self.assertIn('/家庭与场所/',result['coverRef']['remotePath'])
|
||||
ref=result['coverRef'];app.CLOUD_IMAGES.cache.clear()
|
||||
self.assertEqual(hashlib.sha256(app.CLOUD_IMAGES.get(ref)).hexdigest(),ref['sha256'])
|
||||
self.assertEqual(len(self.files),1)
|
||||
app.HOUSEHOLDS.save(dict(id=self.aid,name='重复上传',profileRevision=1,coverData=image_data()),self.root)
|
||||
self.assertEqual(len(self.files),1)
|
||||
|
||||
def test_failed_upload_does_not_save_profile_or_replace_existing_cover(self):
|
||||
from test_appearance import image_data
|
||||
before=app.get_object('households',self.aid)
|
||||
with patch.object(app.BAIDU,'prepare',side_effect=app.Problem('网盘断线',502)):
|
||||
with self.assertRaises(app.Problem):app.HOUSEHOLDS.save(dict(id=self.aid,name='不应保存',coverData=image_data()),self.root)
|
||||
self.assertEqual(app.get_object('households',self.aid),before)
|
||||
|
||||
def test_conflict_during_upload_preserves_newer_profile(self):
|
||||
from test_appearance import image_data
|
||||
put=app.CLOUD_IMAGES.put
|
||||
def concurrent(*args):
|
||||
ref=put(*args);home=app.get_object('households',self.aid)
|
||||
home.update(name='他人刚修改',profileRevision=1);app.save_object('households',home);return ref
|
||||
with patch.object(app.CLOUD_IMAGES,'put',side_effect=concurrent):
|
||||
with self.assertRaises(app.Problem):app.HOUSEHOLDS.save(dict(id=self.aid,name='旧编辑',coverData=image_data()),self.root)
|
||||
self.assertEqual(app.get_object('households',self.aid)['name'],'他人刚修改')
|
||||
|
||||
def test_legacy_migration_is_verified_and_idempotent(self):
|
||||
from test_appearance import image_data
|
||||
home=app.get_object('households',self.aid);home['coverData']=image_data();app.save_object('households',home)
|
||||
self.assertEqual(len(app.CLOUD_IMAGES.migrate_legacy()),1)
|
||||
self.assertNotIn('coverData',app.get_object('households',self.aid))
|
||||
self.assertEqual(app.CLOUD_IMAGES.migrate_legacy(),[])
|
||||
|
||||
def test_corrupt_remote_fails_before_commit_and_no_disk_cache(self):
|
||||
from test_appearance import image_data
|
||||
with patch.object(app.BAIDU,'open_file',return_value=io.BytesIO(b'corrupt')):
|
||||
with self.assertRaises(app.Problem):app.CLOUD_IMAGES.put(image_data(),self.aid,'封面')
|
||||
self.assertEqual(list(app.CLOUD_IMAGES.cache),[])
|
||||
@@ -0,0 +1,26 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict');
|
||||
const geo=require('./web/geography.js');
|
||||
test('legacy known China path upgrades losslessly',()=>{
|
||||
const v=geo.infer({province:'山东省',city:'烟台市',district:'莱阳市'});
|
||||
assert.equal(v.mode,'china');assert.equal(v.districtCode,'370682');
|
||||
assert.match(geo.label(v),/山东省 · 烟台市 · 莱阳市/);
|
||||
});
|
||||
test('unknown historic names are preserved in custom mode',()=>{
|
||||
const v=geo.infer({nativeCountry:'中国',nativeProvince:'山东',nativeCity:'登州府'},'native');
|
||||
assert.equal(v.mode,'custom');assert.equal(v.city,'登州府');
|
||||
});
|
||||
test('changing an ancestor clears every lower selection',()=>{
|
||||
let v=geo.infer({province:'山东省',city:'烟台市',district:'莱阳市'});
|
||||
v=geo.change(v,'provinceCode','11');assert.equal(v.province,'北京市');
|
||||
assert.equal(v.city,'');assert.equal(v.cityCode,'');assert.equal(v.districtCode,'');
|
||||
v=geo.change(v,'mode','world');assert.equal(v.provinceCode,'');assert.equal(v.countryCode,'');
|
||||
v=geo.change(v,'countryCode','US');assert.equal(v.country,'美国');
|
||||
v=geo.change({...v,city:'Boston'},'countryCode','JP');assert.equal(v.city,'');
|
||||
});
|
||||
test('self-defined celestial names and deep paths display without pretending to be administrative codes',()=>{
|
||||
const v=geo.infer({geography:{mode:'custom',body:'火星',path:'基地 / 居住区 / 一栋 / 二层'}});
|
||||
assert.equal(geo.label(v),'火星 · 基地 / 居住区 / 一栋 / 二层');
|
||||
});
|
||||
test('world selection can return to China cascade',()=>{
|
||||
assert.equal(geo.change({mode:'world'},'countryCode','CN').mode,'china');
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
import geography
|
||||
from test_app import app
|
||||
import test_appearance as appearance_fixture
|
||||
|
||||
|
||||
class GeographyUnitTests(unittest.TestCase):
|
||||
def norm(self, value): return geography.normalize(value, app.clean_text)
|
||||
|
||||
def test_codes_resolve_canonical_names_and_reject_wrong_parent(self):
|
||||
v=dict(mode='china',provinceCode='37',cityCode='3706',districtCode='370682',city='假名字')
|
||||
result=self.norm(v)
|
||||
self.assertEqual((result['province'],result['city'],result['district']),('山东省','烟台市','莱阳市'))
|
||||
with self.assertRaises(ValueError):self.norm(dict(v,provinceCode='11'))
|
||||
with self.assertRaises(ValueError):self.norm(dict(v,cityCode=''))
|
||||
with self.assertRaises(ValueError):self.norm(dict(v,districtCode='999999'))
|
||||
|
||||
def test_partial_unknown_and_manual_paths(self):
|
||||
self.assertEqual(self.norm(dict(mode='china'))['province'],'')
|
||||
self.assertEqual(self.norm(dict(mode='china',provinceCode='81',city='香港岛',district='中西区'))['district'],'中西区')
|
||||
result=self.norm(dict(mode='world',countryCode='US',province='California',city='Los Angeles'))
|
||||
self.assertEqual(result['country'],'美国')
|
||||
with self.assertRaises(ValueError):self.norm(dict(mode='world',countryCode='ZZ'))
|
||||
with self.assertRaises(ValueError):self.norm(dict(mode='world',countryCode='CN'))
|
||||
result=self.norm(dict(mode='custom',body='火星',path='基地 / 一区 / 一栋'))
|
||||
self.assertEqual(result['body'],'火星');self.assertEqual(result['countryCode'],'')
|
||||
|
||||
def test_invalid_country_types_have_a_validation_error(self):
|
||||
for code in ([], {}, None, 42, True):
|
||||
with self.subTest(code=code),self.assertRaises(ValueError):
|
||||
self.norm(dict(mode='world',countryCode=code))
|
||||
|
||||
def test_legacy_edits_drop_stale_codes_and_other_updates_preserve(self):
|
||||
old=dict(geography=self.norm(dict(mode='china',provinceCode='37',cityCode='3706')),province='山东省',city='烟台市')
|
||||
item={};geography.apply(item,{},old,app.clean_text,app.Problem)
|
||||
self.assertEqual(item['geography']['cityCode'],'3706')
|
||||
geography.apply(item,dict(city='登州府'),old,app.clean_text,app.Problem)
|
||||
self.assertEqual(item['geography']['mode'],'custom');self.assertEqual(item['geography']['city'],'登州府')
|
||||
self.assertEqual(item['geography']['cityCode'],'')
|
||||
|
||||
|
||||
class GeographyIntegrationTests(unittest.TestCase):
|
||||
setUp=appearance_fixture.AppearanceTests.setUp
|
||||
stop_server=appearance_fixture.AppearanceTests.stop_server
|
||||
request=appearance_fixture.AppearanceTests.request
|
||||
|
||||
def test_bad_country_input_returns_400_and_preserves_profile(self):
|
||||
old=app.get_object('households',self.aid)
|
||||
status,result=self.request('/api/households',self.root,dict(id=self.aid,name='不应写入',geography=dict(mode='world',countryCode=[])))
|
||||
self.assertEqual(status,400);self.assertIn('国家',result['error'])
|
||||
self.assertEqual(app.get_object('households',self.aid),old)
|
||||
|
||||
def test_household_cover_and_path_roundtrip(self):
|
||||
v=dict(mode='china',provinceCode='37',cityCode='3706',districtCode='370682')
|
||||
status,saved=self.request('/api/households',self.root,dict(id=self.aid,name='封面与地域',geography=v,coverData=appearance_fixture.image_data()))
|
||||
self.assertEqual(status,200);self.assertEqual(saved['district'],'莱阳市')
|
||||
listed=self.request('/api/households',self.root)[1]['households']
|
||||
home=next(x for x in listed if x['id']==self.aid)
|
||||
self.assertEqual(home['geography']['districtCode'],'370682');self.assertTrue(home['coverUrl'])
|
||||
self.assertNotIn('coverData',home)
|
||||
code,_=self.request('/api/households',self.owner,dict(id=self.aid,name='错误地区',profileRevision=1,geography=dict(v,cityCode='1101')))
|
||||
self.assertEqual(code,400)
|
||||
self.assertEqual(app.get_object('households',self.aid)['name'],'封面与地域')
|
||||
|
||||
def test_person_and_site_preserve_world_and_celestial_locations(self):
|
||||
person=app.FAMILY.person(dict(name='人物',birthGeography=dict(mode='world',countryCode='JP',province='东京都'),nativeGeography=dict(mode='custom',body='地球',path='登州府 / 某村')),None)
|
||||
self.assertEqual(person['birthCountry'],'日本');self.assertEqual(person['nativeGeography']['path'],'登州府 / 某村')
|
||||
site=app.save_site(dict(name='未来基地',geography=dict(mode='custom',body='火星',path='基地 / 居住区 / 一区')),self.root)
|
||||
self.assertEqual(site['geography']['body'],'火星')
|
||||
@@ -30,3 +30,46 @@ test('owner is visibly distinct from family and super administrators',()=>{
|
||||
assert.equal(c.label({id:'backup',role:'family_admin'}),'家庭管理员');
|
||||
assert.equal(c.label({id:'root',role:'admin'}),'超级管理员');
|
||||
});
|
||||
|
||||
function saveHarness({saveError,refreshError}={}){
|
||||
const events=[],error={textContent:''},elements=[{disabled:false},{disabled:true}];
|
||||
elements.id={value:'home'};elements.profileRevision={value:0};
|
||||
const form={elements,querySelector:()=>error,closest:()=>({close:()=>events.push('closed')})};
|
||||
let submit;
|
||||
const state={households:[{id:'home',name:'原档案'}]};
|
||||
const c=vm.createContext({coverSaving:false,coverDraft:'data:image/png;base64,test',sessionGeneration:1,
|
||||
$:()=>({addEventListener:(_type,fn)=>{submit=fn;}}),imageJobPending:()=>false,imageJobs:new Map(),
|
||||
formValues:()=>({id:'home',name:'已保存'}),state,
|
||||
api:async()=>{if(saveError)throw saveError;return {id:'home',name:'已保存',profileRevision:1,coverRevision:'new',coverRef:{fsId:'1'}};},
|
||||
renderHouseholds:()=>events.push('rendered'),toast:t=>events.push(t),
|
||||
refresh:async()=>{events.push('refresh');if(refreshError)throw refreshError;}});
|
||||
vm.runInContext(part(homes,"$('#household-form').addEventListener('submit'",'document.addEventListener'),c);
|
||||
return {run:()=>submit({preventDefault(){},currentTarget:form}),events,error,elements,state,c};
|
||||
}
|
||||
|
||||
test('saved cover stays committed and success is visible before a failed refresh',async()=>{
|
||||
const h=saveHarness({refreshError:Error('network')});await h.run();
|
||||
assert.equal(h.state.households[0].name,'已保存');assert.equal(h.elements.profileRevision.value,1);
|
||||
assert.match(h.state.households[0].coverUrl,/v=new/);
|
||||
assert.ok(h.events.indexOf('封面已存百度网盘,归属档案已保存')<h.events.indexOf('refresh'));
|
||||
assert.match(h.events.at(-1),/无需重复保存/);assert.ok(h.events.includes('closed'));
|
||||
assert.deepEqual(h.elements.map(e=>e.disabled),[false,true]);assert.equal(h.c.coverSaving,false);
|
||||
});
|
||||
|
||||
test('failed cover save keeps the draft open and does not claim success',async()=>{
|
||||
const h=saveHarness({saveError:Error('网盘暂时不可用')});await h.run();
|
||||
assert.equal(h.state.households[0].name,'原档案');assert.equal(h.error.textContent,'网盘暂时不可用');
|
||||
assert.deepEqual(h.events,[]);assert.equal(h.c.coverDraft,'data:image/png;base64,test');
|
||||
});
|
||||
|
||||
test('a stale post-save refresh does not show a new-session warning',async()=>{
|
||||
const h=saveHarness({refreshError:Object.assign(Error('stale'),{stale:true})});await h.run();
|
||||
assert.equal(h.events.at(-1),'refresh');assert.equal(h.state.households[0].name,'已保存');
|
||||
});
|
||||
|
||||
test('storage guide names cloud categories and avoids old all-local claims',()=>{
|
||||
const html=fs.readFileSync('web/index.html','utf8');
|
||||
assert.ok(html.includes('id="baidu-storage-guide"'));
|
||||
for(const label of ['家庭与场所:','平台外观:','个人记事:','数据库备份:'])assert.ok(html.includes(label));
|
||||
assert.ok(!html.includes('自有部署 · 本地存储'));assert.ok(!app.includes('数据存于本机'));
|
||||
});
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ test('attachment preview uses a closable dialog and deletion has a restore path'
|
||||
});
|
||||
test('structured place formatting and search preserve separate ancestral/birth/legacy locations',()=>{
|
||||
const vm=require('node:vm'),js=fs.readFileSync('web/family.js','utf8');
|
||||
const c=vm.createContext({});vm.runInContext(js.slice(js.indexOf('function personLocation'),js.indexOf('function personName')),c);
|
||||
const c=vm.createContext({ZhaoGeography:require('./web/geography.js')});vm.runInContext(js.slice(js.indexOf('function personLocation'),js.indexOf('function personName')),c);
|
||||
const person={nativeProvince:'山东省',nativeCity:'烟台市',nativeDistrict:'莱阳市',birthCity:'济南市',birthplace:'旧村名'};
|
||||
assert.equal(c.personLocation(person,'native'),'山东省 · 烟台市 · 莱阳市');assert.equal(c.personLocation(person,'birth'),'济南市');assert.ok(c.personSearch(person).includes('旧村名'));
|
||||
});
|
||||
|
||||
+20
-1
@@ -36,7 +36,7 @@ test('backup messages distinguish local-only, network copy, and failure',()=>{
|
||||
});
|
||||
test('place cards group by city without exposing an inline photo payload',()=>{
|
||||
const out={innerHTML:''},filter={};const c=vm.createContext({state:{familyId:'a',households:[{id:'a',name:'Home',province:'Province',city:'City A',members:1,sites:1,coverUrl:'/api/households/cover?id=a',coverData:'PRIVATE-PHOTO'},{id:'b',name:'Office',city:'City B',kind:'office',members:1,sites:0}]},
|
||||
activePage:'households',setOptions:()=>{},$:s=>s==='#household-list'?out:filter,isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'});
|
||||
ZhaoGeography:require('./web/geography.js'),activePage:'households',setOptions:()=>{},$:s=>s==='#household-list'?out:filter,isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'});
|
||||
vm.runInContext(part(homes,'function renderHouseholds(','async function switchHousehold('),c);c.renderHouseholds();
|
||||
assert.match(out.innerHTML,/City A/);assert.match(out.innerHTML,/City B/);assert.match(out.innerHTML,/\/api\/households\/cover\?id=a/);
|
||||
assert.doesNotMatch(out.innerHTML,/PRIVATE-PHOTO|data-edit-household/);
|
||||
@@ -66,3 +66,22 @@ test('replacing or resetting an image invalidates older decode jobs',()=>{
|
||||
const first=c.nextImageJob('logo'),second=c.nextImageJob('logo');assert.equal(c.imageJobCurrent('logo',first),false);assert.equal(c.imageJobCurrent('logo',second),true);
|
||||
c.invalidateBrandImages();assert.equal(c.imageJobCurrent('logo',second),false);
|
||||
});
|
||||
|
||||
test('cover accepts a large original but compresses below proxy request limits',async()=>{
|
||||
let size=0;const canvas={getContext:()=>({drawImage(){}}),toDataURL:()=> 'data:image/png;base64,'+'A'.repeat(++size===1?1000000:500000)};
|
||||
const c=vm.createContext({createImageBitmap:async()=>({width:8000,height:6000,close(){}}),document:{createElement:()=>canvas}});
|
||||
vm.runInContext(part(source,'async function readImage(','for(const [id,type,max]'),c);
|
||||
const result=await c.readImage({type:'image/jpeg',size:15*1024*1024},960);
|
||||
assert.ok((result.length-22)*.75<600*1024);assert.ok(canvas.width<=960);
|
||||
await assert.rejects(c.readImage({type:'image/jpeg',size:31*1024*1024},960),/30 MB/);
|
||||
await assert.rejects(c.readImage({type:'image/jpeg',size:15*1024*1024},256),/5 MB/);
|
||||
});
|
||||
|
||||
test('failed and still-processing covers block profile save instead of reporting success',async()=>{
|
||||
let submit,calls=0;const error={textContent:''},f={querySelector:()=>error,addEventListener:(_,fn)=>submit=fn};
|
||||
const jobs=new Map([['cover',{error:true}]]);
|
||||
const c=vm.createContext({coverSaving:false,$:()=>f,imageJobs:jobs,imageJobPending:()=>false,api:async()=>{calls++;},formValues:()=>{throw Error('must not serialize failed image');}});
|
||||
vm.runInContext(part(homes,"$('#household-form').addEventListener('submit'",'document.addEventListener'),c);
|
||||
await submit({preventDefault(){},currentTarget:f});assert.equal(calls,0);assert.match(error.textContent,/封面未准备成功/);
|
||||
c.imageJobPending=()=>true;await submit({preventDefault(){},currentTarget:f});assert.match(error.textContent,/仍在处理/);
|
||||
});
|
||||
|
||||
+5
-5
@@ -53,7 +53,7 @@ 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 address(s) { return [ZhaoGeography.describe(s),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;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 = ''; }
|
||||
@@ -94,7 +94,7 @@ function render() {
|
||||
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
|
||||
$('#service-status').textContent = state.mediaOnline ? '视频服务已连接' : '视频服务未连接';
|
||||
$('#service-status').classList.toggle('off', !state.mediaOnline);
|
||||
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
|
||||
$('#version').textContent = state.version ? 'v' + state.version + ' / 独立部署 · 数据自有' : '独立部署 / 数据自有';
|
||||
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
|
||||
$('#banner').textContent = warning; $('#banner').hidden = !warning;
|
||||
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join(''));
|
||||
@@ -144,7 +144,7 @@ function renderCameras() {
|
||||
}
|
||||
function renderSites() {
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><button class="quiet" data-child-site="${esc(s.id)}">+ 新增下级区域</button><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><button class="quiet" data-child-site="${esc(s.id)}">+ 新增下级区域</button><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([ZhaoGeography.describe(s),s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
}
|
||||
function renderStorage() {
|
||||
const s=state.storage;
|
||||
@@ -154,7 +154,7 @@ function renderStorage() {
|
||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
|
||||
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}if(typeof onAppearanceSession==='function')onAppearanceSession(s.preferences);}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
|
||||
function openSite(id,parentId='') {const f=$('#site-form');resetEditorForm(f);f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(!item){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
|
||||
function openSite(id,parentId='') {const f=$('#site-form');resetEditorForm(f);f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(!item){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;ZhaoGeography.fill(f,item||{});$('#site-dialog').showModal();}
|
||||
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
|
||||
function setMobileMenu(open){$('.sidebar').classList.toggle('mobile-open',open);$('#mobile-nav-toggle').setAttribute('aria-expanded',String(open));$('#mobile-nav-toggle').textContent=open?'收起菜单':'☰ 菜单';}
|
||||
$('#mobile-nav-toggle').onclick=()=>setMobileMenu($('#mobile-nav-toggle').getAttribute('aria-expanded')!=='true');
|
||||
@@ -164,7 +164,7 @@ function resetEditorForm(form){
|
||||
// Hidden input values become reset defaults in browsers; always clear record identity explicitly.
|
||||
for(const field of form.querySelectorAll('input[type="hidden"]'))field.value=field.name.toLowerCase().includes('revision')?'0':'';
|
||||
}
|
||||
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
||||
function formValues(f) {return {...Object.fromEntries(new FormData(f).entries()),...(typeof ZhaoGeography!=='undefined'?ZhaoGeography.collect(f):{})};}
|
||||
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
|
||||
handleForm('site-form','/api/sites');
|
||||
handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enabled:f.elements.enabled.checked,record:f.elements.record.checked}));
|
||||
|
||||
@@ -341,3 +341,6 @@ html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink
|
||||
.device-context [role=group]{display:flex;flex-wrap:wrap;gap:8px}.device-context button{flex:1 1 42%;white-space:normal}.metrics{margin-bottom:20px}
|
||||
}
|
||||
@media(max-width:760px){.sidebar .brand{grid-column:1;max-width:100%;width:auto}.sidebar>.mobile-nav-toggle{grid-column:2;grid-row:1;width:auto;max-width:110px}.sidebar .brand-icon{flex-shrink:0}.sidebar .brand>span{overflow:hidden}.sidebar #brand-name{font-size:21px}}
|
||||
|
||||
/* Shared geography controls and cover feedback, including phone dialogs. */
|
||||
.geo-picker{min-width:0;border:1px solid var(--line,#d8d2c3);border-radius:10px;padding:14px;margin:0}.geo-picker legend{padding:0 8px}.geo-picker .geo-path{display:block;overflow-wrap:anywhere;margin-top:10px;font-size:.9rem}.geo-picker .form-grid{min-width:0}.geo-picker input,.geo-picker select{width:100%;min-width:0}#household-cover-status[data-error="true"]{color:#ad3c28;font-weight:600}#household-cover-preview{max-width:100%;max-height:240px;object-fit:contain}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
China data: https://raw.githubusercontent.com/modood/Administrative-divisions-of-China/master/dist/pca-code.json
|
||||
Snapshot: 2023-06-30; project no longer updates. HK/MO/TW root entries added; subdivisions entered manually.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
|
||||
|
||||
Country/region display names: Unicode CLDR, retrieved 2026-10-04
|
||||
https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-localenames-full/main/zh/territories.json
|
||||
|
||||
UNICODE LICENSE V3
|
||||
|
||||
COPYRIGHT AND PERMISSION NOTICE
|
||||
|
||||
Copyright © 2015-2024 Unicode, Inc.
|
||||
|
||||
NOTICE TO USER: Carefully read the following legal agreement. BY
|
||||
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
||||
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
||||
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
||||
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of data files and any associated documentation (the "Data Files") or
|
||||
software and any associated documentation (the "Software") to deal in the
|
||||
Data Files or Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
||||
copies of the Data Files or Software, and to permit persons to whom the
|
||||
Data Files or Software are furnished to do so, provided that either (a)
|
||||
this copyright and permission notice appear with all copies of the Data
|
||||
Files or Software, or (b) this copyright and permission notice appear in
|
||||
associated Documentation.
|
||||
|
||||
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
||||
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
THIRD PARTY RIGHTS.
|
||||
|
||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
||||
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
||||
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
||||
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
||||
FILES OR SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of a copyright holder shall
|
||||
not be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in these Data Files or Software without prior written
|
||||
authorization of the copyright holder.
|
||||
|
||||
SPDX-License-Identifier: Unicode-3.0
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,7 +6,7 @@ const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
|
||||
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
|
||||
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
|
||||
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
|
||||
function personLocation(p,prefix){return ['Country','Province','City','District','Town','Community','Address'].map(k=>p[prefix+k]).filter(Boolean).join(' · ');}
|
||||
function personLocation(p,prefix){return [ZhaoGeography.describe(p,prefix),...['Town','Community','Address'].map(k=>p[prefix+k])].filter(Boolean).join(' · ');}
|
||||
function personSearch(p){return [p.name,p.alias,p.birthplace,personLocation(p,'native'),personLocation(p,'birth')].join(' ').toLowerCase();}
|
||||
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
||||
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
||||
@@ -56,7 +56,7 @@ function renderFamilyTree(){
|
||||
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
|
||||
}
|
||||
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
|
||||
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();}
|
||||
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});ZhaoGeography.fill($('#person-form'),p);$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();}
|
||||
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
|
||||
for(const [name,path]of [['person','person'],['relation','link']]){
|
||||
$('#'+name+'-form').addEventListener('submit',async e=>{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
const ZhaoGeography=(()=>{
|
||||
const data=typeof ZhaoGeographyData!=='undefined'?ZhaoGeographyData:require('./data/geography-data.js');
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fields=['province','city','district'];
|
||||
const key=p=>p?p+'Geography':'geography';
|
||||
const legacy=(item,p,f)=>item[p?p+f[0].toUpperCase()+f.slice(1):f]||'';
|
||||
function infer(item={},prefix=''){
|
||||
if(item[key(prefix)])return {...item[key(prefix)]};
|
||||
const v={mode:'china',body:'地球',countryCode:'CN',country:'中国',path:''};
|
||||
let children=data.china,manual=false,valid=true;
|
||||
for(const f of fields){
|
||||
v[f]=legacy(item,prefix,f);const selected=children.find(x=>x.name===v[f]);v[f+'Code']=selected?.code||'';
|
||||
if(v[f]&&!selected&&!manual)valid=false;
|
||||
manual=manual||!!selected?.manual;children=selected?.children||[];
|
||||
}
|
||||
const country=legacy(item,prefix,'country');
|
||||
if(country&&country!=='中国'&&country!=='中华人民共和国'){
|
||||
const match=Object.entries(data.countries).find(([,name])=>name===country);
|
||||
v.mode=match?'world':'custom';v.country=country;v.countryCode=match?.[0]||'';
|
||||
}else if(!valid){v.mode='custom';v.country=country||'中国';}
|
||||
return v;
|
||||
}
|
||||
function change(v,field,value){
|
||||
const next={...v,[field]:value};
|
||||
if(field==='provinceCode'){next.cityCode='';next.city='';next.districtCode='';next.district='';}
|
||||
if(field==='cityCode'){next.districtCode='';next.district='';}
|
||||
if(field==='countryCode'){next.province='';next.city='';next.district='';if(value==='CN')next.mode='china';}
|
||||
if(field==='mode'){
|
||||
Object.assign(next,{body:'地球',countryCode:value==='china'?'CN':'',country:value==='china'?'中国':'',provinceCode:'',cityCode:'',districtCode:'',province:'',city:'',district:'',path:''});
|
||||
}
|
||||
if(next.mode==='china'){
|
||||
let children=data.china,manual=false;
|
||||
for(const f of fields){if(manual)continue;const selected=children.find(x=>x.code===next[f+'Code']);next[f]=selected?.name||'';children=selected?.children||[];manual=!!selected?.manual;}
|
||||
next.country='中国';next.body='地球';next.countryCode='CN';
|
||||
}else if(next.mode==='world')next.country=data.countries[next.countryCode]||'';
|
||||
return next;
|
||||
}
|
||||
function label(v){return [v.body&&v.body!=='地球'?v.body:'',v.country,v.province,v.city,v.district,v.path].filter(Boolean).filter((x,i,a)=>!i||x!==a[i-1]).join(' · ');}
|
||||
function describe(item,prefix=''){
|
||||
if(item[key(prefix)])return label(item[key(prefix)]);
|
||||
return ['country',...fields].map(f=>legacy(item,prefix,f)).filter(Boolean).join(' · ');
|
||||
}
|
||||
function fill(form,item={}){
|
||||
for(const root of form.querySelectorAll('[data-geography]')){
|
||||
const prefix=root.dataset.geography||'';let value=infer(item,prefix);
|
||||
const title=root.dataset.geoTitle||'地域';
|
||||
function draw(){
|
||||
const select=(field,text,options,disabled=false)=>`<label>${text}<select data-geo-field="${field}" aria-label="${esc(title+' · '+text)}" ${disabled?'disabled':''}>${options.map(([id,name])=>`<option value="${esc(id)}" ${value[field]===id?'selected':''}>${esc(name)}</option>`).join('')}</select></label>`;
|
||||
const input=(field,text,placeholder='')=>`<label>${text}<input data-geo-field="${field}" value="${esc(value[field]||'')}" maxlength="${field==='path'?1000:100}" placeholder="${esc(placeholder)}" aria-label="${esc(title+' · '+text)}"></label>`;
|
||||
let html=select('mode','地域范围',[['china','中国'],['world','世界 · 国家/地区'],['custom','自定义 · 历史地名/其他天体']]);
|
||||
if(value.mode==='china'){
|
||||
const province=data.china.find(x=>x.code===value.provinceCode),city=province?.children.find(x=>x.code===value.cityCode);
|
||||
html+=select('provinceCode','省/自治区/直辖市',[['','请选择(可留空)'],...data.china.map(x=>[x.code,x.name])]);
|
||||
if(province?.manual)html+=input('city','城市/地区')+input('district','区县/区域');
|
||||
else html+=select('cityCode','地级市/州/盟',[['','请选择(可留空)'],...(province?.children||[]).map(x=>[x.code,x.name])],!province)+select('districtCode','区/县/县级市',[['','请选择(可留空)'],...(city?.children||[]).map(x=>[x.code,x.name])],!city);
|
||||
html+='<p class="description full">选择上级后再选下级;变更上级会清空下级。内置区划为 2023 年数据,港澳台下级、新设区划或旧地名可手动填写或使用自定义。</p>';
|
||||
}else if(value.mode==='world'){
|
||||
html+=select('countryCode','国家/地区',[['','请选择'],...Object.entries(data.countries).sort((a,b)=>a[1].localeCompare(b[1],'zh-CN'))]);
|
||||
html+=input('province','州/省/一级行政区')+input('city','城市/二级行政区')+input('district','区县/地方行政区');
|
||||
html+='<p class="description full">国家/地区使用选项;各国层级不同,下级按当地名称填写,不适用可留空。</p>';
|
||||
}else{
|
||||
html+=input('body','天体/世界','例如:地球、月球、火星')+input('country','国家/地区(可空)')+input('province','省州/大区(可空)')+input('city','城市/区域(可空)')+input('district','区县/分区(可空)')+input('path','补充层级路径','例如:基地 / 居住区 / 一号楼');
|
||||
html+='<p class="description full">自定义内容按原文保存,适合历史地名或扩展地域;不做行政区划校验。</p>';
|
||||
}
|
||||
root.innerHTML=`<legend>${esc(title)}</legend><div class="form-grid">${html}</div><output class="geo-path" aria-live="polite">${esc(label(value)||'地域待选择')}</output>`;
|
||||
}
|
||||
root.onchange=e=>{const field=e.target.dataset.geoField;if(!field)return;value=change(value,field,e.target.value);if(e.target.tagName==='SELECT'){draw();root.querySelector(`[data-geo-field="${field}"]`)?.focus();}else root.querySelector('output').textContent=label(value);};
|
||||
root.oninput=e=>{const field=e.target.dataset.geoField;if(field&&e.target.tagName==='INPUT'){value[field]=e.target.value;root.querySelector('output').textContent=label(value);}};
|
||||
root.geoValue=()=>({...value});draw();
|
||||
}
|
||||
}
|
||||
function collect(form){return Object.fromEntries([...form.querySelectorAll('[data-geography]')].map(root=>[key(root.dataset.geography||''),root.geoValue()]));}
|
||||
return {infer,change,label,describe,fill,collect};
|
||||
})();
|
||||
if(typeof module!=='undefined')module.exports=ZhaoGeography;
|
||||
+41
-9
@@ -1,13 +1,14 @@
|
||||
'use strict';
|
||||
pageInfo.households=['我的家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
|
||||
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
|
||||
let coverSaving=false;
|
||||
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;
|
||||
const cards=h=>`<article class="site-card home-card"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><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="home-location">${esc([h.province,h.city,h.district].filter(Boolean).join(' · ')||'地区待填写')}</p></div></div><div class="home-card-body"><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>`:''}</div></article>`;
|
||||
const groups=new Map();for(const h of homes){const key=[h.province,h.city||'未填写地级市'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);}
|
||||
const cards=h=>`<article class="site-card home-card"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><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="home-location">${esc(ZhaoGeography.describe(h)||'地区待填写')}</p></div></div><div class="home-card-body"><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>`:''}</div></article>`;
|
||||
const groups=new Map();for(const h of homes){const key=[h.geography?.body&&h.geography.body!=='地球'?h.geography.body:'',h.geography?.country||h.country||'',h.province,h.city||'未填写城市/区域'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);}
|
||||
$('#household-list').innerHTML=[...groups].sort(([a],[b])=>a.localeCompare(b,'zh-CN')).map(([city,rows])=>`<section class="home-city-group"><h3>${esc(city)} <small>${rows.length} 个家庭或场所</small></h3><div class="site-grid">${rows.map(cards).join('')}</div></section>`).join('');
|
||||
}
|
||||
async function switchHousehold(id){
|
||||
@@ -21,10 +22,11 @@ async function switchHousehold(id){
|
||||
await auth();return loggedIn;
|
||||
}
|
||||
function openHousehold(id){
|
||||
if(coverSaving){toast('档案仍在保存,请稍候。');return;}
|
||||
if(!isAdmin())return;
|
||||
const item=(state.households||[]).find(h=>h.id===id);
|
||||
if(!item&&!isPlatformAdmin())return;
|
||||
nextImageJob('cover');fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='照片仅向有权进入该家庭或场所的账号开放。';$('#household-dialog').showModal();
|
||||
nextImageJob('cover');$('#household-cover-status').dataset.error='false';fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;ZhaoGeography.fill($('#household-form'),item||{});$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='封面存入百度网盘,原图可选 30 MB 以内;浏览器会先压缩。仅向获授权账号开放。';$('#household-dialog').showModal();
|
||||
}
|
||||
async function openLeadership(id){
|
||||
try{
|
||||
@@ -48,10 +50,24 @@ $('#leadership-form').addEventListener('submit',async e=>{
|
||||
$('#household-filter').onchange=async e=>{const switched=await switchHousehold(e.target.value);if(switched&&loggedIn)showPage('overview');else e.target.value=state.familyId;};
|
||||
$('#new-household').onclick=()=>openHousehold();
|
||||
$('#household-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(imageJobPending('cover')){$('#household-cover-status').textContent='照片仍在读取,请完成后保存。';return;}const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{await api('/api/households',{...formValues(f),profileRevision:Number(f.elements.profileRevision.value||0),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
|
||||
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
|
||||
finally{b.disabled=false;}
|
||||
e.preventDefault();const f=e.currentTarget;if(coverSaving)return;
|
||||
if(imageJobPending('cover')){f.querySelector('.form-error').textContent='照片仍在处理,请完成后保存。';return;}
|
||||
if(imageJobs.get('cover')?.error){f.querySelector('.form-error').textContent='封面未准备成功:请重新选择图片,或点击“取消本次图片选择”。';return;}
|
||||
const payload={...formValues(f),profileRevision:Number(f.elements.profileRevision.value||0),...(coverDraft!==undefined?{coverData:coverDraft}:{})};
|
||||
const controls=[...f.elements],disabled=controls.map(x=>x.disabled),generation=sessionGeneration;
|
||||
coverSaving=true;controls.forEach(x=>x.disabled=true);f.querySelector('.form-error').textContent=payload.coverData?'正在上传封面到百度网盘并校验,请稍候…':'正在保存档案…';
|
||||
try{
|
||||
const saved=await api('/api/households',payload);if(generation!==sessionGeneration)return;
|
||||
if(payload.coverData&&!saved.coverRef?.fsId)throw Error('服务器未确认封面保存,请重试。');
|
||||
// Adopt the returned revision even if a later state refresh fails.
|
||||
f.elements.id.value=saved.id;f.elements.profileRevision.value=saved.profileRevision;
|
||||
const view={...saved,coverUrl:saved.coverData||saved.coverRef?'/api/households/cover?id='+encodeURIComponent(saved.id)+'&v='+saved.coverRevision:''};
|
||||
delete view.coverData;delete view.coverRef;const previous=(state.households||[]).find(x=>x.id===saved.id);
|
||||
if(previous)Object.assign(previous,view);else (state.households||=[]).push(view);
|
||||
renderHouseholds();f.closest('dialog').close();toast(payload.coverData?'封面已存百度网盘,归属档案已保存':payload.coverData===''?'归属档案已保存,已使用默认封面':'归属档案已保存');
|
||||
try{await refresh();}catch(error){if(!error.stale&&generation===sessionGeneration)toast('档案已保存,列表刷新暂未完成,请稍后刷新页面。无需重复保存。');}
|
||||
}catch(error){if(!error.stale&&generation===sessionGeneration)f.querySelector('.form-error').textContent=error.message;}
|
||||
finally{coverSaving=false;controls.forEach((x,i)=>x.disabled=disabled[i]);}
|
||||
});
|
||||
document.addEventListener('click',async e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
@@ -60,6 +76,22 @@ document.addEventListener('click',async e=>{
|
||||
if(b.dataset.enterHousehold){const switched=await switchHousehold(b.dataset.enterHousehold);if(switched&&loggedIn)showPage('overview');}
|
||||
});
|
||||
|
||||
$('#household-cover-file').onchange=async e=>{const job=nextImageJob('cover'),generation=sessionGeneration;imageJobs.get('cover').pending=true;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){if(generation===sessionGeneration&&imageJobCurrent('cover',job))$('#household-cover-status').textContent=e.message;}finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}};
|
||||
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
|
||||
$('#household-cover-file').onchange=async e=>{
|
||||
const file=e.target.files[0];if(!file)return;
|
||||
const job=nextImageJob('cover'),generation=sessionGeneration,status=$('#household-cover-status');
|
||||
imageJobs.get('cover').pending=true;status.dataset.error='false';status.textContent='正在压缩照片,请稍候…';$('#household-form .form-error').textContent='';
|
||||
try{const image=await readImage(file,960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;
|
||||
coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;status.textContent='封面预览已就绪(约 '+Math.ceil((image.length-22)*.75/1024)+' KB),保存后上传百度网盘并更新卡片。';
|
||||
}catch(error){if(generation===sessionGeneration&&imageJobCurrent('cover',job)){imageJobs.get('cover').error=true;status.dataset.error='true';status.textContent=error.message+'。本次图片未保存,请重新选择或取消本次选择。';}}
|
||||
finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}
|
||||
};
|
||||
$('#household-cover-cancel').onclick=()=>{
|
||||
nextImageJob('cover');coverDraft=undefined;$('#household-cover-file').value='';
|
||||
const item=(state.households||[]).find(x=>x.id===$('#household-form').elements.id.value),img=$('#household-cover-preview');
|
||||
img.hidden=!item?.coverUrl;if(item?.coverUrl)img.src=item.coverUrl;else img.removeAttribute('src');
|
||||
$('#household-cover-status').dataset.error='false';$('#household-cover-status').textContent='已取消本次图片选择,保留原有封面。';$('#household-form .form-error').textContent='';
|
||||
};
|
||||
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-file').value='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').dataset.error='false';$('#household-cover-status').textContent='保存后使用默认封面。';$('#household-form .form-error').textContent='';};
|
||||
$('#household-dialog').addEventListener('close',()=>nextImageJob('cover'));
|
||||
$('#household-dialog').addEventListener('cancel',event=>{if(coverSaving)event.preventDefault();});
|
||||
$('#household-cover-preview').addEventListener('error',()=>{$('#household-cover-status').textContent='封面暂时无法从网盘读取,请稍后重新打开;原有记录仍保留。';});
|
||||
|
||||
+7
-7
File diff suppressed because one or more lines are too long
+10
-9
@@ -52,13 +52,13 @@ function appearanceFields(container,values,inherit){
|
||||
}
|
||||
function fillBrandForm(value){
|
||||
invalidateBrandImages();
|
||||
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
|
||||
brandDraft=value;brandLogo=value.logoData;brandIcon=value.iconData;
|
||||
const form=$('#brand-form');form.reset();
|
||||
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
|
||||
appearanceFields($('#brand-defaults'),value.defaults,false);renderBrandImages();
|
||||
}
|
||||
function renderBrandImages(){
|
||||
$('#brand-logo-preview').src=brandLogo||'/zhao-icon.png';$('#brand-icon-preview').src=brandIcon||'/zhao-icon.png';
|
||||
$('#brand-logo-preview').src=brandLogo===undefined?(brandDraft?.logoUrl||'/zhao-icon.png'):(brandLogo||'/zhao-icon.png');$('#brand-icon-preview').src=brandIcon===undefined?(brandDraft?.iconUrl||'/zhao-icon.png'):(brandIcon||'/zhao-icon.png');
|
||||
}
|
||||
function showSettingsSection(section=settingsSection){
|
||||
settingsSection=['preferences','baidu','brand','backup'].includes(section)&&(section==='preferences'||isPlatformAdmin())?section:'preferences';
|
||||
@@ -92,10 +92,11 @@ $('#personal-follow').onclick=()=>{appearanceFields($('#personal-choices'),Objec
|
||||
$('#personal-cancel').onclick=()=>{appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();$('#preferences-status').textContent='已恢复已保存的偏好。';};
|
||||
$('#settings-reload').onclick=loadSettings;
|
||||
async function readImage(file,maxSide=512){
|
||||
if(!file||!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>5*1024*1024)throw Error('请选择 5 MB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
const maxBytes=maxSide>512?30*1024*1024:5*1024*1024;
|
||||
if(!file||!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>maxBytes)throw Error('请选择 '+(maxBytes/1024/1024)+' MB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
const bitmap=await createImageBitmap(file);
|
||||
try{if(bitmap.width*bitmap.height>40000000)throw Error('图片尺寸过大,请先缩小');
|
||||
let scale=Math.min(1,maxSide/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas'),limit=maxSide>256?2*1024*1024:256*1024;
|
||||
try{if(bitmap.width*bitmap.height>(maxSide>512?80000000:40000000))throw Error('图片尺寸过大,请先缩小');
|
||||
let scale=Math.min(1,maxSide/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas'),limit=maxSide>512?600*1024:maxSide>256?2*1024*1024:256*1024;
|
||||
for(let attempt=0;attempt<5;attempt++){
|
||||
canvas.width=Math.max(1,Math.round(bitmap.width*scale));canvas.height=Math.max(1,Math.round(bitmap.height*scale));
|
||||
canvas.getContext('2d').drawImage(bitmap,0,0,canvas.width,canvas.height);const result=canvas.toDataURL('image/png');
|
||||
@@ -105,17 +106,17 @@ async function readImage(file,maxSide=512){
|
||||
}finally{bitmap.close();}
|
||||
}
|
||||
for(const [id,type,max] of [['brand-logo-file','logo',256],['brand-icon-file','icon',96]]){
|
||||
$('#'+id).onchange=async e=>{const generation=appearanceGeneration,job=nextImageJob(type);nextImageJob('import');imageJobs.get(type).pending=true;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration||!imageJobCurrent(type,job))return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent(type,job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent(type,job))imageJobs.get(type).pending=false;}};
|
||||
$('#'+id).onchange=async e=>{const generation=appearanceGeneration,job=nextImageJob(type);nextImageJob('import');imageJobs.get(type).pending=true;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration||!imageJobCurrent(type,job))return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存后上传百度网盘。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent(type,job)){imageJobs.get(type).error=true;$('#brand-status').textContent=e.message;}}finally{if(imageJobCurrent(type,job))imageJobs.get(type).pending=false;}};
|
||||
}
|
||||
$('#brand-logo-reset').onclick=()=>{nextImageJob('logo');nextImageJob('import');brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{nextImageJob('icon');nextImageJob('import');brandIcon='';renderBrandImages();};
|
||||
function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),logoData:brandLogo,iconData:brandIcon,revision:brandDraft?.revision};}
|
||||
function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),...(brandLogo!==undefined?{logoData:brandLogo}:{}),...(brandIcon!==undefined?{iconData:brandIcon}:{}),revision:brandDraft?.revision};}
|
||||
$('#brand-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.disabled=true;
|
||||
e.preventDefault();if(['logo','icon','import'].some(k=>imageJobs.get(k)?.error)){$('#brand-status').textContent='图片或配置读取失败,请重新选择,或恢复默认图片后再保存。';return;}if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.disabled=true;
|
||||
try{const result=await api('/api/appearance',brandPayload());if(generation!==appearanceGeneration)return;fillBrandForm(result);await loadBranding();if(generation!==appearanceGeneration)return;$('#brand-status').textContent='发布配置已保存。已有个人偏好继续保留。';}
|
||||
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{for(const control of controls)control.disabled=false;}
|
||||
});
|
||||
$('#brand-export').onclick=()=>{if(!brandDraft)return;const {revision,...value}=brandPayload(),blob=new Blob([JSON.stringify({format:'digital-steward-appearance',version:1,config:value},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='digital-steward-appearance.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;invalidateBrandImages();const job=nextImageJob('import');imageJobs.get('import').pending=true;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration||!imageJobCurrent('import',job))return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).sort().join('|')!==['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].sort().join('|'))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent('import',job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent('import',job))imageJobs.get('import').pending=false;}};
|
||||
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;invalidateBrandImages();const job=nextImageJob('import');imageJobs.get('import').pending=true;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration||!imageJobCurrent('import',job))return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).some(k=>!['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].includes(k))||['name','subtitle','motto','loginTitle','loginNote','footer','defaults'].some(k=>!(k in config)))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...brandDraft,...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent('import',job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent('import',job))imageJobs.get('import').pending=false;}};
|
||||
function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;if(f.elements.baidu)f.elements.baidu.checked=!!c.baidu;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);}
|
||||
function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;后续备份步骤未完成':'备份失败'):r.baiduOk?(r.cloudCleanupError?'网盘备份已校验;过期副本清理待重试':r.cloudCleanupOk?'网盘备份已校验,过期副本清理完成':'本机备份完成,百度网盘已确认文件'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
|
||||
function renderBackup(value){
|
||||
|
||||
Reference in New Issue
Block a user