mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
862 lines
32 KiB
JavaScript
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) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
})[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 `<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();
|