升级 NUPACK 4.1 并支持混合材料设计
This commit is contained in:
parent
5daa60a464
commit
c6189d857d
33 changed files with 5830 additions and 466 deletions
553
service/static/portal.js
Normal file
553
service/static/portal.js
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
const page = document.body.dataset.page;
|
||||
const state = {
|
||||
me: null,
|
||||
cloudOffset: 0,
|
||||
cloudTotal: 0,
|
||||
adminOffset: 0,
|
||||
adminTotal: 0,
|
||||
adminView: "active",
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(Number(value) * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "-";
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)} min`;
|
||||
return `${(seconds / 3600).toFixed(1)} h`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
let bytes = Number(value) || 0;
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let index = 0;
|
||||
while (bytes >= 1024 && index < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${bytes.toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function downloadJson(filename, value) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const value = String(status || "unknown");
|
||||
return `<span class="status ${escapeHtml(value)}">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`HTTP ${response.status} 返回了无法解析的响应`);
|
||||
}
|
||||
if (response.status === 401 && data.login_url) {
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`${data.login_url}?next=${encodeURIComponent(next)}`);
|
||||
throw new Error("需要登录");
|
||||
}
|
||||
if (response.status === 401 && page === "admin") {
|
||||
window.location.assign("/admin");
|
||||
throw new Error("Admin 会话已失效,请重新登录");
|
||||
}
|
||||
if (!response.ok) throw new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderNavigation() {
|
||||
const nav = document.getElementById("appNav");
|
||||
if (!nav) return;
|
||||
if (page === "admin") {
|
||||
nav.innerHTML = `
|
||||
<a class="brand" href="#">NUPACK Admin</a>
|
||||
<nav class="nav-list"><a class="active" href="#">全局任务</a></nav>
|
||||
<div class="nav-meta">独立密码会话 · 免 OIDC</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const items = [
|
||||
["home", "/", "概览"],
|
||||
["workspace", "/workspace", "计算工作台"],
|
||||
["cloud", "/cloud", "云端记录"],
|
||||
["account", "/account", "账号与用量"],
|
||||
["settings", "/settings", "设置"],
|
||||
];
|
||||
nav.innerHTML = `
|
||||
<a class="brand" href="/">NUPACK</a>
|
||||
<nav class="nav-list">
|
||||
${items.map(([name, href, label]) => `<a class="${page === name ? "active" : ""}" href="${href}">${label}</a>`).join("")}
|
||||
</nav>
|
||||
<div class="nav-meta" id="navIdentity">正在读取账号...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
if (page === "admin") return null;
|
||||
const data = await request("/api/me");
|
||||
state.me = data;
|
||||
const user = data.user || {};
|
||||
const identity = document.getElementById("navIdentity");
|
||||
if (identity) identity.textContent = user.display_name || user.username || user.email || user.user_id;
|
||||
return data;
|
||||
}
|
||||
|
||||
function recentRow(job) {
|
||||
return `
|
||||
<tr>
|
||||
<td><a class="mono" href="/workspace?job=${encodeURIComponent(job.job_id)}">${escapeHtml(job.job_id.slice(0, 12))}</a></td>
|
||||
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}</td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(formatDate(job.created_at))}</td>
|
||||
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function initHome() {
|
||||
const [me, health, history] = await Promise.all([
|
||||
loadIdentity(),
|
||||
request("/health"),
|
||||
request("/api/history?limit=6&offset=0"),
|
||||
]);
|
||||
document.getElementById("statRunning").textContent = health.jobs_running ?? "-";
|
||||
document.getElementById("statQueued").textContent = health.jobs_queued ?? "-";
|
||||
document.getElementById("statTotal").textContent = me.usage?.total_jobs ?? "-";
|
||||
document.getElementById("statCompute").textContent = formatDuration(me.usage?.compute_seconds);
|
||||
const body = document.getElementById("recentJobs");
|
||||
body.innerHTML = history.items?.length
|
||||
? history.items.map(recentRow).join("")
|
||||
: '<tr><td colspan="5" class="empty">还没有任务记录。</td></tr>';
|
||||
}
|
||||
|
||||
function cloudQuery() {
|
||||
const form = new FormData(document.getElementById("cloudFilters"));
|
||||
const query = new URLSearchParams({
|
||||
limit: String(state.limit),
|
||||
offset: String(state.cloudOffset),
|
||||
});
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (String(value).trim()) query.set(key, String(value).trim());
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function cloudJobRow(job) {
|
||||
const active = ["queued", "running", "cancel_requested"].includes(job.status);
|
||||
return `
|
||||
<tr>
|
||||
<td class="mono">${escapeHtml(job.job_id.slice(0, 12))}</td>
|
||||
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}<br><span class="mono">${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)}</span></td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(formatDate(job.created_at))}</td>
|
||||
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
|
||||
<td>
|
||||
<div class="toolbar">
|
||||
<a class="button" href="/workspace?job=${encodeURIComponent(job.job_id)}">查看</a>
|
||||
<button type="button" data-export-job="${escapeHtml(job.job_id)}">JSON</button>
|
||||
<button type="button" data-refresh-job="${escapeHtml(job.job_id)}">刷新状态</button>
|
||||
<button type="button" data-share-job="${escapeHtml(job.job_id)}">分享</button>
|
||||
${active
|
||||
? `<button class="danger" type="button" data-cancel-job="${escapeHtml(job.job_id)}">终止</button>`
|
||||
: `<button class="danger" type="button" data-delete-job="${escapeHtml(job.job_id)}">删除</button>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function showNotice(id, message, isError = false) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.classList.remove("hidden");
|
||||
node.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
async function loadCloudJobs() {
|
||||
const data = await request(`/api/history?${cloudQuery()}`);
|
||||
state.cloudTotal = Number(data.total) || 0;
|
||||
const body = document.getElementById("cloudJobs");
|
||||
body.innerHTML = data.items?.length
|
||||
? data.items.map(cloudJobRow).join("")
|
||||
: '<tr><td colspan="6" class="empty">没有符合条件的任务。</td></tr>';
|
||||
const pageNumber = Math.floor(state.cloudOffset / state.limit) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(state.cloudTotal / state.limit));
|
||||
document.getElementById("cloudPage").textContent = `第 ${pageNumber} / ${pageCount} 页,共 ${state.cloudTotal} 条`;
|
||||
document.getElementById("cloudPrev").disabled = state.cloudOffset === 0;
|
||||
document.getElementById("cloudNext").disabled = state.cloudOffset + state.limit >= state.cloudTotal;
|
||||
}
|
||||
|
||||
async function refreshCloud() {
|
||||
await loadCloudJobs();
|
||||
}
|
||||
|
||||
function historyEntriesFromJson(raw) {
|
||||
const source = Array.isArray(raw) ? raw : (Array.isArray(raw?.history) ? raw.history : [raw]);
|
||||
return source.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
if (entry.payload && typeof entry.payload === "object") return entry;
|
||||
if (!entry.workflow || !entry.mode) return null;
|
||||
return {
|
||||
id: entry.id || entry.job_id || `import-${Date.now()}-${index}`,
|
||||
created_at: entry.created_at || new Date().toISOString(),
|
||||
payload: entry,
|
||||
result: null,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function exportCloudHistory() {
|
||||
const history = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const page = await request(`/api/history?limit=100&offset=${offset}`);
|
||||
const summaries = page.items || [];
|
||||
const details = await Promise.all(
|
||||
summaries.map((item) => request(`/api/history/${encodeURIComponent(item.job_id)}`)),
|
||||
);
|
||||
history.push(...details.map((detail) => detail.item));
|
||||
offset += summaries.length;
|
||||
if (!summaries.length || offset >= Number(page.total || 0)) break;
|
||||
}
|
||||
downloadJson("nupack-history.json", {
|
||||
version: 2,
|
||||
exported_at: new Date().toISOString(),
|
||||
history,
|
||||
});
|
||||
showNotice("cloudNotice", `已导出 ${history.length} 条云端记录。`);
|
||||
}
|
||||
|
||||
async function exportCloudHistoryItem(jobId) {
|
||||
const detail = await request(`/api/history/${encodeURIComponent(jobId)}`);
|
||||
downloadJson(`nupack-history-${jobId}.json`, detail.item);
|
||||
showNotice("cloudNotice", `已导出任务 ${jobId.slice(0, 12)}。`);
|
||||
}
|
||||
|
||||
async function importCloudHistory(files) {
|
||||
const history = [];
|
||||
for (const file of Array.from(files || [])) {
|
||||
history.push(...historyEntriesFromJson(JSON.parse(await file.text())));
|
||||
}
|
||||
if (!history.length) throw new Error("JSON 中没有可导入的计算记录。");
|
||||
const result = await request("/api/history/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ history }),
|
||||
});
|
||||
state.cloudOffset = 0;
|
||||
await loadCloudJobs();
|
||||
showNotice("cloudNotice", `导入完成:新增 ${result.imported} 条记录。`);
|
||||
}
|
||||
|
||||
async function handleCloudAction(event) {
|
||||
const shareJob = event.target.closest("[data-share-job]");
|
||||
const exportJob = event.target.closest("[data-export-job]");
|
||||
const refreshJob = event.target.closest("[data-refresh-job]");
|
||||
const cancelJob = event.target.closest("[data-cancel-job]");
|
||||
const deleteJob = event.target.closest("[data-delete-job]");
|
||||
try {
|
||||
if (exportJob) {
|
||||
await exportCloudHistoryItem(exportJob.dataset.exportJob);
|
||||
} else if (refreshJob) {
|
||||
const job = await request(`/api/jobs/${encodeURIComponent(refreshJob.dataset.refreshJob)}`);
|
||||
await loadCloudJobs();
|
||||
showNotice("cloudNotice", `任务状态已刷新:${job.status}`);
|
||||
} else if (shareJob) {
|
||||
const jobId = shareJob.dataset.shareJob;
|
||||
const data = await request(`/api/jobs/${encodeURIComponent(jobId)}/shares`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expires_in: 604800 }),
|
||||
});
|
||||
const url = new URL(data.url, window.location.origin).toString();
|
||||
await navigator.clipboard?.writeText(url);
|
||||
showNotice("cloudNotice", `分享链接已复制:${url}`);
|
||||
} else if (cancelJob) {
|
||||
if (!window.confirm("确定终止这个任务?")) return;
|
||||
await request(`/api/jobs/${encodeURIComponent(cancelJob.dataset.cancelJob)}/cancel`, { method: "POST" });
|
||||
await loadCloudJobs();
|
||||
} else if (deleteJob) {
|
||||
if (!window.confirm("确定将这个任务移入回收站?")) return;
|
||||
await request(`/api/history/${encodeURIComponent(deleteJob.dataset.deleteJob)}`, { method: "DELETE" });
|
||||
await refreshCloud();
|
||||
}
|
||||
} catch (error) {
|
||||
showNotice("cloudNotice", error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function initCloud() {
|
||||
await loadIdentity();
|
||||
const importInput = document.getElementById("cloudImportInput");
|
||||
document.getElementById("cloudImport").addEventListener("click", () => importInput.click());
|
||||
importInput.addEventListener("change", () => {
|
||||
importCloudHistory(importInput.files)
|
||||
.catch((error) => showNotice("cloudNotice", error.message, true))
|
||||
.finally(() => { importInput.value = ""; });
|
||||
});
|
||||
document.getElementById("cloudExport").addEventListener("click", (event) => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
exportCloudHistory()
|
||||
.catch((error) => showNotice("cloudNotice", error.message, true))
|
||||
.finally(() => { button.disabled = false; });
|
||||
});
|
||||
document.getElementById("cloudFilters").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
state.cloudOffset = 0;
|
||||
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("cloudRefresh").addEventListener("click", () => {
|
||||
refreshCloud().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("cloudPrev").addEventListener("click", () => {
|
||||
state.cloudOffset = Math.max(0, state.cloudOffset - state.limit);
|
||||
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("cloudNext").addEventListener("click", () => {
|
||||
state.cloudOffset += state.limit;
|
||||
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.querySelector(".content").addEventListener("click", handleCloudAction);
|
||||
await refreshCloud();
|
||||
}
|
||||
|
||||
function renderDefinitionList(node, rows) {
|
||||
node.innerHTML = rows.map(([term, value]) => `<dt>${escapeHtml(term)}</dt><dd>${escapeHtml(value ?? "-")}</dd>`).join("");
|
||||
}
|
||||
|
||||
async function initAccount() {
|
||||
const [me, health] = await Promise.all([loadIdentity(), request("/health")]);
|
||||
const user = me.user || {};
|
||||
const usage = me.usage || {};
|
||||
document.getElementById("accountTotal").textContent = usage.total_jobs ?? "-";
|
||||
document.getElementById("accountSuccess").textContent = usage.success_jobs ?? "-";
|
||||
document.getElementById("accountActive").textContent = usage.active_jobs ?? "-";
|
||||
document.getElementById("accountStorage").textContent = formatBytes(usage.stored_bytes);
|
||||
renderDefinitionList(document.getElementById("identityDetails"), [
|
||||
["显示名称", user.display_name],
|
||||
["用户名", user.username],
|
||||
["邮箱", user.email],
|
||||
["用户 ID", user.user_id],
|
||||
["用户组", (user.groups || []).join(", ") || "-"],
|
||||
]);
|
||||
const worker = health.worker_resource_plan || {};
|
||||
renderDefinitionList(document.getElementById("resourceDetails"), [
|
||||
["任务后端", health.job_backend],
|
||||
["并发来源", health.worker_concurrency_source],
|
||||
["Worker 并发", health.worker_concurrency],
|
||||
["单任务线程", health.per_job_thread_limit],
|
||||
["Worker 内存", worker.memory_gb == null ? "-" : `${worker.memory_gb} GB`],
|
||||
["单任务内存预算", worker.estimated_job_memory_gb == null ? "-" : `${worker.estimated_job_memory_gb} GB`],
|
||||
]);
|
||||
}
|
||||
|
||||
const workspacePreferences = [
|
||||
["settingsLanguage", "np_replica_lang", "zh"],
|
||||
["settingsTheme", "np_replica_theme", "harbor"],
|
||||
["settingsLayout", "np_replica_layout", "balanced"],
|
||||
["settingsHelp", "np_replica_help_visibility", "shown"],
|
||||
];
|
||||
|
||||
function loadWorkspacePreferences() {
|
||||
for (const [id, key, fallback] of workspacePreferences) {
|
||||
const control = document.getElementById(id);
|
||||
if (control) control.value = localStorage.getItem(key) || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function initSettings() {
|
||||
await loadIdentity();
|
||||
loadWorkspacePreferences();
|
||||
const form = document.getElementById("settingsForm");
|
||||
form.addEventListener("change", (event) => {
|
||||
const preference = workspacePreferences.find(([id]) => id === event.target.id);
|
||||
if (!preference) return;
|
||||
localStorage.setItem(preference[1], event.target.value);
|
||||
showNotice("settingsNotice", "设置已保存,重新打开工作台后生效。");
|
||||
});
|
||||
document.getElementById("settingsReset").addEventListener("click", () => {
|
||||
for (const [, key] of workspacePreferences) localStorage.removeItem(key);
|
||||
loadWorkspacePreferences();
|
||||
showNotice("settingsNotice", "已恢复默认设置。");
|
||||
});
|
||||
}
|
||||
|
||||
function adminQuery() {
|
||||
const form = new FormData(document.getElementById("adminFilters"));
|
||||
const query = new URLSearchParams({
|
||||
limit: String(state.limit),
|
||||
offset: String(state.adminOffset),
|
||||
deleted: state.adminView === "trash" ? "1" : "0",
|
||||
});
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (String(value).trim()) query.set(key, String(value).trim());
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function adminJobRow(job) {
|
||||
const active = ["queued", "running", "cancel_requested"].includes(job.status);
|
||||
const deleted = Boolean(job.deleted_at);
|
||||
const actions = deleted
|
||||
? `<button type="button" data-admin-action="restore" data-job-id="${escapeHtml(job.job_id)}">恢复</button>
|
||||
<button class="danger" type="button" data-admin-action="delete" data-job-id="${escapeHtml(job.job_id)}">永久删除</button>`
|
||||
: `${active ? `<button class="danger" type="button" data-admin-action="cancel" data-job-id="${escapeHtml(job.job_id)}">停止</button>` : ""}
|
||||
${active ? "" : `<button class="danger" type="button" data-admin-action="trash" data-job-id="${escapeHtml(job.job_id)}">移入回收站</button>`}`;
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(job.display_name || job.username || job.email || job.user_id)}<br><span class="mono">${escapeHtml(job.user_id)}</span></td>
|
||||
<td class="mono">${escapeHtml(job.job_id)}</td>
|
||||
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}<br>${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)}</td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(formatDate(job.created_at))}${deleted ? `<br><span class="mono">删除 ${escapeHtml(formatDate(job.deleted_at))}<br>清理 ${escapeHtml(formatDate(job.purge_after))}</span>` : ""}</td>
|
||||
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
|
||||
<td><div class="toolbar">${actions}</div><span class="mono">${escapeHtml(formatBytes(job.stored_bytes))}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAdminJobs() {
|
||||
const data = await request(`/api/admin/jobs?${adminQuery()}`);
|
||||
state.adminTotal = Number(data.total) || 0;
|
||||
document.getElementById("adminTotal").textContent = state.adminTotal;
|
||||
document.getElementById("adminRunning").textContent = data.live?.running ?? "-";
|
||||
document.getElementById("adminQueued").textContent = data.live?.queued ?? "-";
|
||||
document.getElementById("adminConcurrency").textContent = data.worker?.concurrency ?? "-";
|
||||
const body = document.getElementById("adminJobs");
|
||||
body.innerHTML = data.items?.length
|
||||
? data.items.map(adminJobRow).join("")
|
||||
: '<tr><td colspan="7" class="empty">没有符合条件的任务。</td></tr>';
|
||||
const pageNumber = Math.floor(state.adminOffset / state.limit) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(state.adminTotal / state.limit));
|
||||
document.getElementById("adminPage").textContent = `第 ${pageNumber} / ${pageCount} 页,共 ${state.adminTotal} 条`;
|
||||
document.getElementById("adminPrev").disabled = state.adminOffset === 0;
|
||||
document.getElementById("adminNext").disabled = state.adminOffset + state.limit >= state.adminTotal;
|
||||
}
|
||||
|
||||
async function loadAdminSettings() {
|
||||
const data = await request("/api/admin/settings");
|
||||
document.getElementById("trashRetentionDays").value = Number(data.trash_retention_days).toFixed(2).replace(/\.00$/, "");
|
||||
}
|
||||
|
||||
async function handleAdminAction(event) {
|
||||
const button = event.target.closest("[data-admin-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.adminAction;
|
||||
const labels = { cancel: "停止", trash: "移入回收站", restore: "恢复", delete: "永久删除" };
|
||||
if (!window.confirm(`确定${labels[action]}这个任务?`)) return;
|
||||
try {
|
||||
await request(`/api/admin/jobs/${encodeURIComponent(button.dataset.jobId)}/${action}`, { method: "POST" });
|
||||
showNotice("adminNotice", `任务已${labels[action]}。`);
|
||||
await loadAdminJobs();
|
||||
} catch (error) {
|
||||
showNotice("adminNotice", error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function initAdminLogin() {
|
||||
document.getElementById("adminLoginForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const data = await request("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: document.getElementById("adminPassword").value }),
|
||||
});
|
||||
window.location.assign(data.url || "/admin/panel");
|
||||
} catch (error) {
|
||||
showNotice("adminLoginNotice", error.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initAdmin() {
|
||||
document.getElementById("adminFilters").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
state.adminOffset = 0;
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("adminRefresh").addEventListener("click", () => {
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("adminPrev").addEventListener("click", () => {
|
||||
state.adminOffset = Math.max(0, state.adminOffset - state.limit);
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("adminNext").addEventListener("click", () => {
|
||||
state.adminOffset += state.limit;
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.querySelector(".content").addEventListener("click", handleAdminAction);
|
||||
document.querySelectorAll("[data-admin-view]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.adminView = button.dataset.adminView;
|
||||
state.adminOffset = 0;
|
||||
document.querySelectorAll("[data-admin-view]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
});
|
||||
document.getElementById("trashSettings").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await request("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trash_retention_days: Number(document.getElementById("trashRetentionDays").value) }),
|
||||
});
|
||||
showNotice("adminNotice", "回收站保留时间已保存。");
|
||||
} catch (error) {
|
||||
showNotice("adminNotice", error.message, true);
|
||||
}
|
||||
});
|
||||
document.getElementById("adminLogout").addEventListener("click", async () => {
|
||||
await request("/api/admin/logout", { method: "POST" });
|
||||
window.location.assign("/admin");
|
||||
});
|
||||
await Promise.all([loadAdminJobs(), loadAdminSettings()]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
renderNavigation();
|
||||
if (page === "home") await initHome();
|
||||
else if (page === "cloud") await initCloud();
|
||||
else if (page === "account") await initAccount();
|
||||
else if (page === "settings") await initSettings();
|
||||
else if (page === "admin") await initAdmin();
|
||||
else if (page === "admin-login") await initAdminLogin();
|
||||
}
|
||||
|
||||
initialize().catch((error) => {
|
||||
const target = document.querySelector(".content") || document.body;
|
||||
const notice = document.createElement("div");
|
||||
notice.className = "notice error";
|
||||
notice.textContent = error.message;
|
||||
target.appendChild(notice);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue