proofdb/app/view/admin/dashboard.html
2026-05-11 15:23:34 +08:00

826 lines
37 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proof DB 管理面板</title>
<link rel="stylesheet" href="/admin.css">
</head>
<body class="admin-dashboard">
<main class="admin-console page-shell-wide">
<section class="admin-console-shell panel">
<aside class="admin-console-sidebar">
<div class="admin-console-brand">
<div class="eyebrow">Maintenance Console / v<?=htmlspecialchars($version)?></div>
<h1 class="admin-console-title">Proof DB</h1>
<div class="admin-console-subtitle">管理员工作台</div>
</div>
<nav class="admin-console-nav">
<button class="admin-console-nav-item is-active" type="button" data-target="overview">总览</button>
<button class="admin-console-nav-item" type="button" data-target="archives">档案数据库</button>
<button class="admin-console-nav-item" type="button" data-target="opensearch">OpenSearch</button>
<button class="admin-console-nav-item" type="button" data-target="users">用户管理</button>
<button class="admin-console-nav-item" type="button" data-target="apidoc">API 文档</button>
<button class="admin-console-nav-item" type="button" data-target="scripts">维护脚本</button>
</nav>
<div class="admin-console-sidebar-foot">
<div class="metric-label">当前会话</div>
<div class="admin-console-identity"><?=htmlspecialchars($admin['display_name'] ?: $admin['username'])?></div>
<div class="admin-console-identity-sub">@<?=htmlspecialchars($admin['username'])?></div>
</div>
</aside>
<section class="admin-console-main">
<header class="admin-console-header">
<div>
<div class="eyebrow">Administrative Entry</div>
<h2 class="admin-console-header-title">Proof DB 管理面板</h2>
<p class="admin-console-header-copy">在这里维护 archives 表、OpenSearch 状态、管理员账号、API 文档,以及脚本级运维动作。</p>
</div>
<div class="admin-dashboard-actions">
<?php if ($archiveCaskUrl !== ''): ?>
<a class="button" href="<?=htmlspecialchars($archiveCaskUrl)?>">返回 Archive Cask</a>
<?php endif; ?>
<button class="button" id="logout-button" type="button">退出登录</button>
</div>
</header>
<div id="global-message" class="console-message" hidden></div>
<section class="admin-pane is-active" data-pane="overview">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">系统总览</h3>
<div class="admin-dashboard-section-note">集中查看数据库、OpenSearch 和当前版本状态。</div>
</div>
<button class="button" type="button" id="refresh-overview">刷新总览</button>
</div>
<div class="metric-grid admin-console-overview-grid" id="overview-metrics"></div>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">OpenSearch 摘要</h4>
<div class="admin-dashboard-section-note">来自管理员状态 API</div>
</div>
<div class="terminal-block" id="overview-opensearch-terminal">等待加载...</div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">快速入口</h4>
<div class="admin-dashboard-section-note">直接跳到主要维护区块</div>
</div>
<div class="admin-console-quick-actions">
<button class="button" type="button" data-open-pane="archives">管理 archives</button>
<button class="button" type="button" data-open-pane="opensearch">查看 OpenSearch</button>
<button class="button" type="button" data-open-pane="users">管理用户</button>
<button class="button" type="button" data-open-pane="apidoc">查看 APIDOC</button>
<button class="button" type="button" data-open-pane="scripts">执行维护脚本</button>
</div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="archives">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">archives 表管理</h3>
<div class="admin-dashboard-section-note">搜索、查看、编辑和删除档案记录。这里只操作 archives 表本身。</div>
</div>
</div>
<div class="admin-console-workbench">
<section class="admin-dashboard-section panel-soft">
<div class="admin-toolbar">
<input class="text-input admin-toolbar-input" id="archives-query" placeholder="按 archive_uid / title / summary / author / source 搜索">
<button class="button" type="button" id="archives-search">搜索</button>
<button class="button" type="button" id="archives-reload">刷新</button>
</div>
<div class="admin-list" id="archives-list"></div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">档案编辑器</h4>
<div class="admin-dashboard-section-note">选择左侧档案后可编辑。</div>
</div>
<form id="archive-form" class="admin-form-grid">
<label class="field-label" for="archive-uid">archive_uid</label>
<input class="text-input" id="archive-uid" name="archive_uid" readonly>
<label class="field-label" for="archive-title">title</label>
<input class="text-input" id="archive-title" name="title">
<label class="field-label" for="archive-year">year</label>
<input class="text-input" id="archive-year" name="year">
<label class="field-label" for="archive-author">author</label>
<input class="text-input" id="archive-author" name="author">
<label class="field-label" for="archive-source">source</label>
<input class="text-input" id="archive-source" name="source">
<label class="field-label" for="archive-series">series</label>
<input class="text-input" id="archive-series" name="series">
<label class="field-label" for="archive-tags">tags</label>
<textarea class="text-area" id="archive-tags" name="tags" rows="2" placeholder="逗号或换行分隔"></textarea>
<label class="field-label" for="archive-summary">summary</label>
<textarea class="text-area" id="archive-summary" name="summary" rows="5"></textarea>
<label class="field-label" for="archive-metadata">metadata</label>
<textarea class="text-area admin-code-area" id="archive-metadata" name="metadata" rows="8" placeholder='{"key":"value"}'></textarea>
<div class="admin-form-actions">
<button class="button primary" type="submit">保存档案</button>
<button class="button" type="button" id="archive-delete">删除档案</button>
</div>
</form>
</section>
</div>
</section>
<section class="admin-pane" data-pane="opensearch">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">OpenSearch 管理</h3>
<div class="admin-dashboard-section-note">查看集群、索引、数据库侧索引状态,以及索引中的文档粗览。</div>
</div>
<button class="button" type="button" id="opensearch-refresh">刷新状态</button>
</div>
<div class="metric-grid admin-console-opensearch-grid" id="opensearch-metrics"></div>
<section class="admin-dashboard-section panel-soft">
<div class="admin-toolbar">
<input class="text-input admin-toolbar-input" id="opensearch-query" placeholder="按 title / summary / source / author / text 搜索索引文档">
<button class="button" type="button" id="opensearch-search">搜索索引</button>
<button class="button" type="button" id="opensearch-reload-docs">刷新文档</button>
</div>
<div class="admin-list" id="opensearch-documents"></div>
</section>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">OpenSearch 详情</h4>
<div class="admin-dashboard-section-note">主机、索引、mapping 字段等。</div>
</div>
<div class="terminal-block" id="opensearch-terminal">等待加载...</div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">建议动作</h4>
<div class="admin-dashboard-section-note">跳转到脚本面板执行维护脚本。</div>
</div>
<div class="admin-console-quick-actions">
<button class="button" type="button" data-script="setup_opensearch">执行 setup_opensearch</button>
<button class="button" type="button" data-script="reindex_opensearch">执行 reindex_opensearch</button>
</div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="users">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">管理员用户管理</h3>
<div class="admin-dashboard-section-note">创建管理员账号,修改显示名、密码与启用状态。</div>
</div>
</div>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">创建新管理员</h4>
<div class="admin-dashboard-section-note">账号创建后即默认启用。</div>
</div>
<form id="create-user-form" class="admin-form-grid compact">
<label class="field-label" for="new-username">username</label>
<input class="text-input" id="new-username" name="username" required>
<label class="field-label" for="new-display-name">display_name</label>
<input class="text-input" id="new-display-name" name="display_name">
<label class="field-label" for="new-password">password</label>
<input class="text-input" id="new-password" name="password" type="password" required>
<div class="admin-form-actions">
<button class="button primary" type="submit">创建用户</button>
</div>
</form>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">现有管理员</h4>
<div class="admin-dashboard-section-note">留空密码表示不修改。</div>
</div>
<div class="admin-user-list" id="users-list"></div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="apidoc">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">APIDOC 查看</h3>
<div class="admin-dashboard-section-note">浏览 `/apidoc` 中的接口文档。</div>
</div>
</div>
<div class="admin-console-workbench">
<section class="admin-dashboard-section panel-soft">
<div class="admin-list" id="docs-list"></div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title" id="doc-title">文档内容</h4>
<div class="admin-dashboard-section-note" id="doc-name">请选择一份文档。</div>
</div>
<div class="admin-markdown-viewer" id="doc-content">等待加载...</div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="scripts">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">维护脚本伪终端</h3>
<div class="admin-dashboard-section-note">仅允许执行白名单中的 `scripts/*.php` 维护脚本。</div>
</div>
</div>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">可执行脚本</h4>
<div class="admin-dashboard-section-note">支持 `--key=value` 参数格式。</div>
</div>
<div class="admin-script-list" id="scripts-list"></div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">执行终端</h4>
<div class="admin-dashboard-section-note">脚本 stdout / stderr 会显示在下方。</div>
</div>
<form id="script-form" class="admin-form-grid compact">
<label class="field-label" for="script-select">script_name</label>
<select class="text-input" id="script-select" name="script_name"></select>
<label class="field-label" for="script-args">args</label>
<input class="text-input" id="script-args" name="args" placeholder="--archive_uid=01...">
<div class="admin-form-actions">
<button class="button primary" type="submit">执行脚本</button>
</div>
</form>
<div class="terminal-block" id="script-terminal"><span class="prompt">proofdb-admin$</span> 等待命令...</div>
</section>
</div>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title" id="script-doc-title">脚本文档</h4>
<div class="admin-dashboard-section-note" id="script-doc-name">如果该脚本有文档,会显示在这里。</div>
</div>
<div class="admin-markdown-viewer" id="script-doc-content">等待加载...</div>
</section>
</section>
</section>
</section>
</main>
<script>
const state = {
archiveUid: null,
docName: null,
docHtml: null,
scripts: [],
opensearch: null,
selectedScript: null
};
const els = {
message: document.getElementById('global-message'),
overviewMetrics: document.getElementById('overview-metrics'),
overviewTerminal: document.getElementById('overview-opensearch-terminal'),
archivesList: document.getElementById('archives-list'),
archiveForm: document.getElementById('archive-form'),
archiveDelete: document.getElementById('archive-delete'),
opensearchMetrics: document.getElementById('opensearch-metrics'),
opensearchTerminal: document.getElementById('opensearch-terminal'),
opensearchDocuments: document.getElementById('opensearch-documents'),
usersList: document.getElementById('users-list'),
createUserForm: document.getElementById('create-user-form'),
docsList: document.getElementById('docs-list'),
docTitle: document.getElementById('doc-title'),
docName: document.getElementById('doc-name'),
docContent: document.getElementById('doc-content'),
scriptsList: document.getElementById('scripts-list'),
scriptForm: document.getElementById('script-form'),
scriptSelect: document.getElementById('script-select'),
scriptArgs: document.getElementById('script-args'),
scriptTerminal: document.getElementById('script-terminal'),
scriptDocTitle: document.getElementById('script-doc-title'),
scriptDocName: document.getElementById('script-doc-name'),
scriptDocContent: document.getElementById('script-doc-content')
};
function field(form, name) {
return form.elements.namedItem(name);
}
function showMessage(text, kind = 'info') {
if (!text) {
els.message.hidden = true;
els.message.textContent = '';
els.message.className = 'console-message';
return;
}
els.message.hidden = false;
els.message.textContent = text;
els.message.className = `console-message is-${kind}`;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
async function api(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const data = await response.json();
if (!response.ok || data.code !== 0) {
throw new Error(data.errors ? Object.values(data.errors)[0] : data.message || '请求失败');
}
return data.data;
}
function activatePane(name) {
document.querySelectorAll('.admin-console-nav-item').forEach((button) => {
button.classList.toggle('is-active', button.dataset.target === name);
});
document.querySelectorAll('.admin-pane').forEach((pane) => {
pane.classList.toggle('is-active', pane.dataset.pane === name);
});
}
function tokeniseArgs(text) {
const tokens = text.match(/"[^"]*"|'[^']*'|\S+/g) || [];
return tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
}
function renderMetricCards(metrics) {
return metrics.map((metric) => `
<div class="metric-card">
<div class="metric-label">${escapeHtml(metric.label)}</div>
<div class="metric-value">${escapeHtml(metric.value)}</div>
<div class="metric-subvalue">${escapeHtml(metric.note || '')}</div>
</div>
`).join('');
}
async function loadOpenSearchStatus() {
const data = await api('/api/admin/opensearch/status');
state.opensearch = data;
const metrics = [
{label: 'archives', value: data.database.archives_total, note: 'archives 表记录数'},
{label: 'chunks', value: data.database.chunks_total, note: 'chunks 表记录数'},
{label: 'embedded', value: data.database.embedded_chunks, note: 'embedding_status = 3'},
{label: 'indexed', value: data.database.indexed_chunks, note: 'search_index_status = 3'},
{label: 'index', value: data.config.index_name, note: data.opensearch.index_exists ? '索引已存在' : '索引不存在'},
{label: 'docs.count', value: data.opensearch.docs_count, note: data.opensearch.health || '未获取健康状态'}
];
els.overviewMetrics.innerHTML = renderMetricCards(metrics);
els.opensearchMetrics.innerHTML = renderMetricCards(metrics);
const terminal = [
`hosts: ${(data.config.hosts || []).join(', ') || '[]'}`,
`ssl_verify: ${String(data.config.ssl_verify)}`,
`cluster_name: ${data.opensearch.cluster_name || '-'}`,
`reachable: ${String(data.opensearch.reachable)}`,
`index_exists: ${String(data.opensearch.index_exists)}`,
`health: ${data.opensearch.health || '-'}`,
`mapping_fields: ${(data.opensearch.mapping_fields || []).join(', ') || '-'}`,
data.opensearch.error ? `error: ${data.opensearch.error}` : ''
].filter(Boolean).join('\n');
els.overviewTerminal.textContent = terminal;
els.opensearchTerminal.textContent = terminal;
}
async function loadOpenSearchDocuments() {
const query = document.getElementById('opensearch-query').value.trim();
const data = await api(`/api/admin/opensearch/documents?query=${encodeURIComponent(query)}&size=20`);
if ((data.items || []).length === 0) {
els.opensearchDocuments.innerHTML = '<div class="admin-list-empty">当前没有可展示的索引文档。</div>';
return;
}
els.opensearchDocuments.innerHTML = data.items.map((item) => `
<article class="admin-list-item no-click">
<div class="admin-list-item-head">
<strong>${escapeHtml(item.title || item.chunk_uid)}</strong>
<span>${escapeHtml(item.chunk_uid || '-')}</span>
</div>
<div class="admin-list-item-copy">${escapeHtml(item.text_preview || item.summary || '无预览文本')}</div>
<div class="admin-list-item-meta">
<span>${escapeHtml(item.archive_uid || '-')}</span>
<span>p.${escapeHtml(item.page_start || '-')} - ${escapeHtml(item.page_end || '-')}</span>
<span>${escapeHtml(item.source || '-')}</span>
<span>${escapeHtml(item.embedding_model || '-')}</span>
</div>
</article>
`).join('');
}
async function loadArchives() {
const query = document.getElementById('archives-query').value.trim();
const data = await api(`/api/admin/archives?query=${encodeURIComponent(query)}`);
if (data.items.length === 0) {
els.archivesList.innerHTML = '<div class="admin-list-empty">没有找到档案记录。</div>';
return;
}
els.archivesList.innerHTML = data.items.map((item) => `
<button class="admin-list-item ${state.archiveUid === item.archive_uid ? 'is-active' : ''}" type="button" data-archive="${escapeHtml(item.archive_uid)}">
<div class="admin-list-item-head">
<strong>${escapeHtml(item.title || item.archive_uid)}</strong>
<span>${escapeHtml(item.archive_uid)}</span>
</div>
<div class="admin-list-item-copy">${escapeHtml(item.summary || '无 summary')}</div>
<div class="admin-list-item-meta">
<span>${escapeHtml(item.source || '-')}</span>
<span>${escapeHtml(item.year || '-')}</span>
<span>${escapeHtml(item.chunk_count)} chunks</span>
</div>
</button>
`).join('');
els.archivesList.querySelectorAll('[data-archive]').forEach((button) => {
button.addEventListener('click', async () => {
try {
await loadArchiveDetail(button.dataset.archive);
} catch (error) {
showMessage(error.message || '加载档案详情失败。', 'error');
}
});
});
if (!state.archiveUid && data.items[0]) {
await loadArchiveDetail(data.items[0].archive_uid);
}
}
async function loadArchiveDetail(archiveUid) {
const data = await api(`/api/admin/archives/${encodeURIComponent(archiveUid)}`);
state.archiveUid = archiveUid;
document.querySelectorAll('#archives-list [data-archive]').forEach((item) => {
item.classList.toggle('is-active', item.dataset.archive === archiveUid);
});
field(els.archiveForm, 'archive_uid').value = data.archive_uid || '';
field(els.archiveForm, 'title').value = data.title || '';
field(els.archiveForm, 'year').value = data.year || '';
field(els.archiveForm, 'author').value = data.author || '';
field(els.archiveForm, 'source').value = data.source || '';
field(els.archiveForm, 'series').value = data.series || '';
field(els.archiveForm, 'tags').value = (data.tags || []).join(', ');
field(els.archiveForm, 'summary').value = data.summary || '';
field(els.archiveForm, 'metadata').value = JSON.stringify(data.metadata || {}, null, 2);
}
async function saveArchive(event) {
event.preventDefault();
if (!state.archiveUid) {
showMessage('请先选择一条档案记录。', 'error');
return;
}
let metadata;
try {
const metadataField = field(els.archiveForm, 'metadata');
metadata = metadataField.value.trim() === '' ? {} : JSON.parse(metadataField.value);
} catch (error) {
showMessage('metadata 不是合法 JSON。', 'error');
return;
}
const payload = {
title: field(els.archiveForm, 'title').value,
year: field(els.archiveForm, 'year').value,
author: field(els.archiveForm, 'author').value,
source: field(els.archiveForm, 'source').value,
series: field(els.archiveForm, 'series').value,
tags: field(els.archiveForm, 'tags').value,
summary: field(els.archiveForm, 'summary').value,
metadata
};
await api(`/api/admin/archives/${encodeURIComponent(state.archiveUid)}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
showMessage('档案已更新。', 'success');
await loadArchiveDetail(state.archiveUid);
}
async function deleteArchive() {
if (!state.archiveUid) {
showMessage('请先选择一条档案记录。', 'error');
return;
}
if (!window.confirm(`确认删除档案 ${state.archiveUid} 吗?这会级联删除相关 chunks。`)) {
return;
}
await api(`/api/admin/archives/${encodeURIComponent(state.archiveUid)}`, {method: 'DELETE'});
showMessage('档案已删除。', 'success');
state.archiveUid = null;
els.archiveForm.reset();
await loadArchives();
await loadOpenSearchStatus();
}
function renderUsers(users) {
els.usersList.innerHTML = users.map((user) => `
<form class="admin-user-card" data-user-id="${user.id}">
<div class="admin-user-card-head">
<strong>${escapeHtml(user.username)}</strong>
<span>${user.is_active ? '启用中' : '已停用'}</span>
</div>
<label class="field-label">display_name</label>
<input class="text-input" name="display_name" value="${escapeHtml(user.display_name || '')}">
<label class="field-label">new_password</label>
<input class="text-input" type="password" name="password" placeholder="留空则不修改">
<label class="admin-inline-check">
<input type="checkbox" name="is_active" ${user.is_active ? 'checked' : ''}>
<span>is_active</span>
</label>
<div class="admin-form-actions">
<button class="button" type="submit">保存用户</button>
</div>
</form>
`).join('');
els.usersList.querySelectorAll('.admin-user-card').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const id = form.dataset.userId;
await api(`/api/admin/users/${id}`, {
method: 'PATCH',
body: JSON.stringify({
display_name: field(form, 'display_name').value,
password: field(form, 'password').value,
is_active: field(form, 'is_active').checked
})
});
showMessage(`用户 ${id} 已更新。`, 'success');
await loadUsers();
});
});
}
async function loadUsers() {
const data = await api('/api/admin/users');
renderUsers(data.items || []);
}
async function createUser(event) {
event.preventDefault();
await api('/api/admin/users', {
method: 'POST',
body: JSON.stringify({
username: field(els.createUserForm, 'username').value.trim(),
display_name: field(els.createUserForm, 'display_name').value.trim(),
password: field(els.createUserForm, 'password').value
})
});
els.createUserForm.reset();
showMessage('管理员用户已创建。', 'success');
await loadUsers();
}
async function loadDocs() {
const data = await api('/api/admin/docs');
els.docsList.innerHTML = data.items.map((doc) => `
<button class="admin-list-item ${state.docName === doc.name ? 'is-active' : ''}" type="button" data-doc="${escapeHtml(doc.name)}">
<div class="admin-list-item-head">
<strong>${escapeHtml(doc.title)}</strong>
<span>${escapeHtml(doc.name)}</span>
</div>
</button>
`).join('');
els.docsList.querySelectorAll('[data-doc]').forEach((button) => {
button.addEventListener('click', () => loadDoc(button.dataset.doc));
});
if (!state.docName && data.items[0]) {
await loadDoc(data.items[0].name);
}
}
async function loadDoc(name) {
const data = await api(`/api/admin/docs/${encodeURIComponent(name)}`);
state.docName = name;
els.docTitle.textContent = data.title;
els.docName.textContent = data.name;
els.docContent.innerHTML = data.html || '';
await loadDocs();
}
async function loadScripts() {
const data = await api('/api/admin/scripts');
state.scripts = data.items || [];
els.scriptSelect.innerHTML = state.scripts.map((script) => `
<option value="${escapeHtml(script.name)}">${escapeHtml(script.label)} (${escapeHtml(script.name)})</option>
`).join('');
els.scriptsList.innerHTML = state.scripts.map((script) => `
<button class="admin-script-card" type="button" data-script-select="${escapeHtml(script.name)}">
<div class="admin-script-card-head">
<strong>${escapeHtml(script.label)}</strong>
<span>${escapeHtml(script.name)}</span>
</div>
<div class="admin-script-card-copy">${escapeHtml(script.description)}</div>
<div class="admin-script-card-meta">
<span>${escapeHtml(script.args_hint)}</span>
<span>${escapeHtml(script.doc_name)}</span>
</div>
</button>
`).join('');
els.scriptsList.querySelectorAll('[data-script-select]').forEach((button) => {
button.addEventListener('click', async () => {
els.scriptSelect.value = button.dataset.scriptSelect;
await loadScriptDoc(button.dataset.scriptSelect);
});
});
if (!state.selectedScript && state.scripts[0]) {
els.scriptSelect.value = state.scripts[0].name;
await loadScriptDoc(state.scripts[0].name);
}
}
async function loadScriptDoc(name) {
const data = await api(`/api/admin/scripts/${encodeURIComponent(name)}`);
state.selectedScript = name;
els.scriptDocTitle.textContent = data.doc_title || data.label || data.name;
els.scriptDocName.textContent = data.doc_name || '暂无文档';
els.scriptDocContent.innerHTML = data.doc_html || '<p>该脚本暂时没有文档。</p>';
}
async function runScript(event) {
event.preventDefault();
const scriptName = els.scriptSelect.value;
const args = tokeniseArgs(els.scriptArgs.value.trim());
const result = await api('/api/admin/scripts/run', {
method: 'POST',
body: JSON.stringify({
script_name: scriptName,
args
})
});
els.scriptTerminal.textContent = [
`$ ${result.command.join(' ')}`,
'',
result.stdout ? `[stdout]\n${result.stdout}` : '',
result.stderr ? `[stderr]\n${result.stderr}` : '',
`exit_code: ${result.exit_code}`
].filter(Boolean).join('\n');
showMessage(`脚本 ${scriptName} 执行完成。`, result.ok ? 'success' : 'error');
await loadOpenSearchStatus();
}
document.querySelectorAll('.admin-console-nav-item').forEach((button) => {
button.addEventListener('click', async () => {
activatePane(button.dataset.target);
showMessage('');
if (button.dataset.target === 'archives') {
await loadArchives();
} else if (button.dataset.target === 'opensearch' || button.dataset.target === 'overview') {
await loadOpenSearchStatus();
if (button.dataset.target === 'opensearch') {
await loadOpenSearchDocuments();
}
} else if (button.dataset.target === 'users') {
await loadUsers();
} else if (button.dataset.target === 'apidoc') {
await loadDocs();
} else if (button.dataset.target === 'scripts') {
await loadScripts();
}
});
});
document.querySelectorAll('[data-open-pane]').forEach((button) => {
button.addEventListener('click', () => {
document.querySelector(`.admin-console-nav-item[data-target="${button.dataset.openPane}"]`)?.click();
});
});
document.querySelectorAll('[data-script]').forEach((button) => {
button.addEventListener('click', async () => {
activatePane('scripts');
await loadScripts();
els.scriptSelect.value = button.dataset.script;
showMessage(`已切换到维护脚本,并选中 ${button.dataset.script}`);
});
});
document.getElementById('logout-button').addEventListener('click', async () => {
await fetch('/api/admin/logout', {method: 'POST'});
window.location.href = '/';
});
document.getElementById('refresh-overview').addEventListener('click', loadOpenSearchStatus);
document.getElementById('opensearch-refresh').addEventListener('click', loadOpenSearchStatus);
document.getElementById('opensearch-search').addEventListener('click', loadOpenSearchDocuments);
document.getElementById('opensearch-reload-docs').addEventListener('click', loadOpenSearchDocuments);
document.getElementById('archives-search').addEventListener('click', loadArchives);
document.getElementById('archives-reload').addEventListener('click', loadArchives);
els.scriptSelect.addEventListener('change', async () => {
try {
await loadScriptDoc(els.scriptSelect.value);
} catch (error) {
showMessage(error.message || '加载脚本文档失败。', 'error');
}
});
els.archiveForm.addEventListener('submit', async (event) => {
try {
await saveArchive(event);
} catch (error) {
showMessage(error.message || '保存档案失败。', 'error');
}
});
els.archiveDelete.addEventListener('click', async () => {
try {
await deleteArchive();
} catch (error) {
showMessage(error.message || '删除档案失败。', 'error');
}
});
els.createUserForm.addEventListener('submit', async (event) => {
try {
await createUser(event);
} catch (error) {
showMessage(error.message || '创建用户失败。', 'error');
}
});
els.scriptForm.addEventListener('submit', async (event) => {
try {
await runScript(event);
} catch (error) {
showMessage(error.message || '脚本执行失败。', 'error');
}
});
(async function bootstrap() {
try {
await loadOpenSearchStatus();
} catch (error) {
showMessage(error.message || '加载总览失败。', 'error');
}
})();
</script>
</body>
</html>