fix: 优化任务列表布局

This commit is contained in:
2026-06-20 13:54:22 +08:00
parent a1b791e6ef
commit 8a06236dab
7 changed files with 237 additions and 125 deletions
+6 -6
View File
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when // the top-nav active state in sync, and re-renders chrome strings when
// the locale changes. // the locale changes.
import { escapeHtml, onRoute } from "./state.js?v=security-fixes-1"; import { escapeHtml, onRoute } from "./state.js?v=job-layout-1";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=security-fixes-1"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-layout-1";
import { renderRemotes } from "./views/remotes.js?v=security-fixes-1"; import { renderRemotes } from "./views/remotes.js?v=job-layout-1";
import { renderBrowse } from "./views/browser.js?v=security-fixes-1"; import { renderBrowse } from "./views/browser.js?v=job-layout-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=security-fixes-1"; import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-layout-1";
import { import {
renderConfigureNew, renderConfigureNew,
renderConfigureEdit, renderConfigureEdit,
} from "./views/configure.js?v=security-fixes-1"; } from "./views/configure.js?v=job-layout-1";
const views = { const views = {
remotes: renderRemotes, remotes: renderRemotes,
+3 -3
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js?v=security-fixes-1"; import { post, uploadFile, downloadURL } from "../rc.js?v=job-layout-1";
import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=security-fixes-1"; import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=security-fixes-1"; import { t } from "../i18n.js?v=job-layout-1";
export async function renderBrowse({ remote, path }) { export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app"); const app = document.getElementById("app");
+3 -3
View File
@@ -9,9 +9,9 @@
// OAuth backends (option named "token" with IsPassword) get a banner // OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal. // and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js?v=security-fixes-1"; import { post } from "../rc.js?v=job-layout-1";
import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1"; import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=security-fixes-1"; import { t } from "../i18n.js?v=job-layout-1";
// --- Route entrypoints --- // --- Route entrypoints ---
+59 -74
View File
@@ -1,14 +1,14 @@
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. // views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
import { post } from "../rc.js?v=security-fixes-1"; import { post } from "../rc.js?v=job-layout-1";
import { import {
toast, toast,
formatBytes, formatBytes,
formatSpeed, formatSpeed,
formatDuration, formatDuration,
escapeHtml, escapeHtml,
} from "../state.js?v=security-fixes-1"; } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=security-fixes-1"; import { t } from "../i18n.js?v=job-layout-1";
import { import {
createOneTimeJob, createOneTimeJob,
createRecurringJob, createRecurringJob,
@@ -21,7 +21,7 @@ import {
runRecurringJobNow, runRecurringJobNow,
stopOneTimeJob, stopOneTimeJob,
stopRecurringJob, stopRecurringJob,
} from "../jobs_api.js?v=security-fixes-1"; } from "../jobs_api.js?v=job-layout-1";
let pollTimer = null; let pollTimer = null;
const LOCAL_FS_VALUE = "__local__"; const LOCAL_FS_VALUE = "__local__";
@@ -537,78 +537,37 @@ function renderJobTable(statuses) {
return bTime - aTime; return bTime - aTime;
}); });
const rows = statuses.map(renderJobRows).join(""); const rows = statuses.map(renderJobRow).join("");
return ` return `
<table class="table"> <div class="job-list">
<thead>
<tr>
<th>${t("jobs.col.id")}</th>
<th>${t("jobs.col.job")}</th>
<th>${t("jobs.col.status")}</th>
<th>${t("jobs.col.next_run")}</th>
<th class="col-num">${t("jobs.col.progress")}</th>
<th class="col-num">${t("jobs.col.speed")}</th>
<th class="col-num">${t("jobs.col.eta")}</th>
<th class="col-num">${t("jobs.col.files")}</th>
<th class="col-num">${t("jobs.col.errors")}</th>
<th class="col-num">${t("jobs.col.action")}</th>
</tr>
</thead>
<tbody>
${rows} ${rows}
</tbody> </div>
</table>
`; `;
} }
function renderJobRows(s) {
const row = renderJobRow(s);
if (!expandedJobs.has(jobKey(s))) {
return row;
}
return row + renderJobDetailsRow(s);
}
function renderJobRow(job) { function renderJobRow(job) {
const snapshot = job.statusSnapshot || {}; const snapshot = job.statusSnapshot || {};
const stats = transferStats(snapshot); const stats = transferStats(snapshot);
const id = job.id;
const key = jobKey(job); const key = jobKey(job);
const running = !!job.running; const running = !!job.running;
const jobid = job.currentJobid || job.lastJobid; const jobid = job.currentJobid || job.lastJobid;
const displayedSpeed = stats.speed || averageSpeed(stats); const displayedSpeed = stats.speed || averageSpeed(stats);
const displayedEta = running ? stats.eta : 0; const displayedEta = running ? stats.eta : 0;
const expanded = expandedJobs.has(key);
const badge = renderStatusBadge(job.status); const badge = renderStatusBadge(job.status);
const jobCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(job.action)}</span>
<span class="job-paths">
<code>${escapeHtml(job.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(job.dst)}</code>
</span>
</div>`;
const scheduleCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}</span>
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
</div>
`;
const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0; const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0;
const progress = ` const progress = `
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px"> <div class="job-progress">
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div> <div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
<span class="col-mono" style="font-size:11px;color:var(--color-muted)"> <span class="job-progress-text">
${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)} ${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
</span> </span>
</div> </div>
`; `;
const detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${escapeAttr(key)}">${expandedJobs.has(key) ? t("jobs.hide_details") : t("jobs.details")}</button>`; const detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${escapeAttr(key)}">${expanded ? t("jobs.hide_details") : t("jobs.details")}</button>`;
const startBtn = running const startBtn = running
? "" ? ""
: `<button class="btn btn-secondary btn-sm" data-start="${escapeAttr(key)}">${t("jobs.start_again")}</button>`; : `<button class="btn btn-secondary btn-sm" data-start="${escapeAttr(key)}">${t("jobs.start_again")}</button>`;
@@ -618,28 +577,50 @@ function renderJobRow(job) {
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${escapeAttr(key)}">${t("jobs.delete_record")}</button>`; const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${escapeAttr(key)}">${t("jobs.delete_record")}</button>`;
return ` return `
<tr> <article class="job-item">
<td class="col-mono"> <div class="job-main">
${escapeHtml(displayJobId(job))} <div class="job-head">
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""} <div class="job-id">
</td> <span>${escapeHtml(displayJobId(job))}</span>
<td>${jobCell}</td> ${jobid ? `<small>job ${escapeHtml(jobid)}</small>` : ""}
<td>${badge}</td> </div>
<td>${scheduleCell}</td> ${badge}
<td class="col-num">${progress}</td> </div>
<td class="col-num col-mono">${escapeHtml(formatSpeed(displayedSpeed))}</td> <div class="job-route">
<td class="col-num col-mono">${escapeHtml(formatDuration(displayedEta))}</td> <div class="job-route-line">
<td class="col-num col-mono">${escapeHtml(formatTransferCount(stats))}</td> <span>${escapeHtml(job.action)}</span>
<td class="col-num col-mono">${escapeHtml(String(stats.errors || 0))}</td> <code>${escapeHtml(job.src)}</code>
<td class="col-num"> </div>
<div class="job-route-line">
<span>→</span>
<code>${escapeHtml(job.dst)}</code>
</div>
</div>
<div class="job-schedule">
<span>${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}</span>
<span>${escapeHtml(formatSchedule(job))}</span>
</div>
</div>
<div class="job-side">
<div class="job-progress-wrap">
<span class="job-metric-label">${t("jobs.col.progress")}</span>
${progress}
</div>
<div class="job-metrics">
${metricHtml(t("jobs.col.speed"), formatSpeed(displayedSpeed))}
${metricHtml(t("jobs.col.eta"), formatDuration(displayedEta))}
${metricHtml(t("jobs.col.files"), formatTransferCount(stats))}
${metricHtml(t("jobs.col.errors"), String(stats.errors || 0))}
</div>
<div class="job-actions"> <div class="job-actions">
${detailBtn} ${detailBtn}
${startBtn} ${startBtn}
${stopBtn} ${stopBtn}
${deleteBtn} ${deleteBtn}
</div> </div>
</td> </div>
</tr> ${expanded ? renderJobDetails(job) : ""}
</article>
`; `;
} }
@@ -661,7 +642,16 @@ function renderStatusBadge(status) {
} }
} }
function renderJobDetailsRow(job) { function metricHtml(label, value) {
return `
<div class="job-metric">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`;
}
function renderJobDetails(job) {
const snapshot = job.statusSnapshot || {}; const snapshot = job.statusSnapshot || {};
const stats = transferStats(snapshot); const stats = transferStats(snapshot);
const jobid = job.currentJobid || job.lastJobid || ""; const jobid = job.currentJobid || job.lastJobid || "";
@@ -688,9 +678,6 @@ function renderJobDetailsRow(job) {
: ""; : "";
return ` return `
<tr class="job-detail-row">
<td></td>
<td colspan="9">
<div class="job-detail"> <div class="job-detail">
<dl> <dl>
${details.map(([label, value]) => ` ${details.map(([label, value]) => `
@@ -707,8 +694,6 @@ function renderJobDetailsRow(job) {
</div> </div>
` : ""} ` : ""}
</div> </div>
</td>
</tr>
`; `;
} }
+3 -3
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD. // views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js?v=security-fixes-1"; import { post } from "../rc.js?v=job-layout-1";
import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1"; import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=security-fixes-1"; import { t } from "../i18n.js?v=job-layout-1";
export async function renderRemotes() { export async function renderRemotes() {
const app = document.getElementById("app"); const app = document.getElementById("app");
+145 -18
View File
@@ -865,46 +865,148 @@
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
/* ---------- Job cell with action + src→dst ---------- */ /* ---------- Jobs ---------- */
.job-cell { .job-list {
display: grid;
gap: var(--space-sm);
}
.job-item {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: var(--space-lg);
padding: var(--space-lg);
border-bottom: 1px solid var(--color-hairline-soft);
}
.job-item:last-child {
border-bottom: none;
}
.job-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-md);
min-width: 0;
}
.job-head {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; align-items: flex-start;
min-width: 200px; gap: var(--space-xs);
} }
.job-cell .job-action { .job-id {
display: grid;
gap: 2px;
min-width: 58px;
color: var(--color-muted);
font: var(--typo-code);
}
.job-id small {
color: var(--color-muted-soft);
font: var(--typo-caption);
}
.job-route {
display: grid;
gap: var(--space-xs);
min-width: 0;
}
.job-route-line {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: start;
gap: var(--space-xs);
}
.job-route-line span {
color: var(--color-muted);
font: var(--typo-caption-uppercase); font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking); letter-spacing: var(--typo-caption-uppercase-tracking);
color: var(--color-muted); text-transform: uppercase;
} }
.job-cell .job-paths { .job-route-line code {
font: var(--typo-body-sm);
color: var(--color-body); color: var(--color-body);
word-break: break-all; font: var(--typo-code);
white-space: normal;
overflow-wrap: anywhere;
} }
.job-cell .job-paths .arrow { .job-schedule {
color: var(--color-muted-soft); grid-column: 2;
margin: 0 var(--space-xxs); display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
color: var(--color-muted);
font: var(--typo-caption);
}
.job-side {
display: grid;
align-content: start;
gap: var(--space-sm);
min-width: 0;
}
.job-progress-wrap {
display: grid;
gap: var(--space-xs);
}
.job-metric-label,
.job-metric span {
color: var(--color-muted);
font: var(--typo-caption);
}
.job-progress {
display: grid;
gap: 4px;
}
.job-progress-text {
color: var(--color-muted);
font: var(--typo-code);
}
.job-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-xs);
}
.job-metric {
display: grid;
gap: 2px;
min-width: 0;
}
.job-metric strong {
color: var(--color-body);
font: var(--typo-code);
font-weight: 400;
overflow-wrap: anywhere;
} }
.job-actions { .job-actions {
display: inline-flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-xs); gap: var(--space-xs);
flex-wrap: wrap; flex-wrap: wrap;
} }
.job-detail-row {
background-color: var(--color-surface-soft);
}
.job-detail { .job-detail {
grid-column: 1 / -1;
display: grid; display: grid;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) 0; padding: var(--space-md);
background-color: var(--color-surface-soft);
border-radius: var(--radius-sm);
} }
.job-detail dl { .job-detail dl {
@@ -964,6 +1066,15 @@
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.job-item {
grid-template-columns: 1fr;
}
.job-side {
grid-template-columns: 1fr;
}
.job-actions {
justify-content: flex-start;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -982,4 +1093,20 @@
.top-nav nav { .top-nav nav {
display: none; display: none;
} }
.job-item {
padding: var(--space-md);
}
.job-main {
grid-template-columns: 1fr;
}
.job-schedule {
grid-column: 1;
}
.job-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.job-detail dl,
.job-detail dl div {
grid-template-columns: 1fr;
}
} }
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css"> <link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css"> <link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css?v=security-fixes-1"> <link rel="stylesheet" href="/assets/styles/components.css?v=job-layout-1">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
@@ -80,6 +80,6 @@
<div id="toast-stack" class="toast-stack"></div> <div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div> <div id="modal-root"></div>
<script type="module" src="/assets/js/app.js?v=security-fixes-1"></script> <script type="module" src="/assets/js/app.js?v=job-layout-1"></script>
</body> </body>
</html> </html>