2026-10-23 14:47:27 +08:00
'use strict' ;
Object . assign ( MESSAGES . zh , { thumbnails : '小图方案' , thumbnailPlanInUse : '此方案已被任务或待处理操作使用,请先取消关联或更换方案。' });
Object . assign ( MESSAGES . en , { thumbnails : 'Thumbnail plans' , thumbnailPlanInUse : 'This plan is used by a task or pending operation. Unlink it or select another plan first.' });
views . thumbnails = async function ( epoch ){
const [ plans , sources ] = await Promise . all ([ api ( '/api/v1/thumbnail-plans' ), api ( '/api/v1/photo-sources' )]);
if ( epoch !== state . epoch ) return ;
2026-10-23 15:00:29 +08:00
$ ( '#main' ). innerHTML = `<section class="card"><div class="actions"><select id="thumb-plan"><option value=""> ${ tr ( '新建方案' , 'New plan' ) } </option> ${ plans . map ( p => `<option value=" ${ esc ( p . id ) } "> ${ esc ( p . name ) } · ${ p . width } × ${ p . height } </option>` ). join ( '' ) } </select><button id="thumb-delete" class="danger"> ${ t ( 'delete' ) } </button></div><form id="thumb-form"><div class="field"><label for="thumb-name"> ${ t ( 'name' ) } </label><input id="thumb-name" required maxlength="120"></div><div class="form-grid"> ${ [[ 'width' , tr ( '宽度(像素)' , 'Width (px)' ), 410 , 16 , 2048 , 1 ],[ 'height' , tr ( '高度(像素)' , 'Height (px)' ), 502 , 16 , 2048 , 1 ],[ 'center_x' , tr ( '水平裁切位置(0 左 / 1 右)' , 'Crop X (0 left / 1 right)' ), 0.5 , 0 , 1 , 0.01 ],[ 'center_y' , tr ( '垂直裁切位置(0 上 / 1 下)' , 'Crop Y (0 top / 1 bottom)' ), 0.5 , 0 , 1 , 0.01 ],[ 'quality' , tr ( 'JPEG 质量' , 'JPEG quality' ), 85 , 40 , 95 , 1 ]]. map (([ key , label , value , min , max , step ])=> `<div class="field"><label for="thumb- ${ key } "> ${ label } </label><input id="thumb- ${ key } " type="number" required min=" ${ min } " max=" ${ max } " step=" ${ step } " value=" ${ value } "></div>` ). join ( '' ) } <div class="field"><label for="thumb-fit"> ${ tr ( '适配方式' , 'Fit' ) } </label><select id="thumb-fit"><option value="cover"> ${ tr ( '等比裁切铺满(默认)' , 'Fill with proportional crop (default)' ) } </option><option value="contain"> ${ tr ( '完整画面(必要时补白)' , 'Full picture (white padding if needed)' ) } </option></select></div></div><p class="hint"> ${ tr ( '不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。' , 'No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.' ) } </p><button type="submit" class="primary"> ${ tr ( '保存方案' , 'Save plan' ) } </button></form></section><section class="card"><h2> ${ tr ( '预览与生成' , 'Preview and generate' ) } </h2><div class="field"><label for="thumb-source"> ${ t ( 'source' ) } </label><select id="thumb-source"><option value=""> ${ tr ( '选择照片数据源' , 'Choose photo source' ) } </option> ${ sources . map ( s => `<option value=" ${ esc ( s . id ) } "> ${ esc ( s . name ) } </option>` ). join ( '' ) } </select></div><div class="field"><label for="thumb-item"> ${ tr ( '源图片' , 'Source image' ) } </label><select id="thumb-item"></select></div><div class="actions"><button id="thumb-prev"> ${ tr ( '上一页' , 'Previous page' ) } </button><button id="thumb-next"> ${ tr ( '下一页' , 'Next page' ) } </button><button id="thumb-preview"> ${ tr ( '预览小图' , 'Preview thumbnail' ) } </button><button id="thumb-generate" class="primary"> ${ tr ( '生成并保存到小图图库' , 'Generate and save to thumbnail gallery' ) } </button></div><button id="thumb-gallery" type="button"> ${ tr ( '查看手表小图图库' , 'Open watch thumbnail gallery' ) } </button><p id="thumb-result" class="hint" aria-live="polite"></p><img id="thumb-image" alt=" ${ tr ( '小图预览' , 'Thumbnail preview' ) } " hidden style="max-width:100%;max-height:502px;width:auto;height:auto"></section>` ;
2026-10-23 14:47:27 +08:00
const defaults = { name : '' , width : 410 , height : 502 , center_x : 0.5 , center_y : 0.5 , quality : 85 , fit : 'cover' };
const fill = p =>{ for ( const key of Object . keys ( defaults )) $ ( '#thumb-' + key ). value = p [ key ];};
const read = ()=>{ const p = {}; for ( const key of Object . keys ( defaults )) p [ key ] = [ 'name' , 'fit' ]. includes ( key ) ? $ ( '#thumb-' + key ). value : Number ( $ ( '#thumb-' + key ). value ); return p ;};
fill ( defaults ); $ ( '#thumb-delete' ). disabled = true ;
2026-10-23 15:00:29 +08:00
bind ( 'thumb-gallery' ,()=>{ state . tab = 'watch' ; state . watchOffset = 0 ; return navigate ( 'gallery' );});
2026-10-23 14:47:27 +08:00
$ ( '#thumb-form' ). oninput = ()=>{ $ ( '#thumb-image' ). hidden = true ; $ ( '#thumb-result' ). textContent = tr ( '参数已修改,请重新预览' , 'Parameters changed; preview again' );};
$ ( '#thumb-plan' ). onchange = ()=>{ fill ( plans . find ( p => p . id === $ ( '#thumb-plan' ). value ) || defaults ); $ ( '#thumb-delete' ). disabled =! $ ( '#thumb-plan' ). value ; $ ( '#thumb-image' ). hidden = true ;};
$ ( '#thumb-form' ). onsubmit = busy ( async e =>{ e . preventDefault (); const id = $ ( '#thumb-plan' ). value ; await api ( '/api/v1/thumbnail-plans' + ( id ? '/' + id : '' ),{ method : id ? 'PUT' : 'POST' , body : read ()}); toast ( t ( 'save' )); await navigate ();});
bind ( 'thumb-delete' ,()=> confirmDialog ( t ( 'removeConfirm' ), async ()=>{ await api ( '/api/v1/thumbnail-plans/' + $ ( '#thumb-plan' ). value ,{ method : 'DELETE' }); await navigate ();}));
let offset = 0 , version = 0 ;
async function images (){ const source = $ ( '#thumb-source' ). value ; const ticket =++ version ; $ ( '#thumb-item' ). innerHTML = '' ; if ( ! source ) return ; const page = await api ( '/api/v1/photo-sources/' + encodeURIComponent ( source ) + '/images?offset=' + offset + '&limit=50' ); if ( ticket !== version || epoch !== state . epoch ) return ; $ ( '#thumb-item' ). innerHTML = page . items . map ( i => `<option value=" ${ esc ( i . id ) } "> ${ esc ( i . name ) } </option>` ). join ( '' ); $ ( '#thumb-prev' ). disabled = offset === 0 ; $ ( '#thumb-next' ). disabled = offset + 50 >= page . total ;}
$ ( '#thumb-source' ). onchange = busy ( async ()=>{ offset = 0 ; await images ();});
bind ( 'thumb-prev' , async ()=>{ offset = Math . max ( 0 , offset - 50 ); await images ();}); bind ( 'thumb-next' , async ()=>{ offset += 50 ; await images ();});
function payload (){ if ( ! $ ( '#thumb-form' ). reportValidity ()) throw new Error ( tr ( '请补全方案参数' , 'Complete the plan parameters' )); if ( ! $ ( '#thumb-item' ). value ) throw new Error ( tr ( '请选择源图片' , 'Choose a source image' )); return { source_id : $ ( '#thumb-source' ). value , item_id : $ ( '#thumb-item' ). value , plan : read ()};}
bind ( 'thumb-preview' , async ()=>{ const blob = await api ( '/api/v1/thumbnails/preview' ,{ method : 'POST' , body : payload (), binary : true }); if ( epoch !== state . epoch ) return ; const url = URL . createObjectURL ( blob ); state . blobs . push ( url ); $ ( '#thumb-image' ). src = url ; $ ( '#thumb-image' ). hidden = false ; $ ( '#thumb-result' ). textContent = tr ( '预览未保存' , 'Preview is not saved' );});
2026-10-23 15:00:29 +08:00
bind ( 'thumb-generate' , async ()=>{ const asset = await api ( '/api/v1/thumbnails/generate' ,{ method : 'POST' , body : payload ()}); if ( epoch !== state . epoch ) return ; $ ( '#thumb-image' ). hidden = false ; await blobImage ( $ ( '#thumb-image' ), asset . url ); $ ( '#thumb-result' ). textContent = tr ( '已保存到手表小图图库:' , 'Saved to watch thumbnail gallery: ' ) + asset . name ;});
2026-10-23 14:47:27 +08:00
};
async function thumbnailJobFields ( job ){
const plans = await api ( '/api/v1/thumbnail-plans' ); if ( ! $ ( '#job-form' )) return ;
2026-10-23 17:45:30 +08:00
const box = document . createElement ( 'section' ); box . id = 'legacy-thumbnail-job-fields' ; box . innerHTML = `<label class="check-row"><input type="checkbox" id="job-thumbnail" ${ job . generate_thumbnail ? 'checked' : '' } > ${ tr ( '生成小图' , 'Generate thumbnail' ) } </label><div class="field"><label for="job-thumbnail-plan"> ${ tr ( '小图生成方案(单选)' , 'Thumbnail plan (choose one)' ) } </label><select id="job-thumbnail-plan"><option value=""> ${ tr ( '请选择已保存方案' , 'Choose a saved plan' ) } </option> ${ plans . map ( p => `<option value=" ${ esc ( p . id ) } " ${ job . thumbnail_plan_id === p . id ? 'selected' : '' } > ${ esc ( p . name ) } · ${ p . width } × ${ p . height } </option>` ). join ( '' ) } </select></div><p class="hint"> ${ tr ( '仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。' , 'For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.' ) } </p>` ;
2026-10-23 14:47:27 +08:00
$ ( '#job-form .modal-actions' ). before ( box );
const update = ()=>{ const supported = $ ( '#job-content-type' ) ? . value === 'photo_source' ; $ ( '#job-thumbnail' ). disabled =! supported ; if ( ! supported ) $ ( '#job-thumbnail' ). checked = false ; $ ( '#job-thumbnail-plan' ). disabled =! $ ( '#job-thumbnail' ). checked ; $ ( '#job-thumbnail-plan' ). required = $ ( '#job-thumbnail' ). checked ;};
$ ( '#job-thumbnail' ). onchange = update ; $ ( '#job-content-type' ) ? . addEventListener ( 'change' , update ); update ();
}
2026-10-23 15:00:29 +08:00
async function watchGalleryPage ( epoch , destinations ){
const offset = state . watchOffset || 0 ;
const [ page , devices ] = await Promise . all ([ api ( '/api/v1/thumbnails?limit=50&offset=' + offset ), api ( '/api/v1/thumbnail-devices' )]);
if ( epoch !== state . epoch ) return ;
const watch = devices . find ( d => d . id === 'watch' );
$ ( '#main' ). innerHTML = `<div class="section-heading"><div class="tabs gallery-tabs"> ${ destinations . map ( d => `<button data-watch-tab=" ${ esc ( d . id ) } " class=" ${ d . id === 'watch' ? 'active' : '' } "> ${ esc ( galleryDestinationName ( d )) } </button>` ). join ( '' ) } </div><button id="watch-refresh"> ${ t ( 'refresh' ) } </button></div><section class="card"><div class="section-heading"><div><h2> ${ tr ( '手表' , 'Watch' ) } · ${ watch . width } × ${ watch . height } </h2><p class="hint"> ${ tr ( '独立小图设备 · 图片已保存在服务器;设备传输尚未接入。' , 'Separate thumbnail device · Images are saved on the server; device transfer is not connected.' ) } </p></div><button id="watch-plans" class="primary"> ${ tr ( '管理小图方案 / 生成图片' , 'Manage plans / Generate images' ) } </button></div><div class="actions"><button id="watch-prev" ${ offset === 0 ? 'disabled' : '' } > ${ tr ( '上一页' , 'Previous page' ) } </button><button id="watch-next" ${ offset + page . limit >= page . total ? 'disabled' : '' } > ${ tr ( '下一页' , 'Next page' ) } </button><span class="hint"> ${ tr ( '共' , 'Total' ) } ${ page . total } ${ tr ( '张小图' , 'thumbnails' ) } </span></div></section><div class="gallery"> ${ page . items . map (( x , i )=> `<article class="card image-card"><button class="image-preview" data-watch-preview=" ${ i } " aria-label=" ${ tr ( '预览' , 'Preview' ) } · ${ esc ( x . name ) } "><img class="thumb" data-src=" ${ esc ( x . url ) } " alt=" ${ esc ( x . name ) } " style="aspect-ratio: ${ x . thumbnail . width } / ${ x . thumbnail . height } ;object-fit:contain"></button><h3> ${ esc ( x . name ) } </h3><small> ${ x . thumbnail . width } × ${ x . thumbnail . height } · ${ esc ( x . thumbnail . plan ? . name || tr ( '默认手表方案' , 'Default watch plan' )) } </small><small> ${ esc ( fmt ( x . thumbnail . generated_at || x . created_at )) } </small><div class="actions"><button data-watch-preview=" ${ i } "> ${ tr ( '预览' , 'Preview' ) } </button><button data-watch-download=" ${ i } "> ${ tr ( '下载图片' , 'Download image' ) } </button><button class="danger" data-watch-delete=" ${ i } "> ${ t ( 'delete' ) } </button></div></article>` ). join ( '' ) } </div> ${ page . items . length ? '' : `<section class="card empty"><strong> ${ tr ( '还没有小图' , 'No thumbnails yet' ) } </strong><p> ${ tr ( '在“小图方案”中生成,或为照片任务勾选生成小图。旧版手动生成的小图仍在原本地图库。' , 'Generate one in Thumbnail plans or enable thumbnails on a photo task. Older manually generated images remain in the original local gallery.' ) } </p></section>` } ` ;
document . querySelectorAll ( '[data-watch-tab]' ). forEach ( b => b . onclick = ()=>{ state . tab = b . dataset . watchTab ; return navigate ( 'gallery' );});
2026-10-23 17:45:30 +08:00
bind ( 'watch-refresh' ,()=> navigate ()); bind ( 'watch-plans' ,()=> navigate ( 'legacy-plans' ));
2026-10-23 15:00:29 +08:00
bind ( 'watch-prev' ,()=>{ state . watchOffset = Math . max ( 0 , offset - 50 ); return navigate ();}); bind ( 'watch-next' ,()=>{ state . watchOffset = offset + 50 ; return navigate ();});
document . querySelectorAll ( '[data-watch-preview]' ). forEach ( b => b . onclick = busy (()=>{ const x = page . items [ b . dataset . watchPreview ]; return openImageViewer ( x . url , x . name );}));
document . querySelectorAll ( '[data-watch-download]' ). forEach ( b => b . onclick = busy (()=>{ const x = page . items [ b . dataset . watchDownload ]; return downloadImage ( x . url , x . name );}));
document . querySelectorAll ( '[data-watch-delete]' ). forEach ( b => b . onclick = ()=>{ const x = page . items [ b . dataset . watchDelete ]; return confirmDialog ( tr ( '删除这张手表小图?不会删除源照片。' , 'Delete this watch thumbnail? The source photo is kept.' ), async ()=>{ await api ( '/api/v1/assets/' + x . asset_id ,{ method : 'DELETE' }); state . watchOffset = Math . max ( 0 , offset - ( page . items . length === 1 ? 50 : 0 )); await navigate ();});});
hydrateImages ();
}