mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-05 07:27:53 +08:00
Sync deployed SparkFlow reliability updates
This commit is contained in:
@@ -1,148 +1,521 @@
|
||||
{% set current_nav %}{% block nav_key %}dashboard{% endblock %}{% endset %}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}自动续火花{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}续火花{% endblock %}</title>
|
||||
<style>
|
||||
/* ============ DARK (default) ============ */
|
||||
:root {
|
||||
--bg: #0a0e1c;
|
||||
--bg-glow: rgba(255, 77, 109, 0.10);
|
||||
--surface: #161e34;
|
||||
--surface-subtle: #1a2440;
|
||||
--surface-tint: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #eaf0ff;
|
||||
--text-main: #eaf0ff;
|
||||
--text-soft: #9aa6c8;
|
||||
--text-faint: #64709a;
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--primary-deep: #ff2da8;
|
||||
--primary-soft: rgba(255, 122, 61, 0.14);
|
||||
--success: #2bd47f;
|
||||
--success-soft: rgba(43, 212, 127, 0.14);
|
||||
--danger: #ff5470;
|
||||
--danger-soft: rgba(255, 84, 112, 0.14);
|
||||
--warning: #ffb547;
|
||||
--warning-soft: rgba(255, 181, 71, 0.14);
|
||||
--info: #5b8bff;
|
||||
--info-soft: rgba(91, 139, 255, 0.16);
|
||||
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.30);
|
||||
--ring: 0 0 0 4px rgba(255, 122, 61, 0.22);
|
||||
--fire-grad: linear-gradient(135deg, #ffb04a 0%, #ff7a3d 30%, #ff4d6d 65%, #ff2da8 100%);
|
||||
--glow-fire: 0 18px 50px rgba(255, 77, 109, 0.28);
|
||||
--sidebar-bg: linear-gradient(200deg, rgba(22, 30, 52, 0.96) 0%, rgba(14, 20, 38, 0.96) 100%);
|
||||
--sidebar-text: rgba(255, 255, 255, 0.72);
|
||||
--sidebar-text-strong: #ffffff;
|
||||
--sidebar-faint: rgba(255, 255, 255, 0.42);
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.06);
|
||||
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.22), rgba(255, 45, 168, 0.10));
|
||||
--sidebar-active-border: rgba(255, 122, 61, 0.28);
|
||||
--panel-glass: rgba(22, 30, 52, 0.72);
|
||||
--glass-blur: blur(10px);
|
||||
--body-bg:
|
||||
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.16), transparent 50%),
|
||||
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.14), transparent 48%),
|
||||
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
|
||||
--title-grad: linear-gradient(120deg, #ffffff 0%, #ffd9c8 60%, #ff7a9d 120%);
|
||||
--th-bg: rgba(255, 255, 255, 0.04);
|
||||
--row-hover: rgba(255, 255, 255, 0.03);
|
||||
--table-border: rgba(255, 255, 255, 0.06);
|
||||
--input-bg: rgba(255, 255, 255, 0.04);
|
||||
--code-bg: #0c1426;
|
||||
--code-light-bg: rgba(255, 255, 255, 0.03);
|
||||
--empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 16px;
|
||||
--radius-sm: 12px;
|
||||
}
|
||||
|
||||
/* ============ LIGHT ============ */
|
||||
[data-theme="light"] {
|
||||
--bg: #eef2f9;
|
||||
--bg-glow: rgba(255, 122, 61, 0.08);
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f7faff;
|
||||
--surface-tint: #f0f4fb;
|
||||
--border: #e3e9f3;
|
||||
--border-strong: #d0d9e8;
|
||||
--text: #14213d;
|
||||
--text-main: #14213d;
|
||||
--text-soft: #566079;
|
||||
--text-faint: #8a96b3;
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--primary-deep: #ff2da8;
|
||||
--primary-soft: rgba(255, 122, 61, 0.10);
|
||||
--success: #18a558;
|
||||
--success-soft: rgba(24, 165, 88, 0.14);
|
||||
--danger: #e5484a;
|
||||
--danger-soft: rgba(229, 72, 74, 0.12);
|
||||
--warning: #e08a0c;
|
||||
--warning-soft: rgba(224, 138, 12, 0.14);
|
||||
--info: #3a66ff;
|
||||
--info-soft: rgba(58, 102, 255, 0.12);
|
||||
--shadow: 0 20px 45px rgba(20, 33, 61, 0.10);
|
||||
--shadow-soft: 0 8px 24px rgba(20, 33, 61, 0.06);
|
||||
--ring: 0 0 0 4px rgba(255, 122, 61, 0.18);
|
||||
--glow-fire: 0 16px 40px rgba(255, 77, 109, 0.20);
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-text: #566079;
|
||||
--sidebar-text-strong: #14213d;
|
||||
--sidebar-faint: #8a96b3;
|
||||
--sidebar-hover: rgba(20, 33, 61, 0.04);
|
||||
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.16), rgba(255, 45, 168, 0.06));
|
||||
--sidebar-active-border: rgba(255, 122, 61, 0.30);
|
||||
--panel-glass: #ffffff;
|
||||
--glass-blur: none;
|
||||
--body-bg:
|
||||
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.10), transparent 50%),
|
||||
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.10), transparent 48%),
|
||||
linear-gradient(180deg, #f1f5fc 0%, #e9eef7 100%);
|
||||
--title-grad: linear-gradient(120deg, #14213d 0%, #c45a3a 60%, #e83e7c 120%);
|
||||
--th-bg: #f3f6fc;
|
||||
--row-hover: rgba(58, 102, 255, 0.04);
|
||||
--table-border: #eaeff7;
|
||||
--input-bg: #ffffff;
|
||||
--code-bg: #0f1728;
|
||||
--code-light-bg: #f7f9fd;
|
||||
--empty-bg: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--body-bg);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
::selection { background: rgba(255, 77, 109, 0.30); }
|
||||
|
||||
.main-area ::-webkit-scrollbar,
|
||||
.table-shell ::-webkit-scrollbar,
|
||||
.code-block::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
.main-area ::-webkit-scrollbar-thumb,
|
||||
.table-shell ::-webkit-scrollbar-thumb,
|
||||
.code-block::-webkit-scrollbar-thumb {
|
||||
background: var(--text-faint);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.main-area ::-webkit-scrollbar-thumb:hover { opacity: 0.8; background-clip: padding-box; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 236px minmax(0, 1fr); }
|
||||
|
||||
/* ---------- sidebar ---------- */
|
||||
.sidebar {
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
padding: 22px 14px 18px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 6px 14px; border-bottom: 1px solid var(--border); }
|
||||
.brand-mark {
|
||||
width: 40px; height: 40px; border-radius: 13px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 19px; font-weight: 800;
|
||||
box-shadow: var(--glow-fire);
|
||||
}
|
||||
.brand-title { font-size: 16px; font-weight: 800; letter-spacing: 0.01em; color: var(--sidebar-text-strong); }
|
||||
.brand-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
|
||||
.sidebar-section-title {
|
||||
margin: 0 10px 8px; font-size: 10.5px; color: var(--sidebar-faint);
|
||||
letter-spacing: 0.16em; text-transform: uppercase;
|
||||
}
|
||||
.nav-list { display: grid; gap: 4px; }
|
||||
.nav-item {
|
||||
position: relative; display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 14px; border-radius: 11px;
|
||||
color: var(--sidebar-text); font-size: 14px; font-weight: 600;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.nav-item:hover { background: var(--sidebar-hover); color: var(--sidebar-text-strong); }
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-text-strong); font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px var(--sidebar-active-border);
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: ""; position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 18px; border-radius: 3px; background: var(--fire-grad);
|
||||
}
|
||||
.nav-icon { width: 18px; text-align: center; font-size: 14px; flex: 0 0 18px; opacity: 0.9; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto; padding: 14px; border-radius: 16px;
|
||||
background: var(--surface-tint); border: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-footer-head { display: flex; align-items: center; gap: 10px; }
|
||||
.sidebar-footer-mark {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-weight: 800;
|
||||
}
|
||||
.sidebar-footer-title { font-size: 13.5px; font-weight: 700; color: var(--sidebar-text-strong); }
|
||||
.sidebar-footer-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
|
||||
.sidebar-logout {
|
||||
width: 100%; justify-content: center; display: flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border); color: var(--sidebar-text);
|
||||
background: transparent; box-shadow: none; padding: 9px;
|
||||
}
|
||||
.sidebar-logout:hover {
|
||||
border-color: rgba(255, 84, 112, 0.4); color: var(--danger);
|
||||
background: var(--danger-soft); transform: none; box-shadow: none; filter: none;
|
||||
}
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main-area { min-width: 0; padding: 24px 28px 36px; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
|
||||
.page-header__title { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||
.page-title {
|
||||
font-size: 28px; line-height: 1.12; font-weight: 800; letter-spacing: -0.02em;
|
||||
background: var(--title-grad);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.page-subtitle { font-size: 14px; color: var(--text-soft); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
.page-header__right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.theme-toggle {
|
||||
width: 38px; height: 38px; border-radius: 11px; flex: 0 0 38px;
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--text-soft);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 17px; cursor: pointer; transition: 0.18s; padding: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.theme-toggle:hover { color: var(--primary); border-color: var(--primary); transform: none; box-shadow: var(--shadow-soft); filter: none; }
|
||||
.theme-toggle .ico-moon { display: none }
|
||||
.theme-toggle .ico-sun { display: inline }
|
||||
[data-theme="light"] .theme-toggle .ico-moon { display: inline }
|
||||
[data-theme="light"] .theme-toggle .ico-sun { display: none }
|
||||
|
||||
.page-header__actions {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 6px; border-radius: 14px;
|
||||
background: var(--panel-glass); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.page-header__actions > form { margin: 0; }
|
||||
.page-header__actions button,
|
||||
.page-header__actions .link-button,
|
||||
.page-header__actions .ghost-button,
|
||||
.page-header__actions .soft-button {
|
||||
margin: 0; border-radius: 10px; padding: 9px 16px; font-size: 13.5px; font-weight: 700;
|
||||
box-shadow: none; background: transparent; color: var(--text-soft);
|
||||
border: 1px solid transparent; transform: none; filter: none;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.page-header__actions button:hover,
|
||||
.page-header__actions .link-button:hover,
|
||||
.page-header__actions .ghost-button:hover,
|
||||
.page-header__actions .soft-button:hover {
|
||||
transform: none; filter: none; box-shadow: none;
|
||||
background: var(--primary-soft); color: var(--primary); border-color: rgba(255, 122, 61, 0.22);
|
||||
}
|
||||
.page-header__actions button:active { transform: none; box-shadow: none; }
|
||||
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button) {
|
||||
background: var(--fire-grad); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 8px 18px rgba(255, 77, 109, 0.32);
|
||||
}
|
||||
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button):hover {
|
||||
filter: brightness(1.06); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.40); transform: none;
|
||||
}
|
||||
.page-header__actions button:disabled {
|
||||
background: transparent; color: var(--text-faint);
|
||||
border: 1px dashed var(--border-strong); box-shadow: none; cursor: not-allowed; opacity: 1;
|
||||
}
|
||||
|
||||
.page-body { display: grid; gap: 22px; }
|
||||
.stack { display: grid; gap: 22px; }
|
||||
|
||||
/* ---------- panel ---------- */
|
||||
.panel {
|
||||
background: var(--panel-glass); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--glass-blur); padding: 22px;
|
||||
}
|
||||
.section-title-row,
|
||||
.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.section-title-row h2, .panel h2, .panel h3, .panel h4 { margin: 0; }
|
||||
.muted { color: var(--text-soft); }
|
||||
.muted.compact { margin: 6px 0 0; font-size: 13px; line-height: 1.6; }
|
||||
|
||||
/* ---------- stat cards (kept for send_console/logs compat) ---------- */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
|
||||
.stat-card {
|
||||
min-height: 124px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border); background: var(--panel-glass);
|
||||
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
|
||||
padding: 20px 22px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--sidebar-active-border); }
|
||||
.stat-meta { display: grid; gap: 10px; }
|
||||
.stat-label { font-size: 14px; color: var(--text-soft); }
|
||||
.stat-value { font-size: 28px; line-height: 1; font-weight: 800; letter-spacing: -0.03em; }
|
||||
.stat-help { font-size: 13px; color: var(--text-soft); }
|
||||
.stat-help.positive { color: var(--success); }
|
||||
.stat-help.negative { color: var(--danger); }
|
||||
.stat-icon {
|
||||
width: 58px; height: 58px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 24px; font-weight: 700;
|
||||
}
|
||||
.stat-icon.blue { background: var(--info-soft); color: var(--info); }
|
||||
.stat-icon.green { background: var(--success-soft); color: var(--success); }
|
||||
.stat-icon.red { background: var(--danger-soft); color: var(--danger); }
|
||||
.stat-icon.orange { background: var(--warning-soft); color: var(--warning); }
|
||||
|
||||
.layout-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
|
||||
.overview-time { display: inline-flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 14px; font-weight: 600; }
|
||||
.overview-time::before {
|
||||
content: ""; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--fire-grad); box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
|
||||
}
|
||||
|
||||
/* ---------- pills / chips ---------- */
|
||||
.status-pill, .pill, .status-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 700;
|
||||
color: var(--text-soft); background: var(--surface-tint); border: 1px solid var(--border);
|
||||
}
|
||||
.pill.soft, .status-pill.soft, .status-chip.success { color: var(--success); background: var(--success-soft); }
|
||||
.pill.warning, .status-pill.warning, .status-chip.warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.pill.danger, .status-pill.danger, .status-chip.danger { color: var(--danger); background: var(--danger-soft); }
|
||||
.status-chip.info { color: var(--info); background: var(--info-soft); }
|
||||
.status-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
button, .link-button {
|
||||
appearance: none; border: none; border-radius: 12px;
|
||||
background: var(--fire-grad); color: #fff; cursor: pointer;
|
||||
padding: 11px 18px; font-weight: 700; letter-spacing: 0.01em;
|
||||
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.26);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
button:hover, .link-button:hover { transform: translateY(-1px); box-shadow: 0 16px 30px rgba(255, 77, 109, 0.36); filter: brightness(1.05); }
|
||||
button:active, .link-button:active { transform: translateY(0); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.24); }
|
||||
button:focus-visible, .link-button:focus-visible, .nav-item:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.5; transform: none; box-shadow: none; filter: grayscale(0.3); }
|
||||
|
||||
.ghost-button, .soft-button, .danger-button, .success-button { background: var(--surface); box-shadow: none; }
|
||||
.ghost-button { color: var(--text); border: 1px solid var(--border-strong); }
|
||||
.ghost-button:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); box-shadow: none; transform: none; filter: none; }
|
||||
.soft-button { color: var(--primary); background: var(--primary-soft); border: 1px solid rgba(255, 122, 61, 0.20); }
|
||||
.soft-button:hover { background: rgba(255, 122, 61, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
.danger-button { color: var(--danger); border: 1px solid rgba(255, 84, 112, 0.26); background: var(--danger-soft); }
|
||||
.danger-button:hover { background: rgba(255, 84, 112, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
.success-button { color: var(--success); border: 1px solid rgba(43, 212, 127, 0.28); background: var(--success-soft); }
|
||||
.success-button:hover { background: rgba(43, 212, 127, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
|
||||
.button-row, .button-grid { display: grid; gap: 12px; }
|
||||
.button-row.two, .button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
input[type="text"], input[type="password"], input[type="search"], input[type="number"], textarea, select {
|
||||
width: 100%; border: 1px solid var(--border-strong); border-radius: 12px;
|
||||
background: var(--input-bg); color: var(--text); padding: 11px 13px; outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--primary); box-shadow: var(--ring); }
|
||||
input::placeholder, textarea::placeholder { color: var(--text-faint); }
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
.stack-form { display: grid; gap: 14px; }
|
||||
.stack-form label { display: grid; gap: 8px; }
|
||||
.stack-form span { font-size: 13px; color: var(--text-soft); }
|
||||
.check-row { display: flex !important; align-items: center; gap: 10px; }
|
||||
.check-row span { color: var(--text); }
|
||||
|
||||
/* ---------- flash ---------- */
|
||||
.flash { border-radius: 14px; padding: 14px 16px; border: 1px solid var(--border); box-shadow: var(--shadow-soft); background: var(--surface); }
|
||||
.flash.success { background: var(--success-soft); color: var(--success); border-color: rgba(43, 212, 127, 0.24); }
|
||||
.flash.warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(255, 181, 71, 0.24); }
|
||||
.flash.error { background: var(--danger-soft); color: var(--danger); border-color: rgba(255, 84, 112, 0.24); }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
.table-shell { overflow: hidden; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
|
||||
.table-shell.scrollable { overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--table-border); text-align: left; font-size: 13px; vertical-align: middle; }
|
||||
th { background: var(--th-bg); color: var(--text-soft); font-weight: 700; letter-spacing: 0.01em; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: var(--row-hover); }
|
||||
|
||||
/* ---------- code ---------- */
|
||||
.code-block {
|
||||
margin: 0; padding: 16px; border-radius: 14px;
|
||||
background: var(--code-bg); color: #cdd8f5; overflow: auto;
|
||||
font-size: 12px; line-height: 1.7;
|
||||
}
|
||||
.code-block.light { color: var(--text); background: var(--code-light-bg); border: 1px solid var(--border); }
|
||||
|
||||
.empty-state {
|
||||
padding: 34px 20px; text-align: center; border-radius: 16px;
|
||||
border: 1px dashed var(--border-strong); background: var(--empty-bg); color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* ---------- task banner ---------- */
|
||||
.task-state-banner {
|
||||
margin: 0 0 16px; padding: 12px 14px; border-radius: 12px;
|
||||
border: 1px solid var(--border); font-size: 13px; font-weight: 700;
|
||||
}
|
||||
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
|
||||
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.layout-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.main-area { padding: 18px 16px 28px; }
|
||||
.page-header { flex-direction: column; }
|
||||
.stats-grid, .button-grid, .button-row.two { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<div class="mobile-topbar">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
data-nav-toggle
|
||||
aria-label="打开导航"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="mobile-brand">
|
||||
<span class="brand-mark">⚡</span>
|
||||
<span>自动续火花</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-theme-toggle
|
||||
aria-label="切换黑夜模式"
|
||||
title="切换黑夜模式"
|
||||
>
|
||||
<span class="theme-icon theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon theme-moon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-backdrop" data-nav-close></div>
|
||||
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">⚡</div>
|
||||
<div>
|
||||
<div class="brand-title">自动续火花</div>
|
||||
<div class="brand-subtitle">多账号运维面板</div>
|
||||
<aside class="sidebar">
|
||||
<div>
|
||||
<div class="brand">
|
||||
<div class="brand-mark">🔥</div>
|
||||
<div>
|
||||
<div class="brand-title">续火花</div>
|
||||
<div class="brand-subtitle">多账号控制平台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="sidebar-section-title">导航</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
|
||||
<span class="nav-icon">⌂</span><span>首页总览</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
|
||||
<span class="nav-icon">✦</span><span>发送控制台</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
|
||||
<span class="nav-icon">☰</span><span>发送记录</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<p class="sidebar-section-title" style="margin-top:18px;">管理</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item" href="/#account-management"><span class="nav-icon">◉</span><span>账号管理</span></a>
|
||||
<a class="nav-item" href="/#config-panel"><span class="nav-icon">▣</span><span>运行配置</span></a>
|
||||
<a class="nav-item" href="/#interactive-login-section"><span class="nav-icon">⚡</span><span>登录抖音账号</span></a>
|
||||
<a class="nav-item" href="/#ops-panel"><span class="nav-icon">☷</span><span>运维操作</span></a>
|
||||
<a class="nav-item" href="/#settings-panel"><span class="nav-icon">⚙</span><span>系统设置</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups">
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">概览</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}"
|
||||
href="/"
|
||||
>
|
||||
<span class="nav-icon">⌂</span>
|
||||
<span>控制台概览</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">发送</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'login_workspace' %}active{% endif %}"
|
||||
href="/login-workspace"
|
||||
>
|
||||
<span class="nav-icon">◉</span>
|
||||
<span>登录工作区</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">账号</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'accounts' %}active{% endif %}"
|
||||
href="/accounts"
|
||||
>
|
||||
<span class="nav-icon">◎</span>
|
||||
<span>账号与目标</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">系统</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'settings' %}active{% endif %}"
|
||||
href="/settings"
|
||||
>
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>运行与系统</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}"
|
||||
href="/ops/logs"
|
||||
>
|
||||
<span class="nav-icon">☰</span>
|
||||
<span>运行日志</span>
|
||||
</a>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer-title">运行状态</div>
|
||||
<div class="sidebar-footer-subtitle">批量发送与运维调度</div>
|
||||
<div class="sidebar-footer-head">
|
||||
<div class="sidebar-footer-mark">{{ (current_user or "A")[:1] }}</div>
|
||||
<div style="min-width:0;">
|
||||
<div class="sidebar-footer-title" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ current_user or "未登录" }}</div>
|
||||
<div class="sidebar-footer-subtitle">已登录管理员</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/logout" style="margin-top:14px;">
|
||||
<button type="submit" class="sidebar-logout"><span>⏻</span><span>退出登录</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-area">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow">
|
||||
{% block page_eyebrow %}SparkFlow Admin{% endblock %}
|
||||
</div>
|
||||
<div class="page-title-row">
|
||||
<h1 class="page-title">
|
||||
{% block page_title %}自动续火花{% endblock %}
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
{% block page_subtitle %}多账号发送控制平台{% endblock %}
|
||||
</p>
|
||||
<div class="page-header__title">
|
||||
<div class="page-title">{% block page_title %}续火花{% endblock %}</div>
|
||||
</div>
|
||||
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-theme-toggle
|
||||
aria-label="切换黑夜模式"
|
||||
title="切换黑夜模式"
|
||||
>
|
||||
<span class="theme-icon theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon theme-moon" aria-hidden="true">☾</span>
|
||||
<div class="page-header__right">
|
||||
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
|
||||
<span class="ico-sun">☀</span><span class="ico-moon">☾</span>
|
||||
</button>
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
<div class="page-header__actions">
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-body">
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %} {% block content %}{% endblock %}
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script>
|
||||
(function(){
|
||||
var root = document.documentElement;
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var saved = 'dark';
|
||||
try { saved = localStorage.getItem('sparkflow-theme') || 'dark'; } catch(e){}
|
||||
function apply(t){ root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark'); try{ localStorage.setItem('sparkflow-theme', t); }catch(e){} }
|
||||
apply(saved);
|
||||
if (btn) btn.addEventListener('click', function(){
|
||||
var cur = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
apply(cur === 'light' ? 'dark' : 'light');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,262 +1,642 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}dashboard{% endblock %}
|
||||
{% block title %}控制台概览 | 自动续火花{% endblock %}
|
||||
{% block page_title %}控制台概览{% endblock %}
|
||||
{% block page_subtitle %}发送状态、账号健康和关键运维入口{% endblock %}
|
||||
{% block title %}续火花控制台{% endblock %}
|
||||
{% block page_title %}今日续火花概览{% endblock %}
|
||||
{% block page_subtitle %}多账号发送与运营管理{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/login-workspace">登录工作区</a>
|
||||
<a class="ghost-button" href="/ops/send-console">发送控制台</a>
|
||||
<form method="post" action="/ops/run-failed">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set task_lock = ops.task_lock %}
|
||||
{% set enabled_accounts = accounts | selectattr("enabled", "equalto", true) | list %}
|
||||
{% set send_summary = ops.send_console.summary %}
|
||||
{% set pending_total = send_summary.today_pending_targets + send_summary.today_unprocessed_targets %}
|
||||
{% set proxy_rows = ops.containers | selectattr("Names", "equalto", "mihomo") | list %}
|
||||
{% set total_targets = send_summary.total_targets or 0 %}
|
||||
{% set done_targets = send_summary.today_sent_targets or 0 %}
|
||||
{% set failed_targets = send_summary.today_failed_targets or 0 %}
|
||||
{% set pending_targets = send_summary.today_pending_targets or 0 %}
|
||||
{% set unproc_targets = send_summary.today_unprocessed_targets or 0 %}
|
||||
{% set block_targets = send_summary.today_account_blocked_targets or 0 %}
|
||||
{% set ring_total = total_targets if total_targets > 0 else 1 %}
|
||||
{% set ring_pct = (done_targets / ring_total * 100) | round(0) | int %}
|
||||
{% set ring_circ = 490 %}
|
||||
{% set ring_offset = (ring_circ * (1 - ring_pct / 100)) | round(1) %}
|
||||
{% set success_rate = (done_targets / ring_total * 100) | round(0) | int if total_targets else 0 %}
|
||||
{% set remaining = total_targets - done_targets %}
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<section class="stats-grid" aria-label="今日概览">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value">{{ enabled_accounts|length }}</strong>
|
||||
<span class="stat-help">总账号 {{ accounts|length }}</span>
|
||||
</div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
</article>
|
||||
<style>
|
||||
.dash-stack { display: grid; gap: 20px; }
|
||||
.bento { display: grid; grid-template-columns: 1.05fr 0.95fr 1fr; gap: 18px; }
|
||||
.card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.card-h h3 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
.card-h .tag { font-size: 11.5px; color: var(--text-faint); font-weight: 700; }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_sent_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">浏览器确认发送成功</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
</article>
|
||||
/* ring */
|
||||
.ring-card { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 260px; position: relative; overflow: hidden; }
|
||||
.ring-card::after { content: ""; position: absolute; right: -90px; bottom: -90px; width: 240px; height: 240px; border-radius: 50%; background: radial-gradient(circle, rgba(255, 77, 109, 0.22), transparent 68%); pointer-events: none; }
|
||||
.ring { position: relative; width: 184px; height: 184px; z-index: 1; }
|
||||
.ring svg { transform: rotate(-90deg); }
|
||||
.ring .track { stroke: var(--surface-tint); }
|
||||
.ring .fill { stroke: url(#fireGrad); stroke-linecap: round; filter: drop-shadow(0 0 10px rgba(255, 77, 109, 0.5)); }
|
||||
.ring-c { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
|
||||
.ring-num { font-size: 40px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
|
||||
.ring-num small { font-size: 19px; color: var(--text-soft); font-weight: 700; }
|
||||
.ring-lab { font-size: 12.5px; color: var(--text-soft); font-weight: 700; margin-top: 4px; }
|
||||
.ring-pct { margin-top: 8px; font-size: 13px; font-weight: 800; background: var(--fire-grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_failed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">进入重试队列</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
</article>
|
||||
/* streak / overview card */
|
||||
.ov-card { display: flex; flex-direction: column; gap: 16px; min-height: 260px; }
|
||||
.ov-hero { display: flex; align-items: center; gap: 16px; }
|
||||
.ov-flame { width: 70px; height: 70px; border-radius: 20px; flex: 0 0 70px; background: var(--fire-grad); display: flex; align-items: center; justify-content: center; font-size: 34px; box-shadow: var(--glow-fire); }
|
||||
.ov-num { font-size: 42px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
|
||||
.ov-lab { font-size: 13px; color: var(--text-soft); font-weight: 700; margin-top: 4px; }
|
||||
.ov-list { display: grid; gap: 10px; margin-top: auto; }
|
||||
.ov-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-soft); }
|
||||
.ov-row b { color: var(--text); }
|
||||
.sparkdots { display: flex; gap: 5px; }
|
||||
.sparkdots i { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-tint); }
|
||||
.sparkdots i.on { background: var(--fire-grad); box-shadow: 0 0 8px rgba(255, 77, 109, 0.6); }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_pending_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">{{ ops.daily_schedule or "未配置窗口" }}</span>
|
||||
</div>
|
||||
<div class="stat-icon orange">◔</div>
|
||||
</article>
|
||||
/* status tiles */
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.tile { border: 1px solid var(--border); border-radius: 16px; padding: 14px 16px; background: var(--surface-tint); position: relative; overflow: hidden; }
|
||||
.tile::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
|
||||
.tile.s::before { background: var(--success); }
|
||||
.tile.f::before { background: var(--danger); }
|
||||
.tile.p::before { background: var(--warning); }
|
||||
.tile.u::before { background: var(--info); }
|
||||
.tile-lab { font-size: 12px; color: var(--text-soft); font-weight: 700; }
|
||||
.tile-val { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; margin-top: 6px; }
|
||||
.tile-help { font-size: 11px; color: var(--text-faint); margin-top: 4px; }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">未处理</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_unprocessed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">等待后续调度</span>
|
||||
</div>
|
||||
<div class="stat-icon gray">…</div>
|
||||
</article>
|
||||
</section>
|
||||
/* section header */
|
||||
.sect-h { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
|
||||
.sect-h h2 { margin: 0; font-size: 18px; font-weight: 800; }
|
||||
.sect-h .link { font-size: 13px; font-weight: 700; color: var(--primary); text-decoration: none; }
|
||||
.sect-h .link:hover { text-decoration: underline; }
|
||||
|
||||
<div class="layout-grid">
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>今日发送摘要</h2>
|
||||
<p class="muted compact">
|
||||
按账号展示今日发送、失败和待处理状态,详细明细可继续展开查看。
|
||||
</p>
|
||||
</div>
|
||||
<a class="link-button" href="/ops/send-console">查看明细</a>
|
||||
/* summary table extras */
|
||||
.summary-table td:first-child strong { display: inline-block; margin-bottom: 4px; }
|
||||
.summary-table td:first-child span { display: inline-block; font-size: 12px; color: var(--text-soft); }
|
||||
.mini-bar { height: 6px; border-radius: 999px; background: var(--surface-tint); overflow: hidden; min-width: 80px; }
|
||||
.mini-bar > i { display: block; height: 100%; border-radius: 999px; background: var(--fire-grad); }
|
||||
|
||||
/* collapse login */
|
||||
.collapse-panel { border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--panel-glass); box-shadow: var(--shadow); backdrop-filter: var(--glass-blur); overflow: hidden; }
|
||||
.collapse-panel > summary { list-style: none; cursor: pointer; padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.collapse-panel > summary::-webkit-details-marker { display: none; }
|
||||
.collapse-summary-title { display: flex; align-items: center; gap: 12px; }
|
||||
.collapse-summary-title h2 { margin: 0; font-size: 18px; }
|
||||
.collapse-summary-title .chev { width: 26px; height: 26px; border-radius: 8px; background: var(--surface-tint); color: var(--text-soft); display: inline-flex; align-items: center; justify-content: center; font-size: 13px; transition: transform 0.18s ease; }
|
||||
.collapse-panel[open] > summary .chev { transform: rotate(90deg); }
|
||||
.collapse-body { padding: 0 22px 22px; }
|
||||
.login-stepbar { display: flex; align-items: center; gap: 12px; padding: 0 0 16px; flex-wrap: wrap; }
|
||||
.step-item { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-soft); font-weight: 700; white-space: nowrap; }
|
||||
.step-index { width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: var(--fire-grad); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.26); font-size: 12px; }
|
||||
.step-connector { flex: 1; min-width: 24px; height: 2px; border-radius: 999px; background: linear-gradient(90deg, rgba(255, 122, 61, 0.3), var(--border)); }
|
||||
.login-grid { display: grid; grid-template-columns: 320px minmax(0, 1fr) 300px; gap: 18px; align-items: start; }
|
||||
.login-box { border: 1px solid var(--border); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow-soft); padding: 18px; min-height: 300px; }
|
||||
.login-box.warning { background: var(--warning-soft); }
|
||||
.login-box.highlight { background: var(--success-soft); }
|
||||
.login-lead { color: var(--text-soft); font-size: 14px; line-height: 1.8; }
|
||||
.feature-list { margin: 0; padding-left: 18px; color: var(--text); line-height: 1.8; }
|
||||
.url-field { display: flex; gap: 10px; align-items: center; }
|
||||
.url-field input { flex: 1; }
|
||||
.url-copy { width: 46px; padding: 11px 0; }
|
||||
.desktop-frame-wrap { overflow: hidden; border-radius: 18px; border: 1px solid var(--border); background: #0d1525; }
|
||||
.desktop-frame { width: 100%; aspect-ratio: 16 / 10; border: none; display: block; background: #0d1525; }
|
||||
.status-list { display: grid; gap: 12px; margin: 8px 0 18px; }
|
||||
.status-item { display: flex; align-items: center; gap: 10px; color: var(--text-soft); font-size: 14px; font-weight: 700; }
|
||||
.status-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--surface-tint); border: 1px solid var(--border-strong); }
|
||||
.status-item.pending .status-dot { background: var(--info-soft); border-color: var(--info); }
|
||||
.status-item.success .status-dot { background: var(--success-soft); border-color: var(--success); }
|
||||
.status-item.error .status-dot { background: var(--danger-soft); border-color: var(--danger); }
|
||||
.info-card { border-radius: 16px; border: 1px solid rgba(43, 212, 127, 0.22); background: var(--success-soft); padding: 16px; color: var(--text); line-height: 1.7; font-size: 13px; }
|
||||
.action-stack { display: grid; gap: 12px; }
|
||||
|
||||
/* layout */
|
||||
.dashboard-layout { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
|
||||
.dashboard-main, .dashboard-side { display: grid; gap: 22px; }
|
||||
|
||||
/* activity timeline */
|
||||
.tl { display: grid; gap: 10px; }
|
||||
.tl-row { display: flex; align-items: center; gap: 14px; padding: 12px 14px; border-radius: 14px; background: var(--surface-tint); border: 1px solid var(--border); }
|
||||
.tl-ico { width: 30px; height: 30px; border-radius: 9px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; flex: 0 0 30px; }
|
||||
.tl-ico.ok { background: var(--success-soft); color: var(--success); }
|
||||
.tl-ico.no { background: var(--danger-soft); color: var(--danger); }
|
||||
.tl-ico.wait { background: var(--warning-soft); color: var(--warning); }
|
||||
.tl-main { flex: 1; min-width: 0; }
|
||||
.tl-t { font-size: 13.5px; font-weight: 600; }
|
||||
.tl-t b { font-weight: 800; }
|
||||
.tl-s { font-size: 11.5px; color: var(--text-faint); margin-top: 2px; }
|
||||
.tl-time { font-size: 12px; color: var(--text-faint); white-space: nowrap; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; }
|
||||
|
||||
/* account details compact */
|
||||
.account-details { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-soft); overflow: hidden; }
|
||||
.account-details > summary { list-style: none; cursor: pointer; padding: 14px 18px; display: flex; align-items: center; gap: 16px; }
|
||||
.account-details > summary::-webkit-details-marker { display: none; }
|
||||
.account-summary { flex: 1; display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
.avatar-photo { width: 40px; height: 40px; border-radius: 12px; background: var(--fire-grad); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; font-weight: 800; flex: 0 0 40px; }
|
||||
.account-summary-name { min-width: 0; }
|
||||
.account-summary-name strong { font-size: 16px; font-weight: 700; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.account-summary-name span { font-size: 12px; color: var(--text-soft); }
|
||||
.account-summary-metrics { display: flex; gap: 16px; flex-wrap: wrap; margin-left: auto; }
|
||||
.account-metric { text-align: center; }
|
||||
.account-metric b { display: block; font-size: 17px; font-weight: 800; }
|
||||
.account-metric span { font-size: 11px; color: var(--text-faint); }
|
||||
.account-chev { color: var(--text-faint); font-size: 16px; transition: transform 0.18s ease; }
|
||||
.account-details[open] > summary .account-chev { transform: rotate(90deg); }
|
||||
.account-card { padding: 18px; border-top: 1px solid var(--border); display: grid; gap: 16px; }
|
||||
.friend-picker { border-radius: 18px; border: 1px solid var(--border); background: var(--surface-tint); padding: 14px; }
|
||||
.friend-picker-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
|
||||
.friend-picker-title { font-size: 14px; font-weight: 700; }
|
||||
.friend-picker-list { max-height: 220px; overflow: auto; border-radius: 14px; border: 1px solid var(--border); background: var(--surface); padding: 8px; display: grid; gap: 6px; }
|
||||
.friend-option { display: flex; align-items: center; justify-content: space-between; gap: 10px; border-radius: 12px; border: 1px solid transparent; padding: 9px 10px; }
|
||||
.friend-option input[type="checkbox"] { position: static; inset: auto; width: 16px; height: 16px; opacity: 1; pointer-events: auto; accent-color: var(--primary); flex: 0 0 auto; }
|
||||
.friend-option.selected { background: var(--primary-soft); border-color: rgba(255, 122, 61, 0.18); }
|
||||
.friend-picker-empty { padding: 18px 12px; text-align: center; color: var(--text-soft); }
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.ops-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
||||
.ops-card-grid .panel { padding: 20px; }
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.bento { grid-template-columns: 1fr 1fr; }
|
||||
.ring-card { grid-column: 1 / 2; }
|
||||
.ops-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.dashboard-layout { grid-template-columns: 1fr; }
|
||||
.login-grid { grid-template-columns: 1fr; }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.bento { grid-template-columns: 1fr; }
|
||||
.account-summary-metrics { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<svg width="0" height="0" style="position:absolute"><defs>
|
||||
<linearGradient id="fireGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffb04a"/><stop offset="40%" stop-color="#ff7a3d"/>
|
||||
<stop offset="75%" stop-color="#ff4d6d"/><stop offset="100%" stop-color="#ff2da8"/>
|
||||
</linearGradient>
|
||||
</defs></svg>
|
||||
|
||||
<div class="dash-stack">
|
||||
{# ---- task state ---- #}
|
||||
{% if task_lock and task_lock.running %}
|
||||
<div class="task-state-banner warning">发送任务运行中:pid {{ task_lock.pid or "unknown" }},已运行约 {{ task_lock.ageSeconds }} 秒。补发按钮会等任务结束后再可用。</div>
|
||||
{% elif task_lock and task_lock.staleRemoved %}
|
||||
<div class="task-state-banner info">已自动清理一个过期任务锁,可以重新发起补发。</div>
|
||||
{% else %}
|
||||
<div class="task-state-banner success">当前没有发送任务运行。</div>
|
||||
{% endif %}
|
||||
|
||||
{# ---- bento hero ---- #}
|
||||
<div class="bento">
|
||||
<div class="panel ring-card">
|
||||
<div class="card-h" style="position:relative;z-index:1;width:100%;justify-content:center"><h3>今日续火花进度</h3><span class="tag">实时</span></div>
|
||||
<div class="ring">
|
||||
<svg width="184" height="184" viewBox="0 0 184 184">
|
||||
<circle class="track" cx="92" cy="92" r="78" fill="none" stroke-width="14"/>
|
||||
<circle class="fill" cx="92" cy="92" r="78" fill="none" stroke-width="14"
|
||||
stroke-dasharray="{{ ring_circ }}" stroke-dashoffset="{{ ring_offset }}"/>
|
||||
</svg>
|
||||
<div class="ring-c">
|
||||
<div class="ring-num">{{ done_targets }}<small>/{{ total_targets }}</small></div>
|
||||
<div class="ring-lab">今日成功 / 总目标</div>
|
||||
<div class="ring-pct">{{ ring_pct }}% 已完成</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号</th>
|
||||
<th>今日成功</th>
|
||||
<th>失败待补发</th>
|
||||
<th>待发送</th>
|
||||
<th>未处理</th>
|
||||
<th>最近失败原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in ops.send_console.accounts %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ row.username }}</strong><br />
|
||||
<span class="muted">{{ row.unique_id }}</span>
|
||||
</td>
|
||||
<td>{{ row.sent_targets|length }}</td>
|
||||
<td>{{ row.failed_targets|length }}</td>
|
||||
<td>{{ row.pending_targets|length }}</td>
|
||||
<td>{{ row.unprocessed_targets|length }}</td>
|
||||
<td>{{ row.last_failure_reason or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">当前没有发送摘要数据。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>账号健康</h2>
|
||||
<p class="muted compact">
|
||||
快速确认账号启用状态、目标好友数量和登录态缓存规模。
|
||||
</p>
|
||||
</div>
|
||||
<a class="ghost-button" href="/accounts">管理账号</a>
|
||||
</div>
|
||||
|
||||
{% if accounts %}
|
||||
<div class="content-grid">
|
||||
{% for account in accounts %}
|
||||
<article class="quick-card">
|
||||
<div class="account-head">
|
||||
<div class="account-ident">
|
||||
<div class="avatar-photo">
|
||||
{{ account.username[:1] if account.username else "A" }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="account-name">{{ account.username }}</div>
|
||||
<div class="account-sub">
|
||||
unique_id: {{ account.unique_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="status-chip {% if account.enabled|default(true) %}success{% else %}warning{% endif %}"
|
||||
>
|
||||
{% if account.enabled|default(true) %}已启用{% else %}已停用{%
|
||||
endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="account-metrics">
|
||||
<div class="metric-box">
|
||||
<span class="label">目标</span
|
||||
><strong>{{ account.targets|length }}</strong>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">好友缓存</span
|
||||
><strong
|
||||
>{{ account.friends_cache|default([], true)|length }}</strong
|
||||
>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">Cookies</span
|
||||
><strong>{{ account.cookies|length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
还没有账号。前往登录工作区完成扫码登录并保存账号。
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>关键操作</h2>
|
||||
<p class="muted compact">
|
||||
常用入口和高风险动作分开处理,避免误触。
|
||||
</p>
|
||||
<div class="panel ov-card">
|
||||
<div class="card-h"><h3>火花概览</h3><span class="tag">全部账号</span></div>
|
||||
<div class="ov-hero">
|
||||
<div class="ov-flame">🔥</div>
|
||||
<div>
|
||||
<div class="ov-num">{{ enabled_accounts|length }}</div>
|
||||
<div class="ov-lab">启用账号 · 共 {{ accounts|length }} 个</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-list">
|
||||
<div class="ov-row"><span>今日成功率</span><b>{{ success_rate }}%</b></div>
|
||||
<div class="ov-row"><span>未完成目标</span><b>{{ remaining }}</b></div>
|
||||
<div class="ov-row"><span>今日火花状态</span>
|
||||
<span class="sparkdots">
|
||||
{% for i in range(5) %}<i class="{% if loop.index0 < (done_targets * 5 // ring_total) %}on{% endif %}"></i>{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="card-h"><h3>今日状态分布</h3><span class="tag">{{ total_targets }} 个目标</span></div>
|
||||
<div class="tiles">
|
||||
<div class="tile s"><div class="tile-lab">今日成功</div><div class="tile-val">{{ done_targets }}</div><div class="tile-help">已完成 {{ done_targets }}/{{ total_targets }}</div></div>
|
||||
<div class="tile f"><div class="tile-lab">失败待补发</div><div class="tile-val">{{ failed_targets }}</div><div class="tile-help">进入重试队列</div></div>
|
||||
<div class="tile p"><div class="tile-lab">待发送</div><div class="tile-val">{{ pending_targets }}</div><div class="tile-help">排队等待中</div></div>
|
||||
<div class="tile u"><div class="tile-lab">未处理</div><div class="tile-val">{{ unproc_targets }}</div><div class="tile-help">尚未进入流程</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- send summary table ---- #}
|
||||
<section class="panel" id="send-console-summary">
|
||||
<div class="sect-h">
|
||||
<div><h2>发送控制台摘要</h2><p class="muted compact">每个账号今天的发送状态分布,进入发送控制台可做批量补发或针对性重试。</p></div>
|
||||
<a class="link" href="/ops/send-console">打开发送控制台 →</a>
|
||||
</div>
|
||||
<div class="table-shell summary-table">
|
||||
<table>
|
||||
<thead><tr><th>账号</th><th>完成进度</th><th>今日成功</th><th>失败待补发</th><th>待发送</th><th>未处理</th><th>账号异常</th><th>最近失败原因</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in ops.send_console.accounts %}
|
||||
{% set row_total = row.total_targets or 1 %}
|
||||
{% set row_done = row.sent_targets|length %}
|
||||
{% set row_pct = (row_done / row_total * 100)|round(0)|int %}
|
||||
<tr>
|
||||
<td><strong>{{ row.username }}</strong><br><span>{{ row.unique_id }}</span></td>
|
||||
<td><div style="display:flex;align-items:center;gap:10px;"><span>{{ row_done }}/{{ row.total_targets }}</span><span class="mini-bar" style="width:90px;"><i style="width:{{ row_pct }}%"></i></span></div></td>
|
||||
<td><span class="status-chip success">{{ row_done }}</span></td>
|
||||
<td>{% if row.failed_targets|length %}<span class="status-chip danger">{{ row.failed_targets|length }}</span>{% else %}<span class="muted">0</span>{% endif %}</td>
|
||||
<td>{{ row.pending_targets|length }}</td>
|
||||
<td>{{ row.unprocessed_targets|length }}</td>
|
||||
<td>{% if row.account_blocked_targets|length %}<span class="status-chip warning">{{ row.account_blocked_targets|length }}</span>{% else %}<span class="muted">0</span>{% endif %}</td>
|
||||
<td>{% if row.last_failure_reason %}{{ row.last_failure_reason }}{% elif row.account_failure %}账号异常:{{ row.account_failure.category or "-" }}{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">当前没有发送摘要数据。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- activity timeline ---- #}
|
||||
<section>
|
||||
<div class="sect-h"><h2>实时发送动态</h2><a class="link" href="/ops/send-console">打开发送控制台 →</a></div>
|
||||
<div class="panel" style="padding:18px">
|
||||
{% set ns = namespace(rows=[]) %}
|
||||
{% for acct in ops.send_console.accounts %}
|
||||
{% for item in acct.sent_targets[:6] %}{% set ns.rows = ns.rows + [{type:'ok', acc:acct.username, target:item.target, msg:item.message, time:item.sentAt}] %}{% endfor %}
|
||||
{% for item in acct.failed_targets[:4] %}{% set ns.rows = ns.rows + [{type:'no', acc:acct.username, target:item.target, msg:item.reason, time:item.category}] %}{% endfor %}
|
||||
{% for item in acct.pending_targets[:4] %}{% set ns.rows = ns.rows + [{type:'wait', acc:acct.username, target:item.target, msg:item.scheduledAt, time:'待发送'}] %}{% endfor %}
|
||||
{% endfor %}
|
||||
{% if ns.rows %}
|
||||
<div class="tl">
|
||||
{% for r in ns.rows[:10] %}
|
||||
<div class="tl-row">
|
||||
<div class="tl-ico {{ r.type }}">{% if r.type == 'ok' %}✓{% elif r.type == 'no' %}✗{% else %}◔{% endif %}</div>
|
||||
<div class="tl-main">
|
||||
<div class="tl-t">{{ r.acc }} → <b>{{ r.target }}</b> {% if r.type == 'ok' %}续火花成功{% elif r.type == 'no' %}发送失败{% else %}等待发送{% endif %}</div>
|
||||
<div class="tl-s">{% if r.type == 'ok' and r.msg %}消息:{{ r.msg }}{% elif r.type == 'no' and r.msg %}原因:{{ r.msg }}{% elif r.type == 'wait' and r.msg %}预计 {{ r.msg }} 发送{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="tl-time">{{ r.time or '—' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">今日暂无发送动态。任务运行后会在这里实时显示成功 / 失败 / 待发送记录。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- interactive login browser (full width) ---- #}
|
||||
<section>
|
||||
<div class="sect-h"><h2>登录抖音账号</h2></div>
|
||||
<details class="collapse-panel" id="interactive-login-section" open>
|
||||
<summary>
|
||||
<div class="collapse-summary-title"><span class="chev">▸</span><h2 style="font-size:16px">交互式登录浏览器</h2></div>
|
||||
<span class="pill" id="login-desktop-runtime-state">检查中</span>
|
||||
</summary>
|
||||
<div class="collapse-body">
|
||||
<p class="muted compact" style="margin:-4px 0 16px;">通过 noVNC 打开远端浏览器,手动扫码登录抖音,再把登录态保存回系统。适合登录态失效或批量修复。</p>
|
||||
<div class="login-grid">
|
||||
<div class="login-box warning">
|
||||
<div class="action-stack">
|
||||
<div class="status-line"><span class="pill soft">推荐模式</span><span class="pill">noVNC :8788</span></div>
|
||||
<div class="login-lead">在网页中操作远端浏览器完成抖音登录,登录态同步回后台账号系统。</div>
|
||||
<ul class="feature-list"><li>支持扫码、验证码、短信验证等人工步骤</li><li>登录成功后可保存到新账号或覆盖已有账号</li><li>适合登录态失效、环境异常的人工接管</li></ul>
|
||||
<div class="button-row">
|
||||
<button type="button" class="success-button login-desktop-open" data-relogin-unique-id="">打开交互式登录浏览器 ↗</button>
|
||||
<button type="button" class="ghost-button login-desktop-reset">重置登录桌面 ↻</button>
|
||||
<button type="button" class="soft-button login-desktop-save" data-relogin-unique-id="">保存当前登录账号</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<div class="stack-form">
|
||||
<strong>浏览器工作区</strong>
|
||||
<div class="url-field"><input id="desktop-public-url" type="text" value="{{ login_desktop_public_url }}" readonly><button type="button" class="ghost-button url-copy" id="copy-public-url">⧉</button></div>
|
||||
<div class="desktop-frame-wrap"><iframe class="desktop-frame" src="{{ login_desktop_public_url }}" title="交互式登录浏览器工作区" loading="lazy"></iframe></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box highlight" id="login-desktop-controls" data-public-url="{{ login_desktop_public_url }}" data-csrf-token="{{ csrf_token }}">
|
||||
<div class="card-h" style="margin-bottom:10px"><h3 style="font-size:14px">登录状态</h3></div>
|
||||
<div class="status-list">
|
||||
<div class="status-item" id="desktop-status-checking"><span class="status-dot"></span><span>检查中</span></div>
|
||||
<div class="status-item pending" id="desktop-status-pending"><span class="status-dot"></span><span>待登录</span></div>
|
||||
<div class="status-item success" id="desktop-status-success"><span class="status-dot"></span><span>已登录</span></div>
|
||||
<div class="status-item error" id="desktop-status-error"><span class="status-dot"></span><span>异常</span></div>
|
||||
</div>
|
||||
<div class="info-card"><div id="login-desktop-status-text">正在检查交互式登录桌面状态。</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-list">
|
||||
<a class="link-button" href="/login-workspace">打开登录工作区</a>
|
||||
<a class="ghost-button" href="/accounts">账号与目标管理</a>
|
||||
<a class="ghost-button" href="/settings">运行与系统设置</a>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="soft-button" type="submit">补发未成功目标</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/ops/run-now"
|
||||
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">补发全部对象</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{# ---- account management ---- #}
|
||||
<section class="panel" id="account-management">
|
||||
<div class="sect-h"><div><h2>账号管理</h2><p class="muted compact">点击账号展开管理开关、目标好友、好友缓存、登录同步与删除。共 {{ accounts|length }} 个账号。</p></div></div>
|
||||
{% if accounts %}
|
||||
<div style="display:grid;gap:14px;">
|
||||
{% for account in accounts %}
|
||||
<details class="account-details">
|
||||
<summary>
|
||||
<div class="account-summary">
|
||||
<div class="avatar-photo">{{ account.username[:1] if account.username else "A" }}</div>
|
||||
<div class="account-summary-name"><strong>{{ account.username }}</strong><span>unique_id: {{ account.unique_id }}</span></div>
|
||||
<span class="status-chip {% if account.enabled|default(true) %}success{% else %}warning{% endif %}">{% if account.enabled|default(true) %}已启用{% else %}已停用{% endif %}</span>
|
||||
<div class="account-summary-metrics">
|
||||
<div class="account-metric"><b>{{ account.cookies|length }}</b><span>Cookies</span></div>
|
||||
<div class="account-metric"><b>{{ account.targets|length }}</b><span>目标</span></div>
|
||||
<div class="account-metric"><b>{{ account.friends_cache|default([], true)|length }}</b><span>好友缓存</span></div>
|
||||
</div>
|
||||
<span class="account-chev">▸</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="account-card">
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/update" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><span>显示名</span><input type="text" name="username" value="{{ account.username }}"></label>
|
||||
<label class="check-row"><input type="checkbox" name="enabled" {% if account.enabled|default(true) %}checked{% endif %}><span>启用自动续火花</span></label>
|
||||
<label><span>目标好友(每行一个,可手动编辑或从下方勾选)</span><textarea class="targets-textarea" name="targets" rows="5">{{ account.targets|default([], true)|join('\n') }}</textarea></label>
|
||||
<p class="muted compact">未在好友缓存中的昵称也会保留在目标列表里。</p>
|
||||
<div class="friend-picker" data-account-id="{{ account.unique_id }}" data-refresh-url="/accounts/{{ account.unique_id }}/friends/refresh" data-csrf-token="{{ csrf_token }}" data-updated-at="{{ account.friends_cache_updated_at|default('', true) }}">
|
||||
<div class="friend-picker-toolbar">
|
||||
<div><span class="friend-picker-title">好友选择器</span><p class="muted compact friend-picker-status">{% if account.friends_cache_updated_at %}上次刷新:{{ account.friends_cache_updated_at }}{% else %}还没有读取好友列表{% endif %}</p></div>
|
||||
<button type="button" class="ghost-button friend-refresh-button">刷新好友列表</button>
|
||||
</div>
|
||||
<label class="stack-form" style="gap: 8px;"><span>搜索好友</span><input type="search" class="friend-search-input" placeholder="输入昵称筛选好友"></label>
|
||||
<p class="friend-picker-current-targets muted compact"><strong>当前目标好友:</strong><span>{{ account.targets|join('、') if account.targets else '未选择' }}</span></p>
|
||||
<p class="friend-picker-summary muted compact">已选 0 人</p>
|
||||
<div class="friend-selected-inputs"></div>
|
||||
<div class="friend-picker-list"></div>
|
||||
<script type="application/json" id="friends-cache-{{ account.unique_id }}">{{ account.friends_cache|default([], true)|tojson }}</script>
|
||||
<script type="application/json" id="selected-targets-{{ account.unique_id }}">{{ account.targets|default([], true)|tojson }}</script>
|
||||
</div>
|
||||
<button type="submit">保存账号</button>
|
||||
</form>
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost-button login-desktop-open" data-relogin-unique-id="{{ account.unique_id }}" data-account-name="{{ account.username }}">打开登录浏览器</button>
|
||||
<button type="button" class="ghost-button login-desktop-save" data-relogin-unique-id="{{ account.unique_id }}" data-account-name="{{ account.username }}">保存当前浏览器登录</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/toggle-enabled"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="{% if account.enabled|default(true) %}soft-button{% else %}success-button{% endif %}" type="submit">{% if account.enabled|default(true) %}停用自动续火花{% else %}启用自动续火花{% endif %}</button></form>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/delete"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger-button" type="submit">删除账号</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">当前没有账号。先通过交互式登录浏览器登录并保存账号。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<div class="dashboard-main">
|
||||
<div class="ops-card-grid">
|
||||
<section class="panel"><h2>容器状态</h2><div class="table-shell scrollable" style="margin-top:16px;"><table><thead><tr><th>Name</th><th>Status</th><th>Image</th></tr></thead><tbody>{% for row in ops.containers %}<tr><td>{{ row.Names }}</td><td><span class="pill {% if 'Up' in row.Status %}soft{% else %}warning{% endif %}">{{ row.Status }}</span></td><td>{{ row.Image }}</td></tr>{% else %}<tr><td colspan="3">当前没有可见容器状态。</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="panel"><h2>Cron / 调度</h2><pre class="code-block" style="margin-top:16px;">{{ ops.crontab or "当前没有 crontab 任务。" }}</pre></section>
|
||||
<section class="panel"><h2>日志预览</h2><pre class="code-block light" style="margin-top:16px;min-height:260px;">{{ ops.log_tail or "暂无日志" }}</pre></section>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="dashboard-side">
|
||||
<section class="panel" id="config-panel">
|
||||
<div class="sect-h"><div><h2>运行配置</h2><p class="muted compact">消息模板、随机策略、时间间隔等核心发送参数。</p></div></div>
|
||||
<form method="post" action="/config" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><span>固定消息模板</span><input type="text" name="messageTemplate" value="{{ runtime_config.messageTemplate }}"></label>
|
||||
<label><span>消息变体(每行一个)</span><textarea name="messageVariants" rows="5">{{ runtime_config.sendStrategy.messageVariants|join('\n') }}</textarea></label>
|
||||
<label><span>是否随机打乱目标顺序</span><select name="shuffleTargets"><option value="on" {% if runtime_config.sendStrategy.shuffleTargets %}selected{% endif %}>是</option><option value="" {% if not runtime_config.sendStrategy.shuffleTargets %}selected{% endif %}>否</option></select></label>
|
||||
<div class="settings-grid">
|
||||
<label><span>消息最小间隔(秒)</span><input type="number" name="messageIntervalSecondsMin" min="0" value="{{ runtime_config.sendStrategy.messageIntervalSecondsMin }}"></label>
|
||||
<label><span>消息最大间隔(秒)</span><input type="number" name="messageIntervalSecondsMax" min="0" value="{{ runtime_config.sendStrategy.messageIntervalSecondsMax }}"></label>
|
||||
</div>
|
||||
<button type="submit">保存运行配置</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="ops-panel">
|
||||
<div class="sect-h"><div><h2>运维操作</h2><p class="muted compact">立即补发、刷新代理、查看日志和调整自动发送窗口。</p></div></div>
|
||||
<div class="button-grid">
|
||||
<form method="post" action="/ops/run-failed"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button></form>
|
||||
<form method="post" action="/ops/run-now"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button></form>
|
||||
<form method="post" action="/ops/proxy/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost-button" type="submit">刷新代理订阅</button></form>
|
||||
<form method="post" action="/ops/proxy/restart"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger-button" type="submit">重启代理容器</button></form>
|
||||
<a class="link-button" href="/ops/logs">查看详细日志</a>
|
||||
</div>
|
||||
<form method="post" action="/ops/schedule" class="stack-form" style="margin-top:18px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><span>发送窗口(北京时间,例如 10:00-18:00/10m)</span><input type="text" name="daily_schedule" value="{{ ops.daily_schedule }}" placeholder="10:00-18:00/10m"></label>
|
||||
<button type="submit" class="ghost-button">更新发送窗口</button>
|
||||
</form>
|
||||
<div class="stack-form" style="margin-top:18px;">
|
||||
<label><span>Compose 根目录</span><input type="text" value="{{ ops.compose_root }}" readonly></label>
|
||||
<label><span>Compose 文件路径</span><input type="text" value="{{ ops.compose_file or '未检测到' }}" readonly></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>服务状态</h2>
|
||||
<p class="muted compact">代理、调度和发送窗口的轻量巡检。</p>
|
||||
<section class="panel" id="settings-panel">
|
||||
<div class="sect-h"><div><h2>面板与服务设置</h2><p class="muted compact">服务连接、日志路径、交互式登录 API 与管理员配置。</p></div></div>
|
||||
<form method="post" action="/settings" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="settings-grid">
|
||||
<label><span>服务器 Host</span><input type="text" name="server_host" value="{{ app_settings.server_host }}"></label>
|
||||
<label><span>服务器用户名</span><input type="text" name="server_username" value="{{ app_settings.server_username }}"></label>
|
||||
<label><span>服务器密码</span><input type="password" name="server_password" value="{{ app_settings.server_password }}"></label>
|
||||
<label><span>Compose 根目录</span><input type="text" name="compose_root" value="{{ app_settings.compose_root }}"></label>
|
||||
<label><span>任务日志文件</span><input type="text" name="ops_log_file" value="{{ app_settings.ops_log_file }}"></label>
|
||||
<label><span>代理刷新脚本</span><input type="text" name="proxy_refresh_script" value="{{ app_settings.proxy_refresh_script }}"></label>
|
||||
<label><span>交互式登录桌面 API 地址</span><input type="text" name="login_desktop_api_url" value="{{ app_settings.login_desktop_api_url }}"></label>
|
||||
<label><span>Web UI 端口</span><input type="number" min="1" max="65535" name="ui_port" value="{{ app_settings.ui_port }}"></label>
|
||||
<label><span>修改管理员密码</span><input type="password" name="new_password" placeholder="留空表示不修改"></label>
|
||||
<label><span>确认新密码</span><input type="password" name="confirm_password" placeholder="再次输入新密码"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div class="summary-row">
|
||||
<div>
|
||||
<strong>代理容器</strong>
|
||||
<span
|
||||
>{{ proxy_rows[0].Status if proxy_rows else "未检测到 mihomo"
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="status-chip {% if proxy_rows and 'Up' in proxy_rows[0].Status %}success{% else %}warning{% endif %}"
|
||||
>
|
||||
{% if proxy_rows and 'Up' in proxy_rows[0].Status %}运行中{% else
|
||||
%}需检查{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<div>
|
||||
<strong>发送窗口</strong>
|
||||
<span>{{ ops.daily_schedule or "未配置" }}</span>
|
||||
</div>
|
||||
<a class="soft-button" href="/settings">调整</a>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<div>
|
||||
<strong>待处理目标</strong>
|
||||
<span>{{ pending_total }} 个目标等待发送或调度</span>
|
||||
</div>
|
||||
<a class="ghost-button" href="/ops/send-console">查看</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;"><button type="submit">保存设置</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
const csrfToken = root.dataset.csrfToken || "";
|
||||
const publicUrl = root.dataset.publicUrl || "";
|
||||
const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
|
||||
const statusTextEl = document.getElementById("login-desktop-status-text");
|
||||
const openButtons = document.querySelectorAll(".login-desktop-open");
|
||||
const saveButtons = document.querySelectorAll(".login-desktop-save");
|
||||
const resetButtons = document.querySelectorAll(".login-desktop-reset");
|
||||
const copyPublicUrlButton = document.getElementById("copy-public-url");
|
||||
const statusMap = {
|
||||
checking: document.getElementById("desktop-status-checking"),
|
||||
pending: document.getElementById("desktop-status-pending"),
|
||||
success: document.getElementById("desktop-status-success"),
|
||||
error: document.getElementById("desktop-status-error"),
|
||||
};
|
||||
const setVisualStatus = (state) => {
|
||||
Object.values(statusMap).forEach((node) => { if (node) node.style.opacity = "0.42"; });
|
||||
if (statusMap[state]) statusMap[state].style.opacity = "1";
|
||||
};
|
||||
const setStatus = (text, tone = "", state = "checking") => {
|
||||
if (statusTextEl) statusTextEl.textContent = text;
|
||||
if (runtimeStateEl) {
|
||||
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
runtimeStateEl.textContent = state === "success" ? "已登录" : (state === "error" ? "异常" : (state === "pending" ? "待登录" : "检查中"));
|
||||
}
|
||||
setVisualStatus(state);
|
||||
};
|
||||
const postForm = async (url, payload = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
Object.entries(payload).forEach(([key, value]) => formData.set(key, String(value ?? "")));
|
||||
const response = await fetch(url, { method: "POST", body: formData, credentials: "same-origin" });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false) throw new Error(data.error || `request failed: ${response.status}`);
|
||||
return data;
|
||||
};
|
||||
const openDesktopWindow = () => {
|
||||
const popup = window.open(publicUrl, "_blank");
|
||||
if (!popup) { setStatus("浏览器阻止了交互式登录窗口,请允许弹窗后再试。", "danger", "error"); return false; }
|
||||
return true;
|
||||
};
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/login-desktop/status", { credentials: "same-origin" });
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) { setStatus(data.error || "交互式登录桌面不可用", "warning", "error"); return; }
|
||||
if (data.logged_in) setStatus(`当前浏览器已登录:${data.username}(${data.unique_id})`, "soft", "success");
|
||||
else setStatus("当前浏览器尚未登录,可打开交互式登录浏览器开始人工登录。", "", "pending");
|
||||
} catch (error) { setStatus(`状态检查失败:${error.message}`, "danger", "error"); }
|
||||
};
|
||||
if (copyPublicUrlButton) {
|
||||
copyPublicUrlButton.addEventListener("click", async () => {
|
||||
try { await navigator.clipboard.writeText(publicUrl); setStatus("交互式登录地址已复制,可在新标签页打开。", "soft", "pending"); }
|
||||
catch (error) { setStatus(`复制失败:${error.message}`, "warning", "error"); }
|
||||
});
|
||||
}
|
||||
openButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(button.dataset.reloginUniqueId || "").trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
openDesktopWindow();
|
||||
setStatus(reloginUniqueId ? `已打开交互式登录浏览器,请使用账号 ${accountName || reloginUniqueId} 完成登录。` : "已打开交互式登录浏览器,请在远端浏览器中完成抖音创作者中心登录。", "", "pending");
|
||||
} catch (error) { setStatus(`打开交互式登录浏览器失败:${error.message}`, "danger", "error"); }
|
||||
});
|
||||
});
|
||||
saveButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(button.dataset.reloginUniqueId || "").trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
const data = await postForm("/login-desktop/save", { relogin_unique_id: reloginUniqueId });
|
||||
setStatus(reloginUniqueId ? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}` : `已保存当前登录账号:${data.account?.username || ""}`, "soft", "success");
|
||||
window.setTimeout(() => window.location.reload(), 800);
|
||||
} catch (error) { setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error"); }
|
||||
});
|
||||
});
|
||||
resetButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try { await postForm("/login-desktop/reset"); setStatus("交互式登录桌面已重置,正在重新初始化浏览器。", "warning", "checking"); await pollStatus(); }
|
||||
catch (error) { setStatus(`重置交互式登录桌面失败:${error.message}`, "danger", "error"); }
|
||||
});
|
||||
});
|
||||
setVisualStatus("checking");
|
||||
pollStatus();
|
||||
window.setInterval(pollStatus, 5000);
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const pickers = document.querySelectorAll(".friend-picker");
|
||||
if (!pickers.length) return;
|
||||
const parseJsonScript = (id) => { const el = document.getElementById(id); if (!el) return []; try { return JSON.parse(el.textContent || "[]"); } catch (e) { return []; } };
|
||||
const escapeHtml = (v) => v.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
|
||||
pickers.forEach((picker) => {
|
||||
const accountId = picker.dataset.accountId;
|
||||
const refreshUrl = picker.dataset.refreshUrl;
|
||||
const csrfToken = picker.dataset.csrfToken;
|
||||
const searchInput = picker.querySelector(".friend-search-input");
|
||||
const refreshButton = picker.querySelector(".friend-refresh-button");
|
||||
const listEl = picker.querySelector(".friend-picker-list");
|
||||
const summaryEl = picker.querySelector(".friend-picker-summary");
|
||||
const statusEl = picker.querySelector(".friend-picker-status");
|
||||
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
|
||||
const formEl = picker.closest("form");
|
||||
const targetsTextarea = formEl?.querySelector(".targets-textarea");
|
||||
const currentTargetsEl = picker.querySelector(".friend-picker-current-targets span");
|
||||
let friends = parseJsonScript(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
|
||||
const combinedFriends = () => { const merged = []; const seen = new Set(); [...selected, ...friends].forEach((name) => { if (!name || seen.has(name)) return; seen.add(name); merged.push(name); }); return merged; };
|
||||
const splitTargetText = (value) => { const seen = new Set(); return String(value || "").replaceAll(",", "\n").split(/\r?\n/).map((n) => n.trim()).filter((n) => { if (!n || seen.has(n)) return false; seen.add(n); return true; }); };
|
||||
const syncTextareaFromSelected = () => { if (targetsTextarea) targetsTextarea.value = [...selected].join("\n"); };
|
||||
const syncSelectedFromTextarea = () => { if (targetsTextarea) selected = new Set(splitTargetText(targetsTextarea.value)); };
|
||||
const renderHiddenInputs = () => { hiddenInputsEl.innerHTML = ""; [...selected].forEach((name) => { const input = document.createElement("input"); input.type = "hidden"; input.name = "targets"; input.value = name; hiddenInputsEl.appendChild(input); }); };
|
||||
const updateSummary = () => { summaryEl.textContent = `已选 ${selected.size} 人`; if (currentTargetsEl) currentTargetsEl.textContent = selected.size ? [...selected].join("、") : "未选择"; };
|
||||
const renderList = () => {
|
||||
const query = (searchInput.value || "").trim().toLowerCase();
|
||||
const allNames = combinedFriends();
|
||||
const displayNames = allNames.filter((n) => n.toLowerCase().includes(query));
|
||||
renderHiddenInputs(); updateSummary();
|
||||
if (!allNames.length) { listEl.innerHTML = '<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>'; return; }
|
||||
if (!displayNames.length) { listEl.innerHTML = '<div class="friend-picker-empty">没有匹配的好友。</div>'; return; }
|
||||
listEl.innerHTML = displayNames.map((name) => `<label class="friend-option ${selected.has(name) ? "selected" : ""}"><span>${escapeHtml(name)}</span><input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}></label>`).join("");
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const option = cb.closest(".friend-option"); const value = cb.value;
|
||||
if (cb.checked) { selected.add(value); option?.classList.add("selected"); } else { selected.delete(value); option?.classList.remove("selected"); }
|
||||
syncTextareaFromSelected(); renderHiddenInputs(); updateSummary();
|
||||
});
|
||||
});
|
||||
};
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
refreshButton.disabled = true; const orig = refreshButton.textContent; refreshButton.textContent = "刷新中...";
|
||||
statusEl.textContent = "正在实时读取好友列表…";
|
||||
try {
|
||||
const formData = new FormData(); formData.set("csrf_token", csrfToken);
|
||||
const response = await fetch(refreshUrl, { method: "POST", body: formData, credentials: "same-origin" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "刷新好友列表失败");
|
||||
friends = Array.isArray(payload.friends) ? payload.friends : [];
|
||||
statusEl.textContent = payload.message || `已刷新 ${friends.length} 个好友`;
|
||||
renderList();
|
||||
} catch (error) { statusEl.textContent = error.message || "刷新好友列表失败"; }
|
||||
finally { refreshButton.disabled = false; refreshButton.textContent = orig; }
|
||||
});
|
||||
searchInput.addEventListener("input", renderList);
|
||||
if (targetsTextarea) targetsTextarea.addEventListener("input", () => { syncSelectedFromTextarea(); renderList(); });
|
||||
syncSelectedFromTextarea(); renderList();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,71 +1,168 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 | 自动续火花控制台</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle login-theme-toggle"
|
||||
data-theme-toggle
|
||||
aria-label="切换黑夜模式"
|
||||
title="切换黑夜模式"
|
||||
>
|
||||
<span class="theme-icon theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon theme-moon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 | 续火花控制台</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--text: #eaf0ff;
|
||||
--text-soft: #9aa6c8;
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--fire-grad: linear-gradient(135deg,#ffb04a 0%,#ff7a3d 30%,#ff4d6d 65%,#ff2da8 100%);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
||||
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
<main class="login-card">
|
||||
<div class="brand login-brand">
|
||||
<div class="brand-mark">⚡</div>
|
||||
/* ---- left brand panel ---- */
|
||||
.brand-panel {
|
||||
position: relative; overflow: hidden;
|
||||
padding: 48px 52px;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(680px circle at 12% 12%, rgba(255,122,61,0.45), transparent 55%),
|
||||
radial-gradient(620px circle at 95% 92%, rgba(255,45,168,0.32), transparent 50%),
|
||||
linear-gradient(205deg, #1a1230 0%, #140e26 56%, #0c0a1a 100%);
|
||||
display: flex; flex-direction: column; justify-content: space-between; gap: 32px;
|
||||
}
|
||||
.brand-panel::after {
|
||||
content: ""; position: absolute; right: -120px; bottom: -120px;
|
||||
width: 360px; height: 360px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,77,109,0.32), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-top { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
|
||||
.brand-mark {
|
||||
width: 46px; height: 46px; border-radius: 14px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; font-weight: 800;
|
||||
box-shadow: 0 16px 32px rgba(255,77,109,0.45);
|
||||
}
|
||||
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: 0.01em; }
|
||||
.brand-sub { margin-top: 3px; font-size: 12.5px; color: rgba(255,255,255,0.55); }
|
||||
|
||||
.brand-hero { position: relative; z-index: 1; max-width: 440px; }
|
||||
.brand-hero h1 {
|
||||
margin: 0 0 16px; font-size: 36px; line-height: 1.18; font-weight: 800; letter-spacing: -0.02em;
|
||||
}
|
||||
.brand-hero h1 em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(120deg,#ffd9a0,#ff7a3d 50%,#ff2da8);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.brand-hero p { margin: 0; font-size: 15px; line-height: 1.8; color: rgba(255,255,255,0.72); }
|
||||
.brand-features { position: relative; z-index: 1; display: grid; gap: 14px; margin-top: 8px; }
|
||||
.brand-feature { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: rgba(255,255,255,0.82); }
|
||||
.brand-feature i {
|
||||
font-style: normal; width: 26px; height: 26px; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14);
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
}
|
||||
.brand-foot { position: relative; z-index: 1; font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* ---- right form panel ---- */
|
||||
.form-panel {
|
||||
display: flex; align-items: center; justify-content: center; padding: 40px 28px;
|
||||
background:
|
||||
radial-gradient(720px circle at 100% -10%, rgba(255,122,61,0.10), transparent 45%),
|
||||
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
|
||||
}
|
||||
.card { width: min(94vw, 420px); }
|
||||
.card-title { margin: 0 0 6px; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
|
||||
.card-lead { margin: 0 0 24px; color: var(--text-soft); line-height: 1.6; font-size: 14px; }
|
||||
form { display: grid; gap: 15px; }
|
||||
label { display: grid; gap: 7px; }
|
||||
label > span { font-size: 13px; color: var(--text-soft); font-weight: 600; }
|
||||
input {
|
||||
width: 100%; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 12px 14px; color: var(--text); outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
input::placeholder { color: var(--text-soft); }
|
||||
input:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(255,122,61,0.22); }
|
||||
button {
|
||||
margin-top: 4px; border: none; border-radius: 12px; padding: 13px 14px;
|
||||
background: var(--fire-grad); color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
|
||||
box-shadow: 0 14px 28px rgba(255,77,109,0.36);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 18px 34px rgba(255,77,109,0.44); filter: brightness(1.05); }
|
||||
button:active { transform: translateY(0); }
|
||||
.flash { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; font-size: 13px; border: 1px solid transparent; }
|
||||
.flash.success { background: rgba(43,212,127,0.14); color: #2bd47f; border-color: rgba(43,212,127,0.28); }
|
||||
.flash.warning { background: rgba(255,181,71,0.14); color: #ffb547; border-color: rgba(255,181,71,0.28); }
|
||||
.flash.error { background: rgba(255,84,112,0.14); color: #ff5470; border-color: rgba(255,84,112,0.28); }
|
||||
.foot { margin-top: 20px; text-align: center; font-size: 12px; color: #64709a; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body { grid-template-columns: 1fr; }
|
||||
.brand-panel { padding: 32px 28px; }
|
||||
.brand-hero h1 { font-size: 26px; }
|
||||
.brand-features { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="brand-panel">
|
||||
<div class="brand-top">
|
||||
<div class="brand-mark">🔥</div>
|
||||
<div>
|
||||
<div class="brand-title">自动续火花</div>
|
||||
<div class="brand-subtitle">多账号运维面板</div>
|
||||
<div class="brand-title">续火花</div>
|
||||
<div class="brand-sub">多账号控制平台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>控制台登录</h1>
|
||||
<p class="muted">
|
||||
用于管理抖音多账号、目标好友、自动续火花任务与运维配置。
|
||||
</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }} login-flash">
|
||||
{{ flash.message }}
|
||||
<div class="brand-hero">
|
||||
<h1>让每个好友<br><em>都不再断火花</em></h1>
|
||||
<p>集中管理抖音多账号、目标好友与自动续火花任务,定时发送、失败补发、登录同步一站式运维。</p>
|
||||
<div class="brand-features">
|
||||
<div class="brand-feature"><i>✦</i><span>多账号批量发送与定时调度</span></div>
|
||||
<div class="brand-feature"><i>↻</i><span>失败目标自动补发与重试队列</span></div>
|
||||
<div class="brand-feature"><i>🔥</i><span>网页内交互式登录与登录态同步</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-foot">DouYin Spark Flow · Admin Console</div>
|
||||
</aside>
|
||||
|
||||
<main class="form-panel">
|
||||
<div class="card">
|
||||
<h2 class="card-title">控制台登录</h2>
|
||||
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap">
|
||||
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
|
||||
<label><span>管理员密码</span><input type="password" name="password"></label>
|
||||
<label><span>确认密码</span><input type="password" name="confirm_password"></label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login">
|
||||
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
|
||||
<label><span>管理员密码</span><input type="password" name="password"></label>
|
||||
<button type="submit">登录控制台</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div>
|
||||
</div>
|
||||
{% endif %} {% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<label>
|
||||
<span>确认密码</span>
|
||||
<input type="password" name="confirm_password" />
|
||||
</label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<button type="submit">登录控制台</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</main>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,19 +3,216 @@
|
||||
{% block nav_key %}logs{% endblock %}
|
||||
{% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %}
|
||||
{% block page_title %}运行日志{% endblock %}
|
||||
{% block page_subtitle %}查看最近任务输出和服务诊断信息{% endblock %}
|
||||
{% block page_subtitle %}登录同步 · 发送过程 · 容器状态{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/">⌂ 返回首页</a>
|
||||
<a class="ghost-button" href="/ops/send-console">✦ 发送控制台</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.logs-toolbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logs-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.logs-meta .dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--fire-grad);
|
||||
box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
|
||||
}
|
||||
.logs-meta .dot.live {
|
||||
background: linear-gradient(135deg, #18a558, #4fd08a);
|
||||
box-shadow: 0 0 0 4px rgba(24, 165, 88, 0.16);
|
||||
animation: livePulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
.toggle-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.toggle-track {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-strong);
|
||||
position: relative;
|
||||
transition: background 0.18s ease;
|
||||
flex: 0 0 42px;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 5px rgba(20, 35, 70, 0.18);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.toggle-switch input { display: none; }
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: linear-gradient(180deg, var(--primary), var(--primary-strong));
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.logs-code {
|
||||
margin: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: #0f1728;
|
||||
color: #d8e2fb;
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
min-height: 420px;
|
||||
max-height: 70vh;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
tab-size: 2;
|
||||
}
|
||||
.logs-code:empty::before {
|
||||
content: "暂无日志输出";
|
||||
color: #5f6f8c;
|
||||
}
|
||||
.logs-empty {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--empty-bg);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>详细日志</h2>
|
||||
<p class="muted compact">
|
||||
展示最近的任务输出,便于检查登录同步、发送过程和容器状态。
|
||||
</p>
|
||||
<p class="muted compact">展示最近的任务输出,便于检查登录同步、发送过程和容器状态。</p>
|
||||
</div>
|
||||
<a class="link-button" href="/settings">返回系统设置</a>
|
||||
</div>
|
||||
<pre class="code-block tall">{{ log_tail or "暂无日志" }}</pre>
|
||||
|
||||
<div class="logs-toolbar">
|
||||
<div class="logs-toolbar__left">
|
||||
<span class="logs-meta"><span class="dot" id="logs-live-dot"></span><span id="logs-status-text">最近更新:刚刚</span></span>
|
||||
<span class="logs-meta"><span class="dot"></span><span id="logs-line-count">{{ (log_tail|trim).splitlines()|length }} 行</span></span>
|
||||
</div>
|
||||
<div class="logs-toolbar__left">
|
||||
<label class="toggle-switch" title="每 5 秒自动刷新日志">
|
||||
<input type="checkbox" id="logs-autorefresh">
|
||||
<span class="toggle-track"></span>
|
||||
<span>自动刷新</span>
|
||||
</label>
|
||||
<button type="button" class="ghost-button" id="logs-refresh-btn">↻ 刷新</button>
|
||||
<a class="ghost-button" id="logs-download-btn" href="data:text/plain;charset=utf-8,{{ log_tail|urlencode }}">⬇ 下载日志</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if log_tail and log_tail.strip() %}
|
||||
<pre class="logs-code" id="logs-code">{{ log_tail }}</pre>
|
||||
{% else %}
|
||||
<div class="logs-empty" id="logs-empty">当前没有日志输出。任务运行后这里会实时显示。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const codeEl = document.getElementById("logs-code");
|
||||
const emptyEl = document.getElementById("logs-empty");
|
||||
const statusEl = document.getElementById("logs-status-text");
|
||||
const lineEl = document.getElementById("logs-line-count");
|
||||
const liveDot = document.getElementById("logs-live-dot");
|
||||
const toggle = document.getElementById("logs-autorefresh");
|
||||
const refreshBtn = document.getElementById("logs-refresh-btn");
|
||||
let timer = null;
|
||||
|
||||
const fmtTime = () => {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `最近更新:${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
const renderBody = (text) => {
|
||||
const has = !!(text && text.trim());
|
||||
if (has) {
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
if (codeEl) {
|
||||
codeEl.style.display = "";
|
||||
codeEl.textContent = text;
|
||||
}
|
||||
if (lineEl) lineEl.textContent = `${text.trim().split(/\r?\n/).length} 行`;
|
||||
} else {
|
||||
if (codeEl) codeEl.style.display = "none";
|
||||
if (emptyEl) emptyEl.style.display = "";
|
||||
if (lineEl) lineEl.textContent = "0 行";
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const res = await fetch("/ops/logs", { credentials: "same-origin", headers: { "Accept": "text/html" } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const fetched = doc.getElementById("logs-code");
|
||||
const fetchedEmpty = doc.getElementById("logs-empty");
|
||||
if (fetched) {
|
||||
renderBody(fetched.textContent);
|
||||
} else if (fetchedEmpty) {
|
||||
renderBody("");
|
||||
}
|
||||
if (statusEl) statusEl.textContent = fmtTime();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = `刷新失败:${err.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
const setLive = (on) => {
|
||||
if (liveDot) liveDot.classList.toggle("live", on);
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (on) {
|
||||
timer = setInterval(refresh, 5000);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
if (toggle) toggle.addEventListener("change", () => setLive(toggle.checked));
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", refresh);
|
||||
if (statusEl) statusEl.textContent = fmtTime();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,96 +1,424 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}send_console{% endblock %}
|
||||
{% block title %}发送明细 | 自动续火花{% endblock %}
|
||||
{% block page_title %}发送明细{% endblock %}
|
||||
{% block page_subtitle %}今日发送明细、失败队列和针对性重试{% endblock %}
|
||||
{% block title %}发送控制台{% endblock %}
|
||||
{% block page_title %}发送控制台{% endblock %}
|
||||
{% block page_subtitle %}今日发送总览{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/">返回概览</a>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="soft-button" type="submit">补发未成功目标</button>
|
||||
<a class="ghost-button" href="/">返回首页</a>
|
||||
<form method="post" action="/ops/run-failed">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/ops/run-now"
|
||||
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">补发全部对象</button>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未发送目标</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.task-state-banner {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
|
||||
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
|
||||
</style>
|
||||
|
||||
{% set task_lock = ops.task_lock %}
|
||||
{% if task_lock and task_lock.running %}
|
||||
<div class="task-state-banner warning">发送任务运行中:pid {{ task_lock.pid or "unknown" }},已运行约 {{ task_lock.ageSeconds }} 秒。补发按钮会等任务结束后再可用。</div>
|
||||
{% elif task_lock and task_lock.staleRemoved %}
|
||||
<div class="task-state-banner info">已自动清理一个过期任务锁,可以重新发起补发。</div>
|
||||
{% else %}
|
||||
<div class="task-state-banner success">当前没有发送任务运行。</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% set send_console = ops.send_console %}
|
||||
<style>
|
||||
.console-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.console-overview {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.console-stats .stat-card {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.console-kpi-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.console-account-list {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
padding: 20px 20px 22px;
|
||||
border-left: 4px solid var(--border-strong);
|
||||
}
|
||||
.account-panel.is-success { border-left-color: var(--success); }
|
||||
.account-panel.is-warning { border-left-color: var(--warning); }
|
||||
.account-panel.is-danger { border-left-color: var(--danger); }
|
||||
|
||||
.account-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: -2px 0 16px;
|
||||
}
|
||||
.account-progress__bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-tint);
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-progress__bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--fire-grad);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.account-progress__label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.account-progress__label b { color: var(--text); }
|
||||
|
||||
.account-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.account-panel__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--fire-grad);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 16px rgba(255, 77, 109, 0.28);
|
||||
}
|
||||
|
||||
.account-panel__name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.account-panel__subline {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-panel__subline code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
background: var(--surface-tint);
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.account-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-panel__collapse {
|
||||
color: var(--text-faint);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.account-alert,
|
||||
.friend-scan-meta {
|
||||
margin: -4px 0 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text-main);
|
||||
background: var(--surface-tint);
|
||||
}
|
||||
|
||||
.account-alert {
|
||||
border-color: rgba(255, 185, 70, 0.42);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.account-alert strong,
|
||||
.friend-scan-meta strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.account-alert__line,
|
||||
.friend-scan-meta__line {
|
||||
margin-top: 4px;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 12px;
|
||||
}
|
||||
|
||||
.data-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__title i {
|
||||
font-style: normal;
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.data-card__title.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.data-card__title.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.data-card__title.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.data-card__title.info {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.data-card__table {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.data-card__table td:first-child,
|
||||
.data-card__table th:first-child {
|
||||
min-width: 176px;
|
||||
}
|
||||
|
||||
.data-card__table td:last-child,
|
||||
.data-card__table th:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-card__footer {
|
||||
padding: 10px 18px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__footer-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__footer-button:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.data-card__footer-button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-card__extra {
|
||||
display: none;
|
||||
padding: 0 18px 16px;
|
||||
}
|
||||
|
||||
.data-card__extra.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-card__extra .table-shell {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.console-empty {
|
||||
padding: 46px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
border-radius: 18px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--empty-bg);
|
||||
}
|
||||
|
||||
.console-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
padding: 6px 0 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.console-pagination {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-page {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.console-page.active {
|
||||
background: linear-gradient(180deg, #3d79ff, #2f67ff);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 10px 18px rgba(47, 103, 255, 0.18);
|
||||
}
|
||||
|
||||
.console-page.ghost {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.account-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="console-shell">
|
||||
<section class="console-overview">
|
||||
<div class="overview-time muted">
|
||||
当前时间:{{ send_console.now|replace('T', ' ') }}
|
||||
</div>
|
||||
<div class="overview-time">当前时间:{{ send_console.now|replace('T', ' ') }}</div>
|
||||
|
||||
<div class="stats-grid console-stats">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.enabled_accounts }}</strong
|
||||
>
|
||||
<span class="stat-help">当前可发送账号</span>
|
||||
<span class="stat-label">总目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.total_targets }}</strong>
|
||||
<span class="stat-help">启用账号 {{ send_console.summary.enabled_accounts }} 个 · 今日发送账本总量</span>
|
||||
</div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
<div class="stat-icon blue"><span class="console-kpi-icon">👥</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_sent_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">写回发送历史</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_console.summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
|
||||
>
|
||||
✓
|
||||
<span class="stat-label">今日成功目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
|
||||
<span class="stat-help positive">已完成 {{ send_console.summary.today_sent_targets }}/{{ send_console.summary.total_targets }}</span>
|
||||
</div>
|
||||
<div class="stat-icon green"><span class="console-kpi-icon">🎯</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_failed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">按失败分类重试</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_console.summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
|
||||
>
|
||||
!
|
||||
<strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
|
||||
<span class="stat-help negative">按失败分类进入补发与重试</span>
|
||||
</div>
|
||||
<div class="stat-icon red"><span class="console-kpi-icon">⚠</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_pending_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">窗口内按计划发送</span>
|
||||
<span class="stat-label">未完成目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_remaining_targets }}</strong>
|
||||
<span class="stat-help">正常待发 {{ send_console.summary.today_pending_targets + send_console.summary.today_unprocessed_targets }} 个 · 账号异常影响 {{ send_console.summary.today_account_blocked_targets or 0 }} 个</span>
|
||||
</div>
|
||||
<div class="stat-icon orange">◔</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">未处理</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_unprocessed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">等待调度</span>
|
||||
</div>
|
||||
<div class="stat-icon gray">…</div>
|
||||
<div class="stat-icon orange"><span class="console-kpi-icon">◔</span></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -98,40 +426,81 @@
|
||||
{% if send_console.accounts %}
|
||||
<section class="console-account-list">
|
||||
{% for account in send_console.accounts %}
|
||||
<article class="panel account-panel">
|
||||
{% set at = account.total_targets or 1 %}
|
||||
{% set ad = account.sent_targets|length %}
|
||||
{% set ap = (ad / at * 100)|round(0)|int %}
|
||||
{% set panel_state = 'is-danger' if (account.account_paused or account.account_failure) else ('is-warning' if account.failed_targets|length else ('is-success' if ad == account.total_targets else 'is-warning')) %}
|
||||
<article class="panel account-panel {{ panel_state }}">
|
||||
<div class="account-panel__header">
|
||||
<div class="account-panel__meta">
|
||||
<div class="account-avatar">{{ (account.username or 'A')[:1] }}</div>
|
||||
<div>
|
||||
<div class="account-panel__name">{{ account.username }}</div>
|
||||
<div class="account-panel__subline">
|
||||
unique_id: {{ account.unique_id }}
|
||||
<span>unique_id:</span>
|
||||
<code>{{ account.unique_id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<span class="status-chip success"
|
||||
>成功 {{ account.sent_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip danger"
|
||||
>失败 {{ account.failed_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip warning"
|
||||
>待发送 {{ account.pending_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip info"
|
||||
>未处理 {{ account.unprocessed_targets|length }}</span
|
||||
>
|
||||
<div class="account-panel__status">
|
||||
<span class="status-chip success">成功 {{ account.sent_targets|length }}</span>
|
||||
<span class="status-chip info">完成 {{ account.sent_targets|length }}/{{ account.total_targets }}</span>
|
||||
<span class="status-chip danger">失败 {{ account.failed_targets|length }}</span>
|
||||
<span class="status-chip warning">待发送 {{ account.pending_targets|length }}</span>
|
||||
<span class="status-chip info">未处理 {{ account.unprocessed_targets|length }}</span>
|
||||
{% if account.account_blocked_targets %}
|
||||
<span class="status-chip warning">账号影响 {{ account.account_blocked_targets|length }}</span>
|
||||
{% endif %}
|
||||
{% if account.account_paused %}
|
||||
<span class="status-chip danger">账号暂停</span>
|
||||
{% elif account.account_failure %}
|
||||
<span class="status-chip warning">账号异常</span>
|
||||
{% endif %}
|
||||
<span class="account-panel__collapse">⌃</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if account.account_failure %}
|
||||
<div class="account-alert">
|
||||
<strong>账号级页面异常:{{ account.account_failure.category or "-" }}</strong>
|
||||
<div class="account-alert__line">
|
||||
今日尝试 {{ account.account_failure.attemptCount or 0 }}/{{ account.account_failure_pause_after }}
|
||||
{% if account.account_paused %},已暂停今日自动发送{% endif %}
|
||||
· lastAttemptAt {{ account.account_failure.lastAttemptAt or "-" }}
|
||||
</div>
|
||||
<div class="account-alert__line">原因:{{ account.account_failure.reason or "-" }}</div>
|
||||
{% if account.account_failure.affectedTargets %}
|
||||
<div class="account-alert__line">影响目标:{{ account.account_failure.affectedTargets|join(", ") }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if account.friend_index_meta.lastScanAt %}
|
||||
<div class="friend-scan-meta">
|
||||
<strong>
|
||||
好友索引:{{ "完整" if account.friend_index_meta.lastScanComplete else "未完整" }}
|
||||
· 已记录 {{ account.friend_index_count }} 个
|
||||
</strong>
|
||||
<div class="friend-scan-meta__line">
|
||||
lastScanAt {{ account.friend_index_meta.lastScanAt }}
|
||||
· scannedCount {{ account.friend_index_meta.scannedCount }}
|
||||
</div>
|
||||
{% if account.friend_index_meta.missingTargets %}
|
||||
<div class="friend-scan-meta__line">索引扫描后仍缺失:{{ account.friend_index_meta.missingTargets|join(", ") }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="account-progress">
|
||||
<span class="account-progress__label">完成进度 <b>{{ ad }}/{{ account.total_targets }}</b> · {{ ap }}%</span>
|
||||
<span class="account-progress__bar"><i style="width:{{ ap }}%"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="account-grid">
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title success">
|
||||
<i>●</i><span>今日已成功</span>
|
||||
</div>
|
||||
<div class="data-card__title success"><i>●</i><span>A. 今日已成功</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -150,27 +519,22 @@
|
||||
<td>{{ item.sentAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">今日暂无成功目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="3">今日暂无成功目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.sent_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="sent-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.sent_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.sent_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.sent_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.sent_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="sent-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -195,11 +559,9 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title danger">
|
||||
<i>▲</i><span>失败待补发</span>
|
||||
</div>
|
||||
<div class="data-card__title danger"><i>▲</i><span>B. 失败待补发</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<div class="table-shell data-card__table scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -218,46 +580,30 @@
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/retry-target"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token }}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="target"
|
||||
value="{{ item.target }}"
|
||||
/>
|
||||
<button type="submit" class="soft-button">重试</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="soft-button retry-button">重试此目标</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">暂无失败待补发目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="5">暂无失败待补发目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.failed_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="failed-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.failed_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.failed_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.failed_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.failed_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="failed-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -276,21 +622,10 @@
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/retry-target"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token }}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="target"
|
||||
value="{{ item.target }}"
|
||||
/>
|
||||
<button type="submit" class="soft-button">重试</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="soft-button retry-button">重试此目标</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -303,9 +638,7 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title warning">
|
||||
<i>◔</i><span>今日待发送</span>
|
||||
</div>
|
||||
<div class="data-card__title warning"><i>◔</i><span>C. 今日待发送</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -322,27 +655,22 @@
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">今日暂无待发送目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="2">今日暂无待发送目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.pending_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="pending-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.pending_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.pending_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.pending_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.pending_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="pending-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -365,9 +693,7 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title info">
|
||||
<i>◌</i><span>今日未处理</span>
|
||||
</div>
|
||||
<div class="data-card__title info"><i>◌</i><span>D. 今日未处理</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -384,27 +710,22 @@
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">今日暂无未处理目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="2">今日暂无未处理目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.unprocessed_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="unprocessed-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.unprocessed_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.unprocessed_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.unprocessed_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.unprocessed_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="unprocessed-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -424,16 +745,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if account.account_blocked_targets %}
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title warning"><i>!</i><span>E. 账号异常影响</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>scheduledAt</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.account_blocked_targets[:5] %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
<td>{{ "本次账号异常重试目标" if item.accountFailureAffected else "因账号暂停尚未处理" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="blocked-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.account_blocked_targets|length }})"
|
||||
{% if account.account_blocked_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.account_blocked_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="blocked-{{ loop.index0 }}">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>scheduledAt</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.account_blocked_targets %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
<td>{{ "本次账号异常重试目标" if item.accountFailureAffected else "因账号暂停尚未处理" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="console-footer">共 {{ send_console.accounts|length }} 个账号</div>
|
||||
{% else %}
|
||||
<div class="console-empty empty-state">
|
||||
当前没有启用账号,暂无可展示的发送明细。
|
||||
<div class="console-footer">
|
||||
<span>共 {{ send_console.accounts|length }} 个启用账号 · 数据按今日发送状态实时汇总</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="console-empty">当前没有启用账号,暂无可展示的发送控制台数据。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.expandTarget;
|
||||
if (!targetId) return;
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle("is-open");
|
||||
button.textContent = isOpen ? "收起" : (button.getAttribute("data-total-label") || "查看全部");
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user