feat: paginate filter and delete operation history
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m29s

This commit is contained in:
渡口浪人
2026-10-20 16:53:27 +08:00
parent c3bed2edce
commit 4771db6f2a
14 changed files with 808 additions and 20 deletions
+7 -2
View File
@@ -14,6 +14,7 @@ from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.exceptions import HTTPException
from pigway_frame_flow.api.history import install_history
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client
from pigway_frame_flow.core.registry import DeviceRegistry
@@ -132,6 +133,7 @@ def create_app(settings=None, start_worker=True):
)
install_extensions(app, service)
install_accounts(app, db)
install_history(app, service)
app.state.service = service
app.state.worker_enabled = start_worker
app.state.scheduler = scheduler
@@ -842,7 +844,7 @@ def create_app(settings=None, start_worker=True):
return [
service.operation(x["id"])
for x in db.all(
"SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?",
"SELECT id FROM operations WHERE (? OR client_id=?) AND NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=operations.id) ORDER BY created_at DESC,rowid DESC LIMIT ?",
("admin" in request.state.permissions, request.state.client_id, limit),
)
]
@@ -1001,7 +1003,10 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("history:read"))],
)
def history(limit: int = Query(default=100, ge=1, le=200)):
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
return db.all(
"SELECT * FROM history WHERE NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=history.operation_id) ORDER BY id DESC LIMIT ?",
(limit,),
)
@app.get(
"/api/v1/settings",
+121
View File
@@ -0,0 +1,121 @@
"""Paginated history without removing operation receipts used by other services."""
from datetime import UTC, date, datetime, time, timedelta
from zoneinfo import ZoneInfo
from fastapi import Depends, Query, Request
from pydantic import BaseModel, Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Operation, OperationStatus
class HistoryPage(BaseModel):
items: list[Operation]
total: int
page: int
page_size: int
class HistoryDelete(BaseModel):
operation_ids: list[str] = Field(min_length=1, max_length=200)
def install_history(app, service):
db = service.db
@app.get(
"/api/v1/operation-history",
response_model=HistoryPage,
tags=["Operations"],
dependencies=[Depends(require("history:read"))],
)
def list_history(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: OperationStatus | None = None,
action: str = Query("", max_length=80),
device_id: str = Query("", max_length=120),
q: str = Query("", max_length=200),
start: date | None = None,
end: date | None = None,
):
if start and end and start > end:
raise FlowError(
E.INVALID_REQUEST, "Start date must not follow end date", 422
)
clauses = [
"NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=o.id)"
]
args: list = []
if "admin" not in request.state.permissions:
clauses.append("o.client_id=?")
args.append(request.state.client_id)
for column, value in [
("status", status),
("action", action),
("device_id", device_id),
]:
if value:
clauses.append(f"o.{column}=?")
args.append(value)
if q.strip():
clauses.append(
"instr(lower(o.id||' '||o.request_id||' '||o.client_id||' '||coalesce(o.result,'')),lower(?))>0"
)
args.append(q.strip())
for day, operator, offset in [(start, ">=", 0), (end, "<", 1)]:
if day:
boundary = datetime.combine(
day, time(), ZoneInfo("Asia/Shanghai")
) + timedelta(days=offset)
clauses.append(f"o.created_at{operator}?")
args.append(boundary.astimezone(UTC).isoformat())
where = " AND ".join(clauses)
# Keep count and rows consistent if a worker finishes or a deletion occurs.
with db.lock:
total = db.one(
"SELECT count(*) AS total FROM operations o WHERE " + where, args
)["total"]
page = min(page, max(1, (total + page_size - 1) // page_size))
rows = db.all(
"SELECT o.id FROM operations o WHERE "
+ where
+ " ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?",
[*args, page_size, (page - 1) * page_size],
)
return HistoryPage(
items=[service.operation(row["id"]) for row in rows],
total=total,
page=page,
page_size=page_size,
)
@app.delete(
"/api/v1/operation-history",
tags=["Operations"],
dependencies=[Depends(require("admin"))],
)
def delete_history(payload: HistoryDelete):
ids = list(dict.fromkeys(payload.operation_ids))
placeholders = ",".join("?" for _ in ids)
with db.transaction() as connection:
rows = connection.execute(
f"SELECT id,status FROM operations WHERE id IN ({placeholders})", ids
).fetchall()
if len(rows) != len(ids):
raise FlowError(E.NOT_FOUND, "History record not found", 404)
if any(
row["status"] not in {"COMPLETED", "FAILED", "UNKNOWN"} for row in rows
):
raise FlowError(E.CONFLICT, "Running operations cannot be deleted", 409)
before = connection.total_changes
connection.executemany(
"INSERT OR IGNORE INTO hidden_history(operation_id) VALUES(?)",
[(key,) for key in ids],
)
deleted = connection.total_changes - before
return {"deleted": deleted}
+1
View File
@@ -45,6 +45,7 @@ class Database:
last_run TEXT, last_result TEXT);
CREATE TABLE IF NOT EXISTS job_runs (job_id TEXT, scheduled_at TEXT,
operation_id TEXT, PRIMARY KEY(job_id,scheduled_at));
CREATE TABLE IF NOT EXISTS hidden_history (operation_id TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, client_id TEXT,
request_id TEXT, operation_id TEXT, device_id TEXT, action TEXT,
result TEXT, timestamp TEXT);
+1 -1
View File
@@ -151,7 +151,7 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
};
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
+24 -1
View File
@@ -45,7 +45,8 @@ async function weatherSettings(){
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
}
async function enhanceHistory(){
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
historyControls();
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);const op=state.historyPageData?.items.find(x=>x.operation_id===operationId);if(op&&['COMPLETED','FAILED','UNKNOWN'].includes(op.status)){const remove=document.createElement('button');remove.textContent=t('delete');remove.className='danger';remove.onclick=()=>deleteHistory([operationId]);actions.append(remove);}button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
}
@@ -166,3 +167,25 @@ async function previewSource(id,item_id=null){
async function sourceJobForm(id){
await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50});
}
function historyQuery(){return new URLSearchParams({...state.historyFilters,page:state.historyPage||1,page_size:state.historyPageSize||20});}
function historyControls(){
const data=state.historyPageData;if(!data)return;
const filters=state.historyFilters||{};
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
const wrap=$('#main .table-wrap');wrap.before(form);
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
$('#history-end').oninput=()=>$('#history-end').setCustomValidity('');$('#history-start').oninput=()=>$('#history-end').setCustomValidity('');
bind('history-reset',async()=>{state.historyFilters={};state.historyPage=1;await navigate();});
const bar=document.createElement('div');bar.className='actions history-toolbar';bar.innerHTML=`<button id="history-all">${t('selectAll')}</button><button id="history-invert">${t('invertSelection')}</button><button id="history-delete" class="danger" disabled>${t('deleteSelected')}</button><span id="history-selected" class="hint" role="status"></span>`;wrap.before(bar);
const pager=document.createElement('div');pager.className='actions history-pagination';const pages=Math.max(1,Math.ceil(data.total/data.page_size));pager.innerHTML=`<span>${t('historyTotal').replace('{count}',data.total)} · ${data.page} / ${pages}</span><button id="history-prev" ${data.page<=1?'disabled':''}>${t('previousPage')}</button><button id="history-next" ${data.page>=pages?'disabled':''}>${t('nextPage')}</button><label for="history-page-size">${t('pageSize')}</label><select id="history-page-size">${[20,50,100].map(n=>`<option value="${n}" ${n===data.page_size?'selected':''}>${n}</option>`).join('')}</select>`;wrap.after(pager);
bind('history-prev',async()=>{state.historyPage=data.page-1;await navigate();});bind('history-next',async()=>{state.historyPage=data.page+1;await navigate();});$('#history-page-size').onchange=busy(async()=>{state.historyPageSize=Number($('#history-page-size').value);state.historyPage=1;await navigate();});
const checkboxes=()=>[...document.querySelectorAll('.history-select:not(:disabled)')];
const update=()=>{const selected=checkboxes().filter(x=>x.checked);$('#history-delete').disabled=!selected.length;$('#history-selected').textContent=t('historySelected').replace('{count}',selected.length);};
for(const op of data.items){const row=[...wrap.querySelectorAll('tbody tr')].find(r=>r.dataset.operationId===op.operation_id);if(!row)continue;const box=document.createElement('input');box.type='checkbox';box.className='history-select';box.value=op.operation_id;box.setAttribute('aria-label',t('selectHistory')+' '+op.operation_id);box.disabled=!['COMPLETED','FAILED','UNKNOWN'].includes(op.status);box.onchange=update;row.cells[0].prepend(box);}
bind('history-all',()=>{const boxes=checkboxes(),checked=boxes.length>0&&boxes.every(x=>x.checked);boxes.forEach(x=>x.checked=!checked);update();});bind('history-invert',()=>{checkboxes().forEach(x=>x.checked=!x.checked);update();});bind('history-delete',()=>deleteHistory(checkboxes().filter(x=>x.checked).map(x=>x.value)));update();
}
async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').replace('{count}',ids.length),async()=>{await api('/api/v1/operation-history',{method:'DELETE',body:{operation_ids:ids}});await navigate();});}
+3
View File
@@ -149,3 +149,6 @@ Object.assign(window.MESSAGES.zh,{signIn:'登录',livingRoom:'客厅'});Object.a
Object.assign(MESSAGES.zh,{RESTORE_IMAGE_PROTECTED:'这张图片仍用于恢复显示,暂时不能删除。请先结束临时展示,或显示另一张图片以更新恢复目标。'});
Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed to restore the display. End the temporary display or show another image to update the restore target before deleting it.'});
Object.assign(MESSAGES.zh,{searchHistory:'搜索图片名称、请求 ID、任务 ID 或来源',allStatuses:'全部状态',allActions:'全部操作',allDevices:'全部设备',startDate:'开始日期',endDate:'结束日期',filter:'筛选',resetFilters:'重置筛选',dateRangeInvalid:'结束日期不能早于开始日期',historyTotal:'共 {count} 条',pageSize:'每页条数',historySelected:'已选 {count} 条(仅当前页)',selectHistory:'选择记录',deleteHistoryConfirm:'确认删除这 {count} 条历史记录?记录将从历史列表移除,不会删除图片或取消任务。此操作无法撤销。'});
Object.assign(MESSAGES.en,{searchHistory:'Search image name, request ID, operation ID or client',allStatuses:'All statuses',allActions:'All actions',allDevices:'All devices',startDate:'Start date',endDate:'End date',filter:'Filter',resetFilters:'Reset filters',dateRangeInvalid:'End date must not precede start date',historyTotal:'{count} records',pageSize:'Rows per page',historySelected:'{count} selected (current page only)',selectHistory:'Select record',deleteHistoryConfirm:'Delete these {count} history records? This removes them from history without deleting images or cancelling tasks. This cannot be undone.'});
+10 -1
View File
@@ -49,7 +49,7 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:180px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:190px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
@@ -238,3 +238,12 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
/* Stacked wordmark keeps the product name readable within the sidebar. */
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
#history-filters .field{min-width:0;margin:0}
#history-filters>.actions{grid-column:1/-1}
.history-select{margin:0 10px 0 0;vertical-align:middle}
.history-actions button+button{margin-left:10px}
.history-pagination select{width:auto}
@media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}}