From 2275974cde95724c4dcc63881f0f6aad3d406d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Fri, 16 Oct 2026 21:34:48 +0800 Subject: [PATCH] Release v0.1.8: improve gallery selection, image viewing and UI wording [skip ci] --- README.md | 2 +- README.zh-CN.md | 2 +- docs/PHOTO_SOURCES.md | 4 ++-- docs/audits/2026-10-16-ui-copy.md | 27 +++++++++++++++++++++++++++ docs/openapi.json | 6 +++--- docs/releases/v0.1.8.md | 13 +++++++++++++ pigway_frame_flow/api/app.py | 4 ++-- pigway_frame_flow/models.py | 4 ++-- pigway_frame_flow/web/app.js | 7 ++++--- pigway_frame_flow/web/extensions.js | 4 ++-- pigway_frame_flow/web/features.js | 14 +++++++------- pigway_frame_flow/web/i18n.js | 25 +++++++++++++++++-------- pigway_frame_flow/web/index.html | 2 +- pigway_frame_flow/web/style.css | 8 +++++--- pyproject.toml | 2 +- scripts/ui-copy-audit.cjs | 5 +++++ scripts/ui-dashboard-preview.cjs | 6 ++++++ scripts/ui-gallery-stars.cjs | 7 +++++-- 18 files changed, 104 insertions(+), 38 deletions(-) create mode 100644 docs/audits/2026-10-16-ui-copy.md create mode 100644 docs/releases/v0.1.8.md create mode 100644 scripts/ui-copy-audit.cjs create mode 100644 scripts/ui-dashboard-preview.cjs diff --git a/README.md b/README.md index 0d47c65..0cdd3b8 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The demo forces mock mode and disables device writes. Data and configuration rem - Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert. - Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages. - Device-bound schedules, ordered/random playlists, temporary priority overrides, restore and persistent operation history. -- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own configuration and authorization. +- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources. - Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection. Templates **render images**, including text-only templates. Choose a template, edit its data, generate a preview, then display it. Preview generation and saving template settings do not update the frame. Photo + Text and Daily Dashboard can include a selected local photo; other templates do not automatically choose one. Scheduled template jobs render afresh on each run. See [photo sources](docs/PHOTO_SOURCES.md), [automatic collages](docs/AUTOMATIC_COLLAGE.md) and [integration setup/limits](docs/EXTENSIONS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index d38fc8e..3421e98 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,7 +40,7 @@ python3.14 -m venv .venv ### 功能与边界 - 中英文 Web 界面、深浅色主题、账号登录、设备面板与图片预览。 -- 本地暂存和 KoKonna 云端图库,上传、切换、星标、来源/任务筛选及批量操作。 +- NAS 本地图库和 KoKonna 云端图库;星标、来源/任务筛选及批量操作位于本地图库,上传到云端并显示需单独操作。 - 保持比例的 contain/cover/smart 图片转换,彩色/灰度模式与照片数据源的横竖方向选择。 - 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。 - 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。 diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index b61f780..6eb64e5 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -30,7 +30,7 @@ change NAS permissions. Unreadable roots return an explicit HTTP 403 rather than an empty catalog. Missing host folders fail explicitly rather than being created automatically. -Open **Gallery → Photo sources → Add source**, choose **NAS local folder**, +Open **Photo sources → Add source**, choose **NAS local folder**, enter a relative folder (`.` for root or `family` for a subfolder). Supported formats: JPEG, PNG, WebP. Optional recursion excludes hidden directories, Synology `@eaDir` thumbnail trees, and symlinks. Original files remain unchanged. @@ -134,7 +134,7 @@ caption still comes from the original curated directory. 1. 将共享图库(如 `/volume1/photo`)挂载为 `/photos`,保持只读。 2. 在 `.env` 配置 `SYNOLOGY_PHOTOS_URL=https://NAS_IP:9115` 和 `SYNOLOGY_PHOTOS_CERT_SHA256`(服务实际 TLS 证书的 SHA-256 指纹,从可信 NAS 配置核实)。重建容器后生效。指纹变化时必须重新核实;每次连接在发送登录信息前校验同一个 TLS socket 的证书。 -3. 照片数据源 → 编辑数据源 → 连接 / 选择人物。管理员输入 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。 +3. 照片数据源 → 顶部共享连接 → 连接 Synology Photos。输入有权限的 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。登录由所有人物筛选数据源共享;随后进入各数据源编辑器选择人物。 4. 选择人物,启用筛选,选择“任意”或“同时包含所有”。默认只列出已命名人物,可搜索 ID、展开未命名分组。至少选择一个人物。 5. 与当前本地目录、日期文件夹规则取交集,再随机选图。原文件夹标题、日期、地点仍保留。不匹配返回空图库;预览 / 定时任务失败,不回退到全图库。 diff --git a/docs/audits/2026-10-16-ui-copy.md b/docs/audits/2026-10-16-ui-copy.md new file mode 100644 index 0000000..8f851b5 --- /dev/null +++ b/docs/audits/2026-10-16-ui-copy.md @@ -0,0 +1,27 @@ +# 界面表达审计(2026-10-16) + +范围:11 个主页面、主要弹窗、中英文文案、README 和照片数据源说明;对照当前 API、清理与来源记录逻辑。不是厂商接口能力或线上服务状态的全面审计。 + +| 位置 | 原问题 | 修正 | +| --- | --- | --- | +| 图库 | 排序使用“适配方式” | 独立“排序方式”标签 | +| 轮播 / 播放列表 | 播放顺序使用图片适配标签;间隔重复单位 | 使用“播放顺序”“切换间隔” | +| 总览 | 天气缺译,显示原始 weather | 补齐中英文 | +| 总览 | 图片 ID 标成图片;型号来源标成型号 | 独立标注 ID 与信息来源 | +| 同步状态 | “已同步”容易被视为实物刷新确认 | 标明云端确认 | +| 图片来源 | 无记录却断言非本系统生成 | 只描述未找到可用记录 | +| 模板编辑 | 非模板图片复用不准确的来源错误 | 明确无可编辑的模板来源 | +| 删除 | 本地当前图也提示画框切图 | 按本地、云端、当前云端副本区分确认文案 | +| 星标 / 多选 | 自动清理保护及隐藏勾选项范围不明确 | 加入服务端保留、手动删除、当前页选择范围说明 | +| 上传 | 固定尺寸提示可能与输出不一致 | 提示以生成预览为准 | +| 显示选项 | 不勾选恢复时仍显示时长,容易误解 | 说明时长与优先级的临时显示用途 | +| 数据源 | 未配置与无匹配照片共用空态 | 拆分两种空态 | +| 数据源 | 位置标签、凭据提示仅描述 WebDAV | 补充网盘目录 ID、百度路径、OAuth 凭据 | +| 人物筛选 | 仅描述共享空间,但实际可选个人空间 | 按所选空间说明访问权限 | +| AI / MQTT | AI 密钥称为设备密钥;连接状态缺译 | 独立 API 密钥标签,补齐连接状态 | +| 错误页 | 所有失败都归因设备云端 | 改成通用页面加载失败,保留具体原因 | +| 文档 | 照片数据源旧入口及账号独立性描述过时 | 对齐独立导航与共享 Photos 登录 | + +验证:305 项布局检查覆盖 320、390、844 横屏、1440 宽度,中英文与明暗主题,11 页及主要弹窗;8 组星标 / 反选回归;新增文案检查覆盖静态翻译完整性、排序 / 播放 / 型号标签、本地删除和星标作用范围。测试使用隔离 Mock,无真实设备写入、照片删除、AI 调用或部署。 + +此前未提交的反选、总览图片放大及共享图片查看器改动保留。此报告不代表已提交或已部署。 diff --git a/docs/openapi.json b/docs/openapi.json index 573b53f..f63e123 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Pigway Frame Flow API", - "version": "0.1.7" + "version": "0.1.8" }, "paths": { "/api/v1/devices/{device_id}/widgets/selection": { @@ -15216,7 +15216,7 @@ "version": { "type": "string", "title": "Version", - "default": "0.1.7" + "default": "0.1.8" } }, "type": "object", @@ -17412,7 +17412,7 @@ "version": { "type": "string", "title": "Version", - "default": "0.1.7" + "default": "0.1.8" }, "api_version": { "type": "string", diff --git a/docs/releases/v0.1.8.md b/docs/releases/v0.1.8.md new file mode 100644 index 0000000..25532e1 --- /dev/null +++ b/docs/releases/v0.1.8.md @@ -0,0 +1,13 @@ +# Pigway Frame Flow v0.1.8 + +增加 NAS 本地图库反选、放大总览图片,并统一图片查看器与界面表达。 + +- 反选只切换当前页可见图片的勾选状态;隐藏勾选项的批量操作范围明确提示。 +- 总览当前图片预览增大,点击或键盘打开与图库相同的查看器,读取原图并支持适应屏幕、原始尺寸和 Esc 关闭。 +- 修正图库排序、播放顺序、型号信息来源、云端确认状态等标签,补齐天气与连接状态翻译。 +- 区分本地与云端删除影响,解释星标保留规则,避免对未知来源作无依据判断。 +- 修正数据源空态、路径与凭据说明;同步 README 和操作文档中的共享 Photos 登录机制与入口。 + +验证:305 项布局检查、8 组星标与反选检查、12 组总览查看器检查、45 项图片加载检查及新增中英文文案检查通过。全套后端与静态检查由本次发布流水线再次验证。检查不涉及真实照片删除、设备写入或收费 AI 调用。 + +Improve NAS gallery selection, dashboard image viewing and UI wording. Invert selection affects visible items on the current page. Dashboard previews open the same full-image viewer as the gallery. Storage destinations, deletion scope, server-side stars, cloud confirmation and shared Photos credentials are clarified in both languages. diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 7365b56..b60e138 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True): app = FastAPI( title="Pigway Frame Flow API", docs_url=None, - version="0.1.7", + version="0.1.8", lifespan=lifespan, responses=error_responses, ) @@ -1031,7 +1031,7 @@ def create_app(settings=None, start_worker=True): supports_upload=True, ), ], - version="0.1.7", + version="0.1.8", mock=settings.mock, write_enabled=settings.write_enabled, api_key_configured=bool(settings.api_key) diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 79dd839..b3815d1 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -363,7 +363,7 @@ class SettingsView(BaseModel): class Health(BaseModel): status: str - version: str = "0.1.7" + version: str = "0.1.8" class Readiness(BaseModel): @@ -393,7 +393,7 @@ class RestoreRequest(StrictModel): class Version(BaseModel): product: str = "Pigway Frame Flow" - version: str = "0.1.7" + version: str = "0.1.8" api_version: str = "v1" commit: str = "development" build_time: str = "unknown" diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 8cc60d0..bb091de 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -95,6 +95,7 @@ function showModal(html){ } dialog.showModal();bind('modal-cancel',()=>dialog.close()); } +async function openImageViewer(path,name){showModal(`

${esc(name)}

${esc(name)}`);await blobImage($('#original-image'),path);} async function confirmDialog(message,fn){showModal(`

${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;} @@ -116,12 +117,12 @@ const views={ studio:()=>layoutStudioPage(), connections:()=>connectionsPage(), 'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); }, - async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

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

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

${t('refreshInterval')}

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

${t('device')}

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

${t('cloudRotation')}

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

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};}, + async 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'; 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('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`

${t('displayByName')}

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

${esc(x.name)}

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

${t('displayByName')}

`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];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):'')); @@ -142,7 +143,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,cleanup_unstarred:j.cleanup_unstarred||false,...changes};} async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);} -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);} +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=()=>{state.device=$('#device').value;navigate();};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/extensions.js b/pigway_frame_flow/web/extensions.js index 5062f45..481c0d1 100644 --- a/pigway_frame_flow/web/extensions.js +++ b/pigway_frame_flow/web/extensions.js @@ -29,7 +29,7 @@ async function connectionsPage(){ await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel(); } async function aiSettingsPanel(){ - const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}

${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}

${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}

`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));}); + const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}

${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}

${t('apiKeyConfigured')}: ${t(config.key_configured?'yes':'no')}

`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));}); } async function storageSettingsPanel(){ const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('本地图库保留策略','Local storage retention')}

${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}

${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}

`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';}); @@ -53,7 +53,7 @@ function addPhotoQualityFields(source){ } async function aiImagePanel(){ - const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('AI 生图','AI image generation')}

${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}

${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}

`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='Generated image';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));}); + const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`

${tr('AI 生图','AI image generation')}

${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}

${t('apiKeyConfigured')}: ${t(info.key_configured?'yes':'no')}

`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='Generated image';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));}); } async function mqttPanel(){ const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`

MQTT · ${t(status.connected?'connected':'disconnected')}

${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}

`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));}); diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 5410f67..207c1c4 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state async function enhanceDashboard(){ const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel); const current=await currentPanel(panel);if(epoch!==state.epoch)return; - if(current.preview_url){frame.innerHTML=`${t('currentContent')}${t('currentContent')} #${current.image_id}`;void blobImage($('#current-frame-image'),current.preview_url);}else{frame.innerHTML=`
${t('testLabel')}`;} + if(current.preview_url){frame.innerHTML=`${t('currentContent')} #${esc(current.image_id)}`;const original=new URL(current.preview_url,location.origin);original.searchParams.delete('thumbnail');bind('current-frame-preview',()=>openImageViewer(original.pathname+original.search,`${t('currentContent')} #${current.image_id}`));void blobImage($('#current-frame-image'),current.preview_url);}else{frame.innerHTML=`
${t('testLabel')}`;} const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('weather')}

${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}

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

`;$('#main').append(box); } async function enhanceTemplates(){ @@ -28,19 +28,19 @@ async function enhanceTemplates(){ async function downloadImage(path,name){const blob=await api(path,{binary:true});const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);} function displayDialog(content){ if(!liveWriteAllowed()){toast(t('readOnlyWarning'));return;} - showModal(`

${t('displayOptions')}

`); + 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';controls.innerHTML=`
`;grid.before(controls); - if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;ix.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}} + const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`
`;grid.before(controls); + if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');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=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});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','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;ix.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}} const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));}; - cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}}); + cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('templateSourceUnavailable'));});}}); $('#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; + 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('')}`;} result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));}); } @@ -156,7 +156,7 @@ async function photoSourceForm(source=null){ } 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')}

${result.items.map((x,i)=>``).join('')||t('noSources')}
${buttons('source-refresh','refresh')}
`; + box.innerHTML=`

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

${t('sourceBrowseHint')}

${result.items.map((x,i)=>``).join('')||t('noSourcePhotos')}
${buttons('source-refresh','refresh')}
`; 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){ diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 7d4bd86..f56207a 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -1,13 +1,13 @@ window.MESSAGES={ -zh:{DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'KoKonna 云端图库',cloudGallery:'云端图库',galleryListLoading:'正在读取图片列表…',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',imageLoadError:'图片加载失败,请刷新重试',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 1200 × 1600',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'这是当前显示图片。删除后画框将切换到其他图片。是否继续?',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'设备云端异常,请稍后刷新',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, -en:{DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'KoKonna cloud gallery',cloudGallery:'cloud gallery',galleryListLoading:'Reading image list…',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} +zh:{DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'KoKonna 云端图库',cloudGallery:'云端图库',galleryListLoading:'正在读取图片列表…',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',imageLoadError:'图片加载失败,请刷新重试',mode:'适配方式',profile:'渲染模式',contain:'完整显示 · 留白',cover:'铺满屏幕 · 中心裁切',smart:'智能裁切 · 检查预览',color:'彩色',grayscale:'灰度',staging:'仅保存到本地图库',uploadHint:'JPG、PNG、WebP · 最大 20 MiB · 输出尺寸以生成预览为准',uploadNotice:'KoKonna 云端上传会立即切换画框。仅保存图库时使用本地暂存,不会改变设备。',selectFile:'选择图片',previewHint:'先选择图片并预览,再决定保存或显示。smart 使用构图启发式,不保证识别人脸。',mock:'演示模式',readOnly:'只读',writable:'允许写入',cloudRotation:'设备轮播',queue:'顺序',random:'随机',minutes:'分钟',rotationNotice:'可读取设备轮播模式与间隔;官方接口不提供具体轮播成员,也不支持修改规则。云端图库不是已确认的轮播队列。',playlistNotice:'以下是 Frame Flow 管理的播放列表,与 KoKonna 内置轮播独立。临时告警期间暂停,结束后继续。建议在厂商 App 中关闭内置自动轮播以避免竞争。',newPlaylist:'新建播放列表',newJob:'新建任务',interval:'切换间隔(分钟)',imageIds:'图片 ID(逗号分隔)',enabled:'启用',disabled:'暂停',edit:'编辑',remove:'移除',schedule:'时间安排',source:'内容来源',duration:'显示时长(秒)',restoreAfter:'到期恢复之前图片',priority:'优先级(0–100)',cron:'Cron',daily:'每天',weekdays:'工作日',weekends:'周末',date:'指定日期',temporary:'临时任务',time:'时间(上海时区)',at:'执行日期',lastRun:'上次运行',nextRun:'下次运行',result:'结果',templateData:'模板数据(JSON)',render:'生成预览',templateHint:'模板与数据、设备驱动分离。预览成功后再推送。天气为明确标记的 Mock。',template:'模板',operation:'操作',action:'动作',requestId:'请求 ID',client:'调用方',noImages:'图库为空',emptyHint:'上传图片或生成模板,开始布置你的画框。',noTasks:'还没有任务',noHistory:'暂无历史记录',connect:'连接 Frame Flow',token:'Frame Flow API Token',rememberLogin:'在此浏览器记住登录 30 天',tokenHint:'输入 Frame Flow 登录 Token,不要输入 KoKonna API Key。勾选后将登录 Token 保存在此浏览器 30 天;共享设备请取消勾选。断开连接会清除保存。',logout:'断开连接',auth:'鉴权已启用',apiDocs:'API 文档',version:'版本',writeProtection:'真实写入保护',keyConfigured:'设备密钥已配置',yes:'是',no:'否',lowBattery:'电量较低,建议充电',unknown:'写入结果未知,需要核对设备状态',reconcile:'只读核对',acknowledge:'已人工核对,解除阻塞',accepted:'操作已受理;请在历史记录查看同步结果。',deleteCurrent:'确认删除当前显示图片的云端副本?可能影响画框当前显示,且无法撤销。',deleteConfirm:'确认删除此图片?此操作不可撤销。',removeConfirm:'确认移除此配置?',error:'操作失败',done:'已完成',apiError:'页面加载失败,请查看原因后重试',noToken:'输入 API Token 后可管理画框',status:'状态',systemTheme:'跟随系统',theme:'外观',language:'语言',dark:'深色',light:'浅色',refreshInterval:'设备信息缓存 60 秒;图库仅按需读取',model:'型号',coverId:'封面 ID',saveTemplate:'保存模板默认数据',previewLoading:'正在生成预览…',storage:'设备存储占用',readOnlyWarning:'真实写入已关闭。可以预览和配置任务,但不会上传、切换或删除设备图片。',footer:'独立智能画框服务',photos:'画框内容',count:'张图片',testLabel:'示意画面 · 不代表当前真实内容'}, +en:{DISPLAY_NOT_CONFIRMED:'Image uploaded but display was not confirmed; future jobs unblocked',OFFICIAL_AUTH_REQUIRED:'Official account authorization expired; reconnect',INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or invalid API Token',FORBIDDEN:'This token does not allow this action',DEVICE_NOT_FOUND:'Device not found',DEVICE_OFFLINE:'Target frame is offline',UNSUPPORTED_CAPABILITY:'Unsupported device capability or content type',WRITE_RESULT_UNKNOWN:'Write outcome unknown; reconcile device state',RATE_LIMITED:'Request budget exceeded; try later',WRITE_DISABLED:'Real device writes are disabled',CONFIG_REQUIRED:'Device configuration required',CLOUD_UNAVAILABLE:'Device cloud is unavailable',NOT_FOUND:'Requested content not found',CONFLICT:'Conflicting configuration or restore reference; check current state',INTERNAL_ERROR:'Internal service error',lastScheduled:'Last scheduled',displayByName:'Display by filename',QUEUED:'Queued',RENDERING:'Rendering',UPLOADING:'Sending',SYNCING:'Awaiting sync',COMPLETED:'Completed',FAILED:'Failed',UNKNOWN:'Unknown outcome',modelVerified:'API verified',modelConfigured:'Configured model',dashboard:'Dashboard',gallery:'Gallery',upload:'Upload',templates:'Templates',automation:'Automation',playlists:'Playlists',history:'History',settings:'Settings',refresh:'Refresh',restore:'Restore previous',online:'Online',offline:'Offline',battery:'Battery',firmware:'Firmware',current:'Current image',heartbeat:'Last heartbeat',device:'Device details',rotation:'Rotation',timezone:'Timezone',sync:'Sync state',synced:'Synced',syncing:'Awaiting sync',cloud:'KoKonna cloud gallery',cloudGallery:'cloud gallery',galleryListLoading:'Reading image list…',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · output dimensions are shown in the rendered preview',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'Delete the cloud copy of the currently displayed image? This may affect the frame display and cannot be undone.',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Page could not load. Check the details and try again.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'} }; Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'}); Object.assign(window.MESSAGES.en,{username:'Username',password:'Password',connect:'Sign in to Frame Flow',UNAUTHORIZED:'Invalid credentials or expired session',tokenHint:'Sign in with your account. Remember me keeps the session for 30 days; sign out on shared devices.',logout:'Sign out',noToken:'Sign in to manage frames',formEditor:'Form editor',advanced:'Advanced JSON',resetTemplate:'Reset template',resetConfirm:'Remove saved settings and restore built-in defaults?',eventsHelp:'One event per line',photo:'Family photo',noPhoto:'Use photo placeholder',changePassword:'Change password',currentPassword:'Current password',newPassword:'New password (at least 8 characters)',passwordChanged:'Password changed. Sign in again.',saveTemplate:'Save template settings'}); -Object.assign(window.MESSAGES.zh,{currentContent:'当前显示内容',currentTemplate:'当前模板',sourceUnknown:'来源未知:这张图片未通过 Frame Flow 生成,设备 API 不提供模板数据。',editCurrent:'读取当前模板并编辑',sourceKnown:'Frame Flow 已记录来源',weatherSettings:'天气来源',location:'位置名称',latitude:'纬度',longitude:'经度',weatherPreview:'读取天气',forecast:'未来三天天气',weatherUnavailable:'天气暂不可用',weatherAttribution:'天气来源:Open-Meteo(CC BY 4.0)',searchImages:'搜索文件名或图片 ID',sortNewest:'最新优先',sortOldest:'最早优先',sortName:'按名称',download:'下载图片',continueEdit:'继续编辑模板',details:'查看详情',runNow:'立即执行',runConfirm:'现在执行此任务将改变画框显示,是否继续?',jobPreview:'预览任务',temporaryDisplay:'临时显示',displayOptions:'显示选项',sourceType:'内容类型',templateFields:'模板数据',imageChoice:'选择图片',imageURL:'图片 URL',restoreSource:'恢复上一张',weatherMock:'天气演示',weatherReal:'实时天气',stale:'数据过期',apply:'应用'}); -Object.assign(window.MESSAGES.en,{currentContent:'Currently displayed content',currentTemplate:'Current template',sourceUnknown:'Unknown source: this image was not generated by Frame Flow. The device API provides no template data.',editCurrent:'Load current template for editing',sourceKnown:'Source recorded by Frame Flow',weatherSettings:'Weather provider',location:'Location name',latitude:'Latitude',longitude:'Longitude',weatherPreview:'Read weather',forecast:'Three-day forecast',weatherUnavailable:'Weather unavailable',weatherAttribution:'Weather: Open-Meteo (CC BY 4.0)',searchImages:'Search filename or image ID',sortNewest:'Newest first',sortOldest:'Oldest first',sortName:'By name',download:'Download image',continueEdit:'Edit this template',details:'Details',runNow:'Run now',runConfirm:'Running this task will change the frame display. Continue?',jobPreview:'Preview task',temporaryDisplay:'Temporary display',displayOptions:'Display options',sourceType:'Content type',templateFields:'Template data',imageChoice:'Select image',imageURL:'Image URL',restoreSource:'Restore previous image',weatherMock:'Mock weather',weatherReal:'Live weather',stale:'Stale data',apply:'Apply'}); +Object.assign(window.MESSAGES.zh,{currentContent:'当前显示内容',currentTemplate:'当前模板',sourceUnknown:'未找到可用的来源记录,无法还原模板设置。设备 API 不提供原始模板数据。',editCurrent:'读取当前模板并编辑',sourceKnown:'Frame Flow 已记录来源',weatherSettings:'天气来源',location:'位置名称',latitude:'纬度',longitude:'经度',weatherPreview:'读取天气',forecast:'未来三天天气',weatherUnavailable:'天气暂不可用',weatherAttribution:'天气来源:Open-Meteo(CC BY 4.0)',searchImages:'搜索文件名或图片 ID',sortNewest:'最新优先',sortOldest:'最早优先',sortName:'按名称',download:'下载图片',continueEdit:'继续编辑模板',details:'查看详情',runNow:'立即执行',runConfirm:'现在执行此任务将改变画框显示,是否继续?',jobPreview:'预览任务',temporaryDisplay:'临时显示',displayOptions:'显示选项',sourceType:'内容类型',templateFields:'模板数据',imageChoice:'选择图片',imageURL:'图片 URL',restoreSource:'恢复上一张',weatherMock:'天气演示',weatherReal:'实时天气',stale:'数据过期',apply:'应用'}); +Object.assign(window.MESSAGES.en,{currentContent:'Currently displayed content',currentTemplate:'Current template',sourceUnknown:'No usable source record was found, so template settings cannot be restored. The device API does not provide original template data.',editCurrent:'Load current template for editing',sourceKnown:'Source recorded by Frame Flow',weatherSettings:'Weather provider',location:'Location name',latitude:'Latitude',longitude:'Longitude',weatherPreview:'Read weather',forecast:'Three-day forecast',weatherUnavailable:'Weather unavailable',weatherAttribution:'Weather: Open-Meteo (CC BY 4.0)',searchImages:'Search filename or image ID',sortNewest:'Newest first',sortOldest:'Oldest first',sortName:'By name',download:'Download image',continueEdit:'Edit this template',details:'Details',runNow:'Run now',runConfirm:'Running this task will change the frame display. Continue?',jobPreview:'Preview task',temporaryDisplay:'Temporary display',displayOptions:'Display options',sourceType:'Content type',templateFields:'Template data',imageChoice:'Select image',imageURL:'Image URL',restoreSource:'Restore previous image',weatherMock:'Mock weather',weatherReal:'Live weather',stale:'Stale data',apply:'Apply'}); window.MESSAGES.zh.templateHint='先生成预览,再选择显示方式。天气来源可在设置中配置。';window.MESSAGES.en.templateHint='Render a preview, then choose display options. Configure weather in Settings.'; Object.assign(window.MESSAGES.zh,{allowedHosts:'允许下载图片的域名(每行一个)',urlHint:'只允许 HTTPS 和设置中明确允许的域名,内网地址始终禁止。',uploadURL:'从链接导入',urlSettings:'图片 URL 安全设置'});Object.assign(window.MESSAGES.en,{allowedHosts:'Allowed image hostnames (one per line)',urlHint:'HTTPS and explicitly allowed hosts only. Private network addresses are blocked.',uploadURL:'Import URL',urlSettings:'Image URL security'}); @@ -25,8 +25,8 @@ Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders Object.assign(MESSAGES.zh,{synologyPreviews:'超大图片使用 Synology Photos 的现有预览图'});Object.assign(MESSAGES.en,{synologyPreviews:'Use existing Synology Photos previews for oversized images'}); -Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只在当前目录和所选人物的交集中选图;不会退回全图库。需要共享空间人物相册授权。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256,并将共享 photo 目录挂载为照片根目录。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'}); -Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose only from the intersection of this folder and selected people. Requires shared-space People access; never falls back to the full catalog.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Admin must configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256, and mount the shared photo folder as the photo root.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '}); +Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只从当前目录中选择符合人物条件的照片。所选空间的人物相册需要当前 Photos 账号有权访问。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256,并将共享 photo 目录挂载为照片根目录。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'}); +Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose matching people photos only within this folder. The Photos account needs access to People albums in the selected space.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Admin must configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256, and mount the shared photo folder as the photo root.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '}); Object.assign(MESSAGES.zh,{'photo-sources':'照片数据源'}); Object.assign(MESSAGES.en,{'photo-sources':'Photo sources'}); @@ -36,8 +36,8 @@ Object.assign(MESSAGES.en,{photoOrientation:'Photo orientation',orientationAuto: Object.assign(window.MESSAGES.zh,{cleanupUnstarred:'未星标随机照片上传 7 天后清理',retentionHint:'仅适用于随机照片任务的新上传。星标保留;当前图片、恢复图片和播放列表引用不会自动删除。不影响 NAS 原图。',star:'☆ 星标保留',unstar:'★ 取消星标',starredKeep:'已星标 · 保留',expiresAt:'未星标清理时间:'}); Object.assign(window.MESSAGES.en,{cleanupUnstarred:'Clean unstarred random uploads after 7 days',retentionHint:'New random photo uploads only. Starred, current, restore and playlist images are protected. NAS originals remain untouched.',star:'☆ Keep',unstar:'★ Unstar',starredKeep:'Starred · kept',expiresAt:'Cleanup due:'}); -Object.assign(window.MESSAGES.zh,{cloudKeepLatest:'随机任务云端仅保留最新成功图;本地星标上传后保留云端副本。新图失败不会淘汰旧图。',taskOrigin:'任务生成',originFilter:'来源筛选',origin_all:'全部来源',origin_manual:'手动上传',origin_task:'任务生成',origin_external:'外部推送',origin_template:'模板生成',origin_unknown:'来源未知',allTasks:'全部任务',selectImage:'选择图片',selectAll:'全选 / 取消全选',deleteSelected:'删除所选',selectImagesFirst:'请先选择图片',deleteSelectedConfirm:'删除所选本地图片?不会删除 NAS 原始照片。',batchFailed:'未成功操作的图片数:'}); -Object.assign(window.MESSAGES.en,{cloudKeepLatest:'Each random task keeps its latest confirmed cloud image. Uploaded local favorites stay. Failed replacements never evict the last good image.',taskOrigin:'Task generated',originFilter:'Filter by origin',origin_all:'All origins',origin_manual:'Manual upload',origin_task:'Task generated',origin_external:'External push',origin_template:'Template generated',origin_unknown:'Unknown origin',allTasks:'All tasks',selectImage:'Select image',selectAll:'Select / deselect all',deleteSelected:'Delete selected',selectImagesFirst:'Select images first',deleteSelectedConfirm:'Delete selected local images? NAS originals remain untouched.',batchFailed:'Failed items:'}); +Object.assign(window.MESSAGES.zh,{cloudKeepLatest:'每个随机照片或自动拼图任务保留最新确认成功的云端图片;星标图片另行保留。新图未确认成功前,不清理旧图。',taskOrigin:'任务生成',originFilter:'来源筛选',origin_all:'全部来源',origin_manual:'手动上传',origin_task:'任务生成',origin_external:'外部推送',origin_template:'模板生成',origin_unknown:'来源未知',allTasks:'全部任务',selectImage:'选择图片',selectAll:'全选 / 取消全选',invertSelection:'反选',deleteSelected:'删除所选',selectImagesFirst:'请先选择图片',deleteSelectedConfirm:'删除所选本地图片?不会删除 NAS 原始照片。',batchFailed:'未成功操作的图片数:'}); +Object.assign(window.MESSAGES.en,{cloudKeepLatest:'Each random-photo or automatic-collage job keeps its latest confirmed cloud image. Starred images are also retained. Unconfirmed replacements do not evict the previous image.',taskOrigin:'Task generated',originFilter:'Filter by origin',origin_all:'All origins',origin_manual:'Manual upload',origin_task:'Task generated',origin_external:'External push',origin_template:'Template generated',origin_unknown:'Unknown origin',allTasks:'All tasks',selectImage:'Select image',selectAll:'Select / deselect all',invertSelection:'Invert selection',deleteSelected:'Delete selected',selectImagesFirst:'Select images first',deleteSelectedConfirm:'Delete selected local images? NAS originals remain untouched.',batchFailed:'Failed items:'}); Object.assign(window.MESSAGES.zh,{previousPage:'上一页',nextPage:'下一页'});Object.assign(window.MESSAGES.en,{previousPage:'Previous page',nextPage:'Next page'}); @@ -130,3 +130,12 @@ Object.assign(MESSAGES.en,{photosSharedConnection:'Shared connection',photosConn Object.assign(MESSAGES.zh,{photosDisconnect:'断开共享连接',photosSharedDisconnectWarning:'这将断开所有人物筛选数据源共用的 Photos 登录,相关任务会无法读取人物照片。文件夹设置和网盘连接不变。确定断开吗?'}); Object.assign(MESSAGES.en,{photosDisconnect:'Disconnect shared session',photosSharedDisconnectWarning:'This disconnects the Photos session used by all people-filtered sources. Their tasks will fail to read people photos. Folder settings and drive connections remain unchanged. Disconnect?'}); + +Object.assign(MESSAGES.zh,{weather:'天气',connected:'已连接',disconnected:'未连接',sortOrder:'排序方式',playbackMode:'播放顺序',apiKeyConfigured:'API 密钥已配置',templateSourceUnavailable:'这张图片没有可编辑的模板来源。可编辑模板仅适用于保留了模板配置的生成图片。',localDeleteConfirm:'确认删除本地图库中的这张图片?不会删除 NAS 原始照片或云端副本,也不会切换画框。此操作不可撤销。',cloudDeleteConfirm:'确认删除这张图片的云端副本?不会删除 NAS 原始照片或本地图库副本。此操作不可撤销。',gallerySelectionHelp:'全选和反选只作用于当前页可见图片;批量操作处理当前页全部已勾选图片,包括搜索后隐藏的勾选项。',starHelp:'星标保存在服务端,用于防止自动清理本地图及其最近一次成功上传的云端副本。星标本身不会上传或切换画框;手动删除仍有效。',sourceLocationHint:'NAS 目录:填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV:填写 HTTPS 目录地址,并在设置中允许其域名。Google Drive / 阿里云盘:目录 ID;百度网盘:目录路径。',sourceCredential:'WebDAV / 网盘凭据环境变量名',sourceCredentialHint:'填写变量名,不要填写密码。管理员可通过环境变量配置凭据;原生网盘也可使用下方 OAuth 凭据。'}); +Object.assign(MESSAGES.en,{weather:'Weather',connected:'Connected',disconnected:'Not connected',sortOrder:'Sort order',playbackMode:'Playback order',apiKeyConfigured:'API key configured',templateSourceUnavailable:'No editable template source is available for this image. Template editing requires a generated image with saved template settings.',localDeleteConfirm:'Delete this local gallery image? NAS originals and cloud copies remain intact, and the frame display is unchanged. This cannot be undone.',cloudDeleteConfirm:'Delete this cloud image? NAS originals and local gallery copies remain intact. This cannot be undone.',gallerySelectionHelp:'Select all and invert affect visible images on this page. Batch actions include every checked image on this page, including items hidden by search.',starHelp:'Stars are saved on the server and protect the local image and its latest successfully uploaded cloud copy from automatic cleanup. Starring does not upload or change the frame; manual deletion still applies.',sourceLocationHint:'NAS: a relative folder under the mounted photo root, such as . or family. WebDAV: an HTTPS folder URL whose hostname is allowed in Settings. Google Drive / AliPan: folder ID. Baidu: folder path.',sourceCredential:'WebDAV / drive credential environment variable',sourceCredentialHint:'Enter a variable name, never a password. An administrator can configure credentials through environment variables; native drives can also use the OAuth credentials below.'}); + +Object.assign(MESSAGES.zh,{currentImageId:'当前图片 ID',modelSource:'型号信息来源',modelVerified:'设备 API 返回',modelConfigured:'设备配置文件',switchInterval:'切换间隔',sync:'云端同步状态',synced:'云端已确认',syncing:'等待云端确认'}); +Object.assign(MESSAGES.en,{currentImageId:'Current image ID',modelSource:'Model information source',modelVerified:'Device API',modelConfigured:'Device configuration',switchInterval:'Switch interval',sync:'Cloud sync status',synced:'Cloud confirmed',syncing:'Awaiting cloud confirmation'}); + +Object.assign(MESSAGES.zh,{noSources:'尚未配置照片数据源,请先添加。',noSourcePhotos:'当前目录或筛选条件下没有可用照片。',sourceLocation:'目录路径 / 地址 / ID',temporaryOptionsHint:'勾选到期恢复时,显示时长和优先级用于临时显示;未勾选时持续显示,直到其他操作切换图片。'}); +Object.assign(MESSAGES.en,{noSources:'No photo sources configured. Add a source first.',noSourcePhotos:'No photos match this folder and its filters.',sourceLocation:'Folder path / URL / ID',temporaryOptionsHint:'With restore enabled, duration and priority apply to the temporary display. Otherwise, the image stays until another action changes it.'}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 1237f94..e6ac7b1 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 15da167..0e7c556 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -1,6 +1,6 @@ :root{color-scheme:light;--bg:#f4f6f5;--panel:#fff;--soft:#edf2f0;--ink:#182e27;--muted:#687970;--border:#dee6e1;--accent:#167253;--accent-soft:#e1f2e8;--bad:#b43636;--shadow:0 12px 34px #203e2a08} :root[data-theme=dark]{color-scheme:dark;--bg:#111917;--panel:#1b2521;--soft:#24322b;--ink:#edf4ee;--muted:#a1b3a8;--border:#34463c;--accent:#8cddb1;--accent-soft:#254735;--bad:#ff9696;--shadow:0 12px 34px #0002} -*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:4px}button{cursor:pointer;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--ink);padding:10px 16px;font-weight:600}button:hover{background:var(--soft)}button:disabled{opacity:.45;cursor:not-allowed}button.primary{background:var(--accent);color:var(--panel);border-color:var(--accent)}button.danger{color:var(--bad)}a{color:var(--accent);text-decoration:none}small{font-size:12px;color:var(--muted)}.sidebar{position:fixed;inset:0 auto 0 0;width:244px;background:var(--panel);border-right:1px solid var(--border);padding:32px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;color:var(--ink);font-size:20px;font-weight:750;letter-spacing:-.6px}.brand small{display:block;font-size:9px;letter-spacing:.2px;font-weight:500}.brand-icon{width:43px;height:43px;background:var(--accent);color:var(--panel);border-radius:13px;display:flex;align-items:center;justify-content:center;position:relative;font-size:25px}.brand-icon span{font-size:15px;margin-top:-18px}nav{margin-top:42px;display:grid;gap:8px}nav button{border:0;display:flex;gap:14px;align-items:center;text-align:left;color:var(--muted);padding:13px 16px;font-weight:500;background:transparent}nav button.active{background:var(--accent-soft);color:var(--accent);font-weight:650}nav .nav-icon{width:22px;text-align:center;font-size:19px}.sidebar-bottom{margin-top:auto;padding:20px 10px;border-top:1px solid var(--border);font-size:12px;color:var(--muted)}.sidebar-bottom small{display:block;margin-top:8px}.live-dot{display:inline-block;width:7px;height:7px;border-radius:100%;background:var(--accent);margin-right:8px}.workspace{margin-left:244px;padding:0 40px}header{padding:33px 0 28px;display:flex;justify-content:space-between;align-items:center;gap:16px}h1{font-size:30px;font-weight:650;letter-spacing:-1px;margin:4px 0 0}h2{font-size:21px;line-height:1.4;margin:0 0 12px;letter-spacing:-.3px}h3{font-size:17px;margin:0 0 12px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);font-weight:650}.toolbar,.actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.icon-button{padding:9px 12px}.card{background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:25px;box-shadow:var(--shadow)}.hero{display:grid;grid-template-columns:1.4fr 1fr;min-height:370px;background:var(--panel);overflow:hidden;padding:0}.hero-info{padding:35px}.hero h2{font-size:31px;margin:22px 0 0}.hero p{color:var(--muted);margin:4px 0 24px}.frame-demo{background:var(--soft);padding:35px;display:flex;align-items:center;justify-content:center;position:relative}.mini-art{width:185px;height:246px;background:#faf8ef;border:10px solid #72523b;box-shadow:0 18px 30px #0002;position:relative;overflow:hidden}.mini-art:before{content:'';position:absolute;bottom:-40px;left:-30px;background:#8cab82;width:180px;height:180px;border-radius:45%;transform:rotate(25deg)}.mini-art:after{content:'';position:absolute;bottom:-65px;right:-70px;background:#456a56;width:180px;height:230px;border-radius:45%;transform:rotate(-20deg)}.mini-art .sun{position:absolute;top:25px;right:25px;width:55px;height:55px;border-radius:50%;background:#dba25e}.frame-label{position:absolute;bottom:9px;font-size:10px;letter-spacing:1px;color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:7px;font-size:11px;letter-spacing:.6px;border-radius:30px;padding:5px 12px;color:var(--accent);background:var(--accent-soft);font-weight:750}.badge.bad{background:#af343415;color:var(--bad)}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin:20px 0}.stat .value{font-size:24px;font-weight:650;letter-spacing:-.5px;margin-top:6px;word-break:break-word}.stat .label{font-size:12px;color:var(--muted)}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:20px;margin:20px 0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.detail-grid strong{display:block;font-size:14px;overflow-wrap:anywhere}.detail-grid small{display:block}.hint{font-size:13px;color:var(--muted);margin:12px 0}.notice{background:var(--accent-soft);border:1px solid var(--border);padding:14px 18px;border-radius:12px;margin-bottom:20px;font-size:13px}.notice.warning{color:var(--bad);background:#b4363610}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:18px}.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:20px}.image-card{padding:12px}.image-card .thumb{width:100%;aspect-ratio:3/4;background:var(--soft);border-radius:10px;object-fit:contain;display:block}.image-card h3{font-size:14px;margin:14px 0 2px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.image-card .actions{margin-top:12px}.image-card button{font-size:12px;padding:7px 10px}.empty{text-align:center;padding:64px 24px;color:var(--muted)}.empty strong{display:block;font-size:20px;color:var(--ink);margin-bottom:10px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}label{display:block;font-size:13px;font-weight:650;margin:0 0 7px}input,textarea,select{width:100%;border:1px solid var(--border);border-radius:9px;background:var(--panel);color:var(--ink);padding:10px 12px}textarea{min-height:180px;resize:vertical;font:13px/1.7 ui-monospace,monospace}.toolbar select{width:auto;max-width:190px}input[type=checkbox]{width:auto;accent-color:var(--accent)}.field{margin-bottom:18px}.dropzone{border:2px dashed var(--border);border-radius:16px;padding:45px 25px;text-align:center;background:var(--soft)}.dropzone input{margin-top:15px}.preview{width:100%;max-height:620px;object-fit:contain;background:var(--soft);border-radius:12px}.template-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.template-card{cursor:pointer;min-height:160px;display:flex;flex-direction:column;justify-content:space-between}.template-card:hover,.template-card.selected{border-color:var(--accent);background:var(--accent-soft)}.template-card span{font-size:35px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:10px 14px}td{border-top:1px solid var(--border);padding:14px;vertical-align:top}td code{font-size:11px;overflow-wrap:anywhere}.row-actions{display:flex;gap:7px}.row-actions button{padding:5px 9px;font-size:12px}.tabs{display:flex;gap:6px;background:var(--soft);padding:5px;border-radius:12px;margin-bottom:20px;width:fit-content}.tabs button{border:0;background:transparent}.tabs button.active{background:var(--panel);box-shadow:var(--shadow)}footer{padding:30px 0;font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}#toast{position:fixed;bottom:28px;right:28px;padding:14px 20px;background:var(--ink);color:var(--panel);border-radius:12px;max-width:430px;z-index:200;display:none;box-shadow:var(--shadow)}dialog{width:min(600px,calc(100vw - 32px));border:1px solid var(--border);border-radius:18px;padding:28px;background:var(--panel);color:var(--ink)}dialog::backdrop{background:#0007}.modal-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:24px}.check-row{display:flex;align-items:center;gap:10px}.spinner{width:25px;height:25px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;margin:40px auto}@keyframes spin{to{transform:rotate(360deg)}}.wide{grid-column:1/-1} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:4px}button{cursor:pointer;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--ink);padding:10px 16px;font-weight:600}button:hover{background:var(--soft)}button:disabled{opacity:.45;cursor:not-allowed}button.primary{background:var(--accent);color:var(--panel);border-color:var(--accent)}button.danger{color:var(--bad)}a{color:var(--accent);text-decoration:none}small{font-size:12px;color:var(--muted)}.sidebar{position:fixed;inset:0 auto 0 0;width:244px;background:var(--panel);border-right:1px solid var(--border);padding:32px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;color:var(--ink);font-size:20px;font-weight:750;letter-spacing:-.6px}.brand small{display:block;font-size:9px;letter-spacing:.2px;font-weight:500}.brand-icon{width:43px;height:43px;background:var(--accent);color:var(--panel);border-radius:13px;display:flex;align-items:center;justify-content:center;position:relative;font-size:25px}.brand-icon span{font-size:15px;margin-top:-18px}nav{margin-top:42px;display:grid;gap:8px}nav button{border:0;display:flex;gap:14px;align-items:center;text-align:left;color:var(--muted);padding:13px 16px;font-weight:500;background:transparent}nav button.active{background:var(--accent-soft);color:var(--accent);font-weight:650}nav .nav-icon{width:22px;text-align:center;font-size:19px}.sidebar-bottom{margin-top:auto;padding:20px 10px;border-top:1px solid var(--border);font-size:12px;color:var(--muted)}.sidebar-bottom small{display:block;margin-top:8px}.live-dot{display:inline-block;width:7px;height:7px;border-radius:100%;background:var(--accent);margin-right:8px}.workspace{margin-left:244px;padding:0 40px}header{padding:33px 0 28px;display:flex;justify-content:space-between;align-items:center;gap:16px}h1{font-size:30px;font-weight:650;letter-spacing:-1px;margin:4px 0 0}h2{font-size:21px;line-height:1.4;margin:0 0 12px;letter-spacing:-.3px}h3{font-size:17px;margin:0 0 12px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);font-weight:650}.toolbar,.actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.icon-button{padding:9px 12px}.card{background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:25px;box-shadow:var(--shadow)}.hero{display:grid;grid-template-columns:1fr 1.2fr;min-height:450px;background:var(--panel);overflow:hidden;padding:0}.hero-info{padding:35px}.hero h2{font-size:31px;margin:22px 0 0}.hero p{color:var(--muted);margin:4px 0 24px}.frame-demo{background:var(--soft);padding:35px;display:flex;align-items:center;justify-content:center;position:relative}.mini-art{width:185px;height:246px;background:#faf8ef;border:10px solid #72523b;box-shadow:0 18px 30px #0002;position:relative;overflow:hidden}.mini-art:before{content:'';position:absolute;bottom:-40px;left:-30px;background:#8cab82;width:180px;height:180px;border-radius:45%;transform:rotate(25deg)}.mini-art:after{content:'';position:absolute;bottom:-65px;right:-70px;background:#456a56;width:180px;height:230px;border-radius:45%;transform:rotate(-20deg)}.mini-art .sun{position:absolute;top:25px;right:25px;width:55px;height:55px;border-radius:50%;background:#dba25e}.frame-label{position:absolute;bottom:9px;font-size:10px;letter-spacing:1px;color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:7px;font-size:11px;letter-spacing:.6px;border-radius:30px;padding:5px 12px;color:var(--accent);background:var(--accent-soft);font-weight:750}.badge.bad{background:#af343415;color:var(--bad)}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin:20px 0}.stat .value{font-size:24px;font-weight:650;letter-spacing:-.5px;margin-top:6px;word-break:break-word}.stat .label{font-size:12px;color:var(--muted)}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:20px;margin:20px 0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.detail-grid strong{display:block;font-size:14px;overflow-wrap:anywhere}.detail-grid small{display:block}.hint{font-size:13px;color:var(--muted);margin:12px 0}.notice{background:var(--accent-soft);border:1px solid var(--border);padding:14px 18px;border-radius:12px;margin-bottom:20px;font-size:13px}.notice.warning{color:var(--bad);background:#b4363610}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:18px}.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:20px}.image-card{padding:12px}.image-card .thumb{width:100%;aspect-ratio:3/4;background:var(--soft);border-radius:10px;object-fit:contain;display:block}.image-card h3{font-size:14px;margin:14px 0 2px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.image-card .actions{margin-top:12px}.image-card button{font-size:12px;padding:7px 10px}.empty{text-align:center;padding:64px 24px;color:var(--muted)}.empty strong{display:block;font-size:20px;color:var(--ink);margin-bottom:10px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}label{display:block;font-size:13px;font-weight:650;margin:0 0 7px}input,textarea,select{width:100%;border:1px solid var(--border);border-radius:9px;background:var(--panel);color:var(--ink);padding:10px 12px}textarea{min-height:180px;resize:vertical;font:13px/1.7 ui-monospace,monospace}.toolbar select{width:auto;max-width:190px}input[type=checkbox]{width:auto;accent-color:var(--accent)}.field{margin-bottom:18px}.dropzone{border:2px dashed var(--border);border-radius:16px;padding:45px 25px;text-align:center;background:var(--soft)}.dropzone input{margin-top:15px}.preview{width:100%;max-height:620px;object-fit:contain;background:var(--soft);border-radius:12px}.template-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.template-card{cursor:pointer;min-height:160px;display:flex;flex-direction:column;justify-content:space-between}.template-card:hover,.template-card.selected{border-color:var(--accent);background:var(--accent-soft)}.template-card span{font-size:35px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:13px}th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);padding:10px 14px}td{border-top:1px solid var(--border);padding:14px;vertical-align:top}td code{font-size:11px;overflow-wrap:anywhere}.row-actions{display:flex;gap:7px}.row-actions button{padding:5px 9px;font-size:12px}.tabs{display:flex;gap:6px;background:var(--soft);padding:5px;border-radius:12px;margin-bottom:20px;width:fit-content}.tabs button{border:0;background:transparent}.tabs button.active{background:var(--panel);box-shadow:var(--shadow)}footer{padding:30px 0;font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}#toast{position:fixed;bottom:28px;right:28px;padding:14px 20px;background:var(--ink);color:var(--panel);border-radius:12px;max-width:430px;z-index:200;display:none;box-shadow:var(--shadow)}dialog{width:min(600px,calc(100vw - 32px));border:1px solid var(--border);border-radius:18px;padding:28px;background:var(--panel);color:var(--ink)}dialog::backdrop{background:#0007}.modal-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:24px}.check-row{display:flex;align-items:center;gap:10px}.spinner{width:25px;height:25px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;margin:40px auto}@keyframes spin{to{transform:rotate(360deg)}}.wide{grid-column:1/-1} @media(min-width:1600px){.workspace{max-width:1600px;margin:auto;padding-left:280px}.sidebar{left:max(0px,calc((100vw - 1600px)/2))}}@media(max-width:1050px){.workspace{padding:0 24px}.stats{grid-template-columns:repeat(2,1fr)}.columns{grid-template-columns:1fr}.template-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:720px){.sidebar{width:100%;height:64px;inset:auto 0 0;padding:0 6px;border-top:1px solid var(--border);z-index:100;overflow:auto}.brand,.sidebar-bottom{display:none}nav{margin:0;display:flex;min-width:660px;gap:0}nav button{display:flex;flex-direction:column;gap:0;padding:6px 11px;font-size:10px;border-radius:0;flex:1}.workspace{margin:0;padding:0 18px 75px}header{padding:22px 0;align-items:flex-start}h1{font-size:25px}.toolbar{gap:6px}.toolbar select{width:120px;font-size:12px;padding:9px}.hero{grid-template-columns:1fr}.hero-info{padding:25px}.hero h2{font-size:26px}.frame-demo{display:none}.stats{gap:10px}.stat{padding:18px}.stat .value{font-size:20px}.card{padding:20px}.form-grid{grid-template-columns:1fr}.template-grid{gap:10px}.gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.image-card{padding:10px}.image-card .actions{gap:5px}.image-card button{font-size:11px;padding:6px}#toast{bottom:85px;right:18px;left:18px;max-width:none}.section-heading{align-items:flex-start;flex-wrap:wrap}} /* Consistent page rhythm applies to dynamically appended sections as well. */ @@ -19,11 +19,13 @@ details[open]>summary{margin-bottom:16px} .check-row{margin:12px 0;gap:12px;align-items:flex-start} .check-row input{margin-top:4px;flex-shrink:0} #playlist-image-choices{max-height:320px;overflow:auto;padding:8px 12px;background:var(--soft);border-radius:12px;margin:12px 0 20px} -#current-frame-image{height:300px!important;width:auto!important;max-width:100%;object-fit:contain;border-radius:8px} +.current-frame-preview{display:flex;align-items:center;justify-content:center;width:100%;height:400px;min-width:0;padding:0;border:0;background:transparent;border-radius:8px;overflow:hidden;cursor:zoom-in} +.current-frame-preview:hover{background:transparent} +#current-frame-image{display:block;width:100%;height:100%;object-fit:contain;border-radius:8px} #weather-result{margin-top:20px;padding-top:18px;border-top:1px solid var(--border)} #password-form{margin-top:24px;padding-top:24px;border-top:1px solid var(--border)} #modal-content>p,#modal-content>pre{margin:16px 0} -@media(max-width:720px){#main{gap:18px}.columns,.gallery,.template-grid{gap:14px}.frame-demo{display:flex;padding:24px}.hero-info{padding:24px}.hero h2{margin-top:16px}.image-card .actions{gap:8px}.modal-actions{flex-wrap:wrap}.check-row{margin:14px 0}#current-frame-image{height:240px!important}} +@media(max-width:720px){#main{gap:18px}.columns,.gallery,.template-grid{gap:14px}.frame-demo{display:flex;padding:24px}.hero-info{padding:24px}.hero h2{margin-top:16px}.image-card .actions{gap:8px}.modal-actions{flex-wrap:wrap}.check-row{margin:14px 0}.current-frame-preview{height:320px}} /* Keep controls separated inside cards, not just between page sections. */ .template-preview-actions{margin-top:20px;gap:12px} diff --git a/pyproject.toml b/pyproject.toml index 40f69af..c92fa30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.7" +version = "0.1.8" requires-python = ">=3.12" dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"] [project.optional-dependencies] diff --git a/scripts/ui-copy-audit.cjs b/scripts/ui-copy-audit.cjs new file mode 100644 index 0000000..84069fd --- /dev/null +++ b/scripts/ui-copy-audit.cjs @@ -0,0 +1,5 @@ +/* Copy must describe actual storage targets and actions; run against isolated Mock only. */ +const assert=require('assert'),fs=require('fs'),vm=require('vm'),{chromium}=require('playwright'); +const context={};context.window=context;vm.createContext(context);vm.runInContext(fs.readFileSync('pigway_frame_flow/web/i18n.js','utf8'),context); +for(const file of ['app.js','features.js','extensions.js'])for(const match of fs.readFileSync('pigway_frame_flow/web/'+file,'utf8').matchAll(/\bt\('([^']+)'\)/g))for(const lang of ['zh','en'])assert(context.MESSAGES[lang][match[1]],`${file}: missing ${lang}.${match[1]}`); +(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{const p=await b.newPage();let deletes=0;await p.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'fixture-local',name:'Local fixture',origin:'manual',starred:false,current:true,url:'/fixture.jpg'}]}));p.on('request',r=>{if(r.method()==='DELETE')deletes++;});await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('.hero').waitFor();assert(await p.evaluate(()=>state.settings.mock));for(const lang of ['zh','en']){await p.evaluate(async lang=>{state.lang=lang;state.tab='local';await navigate('gallery')},lang);await p.locator('[data-delete="0"]').click();assert(await p.locator('#modal-content').textContent().then(x=>x.includes(lang==='zh'?'不会切换画框':'frame display is unchanged')));await p.locator('#modal-cancel').click();assert.equal(await p.locator('label[for="gallery-sort"]').textContent(),lang==='zh'?'排序方式':'Sort order');assert(await p.locator('#gallery-selection-help').textContent().then(x=>x.includes(lang==='zh'?'手动删除仍有效':'manual deletion still applies')));await p.evaluate(()=>navigate('playlists'));await p.locator('#new-playlist').click();assert.equal(await p.locator('label[for="playlist-mode"]').textContent(),lang==='zh'?'播放顺序':'Playback order');await p.locator('#modal-cancel').click();await p.evaluate(()=>navigate('dashboard'));await p.waitForFunction(()=>document.querySelector('#main').textContent.includes(t('weather')));assert(await p.locator('#main').textContent().then(x=>x.includes(lang==='zh'?'型号信息来源':'Model information source')));}assert.equal(deletes,0);console.log('Copy audit PASS: all literal translations present; bilingual sort/playback/model labels, local deletion and star scope verified; no deletions');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-dashboard-preview.cjs b/scripts/ui-dashboard-preview.cjs new file mode 100644 index 0000000..c58a468 --- /dev/null +++ b/scripts/ui-dashboard-preview.cjs @@ -0,0 +1,6 @@ +/* Isolated Mock only: dashboard and gallery share the same read-only image viewer. */ +const assert=require('assert'),{chromium}=require('playwright'); +(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0; +try{for(const width of [1440,768,390]){const p=await browser.newPage({viewport:{width,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/devices/*/current-content',r=>r.fulfill({json:{image_id:42,preview_url:'/fixture-preview.svg?thumbnail=true',synced:true,source_known:false,overrides:[]}}));await p.route('**/fixture-preview.svg*',r=>r.fulfill({contentType:'image/svg+xml',body:''}));await p.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:42,name:'Fixture gallery',current:true,url:'/fixture-preview.svg'}]})); +await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('#current-frame-preview').waitFor();assert(await p.evaluate(()=>state.settings.mock)); +for(const lang of ['zh','en'])for(const theme of ['dark','light']){await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('dashboard')},{lang,theme});await p.waitForFunction(()=>document.querySelector('#current-frame-image')?.naturalWidth===1200);assert(await p.locator('#current-frame-preview').evaluate(e=>e.getBoundingClientRect().height)>=(width<=720?320:400));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await p.locator('#current-frame-preview').focus();const fullImage=p.waitForRequest(r=>new URL(r.url()).pathname==='/fixture-preview.svg'&&!new URL(r.url()).searchParams.has('thumbnail'));await p.keyboard.press('Enter');await fullImage;await p.locator('dialog.image-viewer[open]').waitFor();await p.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===1200);assert.equal(await p.locator('.image-viewer-name').textContent(),(lang==='zh'?'当前显示内容':'Currently displayed content')+' #42');assert.equal(await p.locator('.image-viewer-actions button').count(),3);const sizing=p.locator('.image-viewer-actions button[aria-pressed]');await sizing.nth(1).click();assert(await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await sizing.nth(0).click();assert(!await p.locator('.image-viewer-viewport').evaluate(e=>e.classList.contains('actual-size')));await p.keyboard.press('Escape');assert(!await p.locator('#modal').evaluate(e=>e.open));await p.waitForFunction(()=>!document.body.classList.contains('image-viewer-open'));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/dashboard-'+width+'.png',fullPage:true});}await p.evaluate(()=>navigate('gallery'));await p.locator('[data-original="0"]').click();await p.locator('dialog.image-viewer[open]').waitFor();assert.equal(await p.locator('.image-viewer-actions button').count(),3);await p.locator('#modal-cancel').click();checks++;}assert.deepEqual(errors,[]);await p.close();}console.log('Dashboard preview: '+checks+' profiles PASS; larger preview, keyboard opening, shared gallery viewer, fit/actual size, Escape, no overflow');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/ui-gallery-stars.cjs b/scripts/ui-gallery-stars.cjs index c6cd63c..0445e12 100644 --- a/scripts/ui-gallery-stars.cjs +++ b/scripts/ui-gallery-stars.cjs @@ -8,8 +8,11 @@ const assert=require('assert'),{chromium}=require('playwright'); 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 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++; + 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('Current-image gallery button: '+checks+' profiles passed; disabled label, other image enabled, no display request'); + 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)});