diff --git a/.env.example b/.env.example
index cc9c131..fbe0078 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,7 @@ TZ=Asia/Shanghai
# Default local access. For NAS LAN access set this to the NAS LAN IP (recommended)
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
WEB_BIND=127.0.0.1
-WEB_PORT=18787
+WEB_PORT=6005
KOKONNA_MOCK=true
KOKONNA_WRITE_ENABLED=false
KOKONNA_API_KEY=
diff --git a/README.md b/README.md
index 8fb68ca..0d47c65 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
docker compose up -d
```
-Open `http://127.0.0.1:18787` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
+Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index b3baa92..d38fc8e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -22,7 +22,7 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
docker compose up -d
```
-打开 `http://127.0.0.1:18787`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
+打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
diff --git a/docker-compose.yml b/docker-compose.yml
index 7977fc5..694b9f6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,7 +17,7 @@ services:
init: true
user: "10333:10333"
ports:
- - "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-18787}:18787"
+ - "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-6005}:18787"
env_file: .env
environment:
TZ: Asia/Shanghai
diff --git a/docs/NAS.md b/docs/NAS.md
index 2b4514a..795be9a 100644
--- a/docs/NAS.md
+++ b/docs/NAS.md
@@ -1,7 +1,9 @@
# Synology NAS deployment / 群晖部署
Target: DS923+, DSM 7.4.1 (verified on target; originally specified 7.3.1), AMD64, Container Manager.
-Directory: /volume2/docker/pigway-frame-flow. Port: 18787.
+Directory: /volume2/docker/pigway-frame-flow. Default published port: 6005.
+The container still listens on 18787; Compose maps host 6005 to container 18787.
+Existing installations must update their own WEB_PORT and recreate the service to activate the change.
## 1. Package and upload / 打包上传
@@ -37,7 +39,7 @@ chmod 600 .env
Edit `.env` on the NAS. Set FRAME_FLOW_TOKEN to a strong random token. API key
stays empty. KOKONNA_MOCK=true and KOKONNA_WRITE_ENABLED=false.
Set WEB_BIND to the actual NAS LAN address (recommended) or explicitly 0.0.0.0;
-WEB_PORT=18787. The latter binds every NAS interface, so use the LAN IP when
+WEB_PORT=6005. The latter binds every NAS interface, so use the LAN IP when
strict LAN-only binding is required. No firewall/router/proxy edits are performed.
Copy deploy/devices.yml.example to config/devices.yml if multiple devices are desired.
@@ -61,7 +63,7 @@ sudo /usr/local/bin/docker image inspect pigway-frame-flow:latest \
Do not print unfiltered `docker inspect`: its environment can contain keys/tokens.
Likewise do not print `docker compose config` without --quiet on a live installation.
Run the read-only smoke checker from a machine with Python and set FRAME_FLOW_URL
-to http://NAS_LAN_IP:18787; FRAME_FLOW_TOKEN stays in private env/file.
+to http://NAS_LAN_IP:6005; FRAME_FLOW_TOKEN stays in private env/file.
Health: GET /health → 200. Readiness: GET /ready → 200 when SQLite, driver config,
auth and Scheduler are ready. Missing credentials in live mode → 503. The cloud
diff --git a/docs/UI_LAYOUT_AUDIT.md b/docs/UI_LAYOUT_AUDIT.md
index 63201de..f7206d7 100644
--- a/docs/UI_LAYOUT_AUDIT.md
+++ b/docs/UI_LAYOUT_AUDIT.md
@@ -1,36 +1,49 @@
-# UI layout audit
+# UI 一致性与图片加载检查
-The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages,
-local/cloud gallery layouts and six modal states plus expanded manual collage at 1440, 768 and 390 CSS pixels,
-in Chinese/English and light/dark themes. It checks page horizontal overflow and
-at least 8 pixels between visible input/select/textarea/button rectangles, and
-44-pixel control heights on mobile.
-Closed details, background content behind dialogs and hidden fields are excluded.
-Photos authentication is intercepted with fixture credentials; no NAS login or
-frame write occurs. Populated task/history fixtures remain in disposable Mock data.
+页面使用既有真实元数据创建控件和图片区域,不使用页面骨架或虚构图片卡片。
+搜索、排序、卡片操作与写入保护在图片请求前初始化;已知图库数据可先显示,
+后台刷新元数据。首页图片独立加载,不阻塞后续页面内容。
-This audit found and corrected missing upload preview spacing, nested task field
-spacing, small mobile action gaps and inconsistent dynamically inserted form
-spacing. NAS source forms now hide irrelevant cloud credential fields. Native
-drive forms expose their credential field and hide local-only quality controls.
+图片共用局部加载函数:旋转指示器仅居中显示于所属图片容器,网络请求与解码完成后
+立即显示图片,失败后显示中英文提示。没有固定等待或动画最短展示时长。
+图库仅请求可见区域及附近图片。浏览器禁缓存不会清除服务端缩略图缓存,
+快速图片可能在第一帧绘制前完成,动画不一定肉眼可见。
-Synology Photos has a dedicated connection card on the Photo Sources page, with
-status, an explanation of affected automation and a direct NAS sign-in dialog.
-Folder settings and person selection remain separate. Credentials are not saved;
-the existing protected session API is used. Successful login does not run a job
-or change frame content. Passwords are cleared from the dialog before submission.
+手动拼版先创建全部真实区域与坐标/拖动交互,再并行请求图片。
+每张图片独立完成或失败,持久的区域容器保持加载前后几何位置与坐标索引稳定。
+过期布局事件不修改新的规划。
-Run against an isolated Mock server with the existing Playwright runtime:
+移动端导航单行横向滚动,当前入口自动进入可见区域;低高度横屏侧栏可滚动。
+页头在窄屏分行,并为底部安全区域留白。可见控件保留 44 像素触控高度,
+当前导航使用 aria-current,扩展表单标签关联相应控件。
+表格与手机导航允许容器内滚动,不应导致整页水平溢出。
+
+## 自动检查
+
+使用隔离 Mock 服务、临时数据与测试账号,禁止对生产执行可写的 Mock 脚本。
+Photos 授权使用拦截测试响应;不需要真实 NAS 凭据。
```sh
-FRAME_FLOW_URL=http://127.0.0.1:18789 \
+FRAME_FLOW_URL=http://127.0.0.1:6005 \
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
FRAME_FLOW_TEST_USERNAME=test-ui \
FRAME_FLOW_TEST_PASSWORD=test-only-password \
node scripts/ui-layout-audit.cjs
```
-Set `NODE_PATH` to the installed Playwright runtime if needed. Optional
-`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review.
-The full audit passed 229 checks with no console errors. This complements
-`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests.
+需要已安装的 Playwright;可通过 NODE_PATH 指定运行时。
+FRAME_FLOW_VIEWPORTS 接受包含 width/height 的 JSON 数组;
+FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
+
+| 脚本 | 检查范围 | 本次结果 |
+| --- | --- | --- |
+| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
+| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
+| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;真实图片数量、搜索先于图片请求、无骨架 | 14 项通过 |
+| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
+
+上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
+生产浏览器交互验证使用拦截的账号、规划及素材响应,对真实服务仅 GET,
+不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
+
+连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
diff --git a/docs/deployment/GITEA_CD.md b/docs/deployment/GITEA_CD.md
index 6f37bfb..ca0b256 100644
--- a/docs/deployment/GITEA_CD.md
+++ b/docs/deployment/GITEA_CD.md
@@ -184,7 +184,7 @@ PROD_IMAGE_REPO=pigway-frame-flow
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
PROD_WEB_BIND=192.0.2.10
-PROD_WEB_PORT=18787
+PROD_WEB_PORT=6005
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
PROD_MOCK=true
PROD_WRITE_ENABLED=false
@@ -263,8 +263,8 @@ as an Actions artifact. First bootstrap has no prior healthy version to roll bac
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
- `192.0.2.10:18787` binding (not loopback).
-4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full
+ `192.0.2.10:6005` binding (not loopback).
+4. Open `http://192.0.2.10:6005/api/v1/version`; its `commit` must equal the full
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
is `pigway-frame-flow:latest`; compare its image ID in
Container Manager with the running container.
diff --git a/docs/openapi.json b/docs/openapi.json
index 9f02bd9..7b70b13 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.4"
+ "version": "0.1.5"
},
"paths": {
"/api/v1/devices/{device_id}/widgets/selection": {
@@ -15156,7 +15156,7 @@
"version": {
"type": "string",
"title": "Version",
- "default": "0.1.4"
+ "default": "0.1.5"
}
},
"type": "object",
@@ -17333,7 +17333,7 @@
"version": {
"type": "string",
"title": "Version",
- "default": "0.1.4"
+ "default": "0.1.5"
},
"api_version": {
"type": "string",
diff --git a/docs/releases/v0.1.5.md b/docs/releases/v0.1.5.md
new file mode 100644
index 0000000..499b457
--- /dev/null
+++ b/docs/releases/v0.1.5.md
@@ -0,0 +1,31 @@
+# Pigway Frame Flow v0.1.5
+
+## 简体中文
+
+- 图片仅在真实图片区域内居中显示加载动画,无页面骨架、虚构图片卡片或人为展示延时;请求及解码完成后立即显示,失败时显示局部提示。
+- 图库搜索、排序、卡片操作及写入保护先于图片请求初始化,复用真实列表元数据;本地任务筛选异步填充,不阻塞其他控件。
+- 手动拼版改为并行加载,各图独立完成或失败;固定区域容器保留坐标和拖动交互,加载前后位置一致。
+- 修复手机底栏文字裁切、窄屏页头拥挤和横屏导航可达性,支持底部安全区域,补齐表单标签和当前导航无障碍状态。
+- 默认对外访问端口改为 **6005**。本地启动、Compose、测试地址及文档同步;容器内部仍监听 18787。
+
+### 验证
+
+本地 505 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法和差异检查通过。
+浏览器回归:布局 305 项、图片加载 45 项、图库加载顺序 14 项、手动拼版 31 项通过。
+先行 UI 部署的生产静态文件哈希、health/ready 和拦截测试数据下的浏览器交互已验证;
+不代表真实 AI 生成、画框写入、手机实机或软键盘测试。
+
+### 升级说明
+
+已有安装须更新私有环境配置中的 `WEB_PORT=6005`,并重建服务后生效。
+自动部署的 `PROD_WEB_PORT` 必须与现有实例的映射一致;仅修改该变量不能迁移端口。
+NAS 附件是源码部署包,需自行构建 Docker 镜像,不含密钥、运行配置、数据库或私人照片。
+
+## English
+
+Image loading indicators stay inside real image regions, without page skeletons or artificial delays.
+Gallery controls initialize before image requests. Manual collage images load independently in parallel,
+with stable regions and working coordinate/drag controls. Mobile navigation, safe-area spacing and form
+accessibility are improved. The default published port is now **6005**, mapped to container port 18787.
+Existing installations must update their private port configuration and recreate the service.
+NAS source package and SHA256 checksum are included; this is not a prebuilt Docker image.
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index 5abfa76..d03a764 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -124,7 +124,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI(
title="Pigway Frame Flow API",
docs_url=None,
- version="0.1.4",
+ version="0.1.5",
lifespan=lifespan,
responses=error_responses,
)
@@ -1000,7 +1000,7 @@ def create_app(settings=None, start_worker=True):
)
def settings_view():
return SettingsView(
- version="0.1.4",
+ version="0.1.5",
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 e1e8883..fbf821a 100644
--- a/pigway_frame_flow/models.py
+++ b/pigway_frame_flow/models.py
@@ -346,7 +346,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
- version: str = "0.1.4"
+ version: str = "0.1.5"
class Readiness(BaseModel):
@@ -376,7 +376,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
- version: str = "0.1.4"
+ version: str = "0.1.5"
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 4b94222..8436230 100644
--- a/pigway_frame_flow/web/app.js
+++ b/pigway_frame_flow/web/app.js
@@ -1,8 +1,8 @@
'use strict';
const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
-function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
-async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
+function forgetLogin(){state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
+async function loadConnection(){const [settings,devices,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/devices').catch(error=>{toast(error.message);return [];}),api('/api/v1/templates')]);state.settings=settings;state.devices=devices;state.templates=templates;state.device=state.devices[0]?.id||state.device;}
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
@@ -27,15 +27,37 @@ async function api(path,options={}){
function bind(id,fn){const element=$('#'+id);if(element)element.onclick=async event=>{try{element.disabled=true;await fn(event);}catch(e){toast(e.message);}finally{element.disabled=false;}};}
function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.message);}};}
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
-function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('');}
-function clearBlobs(){for(const url of state.blobs)URL.revokeObjectURL(url);state.blobs=[];}
-async function blobImage(img,path){try{const blob=await api(path,{binary:true});if(!img.isConnected)return;const url=URL.createObjectURL(blob);state.blobs.push(url);img.src=url;}catch{img.alt=t('apiError');}}
-function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>observer.observe(img));}
+function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
+window.addEventListener('resize',revealCurrentNavigation);
+function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>``).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>``).join('');revealCurrentNavigation();}
+function imageLoading(img){
+ if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
+ const box=document.createElement('div');box.className='image-loading';
+ if(img.classList.contains('thumb'))box.classList.add('image-loading-thumb');
+ if(img.closest('.frame-demo, .image-viewer-viewport, .studio-region'))box.classList.add('image-loading-fill');
+ box.setAttribute('aria-busy','true');img.before(box);box.append(img);
+ const status=document.createElement('span');status.className='image-loading-status';status.setAttribute('role','status');status.setAttribute('aria-label',t('loading'));
+ const spinner=document.createElement('span');spinner.className='spinner';spinner.setAttribute('aria-hidden','true');status.append(spinner);box.append(status);return box;
+}
+async function blobImage(img,path){
+ const box=imageLoading(img);
+ try{
+ const blob=await api(path,{binary:true});if(!img.isConnected)return;
+ const url=URL.createObjectURL(blob);state.blobs.push(url);
+ // Keep the placeholder until the browser has decoded the image as well as fetched it.
+ img.src=url;await img.decode();if(!img.isConnected)return;box.replaceWith(img);
+ }catch{
+ box.setAttribute('aria-busy','false');box.classList.add('image-load-error');
+ const status=box.querySelector('.image-loading-status');status.removeAttribute('aria-label');status.textContent=t('imageLoadError');
+ }
+}
+function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>{imageLoading(img);observer.observe(img);});}
function notice(){return state.settings?.mock?`
${t('mock')}
`:'';}
-async function navigate(page=state.page){state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();clearBlobs();state.epoch++;const epoch=state.epoch;
+async function navigate(page=state.page){const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch;
if(!state.token){login();return;}
- $('#main').innerHTML='';
- try{await views[state.page](epoch);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,gallery:enhanceGallery,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`
${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}
${tr('选择照片','Choose photos')}
${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}
${assets.map(a=>``).join('')}
${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}
${t('preview')}
${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}
`;
+ $('#main').innerHTML=`
${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}
${tr('选择照片','Choose photos')}
${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}
${assets.map(a=>``).join('')}
${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}
${t('preview')}
${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}
`;
await autoCollagePanel();
let rendered=null;
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
$('#studio-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
$('#studio-files').onchange=busy(async()=>{const files=[...$('#studio-files').files];if(!files.length)return;const body=new FormData();files.forEach(f=>body.append('files',f));const uploaded=await api('/api/v1/layout/images',{method:'POST',body});for(const a of uploaded){const label=document.createElement('label');label.className='check-row';label.innerHTML=`${esc(a.name)}`;$('#studio-assets').append(label);}$('#studio-assets').onchange();toast(t('done'));});
- async function showPlan(){
- const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=plan.width+'/'+plan.height;
- $('#studio-region-editor').innerHTML=plan.regions.map((r,i)=>`
${['x','y','width','height'].map(k=>``).join('')}
`).join('');
- const update=(el,r)=>{el.style.left=(r.x/plan.width*100)+'%';el.style.top=(r.y/plan.height*100)+'%';el.style.width=(r.width/plan.width*100)+'%';el.style.height=(r.height/plan.height*100)+'%';};
- for(const [i,r] of plan.regions.entries()){
- const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;update(img,r);box.append(img);await blobImage(img,'/api/v1/assets/'+r.asset_id);
- img.onpointerdown=e=>{e.preventDefault();img.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};img.onpointermove=ev=>{r.x=Math.max(0,Math.min(plan.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*plan.width/box.clientWidth)));r.y=Math.max(150,Math.min(plan.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*plan.height/box.clientHeight)));update(img,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};img.onpointerup=()=>{img.onpointermove=null;};};
+ function showPlan(){
+ const layout=plan;
+ const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=layout.width+'/'+layout.height;
+ $('#studio-region-editor').innerHTML=layout.regions.map((r,i)=>`
${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}
${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}
${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.')}
${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.')}
`).join('')}`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
}
async function webhooksPanel(){
- const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`
Webhook
${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}
${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}
${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}
${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}
${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}
${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}
${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}
${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}
`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>``).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields;
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
- const box=document.createElement('section');box.innerHTML=`
${tr('照片筛选','Photo filtering')}
${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}
${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}
${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.')}
`;$('#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='';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.')}
`;$('#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='';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=`
${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.')}
${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(box);
}
async function enhanceTemplates(){
@@ -31,10 +31,10 @@ function displayDialog(content){
showModal(`
${t('displayOptions')}
`);
$('#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');});
}
-async function enhanceGallery(){
+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';const jobs=await api('/api/v1/jobs');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;$('#asset-job').append(option);}$('#asset-job').value=state.assetJob||'';$('#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();});}}
+ if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=``;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';void api('/api/v1/jobs').then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;ix.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
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'));});}});
}
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index 4a521ee..7882503 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -1,6 +1,6 @@
window.MESSAGES={
-zh:{DISPLAY_NOT_CONFIRMED:'图片已上传,但画框未确认显示;后续任务已恢复',OFFICIAL_AUTH_REQUIRED:'官方账号登录已失效,请重新连接',INVALID_REQUEST:'请求无效,请检查输入',UNAUTHORIZED:'API Token 无效或未填写',FORBIDDEN:'没有执行此操作的权限',DEVICE_NOT_FOUND:'找不到目标设备',DEVICE_OFFLINE:'画框离线',UNSUPPORTED_CAPABILITY:'设备或内容类型不支持此功能',WRITE_RESULT_UNKNOWN:'写入结果未知,请先核对设备状态',RATE_LIMITED:'请求过于频繁,请稍后再试',WRITE_DISABLED:'设备写入已关闭',CONFIG_REQUIRED:'请先配置设备',CLOUD_UNAVAILABLE:'设备云端暂时不可用',NOT_FOUND:'找不到请求的内容',CONFLICT:'配置冲突或内容仍被恢复任务引用,请先检查状态',INTERNAL_ERROR:'服务内部错误',lastScheduled:'上次调度',displayByName:'按文件名显示',QUEUED:'排队中',RENDERING:'生成中',UPLOADING:'发送中',SYNCING:'等待同步',COMPLETED:'已完成',FAILED:'失败',UNKNOWN:'结果未知',modelVerified:'API 已验证',modelConfigured:'设备配置值',dashboard:'总览',gallery:'图库',upload:'上传图片',templates:'模板',automation:'自动化',playlists:'播放列表',history:'历史记录',settings:'设置',refresh:'刷新',restore:'恢复上一张',online:'在线',offline:'离线',battery:'电量',firmware:'固件',current:'当前图片',heartbeat:'最近心跳',device:'设备信息',rotation:'旋转角度',timezone:'时区',sync:'同步状态',synced:'已同步',syncing:'等待同步',cloud:'云端图库',local:'本地图库',preview:'预览',display:'显示到画框',delete:'删除',original:'查看图片',name:'名称',created:'创建时间',save:'保存',cancel:'取消',confirm:'确认',loading:'正在加载…',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:'Cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',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:'云端图库',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:'Cloud gallery',local:'Local gallery',preview:'Preview',display:'Display on frame',delete:'Delete',original:'View image',name:'Name',created:'Created',save:'Save',cancel:'Cancel',confirm:'Confirm',loading:'Loading…',imageLoadError:'Image failed to load. Refresh to try again.',mode:'Image fit',profile:'Renderer profile',contain:'Contain · white margins',cover:'Cover · center crop',smart:'Smart crop · inspect preview',color:'Color',grayscale:'Grayscale',staging:'Save to local gallery only',uploadHint:'JPG, PNG, WebP · up to 20 MiB · 1200 × 1600',uploadNotice:'KoKonna cloud uploads immediately activate the image. Gallery-only uploads are staged locally without changing the device.',selectFile:'Choose an image',previewHint:'Preview your image before saving or displaying it. Smart crop uses saliency heuristics and does not guarantee face detection.',mock:'Demo mode',readOnly:'Live device · read only',writable:'Live device · writes authorized',cloudRotation:'Device slideshow',queue:'Sequential',random:'Random',minutes:'minutes',rotationNotice:'Device slideshow mode and interval are readable. The official API does not expose slideshow membership or rule editing. Cloud gallery is not a verified playback queue.',playlistNotice:'These playlists are managed by Frame Flow independently of KoKonna’s slideshow. Playback pauses during temporary overrides. Disable native slideshow in the vendor app to avoid competing switches.',newPlaylist:'New playlist',newJob:'New job',interval:'Interval (minutes)',imageIds:'Image IDs (comma-separated)',enabled:'Enabled',disabled:'Paused',edit:'Edit',remove:'Remove',schedule:'Schedule',source:'Content source',duration:'Display duration (seconds)',restoreAfter:'Restore previous when expired',priority:'Priority (0–100)',cron:'Cron',daily:'Daily',weekdays:'Weekdays',weekends:'Weekends',date:'Specific date',temporary:'One-time task',time:'Time (Shanghai)',at:'Run at',lastRun:'Last run',nextRun:'Next run',result:'Result',templateData:'Template data (JSON)',render:'Generate preview',templateHint:'Templates are independent of providers and device drivers. Preview before displaying. Weather uses explicitly labeled mock data.',template:'Template',operation:'Operation',action:'Action',requestId:'Request ID',client:'Client',noImages:'Your gallery is empty',emptyHint:'Upload an image or render a template to make this frame yours.',noTasks:'No jobs yet',noHistory:'No history yet',connect:'Connect to Frame Flow',token:'Frame Flow API Token',rememberLogin:'Remember this browser for 30 days',tokenHint:'Use the Frame Flow login Token, not the KoKonna API Key. Remembering saves the login Token in this browser for 30 days. Uncheck on shared devices; Disconnect removes it.',logout:'Disconnect',auth:'Authentication enabled',apiDocs:'API documentation',version:'Version',writeProtection:'Real write protection',keyConfigured:'Device key configured',yes:'Yes',no:'No',lowBattery:'Battery is low. Charge your frame.',unknown:'Write outcome unknown. Reconcile device state.',reconcile:'Read-only reconcile',acknowledge:'Manually verified; release block',accepted:'Operation accepted. Check History for synchronization results.',deleteCurrent:'This is the current image. Deleting it will make the device switch to another image. Continue?',deleteConfirm:'Delete this image? This cannot be undone.',removeConfirm:'Remove this configuration?',error:'Action failed',done:'Completed',apiError:'Device cloud error. Try refreshing later.',noToken:'Enter an API Token to manage your frames',status:'Status',systemTheme:'System',theme:'Appearance',language:'Language',dark:'Dark',light:'Light',refreshInterval:'Device state cached for 60 seconds; gallery loaded on demand',model:'Model',coverId:'Cover ID',saveTemplate:'Save template defaults',previewLoading:'Rendering preview…',storage:'Device storage used',readOnlyWarning:'Real writes are disabled. Preview and configure jobs safely; live device uploads, switches and deletes are blocked.',footer:'Independent smart frame service',photos:'Frame content',count:'images',testLabel:'Illustration · not the device’s current image'}
};
Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'});
diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html
index 5ec3c81..621c25d 100644
--- a/pigway_frame_flow/web/index.html
+++ b/pigway_frame_flow/web/index.html
@@ -1,3 +1,3 @@
-Pigway Frame Flow
-