2026-10-23 17:45:30 +08:00
'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.' });
2026-10-23 19:21:44 +08:00
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' ] = '▧' ;
2026-10-23 17:45:30 +08:00
const legacyGalleryView = views . gallery ;
const legacyPlanView = views . thumbnails ;
delete icons . thumbnails ;
icons [ 'legacy-plans' ] = '▧' ;
views [ 'legacy-plans' ] = legacyPlanView ;
2026-10-23 19:21:44 +08:00
const libraryUI = { kind : 'source' , source : null , plan : null , terminal : null , offset : 0 };
2026-10-23 17:45:30 +08:00
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 ( '' );}
2026-10-23 19:21:44 +08:00
function libraryLegacy ( tab ){ state . tab = tab ; return navigate ( 'legacy-gallery' );}
2026-10-23 17:45:30 +08:00
2026-10-23 19:21:44 +08:00
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 ;
2026-10-23 17:45:30 +08:00
const [ sources , terminals ] = await Promise . all ([ api ( '/api/v1/source-libraries' ), api ( '/api/v1/library-terminals' )]);
if ( epoch !== state . epoch ) return ;
2026-10-23 19:21:44 +08:00
$ ( '#main' ). innerHTML = `<div class="section-heading"><span></span><button id="library-refresh"> ${ t ( 'refresh' ) } </button></div><div id="library-content"></div>` ;
2026-10-23 17:45:30 +08:00
bind ( 'library-refresh' ,()=> navigate ());
2026-10-23 19:21:44 +08:00
if ( kind === 'source' ) await sourceLibraryPage ( epoch , sources , terminals ); else await terminalLibraryPage ( epoch , sources , terminals );
}
2026-10-23 17:45:30 +08:00
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 ));
2026-10-23 21:04:01 +08:00
panel . innerHTML = `<section class="card library-source-controls"><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></section><section class="card library-output-panel"><div class="library-plan-toolbar"><div class="tabs gallery-tabs library-plan-tabs" role="tablist" aria-label=" ${ tr ( '源画面与生成方案' , 'Source pixels and generation plans' ) } "><button id="library-plan-tab-source" role="tab" aria-controls="library-plan-content" aria-selected=" ${ ! plan } " tabindex=" ${ ! plan ? 0 :- 1 } " data-library-plan="" class=" ${ ! plan ? 'active' : '' } "> ${ tr ( '源画面' , 'Source pixels' ) } </button> ${ plans . map ( p => `<button id="library-plan-tab- ${ esc ( p . id ) } " role="tab" aria-controls="library-plan-content" aria-selected=" ${ p . id === plan ? . id } " tabindex=" ${ p . id === plan ? . id ? 0 :- 1 } " 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><div id="library-plan-content" role="tabpanel" aria-labelledby="library-plan-tab- ${ plan ? esc ( plan . id ) : 'source' } "><p class="hint"> ${ tr ( '使用终端:' , 'Used by: ' ) }${ uses . map ( terminalLabel ). map ( esc ). join ( '、' ) || tr ( '尚未绑定' , 'No terminals bound' ) } </p><div class="gallery source-library-gallery"> ${ page . items . map (( picture , i )=>{
2026-10-23 17:45:30 +08:00
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>` ;
2026-10-23 21:04:01 +08:00
} ).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></div></section>` ;
2026-10-23 17:45:30 +08:00
$ ( '#library-source' ). onchange = ()=>{ libraryUI . source = $ ( '#library-source' ). value ; libraryUI . plan = null ; libraryUI . offset = 0 ; return navigate ();};
2026-10-23 21:04:01 +08:00
document . querySelectorAll ( '[data-library-plan]' ). forEach ( b =>{
b . onclick = ()=>{ libraryUI . plan = b . dataset . libraryPlan || null ; return navigate ();};
b . onkeydown = async e =>{ const tabs = [... document . querySelectorAll ( '[data-library-plan]' )], index = tabs . indexOf ( b ); let next ; if ( e . key === 'ArrowRight' ) next = ( index + 1 ) % tabs . length ; else if ( e . key === 'ArrowLeft' ) next = ( index + tabs . length - 1 ) % tabs . length ; else if ( e . key === 'Home' ) next = 0 ; else if ( e . key === 'End' ) next = tabs . length - 1 ; else return ; e . preventDefault (); libraryUI . plan = tabs [ next ]. dataset . libraryPlan || null ; await navigate (); document . querySelector ( '[data-library-plan][aria-selected="true"]' ) ? . focus ();};
});
2026-10-23 17:45:30 +08:00
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 ));
2026-10-23 22:12:47 +08:00
bind ( 'library-delete-plan' ,()=> confirmDialog ( tr ( `确认删除生成方案“ ${ plan . name } ”?已有生成版本保留参数快照;正在被终端使用的方案不能删除。` , `Delete generation plan “ ${ plan . name } ”? Existing versions keep their snapshots. Bound plans cannot be deleted.` ), async ()=>{ await api ( '/api/v1/generation-plans/' + plan . id ,{ method : 'DELETE' }); await navigate ();}));
2026-10-23 17:45:30 +08:00
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 ();});
}
2026-10-23 19:59:02 +08:00
function libraryImportDialog ( sourceId ){ return openSourceBrowser ( sourceId );}
2026-10-23 17:45:30 +08:00
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 );
2026-10-23 22:20:47 +08:00
$ ( '#library-content' ). innerHTML = `<section class="card"><div class="tabs gallery-tabs terminal-choice" role="group" aria-label=" ${ tr ( '终端' , 'Terminal' ) } "> ${ terminals . map ( t => `<button type="button" data-library-terminal=" ${ esc ( t . id ) } " class=" ${ t . id === terminal . id ? 'active' : '' } " aria-pressed=" ${ t . id === terminal . id } "> ${ esc ( terminalLabel ( t )) } </button>` ). join ( '' ) } </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="gallery terminal-library-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>` } <div class="actions library-pagination"><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>` ;
document . querySelectorAll ( '[data-library-terminal]' ). forEach ( b => b . onclick = ()=>{ if ( b . dataset . libraryTerminal === libraryUI . terminal ) return ; libraryUI . terminal = b . dataset . libraryTerminal ; libraryUI . offset = 0 ; state . tab = libraryUI . terminal === 'watch' ? 'watch' : 'cloud' ; if ( libraryUI . terminal !== 'watch' ) state . device = libraryUI . terminal ; return navigate ();});
2026-10-23 17:45:30 +08:00
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 ();}));
2026-10-23 19:21:44 +08:00
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' );});
2026-10-23 17:45:30 +08:00
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 );
}
2026-10-23 19:21:44 +08:00
$ ( '#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 ();};
2026-10-23 17:45:30 +08:00
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>` );
}