升级 NUPACK 4.1 并支持混合材料设计
This commit is contained in:
parent
5daa60a464
commit
c6189d857d
33 changed files with 5830 additions and 466 deletions
582
service/static/app-shell.css
Normal file
582
service/static/app-shell.css
Normal 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
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);
|
||||
});
|
||||
378
service/static/workspace-refresh.css
Normal file
378
service/static/workspace-refresh.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue