Files
BlueArchiveToolkit/web/app.js
T
nyaKazuha 4ed81f0030
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
feat(bat-api): 实现内嵌 dashboard
Closes #46
2026-08-31 23:17:23 +08:00

862 lines
32 KiB
JavaScript

'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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[char]);
}
function attr(value) {
return html(value).replace(/`/g, '&#96;');
}
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 `<span class="pill pill-${tone}">${html(value)}</span>`;
}
function emptyRow(columns, message) {
return `<tr><td colspan="${columns}" class="empty">${html(message)}</td></tr>`;
}
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', '<div class="empty-block">需要 admin token</div>');
text('scheduleSummary', '需要 admin token');
setHTML('scheduleList', '<div class="empty-block">需要 admin token</div>');
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', '<div class="empty-block">需要 admin token</div>');
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]) => `<dt>${html(key)}</dt><dd>${html(String(value))}</dd>`)
.join('');
setHTML(id, rows || '<dd class="empty">无数据</dd>');
}
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) => `
<tr>
<td><code>${html(item.relative_path || item.destination || '-')}</code></td>
<td>${html(fmtBytes(item.bytes))}</td>
<td>${statusPill(item.present && item.size_match ? 'present' : item.present ? 'size_mismatch' : 'missing')}</td>
<td><code>${html(shortHash(item.blake3))}</code></td>
</tr>
`).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', `<div class="empty-block">${html(error.message)}</div>`);
}
}
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 '<div class="empty-block">没有计划</div>';
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 `
<article class="list-item">
<div>
<strong>${html(item.id || '-')}</strong>
<div class="muted">${html(item.group || '-')} ${extras ? `/ ${html(extras)}` : ''}</div>
</div>
<div class="row-actions">
${statusPill(item.enabled ? 'enabled' : 'disabled')}
<button class="btn btn-small" type="button" data-schedule-run="${attr(item.id || '')}">运行</button>
<button class="btn btn-small btn-danger" type="button" data-schedule-remove="${attr(item.id || '')}">删除</button>
</div>
</article>
`;
}).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', `<div class="empty-block">${html(error.message)}</div>`);
}
}
function renderDaemonTaskRows(tasks) {
if (!tasks.length) return emptyRow(5, '没有任务记录');
return tasks.map((task) => `
<tr class="clickable" data-task-id="${attr(task.id)}">
<td><code>${html(task.id)}</code></td>
<td>${html(task.kind || '-')}</td>
<td>${statusPill(task.status)}</td>
<td>${html(task.stage || '-')}</td>
<td>${html(fmtTime(task.updated_at))}</td>
</tr>
`).join('');
}
function renderDashboardTaskList(tasks) {
if (!tasks.length) return '<div class="empty-block">没有任务记录</div>';
return tasks.map((task) => `
<article class="list-item compact-item">
<div>
<strong>${html(task.kind || task.id)}</strong>
<div class="muted">${html(task.id)} / ${html(task.stage || '-')}</div>
</div>
${statusPill(task.status)}
</article>
`).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', `<dd class="empty">${html(error.message)}</dd>`);
}
}
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) => `
<article class="list-item compact-item">
<div>
<strong>${html(check.name || '-')}</strong>
<div class="muted">${html(check.message || '-')}</div>
</div>
${statusPill(check.ok ? 'ok' : 'failed')}
</article>
`).join('') : '<div class="empty-block">没有诊断项</div>');
} catch (error) {
text('doctorSummary', error.message);
setHTML('doctorChecks', `<div class="empty-block">${html(error.message)}</div>`);
}
}
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 `
<tr class="clickable" data-translation-task-id="${attr(taskID(entry))}">
<td><code>${html(taskID(entry))}</code></td>
<td>${html(task.destination || '-')}<div class="muted">${html(task.archive_entry || '')}</div></td>
<td>${statusPill(entry.task_status || entry.worker_status || task.status)}</td>
<td>${html(String(resultCount))}</td>
</tr>
`;
}).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', '<div class="empty-block">选择一个翻译任务</div>');
return;
}
if (!rows.length) {
setHTML('unitEditor', '<div class="empty-block">当前任务没有可显示 TextUnit 明细</div>');
return;
}
setHTML('unitEditor', rows.map((row, index) => `
<article class="unit-card">
<div class="unit-head">
<div>
<strong>${html(row.id)}</strong>
<div class="muted">${html(row.destination)}${row.archiveEntry ? ` / ${html(row.archiveEntry)}` : ''}</div>
</div>
${statusPill(row.translatedText ? 'reviewing' : 'empty')}
</div>
<div class="unit-meta">
<span>field: ${html(row.fieldPath || '-')}</span>
<span>asset: ${html(row.assetName || '-')}</span>
<span>path_id: ${html(row.pathID ?? '-')}</span>
<span>class_id: ${html(row.classID ?? '-')}</span>
<span>format: ${html(row.format || '-')}</span>
</div>
<label class="source-label">Source
<textarea readonly rows="3">${html(row.sourceText)}</textarea>
</label>
<label>Current translation
<textarea class="unit-translation" data-unit-index="${index}" rows="4">${html(row.translatedText)}</textarea>
</label>
</article>
`).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();