'use strict'; const $ = (id) => document.getElementById(id); const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); const app = { baseUrl: defaultBaseUrl(), token: localStorage.getItem('bat-api-token') || '', rememberToken: localStorage.getItem('bat-api-remember-token') === 'true', refreshTimer: null, resources: { offset: 0, limit: 50, total: 0, items: [], filter: '' }, schedules: [], tasks: [], selectedDaemonTaskId: '', translation: { tasks: [], selectedTask: null, unitRows: [] }, }; function defaultBaseUrl() { const saved = localStorage.getItem('bat-api-base-url'); if (saved) return saved; if (window.location.protocol === 'http:' || window.location.protocol === 'https:') { return window.location.origin; } return 'http://localhost:8080'; } function html(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[char]); } function attr(value) { return html(value).replace(/`/g, '`'); } function text(id, value) { const node = $(id); if (node) node.textContent = value ?? '-'; } function setHTML(id, value) { const node = $(id); if (node) node.innerHTML = value; } function apiBase() { return (app.baseUrl || window.location.origin).replace(/\/+$/, ''); } async function requestJSON(path, options = {}) { const headers = { ...(options.headers || {}) }; if (app.token) headers.Authorization = `Bearer ${app.token}`; if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(apiBase() + path, { ...options, headers }); const body = await response.text(); let parsed = {}; if (body) { try { parsed = JSON.parse(body); } catch (error) { throw new Error(`${path} 返回了非 JSON 响应`); } } if (!response.ok) { const detail = parsed.error?.message || parsed.message || response.statusText; throw new Error(`${path} HTTP ${response.status}: ${detail}`); } return parsed; } function postControl(action, payload = {}) { return requestJSON(`/admin/control/${action}`, { method: 'POST', body: JSON.stringify(payload), }); } function requireToken(scope) { if (app.token) return true; toast(`${scope} 需要 admin token`, 'warn'); return false; } function fmtBytes(value) { if (value === null || value === undefined) return '-'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = Number(value); let idx = 0; while (size >= 1024 && idx < units.length - 1) { size /= 1024; idx += 1; } const digits = idx === 0 || size >= 10 ? 0 : 1; return `${size.toFixed(digits)} ${units[idx]}`; } function fmtTime(seconds) { if (!seconds) return '-'; const date = new Date(Number(seconds) * 1000); return Number.isNaN(date.getTime()) ? String(seconds) : date.toLocaleString(); } function shortHash(value) { if (!value) return '-'; const str = String(value); return str.length > 18 ? `${str.slice(0, 10)}...${str.slice(-6)}` : str; } function statusPill(status) { const value = String(status || 'unknown'); const tone = /(ok|ready|completed|published|healthy|success|translated)/i.test(value) ? 'ok' : /(fail|error|missing|unavailable|rejected)/i.test(value) ? 'bad' : /(run|sync|queued|review|proofreading|progress)/i.test(value) ? 'warn' : 'neutral'; return `${html(value)}`; } function emptyRow(columns, message) { return `${html(message)}`; } function toast(message, tone = 'ok') { const item = document.createElement('div'); item.className = `toast toast-${tone}`; item.textContent = message; $('toastRegion').appendChild(item); window.setTimeout(() => item.remove(), 4200); } function setConnection(status, detail) { const dot = $('connectionDot'); dot.className = `state-dot state-${status}`; text('connectionTitle', status === 'ok' ? '已连接' : status === 'warn' ? '需要 token' : status === 'loading' ? '刷新中' : '未连接'); text('connectionSubtitle', detail || apiBase()); } function setAdminPlaceholders() { text('metricLocalized', 'locked'); text('metricLocalizedNote', '需要 admin token'); text('taskSummary', '需要 admin token'); setHTML('dashboardTaskList', '
需要 admin token
'); text('scheduleSummary', '需要 admin token'); setHTML('scheduleList', '
需要 admin token
'); text('translationTaskSummary', '需要 admin token'); setHTML('translationTaskRows', emptyRow(4, '需要 admin token')); text('daemonTaskSummary', '需要 admin token'); setHTML('daemonTaskRows', emptyRow(5, '需要 admin token')); text('doctorSummary', '需要 admin token'); setHTML('doctorChecks', '
需要 admin token
'); text('logSummary', '需要 admin token'); text('daemonLogBox', ''); } async function refreshAll() { setConnection('loading', apiBase()); await Promise.allSettled([loadHealthAndRelease(), loadResources(true)]); if (!app.token) { setConnection('warn', '未配置 admin token'); setAdminPlaceholders(); return; } await Promise.allSettled([ loadSchedules(), loadDaemonTasks(), loadDoctor(), loadDaemonLogs(), loadTranslationStatus(), loadTranslationTasks(), loadHandoff(), ]); } async function loadHealthAndRelease() { try { const health = await requestJSON('/healthz'); text('metricHealth', health.ready ? 'ready' : 'not ready'); text('metricHealthNote', health.source || 'healthz'); text('metricRpc', health.rpc_available ? 'connected' : 'offline'); text('metricRpcNote', health.socket || '-'); text('metricResources', `${health.present_count ?? 0}/${health.entry_count ?? 0}`); text('metricResourcesNote', `${health.missing_count ?? 0} missing`); setConnection('ok', apiBase()); } catch (error) { setConnection('bad', error.message); toast(error.message, 'bad'); } try { const release = await requestJSON('/v1/release'); const snap = release.snapshot || {}; text('releaseSummary', `${snap.app_version || '-'} / ${snap.bundle_version || '-'} / ${release.source || '-'}`); renderKV('releaseDetails', { version_id: snap.version_id, status_code: snap.status_code || release.status_code, distribution_status_code: snap.distribution_status_code, resource_root: release.resource_root, addressables_root: snap.addressables_root, manifest_version: release.manifest_version, entry_count: release.entry_count, present_count: release.present_count, missing_count: release.missing_count, rpc_available: release.rpc_available, doctor_healthy: release.doctor_healthy, }); } catch (error) { text('releaseSummary', error.message); setHTML('releaseDetails', ''); } } function renderKV(id, values) { const rows = Object.entries(values) .filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([key, value]) => `
${html(key)}
${html(String(value))}
`) .join(''); setHTML(id, rows || '
无数据
'); } async function loadResources(sampleOnly = false) { const limit = sampleOnly ? 8 : app.resources.limit; const offset = sampleOnly ? 0 : app.resources.offset; try { const data = await requestJSON(`/v1/resources?offset=${offset}&limit=${limit}`); const items = data.items || []; if (sampleOnly) { setHTML('dashboardResourceRows', renderResourceRows(items)); text('resourceSampleSummary', `${items.length}/${data.total ?? items.length}`); return; } app.resources.items = items; app.resources.total = data.total ?? items.length; text('resourceListSummary', `${app.resources.total} entries`); renderResourceList(); } catch (error) { const target = sampleOnly ? 'dashboardResourceRows' : 'resourceRows'; setHTML(target, emptyRow(4, error.message)); if (!sampleOnly) text('resourceListSummary', error.message); } } function renderResourceRows(items) { if (!items.length) return emptyRow(4, '没有资源记录'); return items.map((item) => ` ${html(item.relative_path || item.destination || '-')} ${html(fmtBytes(item.bytes))} ${statusPill(item.present && item.size_match ? 'present' : item.present ? 'size_mismatch' : 'missing')} ${html(shortHash(item.blake3))} `).join(''); } function renderResourceList() { const needle = app.resources.filter.toLowerCase(); const items = needle ? app.resources.items.filter((item) => String(item.relative_path || item.destination || '').toLowerCase().includes(needle)) : app.resources.items; setHTML('resourceRows', renderResourceRows(items)); const start = app.resources.total === 0 ? 0 : app.resources.offset + 1; const end = Math.min(app.resources.offset + app.resources.items.length, app.resources.total); text('resourcePagerText', `${start}-${end} / ${app.resources.total}`); $('resourcePrevBtn').disabled = app.resources.offset === 0; $('resourceNextBtn').disabled = app.resources.offset + app.resources.limit >= app.resources.total; } async function loadSchedules() { if (!app.token) return setAdminPlaceholders(); const query = new URLSearchParams(); const id = $('scheduleIdFilter').value.trim(); const group = $('scheduleGroupFilter').value.trim(); const enabled = $('scheduleEnabledFilter').value; if (id) query.set('id', id); if (group) query.set('group', group); if (enabled) query.set('enabled', enabled); try { const data = await requestJSON(`/admin/schedules${query.size ? `?${query}` : ''}`); app.schedules = normalizeList(data, ['schedules', 'items', 'entries']); text('scheduleSummary', `${app.schedules.length} schedules`); setHTML('scheduleList', renderScheduleItems(app.schedules)); } catch (error) { text('scheduleSummary', error.message); setHTML('scheduleList', `
${html(error.message)}
`); } } function normalizeList(data, keys) { if (Array.isArray(data)) return data; for (const key of keys) { if (Array.isArray(data?.[key])) return data[key]; } return []; } function renderScheduleItems(items) { if (!items.length) return '
没有计划
'; return items.map((item) => { const extras = [ item.every_seconds ? `every ${item.every_seconds}s` : '', item.next_run_unix_seconds ? `next ${fmtTime(item.next_run_unix_seconds)}` : '', item.remaining_count ? `left ${item.remaining_count}` : '', ].filter(Boolean).join(' / '); return `
${html(item.id || '-')}
${html(item.group || '-')} ${extras ? `/ ${html(extras)}` : ''}
${statusPill(item.enabled ? 'enabled' : 'disabled')}
`; }).join(''); } function schedulePayload() { const id = $('scheduleIdInput').value.trim(); if (!id) throw new Error('schedule id 不能为空'); const payload = { id, enabled: $('scheduleEnabledInput').checked }; const group = $('scheduleGroupInput').value.trim(); const action = $('scheduleActionInput').value.trim(); const args = $('scheduleArgsInput').value.split(/\n|,/).map((arg) => arg.trim()).filter(Boolean); if (group) payload.group = group; if (action) payload.action = action; if (args.length) payload.args = args; for (const [field, idName] of [ ['delay_seconds', 'scheduleDelayInput'], ['every_seconds', 'scheduleEveryInput'], ['count', 'scheduleCountInput'], ['next_run_unix_seconds', 'scheduleNextRunInput'], ]) { const raw = $(idName).value.trim(); if (raw) payload[field] = Number(raw); } return payload; } async function mutateSchedule(action, payload) { if (!requireToken('调度')) return; try { const result = await postControl(action, payload); toast(`${action} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`); await loadSchedules(); } catch (error) { toast(error.message, 'bad'); } } async function loadDaemonTasks() { if (!app.token) return setAdminPlaceholders(); try { const data = await requestJSON('/admin/tasks'); app.tasks = data.tasks || []; text('daemonTaskSummary', `${app.tasks.length} tasks`); text('taskSummary', `${app.tasks.length} tasks`); setHTML('daemonTaskRows', renderDaemonTaskRows(app.tasks)); setHTML('dashboardTaskList', renderDashboardTaskList(app.tasks.slice(0, 5))); } catch (error) { text('daemonTaskSummary', error.message); text('taskSummary', error.message); setHTML('daemonTaskRows', emptyRow(5, error.message)); setHTML('dashboardTaskList', `
${html(error.message)}
`); } } function renderDaemonTaskRows(tasks) { if (!tasks.length) return emptyRow(5, '没有任务记录'); return tasks.map((task) => ` ${html(task.id)} ${html(task.kind || '-')} ${statusPill(task.status)} ${html(task.stage || '-')} ${html(fmtTime(task.updated_at))} `).join(''); } function renderDashboardTaskList(tasks) { if (!tasks.length) return '
没有任务记录
'; return tasks.map((task) => `
${html(task.kind || task.id)}
${html(task.id)} / ${html(task.stage || '-')}
${statusPill(task.status)}
`).join(''); } async function selectDaemonTask(taskId) { app.selectedDaemonTaskId = taskId; text('daemonTaskDetailSummary', taskId); await Promise.allSettled([loadDaemonTaskStatus(taskId), loadDaemonTaskLogs(taskId)]); } async function loadDaemonTaskStatus(taskId) { try { const task = await requestJSON(`/admin/tasks/status?task_id=${encodeURIComponent(taskId)}`); renderKV('daemonTaskDetails', { id: task.id, kind: task.kind, status: task.status, stage: task.stage, message: task.message, created_at: fmtTime(task.created_at), updated_at: fmtTime(task.updated_at), started_at: fmtTime(task.started_at), finished_at: fmtTime(task.finished_at), error: task.error?.message, }); } catch (error) { setHTML('daemonTaskDetails', `
${html(error.message)}
`); } } async function loadDaemonTaskLogs(taskId) { try { const logs = await requestJSON(`/admin/tasks/logs?task_id=${encodeURIComponent(taskId)}`); text('daemonTaskLogBox', (logs.lines || []).join('\n')); } catch (error) { text('daemonTaskLogBox', error.message); } } async function loadDoctor() { if (!app.token) return setAdminPlaceholders(); try { const data = await requestJSON('/admin/diagnostics'); text('doctorSummary', data.healthy ? 'healthy' : (data.status || 'unhealthy')); const checks = data.checks || []; setHTML('doctorChecks', checks.length ? checks.map((check) => `
${html(check.name || '-')}
${html(check.message || '-')}
${statusPill(check.ok ? 'ok' : 'failed')}
`).join('') : '
没有诊断项
'); } catch (error) { text('doctorSummary', error.message); setHTML('doctorChecks', `
${html(error.message)}
`); } } async function loadDaemonLogs() { if (!app.token) return setAdminPlaceholders(); const tail = Math.min(2000, Math.max(1, Number($('logTailInput').value || 200))); try { const data = await requestJSON(`/admin/logs?tail=${tail}`); text('logSummary', `${data.returned_lines ?? 0}/${data.total_lines ?? 0} lines`); text('daemonLogBox', data.content || ''); } catch (error) { text('logSummary', error.message); text('daemonLogBox', error.message); } } async function loadTranslationStatus() { if (!app.token) return setAdminPlaceholders(); try { const data = await requestJSON('/admin/translation/status'); const code = data.status_code || data.localized_status_code || data.localized_release_status || data.status || 'unknown'; text('metricLocalized', code); text('metricLocalizedNote', data.current_localized_release_id || data.localized_release_id || data.message || '-'); text('localizedSummary', `${code} / ${data.localized_release_id || data.current_localized_release_id || '-'}`); } catch (error) { text('metricLocalized', 'error'); text('metricLocalizedNote', error.message); text('localizedSummary', error.message); } } async function loadHandoff() { if (!app.token) return setAdminPlaceholders(); try { const data = await requestJSON('/admin/translation/handoff'); const handoff = data.handoff || data; text('handoffSummary', handoff.job?.status || data.status || (data.available === false ? 'unavailable' : 'available')); renderKV('handoffDetails', { available: data.available, current_version_id: data.current_version_id, job_id: handoff.job?.job_id, job_status: handoff.job?.status, unit_count: handoff.job?.unit_count, provider_runs: handoff.provider_runs?.length, updated_unix_seconds: fmtTime(handoff.job?.updated_unix_seconds), }); } catch (error) { text('handoffSummary', error.message); setHTML('handoffDetails', ''); } } async function loadTranslationTasks() { if (!app.token) return setAdminPlaceholders(); const query = new URLSearchParams({ offset: '0', limit: '50' }); const workerStatus = $('translationStatusFilter').value; const destination = $('translationDestinationFilter').value.trim(); const taskId = $('translationTaskFilter').value.trim(); if (workerStatus) query.set('worker_status', workerStatus); if (destination) query.set('destination', destination); if (taskId) query.set('task_id', taskId); try { const data = await requestJSON(`/admin/translation/tasks?${query}`); app.translation.tasks = data.entries || []; text('translationTaskSummary', `${data.total_entries ?? app.translation.tasks.length} tasks`); setHTML('translationTaskRows', renderTranslationTaskRows(app.translation.tasks)); } catch (error) { text('translationTaskSummary', error.message); setHTML('translationTaskRows', emptyRow(4, error.message)); } } function taskCore(entry) { return entry?.task || entry || {}; } function taskID(entry) { return taskCore(entry).task_id || entry?.task_id || entry?.unit_id || ''; } function renderTranslationTaskRows(tasks) { if (!tasks.length) return emptyRow(4, '没有翻译任务'); return tasks.map((entry) => { const task = taskCore(entry); const resultCount = (entry.translation_results || task.translation_results || []).length; return ` ${html(taskID(entry))} ${html(task.destination || '-')}
${html(task.archive_entry || '')}
${statusPill(entry.task_status || entry.worker_status || task.status)} ${html(String(resultCount))} `; }).join(''); } async function selectTranslationTask(entry) { app.translation.selectedTask = entry; const task = taskCore(entry); text('translationDetailSummary', taskID(entry)); $('manualProviderRunInput').value = entry.provider_run_id || ''; await loadTextUnitsForTask(entry); } async function loadTextUnitsForTask(entry) { const task = taskCore(entry); const existingResults = new Map((entry.translation_results || []).map((result) => [result.unit_id, result])); let units = []; if (task.destination) { const query = new URLSearchParams({ destination: task.destination, limit: '1000' }); if (task.archive_entry) query.set('archive_entry', task.archive_entry); try { const data = await requestJSON(`/admin/parse/text-units?${query}`); units = data.entries || []; } catch (error) { toast(error.message, 'bad'); } } if (!units.length && existingResults.size) { units = Array.from(existingResults.values()).map((result) => ({ id: result.unit_id, source_text: result.source_text, destination: task.destination, archive_entry: task.archive_entry, })); } app.translation.unitRows = units.map((unit) => { const id = unit.id || unit.unit_id; const existing = existingResults.get(id) || {}; return { id, sourceText: unit.source_text || existing.source_text || '', translatedText: existing.translated_text || '', destination: unit.destination || task.destination || '', archiveEntry: unit.archive_entry || task.archive_entry || '', serializedFile: unit.serialized_file || '', pathID: unit.path_id, classID: unit.class_id, fieldPath: unit.field_path || '', assetName: unit.asset_name || '', format: unit.format || '', }; }); renderUnitEditor(); } function renderUnitEditor() { const rows = app.translation.unitRows; if (!app.translation.selectedTask) { setHTML('unitEditor', '
选择一个翻译任务
'); return; } if (!rows.length) { setHTML('unitEditor', '
当前任务没有可显示 TextUnit 明细
'); return; } setHTML('unitEditor', rows.map((row, index) => `
${html(row.id)}
${html(row.destination)}${row.archiveEntry ? ` / ${html(row.archiveEntry)}` : ''}
${statusPill(row.translatedText ? 'reviewing' : 'empty')}
field: ${html(row.fieldPath || '-')} asset: ${html(row.assetName || '-')} path_id: ${html(row.pathID ?? '-')} class_id: ${html(row.classID ?? '-')} format: ${html(row.format || '-')}
`).join('')); } async function saveManualResults() { if (!requireToken('人工校对')) return; const entry = app.translation.selectedTask; if (!entry) return toast('请选择翻译任务', 'warn'); const results = $$('.unit-translation').map((editor) => { const row = app.translation.unitRows[Number(editor.dataset.unitIndex)]; return { unit_id: row.id, source_text: row.sourceText, translated_text: editor.value, }; }).filter((result) => result.translated_text.trim() !== ''); if (!results.length) return toast('没有可保存的译文', 'warn'); const payload = { task_id: taskID(entry), status: 'completed', provider: 'manual', translation_results: results, }; const providerRunID = $('manualProviderRunInput').value.trim(); if (providerRunID) payload.provider_run_id = providerRunID; try { await postControl('translation-task-update', payload); toast(`已保存 ${results.length} 条校对结果`); await Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()]); } catch (error) { toast(error.message, 'bad'); } } async function runTranslationWorker() { if (!requireToken('Provider worker')) return; const payload = { provider: $('workerProviderInput').value, concurrency: Number($('workerConcurrencyInput').value || 8), max_attempts: Number($('workerMaxAttemptsInput').value || 3), lease_seconds: Number($('workerLeaseInput').value || 300), retry_backoff_seconds: Number($('workerBackoffInput').value || 5), }; const maxTasks = $('workerMaxTasksInput').value.trim(); const workerId = $('workerIdInput').value.trim(); const fixture = $('workerFixtureInput').value.trim(); if (maxTasks) payload.max_tasks = Number(maxTasks); if (workerId) payload.worker_id = workerId; if (fixture) payload.fixture_path = fixture; try { const result = await postControl('translation-worker-run', payload); toast(`translation-worker-run accepted: ${result.result?.task_id || 'queued'}`); await loadDaemonTasks(); } catch (error) { toast(error.message, 'bad'); } } async function publishLocalized() { if (!requireToken('汉化发布')) return; const payload = { from_worker: true, force: $('localizedForceInput').checked }; const releaseID = $('localizedReleaseInput').value.trim(); if (releaseID) payload.localized_release_id = releaseID; try { await postControl('localized-publish', payload); toast('localized-publish accepted'); await loadTranslationStatus(); } catch (error) { toast(error.message, 'bad'); } } async function rollbackLocalized() { if (!requireToken('汉化回滚')) return; const payload = {}; const releaseID = $('localizedReleaseInput').value.trim(); if (releaseID) payload.localized_release_id = releaseID; if (!window.confirm('确认回滚当前汉化 release?')) return; try { await postControl('localized-rollback', payload); toast('localized-rollback accepted'); await loadTranslationStatus(); } catch (error) { toast(error.message, 'bad'); } } function switchView(view) { $$('.nav-item').forEach((item) => item.classList.toggle('is-active', item.dataset.view === view)); $$('.view').forEach((section) => section.classList.toggle('is-active', section.id === `view-${view}`)); const active = $(`view-${view}`); text('viewTitle', active?.dataset.title || view); text('viewSubtitle', active?.dataset.subtitle || ''); if (view === 'resources') loadResources(); if (view === 'schedules') loadSchedules(); if (view === 'translation') Promise.allSettled([loadTranslationTasks(), loadTranslationStatus(), loadHandoff()]); if (view === 'tasks') loadDaemonTasks(); if (view === 'logs') Promise.allSettled([loadDoctor(), loadDaemonLogs()]); } function bindEvents() { $$('.nav-item').forEach((item) => item.addEventListener('click', () => switchView(item.dataset.view))); $$('[data-jump]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.jump))); $('refreshAllBtn').addEventListener('click', refreshAll); $('autoRefreshInput').addEventListener('change', configureAutoRefresh); $('resourceLimitInput').addEventListener('change', () => { app.resources.limit = Number($('resourceLimitInput').value || 50); app.resources.offset = 0; loadResources(); }); $('resourceFilterInput').addEventListener('input', () => { app.resources.filter = $('resourceFilterInput').value.trim(); renderResourceList(); }); $('resourcePrevBtn').addEventListener('click', () => { app.resources.offset = Math.max(0, app.resources.offset - app.resources.limit); loadResources(); }); $('resourceNextBtn').addEventListener('click', () => { app.resources.offset += app.resources.limit; loadResources(); }); $$('[data-control]').forEach((button) => button.addEventListener('click', async () => { if (!requireToken(button.dataset.control)) return; try { const result = await postControl(button.dataset.control, button.dataset.control === 'sync' ? { force: false } : {}); toast(`${button.dataset.control} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`); await refreshAll(); } catch (error) { toast(error.message, 'bad'); } })); $('scheduleRefreshBtn').addEventListener('click', loadSchedules); ['scheduleIdFilter', 'scheduleGroupFilter', 'scheduleEnabledFilter'].forEach((id) => $(id).addEventListener('input', loadSchedules)); $('scheduleAddBtn').addEventListener('click', () => { try { mutateSchedule('schedule-add', schedulePayload()); } catch (error) { toast(error.message, 'warn'); } }); $('scheduleUpdateBtn').addEventListener('click', () => { try { mutateSchedule('schedule-update', schedulePayload()); } catch (error) { toast(error.message, 'warn'); } }); $('scheduleList').addEventListener('click', (event) => { const runID = event.target.closest('[data-schedule-run]')?.dataset.scheduleRun; const removeID = event.target.closest('[data-schedule-remove]')?.dataset.scheduleRemove; if (runID) mutateSchedule('schedule-run', { id: runID, force: true }); if (removeID && window.confirm(`删除计划 ${removeID}?`)) mutateSchedule('schedule-remove', { id: removeID }); }); $('daemonTaskRefreshBtn').addEventListener('click', loadDaemonTasks); $('daemonTaskRows').addEventListener('click', (event) => { const row = event.target.closest('[data-task-id]'); if (row) selectDaemonTask(row.dataset.taskId); }); $('daemonTaskCancelBtn').addEventListener('click', async () => { if (!app.selectedDaemonTaskId || !requireToken('任务取消')) return; try { await postControl('task-cancel', { task_id: app.selectedDaemonTaskId }); toast('task.cancel accepted'); await selectDaemonTask(app.selectedDaemonTaskId); } catch (error) { toast(error.message, 'bad'); } }); $('doctorRefreshBtn').addEventListener('click', () => Promise.allSettled([loadDoctor(), loadDaemonLogs()])); $('logTailInput').addEventListener('change', loadDaemonLogs); $('translationRefreshBtn').addEventListener('click', () => Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()])); ['translationStatusFilter', 'translationDestinationFilter', 'translationTaskFilter'].forEach((id) => $(id).addEventListener('input', loadTranslationTasks)); $('translationTaskRows').addEventListener('click', (event) => { const row = event.target.closest('[data-translation-task-id]'); if (!row) return; const entry = app.translation.tasks.find((item) => taskID(item) === row.dataset.translationTaskId); if (entry) selectTranslationTask(entry); }); $('workerRunBtn').addEventListener('click', runTranslationWorker); $('manualSaveBtn').addEventListener('click', saveManualResults); $('proofreadBtn').addEventListener('click', async () => { if (!requireToken('人工校对状态')) return; try { await postControl('translation-proofread', {}); toast('translation.proofread accepted'); await loadTranslationStatus(); } catch (error) { toast(error.message, 'bad'); } }); $('localizedPublishBtn').addEventListener('click', publishLocalized); $('localizedRollbackBtn').addEventListener('click', rollbackLocalized); $('saveSettingsBtn').addEventListener('click', () => { app.baseUrl = $('baseUrlInput').value.trim() || defaultBaseUrl(); app.token = $('tokenInput').value.trim(); app.rememberToken = $('rememberTokenInput').checked; localStorage.setItem('bat-api-base-url', app.baseUrl); localStorage.setItem('bat-api-remember-token', String(app.rememberToken)); if (app.rememberToken && app.token) localStorage.setItem('bat-api-token', app.token); if (!app.rememberToken) localStorage.removeItem('bat-api-token'); refreshAll(); }); $('forgetSettingsBtn').addEventListener('click', () => { localStorage.removeItem('bat-api-base-url'); localStorage.removeItem('bat-api-token'); localStorage.removeItem('bat-api-remember-token'); app.baseUrl = defaultBaseUrl(); app.token = ''; app.rememberToken = false; syncSettingsForm(); refreshAll(); }); } function syncSettingsForm() { $('baseUrlInput').value = app.baseUrl; $('tokenInput').value = app.token; $('rememberTokenInput').checked = app.rememberToken; } function configureAutoRefresh() { if (app.refreshTimer) window.clearInterval(app.refreshTimer); app.refreshTimer = null; if ($('autoRefreshInput').checked) { app.refreshTimer = window.setInterval(refreshAll, 15000); } } syncSettingsForm(); bindEvents(); configureAutoRefresh(); refreshAll();