From a77fcc6671a244e02c2308d23b2c74b66010df5e Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 20:37:14 +0800 Subject: [PATCH] Release v0.1.30: improve daily workflows and mobile layout --- README.md | 13 ++++++++ VERSION | 2 +- journal.py | 4 +++ test_daily_flow.js | 69 ++++++++++++++++++++++++++++++++++++++++++ test_journal.js | 2 +- test_journal.py | 19 ++++++++++++ test_steward.js | 2 +- web/app.js | 35 +++++++++++++-------- web/chinese-theme.css | 22 ++++++++++++++ web/device-model.js | 7 ++++- web/devices.js | 9 ++++-- web/family.js | 2 +- web/households.js | 12 +++++--- web/index.html | 16 +++++----- web/journal.js | 56 +++++++++++++++++++++++++--------- web/personalization.js | 8 +++++ web/recording-ui.js | 4 ++- web/steward.js | 18 +++++++---- web/style.css | 6 ++++ 19 files changed, 252 insertions(+), 54 deletions(-) create mode 100644 test_daily_flow.js diff --git a/README.md b/README.md index 23c90e5..2ec3bd5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # 赵府智家 · 视觉系统 +## v0.1.30 · 日常操作流程与手机网页 + +- 家庭或办公、工厂是权限归属;内部区域可分多级。设备提供按区域、类型、负责人三个快捷视图,并保留自定义组合。搜索支持“卧室 灯”等多词组合,涵盖区域、品牌、人员与标签。 +- 总览补充记事入口;区域管理收进设备页;设备返回列表,家谱与家族记事返回家庭传承。区分个人日常记事与家族重大记事,办公场所不显示家族传承。 +- 修复编辑后新建残留隐藏记录编号的问题。新建区域可选择新增下级,设备和录像机继承当前选中的区域。 +- 记事保存后清空本次输入、附件和旧筛选,重新加载并定位保存结果。补记往事也可定位到对应分页;空页自动回到有效页。保存中锁定表单,失败保留草稿;另写、退出或切换家庭前提示未保存输入。 +- 设置中心分为我的偏好、网盘连接、品牌与外观、数据库备份,后三项仅超级管理员可配置。录像机原有历史录像明确标注待接入,避免查询后才发现不可用。 +- 手机使用可展开的顶部菜单,选择页面后自动收起。表单单列、输入字号 16px、按钮至少 44px;修复设备弹窗、媒体预览和筛选操作在窄屏的布局。 + +验证:138 项 Python 回归、109 项 JavaScript 回归。通过 Edge Computer Use 验证管理员和普通成员、设备查找与编辑后新建、记事保存清空与定位、照片预览和语音播放、草稿取消清空、设置分区、办公场所切换。响应式检查覆盖 360、390、430、768px;手机浏览器引擎、软键盘和真机录音仍需 Android/iOS 实机复核。流程测试使用独立的本地合成数据,未向正式家庭写入测试记事或设备。 + +范围:灯、冰箱等仍是设备档案,自动控制需接入实际协议;录像机原有历史录像接口仍待接入。浏览器预览测试不能替代长时间监控稳定性或设备全部厂商功能的验证。 + ## v0.1.29 · 记事预览、完整地域与云备份保留 - 记事照片默认显示,点击放大;语音、视频自动加载播放信息,提供播放、暂停、进度和音量控制,不自动出声。页面原生播放器按需缓冲,图片延迟解码;媒体仍保存在百度网盘,由同源鉴权接口转发,服务器不保存个人媒体文件。 diff --git a/VERSION b/VERSION index 5ef49d2..013adb7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.29 +0.1.30 diff --git a/journal.py b/journal.py index 79cd373..e7be5ce 100644 --- a/journal.py +++ b/journal.py @@ -71,6 +71,10 @@ class Journal: entries.sort(key=lambda e: (e['occurredOn'], e['createdAt'], e['id']), reverse=True) try: page = max(0, min(100000, int(query.get('page', '0')))) except ValueError: raise a.Problem('页码不正确') + if query.get('focus'): + index = next((i for i, entry in enumerate(entries) if entry['id'] == query['focus']), None) + if index is not None: page = index // 20 + page = min(page, max(0, (len(entries)-1)//20)) media = {m['id']: m for m in a.HOUSEHOLDS.objects(user, 'journal_media')} likes = a.HOUSEHOLDS.objects(user, 'journal_likes') comments = a.HOUSEHOLDS.objects(user, 'journal_comments') diff --git a/test_daily_flow.js b/test_daily_flow.js new file mode 100644 index 0000000..336383f --- /dev/null +++ b/test_daily_flow.js @@ -0,0 +1,69 @@ +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'); +const source=name=>fs.readFileSync('web/'+name,'utf8'); +function part(file,start,end){const s=source(file);return s.slice(s.indexOf(start),s.indexOf(end,s.indexOf(start)+start.length));} +const devices=require('./web/device-model.js'); +const areas=[{id:'floor',name:'一楼'},{id:'room',name:'卧室',parentId:'floor'}]; +const lamp={name:'台灯',deviceType:'灯',siteId:'room',brand:'小米',managerName:'爸爸',tags:['阅读']}; +test('device search combines area, type, brand, responsible person and tags',()=>{ + for(const q of ['卧室 灯','一楼 小米','爸爸 阅读',' 卧室 台灯 '])assert.ok(devices.matches(lamp,q,areas),q); + assert.ok(!devices.matches(lamp,'厨房 灯',areas));assert.ok(devices.matches(lamp,'',areas)); +}); +test('grouping views retain devices and their location',()=>{ + const before=JSON.stringify(lamp); + for(const dimensions of [['space'],['type','space'],['manager','type'],['owner','tag']])assert.equal(devices.groups([lamp],dimensions,areas).flatMap(g=>g.items).length,1); + assert.equal(JSON.stringify(lamp),before); +}); +test('editor reset clears old identities and revisions after editing',()=>{ + const fields=[{name:'id',value:'old'},{name:'familyRevision',value:'9'},{name:'revision',value:'3'}]; + let resets=0;const c=vm.createContext({});vm.runInContext(part('app.js','function resetEditorForm(','function formValues('),c); + c.resetEditorForm({reset(){resets++;},querySelectorAll(){return fields;}}); + assert.equal(resets,1);assert.deepEqual(fields.map(f=>f.value),['','0','0']); +}); +test('back buttons follow place, device and family hierarchy',()=>{ + const c=vm.createContext({});vm.runInContext(part('steward.js','function scopeReturn(','function selectedDevice('),c); + for(const [page,device,target] of [['overview',null,'households'],['live',{},'objects'],['sites',null,'objects'],['family',null,'heritage'],['heritage-records',null,'heritage'],['journal',null,'overview']])assert.equal(c.scopeReturn(page,device).page,target); +}); +function settingsContext(admin){ + const nodes={};const c=vm.createContext({settingsSection:'preferences',isPlatformAdmin:()=>admin,$:id=>nodes[id]??={},$$:()=>[]}); + vm.runInContext(part('personalization.js','function showSettingsSection(','document.addEventListener'),c);return {c,nodes}; +} +test('settings show one section at a time',()=>{ + const {c,nodes}=settingsContext(true); + for(const section of ['preferences','baidu','brand','backup']){c.showSettingsSection(section);assert.equal(Object.values(nodes).filter(n=>!n.hidden).length,1);assert.equal(c.settingsSection,section);} +}); +test('members cannot select platform settings',()=>{ + const {c,nodes}=settingsContext(false);for(const section of ['baidu','brand','backup','unknown']){c.showSettingsSection(section);assert.equal(c.settingsSection,'preferences');assert.equal(nodes['#backup-panel'].hidden,true);} +}); +function journalContext(){ + const fields=Object.fromEntries(['text','occurredOn','category','visibility'].map(k=>[k,{value:k==='visibility'?'private':''}])); + const nodes={'#journal-form':{elements:fields}}; + const c=vm.createContext({journalSelectedMedia:new Set(),journalBaseline:'',journalUpload:false,journalTracks:null,journalSaving:false,journalPage:3,confirm:()=>false,toast:()=>{},$:id=>nodes[id]??={value:''}}); + vm.runInContext(part('journal.js','function journalDraftSignature(','const journalId='),c);return {c,fields,nodes}; +} +test('saved entry filter reset clears searches and dates but retains privacy',()=>{ + const {c,fields,nodes}=journalContext();for(const id of ['journal-search','journal-category','journal-from','journal-to','journal-person'])c.$('#'+id).value='old'; + c.resetJournalFilters('mine');assert.equal(c.journalPage,0);assert.equal(nodes['#journal-search'].value,'');assert.equal(nodes['#journal-person-label'].hidden,true);assert.equal(fields.visibility.value,'private'); +}); +test('dirty drafts and pending saves are protected',()=>{ + const {c,fields}=journalContext();c.journalBaseline=c.journalDraftSignature();assert.equal(c.journalHasDraft(),false); + fields.text.value='未保存';assert.equal(c.journalHasDraft(),true);assert.equal(c.confirmJournalDiscard(),false); + c.confirm=()=>true;assert.equal(c.confirmJournalDiscard(),true);c.journalSaving=true;assert.equal(c.confirmJournalDiscard(),false); +}); +test('empty feed explains private visibility and filtered emptiness',()=>{ + const {c}=journalContext();c.$('#journal-mode').value='feed';assert.match(c.journalEmptyMessage(),/仅自己可见/); + c.$('#journal-search').value='missing';assert.match(c.journalEmptyMessage(),/清除筛选/); +}); + +test('mobile navigation toggles an accessible control without changing scope',()=>{ + let opened=false;const button={setAttribute(k,v){this[k]=v;},textContent:''}; + const c=vm.createContext({$:id=>id==='.sidebar'?{classList:{toggle(k,v){opened=v;}}}:button}); + vm.runInContext(part('app.js','function setMobileMenu(',"$('#mobile-nav-toggle').onclick"),c); + c.setMobileMenu(true);assert.equal(opened,true);assert.equal(button['aria-expanded'],'true'); + c.setMobileMenu(false);assert.equal(opened,false);assert.equal(button['aria-expanded'],'false'); +}); +test('mobile forms use readable inputs, touch targets and bounded dialogs',()=>{ + const css=source('chinese-theme.css'),html=source('index.html'); + assert.match(css,/font-size:16px!important;min-height:44px/); + assert.match(css,/max-height:90dvh/);assert.match(css,/sidebar:not\(\.mobile-open\)>nav/); + assert.match(html,/aria-controls="main-navigation"/); +}); diff --git a/test_journal.js b/test_journal.js index 5d98af9..db93e6c 100644 --- a/test_journal.js +++ b/test_journal.js @@ -31,7 +31,7 @@ test('successful save clears draft attachment ids, stops recorder, and reloads f const js=fs.readFileSync('web/journal.js','utf8'); const fn=js.slice(js.indexOf('function newJournal'),js.indexOf('async function loadJournal')); assert.match(fn,/journalDraftMedia.clear\(\)/);assert.match(fn,/journalSelectedMedia.clear\(\)/);assert.match(fn,/stopJournalVoice\(\)/);assert.match(fn,/journal-library'\).open=false/); - assert.match(js,/newJournal\(\);\$\('#journal-mode'\).value='mine';journalPage=0;await loadJournal\(\)/); + assert.match(js,/newJournal\(\);resetJournalFilters\('mine'\);await loadJournal\(saved.id\)/); assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/); }); test('attachment preview uses a closable dialog and deletion has a restore path',()=>{ diff --git a/test_journal.py b/test_journal.py index d28d5d0..17dbb25 100644 --- a/test_journal.py +++ b/test_journal.py @@ -58,6 +58,25 @@ class JournalTests(unittest.TestCase): self.assertEqual(self.request('/api/journal', self.actor, dict(old,text='修改'))[0], 200) self.assertEqual(self.request('/api/journal', self.actor, old)[0], 409) + def test_saved_backdated_entry_focus_and_page_clamp(self): + for _ in range(21): self.saved() + body = self.note(); body['occurredOn'] = '2000-01-01' + key = app.JOURNAL.save(self.actor, body)['id'] + focused = app.JOURNAL.snapshot(self.actor, dict(mode='mine', focus=key)) + self.assertEqual(focused['page'], 1) + self.assertIn(key, [e['id'] for e in focused['items']]) + self.assertEqual(app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99'))['page'], 1) + empty = app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99', search='不存在的关键词')) + self.assertEqual(empty['page'], 0) + self.assertEqual(empty['items'], []) + + def test_focus_does_not_reveal_private_or_foreign_entries(self): + private = self.saved() + for user in (self.owner, self.root, self.other): + result = app.JOURNAL.snapshot(user, dict(mode='feed', focus=private)) + self.assertEqual(result['total'], 0) + self.assertEqual(result['items'], []) + def test_like_set_is_idempotent_and_privacy_change_removes_all_interactions(self): key = self.saved(visibility='household'); data = dict(id=key,action='like',liked=True) for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0], 200) diff --git a/test_steward.js b/test_steward.js index af462a1..808be7d 100644 --- a/test_steward.js +++ b/test_steward.js @@ -2,7 +2,7 @@ const test=require('node:test'),assert=require('node:assert/strict'),fs=require( const code=fs.readFileSync('web/steward.js','utf8'),html=fs.readFileSync('web/index.html','utf8'),app=fs.readFileSync('web/app.js','utf8'); const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length)); test('navigation starts at places and contains no global camera functions',()=>{ - const nav=html.match(/