| <!doctype html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>DouYin Spark Helper - Admin 控制台</title> |
| <link rel="stylesheet" href="/static/style.css"> |
| </head> |
| <body class="dash-body"> |
| <header class="topbar"> |
| <div> |
| <h1>Admin 后台管理</h1> |
| <p>用户管理 + 定时任务总览</p> |
| </div> |
| <div class="top-actions"> |
| <button id="retryAllFailedBtn" class="btn ghost">一键重试失败任务</button> |
| <button id="refreshBtn" class="btn ghost">刷新</button> |
| <button id="logoutBtn" class="btn ghost">退出登录</button> |
| </div> |
| </header> |
|
|
| <main class="container"> |
| <section class="panel"> |
| <h2>用户与任务总览</h2> |
| <p id="summary" class="muted">加载中...</p> |
| <div id="dbAlert" class="alert warning" style="display:none;"></div> |
| <div class="table-wrap"> |
| <table class="admin-table"> |
| <thead> |
| <tr> |
| <th>发起用户</th> |
| <th>唯一标识</th> |
| <th>注册时间</th> |
| <th>定时状态</th> |
| <th>发送时间</th> |
| <th>消息内容</th> |
| <th>接收方</th> |
| <th>下次执行</th> |
| <th>最近状态</th> |
| <th>操作</th> |
| </tr> |
| </thead> |
| <tbody id="adminBody"> |
| <tr><td colspan="10">暂无数据</td></tr> |
| </tbody> |
| </table> |
| </div> |
| <p id="adminMsg" class="msg"></p> |
| </section> |
|
|
| <section id="detailPanel" class="panel" style="display:none;"> |
| <div class="panel-header"> |
| <h2 id="detailTitle">任务详情</h2> |
| <div class="top-actions"> |
| <button id="detailRetryBtn" class="btn" style="display:none;">重试失败任务</button> |
| <button id="detailRefreshBtn" class="btn">刷新详情</button> |
| </div> |
| </div> |
|
|
| <div id="detailMeta" class="detail-grid"></div> |
|
|
| <div class="detail-split"> |
| <article class="detail-card"> |
| <h3>消息内容</h3> |
| <pre id="detailMessage" class="detail-message">-</pre> |
| </article> |
| <article class="detail-card"> |
| <h3>接收方(去重后)</h3> |
| <pre id="detailTargets" class="detail-message">-</pre> |
| </article> |
| </div> |
|
|
| <article class="detail-card"> |
| <h3>账号明细</h3> |
| <div class="table-wrap"> |
| <table class="admin-table detail-history"> |
| <thead> |
| <tr> |
| <th>账号昵称</th> |
| <th>unique_id</th> |
| <th>目标数</th> |
| <th>cookies 数</th> |
| <th>目标列表</th> |
| </tr> |
| </thead> |
| <tbody id="detailAccountsBody"> |
| <tr><td colspan="5">暂无数据</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </article> |
|
|
| <article class="detail-card"> |
| <h3>执行历史</h3> |
| <div class="table-wrap"> |
| <table class="admin-table detail-history"> |
| <thead> |
| <tr> |
| <th>触发方式</th> |
| <th>开始时间</th> |
| <th>结束时间</th> |
| <th>状态</th> |
| <th>耗时</th> |
| <th>信息</th> |
| </tr> |
| </thead> |
| <tbody id="detailHistoryBody"> |
| <tr><td colspan="6">暂无记录</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </article> |
|
|
| <article class="detail-card"> |
| <h3>任务日志</h3> |
| <pre id="detailLogs" class="detail-logbox">暂无日志。</pre> |
| </article> |
| </section> |
| </main> |
|
|
| <script> |
| const adminBody = document.getElementById("adminBody"); |
| const summary = document.getElementById("summary"); |
| const retryAllFailedBtn = document.getElementById("retryAllFailedBtn"); |
| const dbAlert = document.getElementById("dbAlert"); |
| const adminMsg = document.getElementById("adminMsg"); |
| const detailPanel = document.getElementById("detailPanel"); |
| const detailTitle = document.getElementById("detailTitle"); |
| const detailMeta = document.getElementById("detailMeta"); |
| const detailMessage = document.getElementById("detailMessage"); |
| const detailTargets = document.getElementById("detailTargets"); |
| const detailAccountsBody = document.getElementById("detailAccountsBody"); |
| const detailHistoryBody = document.getElementById("detailHistoryBody"); |
| const detailLogs = document.getElementById("detailLogs"); |
| const detailRetryBtn = document.getElementById("detailRetryBtn"); |
| let currentDetailUser = null; |
| let dbConnected = true; |
| |
| function setMsg(msg, isError = false) { |
| adminMsg.textContent = msg || ""; |
| adminMsg.style.color = isError ? "#c0392b" : "#146356"; |
| } |
| |
| async function requestJSON(url, options = {}) { |
| const resp = await fetch(url, { |
| credentials: "same-origin", |
| headers: { "Content-Type": "application/json", ...(options.headers || {}) }, |
| ...options, |
| }); |
| |
| if (resp.status === 401) { |
| window.location.href = "/admin"; |
| throw new Error("登录已失效,请重新登录。"); |
| } |
| |
| const data = await resp.json(); |
| if (!resp.ok || data.ok === false) { |
| throw new Error(data.message || "请求失败"); |
| } |
| return data; |
| } |
| |
| function escapeHtml(value) { |
| return String(value ?? "") |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/\"/g, """) |
| .replace(/'/g, "'"); |
| } |
| |
| function renderDbStatus(status, fallbackMessage = "") { |
| dbConnected = !status || status.connected !== false; |
| if (dbConnected) { |
| dbAlert.style.display = "none"; |
| dbAlert.textContent = ""; |
| return; |
| } |
| |
| const parts = ["当前无法连接 SQL 服务器。"]; |
| if (fallbackMessage) { |
| parts.push(fallbackMessage); |
| } else if (status && status.last_error) { |
| parts.push(status.last_error); |
| } |
| if (status && status.last_checked_at && status.last_checked_at !== "-") { |
| parts.push(`最近检查:${status.last_checked_at}`); |
| } |
| if (status && status.last_ok_at && status.last_ok_at !== "-") { |
| parts.push(`最近成功连接:${status.last_ok_at}`); |
| } |
| |
| dbAlert.textContent = parts.join(" "); |
| dbAlert.style.display = "block"; |
| } |
| |
| function clearDetailPanel() { |
| currentDetailUser = null; |
| detailPanel.style.display = "none"; |
| detailRetryBtn.style.display = "none"; |
| detailRetryBtn.dataset.user = ""; |
| detailTitle.textContent = "任务详情"; |
| detailMeta.innerHTML = ""; |
| detailMessage.textContent = "-"; |
| detailTargets.textContent = "-"; |
| detailLogs.textContent = "暂无日志。"; |
| detailAccountsBody.innerHTML = '<tr><td colspan="5">暂无数据</td></tr>'; |
| detailHistoryBody.innerHTML = '<tr><td colspan="6">暂无记录</td></tr>'; |
| } |
| |
| function renderRows(users) { |
| if (!users || users.length === 0) { |
| adminBody.innerHTML = '<tr><td colspan="10">暂无用户</td></tr>'; |
| clearDetailPanel(); |
| return; |
| } |
| |
| adminBody.innerHTML = users.map((u) => { |
| if (u.error) { |
| return ` |
| <tr> |
| <td>${escapeHtml(u.username)}</td> |
| <td>${escapeHtml(u.unique_id || "-")}</td> |
| <td>${escapeHtml(u.created_at || "-")}</td> |
| <td colspan="6" style="color:#c0392b;">数据异常:${escapeHtml(u.error)}</td> |
| <td> |
| <div class="admin-actions"> |
| <button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删用户</button> |
| </div> |
| </td> |
| </tr> |
| `; |
| } |
| |
| const targets = Array.isArray(u.targets) ? u.targets : []; |
| const targetText = targets.length ? targets.join(" / ") : "-"; |
| const statusText = u.is_running ? "运行中" : (u.last_status || "-"); |
| const retryAction = u.can_retry |
| ? `<button class="btn admin-retry-task" data-user="${escapeHtml(u.username)}">重试失败</button>` |
| : ""; |
| |
| return ` |
| <tr> |
| <td>${escapeHtml(u.username)}</td> |
| <td>${escapeHtml(u.unique_id)}</td> |
| <td>${escapeHtml(u.created_at)}</td> |
| <td>${u.scheduler_enabled ? "启用" : "禁用"}</td> |
| <td>${escapeHtml(u.schedule_time)} (${escapeHtml(u.schedule_timezone)})</td> |
| <td class="ellipsis-cell" title="${escapeHtml(u.message_template || "")}">${escapeHtml(u.message_template || "-")}</td> |
| <td class="ellipsis-cell" title="${escapeHtml(targetText)}">${escapeHtml(targetText)}</td> |
| <td>${escapeHtml(u.next_run || "-")}</td> |
| <td>${escapeHtml(statusText)}</td> |
| <td> |
| <div class="admin-actions"> |
| <button class="btn admin-view-task" data-user="${escapeHtml(u.username)}">详情/日志</button> |
| ${retryAction} |
| <button class="btn admin-del-task" data-user="${escapeHtml(u.username)}">删任务</button> |
| <button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删用户</button> |
| </div> |
| </td> |
| </tr> |
| `; |
| }).join(""); |
| |
| document.querySelectorAll(".admin-view-task").forEach((btn) => { |
| btn.addEventListener("click", async () => { |
| const username = btn.dataset.user; |
| await loadTaskDetail(username, true); |
| }); |
| }); |
| |
| document.querySelectorAll(".admin-del-task").forEach((btn) => { |
| btn.addEventListener("click", async () => { |
| const username = btn.dataset.user; |
| if (!confirm(`确认删除用户 ${username} 的定时任务?`)) return; |
| setMsg("正在删除任务..."); |
| try { |
| const data = await requestJSON(`/api/admin/tasks/${encodeURIComponent(username)}/delete`, { |
| method: "POST", |
| body: "{}", |
| }); |
| setMsg(data.message || "已删除任务"); |
| await loadOverview(); |
| if (currentDetailUser === username) { |
| await loadTaskDetail(username, false); |
| } |
| } catch (err) { |
| setMsg(err.message, true); |
| } |
| }); |
| }); |
| |
| document.querySelectorAll(".admin-retry-task").forEach((btn) => { |
| btn.addEventListener("click", async () => { |
| const username = btn.dataset.user; |
| await retryFailedTask(username); |
| }); |
| }); |
| |
| document.querySelectorAll(".admin-delete-user").forEach((btn) => { |
| btn.addEventListener("click", async () => { |
| const username = btn.dataset.user; |
| if (!confirm(`确认删除用户 ${username}?该操作不可恢复。`)) return; |
| setMsg("正在删除用户..."); |
| try { |
| const data = await requestJSON(`/api/admin/users/${encodeURIComponent(username)}`, { |
| method: "DELETE", |
| }); |
| setMsg(data.message || "用户已删除"); |
| if (currentDetailUser === username) { |
| clearDetailPanel(); |
| } |
| await loadOverview(); |
| } catch (err) { |
| setMsg(err.message, true); |
| } |
| }); |
| }); |
| } |
| |
| function renderDetail(task) { |
| currentDetailUser = task.username; |
| detailPanel.style.display = "block"; |
| detailTitle.textContent = `任务详情 · ${task.username}`; |
| |
| const runtime = task.runtime || {}; |
| const config = task.config || {}; |
| const targets = Array.isArray(task.targets) ? task.targets : []; |
| |
| detailMeta.innerHTML = ` |
| <div class="detail-item"><span>发起用户</span><strong>${escapeHtml(task.username || "-")}</strong></div> |
| <div class="detail-item"><span>唯一标识</span><strong>${escapeHtml(task.unique_id || "-")}</strong></div> |
| <div class="detail-item"><span>注册时间</span><strong>${escapeHtml(task.created_at || "-")}</strong></div> |
| <div class="detail-item"><span>定时状态</span><strong>${task.scheduler_enabled ? "启用" : "禁用"}</strong></div> |
| <div class="detail-item"><span>发送时间</span><strong>${escapeHtml(task.schedule_time || "-")} (${escapeHtml(task.schedule_timezone || "-")})</strong></div> |
| <div class="detail-item"><span>下一次执行</span><strong>${escapeHtml(runtime.next_run || "-")}</strong></div> |
| <div class="detail-item"><span>最近状态</span><strong>${runtime.is_running ? "运行中" : escapeHtml(runtime.last_status || "-")}</strong></div> |
| <div class="detail-item"><span>最近开始</span><strong>${escapeHtml(runtime.last_start || "-")}</strong></div> |
| <div class="detail-item"><span>账号数 / 目标数</span><strong>${runtime.account_count || 0} / ${runtime.target_count || 0}</strong></div> |
| <div class="detail-item"><span>并发设置</span><strong>multiTask=${config.multiTask ? "true" : "false"}, taskCount=${config.taskCount || 1}</strong></div> |
| `; |
| |
| detailMessage.textContent = task.message_template || "-"; |
| detailTargets.textContent = targets.length ? targets.join("\n") : "-"; |
| detailLogs.textContent = task.logs || "暂无日志。"; |
| detailRetryBtn.dataset.user = task.username || ""; |
| detailRetryBtn.style.display = task.can_retry ? "inline-flex" : "none"; |
| |
| const accounts = Array.isArray(task.accounts) ? task.accounts : []; |
| if (!accounts.length) { |
| detailAccountsBody.innerHTML = '<tr><td colspan="5">暂无账号明细</td></tr>'; |
| } else { |
| detailAccountsBody.innerHTML = accounts.map((item) => { |
| const t = Array.isArray(item.targets) ? item.targets : []; |
| return ` |
| <tr> |
| <td>${escapeHtml(item.username || "-")}</td> |
| <td>${escapeHtml(item.unique_id || "-")}</td> |
| <td>${item.target_count || 0}</td> |
| <td>${item.cookie_count || 0}</td> |
| <td>${escapeHtml(t.join(" / ") || "-")}</td> |
| </tr> |
| `; |
| }).join(""); |
| } |
| |
| const history = Array.isArray(task.history) ? task.history : []; |
| if (!history.length) { |
| detailHistoryBody.innerHTML = '<tr><td colspan="6">暂无记录</td></tr>'; |
| } else { |
| detailHistoryBody.innerHTML = history.map((row) => ` |
| <tr> |
| <td>${escapeHtml(row.trigger || "-")}</td> |
| <td>${escapeHtml(row.start || "-")}</td> |
| <td>${escapeHtml(row.end || "-")}</td> |
| <td>${escapeHtml(row.status || "-")}</td> |
| <td>${escapeHtml(row.duration || "-")}</td> |
| <td>${escapeHtml(row.message || "-")}</td> |
| </tr> |
| `).join(""); |
| } |
| } |
| |
| async function loadTaskDetail(username, showMessage = false) { |
| try { |
| const data = await requestJSON(`/api/admin/tasks/${encodeURIComponent(username)}?log_limit=1200`); |
| renderDetail(data.task || {}); |
| if (showMessage) { |
| setMsg(`已加载 ${username} 的任务详情与日志。`); |
| } |
| } catch (err) { |
| setMsg(err.message, true); |
| } |
| } |
| |
| async function retryAllFailedTasks() { |
| if (!dbConnected) { |
| setMsg("SQL 连接异常,暂时无法批量重试失败任务。", true); |
| return; |
| } |
| |
| setMsg("正在批量重试失败任务..."); |
| try { |
| const data = await requestJSON("/api/admin/tasks/retry-failed", { |
| method: "POST", |
| body: "{}", |
| }); |
| setMsg(data.message || "已开始批量重试失败任务"); |
| await loadOverview(); |
| if (currentDetailUser && dbConnected) { |
| await loadTaskDetail(currentDetailUser, false); |
| } |
| } catch (err) { |
| setMsg(err.message, true); |
| } |
| } |
| |
| async function retryFailedTask(username) { |
| if (!username) { |
| setMsg("未找到可重试的任务用户。", true); |
| return; |
| } |
| |
| setMsg("正在重试失败任务..."); |
| try { |
| const data = await requestJSON(`/api/admin/tasks/${encodeURIComponent(username)}/retry`, { |
| method: "POST", |
| body: "{}", |
| }); |
| setMsg(data.message || "已开始重试失败任务"); |
| await loadOverview(); |
| if (currentDetailUser === username && dbConnected) { |
| await loadTaskDetail(username, false); |
| } |
| } catch (err) { |
| setMsg(err.message, true); |
| } |
| } |
| |
| async function loadOverview() { |
| const data = await requestJSON("/api/admin/overview"); |
| renderDbStatus(data.db_status || null, data.message || ""); |
| summary.textContent = dbConnected |
| ? `共 ${data.task_count || 0} 个用户任务。` |
| : "SQL 连接异常,当前未能加载用户任务。"; |
| renderRows(data.users || []); |
| if (!dbConnected) { |
| clearDetailPanel(); |
| } |
| return data; |
| } |
| |
| retryAllFailedBtn.addEventListener("click", async () => { |
| await retryAllFailedTasks(); |
| }); |
| |
| document.getElementById("refreshBtn").addEventListener("click", async () => { |
| try { |
| await loadOverview(); |
| if (dbConnected && currentDetailUser) { |
| await loadTaskDetail(currentDetailUser, false); |
| } |
| } catch (err) { |
| setMsg(err.message, true); |
| } |
| }); |
| |
| document.getElementById("detailRefreshBtn").addEventListener("click", async () => { |
| if (!currentDetailUser) { |
| setMsg("请先在上方选择一个任务。", true); |
| return; |
| } |
| await loadTaskDetail(currentDetailUser, true); |
| }); |
| |
| detailRetryBtn.addEventListener("click", async () => { |
| await retryFailedTask(detailRetryBtn.dataset.user || currentDetailUser); |
| }); |
| |
| document.getElementById("logoutBtn").addEventListener("click", async () => { |
| await fetch("/api/logout", { method: "POST", credentials: "same-origin" }); |
| window.location.href = "/admin"; |
| }); |
| |
| loadOverview().catch((err) => setMsg(err.message, true)); |
| setInterval(async () => { |
| try { |
| await loadOverview(); |
| if (dbConnected && currentDetailUser) { |
| await loadTaskDetail(currentDetailUser, false); |
| } |
| } catch (_) { |
| } |
| }, 8000); |
| </script> |
| </body> |
| </html> |
|
|