feat: init sproutclaw-cron 定时任务管理框架

- 每任务一目录结构,统一开关与日志
- cronctl CLI:enable/disable/toggle/status/run/sync-cron
- 公共库 lib/shumengya_cron:runner/manager/notify/ssh
- _template 示例任务(hello world + 复制模板)
- webui 管理面板:FastAPI 后端 + 文档式响应式前端
This commit is contained in:
2026-06-24 16:19:53 +08:00
commit 6c2db2dfa3
23 changed files with 2667 additions and 0 deletions

161
webui/backend/main.py Normal file
View File

@@ -0,0 +1,161 @@
"""SproutClaw Cron 定时任务管理面板 API。"""
from __future__ import annotations
import subprocess
import sys
import threading
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
CRON_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"
sys.path.insert(0, str(CRON_ROOT / "lib"))
from shumengya_cron.manager import ( # noqa: E402
build_task_info,
list_tasks,
read_log_tail,
set_task_state,
sync_cron_d,
toggle_task,
)
from shumengya_cron.manager import get_context # noqa: E402
from shumengya_cron.runner import task_is_enabled # noqa: E402
app = FastAPI(title="SproutClaw Cron", version="1.0.0")
_run_jobs: dict[str, subprocess.Popen[bytes]] = {}
_run_lock = threading.Lock()
class MessageResponse(BaseModel):
ok: bool = True
message: str = ""
class RunResponse(BaseModel):
ok: bool = True
message: str
pid: int | None = None
def _task_not_found(exc: FileNotFoundError) -> HTTPException:
return HTTPException(status_code=404, detail=str(exc))
def _cleanup_run_job(task_id: str, proc: subprocess.Popen[bytes]) -> None:
proc.wait()
with _run_lock:
current = _run_jobs.get(task_id)
if current is proc:
_run_jobs.pop(task_id, None)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/tasks")
def api_list_tasks() -> list[dict[str, object]]:
return [task.to_dict() for task in list_tasks(CRON_ROOT)]
@app.get("/api/tasks/{task_id}")
def api_get_task(task_id: str) -> dict[str, object]:
try:
return build_task_info(task_id, CRON_ROOT).to_dict()
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
@app.post("/api/tasks/{task_id}/enable")
def api_enable_task(task_id: str) -> dict[str, object]:
try:
info, _message = set_task_state(task_id, True, CRON_ROOT)
return info.to_dict()
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
@app.post("/api/tasks/{task_id}/disable")
def api_disable_task(task_id: str) -> dict[str, object]:
try:
info, _message = set_task_state(task_id, False, CRON_ROOT)
return info.to_dict()
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
@app.post("/api/tasks/{task_id}/toggle")
def api_toggle_task(task_id: str) -> dict[str, object]:
try:
info, _message = toggle_task(task_id, CRON_ROOT)
return info.to_dict()
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
@app.post("/api/tasks/{task_id}/sync-cron", response_model=MessageResponse)
def api_sync_cron(task_id: str) -> MessageResponse:
try:
ctx = get_context(task_id, CRON_ROOT)
message = sync_cron_d(ctx, task_is_enabled(ctx)) or "已同步"
return MessageResponse(message=message)
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
@app.post("/api/tasks/{task_id}/run", response_model=RunResponse)
def api_run_task(task_id: str) -> RunResponse:
try:
get_context(task_id, CRON_ROOT)
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
with _run_lock:
existing = _run_jobs.get(task_id)
if existing is not None and existing.poll() is None:
return RunResponse(
ok=False,
message="任务已在后台运行",
pid=existing.pid,
)
proc = subprocess.Popen(
[sys.executable, str(CRON_ROOT / "cronctl.py"), "run", task_id],
cwd=str(CRON_ROOT),
)
_run_jobs[task_id] = proc
threading.Thread(
target=_cleanup_run_job,
args=(task_id, proc),
daemon=True,
).start()
return RunResponse(message="已在后台启动", pid=proc.pid)
@app.get("/api/tasks/{task_id}/log")
def api_task_log(
task_id: str,
lines: int = Query(default=200, ge=1, le=2000),
) -> dict[str, str]:
try:
content = read_log_tail(task_id, lines, CRON_ROOT)
return {"task_id": task_id, "content": content}
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
@app.get("/")
def index() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html")
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")

View File

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0

303
webui/frontend/app.js Normal file
View File

@@ -0,0 +1,303 @@
const API = "/api";
const REFRESH_INTERVAL = 15000;
const els = {
taskBody: document.getElementById("task-body"),
taskCards: document.getElementById("task-cards"),
stats: document.getElementById("stats"),
summaryText: document.getElementById("summary-text"),
lastRefresh: document.getElementById("last-refresh"),
filter: document.getElementById("filter"),
toast: document.getElementById("toast"),
logDialog: document.getElementById("log-dialog"),
logTitle: document.getElementById("log-title"),
logSubtitle: document.getElementById("log-subtitle"),
logContent: document.getElementById("log-content"),
sidebar: document.getElementById("sidebar"),
overlay: document.getElementById("overlay"),
};
let allTasks = [];
let currentLogTaskId = null;
let refreshTimer = null;
const escapeHtml = (str) =>
String(str ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function nowText() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function showToast(message, isError = false) {
els.toast.hidden = false;
els.toast.textContent = message;
els.toast.classList.toggle("is-error", isError);
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => {
els.toast.hidden = true;
}, 2800);
}
async function api(path, options = {}) {
const res = await fetch(`${API}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || data.message || `请求失败 (${res.status})`);
}
return data;
}
function statusMarkup(task) {
if (task.running) {
return '<span class="status run"><span class="dot"></span>运行中</span>';
}
if (task.enabled) {
return '<span class="status on"><span class="dot"></span>已开启</span>';
}
return '<span class="status off"><span class="dot"></span>已关闭</span>';
}
function scheduleMarkup(task) {
return task.schedule
? `<span class="schedule-code">${escapeHtml(task.schedule)}</span>`
: '<span class="mono">—</span>';
}
function logMarkup(task) {
const size = formatBytes(task.log_size);
const updated = task.log_updated
? `<span class="muted">${escapeHtml(task.log_updated)}</span>`
: '<span class="muted">无记录</span>';
return `<div class="log-meta">${size}</div>${updated}`;
}
function actionButtons(task) {
const toggleLabel = task.enabled ? "关闭" : "开启";
const toggleClass = task.enabled ? "btn-danger" : "btn-success";
return `
<div class="actions">
<button type="button" class="btn ${toggleClass}" data-action="toggle" data-id="${escapeHtml(task.task_id)}">${toggleLabel}</button>
<button type="button" class="btn" data-action="run" data-id="${escapeHtml(task.task_id)}" ${task.running ? "disabled" : ""}>运行</button>
<button type="button" class="btn" data-action="sync" data-id="${escapeHtml(task.task_id)}">同步</button>
<button type="button" class="btn" data-action="log" data-id="${escapeHtml(task.task_id)}">日志</button>
</div>
`;
}
function nameMarkup(task) {
return `
<div class="task-name">${escapeHtml(task.task_id)}</div>
${task.description ? `<div class="task-desc">${escapeHtml(task.description)}</div>` : ""}
`;
}
function renderStats(tasks) {
const enabled = tasks.filter((t) => t.enabled).length;
const running = tasks.filter((t) => t.running).length;
const off = tasks.length - enabled;
els.stats.innerHTML = `
<div class="meta-item">
<span class="label">任务总数</span>
<span class="value">${tasks.length}</span>
</div>
<div class="meta-item is-accent">
<span class="label">已开启</span>
<span class="value">${enabled}</span>
</div>
<div class="meta-item">
<span class="label">已关闭</span>
<span class="value">${off}</span>
</div>
<div class="meta-item is-run">
<span class="label">运行中</span>
<span class="value">${running}</span>
</div>
`;
els.summaryText.textContent = running
? `${tasks.length} 个任务,${enabled} 个已开启,${running} 个正在运行`
: `${tasks.length} 个任务,${enabled} 个已开启`;
}
function renderTasks(tasks) {
if (!tasks.length) {
els.taskBody.innerHTML =
'<tr><td colspan="5" class="empty">没有匹配的任务</td></tr>';
els.taskCards.innerHTML = '<div class="empty">没有匹配的任务</div>';
return;
}
els.taskBody.innerHTML = tasks
.map(
(task) => `
<tr>
<td>${statusMarkup(task)}</td>
<td>${nameMarkup(task)}</td>
<td>${scheduleMarkup(task)}</td>
<td>${logMarkup(task)}</td>
<td>${actionButtons(task)}</td>
</tr>
`
)
.join("");
els.taskCards.innerHTML = tasks
.map(
(task) => `
<article class="task-card">
<div class="task-card-head">
<div class="name">${nameMarkup(task)}</div>
${statusMarkup(task)}
</div>
<div class="task-card-body">
<div>
<span class="field-label">调度</span>
${scheduleMarkup(task)}
</div>
<div>
<span class="field-label">日志</span>
<span class="log-meta">${formatBytes(task.log_size)}</span>
<span class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</span>
</div>
</div>
${actionButtons(task)}
</article>
`
)
.join("");
}
function applyFilter() {
const q = els.filter.value.trim().toLowerCase();
const filtered = q
? allTasks.filter((t) =>
t.task_id.toLowerCase().includes(q) ||
(t.description || "").toLowerCase().includes(q)
)
: allTasks;
renderTasks(filtered);
}
async function loadTasks() {
try {
const tasks = await api("/tasks");
allTasks = tasks;
renderStats(tasks);
applyFilter();
els.lastRefresh.textContent = `已刷新 ${nowText()}`;
} catch (err) {
els.taskBody.innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(err.message)}</td></tr>`;
els.taskCards.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`;
els.lastRefresh.textContent = `刷新失败 ${nowText()}`;
showToast(err.message, true);
}
}
async function handleAction(action, taskId) {
try {
if (action === "log") {
await openLog(taskId);
return;
}
if (action === "run") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/run`, { method: "POST" });
showToast(res.ok === false ? res.message : `已启动 ${taskId}`);
} else if (action === "sync") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/sync-cron`, { method: "POST" });
showToast(res.message || `已同步 ${taskId} 的 cron`);
} else if (action === "toggle") {
await api(`/tasks/${encodeURIComponent(taskId)}/toggle`, { method: "POST" });
showToast(`已切换 ${taskId} 状态`);
}
await loadTasks();
} catch (err) {
showToast(err.message, true);
}
}
async function openLog(taskId) {
currentLogTaskId = taskId;
els.logTitle.textContent = taskId;
els.logSubtitle.textContent = "加载日志…";
els.logContent.textContent = "";
els.logDialog.showModal();
await refreshLog();
}
async function refreshLog() {
if (!currentLogTaskId) return;
try {
const data = await api(`/tasks/${encodeURIComponent(currentLogTaskId)}/log?lines=300`);
const lineCount = data.content ? data.content.split("\n").length : 0;
els.logSubtitle.textContent = `最近 ${lineCount}`;
els.logContent.textContent = data.content || "";
els.logContent.scrollTop = els.logContent.scrollHeight;
} catch (err) {
els.logSubtitle.textContent = err.message;
showToast(err.message, true);
}
}
function bindActions(root) {
root.addEventListener("click", (event) => {
const btn = event.target.closest("[data-action]");
if (!btn) return;
handleAction(btn.dataset.action, btn.dataset.id);
});
}
function closeSidebar() {
els.sidebar.classList.remove("open");
els.overlay.hidden = true;
}
document.getElementById("btn-refresh").addEventListener("click", loadTasks);
document.getElementById("btn-refresh-top").addEventListener("click", loadTasks);
document.getElementById("btn-log-refresh").addEventListener("click", refreshLog);
document.getElementById("btn-log-close").addEventListener("click", () => els.logDialog.close());
els.filter.addEventListener("input", applyFilter);
document.getElementById("menu-toggle").addEventListener("click", () => {
els.sidebar.classList.add("open");
els.overlay.hidden = false;
});
els.overlay.addEventListener("click", closeSidebar);
els.logDialog.addEventListener("click", (event) => {
if (event.target === els.logDialog) els.logDialog.close();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (els.logDialog.open) els.logDialog.close();
else if (els.sidebar.classList.contains("open")) closeSidebar();
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
els.filter.focus();
}
});
bindActions(document.body);
loadTasks();
refreshTimer = setInterval(loadTasks, REFRESH_INTERVAL);
window.addEventListener("beforeunload", () => clearInterval(refreshTimer));

100
webui/frontend/index.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SproutClaw Cron · 定时任务管理</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="layout">
<aside class="sidebar" id="sidebar" aria-label="主导航">
<div class="brand">
<div class="brand-text">
<h1>SproutClaw Cron</h1>
<p>定时任务管理面板</p>
</div>
</div>
<nav class="sidebar-nav">
<button type="button" class="nav-item active" data-view="tasks">
<span class="nav-dot" aria-hidden="true"></span>
<span>任务清单</span>
</button>
</nav>
<div class="sidebar-foot">
<button type="button" class="btn btn-ghost btn-block" id="btn-refresh">刷新列表</button>
</div>
</aside>
<div class="overlay" id="overlay" hidden></div>
<main class="main">
<header class="topbar">
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="打开菜单">菜单</button>
<div class="masthead">
<h2>定时任务管理</h2>
<p class="masthead-meta" id="summary-text">加载中…</p>
</div>
<button type="button" class="btn btn-primary" id="btn-refresh-top">刷新</button>
</header>
<section class="document">
<div class="doc-meta" id="stats" aria-live="polite"></div>
<div class="toolbar">
<div class="search">
<input type="search" id="filter" placeholder="按任务名或说明过滤…" autocomplete="off" />
</div>
<span class="last-refresh" id="last-refresh"></span>
</div>
<section class="doc-section">
<div class="section-head">
<h3>任务清单</h3>
</div>
<div class="table-wrap">
<table class="task-table" id="task-table">
<thead>
<tr>
<th class="col-status">状态</th>
<th class="col-name">任务</th>
<th class="col-schedule">调度</th>
<th class="col-log">日志</th>
<th class="col-actions">操作</th>
</tr>
</thead>
<tbody id="task-body">
<tr><td colspan="5" class="empty">加载中…</td></tr>
</tbody>
</table>
</div>
<div class="task-cards" id="task-cards" aria-label="任务列表"></div>
</section>
</section>
</main>
</div>
<dialog class="log-dialog" id="log-dialog">
<div class="log-dialog-head">
<div class="log-dialog-title">
<span class="kicker">运行日志</span>
<h3 id="log-title">任务日志</h3>
<p id="log-subtitle"></p>
</div>
<div class="log-dialog-actions">
<button type="button" class="btn btn-ghost" id="btn-log-refresh">刷新日志</button>
<button type="button" class="btn" id="btn-log-close">关闭</button>
</div>
</div>
<pre class="log-content" id="log-content"></pre>
</dialog>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>

566
webui/frontend/styles.css Normal file
View File

@@ -0,0 +1,566 @@
/* SproutClaw Cron · 文档式后台管理面板 */
:root {
/* 配色:暖纸 + 墨色 + 萌芽绿点缀 */
--paper: #faf9f7;
--paper-raised: #ffffff;
--ink: #1c1a17;
--ink-soft: #6b6660;
--ink-faint: #9a948c;
--rule: #e7e2da;
--rule-strong: #d8d2c8;
--accent: #2d6a4f;
--accent-hover: #245340;
--accent-soft: #e6efe9;
--accent-line: #2d6a4f;
--status-on: #2d6a4f;
--status-on-bg: #e6efe9;
--status-off: #9a948c;
--status-off-bg: #efece6;
--status-run: #b45309;
--status-run-bg: #fbf3e6;
--danger: #9b2c2c;
--danger-bg: #f7eded;
--danger-line: #e6c9c9;
--shadow: 0 1px 2px rgba(28, 26, 23, 0.04);
--radius: 6px;
--sidebar-w: 232px;
--doc-max: 1080px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
--font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Source Serif Pro", Georgia, "Songti SC", serif;
--font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: var(--font-sans);
font-size: 15px;
color: var(--ink);
background: var(--paper);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body { overflow-x: hidden; }
button, input, select, textarea, dialog {
font: inherit;
color: inherit;
}
button { cursor: pointer; }
table, caption, th, td { font: inherit; }
code, pre, kbd, samp { font-family: var(--font-mono); }
/* ===== 顶部萌芽绿细条(签名) ===== */
.layout::before {
content: "";
position: fixed;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--accent-line);
z-index: 40;
}
.layout { min-height: 100vh; display: flex; padding-top: 3px; }
/* ===== 侧栏 ===== */
.sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
background: var(--paper-raised);
border-right: 1px solid var(--rule);
padding: 1.5rem 1rem 1.25rem;
display: flex;
flex-direction: column;
position: sticky;
top: 3px;
height: calc(100vh - 3px);
z-index: 20;
}
.brand {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0 0.25rem 1.1rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 1.1rem;
}
.brand-text h1 {
margin: 0;
font-family: var(--font-serif);
font-size: 1.05rem;
font-weight: 600;
line-height: 1.15;
letter-spacing: 0.01em;
}
.brand-text p {
margin: 0.2rem 0 0;
font-size: 0.74rem;
color: var(--ink-soft);
letter-spacing: 0.02em;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 0.15rem; }
.nav-item {
border: none;
background: transparent;
text-align: left;
padding: 0.55rem 0.6rem;
border-radius: var(--radius);
color: var(--ink-soft);
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.12s, color 0.12s;
}
.nav-item:hover { background: var(--paper); color: var(--ink); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.nav-dot {
width: 6px; height: 6px; border-radius: 50%;
background: currentColor;
opacity: 0.55;
}
.nav-item.active .nav-dot { opacity: 1; }
.sidebar-foot { margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--rule); }
.auto-note {
margin: 0.5rem 0 0;
font-size: 0.72rem;
color: var(--ink-faint);
text-align: center;
}
/* ===== 主区 ===== */
.main { flex: 1; min-width: 0; }
.topbar {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem 2rem 1.25rem;
background: var(--paper);
border-bottom: 1px solid var(--rule);
}
.menu-toggle { display: none; }
.masthead { flex: 1; min-width: 0; }
.kicker {
display: block;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: 0.3rem;
}
.masthead h2 {
margin: 0;
font-family: var(--font-serif);
font-size: 1.6rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.005em;
}
.masthead-meta {
margin: 0.45rem 0 0;
font-size: 0.84rem;
color: var(--ink-soft);
}
/* ===== 文档主体 ===== */
.document {
max-width: var(--doc-max);
margin: 0 auto;
padding: 1.75rem 2rem 3rem;
}
.doc-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 1.5rem;
}
.meta-item { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }
.meta-item .label {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
}
.meta-item .value {
font-family: var(--font-serif);
font-size: 1.35rem;
font-weight: 600;
line-height: 1;
}
.meta-item.is-accent .value { color: var(--accent); }
.meta-item.is-run .value { color: var(--status-run); }
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
background: var(--paper-raised);
border: 1px solid var(--rule);
border-radius: var(--radius);
transition: border-color 0.12s, box-shadow 0.12s;
}
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search input {
flex: 1;
border: none;
outline: none;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: var(--ink);
min-width: 0;
}
.search input::placeholder { color: var(--ink-faint); }
.last-refresh {
font-size: 0.76rem;
color: var(--ink-faint);
white-space: nowrap;
}
/* ===== 文档分节 ===== */
.doc-section { margin-top: 0.5rem; }
.section-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.85rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--rule);
}
.section-head h3 {
margin: 0;
font-family: var(--font-serif);
font-size: 1.1rem;
font-weight: 600;
}
.section-hint {
font-size: 0.78rem;
color: var(--ink-faint);
}
/* ===== 任务表格(桌面) ===== */
.table-wrap { overflow-x: auto; }
.task-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.task-table th {
padding: 0.55rem 0.75rem;
text-align: left;
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
font-weight: 600;
border-bottom: 1px solid var(--rule-strong);
white-space: nowrap;
}
.task-table td {
padding: 0.85rem 0.75rem;
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
.task-table tbody tr { transition: background 0.1s; }
.task-table tbody tr:hover { background: var(--paper-raised); }
.task-table tbody tr:last-child td { border-bottom: none; }
.col-status { width: 88px; }
.col-actions { width: 1%; white-space: nowrap; }
.col-schedule { width: 170px; }
.col-log { width: 150px; }
.task-name {
font-weight: 600;
font-size: 0.92rem;
word-break: break-word;
}
.task-desc {
margin-top: 0.2rem;
font-size: 0.8rem;
color: var(--ink-soft);
word-break: break-word;
overflow-wrap: anywhere;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
font-weight: 500;
}
.status .dot {
width: 7px; height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status.on { color: var(--status-on); }
.status.on .dot { background: var(--status-on); }
.status.off { color: var(--status-off); }
.status.off .dot { background: var(--status-off); }
.status.run { color: var(--status-run); }
.status.run .dot {
background: var(--status-run);
box-shadow: 0 0 0 0 var(--status-run-bg);
animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(180, 83, 9, 0.45); }
70% { box-shadow: 0 0 0 5px rgba(180, 83, 9, 0); }
100% { box-shadow: 0 0 0 0 rgba(180, 83, 9, 0); }
}
.mono { font-family: var(--font-mono); font-size: 0.8rem; color: var(--ink-soft); }
.schedule-code {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--ink);
background: var(--paper-raised);
border: 1px solid var(--rule);
border-radius: 4px;
padding: 0.1rem 0.35rem;
}
.log-meta { font-size: 0.78rem; color: var(--ink-soft); line-height: 1.5; }
.log-meta .muted { color: var(--ink-faint); }
.actions { display: flex; flex-wrap: wrap; gap: 0.3rem; }
/* ===== 按钮 ===== */
.btn {
border: 1px solid var(--rule);
background: var(--paper-raised);
color: var(--ink);
border-radius: var(--radius);
padding: 0.4rem 0.7rem;
font-size: 0.8rem;
line-height: 1.2;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover { background: var(--paper); border-color: var(--rule-strong); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-success { color: var(--accent); border-color: var(--accent-soft); background: var(--accent-soft); }
.btn-success:hover { background: #dce8e1; border-color: #d2e2da; }
.btn-danger { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); }
.btn-danger:hover { background: #f1e3e3; }
.btn-ghost { border-color: transparent; background: transparent; }
.btn-ghost:hover { background: var(--paper); border-color: var(--rule); }
.btn-block { width: 100%; }
.empty {
text-align: center;
color: var(--ink-faint);
padding: 2.25rem 1rem !important;
font-size: 0.88rem;
}
/* ===== 移动端任务条目 ===== */
.task-cards { display: none; }
.task-card {
padding: 1rem 0;
border-bottom: 1px solid var(--rule);
}
.task-card:first-child { padding-top: 0.25rem; }
.task-card:last-child { border-bottom: none; padding-bottom: 0.25rem; }
.task-card-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.task-card-head > .name { min-width: 0; flex: 1; }
.task-card-body {
margin: 0.65rem 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem 1rem;
font-size: 0.82rem;
}
.task-card-body .field-label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
margin-bottom: 0.15rem;
}
.task-card .actions { margin-top: 0.75rem; }
.task-card .actions .btn { flex: 1 1 calc(50% - 0.3rem); min-width: 0; text-align: center; }
/* ===== 抽屉遮罩 ===== */
.overlay {
position: fixed;
inset: 0;
background: rgba(28, 26, 23, 0.32);
z-index: 15;
}
.overlay[hidden] { display: none; }
/* ===== 日志弹层 ===== */
.log-dialog {
width: min(960px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: 0;
background: var(--paper-raised);
box-shadow: 0 12px 40px rgba(28, 26, 23, 0.18);
color: var(--ink);
}
.log-dialog::backdrop { background: rgba(28, 26, 23, 0.42); }
.log-dialog-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--rule);
}
.log-dialog-title .kicker { margin-bottom: 0.2rem; }
.log-dialog-title h3 {
margin: 0;
font-family: var(--font-mono);
font-size: 0.95rem;
font-weight: 600;
}
.log-dialog-title p { margin: 0.25rem 0 0; font-size: 0.8rem; color: var(--ink-soft); }
.log-dialog-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.log-content {
margin: 0;
padding: 1.1rem 1.25rem;
max-height: 62vh;
overflow: auto;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
background: #1b1a17;
color: #e7e2da;
}
.log-content:empty::before { content: "(暂无日志)"; color: #6b6660; }
/* ===== Toast ===== */
.toast {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
background: var(--ink);
color: #fff;
padding: 0.6rem 1.1rem;
border-radius: 999px;
font-size: 0.84rem;
z-index: 60;
box-shadow: 0 6px 24px rgba(28, 26, 23, 0.25);
}
.toast.is-error { background: var(--danger); }
/* ===== 响应式 ===== */
@media (max-width: 920px) {
.topbar { padding: 1.1rem 1.25rem 1rem; }
.document { padding: 1.25rem 1.25rem 2.5rem; }
.masthead h2 { font-size: 1.35rem; }
.meta-item .value { font-size: 1.2rem; }
}
@media (max-width: 720px) {
.sidebar {
position: fixed;
left: 0; top: 3px; bottom: 0;
height: calc(100vh - 3px);
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 0 0 40px rgba(28, 26, 23, 0.18);
}
.sidebar.open { transform: translateX(0); }
.overlay:not([hidden]) { display: block; }
.menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
height: 38px;
padding: 0 0.9rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-raised);
font-size: 0.84rem;
flex-shrink: 0;
}
.topbar { padding: 0.9rem 0.9rem 0.85rem; gap: 0.6rem; }
.masthead h2 { font-size: 1.2rem; }
#btn-refresh-top { flex-shrink: 0; padding-inline: 0.6rem; }
.document { padding: 1rem 0.9rem 2rem; }
/* 统计折叠为单行紧凑元数据 */
.doc-meta { gap: 0.4rem 1.25rem; padding-bottom: 1rem; margin-bottom: 1.1rem; }
.meta-item .value { font-size: 1.05rem; }
.meta-item .label { font-size: 0.66rem; }
.toolbar { gap: 0.6rem; }
.last-refresh { font-size: 0.72rem; }
/* 桌面表格隐藏,启用移动条目 */
.table-wrap { display: none; }
.task-cards { display: block; }
.log-dialog-head { flex-direction: column; gap: 0.6rem; }
.log-dialog-actions { width: 100%; }
.log-dialog-actions .btn { flex: 1; }
.log-content { max-height: 55vh; padding: 0.9rem 1rem; }
}
@media (max-width: 380px) {
.task-card-body { grid-template-columns: 1fr; }
}
/* 减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}

16
webui/start.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND="$ROOT/webui/backend"
VENV="$BACKEND/.venv"
if [[ ! -d "$VENV" ]]; then
python3 -m venv "$VENV"
"$VENV/bin/pip" install -r "$BACKEND/requirements.txt"
fi
HOST="${SPROUTCLAW_CRON_WEB_HOST:-0.0.0.0}"
PORT="${SPROUTCLAW_CRON_WEB_PORT:-8765}"
exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT"