diff --git a/SHA256SUMS b/SHA256SUMS index c680be2..6927862 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -31,7 +31,7 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI 10842c477d34e6fde7af0a98c739ef513ef48480d52e2fe6d6874eed33461d15 docs/README.md 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md -aafc41e44e8c5e0d1554a3be5b6b961cf98ed71185f6084b0480f6e9dbea7ae8 docs/SOURCE_LIBRARIES.md +35c15a33db5ee1eab2507f1fe1e2dd7934584a321f155c66cb1593d04a342fb4 docs/SOURCE_LIBRARIES.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md 1ae7e53355dc27e2995c85f2a9a55ad87e53085cccb3235970236666b6485799 docs/WATCH-THUMBNAIL.md f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md @@ -131,22 +131,22 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f 8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json 82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json -3b17eb1781ee8c6147e06ccc16486aa27cf47c01f2a75c89529677c00b88dc90 pigway_frame_flow/web/app.js +c4003d43b43fbba8d4084ec40357b468a034f228fda7fae9435b1b7555ad7854 pigway_frame_flow/web/app.js f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js 94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg -1f77efa9acfb40872593e3b7c475d972b960cb9f21e9afb936add03c3f2dd542 pigway_frame_flow/web/features.js +b511a840ce201cf9ced4ef094e1ad564b54dcfd40a8be275c69a0869571b0150 pigway_frame_flow/web/features.js a07ea444ab4ac75ea11e1188a431b48195353b1e08f7e80f3b931e8097ea6221 pigway_frame_flow/web/i18n.js 6385ec8b0383fa9bdbb23c4c685c8ef1afd7926fd74c8032811e647808548255 pigway_frame_flow/web/index.html -5ab3ce05c6a1516dd4268cf221c5e79bf81317db2db74610ad519e84184a9854 pigway_frame_flow/web/libraries.js +b8de7d813b0ff738b182f25167e520468aad2533ad741e5d25e93ee5d681b3e4 pigway_frame_flow/web/libraries.js 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg 9dcf0419d0292efce8ead7309dc70b4afd27dd24fe82cf9e673682342650f4da pigway_frame_flow/web/source-browser.js -b7d46a2826b217ed760d99690e28b48f60e575f7f580d0f37e416889902f5f9b pigway_frame_flow/web/style.css +d1e42232c50e3ed23912504997d776e70127f089daba944f514f68c90364c332 pigway_frame_flow/web/style.css cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css -ec920d7a083968ddfb15d23295499c64ace13cd2e65ca01a373a69158837bc3f pigway_frame_flow/web/thumbnails.js +0ea2cc7da61c89c7505f4a312aad692d52084df32fc279f5742c96c31d84d09e pigway_frame_flow/web/thumbnails.js 5f392f94636d261ae2982815763b794ae9595a1b9226946b792105634ec3f559 pyproject.toml d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock 008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh @@ -167,10 +167,6 @@ b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/produc 57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs 5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs 6b602f617ba845fb64335ececc90ca3fac46073f13555707652f2f080be73b2e scripts/ui-dashboard-preview.cjs -67b87d9155b74e2591ab459cc785aa4f2ff6e30c0ad6acef297593261f34ed00 scripts/ui-gallery-bootstrap.cjs -f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs -01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs -bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs 8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs 86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs 1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs @@ -178,14 +174,13 @@ bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gal 66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs 70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs 39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs +820f305bed5e04ee621c76a4a80057d878152788bf0f6e382bcdd02d5d61fd3d scripts/ui-libraries.cjs b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs 8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs 98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs -19ec17efbcef3dd162d3698e0b30d55f4182ae44d2058eb94552d2ef44562e04 scripts/ui-photo-retention.cjs f721682b12291d232bf727b1c999a7231fb1f4fc5030303baea04ad3b3bfdf2a scripts/ui-photo-sources.cjs a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs 7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs -d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-progressive-loading.cjs 8eee6d087ae3d65208c423d40623052a9bfbf3db4907299b832e81fbe835fb71 scripts/ui-smoke.cjs 4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs 8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs diff --git a/docs/SOURCE_LIBRARIES.md b/docs/SOURCE_LIBRARIES.md index 812e11c..ec2054d 100644 --- a/docs/SOURCE_LIBRARIES.md +++ b/docs/SOURCE_LIBRARIES.md @@ -6,7 +6,7 @@ 1. 在“照片数据源”配置来源、人物、分辨率、质量、去重等选图条件。 2. 在侧栏“来源图库”选择来源,选入照片,保存无文字叠加的源画面。 -3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。也可将旧版小图方案参数复制到所选来源下。 +3. 新增该来源内的生成方案,可使用相框横版、竖版、手表预设,或自定义尺寸、适配方式、裁切位置、质量、色彩与说明叠加。 4. 来源图库标签为“源画面”和该来源的生成方案;未生成的版本明确显示未生成,生成后可预览、下载、查看参数快照、删除。归档方案仍可查看已有版本。 5. 在“终端图库”对每个终端单选来源和方案。切换来源后必须重新选择方案,服务端检查归属和屏幕尺寸。绑定同一方案的终端共用生成文件。 6. 需要自动化时,为照片任务勾选“按来源图库统一生成并推送”。新建照片任务默认使用该模式;旧任务不在启动时自动改写,确认绑定后可在编辑任务时切换。唯一明确的单来源、单照片任务也可使用 `scripts/migrate-source-libraries.py` 预检,再加 `--apply` 迁移;脚本保留启用状态、调度配置和下次执行时间,不推送设备。若当前设备有可靠的照片来源回执,会选入该画面并仅在本地生成两个版本。生成模式不再额外选择旧版小图方案。 @@ -35,7 +35,7 @@ 删除本地源画面会同时删除其本地生成版本,执行前检查任务、模板和待处理操作的引用;不会删除上游照片或设备已接收的副本。删除方案采用归档,历史版本保留快照;被终端绑定的方案必须先解除或更换绑定。包含绑定、活动方案或本地源画面的来源配置禁止直接删除。 -旧本地成品、旧小图及现有云端图片均保留,通过“历史本地成品”“旧版小图图库”“设备已有云端图片”查看。不会凭文件名或尺寸将旧成品猜测为源画面,不自动覆盖旧任务或推断终端绑定。 +旧本地成品、旧小图及现有云端图片数据保留,但旧图库页面和入口已退役。来源图库与终端图库只展示按新模型关联的图片,不凭文件名或尺寸将旧成品猜测为源画面。照片数据源配置、任务执行及已有小图任务的兼容接口继续保留;新任务使用来源内生成方案和终端绑定。 ## API 与存储 diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 510a201..bbc2c16 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -1,14 +1,14 @@ '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.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=[];} +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:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0}; +function forgetLogin(){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:'▧','terminal-gallery':'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',thumbnails:'▧',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; +const icons={dashboard:'◫',gallery:'▧','terminal-gallery':'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'}; const t=k=>window.MESSAGES[state.lang][k]||k; function operationErrorMessage(error){const reason=error?.details?.reason;if(reason&&t(reason)!==reason)return t(reason);return error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'));} const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); @@ -49,16 +49,7 @@ function operationResultHTML(o){ } function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;} function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;} -function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>!['legacy-plans','legacy-gallery','source-browser'].includes(id)).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=['terminal-gallery','legacy-gallery'].includes(state.page)&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('')+``;revealCurrentNavigation();} -// A destination appears only when its connection and read/write adapter are available. -// Future cloud-drive destinations must implement their adapter before registration here. -const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']); -function galleryDestinations(){ - const configured=state.settings?.gallery_destinations; - const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}]; - return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter)); -} -function galleryDestinationName(destination){if(destination.id==='watch')return tr('手表 · 小图图库','Watch · Thumbnail gallery');return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;} +function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).filter(([id])=>id!=='source-browser').map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='terminal-gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('')+``;revealCurrentNavigation();} function imageLoading(img){ if(img.parentElement?.classList.contains('image-loading'))return img.parentElement; const box=document.createElement('div');box.className='image-loading'; @@ -114,7 +105,7 @@ async function openImageViewer(path,name){showModal(`

${esc(name)}

${t('confirm')}

${esc(message)}

`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});} function detail(label,value){return `
${t(label)}${esc(value)}
`;} function liveWriteAllowed(){return state.settings?.mock||state.settings?.write_enabled;} -function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('[data-show],'+(state.tab==='cloud'?'.image-card .danger,':'')+'#show-name,#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});} +function guardDeviceButtons(){if(!liveWriteAllowed())document.querySelectorAll('#display-template,#display-upload,#restore').forEach(b=>{b.disabled=true;b.title=t('readOnlyWarning');});} async function templateEditor(selected){ const raw=$('#template-data');const assets=await api('/api/v1/assets?source=uploaded&limit=200');let values=JSON.parse(raw.value); const panel=document.createElement('div');panel.id='template-fields';raw.before(panel); @@ -133,24 +124,7 @@ const views={ 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;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+'%'],['currentImageId',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('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}

${t('cloudRotation')}

${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',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'; - if(state.tab==='watch')return watchGalleryPage(epoch,destinations); - 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,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('watch-tab',()=>{state.tab='watch';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];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),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('legacy-gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));}, + 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);toast(tr('已保存到服务器,可用于模板与任务','Saved on server for templates and tasks'));});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;}}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));}, async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`

${t('history')}

${buttons('history-refresh','refresh')}
${ops.some(x=>x.status==='UNKNOWN')?`
${t('unknown')}
`:''}
${ops.length?`${['created','action','status','result','client','requestId'].map(x=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${fmt(o.created_at)}${esc(operationActionLabel(o.action))}
${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}
${t(o.status)}${o.status==='UNKNOWN'?`
`:''}
${operationResultHTML(o)}${esc(operationClientLabel(o.client_id))}${esc(o.request_id)}
${esc(o.operation_id)}
`:`
${t('noHistory')}
`}
`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`

${t('reconcile')}

${t('current')}: ${esc(r.current_image_id)}

${t('result')}: ${esc(r.expected_image_id)}

${t('unknown')}

`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, @@ -158,7 +132,7 @@ const views={ }; function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,library_pipeline:j.library_pipeline||false,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};} async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`

${t('playlists')}

`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);} -$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{if($('#device').value==='@watch'){state.tab='watch';state.watchOffset=0;return navigate('gallery');}state.device=$('#device').value;if(state.page==='gallery'&&state.tab==='watch')state.tab='cloud';navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); +$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme); applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`PIGWay Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000); diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 21af148..a2181be 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -31,20 +31,6 @@ function displayDialog(content){ showModal(`

${t('displayOptions')}

${t('temporaryOptionsHint')}

`); $('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');}); } -function enhanceGallery(){ - const grid=$('.gallery');if(!grid)return; - const controls=document.createElement('div');controls.className='form-grid gallery-filters';controls.innerHTML=`
`;grid.before(controls); - if(state.tab==='local'){controls.classList.add('gallery-filters-local');const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp')+' '+t('excludePhotoHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#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=controls.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();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.checked);for(const action of ['star','unstar','exclude','include','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));star.disabled=Boolean(item.excluded);star.title=item.excluded?t('clearExclusionFirst'):'';card.querySelector('.actions').append(star);const exclude=document.createElement('button');exclude.textContent=t(item.excluded?'includePhoto':'excludePhoto');exclude.setAttribute('aria-pressed',String(Boolean(item.excluded)));exclude.disabled=Boolean(item.starred);exclude.title=item.starred?t('clearStarFirst'):'';card.querySelector('.actions').append(exclude);exclude.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/exclusion',{method:'PUT',body:{excluded:!item.excluded}});await navigate();});if(item.starred){const kept=document.createElement('small');kept.className='photo-mark';kept.textContent=t('photoStarred');card.querySelector('h3').after(kept);}if(item.excluded){const badge=document.createElement('small');badge.className='excluded-photo';badge.textContent=t('photoExcluded');card.querySelector('h3').after(badge);}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(state.tab==='local'?'downloadShort':'download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local')card.querySelector('.actions').append(card.querySelector('[data-delete]'));}); - if(state.tab==='local'){ - const updateMarks=()=>{const selected=[...grid.querySelectorAll('.asset-select:checked')].map(box=>state.gallery.find(item=>item.asset_id===box.dataset.asset));const starred=selected.some(item=>item?.starred),excluded=selected.some(item=>item?.excluded);$('#asset-batch-star').disabled=!selected.length||excluded;$('#asset-batch-star').title=!selected.length?t('selectImagesFirst'):excluded?t('clearExclusionFirst'):'';$('#asset-batch-exclude').disabled=!selected.length||starred;$('#asset-batch-exclude').title=!selected.length?t('selectImagesFirst'):starred?t('clearStarFirst'):'';disabledMarkHints(grid.parentElement);}; - grid.querySelectorAll('.asset-select').forEach(box=>box.onchange=updateMarks); - for(const id of ['asset-all','asset-invert']){const previous=$('#'+id).onclick;$('#'+id).onclick=event=>{previous(event);updateMarks();};}updateMarks(); - } - disabledMarkHints(grid); - $('#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; function result(w){$('#weather-result').innerHTML=`

${esc(w.current.temperature)} · ${esc(w.current.summary)}

${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}

${t('forecast')}

${w.forecast.map(x=>`

${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%

`).join('')}`;} @@ -147,13 +133,13 @@ function photosLoginDialog(){ async function photoSourcesPanel(){ const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; - section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}

${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; + section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.kind==='synology-photos'?t('sourcePhotosAPI'):t('sourceOwnCredentials')}

${esc(s.kind==='synology-photos'?'Synology Photos':s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; await photosConnectionPanel(); bind('add-photo-source',()=>photoSourceForm()); section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit))); section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('photo-sources');})); section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom))); - section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=busy(()=>browseSource(b.dataset.sourceBrowse))); + section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=()=>openSourceBrowser(b.dataset.sourceBrowse)); section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } async function photoSourceForm(source=null){ @@ -161,13 +147,6 @@ async function photoSourceForm(source=null){ $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'synology-photos';const update=()=>{const photosAPI=$('#source-kind').value==='synology-photos';$('#source-nas-root-field').hidden=!photosAPI;$('#source-location').labels[0].textContent=photosAPI?tr('照片目录','Photo folder'):t('sourceLocation');$('#source-location').nextElementSibling.textContent=photosAPI?tr('填写所选空间内的目录路径,. 表示整个空间。按需获取 XL 预览图,不下载原图。','Folder path within the selected space; . means the whole space. Downloads XL previews on demand, not originals.'):t('sourceLocationHint');const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);$('#source-location').closest('.field').before($('#source-people-space').closest('.field'));update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';}; $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,nas_root_path:$('#source-nas-root').value||null,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:false,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_short_edge:Number($('#source-min-short').value),minimum_long_edge:Number($('#source-min-long').value),minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');}); } -async function browseSource(id,offset=0,refresh=false){ - const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return; - box.innerHTML=`

${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}

${t('sourceBrowseHint')} ${t('excludePhotoHelp')}

${result.items.map((x,i)=>`
${x.starred?`${t('photoStarred')}`:''}${x.excluded?`${t('photoExcluded')}`:''}
`).join('')||t('noSourcePhotos')}
${buttons('source-refresh','refresh')}
`; - for(const key of ['star','exclude'])box.querySelectorAll('[data-source-'+key+']').forEach(button=>button.onclick=busy(async()=>{const item=result.items[Number(button.dataset[key==='star'?'sourceStar':'sourceExclude'])],field=key==='star'?'starred':'excluded';await api('/api/v1/photo-sources/'+id+'/preferences?device_id='+encodeURIComponent(state.device),{method:'PUT',body:{item_id:item.id,[field]:!item[field]}});await browseSource(id,offset);})); - disabledMarkHints(box); - box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true)); -} async function previewSource(id,item_id=null){ const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id); showModal(`

${t('preview')}

${t('preview')}

${t('sourcePreviewHint')}

`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id})); diff --git a/pigway_frame_flow/web/libraries.js b/pigway_frame_flow/web/libraries.js index c7759d7..ac3a03a 100644 --- a/pigway_frame_flow/web/libraries.js +++ b/pigway_frame_flow/web/libraries.js @@ -1,25 +1,13 @@ 'use strict'; -// Keep older assets and APIs accessible without interpreting frame products as sources. -Object.assign(MESSAGES.zh,{'legacy-plans':'旧版小图方案',libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{'legacy-plans':'Legacy thumbnail plans',libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'}); -Object.assign(MESSAGES.zh,{gallery:'来源图库','terminal-gallery':'终端图库','legacy-gallery':'历史图库'}); -Object.assign(MESSAGES.en,{gallery:'Source library','terminal-gallery':'Terminal library','legacy-gallery':'Legacy gallery'}); -icons['legacy-gallery']='▧'; -const legacyGalleryView=views.gallery; -const legacyPlanView=views.thumbnails; -delete icons.thumbnails; -icons['legacy-plans']='▧'; -views['legacy-plans']=legacyPlanView; +// Source pixels, generated versions and terminal bindings share one gallery model. +Object.assign(MESSAGES.zh,{libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'}); +Object.assign(MESSAGES.zh,{gallery:'来源图库','terminal-gallery':'终端图库'}); +Object.assign(MESSAGES.en,{gallery:'Source library','terminal-gallery':'Terminal library'}); const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0}; function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;} function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceNameForId(terminal.id);} function librarySelectOptions(items,selected,label){return ``+items.map(p=>``).join('');} -function libraryLegacy(tab){state.tab=tab;return navigate('legacy-gallery');} - -views['legacy-gallery']=async function(epoch){ - await legacyGalleryView(epoch);if(epoch!==state.epoch)return; - const back=document.createElement('button');back.textContent=tr('返回来源图库','Back to source library');back.onclick=()=>navigate('gallery');$('#main').prepend(back); -}; views.gallery=epoch=>libraryPage(epoch,'source'); views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal'); async function libraryPage(epoch,kind){ @@ -34,23 +22,23 @@ async function libraryPage(epoch,kind){ async function sourceLibraryPage(epoch,sources,terminals){ const panel=$('#library-content'); - if(!sources.length){panel.innerHTML=`

${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}

`;bind('library-add-source',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));return;} + if(!sources.length){panel.innerHTML=`

${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}

`;bind('library-add-source',()=>navigate('photo-sources'));return;} if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;} const sourceId=libraryUI.source; const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]); if(epoch!==state.epoch)return; if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null; const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id)); - panel.innerHTML=`

${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}

${plan&&!plan.archived?``:''}

${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}

${page.items.length?'':`
${tr('尚未选入源画面。先从来源选入照片,再按方案生成。','No source pixels imported yet. Import a photo, then generate its versions.')}
`}
${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}
`; $('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();}; document.querySelectorAll('[data-library-plan]').forEach(b=>{ b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();}; b.onkeydown=async e=>{const tabs=[...document.querySelectorAll('[data-library-plan]')],index=tabs.indexOf(b);let next;if(e.key==='ArrowRight')next=(index+1)%tabs.length;else if(e.key==='ArrowLeft')next=(index+tabs.length-1)%tabs.length;else if(e.key==='Home')next=0;else if(e.key==='End')next=tabs.length-1;else return;e.preventDefault();libraryUI.plan=tabs[next].dataset.libraryPlan||null;await navigate();document.querySelector('[data-library-plan][aria-selected="true"]')?.focus();}; }); - bind('library-import',()=>libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local')); + bind('library-import',()=>libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources')); bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items)); bind('library-delete-plan',()=>confirmDialog(tr(`确认删除生成方案“${plan.name}”?已有生成版本保留参数快照;正在被终端使用的方案不能删除。`,`Delete generation plan “${plan.name}”? Existing versions keep their snapshots. Bound plans cannot be deleted.`),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();})); bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();}); @@ -67,13 +55,11 @@ async function sourceLibraryPage(epoch,sources,terminals){ } async function generationPlanEditor(sourceId,plan,pictures){ - const oldPlans=plan?[]:await api('/api/v1/thumbnail-plans'); const p=plan||{name:'',width:410,height:502,fit:'cover',quality:85,center_x:0.5,center_y:0.5,caption:false,profile:'color'}; - showModal(`

${tr('来源图库生成方案','Source-library generation plan')}

${!plan?`
${oldPlans.length?`
`:''}`:''}
${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`
`).join('')}

${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}

`); + showModal(`

${tr('来源图库生成方案','Source-library generation plan')}

${!plan?`
`:''}
${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`
`).join('')}

${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}

`); $('#generation-fit').value=p.fit;$('#generation-profile').value=p.profile; const fill=values=>{for(const key of ['name','width','height','quality','center_x','center_y','fit'])$('#generation-'+key).value=values[key];$('#generation-caption').checked=values.caption||false;$('#generation-profile').value=values.profile||'color';}; document.querySelectorAll('[data-generation-preset]').forEach(b=>b.onclick=()=>{const type=b.dataset.generationPreset;fill({...p,name:type==='watch'?tr('手表','Watch'):type==='frame-portrait'?tr('相框竖版','Portrait frame'):tr('相框横版','Landscape frame'),width:type==='watch'?410:type==='frame-portrait'?1200:1600,height:type==='watch'?502:type==='frame-portrait'?1600:1200,caption:type!=='watch'});}); - if($('#generation-legacy'))$('#generation-legacy').onchange=()=>{const old=oldPlans.find(p=>p.id===$('#generation-legacy').value);if(old)fill(old);}; const read=()=>({source_id:sourceId,name:$('#generation-name').value,width:Number($('#generation-width').value),height:Number($('#generation-height').value),quality:Number($('#generation-quality').value),center_x:Number($('#generation-center_x').value),center_y:Number($('#generation-center_y').value),fit:$('#generation-fit').value,profile:$('#generation-profile').value,caption:$('#generation-caption').checked}); bind('generation-preview',async()=>{if(!$('#generation-form').reportValidity())return;const id=$('#generation-picture').value;if(!id)throw new Error(tr('请先选入源画面','Import a source picture first'));const blob=await api('/api/v1/source-images/'+id+'/preview',{method:'POST',body:read(),binary:true});if(!$('#generation-preview-box'))return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#generation-preview-box').innerHTML=`${tr('生成预览','Generation preview')}`;}); $('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();}); @@ -87,11 +73,10 @@ async function terminalLibraryPage(epoch,sources,terminals){ const [page,plans]=await Promise.all([api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images?limit=30&offset='+libraryUI.offset),terminal.binding?api('/api/v1/generation-plans?source_id='+encodeURIComponent(terminal.binding.source_id)):Promise.resolve([])]); if(epoch!==state.epoch)return; const source=sources.find(s=>s.id===terminal.binding?.source_id),plan=plans.find(p=>p.id===terminal.binding?.plan_id); - $('#library-content').innerHTML=`

${esc(terminalLabel(terminal))}

${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}

${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}

${terminal.binding?``:''}
${page.items.length?'':`
${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}
`}
${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}
`; + $('#library-content').innerHTML=`

${esc(terminalLabel(terminal))}

${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}

${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}

${terminal.binding?``:''}
${page.items.length?'':`
${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}
`}
${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}
`; document.querySelectorAll('[data-library-terminal]').forEach(b=>b.onclick=()=>{if(b.dataset.libraryTerminal===libraryUI.terminal)return;libraryUI.terminal=b.dataset.libraryTerminal;libraryUI.offset=0;state.tab=libraryUI.terminal==='watch'?'watch':'cloud';if(libraryUI.terminal!=='watch')state.device=libraryUI.terminal;return navigate();}); - bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr('解除终端绑定?已有图片保留,后续来源任务不再向此终端输出。','Unbind this terminal? Images are kept; future source tasks will not output to it.'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();})); + bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr(`确认解除“${terminalLabel(terminal)}”与来源图库及生成方案的绑定?已有图片保留,后续来源任务不再向此终端输出。`,`Unbind “${terminalLabel(terminal)}” from its source library and generation plan? Images are kept; future source tasks will not output to it.`),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();})); bind('library-to-source',()=>{libraryUI.source=terminal.binding.source_id;libraryUI.plan=terminal.binding.plan_id;libraryUI.offset=0;state.tab='local';return navigate('gallery');}); - bind('library-old-terminal',()=>{if(terminal.id!=='watch')state.device=terminal.id;return libraryLegacy(terminal.id==='watch'?'watch':'cloud');}); bind('terminal-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('terminal-next',()=>{libraryUI.offset+=30;return navigate();}); document.querySelectorAll('[data-terminal-preview]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalPreview];return openImageViewer(v.url,v.name);})); document.querySelectorAll('[data-terminal-download]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalDownload];return downloadImage(v.url,v.name);})); @@ -107,13 +92,13 @@ async function terminalBindingEditor(terminal,sources){ $('#binding-source').onchange=busy(()=>load(null));$('#terminal-binding-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'PUT',body:{source_id:$('#binding-source').value,plan_id:$('#binding-plan').value}});$('#modal').close();await navigate();});await load(terminal.binding?.plan_id); } -$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||['terminal-gallery','legacy-gallery'].includes(state.page)){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('terminal-gallery');}state.device=id;return navigate();}; +$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||state.page==='terminal-gallery'){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('terminal-gallery');}state.device=id;return navigate();}; async function libraryJobFields(job,isNew){ const box=document.createElement('section');box.innerHTML=`

${tr('选一次照片并保存源画面,按来源内的终端绑定分别生成;同一方案共用成品。相框继续走上传和显示确认,手表仅生成。此模式不再单独选择“小图方案”。旧任务默认保留原模式,勾选后切换。','Select one photo, save source pixels and generate each bound plan. Terminals sharing a plan share its version. Frames upload and confirm display; watches generate only. Legacy tasks keep their old mode until enabled.')}

`; $('#job-form .modal-actions').before(box); const kind=$('#job-content-type'),pipeline=$('#job-library-pipeline'); - const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked;pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}}; + const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked||(!job.generate_thumbnail&&!job.thumbnail_plan_id);pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}}; kind.addEventListener('change',()=>{if(isNew&&kind.value==='photo_source')pipeline.checked=true;update();});pipeline.onchange=update;update(); } diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index b579ec0..775f21c 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -172,12 +172,6 @@ body.login-mode header,body.login-mode footer{flex-shrink:0} .image-card .actions{gap:6px} .image-card .actions button{white-space:nowrap} -.gallery-filters{align-items:end} -.gallery-filters-local{grid-template-columns:repeat(4,minmax(0,1fr))} -.gallery-filters .field{min-width:0} -@media(max-width:1050px){.gallery-filters{grid-template-columns:repeat(2,minmax(0,1fr))}} -@media(max-width:720px){.gallery-filters{grid-template-columns:minmax(0,1fr)}} -.gallery-filters input,.gallery-filters select{height:46px} .automation-status{color:var(--accent)} .automation-table td{vertical-align:middle} @@ -248,16 +242,12 @@ form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)); @media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}} -.source-photo{min-width:0;display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--border);border-radius:12px} -.source-photo>[data-source-item]{width:100%;text-align:left;overflow-wrap:anywhere} -.source-photo .actions{margin-top:auto} .excluded-photo{display:block;color:var(--muted);font-size:12px} .image-card>small{display:block} .photo-mark{display:block;color:var(--accent);font-size:12px} .image-card{display:flex;flex-direction:column} -.image-card>.asset-select{align-self:flex-start} .image-card>.actions{margin-top:auto;padding-top:12px;justify-content:flex-start} .disabled-mark-hint{display:inline-flex;max-width:100%;cursor:help} diff --git a/pigway_frame_flow/web/thumbnails.js b/pigway_frame_flow/web/thumbnails.js index b3ba8f0..88fe8ea 100644 --- a/pigway_frame_flow/web/thumbnails.js +++ b/pigway_frame_flow/web/thumbnails.js @@ -1,48 +1,9 @@ 'use strict'; -Object.assign(MESSAGES.zh, {thumbnails:'小图方案',thumbnailPlanInUse:'此方案已被任务或待处理操作使用,请先取消关联或更换方案。'}); -Object.assign(MESSAGES.en, {thumbnails:'Thumbnail plans',thumbnailPlanInUse:'This plan is used by a task or pending operation. Unlink it or select another plan first.'}); - -views.thumbnails=async function(epoch){ - const [plans,sources]=await Promise.all([api('/api/v1/thumbnail-plans'),api('/api/v1/photo-sources')]); - if(epoch!==state.epoch)return; - $('#main').innerHTML=`
${[['width',tr('宽度(像素)','Width (px)'),410,16,2048,1],['height',tr('高度(像素)','Height (px)'),502,16,2048,1],['center_x',tr('水平裁切位置(0 左 / 1 右)','Crop X (0 left / 1 right)'),0.5,0,1,0.01],['center_y',tr('垂直裁切位置(0 上 / 1 下)','Crop Y (0 top / 1 bottom)'),0.5,0,1,0.01],['quality',tr('JPEG 质量','JPEG quality'),85,40,95,1]].map(([key,label,value,min,max,step])=>`
`).join('')}

${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}

${tr('预览与生成','Preview and generate')}

`; - const defaults={name:'',width:410,height:502,center_x:0.5,center_y:0.5,quality:85,fit:'cover'}; - const fill=p=>{for(const key of Object.keys(defaults))$('#thumb-'+key).value=p[key];}; - const read=()=>{const p={};for(const key of Object.keys(defaults))p[key]=['name','fit'].includes(key)?$('#thumb-'+key).value:Number($('#thumb-'+key).value);return p;}; - fill(defaults);$('#thumb-delete').disabled=true; - bind('thumb-gallery',()=>{state.tab='watch';state.watchOffset=0;return navigate('legacy-gallery');}); - $('#thumb-form').oninput=()=>{$('#thumb-image').hidden=true;$('#thumb-result').textContent=tr('参数已修改,请重新预览','Parameters changed; preview again');}; - $('#thumb-plan').onchange=()=>{fill(plans.find(p=>p.id===$('#thumb-plan').value)||defaults);$('#thumb-delete').disabled=!$('#thumb-plan').value;$('#thumb-image').hidden=true;}; - $('#thumb-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#thumb-plan').value;await api('/api/v1/thumbnail-plans'+(id?'/'+id:''),{method:id?'PUT':'POST',body:read()});toast(t('save'));await navigate();}); - bind('thumb-delete',()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/thumbnail-plans/'+$('#thumb-plan').value,{method:'DELETE'});await navigate();})); - let offset=0,version=0; - async function images(){const source=$('#thumb-source').value;const ticket=++version;$('#thumb-item').innerHTML='';if(!source)return;const page=await api('/api/v1/photo-sources/'+encodeURIComponent(source)+'/images?offset='+offset+'&limit=50');if(ticket!==version||epoch!==state.epoch)return;$('#thumb-item').innerHTML=page.items.map(i=>``).join('');$('#thumb-prev').disabled=offset===0;$('#thumb-next').disabled=offset+50>=page.total;} - $('#thumb-source').onchange=busy(async()=>{offset=0;await images();}); - bind('thumb-prev',async()=>{offset=Math.max(0,offset-50);await images();});bind('thumb-next',async()=>{offset+=50;await images();}); - function payload(){if(!$('#thumb-form').reportValidity())throw new Error(tr('请补全方案参数','Complete the plan parameters'));if(!$('#thumb-item').value)throw new Error(tr('请选择源图片','Choose a source image'));return {source_id:$('#thumb-source').value,item_id:$('#thumb-item').value,plan:read()};} - bind('thumb-preview',async()=>{const blob=await api('/api/v1/thumbnails/preview',{method:'POST',body:payload(),binary:true});if(epoch!==state.epoch)return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#thumb-image').src=url;$('#thumb-image').hidden=false;$('#thumb-result').textContent=tr('预览未保存','Preview is not saved');}); - bind('thumb-generate',async()=>{const asset=await api('/api/v1/thumbnails/generate',{method:'POST',body:payload()});if(epoch!==state.epoch)return;$('#thumb-image').hidden=false;await blobImage($('#thumb-image'),asset.url);$('#thumb-result').textContent=tr('已保存到手表小图图库:','Saved to watch thumbnail gallery: ')+asset.name;}); -}; - +// Compatibility controls for existing thumbnail tasks; legacy gallery pages are retired. async function thumbnailJobFields(job){ const plans=await api('/api/v1/thumbnail-plans');if(!$('#job-form'))return; - const box=document.createElement('section');box.id='legacy-thumbnail-job-fields';box.innerHTML=`

${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}

`; + const box=document.createElement('section');box.id='legacy-thumbnail-job-fields';box.hidden=!job.generate_thumbnail&&!job.thumbnail_plan_id;box.innerHTML=`

${tr('仅支持照片数据源任务;上传成功后使用源画面生成。新任务请使用来源图库生成方案与终端绑定。','For photo-source tasks only; generates from source pixels after upload succeeds. New tasks should use source-library plans and terminal bindings.')}

`; $('#job-form .modal-actions').before(box); const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;}; $('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update(); } - -async function watchGalleryPage(epoch,destinations){ - const offset=state.watchOffset||0; - const [page,devices]=await Promise.all([api('/api/v1/thumbnails?limit=50&offset='+offset),api('/api/v1/thumbnail-devices')]); - if(epoch!==state.epoch)return; - const watch=devices.find(d=>d.id==='watch'); - $('#main').innerHTML=`

${tr('手表','Watch')} · ${watch.width} × ${watch.height}

${tr('独立小图设备 · 图片已保存在服务器;设备传输尚未接入。','Separate thumbnail device · Images are saved on the server; device transfer is not connected.')}

${tr('共','Total')} ${page.total} ${tr('张小图','thumbnails')}
${page.items.length?'':`
${tr('还没有小图','No thumbnails yet')}

${tr('在“小图方案”中生成,或为照片任务勾选生成小图。旧版手动生成的小图仍在原本地图库。','Generate one in Thumbnail plans or enable thumbnails on a photo task. Older manually generated images remain in the original local gallery.')}

`}`; - document.querySelectorAll('[data-watch-tab]').forEach(b=>b.onclick=()=>{state.tab=b.dataset.watchTab;return navigate('legacy-gallery');}); - bind('watch-refresh',()=>navigate());bind('watch-plans',()=>navigate('legacy-plans')); - bind('watch-prev',()=>{state.watchOffset=Math.max(0,offset-50);return navigate();});bind('watch-next',()=>{state.watchOffset=offset+50;return navigate();}); - document.querySelectorAll('[data-watch-preview]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchPreview];return openImageViewer(x.url,x.name);})); - document.querySelectorAll('[data-watch-download]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchDownload];return downloadImage(x.url,x.name);})); - document.querySelectorAll('[data-watch-delete]').forEach(b=>b.onclick=()=>{const x=page.items[b.dataset.watchDelete];return confirmDialog(tr('删除这张手表小图?不会删除源照片。','Delete this watch thumbnail? The source photo is kept.'),async()=>{await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});state.watchOffset=Math.max(0,offset-(page.items.length===1?50:0));await navigate();});}); - hydrateImages(); -} diff --git a/scripts/ui-gallery-bootstrap.cjs b/scripts/ui-gallery-bootstrap.cjs deleted file mode 100644 index c824436..0000000 --- a/scripts/ui-gallery-bootstrap.cjs +++ /dev/null @@ -1,10 +0,0 @@ -/* 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-gallery-current.cjs b/scripts/ui-gallery-current.cjs deleted file mode 100644 index 194c10e..0000000 --- a/scripts/ui-gallery-current.cjs +++ /dev/null @@ -1,14 +0,0 @@ -/* Mock current-image metadata; never performs a device write. */ -const assert=require('assert'),{chromium}=require('playwright'); -(async()=>{ - const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),page=await browser.newPage();let checks=0,writes=0; - await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Current fixture',current:true,url:'/mock-image.jpg',created_at:null},{image_id:2,name:'Other fixture',current:false,url:'/mock-image.jpg',created_at:null}]})); - page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;}); - try{ - await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock)); - for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){ - await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('[data-show="0"]').waitFor();assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++; - } - assert(writes===0);console.log('Current-image gallery button: '+checks+' profiles passed; disabled label, other image enabled, no display request'); - }finally{await browser.close();} -})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-gallery-destinations.cjs b/scripts/ui-gallery-destinations.cjs deleted file mode 100644 index 8ca3839..0000000 --- a/scripts/ui-gallery-destinations.cjs +++ /dev/null @@ -1,18 +0,0 @@ -/* Mock-only: real writable destinations, concrete names and responsive labels. */ -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 browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); - try{ - const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor(); - await page.evaluate(()=>{state.settings.storage_name='FixtureNAS';state.settings.gallery_destinations=[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:'FixtureNAS',storage:'local',connected:true,supports_upload:true},{id:'read-only-drive',adapter:'kokonna',name:'Read-only fixture',storage:'cloud',connected:true,supports_upload:false},{id:'offline-drive',adapter:'kokonna',name:'Offline fixture',storage:'cloud',connected:false,supports_upload:true},{id:'unimplemented-drive',adapter:'unimplemented',name:'Future fixture',storage:'cloud',connected:true,supports_upload:true}];});let checks=0; - for(const width of [320,390,768,1440])for(const lang of ['zh','en'])for(const theme of ['light','dark']){ - await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;return navigate('gallery');},{lang,theme}); - assert.equal(await page.locator('#cloud-tab').innerText(),lang==='zh'?'KoKonna 云端图库':'KoKonna cloud gallery');assert.equal(await page.locator('#local-tab').innerText(),lang==='zh'?'FixtureNAS 本地图库':'FixtureNAS Local gallery');assert.equal(await page.locator('[data-gallery-destination]').count(),2);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++; - } - await page.setViewportSize({width:320,height:740});await page.evaluate(()=>{state.settings.gallery_destinations.find(d=>d.id==='local').name='家庭照片存储服务器名称很长的NAS';return navigate('gallery');});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++; - await page.locator('#local-tab').click();assert.equal(await page.evaluate(()=>state.tab),'local');await page.locator('#cloud-tab').click();assert.equal(await page.evaluate(()=>state.tab),'cloud');checks++; - assert.deepEqual(errors,[]);console.log('Gallery destinations:',checks,'checks PASS; concrete labels, capability/connection/adapter gates, responsive layout and tab switching'); - }finally{await browser.close();} -})().catch(e=>{console.error(e.message);process.exit(1)}); diff --git a/scripts/ui-gallery-stars.cjs b/scripts/ui-gallery-stars.cjs deleted file mode 100644 index 0445e12..0000000 --- a/scripts/ui-gallery-stars.cjs +++ /dev/null @@ -1,18 +0,0 @@ -/* Mock current-image metadata; never performs a device write. */ -const assert=require('assert'),{chromium}=require('playwright'); -(async()=>{ - const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),page=await browser.newPage();let checks=0,writes=0,starred=false; - await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-1',name:'Current fixture',current:true,origin:'manual',starred,url:'/mock-image.jpg',created_at:null},{asset_id:'local-2',name:'Other fixture',current:false,origin:'task',job_id:'fixture-task',job_name:'Fixture task',url:'/mock-image.jpg',created_at:null}]})); - await page.route('**/api/v1/assets/local-1/star',r=>{starred=r.request().postDataJSON().starred;return r.fulfill({json:{starred,expires_at:null}});}); - page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;}); - try{ - await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock)); - for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){ - await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('#local-tab').click();await page.locator('[data-show="0"]').waitFor();const boxes=page.locator('.asset-select');const selected=()=>boxes.evaluateAll(xs=>xs.map(x=>x.checked)); - assert.equal(await page.locator('#asset-invert').textContent(),lang==='zh'?'反选':'Invert selection');await boxes.nth(0).check();await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,true]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]); - await page.locator('#gallery-search').fill('Current fixture');await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,false]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);await page.locator('#gallery-search').fill('no matching fixture');await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[true,false]);await page.locator('#gallery-search').fill('');await page.locator('#asset-all').click();assert.deepEqual(await selected(),[true,true]);await page.locator('#asset-invert').click();assert.deepEqual(await selected(),[false,false]); - const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++; - } - assert(writes===0);console.log('Gallery inversion and current-image star: '+checks+' profiles passed; disabled label, other image enabled, no display request'); - }finally{await browser.close();} -})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-libraries.cjs b/scripts/ui-libraries.cjs new file mode 100644 index 0000000..2bf8bb7 --- /dev/null +++ b/scripts/ui-libraries.cjs @@ -0,0 +1,34 @@ +/* Mock-only regression for the current source/terminal galleries and unbinding. */ +const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright'); +(async()=>{ + const base=process.env.FRAME_FLOW_URL;assert(base); + const token=process.env.FRAME_FLOW_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 browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); + try{ + const page=await browser.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}})); + const sources=await(await fetch(base+'/api/v1/source-libraries',{headers:{Authorization:'Bearer '+token}})).json();assert(sources.length); + const sourceId=sources[0].id,planId='a'.repeat(32);let bound=true,deletions=0; + await page.route('**/api/v1/library-terminals',r=>r.fulfill({json:[{id:'living-room',kind:'frame',transport_supported:true,binding:bound?{source_id:sourceId,plan_id:planId}:null},{id:'watch',kind:'watch',transport_supported:false}]})); + await page.route('**/api/v1/generation-plans?*',r=>r.fulfill({json:[{id:planId,name:'Fixture frame',source_id:sourceId,width:1600,height:1200}]})); + await page.route('**/api/v1/library-terminals/*/images?*',r=>r.fulfill({json:{total:0,items:[]}})); + await page.route('**/api/v1/library-terminals/living-room/binding',r=>{assert.equal(r.request().method(),'DELETE');deletions++;bound=false;return r.fulfill({json:{}});}); + await page.route('**/api/v1/source-libraries/*/browse?*',r=>r.fulfill({json:{items:[],next_offset:null,scanned:0,paged_provider:true}})); + await page.goto(base+'/#gallery');await page.locator('#library-new-plan').waitFor(); + assert.equal(await page.locator('#library-legacy,#library-old-terminal,[data-page="legacy-gallery"],[data-page="legacy-plans"]').count(),0); + await page.locator('#library-new-plan').click();await page.locator('#generation-form').waitFor();assert.equal(await page.locator('#generation-legacy').count(),0);await page.locator('[data-generation-preset="watch"]').click();assert.equal(await page.locator('#generation-width').inputValue(),'410');await page.locator('#modal-cancel').click(); + await page.locator('[data-page="terminal-gallery"]').click();await page.locator('#library-unbind').waitFor(); + await page.locator('#library-unbind').click();await page.locator('dialog[open]').waitFor();assert.equal(deletions,0);assert((await page.locator('#modal-content').innerText()).includes('来源图库'));await page.locator('#modal-cancel').click();assert.equal(deletions,0); + await page.locator('#library-unbind').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>!document.querySelector('#library-unbind'));assert.equal(deletions,1); + for(const width of [1440,390]){await page.setViewportSize({width,height:900});await page.locator('[data-library-terminal="watch"]').click();await page.locator('[data-library-terminal="watch"][aria-pressed=true]').waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator('[data-library-terminal="living-room"]').click();} + await page.locator('[data-page="photo-sources"]').click();await page.locator('[data-source-browse]').first().click();await page.locator('#source-browser-filters').waitFor();assert(page.url().endsWith('#source-browser')); + await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-library-pipeline').waitFor({state:'attached'});await page.locator('#job-content-type').selectOption('photo_source');await page.locator('#job-library-pipeline').waitFor();assert(await page.locator('#legacy-thumbnail-job-fields').isHidden());await page.locator('#modal-cancel').click(); + const oldPlanId='b'.repeat(32); + await page.route('**/api/v1/thumbnail-plans',r=>r.fulfill({json:[{id:oldPlanId,name:'Existing task plan',width:410,height:502}]})); + await page.evaluate(async ({sourceId,oldPlanId})=>jobEditor({id:'fixture-job',name:'Existing task',device_id:'living-room',enabled:false,schedule:{type:'daily',time:'08:00'},source:{type:'photo_source',source_id:sourceId,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50,generate_thumbnail:true,thumbnail_plan_id:oldPlanId}),{sourceId,oldPlanId}); + assert(await page.locator('#legacy-thumbnail-job-fields').isVisible());assert(await page.locator('#job-thumbnail').isChecked());assert.equal(await page.locator('#job-thumbnail-plan').inputValue(),oldPlanId);await page.locator('#modal-cancel').click(); + assert.deepEqual(errors,[]);console.log('Current galleries, retired entries, generation presets, unbind confirmation, source browser, task editor and responsive layout passed'); + }finally{await browser.close();} +})(); diff --git a/scripts/ui-photo-retention.cjs b/scripts/ui-photo-retention.cjs deleted file mode 100644 index a2b46bf..0000000 --- a/scripts/ui-photo-retention.cjs +++ /dev/null @@ -1,3 +0,0 @@ -/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */ -const assert=require('assert'),{chromium}=require('playwright'); -(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-progressive-loading.cjs b/scripts/ui-progressive-loading.cjs deleted file mode 100644 index d340c0d..0000000 --- a/scripts/ui-progressive-loading.cjs +++ /dev/null @@ -1,54 +0,0 @@ -/* Mock-only: known image metadata renders real slots; no synthetic skeletons. */ -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 browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); - try{ - const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); - let releaseAccount;await page.route('**/api/v1/auth/me',async r=>{await new Promise(resolve=>releaseAccount=resolve);await r.continue();}); - await page.goto(base);await page.locator('#nav button').first().waitFor();assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);releaseAccount(); - await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor(); - let checks=1; - for(const width of [1440,390]){ - await page.setViewportSize({width,height:844});await page.evaluate(()=>state.galleryCache?.clear());let releaseMetadata,metadataRequested,releaseImage,imageRequested; - await page.route('**/api/v1/devices/*/images',async r=>{metadataRequested();await new Promise(resolve=>releaseMetadata=resolve);await r.fulfill({json:[{image_id:1,name:'Progressive fixture',url:'/progressive-fixture.svg',current:false}]});}); - await page.route('**/progressive-fixture.svg*',async r=>{assert.equal(await page.locator('#gallery-search').count(),1,'Search must precede image requests');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));imageRequested();await new Promise(resolve=>releaseImage=resolve);await r.fulfill({contentType:'image/svg+xml',body:''});}); - 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.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')); - const cachedMetadata=new Promise(resolve=>metadataRequested=resolve);const cachedImage=new Promise(resolve=>imageRequested=resolve); - await page.evaluate(()=>{window.navigation=navigate('gallery');});await cachedMetadata;await cachedImage; - assert.equal(await page.locator('#gallery-search').count(),1,'Search must exist while cached metadata refresh is pending');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'),'Search must be interactive');assert.equal(await page.locator('.image-card').count(),1);assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);checks++; - releaseMetadata();await page.evaluate(()=>window.navigation);releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading')); - - await page.unroute('**/api/v1/devices/*/images');await page.unroute('**/progressive-fixture.svg*'); - let releaseDevice,deviceRequested;await page.route('**/api/v1/devices/living-room',async r=>{deviceRequested();await new Promise(resolve=>releaseDevice=resolve);await r.continue();}); - const device=new Promise(resolve=>deviceRequested=resolve);await page.evaluate(()=>{window.navigation=navigate('dashboard');});await device; - 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:''})); - const jobs=new Promise(resolve=>jobsRequested=resolve);await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await jobs; - assert.equal(await page.locator('.asset-select').count(),1);assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));await page.locator('#gallery-search').fill('no-match');assert(await page.locator('.image-card').evaluate(e=>e.hidden));releaseJobs();checks++; - assert.deepEqual(errors,[]);console.log('Known image positions without skeletons:',checks,'checks PASS; delayed bootstrap, metadata and images; no page errors'); - }finally{await browser.close();} -})().catch(e=>{console.error(e.message);process.exit(1)});