Files
pigway-frame-flow/pigway_frame_flow/web/libraries.js
T
渡口浪人 8ce4aafb93
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m45s
feat: split source and terminal library navigation
2026-10-23 19:21:44 +08:00

129 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Keep older assets and APIs accessible without interpreting frame products as sources.
Object.assign(MESSAGES.zh,{'legacy-plans':'旧版小图方案',libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{'legacy-plans':'Legacy thumbnail plans',libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'});
Object.assign(MESSAGES.zh,{gallery:'来源图库','terminal-gallery':'终端图库','legacy-gallery':'历史图库'});
Object.assign(MESSAGES.en,{gallery:'Source library','terminal-gallery':'Terminal library','legacy-gallery':'Legacy gallery'});
icons['legacy-gallery']='▧';
const legacyGalleryView=views.gallery;
const legacyPlanView=views.thumbnails;
delete icons.thumbnails;
icons['legacy-plans']='▧';
views['legacy-plans']=legacyPlanView;
const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0};
function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;}
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceNameForId(terminal.id);}
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.name)}</option>`).join('');}
function libraryLegacy(tab){state.tab=tab;return navigate('legacy-gallery');}
views['legacy-gallery']=async function(epoch){
await legacyGalleryView(epoch);if(epoch!==state.epoch)return;
const back=document.createElement('button');back.textContent=tr('返回来源图库','Back to source library');back.onclick=()=>navigate('gallery');$('#main').prepend(back);
};
views.gallery=epoch=>libraryPage(epoch,'source');
views['terminal-gallery']=epoch=>libraryPage(epoch,'terminal');
async function libraryPage(epoch,kind){
if(libraryUI.kind!==kind)libraryUI.offset=0;
libraryUI.kind=kind;
const [sources,terminals]=await Promise.all([api('/api/v1/source-libraries'),api('/api/v1/library-terminals')]);
if(epoch!==state.epoch)return;
$('#main').innerHTML=`<div class="section-heading"><span></span><button id="library-refresh">${t('refresh')}</button></div><div id="library-content"></div>`;
bind('library-refresh',()=>navigate());
if(kind==='source')await sourceLibraryPage(epoch,sources,terminals);else await terminalLibraryPage(epoch,sources,terminals);
}
async function sourceLibraryPage(epoch,sources,terminals){
const panel=$('#library-content');
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button><button id="library-legacy">${tr('查看历史本地成品','View legacy local images')}</button></section>`;bind('library-add-source',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));return;}
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
const sourceId=libraryUI.source;
const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]);
if(epoch!==state.epoch)return;
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
panel.innerHTML=`<section class="card"><div class="library-source-toolbar"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button><button id="library-source-settings">${tr('来源设置','Source settings')}</button><button id="library-legacy">${tr('历史本地成品','Legacy local images')}</button></div></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs"><button data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div></div><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p></section><div class="gallery source-library-gallery">${page.items.map((picture,i)=>{
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan.name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
}).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚未选入源画面。先从来源选入照片,再按方案生成。旧成品保留在“历史本地成品”。','No source pixels imported yet. Import a photo, then generate its versions. Older products remain under Legacy local images.')}</section>`}<div class="actions library-pagination"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div>`;
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
document.querySelectorAll('[data-library-plan]').forEach(b=>b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();});
bind('library-import',()=>libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));
bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items));
bind('library-delete-plan',()=>confirmDialog(tr('删除此方案?已有生成版本保留参数快照;正在被终端使用的方案不能删除。','Delete this plan? Existing versions keep their snapshots. Bound plans cannot be deleted.'),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();}));
bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();});
bind('library-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('library-next',()=>{libraryUI.offset+=30;return navigate();});
const selected=i=>{const p=page.items[i];return plan?p.versions.find(v=>v.plan_id===plan.id):p;};
document.querySelectorAll('[data-library-preview]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryPreview);return openImageViewer(v.url,v.name);}));
document.querySelectorAll('[data-library-download]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryDownload);return downloadImage(v.url,v.name);}));
document.querySelectorAll('[data-library-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(selected(b.dataset.librarySettings)));
document.querySelectorAll('[data-library-generate]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/source-images/'+page.items[b.dataset.libraryGenerate].id+'/generate',{method:'POST',body:{plan_id:plan?.id||null}});await navigate();}));
document.querySelectorAll('[data-library-star]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryStar];await api('/api/v1/source-images/'+p.id+'/star',{method:'PUT',body:{starred:!p.starred}});await navigate();}));
document.querySelectorAll('[data-library-exclude]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryExclude];await api('/api/v1/source-images/'+p.id+'/exclusion',{method:'PUT',body:{excluded:!p.excluded}});await navigate();}));
document.querySelectorAll('[data-library-delete]').forEach(b=>b.onclick=()=>{const p=page.items[b.dataset.libraryDelete],v=selected(b.dataset.libraryDelete);if(plan&&!v)return;return confirmDialog(plan?tr('删除这个本地生成版本?不会删除源画面或设备上已接收的图片。','Delete this local version? Source pixels and device copies are kept.'):tr('删除本地源画面及其所有生成版本?不会删除照片数据源中的照片或设备副本。','Delete this local source picture and all generated versions? Provider photos and device copies are kept.'),async()=>{await api(plan?'/api/v1/library-versions/'+v.asset_id:'/api/v1/source-images/'+p.id,{method:'DELETE'});await navigate();});});
hydrateImages();
}
async function generationPlanEditor(sourceId,plan,pictures){
const oldPlans=plan?[]:await api('/api/v1/thumbnail-plans');
const p=plan||{name:'',width:410,height:502,fit:'cover',quality:85,center_x:0.5,center_y:0.5,caption:false,profile:'color'};
showModal(`<h2>${tr('来源图库生成方案','Source-library generation plan')}</h2><form id="generation-form">${!plan?`<div class="actions"><button type="button" data-generation-preset="frame-portrait">${tr('相框竖版','Portrait frame')}</button><button type="button" data-generation-preset="frame-landscape">${tr('相框横版','Landscape frame')}</button><button type="button" data-generation-preset="watch">${tr('手表','Watch')}</button></div>${oldPlans.length?`<div class="field"><label for="generation-legacy">${tr('从旧版小图方案复制参数(可选)','Copy legacy thumbnail settings (optional)')}</label><select id="generation-legacy">${librarySelectOptions(oldPlans,null,tr('不复制','Do not copy'))}</select></div>`:''}`:''}<div class="field"><label for="generation-name">${t('name')}</label><input id="generation-name" required maxlength="120" value="${esc(p.name)}"></div><div class="form-grid">${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`<div class="field"><label for="generation-${key}">${label}</label><input id="generation-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${p[key]}"></div>`).join('')}<div class="field"><label for="generation-fit">${tr('适配方式','Fit')}</label><select id="generation-fit"><option value="cover">${tr('等比裁切铺满','Proportional fill')}</option><option value="contain">${tr('完整画面,必要时补白','Full picture, white padding')}</option></select></div><div class="field"><label for="generation-profile">${t('profile')}</label><select id="generation-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div></div><label class="check-row"><input id="generation-caption" type="checkbox" ${p.caption?'checked':''}>${tr('叠加来源提供的日期与说明','Add date and caption supplied by the source')}</label><p class="hint">${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}</p><div class="field"><label for="generation-picture">${tr('用本地源画面预览','Preview with a local source picture')}</label><select id="generation-picture">${librarySelectOptions(pictures.map(x=>({id:x.id,name:x.name})),pictures[0]?.id,tr('选择照片','Choose picture'))}</select></div><div id="generation-preview-box"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="button" id="generation-preview">${tr('预览','Preview')}</button><button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#generation-fit').value=p.fit;$('#generation-profile').value=p.profile;
const fill=values=>{for(const key of ['name','width','height','quality','center_x','center_y','fit'])$('#generation-'+key).value=values[key];$('#generation-caption').checked=values.caption||false;$('#generation-profile').value=values.profile||'color';};
document.querySelectorAll('[data-generation-preset]').forEach(b=>b.onclick=()=>{const type=b.dataset.generationPreset;fill({...p,name:type==='watch'?tr('手表','Watch'):type==='frame-portrait'?tr('相框竖版','Portrait frame'):tr('相框横版','Landscape frame'),width:type==='watch'?410:type==='frame-portrait'?1200:1600,height:type==='watch'?502:type==='frame-portrait'?1600:1200,caption:type!=='watch'});});
if($('#generation-legacy'))$('#generation-legacy').onchange=()=>{const old=oldPlans.find(p=>p.id===$('#generation-legacy').value);if(old)fill(old);};
const read=()=>({source_id:sourceId,name:$('#generation-name').value,width:Number($('#generation-width').value),height:Number($('#generation-height').value),quality:Number($('#generation-quality').value),center_x:Number($('#generation-center_x').value),center_y:Number($('#generation-center_y').value),fit:$('#generation-fit').value,profile:$('#generation-profile').value,caption:$('#generation-caption').checked});
bind('generation-preview',async()=>{if(!$('#generation-form').reportValidity())return;const id=$('#generation-picture').value;if(!id)throw new Error(tr('请先选入源画面','Import a source picture first'));const blob=await api('/api/v1/source-images/'+id+'/preview',{method:'POST',body:read(),binary:true});if(!$('#generation-preview-box'))return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#generation-preview-box').innerHTML=`<img alt="${tr('生成预览','Generation preview')}" style="max-width:100%;max-height:360px" src="${url}">`;});
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
}
async function libraryImportDialog(sourceId){
showModal(`<h2>${tr('从照片来源选入','Import from photo source')}</h2><p class="hint">${tr('保存无叠加的源画面,不会修改来源照片。','Stores undecorated source pixels without modifying provider photos.')}</p><div class="field"><label for="library-import-item">${tr('选择照片','Choose picture')}</label><select id="library-import-item"></select></div><div class="actions"><button id="library-import-prev">${tr('上一页','Previous')}</button><button id="library-import-next">${tr('下一页','Next')}</button></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="library-import-confirm" class="primary">${tr('选入本地','Import locally')}</button></div>`);
let offset=0;
async function load(){const page=await api('/api/v1/photo-sources/'+encodeURIComponent(sourceId)+'/images?limit=50&offset='+offset);if(!$('#library-import-item'))return;$('#library-import-item').innerHTML=librarySelectOptions(page.items,page.items[0]?.id,tr('请选择','Choose'));$('#library-import-prev').disabled=offset===0;$('#library-import-next').disabled=offset+50>=page.total;}
bind('library-import-prev',async()=>{offset=Math.max(0,offset-50);await load();});bind('library-import-next',async()=>{offset+=50;await load();});
bind('library-import-confirm',async()=>{const item=$('#library-import-item').value;if(!item)return;await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/import',{method:'POST',body:{item_id:item}});$('#modal').close();libraryUI.offset=0;await navigate();});await load();
}
async function terminalLibraryPage(epoch,sources,terminals){
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
const terminal=terminals.find(t=>t.id===libraryUI.terminal)||terminals[0];libraryUI.terminal=terminal.id;state.tab=terminal.id==='watch'?'watch':'cloud';if(terminal.id!=='watch')state.device=terminal.id;shell();
const [page,plans]=await Promise.all([api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images?limit=30&offset='+libraryUI.offset),terminal.binding?api('/api/v1/generation-plans?source_id='+encodeURIComponent(terminal.binding.source_id)):Promise.resolve([])]);
if(epoch!==state.epoch)return;
const source=sources.find(s=>s.id===terminal.binding?.source_id),plan=plans.find(p=>p.id===terminal.binding?.plan_id);
$('#library-content').innerHTML=`<section class="card"><div class="field"><label for="library-terminal">${tr('终端','Terminal')}</label><select id="library-terminal">${terminals.map(t=>`<option value="${esc(t.id)}" ${t.id===terminal.id?'selected':''}>${esc(terminalLabel(t))}</option>`).join('')}</select></div><h2>${esc(terminalLabel(terminal))}</h2><p>${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}</p><p class="hint">${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}</p><div class="actions"><button id="library-binding" class="primary">${tr('设置来源与方案(各单选)','Bind one source and one plan')}</button>${terminal.binding?`<button id="library-unbind">${tr('解除绑定','Unbind')}</button><button id="library-to-source">${tr('查看来源与生成版本','View source and versions')}</button>`:''}<button id="library-old-terminal">${terminal.kind==='watch'?tr('旧版小图图库','Legacy watch gallery'):tr('设备已有云端图片','Existing device cloud images')}</button></div></section><div class="actions"><button id="terminal-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="terminal-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}</span></div><div class="gallery">${page.items.map((v,i)=>`<article class="card image-card"><button class="image-preview" data-terminal-preview="${i}"><img class="thumb" alt="${esc(v.name)}" data-src="${esc(v.url)}" style="object-fit:contain;aspect-ratio:${v.plan.width}/${v.plan.height}"></button><h3>${esc(v.name)}</h3><small>${v.plan.width} × ${v.plan.height} · ${esc(v.plan.name)}</small><small>${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))}</small><div class="actions"><button data-terminal-preview="${i}">${tr('预览','Preview')}</button><button data-terminal-download="${i}">${tr('下载图片','Download image')}</button><button data-terminal-settings="${i}">${tr('生成参数','Generation settings')}</button>${v.operation?`<button data-terminal-operation="${i}">${tr('推送记录','Delivery history')}</button>`:''}<button data-terminal-send="${i}" ${!terminal.transport_supported||!liveWriteAllowed()||['QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN'].includes(v.delivery_status)?'disabled':''} title="${terminal.transport_supported?(liveWriteAllowed()?'':esc(t('readOnlyWarning'))):tr('手表传输尚未接入','Watch transfer is not connected')}">${tr('推送到终端','Send to terminal')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}</section>`}`;
$('#library-terminal').onchange=()=>{libraryUI.terminal=$('#library-terminal').value;libraryUI.offset=0;state.tab=libraryUI.terminal==='watch'?'watch':'cloud';if(libraryUI.terminal!=='watch')state.device=libraryUI.terminal;return navigate();};
bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr('解除终端绑定?已有图片保留,后续来源任务不再向此终端输出。','Unbind this terminal? Images are kept; future source tasks will not output to it.'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();}));
bind('library-to-source',()=>{libraryUI.source=terminal.binding.source_id;libraryUI.plan=terminal.binding.plan_id;libraryUI.offset=0;state.tab='local';return navigate('gallery');});
bind('library-old-terminal',()=>{if(terminal.id!=='watch')state.device=terminal.id;return libraryLegacy(terminal.id==='watch'?'watch':'cloud');});
bind('terminal-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('terminal-next',()=>{libraryUI.offset+=30;return navigate();});
document.querySelectorAll('[data-terminal-preview]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalPreview];return openImageViewer(v.url,v.name);}));
document.querySelectorAll('[data-terminal-download]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalDownload];return downloadImage(v.url,v.name);}));
document.querySelectorAll('[data-terminal-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(page.items[b.dataset.terminalSettings]));
document.querySelectorAll('[data-terminal-operation]').forEach(b=>b.onclick=()=>{state.historyFilters={q:page.items[b.dataset.terminalOperation].operation.operation_id};state.historyPage=1;return navigate('history');});
document.querySelectorAll('[data-terminal-send]').forEach(b=>b.onclick=()=>{const v=page.items[b.dataset.terminalSend];return confirmDialog(tr('将此版本推送到终端并显示?','Send and display this version on the terminal?'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images/'+v.asset_id+'/send',{method:'POST'});toast(t('accepted'));await navigate();});});hydrateImages();
}
async function terminalBindingEditor(terminal,sources){
showModal(`<h2>${esc(terminalLabel(terminal))} · ${tr('来源与生成方案','Source and generation plan')}</h2><form id="terminal-binding-form"><div class="field"><label for="binding-source">${tr('来源图库(单选)','Source library (one)')}</label><select id="binding-source" required>${librarySelectOptions(sources,terminal.binding?.source_id,tr('请选择来源','Choose source'))}</select></div><div class="field"><label for="binding-plan">${tr('该来源内的生成方案(单选)','Generation plan in this source (one)')}</label><select id="binding-plan" required></select></div><p class="hint">${tr('切换来源必须重新选择方案。方案可被多个终端共用,但尺寸必须匹配各终端屏幕。请先在来源图库中创建方案。','Changing source clears the plan selection. Terminals may share a plan when screen dimensions match. Create plans in the source library first.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);
let request=0;
async function load(selected){const source=$('#binding-source').value,ticket=++request;$('#binding-plan').innerHTML='<option value="">—</option>';if(!source)return;const plans=await api('/api/v1/generation-plans?source_id='+encodeURIComponent(source));if(ticket!==request||!$('#binding-plan'))return;$('#binding-plan').innerHTML=librarySelectOptions(plans.filter(p=>!p.archived),selected,tr('请选择生成方案','Choose generation plan'));}
$('#binding-source').onchange=busy(()=>load(null));$('#terminal-binding-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'PUT',body:{source_id:$('#binding-source').value,plan_id:$('#binding-plan').value}});$('#modal').close();await navigate();});await load(terminal.binding?.plan_id);
}
$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||['terminal-gallery','legacy-gallery'].includes(state.page)){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('terminal-gallery');}state.device=id;return navigate();};
async function libraryJobFields(job,isNew){
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input id="job-library-pipeline" type="checkbox" ${job.library_pipeline||(isNew&&job.source?.type==='photo_source')?'checked':''}>${tr('按来源图库统一生成并推送','Generate and send through source-library bindings')}</label><p class="hint">${tr('选一次照片并保存源画面,按来源内的终端绑定分别生成;同一方案共用成品。相框继续走上传和显示确认,手表仅生成。此模式不再单独选择“小图方案”。旧任务默认保留原模式,勾选后切换。','Select one photo, save source pixels and generate each bound plan. Terminals sharing a plan share its version. Frames upload and confirm display; watches generate only. Legacy tasks keep their old mode until enabled.')}</p>`;
$('#job-form .modal-actions').before(box);
const kind=$('#job-content-type'),pipeline=$('#job-library-pipeline');
const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked;pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}};
kind.addEventListener('change',()=>{if(isNew&&kind.value==='photo_source')pipeline.checked=true;update();});pipeline.onchange=update;update();
}
if(state.settings&&state.page==='gallery')void navigate('gallery');else shell();
function generationVersionDetails(version){
const p=version.plan;
showModal(`<h2>${esc(p.name)}</h2><div class="detail-grid">${detail(tr('输出尺寸','Output size'),p.width+' × '+p.height)}${detail(tr('JPEG 质量','JPEG quality'),p.quality)}${detail(tr('适配方式','Fit'),p.fit==='cover'?tr('等比铺满','Proportional fill'):tr('完整画面','Full picture'))}${detail(tr('裁切位置','Crop position'),p.center_x+' / '+p.center_y)}${detail(tr('叠加说明','Caption'),p.caption?tr('开启','Enabled'):tr('关闭','Disabled'))}${detail(tr('生成时间','Generated at'),fmt(version.created_at))}</div><p class="hint">${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);
}