mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:14:55 +08:00
+19
-8
@@ -1,17 +1,28 @@
|
||||
# bat-api Admin Panel
|
||||
# bat-api Dashboard
|
||||
|
||||
本目录预留给后续 `bat-api` 管理面板。当前后端已提供只读入口
|
||||
`GET /admin/`,返回 health、ready、bootstrap、release、resources 和 OpenAPI
|
||||
链接。
|
||||
当前稳定交付的 dashboard 静态资产位于 `web/` 根目录,并由 Go embed 挂载到
|
||||
`GET /admin/dashboard/`。本目录只保留后续完整管理后台的说明入口。`GET /admin/`
|
||||
仍返回 health、ready、bootstrap、release、resources、OpenAPI、dashboard 和可用
|
||||
控制链接。
|
||||
|
||||
需要配置 `BAT_API_AUTH_TOKEN` 的接口:
|
||||
|
||||
- `GET /admin/diagnostics`:转发 `daemon.doctor`,读取 daemon 诊断。
|
||||
- `GET /admin/logs`:转发 `daemon.logs`,按 `tail` 读取 daemon 日志尾部。
|
||||
- `GET /admin/tasks`、`GET /admin/tasks/status`、`GET /admin/tasks/logs`:
|
||||
转发 `task.*` 查询任务列表、单项状态和任务日志。
|
||||
- `GET /admin/schedules`:转发 Rust 持有的 schedule 查询,支持 `id`、`group`
|
||||
和 `enabled` 过滤。
|
||||
- `POST /admin/control/schedule-{add,update,remove,run}`:转发
|
||||
`schedule.*` 计划控制。
|
||||
- `GET /admin/parse/status`、`GET /admin/parse/text-units`、
|
||||
`GET /admin/parse/errors`:转发 `parse.*` 只读查询当前 release 的解析状态、
|
||||
TextUnit 明细和解析错误。
|
||||
- `POST /admin/control/task-cancel`:转发 `task.cancel`,请求字段为 `task_id`。
|
||||
- `POST /admin/control/translation-task-update`:转发
|
||||
`translation.task.update`,供外部 provider 流程回写任务状态。
|
||||
`translation.task.update`,供外部 provider 流程或人工校对流程回写任务状态;
|
||||
当 `status=completed` 时可带 `provider`、`provider_run_id` 和
|
||||
`translation_results`。
|
||||
- `POST /admin/control/translation-worker-run`:转发
|
||||
`translation.worker.run`,触发 Rust provider worker。
|
||||
- `POST /admin/control/translation-proofread`:转发
|
||||
@@ -27,6 +38,6 @@
|
||||
- `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前
|
||||
release 的完整翻译交接视图。
|
||||
|
||||
正式前端必须复用资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应
|
||||
`Cache-Control: no-store` 策略。静态前端资产仍为 not implemented,尚未作为
|
||||
本目录的稳定交付物。当前组件边界见 `docs/reports/GO_STATUS.md`。
|
||||
内嵌 dashboard 自身允许免 token 读取静态资产;所有写操作和 Rust 状态查询仍复用
|
||||
资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应 `Cache-Control: no-store`
|
||||
策略。当前组件边界见 `docs/reports/GO_STATUS.md`。
|
||||
|
||||
+861
@@ -0,0 +1,861 @@
|
||||
'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();
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets 是 bat-api dashboard 的无构建静态资源。
|
||||
//
|
||||
//go:embed index.html app.js styles.css
|
||||
var Assets embed.FS
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>bat-api Dashboard - BlueArchiveToolkit</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">BA</div>
|
||||
<div>
|
||||
<div class="brand-title">bat-api Dashboard</div>
|
||||
<div class="brand-subtitle">BlueArchiveToolkit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav" aria-label="dashboard navigation">
|
||||
<button class="nav-item is-active" type="button" data-view="dashboard">总览</button>
|
||||
<button class="nav-item" type="button" data-view="resources">资源</button>
|
||||
<button class="nav-item" type="button" data-view="schedules">调度</button>
|
||||
<button class="nav-item" type="button" data-view="translation">翻译</button>
|
||||
<button class="nav-item" type="button" data-view="tasks">任务</button>
|
||||
<button class="nav-item" type="button" data-view="logs">日志</button>
|
||||
<button class="nav-item" type="button" data-view="settings">设置</button>
|
||||
</nav>
|
||||
|
||||
<div class="connection-box">
|
||||
<span class="state-dot" id="connectionDot"></span>
|
||||
<div>
|
||||
<div class="connection-title" id="connectionTitle">未连接</div>
|
||||
<div class="connection-subtitle" id="connectionSubtitle">等待刷新</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1 id="viewTitle">总览</h1>
|
||||
<p id="viewSubtitle">读取 bat-api 与 Rust daemon 的当前状态。</p>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<label class="inline-check">
|
||||
<input id="autoRefreshInput" type="checkbox" checked>
|
||||
自动刷新
|
||||
</label>
|
||||
<button class="btn btn-primary" id="refreshAllBtn" type="button">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="view is-active" id="view-dashboard" data-title="总览" data-subtitle="读取 bat-api 与 Rust daemon 的当前状态。">
|
||||
<div class="status-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-label">bat-api</div>
|
||||
<div class="metric-value" id="metricHealth">unknown</div>
|
||||
<div class="metric-note" id="metricHealthNote">-</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Rust RPC</div>
|
||||
<div class="metric-value" id="metricRpc">unknown</div>
|
||||
<div class="metric-note" id="metricRpcNote">-</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">资源完整度</div>
|
||||
<div class="metric-value" id="metricResources">-</div>
|
||||
<div class="metric-note" id="metricResourcesNote">-</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">汉化状态</div>
|
||||
<div class="metric-value" id="metricLocalized">unknown</div>
|
||||
<div class="metric-note" id="metricLocalizedNote">需要 admin token</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid two-col">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>当前 release</h2>
|
||||
<p id="releaseSummary">尚未读取</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="kv-list" id="releaseDetails"></dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>最近任务</h2>
|
||||
<p id="taskSummary">需要 admin token</p>
|
||||
</div>
|
||||
<button class="btn btn-small" type="button" data-jump="tasks">打开任务</button>
|
||||
</div>
|
||||
<div class="list" id="dashboardTaskList"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>资源样本</h2>
|
||||
<p id="resourceSampleSummary">读取 /v1/resources</p>
|
||||
</div>
|
||||
<button class="btn btn-small" type="button" data-jump="resources">查看资源</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>路径</th>
|
||||
<th>大小</th>
|
||||
<th>状态</th>
|
||||
<th>BLAKE3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashboardResourceRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-resources" data-title="资源" data-subtitle="查看当前发布 release 的资源清单,并触发同步、校验或修复。">
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>资源清单</h2>
|
||||
<p id="resourceListSummary">-</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn" type="button" data-control="sync">同步</button>
|
||||
<button class="btn" type="button" data-control="verify">校验</button>
|
||||
<button class="btn btn-danger" type="button" data-control="repair">修复</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<input id="resourceFilterInput" type="search" placeholder="过滤已加载路径">
|
||||
<select id="resourceLimitInput">
|
||||
<option value="20">20</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>路径</th>
|
||||
<th>大小</th>
|
||||
<th>状态</th>
|
||||
<th>BLAKE3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resourceRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<span id="resourcePagerText">-</span>
|
||||
<div>
|
||||
<button class="btn btn-small" id="resourcePrevBtn" type="button">上一页</button>
|
||||
<button class="btn btn-small" id="resourceNextBtn" type="button">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-schedules" data-title="调度" data-subtitle="管理 Rust 持久化 schedule,覆盖资源拉取、解析、翻译和发布任务。">
|
||||
<div class="grid two-col">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>计划列表</h2>
|
||||
<p id="scheduleSummary">需要 admin token</p>
|
||||
</div>
|
||||
<button class="btn btn-small" id="scheduleRefreshBtn" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<input id="scheduleIdFilter" type="search" placeholder="id">
|
||||
<input id="scheduleGroupFilter" type="search" placeholder="group: res / parse / i18n">
|
||||
<select id="scheduleEnabledFilter">
|
||||
<option value="">全部</option>
|
||||
<option value="true">启用</option>
|
||||
<option value="false">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="list" id="scheduleList"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>新增或更新</h2>
|
||||
<p>schedule.add / schedule.update</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="form" id="scheduleForm">
|
||||
<label>ID <input id="scheduleIdInput" autocomplete="off"></label>
|
||||
<label>Group <input id="scheduleGroupInput" placeholder="res"></label>
|
||||
<label>Command <input id="scheduleActionInput" placeholder="res sync"></label>
|
||||
<label>Args <textarea id="scheduleArgsInput" rows="3" placeholder="每行一个参数"></textarea></label>
|
||||
<div class="form-grid">
|
||||
<label>Delay seconds <input id="scheduleDelayInput" type="number" min="0"></label>
|
||||
<label>Every seconds <input id="scheduleEveryInput" type="number" min="1"></label>
|
||||
<label>Count <input id="scheduleCountInput" type="number" min="1"></label>
|
||||
<label>Next run unix <input id="scheduleNextRunInput" type="number" min="0"></label>
|
||||
</div>
|
||||
<label class="inline-check"><input id="scheduleEnabledInput" type="checkbox" checked>启用</label>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-primary" id="scheduleAddBtn" type="button">新增</button>
|
||||
<button class="btn" id="scheduleUpdateBtn" type="button">更新</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-translation" data-title="翻译" data-subtitle="查看翻译任务、触发 provider worker、编辑人工校对结果并发布汉化资源。">
|
||||
<div class="grid two-col">
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>翻译任务</h2>
|
||||
<p id="translationTaskSummary">需要 admin token</p>
|
||||
</div>
|
||||
<button class="btn btn-small" id="translationRefreshBtn" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<select id="translationStatusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
<input id="translationDestinationFilter" type="search" placeholder="destination">
|
||||
<input id="translationTaskFilter" type="search" placeholder="task_id">
|
||||
</div>
|
||||
<div class="table-wrap compact">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>资源</th>
|
||||
<th>状态</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="translationTaskRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Provider worker</h2>
|
||||
<p>translation.worker.run</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="form">
|
||||
<div class="form-grid">
|
||||
<label>Provider
|
||||
<select id="workerProviderInput">
|
||||
<option value="mock">mock</option>
|
||||
<option value="crowdin">crowdin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Concurrency <input id="workerConcurrencyInput" type="number" min="1" max="256" value="8"></label>
|
||||
<label>Max attempts <input id="workerMaxAttemptsInput" type="number" min="1" value="3"></label>
|
||||
<label>Lease seconds <input id="workerLeaseInput" type="number" min="1" value="300"></label>
|
||||
<label>Backoff seconds <input id="workerBackoffInput" type="number" min="0" value="5"></label>
|
||||
<label>Max tasks <input id="workerMaxTasksInput" type="number" min="1"></label>
|
||||
</div>
|
||||
<label>Worker ID <input id="workerIdInput" placeholder="dashboard-worker"></label>
|
||||
<label>Fixture path <input id="workerFixtureInput" placeholder="/tmp/mock-provider.json"></label>
|
||||
<button class="btn btn-primary" id="workerRunBtn" type="button">启动 worker</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>人工校对</h2>
|
||||
<p id="translationDetailSummary">选择一个翻译任务</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn" id="proofreadBtn" type="button">标记人工校对中</button>
|
||||
<button class="btn btn-primary" id="manualSaveBtn" type="button">保存校对结果</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form manual-meta">
|
||||
<label>Provider run ID <input id="manualProviderRunInput" placeholder="留空时由 Rust 生成"></label>
|
||||
</div>
|
||||
<div class="unit-editor" id="unitEditor"></div>
|
||||
</section>
|
||||
|
||||
<div class="grid two-col">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>汉化发布</h2>
|
||||
<p id="localizedSummary">需要 admin token</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form">
|
||||
<label>Localized release ID <input id="localizedReleaseInput" placeholder="可选"></label>
|
||||
<label class="inline-check"><input id="localizedForceInput" type="checkbox">强制发布</label>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-primary" id="localizedPublishBtn" type="button">从 worker 结果发布</button>
|
||||
<button class="btn btn-danger" id="localizedRollbackBtn" type="button">回滚</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>交接视图</h2>
|
||||
<p id="handoffSummary">translation.handoff</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="kv-list" id="handoffDetails"></dl>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-tasks" data-title="任务" data-subtitle="轮询 Rust daemon 持久化任务,查看状态、日志并发起取消。">
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>任务列表</h2>
|
||||
<p id="daemonTaskSummary">需要 admin token</p>
|
||||
</div>
|
||||
<button class="btn btn-small" id="daemonTaskRefreshBtn" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task ID</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Stage</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="daemonTaskRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>任务详情</h2>
|
||||
<p id="daemonTaskDetailSummary">选择一个任务</p>
|
||||
</div>
|
||||
<button class="btn btn-danger" id="daemonTaskCancelBtn" type="button">取消任务</button>
|
||||
</div>
|
||||
<dl class="kv-list" id="daemonTaskDetails"></dl>
|
||||
<pre class="log-box" id="daemonTaskLogBox"></pre>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-logs" data-title="日志" data-subtitle="读取 Rust daemon 诊断和最近日志。">
|
||||
<div class="grid two-col">
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>诊断</h2>
|
||||
<p id="doctorSummary">需要 admin token</p>
|
||||
</div>
|
||||
<button class="btn btn-small" id="doctorRefreshBtn" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="list" id="doctorChecks"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head split">
|
||||
<div>
|
||||
<h2>Daemon log</h2>
|
||||
<p id="logSummary">需要 admin token</p>
|
||||
</div>
|
||||
<label class="inline-label">Tail <input id="logTailInput" type="number" min="1" max="2000" value="200"></label>
|
||||
</div>
|
||||
<pre class="log-box" id="daemonLogBox"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-settings" data-title="设置" data-subtitle="配置 dashboard 调用 bat-api 的地址和 admin token。">
|
||||
<section class="panel settings-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>连接</h2>
|
||||
<p>默认使用当前页面同源 bat-api。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="form">
|
||||
<label>Base URL <input id="baseUrlInput" autocomplete="off"></label>
|
||||
<label>Admin token <input id="tokenInput" type="password" autocomplete="off"></label>
|
||||
<label class="inline-check"><input id="rememberTokenInput" type="checkbox">在本浏览器保存 token</label>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-primary" id="saveSettingsBtn" type="button">保存并刷新</button>
|
||||
<button class="btn" id="forgetSettingsBtn" type="button">清除</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+670
@@ -0,0 +1,670 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f0f3f8;
|
||||
--border: #d9e0ea;
|
||||
--border-strong: #bcc7d5;
|
||||
--text: #172033;
|
||||
--muted: #657286;
|
||||
--blue: #2563eb;
|
||||
--blue-dark: #1d4ed8;
|
||||
--green: #15803d;
|
||||
--amber: #b45309;
|
||||
--red: #b91c1c;
|
||||
--shadow: 0 10px 30px rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 238px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 20px 16px;
|
||||
background: #111827;
|
||||
color: #eef2ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.brand-subtitle,
|
||||
.connection-subtitle,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.brand-subtitle,
|
||||
.connection-subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.is-active {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.connection-box {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.connection-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.state-ok {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.state-warn,
|
||||
.state-loading {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.state-bad {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 730;
|
||||
}
|
||||
|
||||
.topbar p,
|
||||
.panel-head p,
|
||||
.metric-note,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.row-actions,
|
||||
.filter-row,
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.is-active {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 760;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
min-height: 58px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-head.split {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr);
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue-dark);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--blue-dark);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #fecaca;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
min-height: 28px;
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-check,
|
||||
.inline-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-label input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.filter-row input,
|
||||
.filter-row select {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 11px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.manual-meta {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.manual-meta label {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table-wrap.compact {
|
||||
max-height: 560px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
td code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.clickable:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.empty-block {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.compact-item {
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-muted);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill-ok {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.pill-warn {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.pill-bad {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.pill-neutral {
|
||||
border-color: var(--border);
|
||||
background: var(--surface-muted);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kv-list {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
padding: 6px 16px 14px;
|
||||
}
|
||||
|
||||
.kv-list dt,
|
||||
.kv-list dd {
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.kv-list dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kv-list dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
min-height: 280px;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.unit-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.unit-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fbfcfe;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.unit-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.unit-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.unit-meta span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.source-label textarea {
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.toast-region {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.toast {
|
||||
max-width: min(420px, calc(100vw - 36px));
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
background: #ecfdf5;
|
||||
color: var(--green);
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.toast-warn {
|
||||
background: #fffbeb;
|
||||
color: var(--amber);
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.toast-bad {
|
||||
background: #fef2f2;
|
||||
color: var(--red);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-col,
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.row-actions,
|
||||
.filter-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn,
|
||||
.filter-row input,
|
||||
.filter-row select {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.two-col,
|
||||
.form-grid,
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kv-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kv-list dt {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.kv-list dd {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user