Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29176fc66b |
@@ -22,9 +22,12 @@
|
|||||||
## 功能总览
|
## 功能总览
|
||||||
|
|
||||||
### 1. 前端管理界面
|
### 1. 前端管理界面
|
||||||
|
- **总体统计区**:任务总数 / 运行中 / 累计运行次数 / 成功失败页面 / 图片数 / 磁盘占用
|
||||||
|
- **日间/夜间双主题**:右上角按钮一键切换,自动记忆选择
|
||||||
- 任务卡片总览:状态、进度、统计、下次调度时间一目了然
|
- 任务卡片总览:状态、进度、统计、下次调度时间一目了然
|
||||||
- 一键操作:开始 / 暂停 / 恢复 / 终止 / 编辑 / 删除
|
- 一键操作:开始 / 暂停 / 恢复 / 终止 / 编辑 / 删除
|
||||||
- 任务详情:历次运行记录、结果明细表(HTML/TXT 在线预览)、图片缩略图、实时日志
|
- 任务详情:历次运行记录、结果明细表(HTML/TXT 在线预览)、图片缩略图、实时日志
|
||||||
|
- **防误关保护**:新建/编辑弹窗有未保存修改时,点窗口外 / ESC / 关闭会先确认
|
||||||
- 运行中的任务参数支持**热更新**(修改后从下一页起生效)
|
- 运行中的任务参数支持**热更新**(修改后从下一页起生效)
|
||||||
|
|
||||||
### 2. 批量爬取模式
|
### 2. 批量爬取模式
|
||||||
@@ -36,8 +39,8 @@
|
|||||||
- 完成后邮件通知(复用 send_email.py,默认发到 wlq@tphai.com)
|
- 完成后邮件通知(复用 send_email.py,默认发到 wlq@tphai.com)
|
||||||
|
|
||||||
### 3. 定时爬取模式
|
### 3. 定时爬取模式
|
||||||
- **间隔调度**:每 N 分钟 / 小时 / 天
|
- **间隔调度**:每 N 分钟 / 小时 / 天,可指定**首次执行时间**(留空=尽快,已过时间立即执行)
|
||||||
- **cron 表达式**:5 段式(分 时 日 月 周,周 0/7=周日),如 `0 3 * * *` 每天凌晨 3 点
|
- **cron 表达式**:5 段式(分 时 日 月 周,周 0/7=周日),如 `0 3 * * *` 每天凌晨 3 点,同样支持首次执行时间作为计算起点
|
||||||
- 可启用/停用调度,自动计算下次执行时间;到点自动开跑,跑完自动计算下一次
|
- 可启用/停用调度,自动计算下次执行时间;到点自动开跑,跑完自动计算下一次
|
||||||
|
|
||||||
### 4. 自动爬取模式
|
### 4. 自动爬取模式
|
||||||
|
|||||||
@@ -38,6 +38,33 @@ def now_str():
|
|||||||
return store.now_str()
|
return store.now_str()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_first_run(s):
|
||||||
|
"""解析表单提交的首次执行时间 (datetime-local 格式), 非法返回 None"""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(s), "%Y-%m-%dT%H:%M")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_next_run(sch):
|
||||||
|
"""根据调度配置 + 首次执行时间计算 next_run (str)"""
|
||||||
|
first_run = _parse_first_run(sch.get("first_run"))
|
||||||
|
base = first_run if (first_run and first_run > datetime.now()) else None
|
||||||
|
if sch.get("type") == "cron":
|
||||||
|
expr = sch.get("cron") or "0 * * * *"
|
||||||
|
nn = cron_next(expr, base or datetime.now())
|
||||||
|
if not nn:
|
||||||
|
raise ValueError("cron 表达式在未来一年内无匹配时间")
|
||||||
|
return nn.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
sch.setdefault("interval_unit", "hours")
|
||||||
|
sch.setdefault("interval_value", 24)
|
||||||
|
if base:
|
||||||
|
return base.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
return (datetime.now() + interval_delta(sch)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
def resolve_out_dir(task):
|
def resolve_out_dir(task):
|
||||||
cfg = task.get("config", {}) or {}
|
cfg = task.get("config", {}) or {}
|
||||||
if cfg.get("out_dir", "").strip():
|
if cfg.get("out_dir", "").strip():
|
||||||
@@ -161,16 +188,7 @@ def api_create_task():
|
|||||||
sch.setdefault("enabled", True)
|
sch.setdefault("enabled", True)
|
||||||
sch.setdefault("type", "interval")
|
sch.setdefault("type", "interval")
|
||||||
try:
|
try:
|
||||||
if sch.get("type") == "cron":
|
sch["next_run"] = _schedule_next_run(sch)
|
||||||
expr = sch.get("cron") or "0 * * * *"
|
|
||||||
nn = cron_next(expr)
|
|
||||||
if not nn:
|
|
||||||
raise ValueError("cron 表达式在未来一年内无匹配时间")
|
|
||||||
sch["next_run"] = nn.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
else:
|
|
||||||
sch.setdefault("interval_unit", "hours")
|
|
||||||
sch.setdefault("interval_value", 24)
|
|
||||||
sch["next_run"] = (datetime.now() + interval_delta(sch)).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": f"调度配置错误: {e}"}), 400
|
return jsonify({"error": f"调度配置错误: {e}"}), 400
|
||||||
sch.setdefault("last_run", "")
|
sch.setdefault("last_run", "")
|
||||||
@@ -221,13 +239,7 @@ def api_update_task(tid):
|
|||||||
if "schedule" in body and task.get("mode") == "scheduled":
|
if "schedule" in body and task.get("mode") == "scheduled":
|
||||||
sch = {**task.get("schedule", {}), **body["schedule"]}
|
sch = {**task.get("schedule", {}), **body["schedule"]}
|
||||||
try:
|
try:
|
||||||
if sch.get("type") == "cron":
|
sch["next_run"] = _schedule_next_run(sch)
|
||||||
nn = cron_next(sch.get("cron") or "0 * * * *")
|
|
||||||
if not nn:
|
|
||||||
raise ValueError("cron 表达式在未来一年内无匹配时间")
|
|
||||||
sch["next_run"] = nn.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
else:
|
|
||||||
sch["next_run"] = (datetime.now() + interval_delta(sch)).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": f"调度配置错误: {e}"}), 400
|
return jsonify({"error": f"调度配置错误: {e}"}), 400
|
||||||
task["schedule"] = sch
|
task["schedule"] = sch
|
||||||
@@ -285,6 +297,46 @@ def api_resume(tid):
|
|||||||
return jsonify({"error": "任务未在运行"}), 409
|
return jsonify({"error": "任务未在运行"}), 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- API: 统计 ----------------
|
||||||
|
|
||||||
|
@app.route("/api/stats")
|
||||||
|
def api_stats():
|
||||||
|
tasks = store.load_tasks()
|
||||||
|
total_runs = ok = fail = imgs = 0
|
||||||
|
for t in tasks:
|
||||||
|
for r in store.get_runs(t["id"]):
|
||||||
|
total_runs += 1
|
||||||
|
st = r.get("stats") or {}
|
||||||
|
ok += st.get("ok", 0)
|
||||||
|
fail += st.get("fail", 0)
|
||||||
|
imgs += st.get("images", 0)
|
||||||
|
with JOBS_LOCK:
|
||||||
|
running = sum(1 for j in JOBS.values() if j.is_running())
|
||||||
|
# 统计各任务输出目录的磁盘占用
|
||||||
|
size = 0
|
||||||
|
seen = set()
|
||||||
|
for t in tasks:
|
||||||
|
d = os.path.realpath(resolve_out_dir(t))
|
||||||
|
if d in seen or not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
seen.add(d)
|
||||||
|
for root, _dirs, files in os.walk(d):
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
size += os.path.getsize(os.path.join(root, f))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return jsonify({
|
||||||
|
"tasks": len(tasks),
|
||||||
|
"running": running,
|
||||||
|
"runs": total_runs,
|
||||||
|
"ok": ok,
|
||||||
|
"fail": fail,
|
||||||
|
"images": imgs,
|
||||||
|
"disk_mb": round(size / 1048576, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- API: 运行记录与文件 ----------------
|
# ---------------- API: 运行记录与文件 ----------------
|
||||||
|
|
||||||
@app.route("/api/runs/<rid>")
|
@app.route("/api/runs/<rid>")
|
||||||
|
|||||||
+69
-5
@@ -11,6 +11,7 @@ const state = {
|
|||||||
detail: { task: null, runId: null, logOffset: 0, timer: null },
|
detail: { task: null, runId: null, logOffset: 0, timer: null },
|
||||||
logTimer: null,
|
logTimer: null,
|
||||||
};
|
};
|
||||||
|
let formDirty = false; // 新建/编辑表单是否有未保存修改
|
||||||
|
|
||||||
/* ---------------- 工具 ---------------- */
|
/* ---------------- 工具 ---------------- */
|
||||||
function toast(msg, isErr) {
|
function toast(msg, isErr) {
|
||||||
@@ -48,6 +49,21 @@ async function loadTasks() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast("加载任务失败: " + e.message, true);
|
toast("加载任务失败: " + e.message, true);
|
||||||
}
|
}
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const s = await api("/api/stats");
|
||||||
|
$("stTasks").textContent = s.tasks;
|
||||||
|
$("stRunning").textContent = s.running;
|
||||||
|
$("stRuns").textContent = s.runs;
|
||||||
|
$("stOk").textContent = s.ok;
|
||||||
|
$("stFail").textContent = s.fail;
|
||||||
|
$("stImgs").textContent = s.images;
|
||||||
|
$("stDisk").textContent = s.disk_mb >= 1024
|
||||||
|
? (s.disk_mb / 1024).toFixed(1) + " GB" : s.disk_mb + " MB";
|
||||||
|
} catch (e) { /* 统计失败忽略 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskStatusBadge(t) {
|
function taskStatusBadge(t) {
|
||||||
@@ -156,8 +172,17 @@ function switchMode(mode, lock) {
|
|||||||
$("autoBox").classList.toggle("hidden", mode !== "auto");
|
$("autoBox").classList.toggle("hidden", mode !== "auto");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLocalInputVal(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(String(iso).replace(" ", "T"));
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
state.editTask = null;
|
state.editTask = null;
|
||||||
|
formDirty = false;
|
||||||
$("modalTitle").textContent = "新建爬取任务";
|
$("modalTitle").textContent = "新建爬取任务";
|
||||||
$("taskForm").reset();
|
$("taskForm").reset();
|
||||||
$("taskForm").elements["delay_min"].value = 2;
|
$("taskForm").elements["delay_min"].value = 2;
|
||||||
@@ -195,6 +220,7 @@ async function openEdit(tid) {
|
|||||||
f.elements["interval_value"].value = t.schedule.interval_value ?? 24;
|
f.elements["interval_value"].value = t.schedule.interval_value ?? 24;
|
||||||
f.elements["interval_unit"].value = t.schedule.interval_unit || "hours";
|
f.elements["interval_unit"].value = t.schedule.interval_unit || "hours";
|
||||||
f.elements["cron"].value = t.schedule.cron || "";
|
f.elements["cron"].value = t.schedule.cron || "";
|
||||||
|
f.elements["first_run"].value = toLocalInputVal(t.schedule.next_run || "");
|
||||||
}
|
}
|
||||||
if (t.mode === "auto" && t.auto) {
|
if (t.mode === "auto" && t.auto) {
|
||||||
f.elements["seed_url"].value = t.auto.seed_url || "";
|
f.elements["seed_url"].value = t.auto.seed_url || "";
|
||||||
@@ -209,6 +235,7 @@ async function openEdit(tid) {
|
|||||||
$("formHint").textContent = t.running
|
$("formHint").textContent = t.running
|
||||||
? "⚠️ 任务运行中:参数修改将热更新(网址/规则改动下次运行生效)"
|
? "⚠️ 任务运行中:参数修改将热更新(网址/规则改动下次运行生效)"
|
||||||
: "";
|
: "";
|
||||||
|
formDirty = false;
|
||||||
switchMode(t.mode, true);
|
switchMode(t.mode, true);
|
||||||
showModal("taskModal");
|
showModal("taskModal");
|
||||||
}
|
}
|
||||||
@@ -259,6 +286,7 @@ async function submitForm(e) {
|
|||||||
interval_unit: f.elements["interval_unit"].value,
|
interval_unit: f.elements["interval_unit"].value,
|
||||||
interval_value: parseInt(f.elements["interval_value"].value) || 1,
|
interval_value: parseInt(f.elements["interval_value"].value) || 1,
|
||||||
cron: f.elements["cron"].value.trim(),
|
cron: f.elements["cron"].value.trim(),
|
||||||
|
first_run: f.elements["first_run"].value || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -275,6 +303,7 @@ async function submitForm(e) {
|
|||||||
});
|
});
|
||||||
toast(`任务「${t.name}」已创建`);
|
toast(`任务「${t.name}」已创建`);
|
||||||
}
|
}
|
||||||
|
formDirty = false;
|
||||||
hideModal("taskModal");
|
hideModal("taskModal");
|
||||||
loadTasks();
|
loadTasks();
|
||||||
} catch (err) { toast(err.message, true); }
|
} catch (err) { toast(err.message, true); }
|
||||||
@@ -457,6 +486,29 @@ function previewFile(tid, path, title) {
|
|||||||
function showModal(id) { $(id).classList.remove("hidden"); }
|
function showModal(id) { $(id).classList.remove("hidden"); }
|
||||||
function hideModal(id) { $(id).classList.add("hidden"); }
|
function hideModal(id) { $(id).classList.add("hidden"); }
|
||||||
|
|
||||||
|
/* 关闭任务弹窗: 有未保存修改时先确认 */
|
||||||
|
function safeCloseTaskModal() {
|
||||||
|
if (formDirty && !confirm("有未保存的修改,确定要放弃吗?")) return false;
|
||||||
|
formDirty = false;
|
||||||
|
hideModal("taskModal");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 主题切换 ---------------- */
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.body.dataset.theme = theme;
|
||||||
|
$("btnTheme").textContent = theme === "light" ? "🌙" : "☀️";
|
||||||
|
try { localStorage.setItem("crawler_theme", theme); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
$("btnTheme").onclick = () => {
|
||||||
|
applyTheme(document.body.dataset.theme === "light" ? "dark" : "light");
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
applyTheme(localStorage.getItem("crawler_theme") || "dark");
|
||||||
|
} catch (e) {
|
||||||
|
applyTheme("dark");
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 事件绑定 ---------------- */
|
/* ---------------- 事件绑定 ---------------- */
|
||||||
$("btnNew").onclick = openCreate;
|
$("btnNew").onclick = openCreate;
|
||||||
$("btnNew2").onclick = openCreate;
|
$("btnNew2").onclick = openCreate;
|
||||||
@@ -468,24 +520,36 @@ document.querySelectorAll("#modeTabs .tab").forEach((b) => {
|
|||||||
$("taskForm").onsubmit = submitForm;
|
$("taskForm").onsubmit = submitForm;
|
||||||
$("taskForm").elements["schedule_type"].onchange = syncScheduleUI;
|
$("taskForm").elements["schedule_type"].onchange = syncScheduleUI;
|
||||||
|
|
||||||
document.querySelectorAll("[data-close]").forEach((b) => b.onclick = () => hideModal("taskModal"));
|
document.querySelectorAll("[data-close]").forEach((b) => b.onclick = safeCloseTaskModal);
|
||||||
document.querySelectorAll("[data-close-detail]").forEach((b) => b.onclick = () => { stopLogPoll(); hideModal("detailModal"); });
|
document.querySelectorAll("[data-close-detail]").forEach((b) => b.onclick = () => { stopLogPoll(); hideModal("detailModal"); });
|
||||||
document.querySelectorAll("[data-close-preview]").forEach((b) => b.onclick = () => { $("previewFrame").src = "about:blank"; hideModal("previewModal"); });
|
document.querySelectorAll("[data-close-preview]").forEach((b) => b.onclick = () => { $("previewFrame").src = "about:blank"; hideModal("previewModal"); });
|
||||||
|
|
||||||
|
/* 表单改动监听 -> 脏标记 */
|
||||||
|
$("taskForm").addEventListener("input", () => { formDirty = true; });
|
||||||
|
$("taskForm").addEventListener("change", () => { formDirty = true; });
|
||||||
|
|
||||||
document.querySelectorAll(".modal-overlay").forEach((ov) => {
|
document.querySelectorAll(".modal-overlay").forEach((ov) => {
|
||||||
ov.addEventListener("mousedown", (e) => {
|
ov.addEventListener("mousedown", (e) => {
|
||||||
if (e.target === ov) {
|
if (e.target === ov) {
|
||||||
if (ov.id === "detailModal") stopLogPoll();
|
if (ov.id === "taskModal") {
|
||||||
if (ov.id === "previewModal") $("previewFrame").src = "about:blank";
|
safeCloseTaskModal();
|
||||||
ov.classList.add("hidden");
|
} else {
|
||||||
|
if (ov.id === "detailModal") stopLogPoll();
|
||||||
|
if (ov.id === "previewModal") $("previewFrame").src = "about:blank";
|
||||||
|
ov.classList.add("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
|
if (!$("taskModal").classList.contains("hidden")) {
|
||||||
|
safeCloseTaskModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
stopLogPoll();
|
stopLogPoll();
|
||||||
$("previewFrame").src = "about:blank";
|
$("previewFrame").src = "about:blank";
|
||||||
["taskModal", "detailModal", "previewModal"].forEach((id) => $(id).classList.add("hidden"));
|
["detailModal", "previewModal"].forEach((id) => $(id).classList.add("hidden"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,22 @@
|
|||||||
<div class="logo">🕷️ 通用爬虫系统 <span id="version" class="version"></span></div>
|
<div class="logo">🕷️ 通用爬虫系统 <span id="version" class="version"></span></div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span class="status-pill" id="statusPill">运行中任务: 0</span>
|
<span class="status-pill" id="statusPill">运行中任务: 0</span>
|
||||||
|
<button id="btnTheme" class="btn ghost" title="切换日间/夜间主题">☀️</button>
|
||||||
<button id="btnRefresh" class="btn ghost">⟳ 刷新</button>
|
<button id="btnRefresh" class="btn ghost">⟳ 刷新</button>
|
||||||
<button id="btnNew" class="btn primary">+ 新建任务</button>
|
<button id="btnNew" class="btn primary">+ 新建任务</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div class="stats-bar" id="statsBar">
|
||||||
|
<div class="stat-card"><div class="stat-num" id="stTasks">0</div><div class="stat-label">📋 任务总数</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-num num-run" id="stRunning">0</div><div class="stat-label">🔄 正在运行</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-num" id="stRuns">0</div><div class="stat-label">📊 累计运行次数</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-num num-ok" id="stOk">0</div><div class="stat-label">✅ 成功页面</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-num num-fail" id="stFail">0</div><div class="stat-label">❌ 失败页面</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-num" id="stImgs">0</div><div class="stat-label">🖼️ 已爬图片</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-num" id="stDisk">0</div><div class="stat-label">💾 磁盘占用</div></div>
|
||||||
|
</div>
|
||||||
<div id="taskList" class="task-grid"></div>
|
<div id="taskList" class="task-grid"></div>
|
||||||
<div id="emptyState" class="empty hidden">
|
<div id="emptyState" class="empty hidden">
|
||||||
<div class="empty-icon">🕸️</div>
|
<div class="empty-icon">🕸️</div>
|
||||||
@@ -63,6 +73,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="scheduleBox" class="hidden box">
|
<div id="scheduleBox" class="hidden box">
|
||||||
|
<div class="field"><label>首次执行时间(可留空;留空 = 尽快执行;已过的时间将立即执行)</label>
|
||||||
|
<input name="first_run" type="datetime-local"></div>
|
||||||
<div class="row2">
|
<div class="row2">
|
||||||
<div class="field"><label>调度类型</label>
|
<div class="field"><label>调度类型</label>
|
||||||
<select name="schedule_type">
|
<select name="schedule_type">
|
||||||
|
|||||||
+63
-16
@@ -10,12 +10,36 @@
|
|||||||
--red: #ff5d6c;
|
--red: #ff5d6c;
|
||||||
--yellow: #ffc857;
|
--yellow: #ffc857;
|
||||||
--purple: #a78bfa;
|
--purple: #a78bfa;
|
||||||
|
--overlay: rgba(5, 8, 15, .72);
|
||||||
|
--box-bg: #141b2b;
|
||||||
|
--log-bg: #0b0f1a;
|
||||||
|
--thumb: #2a3550;
|
||||||
|
--preview-bg: #fff;
|
||||||
|
}
|
||||||
|
body[data-theme="light"] {
|
||||||
|
--bg: #f2f5fa;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel2: #eef1f7;
|
||||||
|
--border: #d8e0ee;
|
||||||
|
--text: #1b2436;
|
||||||
|
--muted: #5d6b86;
|
||||||
|
--accent: #2f6bff;
|
||||||
|
--green: #0e9f6e;
|
||||||
|
--red: #e04a5a;
|
||||||
|
--yellow: #b8860b;
|
||||||
|
--purple: #7c5cf0;
|
||||||
|
--overlay: rgba(15, 23, 42, .35);
|
||||||
|
--box-bg: #f7f9fd;
|
||||||
|
--log-bg: #f4f6fa;
|
||||||
|
--thumb: #cdd7e6;
|
||||||
|
--preview-bg: #fff;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
background: var(--bg); color: var(--text);
|
background: var(--bg); color: var(--text);
|
||||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
font-size: 14px; min-height: 100vh;
|
font-size: 14px; min-height: 100vh;
|
||||||
|
transition: background .25s, color .25s;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
@@ -38,7 +62,7 @@ main { padding: 20px 24px; max-width: 1500px; margin: 0 auto; }
|
|||||||
padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
||||||
transition: .15s; white-space: nowrap;
|
transition: .15s; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn:hover { border-color: var(--accent); color: #fff; }
|
.btn:hover { border-color: var(--accent); }
|
||||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.btn.primary:hover { filter: brightness(1.1); }
|
.btn.primary:hover { filter: brightness(1.1); }
|
||||||
.btn.ghost { background: transparent; }
|
.btn.ghost { background: transparent; }
|
||||||
@@ -46,6 +70,21 @@ main { padding: 20px 24px; max-width: 1500px; margin: 0 auto; }
|
|||||||
.btn.sm { padding: 3px 8px; font-size: 12px; border-radius: 6px; }
|
.btn.sm { padding: 3px 8px; font-size: 12px; border-radius: 6px; }
|
||||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ---------- 统计区 ---------- */
|
||||||
|
.stats-bar {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.stat-num { font-size: 24px; font-weight: 700; line-height: 1.2; }
|
||||||
|
.stat-num.num-ok { color: var(--green); }
|
||||||
|
.stat-num.num-fail { color: var(--red); }
|
||||||
|
.stat-num.num-run { color: var(--accent); }
|
||||||
|
.stat-label { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||||||
|
|
||||||
/* ---------- task grid ---------- */
|
/* ---------- task grid ---------- */
|
||||||
.task-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
|
.task-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
|
||||||
.card {
|
.card {
|
||||||
@@ -53,7 +92,7 @@ main { padding: 20px 24px; max-width: 1500px; margin: 0 auto; }
|
|||||||
border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||||
transition: .15s;
|
transition: .15s;
|
||||||
}
|
}
|
||||||
.card:hover { border-color: #3a4a75; transform: translateY(-1px); }
|
.card:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
.card-name { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.card-name { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.card-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
.card-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
@@ -61,7 +100,7 @@ main { padding: 20px 24px; max-width: 1500px; margin: 0 auto; }
|
|||||||
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
font-size: 11px; padding: 2px 8px; border-radius: 10px;
|
||||||
background: var(--panel2); border: 1px solid var(--border); color: var(--muted);
|
background: var(--panel2); border: 1px solid var(--border); color: var(--muted);
|
||||||
}
|
}
|
||||||
.badge.batch { color: #7cc4ff; border-color: #7cc4ff55; }
|
.badge.batch { color: var(--accent); border-color: var(--accent); }
|
||||||
.badge.scheduled { color: var(--purple); border-color: var(--purple); }
|
.badge.scheduled { color: var(--purple); border-color: var(--purple); }
|
||||||
.badge.auto { color: var(--yellow); border-color: var(--yellow); }
|
.badge.auto { color: var(--yellow); border-color: var(--yellow); }
|
||||||
.badge.running { color: var(--green); border-color: var(--green); animation: pulse 1.6s infinite; }
|
.badge.running { color: var(--green); border-color: var(--green); animation: pulse 1.6s infinite; }
|
||||||
@@ -88,7 +127,7 @@ main { padding: 20px 24px; max-width: 1500px; margin: 0 auto; }
|
|||||||
|
|
||||||
/* ---------- modal ---------- */
|
/* ---------- modal ---------- */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(5, 8, 15, .7);
|
position: fixed; inset: 0; background: var(--overlay);
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
@@ -101,24 +140,29 @@ main { padding: 20px 24px; max-width: 1500px; margin: 0 auto; }
|
|||||||
.modal.tall { width: 1000px; height: 88vh; }
|
.modal.tall { width: 1000px; height: 88vh; }
|
||||||
.modal-head {
|
.modal-head {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
padding: 14px 18px; border-bottom: 1px solid var(--border); font-size: 15px; font-weight: 600;
|
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 15px; font-weight: 600; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.modal-foot {
|
.modal-foot {
|
||||||
display: flex; justify-content: flex-end; gap: 10px; align-items: center;
|
display: flex; justify-content: flex-end; gap: 10px; align-items: center;
|
||||||
padding: 12px 18px; border-top: 1px solid var(--border);
|
padding: 12px 18px; border-top: 1px solid var(--border); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.hint { color: var(--yellow); font-size: 12px; margin-right: auto; }
|
.hint { color: var(--yellow); font-size: 12px; margin-right: auto; }
|
||||||
|
|
||||||
.tabs { display: flex; gap: 6px; padding: 12px 18px 0; }
|
.tabs { display: flex; gap: 6px; padding: 12px 18px 0; flex-shrink: 0; }
|
||||||
|
#taskForm { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||||
.tab {
|
.tab {
|
||||||
padding: 8px 16px; background: transparent; border: 1px solid var(--border);
|
padding: 8px 16px; background: transparent; border: 1px solid var(--border);
|
||||||
border-bottom: none; border-radius: 10px 10px 0 0; color: var(--muted);
|
border-bottom: none; border-radius: 10px 10px 0 0; color: var(--muted);
|
||||||
cursor: pointer; font-size: 13px;
|
cursor: pointer; font-size: 13px;
|
||||||
}
|
}
|
||||||
.tab.active { background: var(--panel2); color: #fff; border-color: var(--accent); }
|
.tab.active { background: var(--panel2); color: var(--text); border-color: var(--accent); }
|
||||||
.tab:disabled { opacity: .4; cursor: not-allowed; }
|
.tab:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
.form-body { padding: 16px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
|
.form-body {
|
||||||
|
padding: 16px 18px; overflow-y: auto; flex: 1 1 auto; min-height: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
|
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
|
||||||
.field label { font-size: 12px; color: var(--muted); }
|
.field label { font-size: 12px; color: var(--muted); }
|
||||||
.field.check { flex-direction: row; align-items: center; gap: 8px; padding-top: 22px; }
|
.field.check { flex-direction: row; align-items: center; gap: 8px; padding-top: 22px; }
|
||||||
@@ -130,8 +174,12 @@ input, select, textarea {
|
|||||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
textarea { resize: vertical; }
|
textarea { resize: vertical; }
|
||||||
input[type="checkbox"] { width: auto; accent-color: var(--accent); }
|
input[type="checkbox"] { width: auto; accent-color: var(--accent); }
|
||||||
|
input[type="datetime-local"] { color-scheme: light dark; }
|
||||||
.row2 { display: flex; gap: 12px; }
|
.row2 { display: flex; gap: 12px; }
|
||||||
.box { border: 1px dashed var(--border); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 10px; background: #141b2b; }
|
.box {
|
||||||
|
border: 1px dashed var(--border); border-radius: 10px; padding: 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px; background: var(--box-bg);
|
||||||
|
}
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
/* ---------- detail ---------- */
|
/* ---------- detail ---------- */
|
||||||
@@ -144,7 +192,7 @@ input[type="checkbox"] { width: auto; accent-color: var(--accent); }
|
|||||||
padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border);
|
padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||||
background: var(--panel2); cursor: pointer; font-size: 12px; color: var(--muted);
|
background: var(--panel2); cursor: pointer; font-size: 12px; color: var(--muted);
|
||||||
}
|
}
|
||||||
.run-chip.active { border-color: var(--accent); color: #fff; }
|
.run-chip.active { border-color: var(--accent); color: var(--text); }
|
||||||
.run-panel { border: 1px solid var(--border); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 10px; background: var(--panel2); }
|
.run-panel { border: 1px solid var(--border); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 10px; background: var(--panel2); }
|
||||||
.run-stats { display: flex; gap: 16px; font-size: 12px; flex-wrap: wrap; }
|
.run-stats { display: flex; gap: 16px; font-size: 12px; flex-wrap: wrap; }
|
||||||
.table-wrap { overflow: auto; max-height: 320px; border: 1px solid var(--border); border-radius: 8px; }
|
.table-wrap { overflow: auto; max-height: 320px; border: 1px solid var(--border); border-radius: 8px; }
|
||||||
@@ -155,27 +203,26 @@ td.url-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
|
|||||||
td.title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
td.title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.t-ok { color: var(--green); } .t-fail { color: var(--red); }
|
.t-ok { color: var(--green); } .t-fail { color: var(--red); }
|
||||||
.file-link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
.file-link { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||||||
.file-link:hover { color: #9ec0ff; }
|
|
||||||
.thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
|
.thumbs { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.thumbs img { width: 96px; height: 72px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); cursor: pointer; }
|
.thumbs img { width: 96px; height: 72px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); cursor: pointer; }
|
||||||
.logs {
|
.logs {
|
||||||
background: #0b0f1a; border: 1px solid var(--border); border-radius: 8px;
|
background: var(--log-bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
padding: 10px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
|
padding: 10px; font-family: ui-monospace, Consolas, monospace; font-size: 12px;
|
||||||
max-height: 220px; overflow-y: auto; line-height: 1.7;
|
max-height: 220px; overflow-y: auto; line-height: 1.7;
|
||||||
}
|
}
|
||||||
.logs .info { color: var(--text); } .logs .warn { color: var(--yellow); } .logs .error { color: var(--red); }
|
.logs .info { color: var(--text); } .logs .warn { color: var(--yellow); } .logs .error { color: var(--red); }
|
||||||
|
|
||||||
/* ---------- preview ---------- */
|
/* ---------- preview ---------- */
|
||||||
#previewFrame { flex: 1; border: none; background: #fff; border-radius: 0 0 14px 14px; }
|
#previewFrame { flex: 1; border: none; background: var(--preview-bg); border-radius: 0 0 14px 14px; }
|
||||||
|
|
||||||
/* ---------- misc ---------- */
|
/* ---------- misc ---------- */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
|
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
|
||||||
background: var(--panel2); border: 1px solid var(--accent); color: #fff;
|
background: var(--panel2); border: 1px solid var(--accent); color: var(--text);
|
||||||
padding: 10px 20px; border-radius: 10px; z-index: 300; font-size: 13px;
|
padding: 10px 20px; border-radius: 10px; z-index: 300; font-size: 13px;
|
||||||
box-shadow: 0 8px 30px rgba(0,0,0,.4); animation: fadein .2s;
|
box-shadow: 0 8px 30px rgba(0,0,0,.4); animation: fadein .2s;
|
||||||
}
|
}
|
||||||
.toast.error { border-color: var(--red); }
|
.toast.error { border-color: var(--red); }
|
||||||
@keyframes fadein { from { opacity: 0; transform: translate(-50%, -8px); } }
|
@keyframes fadein { from { opacity: 0; transform: translate(-50%, -8px); } }
|
||||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
::-webkit-scrollbar-thumb { background: #2a3550; border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background: var(--thumb); border-radius: 4px; }
|
||||||
Reference in New Issue
Block a user