'use strict'; const tr=(zh,en)=>state.lang==='zh'?zh:en; async function layoutStudioPage(){ const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null; $('#main').innerHTML=`
${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}
${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}
${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}
`;$('#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=`${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';}); } async function clientsPanel(){ const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}
${clients.map(c=>`${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?``:''}
`).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=`${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.')}
${hooks.map(h=>`${esc(h.callback_url)}
`).join('')}`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;}); } async function externalProvidersPanel(){ const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`${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.')}
${providers.map(p=>`${esc(p.name)} · ${esc(p.kind)}
`).join('')}`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');})); } async function officialWidgetControls(info,data){ const panel=document.createElement('section');panel.className='card';panel.innerHTML=`${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('按小图清晰度、亮度和相似度筛选,不改动或删除 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='${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'));}); } async function autoCollagePanel(){ const sources=await api('/api/v1/photo-sources');const card=document.createElement('section');card.className='card auto-collage'; card.innerHTML=`${tr('选择照片来源和数量,系统每次随机选取不同照片,并自动排版生成一张图片。无需手动拼接。预览不会上传或切换画框。','Choose a photo source and count. Each run randomly selects distinct photos and automatically creates one composition. Preview never uploads to or changes the frame.')}
`; $('#main').prepend(card);if(sources.some(x=>x.enabled))$('#collage-source').value=sources.find(x=>x.enabled).id;let asset=null; $('#auto-collage-form').oninput=()=>{asset=null;$('#collage-display').hidden=true;}; const content=()=>({type:'auto_collage',source_id:$('#collage-source').value||null,count:Number($('#collage-count').value),title:$('#collage-title').value,prompt:$('#collage-prompt').value,use_ai:$('#collage-ai').checked}); $('#auto-collage-form').onsubmit=busy(async e=>{e.preventDefault();asset=null;$('#collage-display').hidden=true;$('#collage-preview').textContent=t('previewLoading');try{const id=await accepted('/api/v1/render',content());const op=await waitOperation(id);asset=op.result;$('#collage-preview').innerHTML='