From d4543a98438c6fb0b0ddc8a07ba572eb24555010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Fri, 16 Oct 2026 19:21:20 +0800 Subject: [PATCH] Release v0.1.7: render gallery controls before cloud metadata and status [skip ci] --- docs/UI_LAYOUT_AUDIT.md | 15 ++++++++++++++- docs/openapi.json | 31 +++++++++++++++++++++++++++--- docs/releases/v0.1.7.md | 24 +++++++++++++++++++++++ pigway_frame_flow/api/app.py | 13 +++++++++++-- pigway_frame_flow/models.py | 10 ++++++++-- pigway_frame_flow/web/app.js | 21 +++++++++++++++----- pigway_frame_flow/web/features.js | 5 +++-- pigway_frame_flow/web/i18n.js | 4 ++-- pigway_frame_flow/web/index.html | 2 +- pigway_frame_flow/web/style.css | 2 ++ pyproject.toml | 2 +- scripts/ui-gallery-bootstrap.cjs | 10 ++++++++++ scripts/ui-progressive-loading.cjs | 12 +++++++++++- tests/test_api.py | 13 +++++++++++++ 14 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 docs/releases/v0.1.7.md create mode 100644 scripts/ui-gallery-bootstrap.cjs diff --git a/docs/UI_LAYOUT_AUDIT.md b/docs/UI_LAYOUT_AUDIT.md index f7206d7..002d360 100644 --- a/docs/UI_LAYOUT_AUDIT.md +++ b/docs/UI_LAYOUT_AUDIT.md @@ -39,7 +39,7 @@ FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。 | --- | --- | --- | | ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 | | ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 | -| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;真实图片数量、搜索先于图片请求、无骨架 | 14 项通过 | +| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 | | ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 | 上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过; @@ -47,3 +47,16 @@ FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。 不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。 连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。 + + +## 首次未缓存图库的初始化 + +目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。 +仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。 +元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。 +列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。 +列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。 + +启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新, +迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表, +验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。 diff --git a/docs/openapi.json b/docs/openapi.json index 920cf83..573b53f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Pigway Frame Flow API", - "version": "0.1.6" + "version": "0.1.7" }, "paths": { "/api/v1/devices/{device_id}/widgets/selection": { @@ -14819,6 +14819,24 @@ ], "title": "Device" }, + "DeviceIdentity": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "DeviceIdentity" + }, "DisplayGeometry": { "properties": { "width": { @@ -15198,7 +15216,7 @@ "version": { "type": "string", "title": "Version", - "default": "0.1.6" + "default": "0.1.7" } }, "type": "object", @@ -17145,6 +17163,13 @@ "type": "array", "title": "Gallery Destinations" }, + "device_catalog": { + "items": { + "$ref": "#/components/schemas/DeviceIdentity" + }, + "type": "array", + "title": "Device Catalog" + }, "version": { "type": "string", "title": "Version" @@ -17387,7 +17412,7 @@ "version": { "type": "string", "title": "Version", - "default": "0.1.6" + "default": "0.1.7" }, "api_version": { "type": "string", diff --git a/docs/releases/v0.1.7.md b/docs/releases/v0.1.7.md new file mode 100644 index 0000000..dd6dae0 --- /dev/null +++ b/docs/releases/v0.1.7.md @@ -0,0 +1,24 @@ +# Pigway Frame Flow v0.1.7 + +## 简体中文 + +修复首次打开图库时内容区空白、必须等待云端图片列表返回才出现控件的问题。 + +- 目的地标签、搜索、排序、刷新和上传按钮立即初始化,图片列表独立请求。 +- 仅在工具栏提示正在读取列表;未知数量时不生成图片卡片,不提前显示“图库为空”。实际图片仍在自己的区域内独立加载。 +- 启动使用轻量设备身份目录,完整设备状态在后台更新,避免厂商状态请求阻塞首屏。 +- 慢请求期间可以切换到 NAS 图库;过期响应不会覆盖当前页面。 +- 列表失败时保留控件并在工具栏显示错误,仍可刷新或切换目的地。 +- 保留等待期间输入的搜索词和排序选择;本地任务筛选请求在同一次导航内复用。 + +### 验证 + +本地 508 项 Python 测试、Ruff、格式检查、mypy 和 JavaScript 语法检查通过。 +新增首次未缓存列表的控件就绪、输入保留、慢云端切换和列表失败回归。 +不增加图库列表的服务端缓存,不修改厂商请求重试、照片、生成内容或设备写入策略。 + +## English + +Fix the blank gallery on its first uncached visit. Destination tabs and gallery controls initialize before metadata requests complete. +Slow or failed cloud listing does not prevent switching to NAS storage. Search and sort choices survive list arrival, and stale responses cannot replace the active destination. +Only a small toolbar status indicates metadata loading; real images keep independent loading indicators. diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index c1a5e9e..7365b56 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -33,6 +33,7 @@ from pigway_frame_flow.models import ( CurrentDisplay, DeleteRequest, Device, + DeviceIdentity, DisplayRequest, ErrorResponse, Fit, @@ -125,7 +126,7 @@ def create_app(settings=None, start_worker=True): app = FastAPI( title="Pigway Frame Flow API", docs_url=None, - version="0.1.6", + version="0.1.7", lifespan=lifespan, responses=error_responses, ) @@ -1002,6 +1003,14 @@ def create_app(settings=None, start_worker=True): def settings_view(): return SettingsView( storage_name=settings.storage_name, + # Page navigation needs device identity, not a fresh cloud status request. + device_catalog=[ + DeviceIdentity( + id=id, + name=getattr(getattr(driver, "cached", None), "name", id), + ) + for id, driver in registry.drivers.items() + ], gallery_destinations=[ GalleryDestination( id="cloud", @@ -1022,7 +1031,7 @@ def create_app(settings=None, start_worker=True): supports_upload=True, ), ], - version="0.1.6", + version="0.1.7", mock=settings.mock, write_enabled=settings.write_enabled, api_key_configured=bool(settings.api_key) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 93d334d..79dd839 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -342,9 +342,15 @@ class GalleryDestination(BaseModel): supports_upload: bool = False +class DeviceIdentity(BaseModel): + id: str + name: str + + class SettingsView(BaseModel): storage_name: str = "NAS" gallery_destinations: list[GalleryDestination] = Field(default_factory=list) + device_catalog: list[DeviceIdentity] = Field(default_factory=list) version: str mock: bool write_enabled: bool @@ -357,7 +363,7 @@ class SettingsView(BaseModel): class Health(BaseModel): status: str - version: str = "0.1.6" + version: str = "0.1.7" class Readiness(BaseModel): @@ -387,7 +393,7 @@ class RestoreRequest(StrictModel): class Version(BaseModel): product: str = "Pigway Frame Flow" - version: str = "0.1.6" + version: str = "0.1.7" api_version: str = "v1" commit: str = "development" build_time: str = "unknown" diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 58cc777..8cc60d0 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -1,8 +1,13 @@ 'use strict'; const $=s=>document.querySelector(s); const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0}; -function forgetLogin(){state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];} -async function loadConnection(){const [settings,devices,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/devices').catch(error=>{toast(error.message);return [];}),api('/api/v1/templates')]);state.settings=settings;state.devices=devices;state.templates=templates;state.device=state.devices[0]?.id||state.device;} +function forgetLogin(){state.galleryQuery='';state.galleryOrder='newest';state.galleryJobsRequest=null;state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];} +async function loadConnection(){ + const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]); + const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];}); + state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device; + if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);}); +} const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; const t=k=>window.MESSAGES[state.lang][k]||k; const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); @@ -111,16 +116,22 @@ const views={ studio:()=>layoutStudioPage(), connections:()=>connectionsPage(), 'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); }, - async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, + async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, async gallery(epoch){ const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local'; const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']); const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key); - const render=items=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}; - if(cached)render(cached); + const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}${pending?t('galleryListLoading'):''}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${``}${!pending&&!items.length?`
${t('noImages')}${t('emptyHint')}
`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`

${esc(x.name)}

${esc(x.name)}`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}; + render(cached||[],!cached); + try{ const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):'')); if(epoch!==state.epoch)return;cache.set(key,items); if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(items); + }catch(error){ + if(epoch!==state.epoch)return;if(!state.token)throw error; + const status=$('#gallery-list-status');status.hidden=false;status.setAttribute('role','alert');status.textContent=error.message; + $('.gallery')?.setAttribute('aria-busy','false'); + } }, async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));}, async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}

${t('templateSteps')}

${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${t('templateGuideTitle')}

${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}

${['weather','daily-dashboard'].includes(selected.id)?`

${t('templateWeatherHelp')}

`:''}

${t('helpProfile')}

${t('templateActionHelp')}

${t('templateAutomationHelp')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('templatePreviewHint')}
`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 7b51f3a..5410f67 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -34,9 +34,10 @@ function displayDialog(content){ function enhanceGallery(){ const grid=$('.gallery');if(!grid)return; const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`
`;grid.before(controls); - if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';void api('/api/v1/jobs').then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;ix.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}} - const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));}; + if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;ix.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}} + const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));}; cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}}); + $('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange(); } async function weatherSettings(){ const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('weatherSettings')}

${['latitude','longitude'].map(k=>`
`).join('')}
`;$('#main').append(box);$('#weather-provider').value=weather.config.provider; diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index b433cdd..7d4bd86 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -1,6 +1,6 @@ window.MESSAGES={ -zh:{DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'KoKonna 云端图库',cloudGallery:'云端图库',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',imageLoadError:'图片加载失败,请刷新重试',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, -en:{DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'KoKonna cloud gallery',cloudGallery:'cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} +zh:{DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'KoKonna 云端图库',cloudGallery:'云端图库',galleryListLoading:'正在读取图片列表…',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',imageLoadError:'图片加载失败,请刷新重试',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, +en:{DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'KoKonna cloud gallery',cloudGallery:'cloud gallery',galleryListLoading:'Reading image list…',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} }; Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 52e5091..1237f94 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index f7528bc..15da167 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -149,3 +149,5 @@ form>.modal-actions{margin-top:8px} .gallery-tabs{max-width:100%;flex-wrap:wrap} .gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere} + +.gallery:empty{display:none} diff --git a/pyproject.toml b/pyproject.toml index 6032300..40f69af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.6" +version = "0.1.7" requires-python = ">=3.12" dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"] [project.optional-dependencies] diff --git a/scripts/ui-gallery-bootstrap.cjs b/scripts/ui-gallery-bootstrap.cjs new file mode 100644 index 0000000..c824436 --- /dev/null +++ b/scripts/ui-gallery-bootstrap.cjs @@ -0,0 +1,10 @@ +/* Mock-only: slow device status and cloud metadata must not block real gallery controls. */ +const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright'); +(async()=>{const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);const b=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); + try{const p=await b.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));let releaseStatus,releaseList;const pending=[];let checks=0; + await p.route('**/api/v1/devices',async r=>{pending.push('device');await new Promise(resolve=>releaseStatus=resolve);await r.fulfill({json:[{id:'living-room',name:'Fixture frame'}]});}); + await p.route('**/api/v1/devices/*/images',async r=>{pending.push('list');await new Promise(resolve=>releaseList=resolve);await r.fulfill({json:[]});}); + await p.goto(base+'/#gallery');await p.locator('#gallery-search').waitFor({timeout:5000});for(let i=0;i<100&&pending.length<2;i++)await p.waitForTimeout(20);assert.deepEqual(pending.sort(),['device','list']);assert(await p.locator('#gallery-upload').isEnabled());assert(await p.locator('#local-tab').isEnabled());assert.equal(await p.locator('.image-card,.placeholder-line,.image-pending,.page-loading').count(),0);checks++;if(process.env.FRAME_FLOW_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/gallery-list-pending.png'});} + await p.locator('#gallery-search').fill('kept query');await p.locator('#gallery-sort').selectOption('name');releaseStatus();releaseList();await p.waitForFunction(()=>document.querySelector('.gallery')?.getAttribute('aria-busy')==='false');assert.equal(await p.locator('#gallery-search').inputValue(),'kept query');assert.equal(await p.locator('#gallery-sort').inputValue(),'name');assert.equal(await p.locator('.card.empty').count(),1);checks++; + assert.deepEqual(errors,[]);console.log('Cold gallery bootstrap:',checks,'checks PASS; device/cloud requests blocked independently, real controls ready, input preserved'); + }finally{await b.close();}})().catch(e=>{console.error(e.message);process.exit(1)}); diff --git a/scripts/ui-progressive-loading.cjs b/scripts/ui-progressive-loading.cjs index 2da0e4d..d340c0d 100644 --- a/scripts/ui-progressive-loading.cjs +++ b/scripts/ui-progressive-loading.cjs @@ -18,8 +18,9 @@ const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright') const metadata=new Promise(resolve=>metadataRequested=resolve);const image=new Promise(resolve=>imageRequested=resolve); await page.evaluate(()=>{window.navigation=navigate('gallery');});await metadata; assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.image-card').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++; + assert(await page.locator('#cloud-tab').isVisible());assert(await page.locator('#local-tab').isEnabled());assert(await page.locator('#gallery-upload').isEnabled());assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#gallery-sort').isEnabled());assert(await page.locator('#gallery-list-status').isVisible());assert.equal(await page.locator('.card.empty').count(),0);await page.locator('#gallery-search').fill('Progressive');await page.locator('#gallery-sort').selectOption('name');checks++; releaseMetadata();await image;await page.evaluate(()=>window.navigation); - assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++; + assert.equal(await page.locator('#gallery-search').inputValue(),'Progressive');assert.equal(await page.locator('#gallery-sort').inputValue(),'name');assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++; assert(await page.locator('.image-loading').evaluate(box=>{const a=box.getBoundingClientRect(),b=box.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;}));checks++; releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));checks++; await page.evaluate(()=>navigate('upload')); @@ -34,6 +35,15 @@ const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright') assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++; releaseDevice();await page.evaluate(()=>window.navigation);await page.unroute('**/api/v1/devices/living-room'); } + await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';}); + let releaseSlow,slowRequested;const slow=new Promise(resolve=>slowRequested=resolve); + await page.route('**/api/v1/devices/*/images',async r=>{slowRequested();await new Promise(resolve=>releaseSlow=resolve);await r.fulfill({json:[]});}); + await page.evaluate(()=>{window.slowNavigation=navigate('gallery');});await slow;await page.locator('#local-tab').click();await page.waitForFunction(()=>state.tab==='local'&&document.querySelector('#local-tab')?.classList.contains('active'));releaseSlow();await page.evaluate(()=>window.slowNavigation);assert.equal(await page.evaluate(()=>state.tab),'local');assert(await page.locator('#gallery-search').isVisible());checks++; + await page.unroute('**/api/v1/devices/*/images'); + await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';}); + await page.route('**/api/v1/devices/*/images',r=>r.fulfill({status:503,json:{error:{code:'CLOUD_UNAVAILABLE',message:'fixture unavailable'}}})); + await page.evaluate(()=>navigate('gallery'));assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#local-tab').isEnabled());assert.equal(await page.locator('#gallery-list-status').getAttribute('role'),'alert');checks++; + await page.unroute('**/api/v1/devices/*/images'); let releaseJobs,jobsRequested;await page.route('**/api/v1/jobs',async r=>{jobsRequested();await new Promise(resolve=>releaseJobs=resolve);await r.fulfill({json:[]});}); await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-fixture',name:'Local fixture',url:'/local-fixture.svg',origin:'manual',starred:false}]})); await page.route('**/local-fixture.svg',r=>r.fulfill({contentType:'image/svg+xml',body:''})); diff --git a/tests/test_api.py b/tests/test_api.py index c9706de..2f1c13b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -442,3 +442,16 @@ def test_storage_display_name_environment(monkeypatch): assert Settings().storage_name == "Family NAS" monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " ") assert Settings().storage_name == "NAS" + + +def test_settings_device_catalog_does_not_fetch_cloud_status( + client, service, monkeypatch +): + registry = service.registry + for driver in registry.drivers.values(): + monkeypatch.setattr( + driver, "device", lambda *a, **kw: pytest.fail("Cloud status fetched") + ) + result = client.get("/api/v1/settings") + assert result.status_code == 200 + assert {d["id"] for d in result.json()["device_catalog"]} == set(registry.drivers)