升级 NUPACK 4.1 并支持混合材料设计

This commit is contained in:
Lihatoo 2026-08-20 16:33:16 +08:00
parent 5daa60a464
commit c6189d857d
33 changed files with 5830 additions and 466 deletions

View file

@ -0,0 +1,582 @@
:root {
color-scheme: light;
--bg: #f5f7f6;
--surface: #ffffff;
--surface-muted: #eef2f0;
--ink: #17201c;
--muted: #627069;
--line: #d9e0dc;
--accent: #167052;
--accent-hover: #0f5b42;
--warning: #a35418;
--danger: #a33a32;
--info: #246a9b;
--radius: 6px;
--shadow: 0 8px 24px rgba(23, 32, 28, 0.07);
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: var(--bg);
color: var(--ink);
}
button,
input,
select {
font: inherit;
}
button,
.button {
min-height: 36px;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 7px 12px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
text-decoration: none;
}
button:hover,
.button:hover {
border-color: var(--accent);
}
button.primary,
.button.primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
button.primary:hover,
.button.primary:hover {
background: var(--accent-hover);
}
button.danger {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 40%, var(--line));
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 7px 10px;
background: var(--surface);
color: var(--ink);
}
input:focus,
select:focus,
button:focus-visible,
.button:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 38%, transparent);
outline-offset: 1px;
}
.app {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
min-height: 100vh;
}
body[data-page="admin"] .app {
grid-template-columns: 160px minmax(0, 1fr);
}
body[data-page="admin"] .sidebar {
padding-right: 8px;
padding-left: 8px;
}
body[data-page="admin"] .main {
padding-right: 20px;
padding-left: 20px;
}
body[data-page="admin"] .page-header,
body[data-page="admin"] .content {
max-width: none;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 20px 14px;
border-right: 1px solid var(--line);
background: #18231e;
color: #f4f8f6;
}
.brand {
display: block;
padding: 8px 10px 20px;
color: #fff;
font-size: 19px;
font-weight: 750;
text-decoration: none;
}
.nav-list {
display: grid;
gap: 4px;
}
.nav-list a {
min-height: 40px;
border-radius: var(--radius);
padding: 10px 12px;
color: #cbd8d1;
text-decoration: none;
}
.nav-list a:hover,
.nav-list a.active {
background: #2b3c34;
color: #fff;
}
.nav-meta {
position: absolute;
right: 14px;
bottom: 18px;
left: 14px;
border-top: 1px solid #34463d;
padding: 14px 10px 0;
color: #aebdb5;
font-size: 12px;
overflow-wrap: anywhere;
}
.main {
min-width: 0;
padding: 24px clamp(18px, 4vw, 48px) 48px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0 auto 22px;
max-width: 1280px;
}
.page-header h1 {
margin: 0;
font-size: 25px;
letter-spacing: 0;
}
.page-header p {
margin: 5px 0 0;
color: var(--muted);
font-size: 14px;
}
.header-actions,
.toolbar,
.pager,
.segmented {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.content {
display: grid;
gap: 18px;
max-width: 1280px;
margin: 0 auto;
}
.band {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
}
.band-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 54px;
border-bottom: 1px solid var(--line);
padding: 12px 16px;
}
.band-header h2 {
margin: 0;
font-size: 15px;
}
.band-body {
padding: 16px;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stat {
min-height: 92px;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
background: var(--surface);
}
.stat span {
display: block;
color: var(--muted);
font-size: 12px;
}
.stat strong {
display: block;
margin-top: 9px;
font-size: 25px;
font-variant-numeric: tabular-nums;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
background: var(--line);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.setting-field {
display: grid;
gap: 7px;
}
.setting-field > span {
color: var(--ink);
font-size: 13px;
font-weight: 700;
}
.setting-field small {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.muted-copy {
margin: 5px 0 0;
color: var(--muted);
font-size: 13px;
}
.login-shell {
display: grid;
min-height: 100vh;
padding: 24px;
place-items: center;
}
.login-panel {
width: min(440px, 100%);
}
.login-panel h1 {
margin: 0;
font-size: 20px;
}
.login-form {
display: grid;
gap: 14px;
}
.login-form p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.settings-actions {
display: flex;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.quick-link {
min-height: 112px;
padding: 18px;
background: var(--surface);
color: var(--ink);
text-decoration: none;
}
.quick-link:hover {
background: #f8fbf9;
}
.quick-link strong,
.quick-link span {
display: block;
}
.quick-link span {
margin-top: 8px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(120px, 1fr)) auto;
gap: 10px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 11px 12px;
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
tbody tr:hover {
background: #f7faf8;
}
.mono {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.status {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 999px;
padding: 3px 8px;
background: var(--surface-muted);
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.status.running,
.status.queued,
.status.cancel_requested {
background: #e6f0f8;
color: var(--info);
}
.status.success {
background: #e3f2eb;
color: var(--accent);
}
.status.error,
.status.canceled {
background: #f7e8e6;
color: var(--danger);
}
.empty,
.loading,
.notice {
padding: 30px 16px;
color: var(--muted);
text-align: center;
}
.notice.error {
color: var(--danger);
}
.pager {
justify-content: flex-end;
padding: 12px 16px;
}
.pager span {
color: var(--muted);
font-size: 12px;
}
.segmented button.active {
border-color: var(--accent);
background: var(--surface-muted);
color: var(--accent);
}
.hidden {
display: none !important;
}
.identity {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 10px 24px;
margin: 0;
}
.identity dt {
color: var(--muted);
}
.identity dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
html,
body {
max-width: 100%;
overflow-x: hidden;
}
.app {
grid-template-columns: 1fr;
min-width: 0;
max-width: 100%;
}
body[data-page="admin"] .app {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
min-width: 0;
max-width: 100%;
height: auto;
padding: 10px 12px;
overflow: hidden;
}
body[data-page="admin"] .sidebar {
padding: 10px 12px;
}
.brand {
padding: 6px 8px 10px;
}
.nav-list {
display: flex;
width: 100%;
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: thin;
}
.nav-list a {
white-space: nowrap;
}
.nav-meta {
display: none;
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters {
grid-template-columns: 1fr 1fr;
}
.quick-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.main {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 18px 12px 36px;
}
body[data-page="admin"] .main {
padding: 18px 12px 36px;
}
.content,
.band,
.band-body,
.settings-grid,
.setting-field {
min-width: 0;
max-width: 100%;
}
.page-header {
align-items: flex-start;
flex-direction: column;
}
.stats,
.filters,
.settings-grid {
grid-template-columns: 1fr;
}
.identity {
grid-template-columns: 1fr;
}
.identity dd {
margin-bottom: 8px;
}
}

553
service/static/portal.js Normal file
View 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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);
});

View file

@ -0,0 +1,378 @@
/* Focused workbench layer. Calculation and account behaviour stay in index.html. */
body {
background: var(--bg-end);
}
.workspace-nav {
position: sticky;
top: 0;
z-index: 20;
min-height: 62px;
padding: 0 28px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel-strong) 94%, transparent);
box-shadow: 0 6px 20px rgba(20, 35, 28, 0.06);
backdrop-filter: blur(18px);
}
.workspace-nav > a {
position: relative;
min-height: 62px;
padding: 21px 14px 18px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.workspace-nav > a:first-child {
margin-right: 16px;
padding-left: 0;
color: var(--ink);
font-size: 17px;
font-weight: 800;
letter-spacing: 0.02em;
}
.workspace-nav > a:hover,
.workspace-nav > a.active {
color: var(--accent-strong);
}
.workspace-nav > a.active::after {
position: absolute;
right: 14px;
bottom: 0;
left: 14px;
height: 3px;
border-radius: 3px 3px 0 0;
background: var(--accent);
content: "";
}
.shell {
max-width: 1500px;
padding: 28px 32px 58px;
}
.hero {
gap: 15px;
margin-bottom: 20px;
padding: 23px 25px 20px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-strong);
box-shadow: 0 12px 32px rgba(20, 35, 28, 0.08);
}
.hero-top {
min-height: 34px;
justify-content: flex-start;
}
.hero-top .account-chip,
.hero-top .toolbar,
#usagePanel {
display: none !important;
}
.hero h1 {
font-size: 30px;
letter-spacing: 0;
}
.hero > p {
display: block;
max-width: 760px;
font-size: 14px;
}
.queue-panel {
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 82%, var(--accent-soft));
}
.queue-panel .metric {
min-height: 74px;
padding: 14px 16px;
border: 0;
border-right: 1px solid var(--line);
border-radius: 0;
background: transparent;
}
.queue-panel .metric:last-child {
border-right: 0;
}
.queue-panel .metric strong {
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.queue-panel .metric-value {
color: var(--ink);
font-size: 20px;
font-weight: 750;
}
.layout {
grid-template-columns: minmax(370px, 450px) minmax(0, 1fr);
gap: 20px;
min-height: calc(100vh - 148px);
}
.panel {
border-radius: 10px;
background: var(--panel-strong);
box-shadow: 0 12px 32px rgba(20, 35, 28, 0.07);
backdrop-filter: none;
}
.panel-inner {
padding: 20px;
}
.control-panel {
top: 82px;
}
.control-panel .panel-inner {
counter-reset: workbench-step;
}
.section {
padding-bottom: 22px;
margin-bottom: 22px;
}
.section h2 {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 14px;
font-size: 16px;
letter-spacing: 0;
}
#workflowModeSection h2::before,
#modelSection h2::before,
#strandSection h2::before,
#computeSection h2::before,
#designSection > h2::before {
display: inline-grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent-strong);
content: counter(workbench-step, decimal-leading-zero);
counter-increment: workbench-step;
font-size: 11px;
font-weight: 800;
}
label {
gap: 7px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
input,
select,
textarea {
border-radius: 7px;
border-color: var(--line);
padding: 10px 11px;
box-shadow: inset 0 1px 1px rgba(20, 35, 28, 0.025);
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent);
outline: 0;
}
.strand-card,
.result-card {
border-radius: 8px;
border-color: var(--line);
background: var(--card-strong);
box-shadow: 0 4px 14px rgba(20, 35, 28, 0.04);
}
.strand-card {
padding: 15px;
}
.sequence-field textarea {
min-height: 98px;
letter-spacing: 0.06em;
}
.section-note,
.minor-note {
line-height: 1.6;
}
.design-preflight {
border-radius: 8px;
border-color: color-mix(in srgb, var(--accent) 20%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 55%, var(--card-strong));
}
.control-panel > .panel-inner > .actions {
bottom: -20px;
margin: 0 -20px 20px;
padding: 13px 20px;
border-color: var(--line);
}
.control-panel > .panel-inner > .actions #runBtn {
min-width: 150px;
font-weight: 750;
}
.results-panel > .panel-inner {
min-height: 100%;
}
.results {
gap: 14px;
}
.result-card h3 {
font-size: 16px;
}
.meta-chip {
border-radius: 5px;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: minmax(330px, 410px) minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.workspace-nav {
overflow-x: auto;
padding: 0 16px;
}
.shell {
padding: 20px 16px 44px;
}
.layout {
grid-template-columns: 1fr;
}
.control-panel {
position: static;
}
}
@media (max-width: 600px) {
body {
overflow-x: hidden;
}
.hero,
.layout,
.panel {
min-width: 0;
}
.workspace-nav {
max-width: 100vw;
}
.workspace-nav > a:first-child {
display: none;
}
.shell {
width: 100%;
max-width: 100vw;
margin: 0;
overflow: hidden;
}
.hero {
padding: 18px;
}
.hero-top {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
}
.hero-top .pill {
justify-self: start;
}
.hero h1 {
font-size: 25px;
}
.queue-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.queue-panel .metric:nth-child(2) {
border-right: 0;
}
.queue-panel .metric:nth-child(-n + 2) {
border-bottom: 1px solid var(--line);
}
.panel-inner {
padding: 16px;
}
.grid.cols-2 {
grid-template-columns: 1fr;
}
.strand-meta-grid {
grid-template-columns: 1fr 1fr;
}
.strand-meta-grid .remove-btn {
grid-column: 1 / -1;
}
.control-panel > .panel-inner > .actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.control-panel > .panel-inner > .actions #runBtn {
grid-column: 1 / -1;
width: 100%;
}
.control-panel > .panel-inner > .actions button {
min-width: 0;
padding-right: 10px;
padding-left: 10px;
line-height: 1.3;
white-space: normal;
}
}