1426 lines
54 KiB
HTML
1426 lines
54 KiB
HTML
{# app_url:Jinja 宏,不依赖 Python 往 context 注入;前缀来自中间件 request.state.web_url_prefix(WEB_URL_PREFIX)或 ASGI root_path #}
|
||
{% macro app_url(path) -%}
|
||
{%- set _p = path if path.startswith('/') else '/' ~ path -%}
|
||
{%- set pre = (request.state.web_url_prefix | default('', true)) | trim -%}
|
||
{%- if not pre -%}
|
||
{%- set pre = request.scope.get('root_path', '') | trim -%}
|
||
{%- endif -%}
|
||
{{- pre ~ _p if pre else _p -}}
|
||
{%- endmacro %}
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Telegram Scraper 控制台</title>
|
||
<link rel="icon" type="image/png" href="{{ app_url('/favicon.ico') }}" sizes="any" />
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<style>
|
||
:root {
|
||
--bg: #0f172a;
|
||
--bg-elevated: #1e293b;
|
||
--bg-muted: #334155;
|
||
--border: #334155;
|
||
--text: #f8fafc;
|
||
--text-muted: #94a3b8;
|
||
--accent: #22c55e;
|
||
--accent-hover: #16a34a;
|
||
--danger: #ef4444;
|
||
--danger-hover: #dc2626;
|
||
--code-bg: #020617;
|
||
--warn: #fbbf24;
|
||
--radius: 12px;
|
||
--radius-sm: 8px;
|
||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||
--font: "Inter", system-ui, -apple-system, sans-serif;
|
||
--transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: var(--font);
|
||
font-size: 15px;
|
||
line-height: 1.55;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
background-image:
|
||
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(34, 197, 94, 0.08), transparent 50%),
|
||
radial-gradient(ellipse 80% 50% at 100% 50%, rgba(51, 65, 85, 0.4), transparent);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
}
|
||
|
||
.shell {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: clamp(16px, 4vw, 28px);
|
||
}
|
||
|
||
.banner-warn {
|
||
margin: 0 0 12px;
|
||
padding: 12px 16px;
|
||
border-radius: var(--radius-sm);
|
||
background: rgba(251, 191, 36, 0.12);
|
||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||
color: var(--warn);
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.dash-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 22px;
|
||
}
|
||
|
||
.dash-top h1 {
|
||
margin: 0 0 10px;
|
||
font-size: clamp(1.35rem, 3.5vw, 1.65rem);
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.dash-sub {
|
||
margin: 0 0 8px;
|
||
font-size: 0.875rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.status-inline {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.status-pill--ok {
|
||
background: rgba(34, 197, 94, 0.15);
|
||
color: var(--accent);
|
||
border: 1px solid rgba(34, 197, 94, 0.35);
|
||
}
|
||
|
||
.status-pill--down {
|
||
background: rgba(239, 68, 68, 0.12);
|
||
color: #fca5a5;
|
||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||
}
|
||
|
||
.status-meta {
|
||
font-size: 0.8125rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.dash-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
border-radius: var(--radius-sm);
|
||
padding: 8px 14px;
|
||
font-family: inherit;
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.btn-ghost:hover {
|
||
border-color: rgba(34, 197, 94, 0.45);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.btn-ghost:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.stats-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: flex-end;
|
||
margin-bottom: 18px;
|
||
padding: 14px 16px;
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.stats-toolbar label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.stats-toolbar input,
|
||
.stats-toolbar select {
|
||
min-width: 160px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.stats-toolbar .btn-ghost {
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
.kpi-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.kpi {
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 16px 18px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.kpi .label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.kpi .value {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.kpi .sub {
|
||
margin-top: 4px;
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.charts-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (min-width: 900px) {
|
||
.charts-grid {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
.charts-grid .chart-wide {
|
||
grid-column: 1 / -1;
|
||
}
|
||
}
|
||
|
||
.chart-card {
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 14px 16px 8px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.chart-card h3 {
|
||
margin: 0 0 4px;
|
||
font-size: 0.9375rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.chart-card .chart-desc {
|
||
margin: 0 0 8px;
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.chart-box {
|
||
width: 100%;
|
||
height: 280px;
|
||
}
|
||
|
||
.chart-box.tall {
|
||
height: 340px;
|
||
}
|
||
|
||
dialog.modal {
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
padding: 0;
|
||
max-width: min(960px, 96vw);
|
||
width: min(960px, 96vw);
|
||
max-height: 92vh;
|
||
background: var(--bg-elevated);
|
||
color: var(--text);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
dialog.modal::backdrop {
|
||
background: rgba(2, 6, 23, 0.72);
|
||
}
|
||
|
||
.modal-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.modal-head h2 {
|
||
margin: 0;
|
||
font-size: 1.05rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.modal-close {
|
||
width: auto;
|
||
margin: 0;
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
font-family: inherit;
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: var(--text);
|
||
border-color: var(--text-muted);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 18px 20px 22px;
|
||
overflow: auto;
|
||
max-height: calc(92vh - 64px);
|
||
}
|
||
|
||
.alert-warn {
|
||
margin: 0 0 16px;
|
||
padding: 12px 14px;
|
||
border-radius: var(--radius-sm);
|
||
background: rgba(251, 191, 36, 0.1);
|
||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||
color: var(--warn);
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.btn-row form { margin: 0; }
|
||
|
||
.btn-row > form > button {
|
||
width: 100%;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.modal-body .form-inline {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: stretch;
|
||
gap: 10px;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.modal-body .form-inline input[type="text"] {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.modal-body .form-inline button {
|
||
width: auto;
|
||
flex-shrink: 0;
|
||
min-width: 104px;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.account-pick-form > div:last-child .btn-primary {
|
||
width: 100%;
|
||
}
|
||
|
||
.config-actions .btn-primary {
|
||
width: auto;
|
||
min-width: 140px;
|
||
}
|
||
|
||
button:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
button.btn-primary {
|
||
background: var(--accent);
|
||
color: #052e16;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px 16px;
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
button.btn-primary:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
button.btn-danger {
|
||
background: var(--danger);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px 16px;
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
button.btn-danger:hover {
|
||
background: var(--danger-hover);
|
||
}
|
||
|
||
.ops-grid {
|
||
display: grid;
|
||
gap: 20px;
|
||
}
|
||
|
||
@media (min-width: 700px) {
|
||
.ops-grid { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
|
||
.ops-block {
|
||
padding: 16px;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid rgba(51, 65, 85, 0.6);
|
||
}
|
||
|
||
.ops-block h4 {
|
||
margin: 0 0 12px;
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.hint {
|
||
margin: 0 0 8px;
|
||
color: var(--text-muted);
|
||
font-size: 0.8125rem;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.hint code {
|
||
font-size: 0.85em;
|
||
padding: 0.1em 0.35em;
|
||
border-radius: 4px;
|
||
background: rgba(15, 23, 42, 0.95);
|
||
border: 1px solid rgba(51, 65, 85, 0.85);
|
||
color: #a5f3fc;
|
||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||
}
|
||
|
||
.form-inline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.form-inline:last-child { margin-bottom: 0; }
|
||
|
||
@media (min-width: 480px) {
|
||
.form-inline {
|
||
flex-direction: row;
|
||
align-items: stretch;
|
||
}
|
||
.form-inline input { flex: 1; min-width: 0; }
|
||
.form-inline button { width: auto; flex-shrink: 0; }
|
||
}
|
||
|
||
input[type="text"],
|
||
input[type="password"] {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
input:focus-visible {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2);
|
||
}
|
||
|
||
.config-form .field-row {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
@media (min-width: 640px) {
|
||
.config-form .field-row {
|
||
grid-template-columns: minmax(180px, 260px) 1fr;
|
||
align-items: center;
|
||
}
|
||
.config-form .field-row.field-row-toggle {
|
||
grid-template-columns: 1fr auto;
|
||
align-items: center;
|
||
}
|
||
}
|
||
|
||
.config-form label {
|
||
font-weight: 500;
|
||
font-size: 0.875rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.field-row-toggle .toggle-label {
|
||
font-weight: 500;
|
||
font-size: 0.875rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.ios-switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 51px;
|
||
height: 31px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ios-switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
position: absolute;
|
||
}
|
||
|
||
.ios-switch-track {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
inset: 0;
|
||
background: #334155;
|
||
border-radius: 999px;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.ios-switch-track::before {
|
||
content: "";
|
||
position: absolute;
|
||
height: 27px;
|
||
width: 27px;
|
||
left: 2px;
|
||
bottom: 2px;
|
||
background: #f8fafc;
|
||
border-radius: 50%;
|
||
transition: transform 0.2s ease;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.ios-switch input:checked + .ios-switch-track {
|
||
background: #22c55e;
|
||
}
|
||
|
||
.ios-switch input:checked + .ios-switch-track::before {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
.ios-switch input:focus-visible + .ios-switch-track {
|
||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35);
|
||
}
|
||
|
||
.ios-switch input:disabled + .ios-switch-track {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.config-actions { margin-top: 8px; }
|
||
|
||
.config-actions button {
|
||
width: auto;
|
||
min-width: 140px;
|
||
}
|
||
|
||
.list {
|
||
max-height: min(280px, 40vh);
|
||
overflow: auto;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px;
|
||
background: var(--bg);
|
||
scrollbar-color: var(--bg-muted) var(--bg);
|
||
}
|
||
|
||
.list .line {
|
||
margin: 0;
|
||
padding: 8px 0;
|
||
font-size: 0.8125rem;
|
||
color: var(--text);
|
||
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.list .line:last-child {
|
||
border-bottom: none;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.list .line:first-child { padding-top: 0; }
|
||
|
||
.account-pick-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.text-btn {
|
||
background: transparent;
|
||
border: 1px solid var(--border);
|
||
color: var(--text-muted);
|
||
padding: 6px 12px;
|
||
width: auto;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border-radius: var(--radius-sm);
|
||
font-family: inherit;
|
||
font-size: 0.8125rem;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.text-btn:hover {
|
||
color: var(--text);
|
||
border-color: rgba(34, 197, 94, 0.45);
|
||
}
|
||
|
||
label.account-ch-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
margin: 0;
|
||
font-weight: 400;
|
||
}
|
||
|
||
label.account-ch-row input[type="checkbox"] {
|
||
margin-top: 3px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
width: 1rem;
|
||
height: 1rem;
|
||
accent-color: var(--accent);
|
||
}
|
||
|
||
label.account-ch-row .row-body { flex: 1; min-width: 0; }
|
||
|
||
label.account-ch-row.is-monitored {
|
||
opacity: 0.65;
|
||
cursor: default;
|
||
}
|
||
|
||
.pill-muted {
|
||
display: inline-block;
|
||
margin-left: 6px;
|
||
padding: 1px 8px;
|
||
border-radius: 999px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
background: rgba(51, 65, 85, 0.6);
|
||
color: var(--text-muted);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
pre#logs-box {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
max-height: min(52vh, 520px);
|
||
overflow: auto;
|
||
background: var(--code-bg);
|
||
color: #bae6fd;
|
||
padding: 16px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid #1e3a5f;
|
||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||
font-size: 0.75rem;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.stat-error {
|
||
padding: 12px;
|
||
border-radius: var(--radius-sm);
|
||
background: rgba(239, 68, 68, 0.1);
|
||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||
color: #fca5a5;
|
||
font-size: 0.875rem;
|
||
margin-bottom: 12px;
|
||
display: none;
|
||
}
|
||
|
||
.stat-error.is-visible { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{% if need_auth_banner %}
|
||
<div class="shell"><p class="banner-warn" role="alert">该操作需要控制台密码:请点击右上角任意按钮,在弹出框中输入密码后再试。</p></div>
|
||
{% endif %}
|
||
{% if error %}
|
||
<div class="shell"><p class="banner-warn" role="alert">列表加载提示:{{ error }}</p></div>
|
||
{% endif %}
|
||
|
||
<div class="shell">
|
||
<header class="dash-top">
|
||
<div>
|
||
<h1>Telegram Scraper 控制台</h1>
|
||
<p class="dash-sub">数据概览 · 本地 SQLite 聚合(与是否在线无关)</p>
|
||
<div class="status-inline" aria-live="polite">
|
||
{% if connected %}
|
||
<span id="connect-status" class="status-pill status-pill--ok">已连接</span>
|
||
{% else %}
|
||
<span id="connect-status" class="status-pill status-pill--down">未连接</span>
|
||
{% endif %}
|
||
{% if job_running %}
|
||
<span id="job-running" class="status-pill status-pill--ok">任务运行中</span>
|
||
{% else %}
|
||
<span id="job-running" class="status-pill status-pill--down">任务空闲</span>
|
||
{% endif %}
|
||
{% if continuous_running %}
|
||
<span id="continuous-running" class="status-pill status-pill--ok">持续抓取中</span>
|
||
{% else %}
|
||
<span id="continuous-running" class="status-pill status-pill--down">持续抓取关</span>
|
||
{% endif %}
|
||
</div>
|
||
<p class="status-meta">
|
||
单次任务:<span id="job-name">{% if job_running %}{{ job_name }}{% else %}无{% endif %}</span>
|
||
· 已运行 <span id="job-uptime">{% if job_running %}{{ job_uptime }}{% else %}-{% endif %}</span>
|
||
· 持续心跳 <span id="continuous-uptime">{{ continuous_uptime if continuous_running else '-' }}</span>
|
||
</p>
|
||
</div>
|
||
<div class="dash-actions">
|
||
<button type="button" class="btn-ghost" data-open="conn">连接 / 持续抓取</button>
|
||
<button type="button" class="btn-ghost" data-open="ops">常规操作</button>
|
||
<button type="button" class="btn-ghost" data-open="channels">频道管理</button>
|
||
<button type="button" class="btn-ghost" data-open="config">环境配置</button>
|
||
<button type="button" class="btn-ghost" data-open="logs">运行日志</button>
|
||
{% if console_authed %}
|
||
<form method="post" action="{{ app_url('/auth/console/logout') }}" style="margin:0;">
|
||
<button type="submit" class="btn-ghost">退出验证</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
</header>
|
||
|
||
<div id="stat-error" class="stat-error"></div>
|
||
|
||
<section class="stats-toolbar">
|
||
<label>统计区间
|
||
<select id="stats-days" aria-label="统计天数">
|
||
<option value="7">最近 7 天</option>
|
||
<option value="30" selected>最近 30 天</option>
|
||
<option value="90">最近 90 天</option>
|
||
</select>
|
||
</label>
|
||
<label>关键词(逗号分隔,估算「招聘类」消息,匹配正文)
|
||
<input type="text" id="stats-keywords" placeholder="留空使用内置:招聘、岗位、hiring…" autocomplete="off" />
|
||
</label>
|
||
<button type="button" class="btn-ghost" id="stats-refresh">刷新统计</button>
|
||
</section>
|
||
|
||
<section class="kpi-grid">
|
||
<div class="kpi">
|
||
<div class="label">区间内消息</div>
|
||
<div class="value" id="kpi-range-msg">—</div>
|
||
<div class="sub" id="kpi-range-meta">按消息日期</div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="label">招聘类估算</div>
|
||
<div class="value" id="kpi-job-like">—</div>
|
||
<div class="sub" id="kpi-kw-meta">关键词 OR 匹配</div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="label">累计消息(全库)</div>
|
||
<div class="value" id="kpi-total-all">—</div>
|
||
<div class="sub" id="kpi-db-meta">所有频道 .db</div>
|
||
</div>
|
||
<div class="kpi">
|
||
<div class="label">监控 / 数据频道</div>
|
||
<div class="value" id="kpi-channels">—</div>
|
||
<div class="sub">state 监控数 · 有库文件夹数</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="charts-grid">
|
||
<div class="chart-card">
|
||
<h3>每日新增消息</h3>
|
||
<p class="chart-desc">区间内按天汇总(所有已抓取入库的频道)</p>
|
||
<div id="chart-messages" class="chart-box"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<h3>每日「招聘类」消息(估算)</h3>
|
||
<p class="chart-desc">正文包含任一关键词即计入(非 NLP 分类)</p>
|
||
<div id="chart-hiring" class="chart-box"></div>
|
||
</div>
|
||
<div class="chart-card chart-wide">
|
||
<h3>各频道区间内消息量</h3>
|
||
<p class="chart-desc">横轴为消息条数,纵轴为频道显示名(来自 state 中的标题 / @用户名)</p>
|
||
<div id="chart-channels" class="chart-box tall"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<dialog class="modal" id="dlg-console-auth" aria-labelledby="dlg-console-auth-title">
|
||
<div class="modal-head">
|
||
<h2 id="dlg-console-auth-title">控制台密码验证</h2>
|
||
<button type="button" class="modal-close" data-close>关闭</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="hint">须验证通过后才能打开「连接 / 常规操作 / 频道管理 / 环境配置 / 运行日志」等面板。请在服务器环境变量中设置 <code>WEB_CONSOLE_PASSWORD</code>(未设置时使用程序内置默认值)。务必设置随机长字符串 <code>WEB_CONSOLE_SESSION_SECRET</code> 用于会话签名,勿泄露。</p>
|
||
<form id="form-console-auth" class="config-form">
|
||
<div class="field-row">
|
||
<label for="console-auth-password">密码</label>
|
||
<input type="password" id="console-auth-password" name="password" autocomplete="current-password" />
|
||
</div>
|
||
<div class="config-actions">
|
||
<button type="submit" class="btn-primary">验证并继续</button>
|
||
</div>
|
||
</form>
|
||
<p id="console-auth-err" class="banner-warn" style="display:none;margin-top:12px;" role="alert"></p>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog class="modal" id="dlg-conn" aria-labelledby="dlg-conn-title">
|
||
<div class="modal-head">
|
||
<h2 id="dlg-conn-title">连接与持续抓取</h2>
|
||
<button type="button" class="modal-close" data-close>关闭</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
{% if error %}
|
||
<p class="alert-warn" role="alert">客户端:{{ error }}</p>
|
||
{% endif %}
|
||
<div class="btn-row">
|
||
<form method="post" action="{{ app_url('/start') }}">
|
||
<button class="btn-primary" type="submit">初始化 / 连接 Telegram</button>
|
||
</form>
|
||
<form method="post" action="{{ app_url('/stop') }}">
|
||
<button class="btn-danger" type="submit">断开 Telegram</button>
|
||
</form>
|
||
<form method="post" action="{{ app_url('/jobs/continuous/start') }}">
|
||
<button class="btn-primary" type="submit">启动持续抓取(含心跳)</button>
|
||
</form>
|
||
<form method="post" action="{{ app_url('/jobs/continuous/stop') }}">
|
||
<button class="btn-danger" type="submit">停止持续抓取</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog class="modal" id="dlg-ops" aria-labelledby="dlg-ops-title">
|
||
<div class="modal-head">
|
||
<h2 id="dlg-ops-title">常规操作</h2>
|
||
<button type="button" class="modal-close" data-close>关闭</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="hint" style="margin-bottom:16px;">
|
||
「频道管理」弹窗中可查看<strong>已监控</strong>列表与<strong>勾选添加</strong>。
|
||
移除/任务范围里的 <code>1,2</code> 与已监控列表从上到下序号一致。
|
||
</p>
|
||
<div class="ops-grid">
|
||
<div class="ops-block">
|
||
<h4>频道(加入 / 移出监控)</h4>
|
||
<p class="hint">
|
||
<strong>添加</strong>:<code>@用户名</code>、<code>-100…</code>、<code>https://t.me/xxx</code>。
|
||
不支持仅靠群内显示标题。
|
||
</p>
|
||
<form method="post" action="{{ app_url('/channels/add') }}" class="form-inline">
|
||
<input type="text" name="channel_spec" placeholder="@channel 或 -100… 或 https://t.me/…" aria-label="要添加的频道" />
|
||
<button class="btn-primary" type="submit">添加</button>
|
||
</form>
|
||
<p class="hint">
|
||
<strong>移除</strong>:<code>all</code>;<code>1,2</code>;<code>-100…</code>;<code>@用户名</code> / t.me(须已在监控中)。
|
||
</p>
|
||
<form method="post" action="{{ app_url('/channels/remove') }}" class="form-inline">
|
||
<input type="text" name="channel_spec" placeholder="all 或 1,2 或 -100… 或 @name" aria-label="要移除的频道" />
|
||
<button class="btn-danger" type="submit">移除</button>
|
||
</form>
|
||
</div>
|
||
<div class="ops-block">
|
||
<h4>任务(抓取 / 导出 / 补媒体)</h4>
|
||
<p class="hint">范围:<code>all</code>、<code>1,3</code>、<code>-100…</code> 或已在监控的 <code>@</code> / 链接。</p>
|
||
<form method="post" action="{{ app_url('/jobs/scrape') }}" class="form-inline">
|
||
<input type="text" name="selection" value="all" aria-label="抓取范围" />
|
||
<button class="btn-primary" type="submit">开始抓取</button>
|
||
</form>
|
||
<form method="post" action="{{ app_url('/jobs/export') }}" class="form-inline">
|
||
<input type="text" name="selection" value="all" aria-label="导出范围" />
|
||
<button class="btn-primary" type="submit">导出 CSV+JSON</button>
|
||
</form>
|
||
<form method="post" action="{{ app_url('/jobs/rescrape') }}" class="form-inline">
|
||
<input type="text" name="selection" value="all" aria-label="补抓范围" />
|
||
<button class="btn-primary" type="submit">补抓媒体</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog class="modal" id="dlg-channels" aria-labelledby="dlg-ch-title">
|
||
<div class="modal-head">
|
||
<h2 id="dlg-ch-title">频道管理</h2>
|
||
<button type="button" class="modal-close" data-close>关闭</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="ops-grid">
|
||
<div>
|
||
<p class="hint">已监控([序号] 对应移除/任务范围里的编号;仅展示名称)</p>
|
||
<div class="list" id="monitored-list" role="list">
|
||
{% if monitored %}
|
||
{% for ch in monitored %}
|
||
<div class="line" role="listitem">[{{ loop.index }}] {{ ch.display_name | default('未命名频道') }} · 进度消息 ID {{ ch.last_message_id }}</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div class="line" role="listitem">暂无</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p class="hint">账号可见频道:勾选后加入监控。标题包含配置项「账号列表隐藏」中任一子串的会话不会出现在此列表(默认隐藏含「远程-到岗-技术招聘」的群,避免选到自有招聘群)。</p>
|
||
{% if account_channels %}
|
||
<form method="post" action="{{ app_url('/channels/add-selected') }}" class="account-pick-form">
|
||
<div class="account-pick-toolbar">
|
||
<button type="button" class="text-btn" id="account-select-all">全选可选项</button>
|
||
<button type="button" class="text-btn" id="account-select-none">全不选</button>
|
||
</div>
|
||
<div class="list" role="list">
|
||
{% for ch in account_channels %}
|
||
{% set cid = ch.channel_id|string %}
|
||
{% if cid in monitored_ids %}
|
||
<div class="line account-ch-row is-monitored" role="listitem">
|
||
<input type="checkbox" checked disabled title="已在监控列表" aria-disabled="true" />
|
||
<span class="row-body">[{{ ch.number }}] {{ ch.channel_name }}{% if ch.username and ch.username != 'no_username' %} · @{{ ch.username }}{% endif %}<span class="pill-muted">已监控</span></span>
|
||
</div>
|
||
{% else %}
|
||
<label class="line account-ch-row" role="listitem">
|
||
<input type="checkbox" name="channel_id" value="{{ ch.channel_id }}" />
|
||
<span class="row-body">[{{ ch.number }}] {{ ch.channel_name }}{% if ch.username and ch.username != 'no_username' %} · @{{ ch.username }}{% endif %}</span>
|
||
</label>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
<div style="margin-top:12px;">
|
||
<button class="btn-primary" type="submit">将选中项加入监控</button>
|
||
</div>
|
||
</form>
|
||
{% else %}
|
||
<div class="list" role="list">
|
||
<div class="line" role="listitem">{% if not console_authed %}请先通过右上角验证(输入密码)后刷新本页以加载账号频道列表。{% else %}请先连接 Telegram 后刷新本页加载列表{% endif %}</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog class="modal" id="dlg-config" aria-labelledby="dlg-cfg-title">
|
||
<div class="modal-head">
|
||
<h2 id="dlg-cfg-title">环境配置(写入 .env)</h2>
|
||
<button type="button" class="modal-close" data-close>关闭</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="hint">保存后建议断开并重新连接 Telegram。</p>
|
||
<form method="post" action="{{ app_url('/config') }}" class="config-form">
|
||
{% for f in fields %}
|
||
{% if f.key in binary_env_keys %}
|
||
<div class="field-row field-row-toggle">
|
||
<span class="toggle-label" id="lbl-{{ f.key }}">{{ f.label }}</span>
|
||
<label class="ios-switch" for="dlg-{{ f.key }}">
|
||
<input
|
||
id="dlg-{{ f.key }}"
|
||
name="{{ f.key }}"
|
||
type="checkbox"
|
||
value="1"
|
||
{% if f.value|string|trim|lower in ['1', 'true', 'yes', 'on'] %}checked{% endif %}
|
||
aria-labelledby="lbl-{{ f.key }}"
|
||
/>
|
||
<span class="ios-switch-track" aria-hidden="true"></span>
|
||
</label>
|
||
</div>
|
||
{% else %}
|
||
<div class="field-row">
|
||
<label for="dlg-{{ f.key }}">{{ f.label }}</label>
|
||
<input
|
||
id="dlg-{{ f.key }}"
|
||
name="{{ f.key }}"
|
||
type="{{ f.type }}"
|
||
value="{{ f.value }}"
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
{% endif %}
|
||
{% endfor %}
|
||
<div class="config-actions">
|
||
<button class="btn-primary" type="submit">保存配置</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog class="modal" id="dlg-logs" aria-labelledby="dlg-logs-title">
|
||
<div class="modal-head">
|
||
<h2 id="dlg-logs-title">运行日志(最近 1200 行)</h2>
|
||
<button type="button" class="modal-close" data-close>关闭</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<pre id="logs-box" tabindex="0" aria-label="运行日志">{% if logs %}{{ logs }}{% else %}(验证通过前不展示日志内容;验证后刷新或等待自动刷新。){% endif %}</pre>
|
||
</div>
|
||
</dialog>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
||
<script>
|
||
window.__URL_PREFIX__ = {{ (request.state.web_url_prefix | default('', true)) | tojson }};
|
||
function appPath(p) {
|
||
p = p.startsWith("/") ? p : ("/" + p);
|
||
var pre = window.__URL_PREFIX__ || "";
|
||
return pre ? (pre + p) : p;
|
||
}
|
||
function appHomeWithQuery(tail) {
|
||
var pre = window.__URL_PREFIX__ || "";
|
||
var base = pre ? (pre + "/") : "/";
|
||
base = base.replace(/\/*$/, "/");
|
||
if (!tail) return base;
|
||
if (tail.charAt(0) !== "?") tail = "?" + tail;
|
||
return base + tail;
|
||
}
|
||
|
||
function formatInt(n) {
|
||
if (n == null || isNaN(n)) return "—";
|
||
return Number(n).toLocaleString("zh-CN");
|
||
}
|
||
|
||
function openDialogById(id) {
|
||
var el = document.getElementById(id);
|
||
if (el && typeof el.showModal === "function") el.showModal();
|
||
}
|
||
|
||
async function consoleAuthStatus() {
|
||
try {
|
||
var r = await fetch(appPath("/auth/console/status"), { credentials: "same-origin" });
|
||
if (!r.ok) return false;
|
||
var j = await r.json();
|
||
return j.ok === true;
|
||
} catch (_e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
var pendingOpenDlgId = null;
|
||
|
||
document.querySelectorAll("[data-open]").forEach(function (btn) {
|
||
btn.addEventListener("click", async function () {
|
||
var key = btn.getAttribute("data-open");
|
||
var dlgId = "dlg-" + key;
|
||
if (!await consoleAuthStatus()) {
|
||
pendingOpenDlgId = dlgId;
|
||
openDialogById("dlg-console-auth");
|
||
return;
|
||
}
|
||
openDialogById(dlgId);
|
||
});
|
||
});
|
||
|
||
(function () {
|
||
var authForm = document.getElementById("form-console-auth");
|
||
var authErr = document.getElementById("console-auth-err");
|
||
var authPwd = document.getElementById("console-auth-password");
|
||
if (!authForm) return;
|
||
authForm.addEventListener("submit", async function (ev) {
|
||
ev.preventDefault();
|
||
if (authErr) {
|
||
authErr.style.display = "none";
|
||
authErr.textContent = "";
|
||
}
|
||
var pw = authPwd ? authPwd.value : "";
|
||
try {
|
||
var res = await fetch(appPath("/auth/console/login"), {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "same-origin",
|
||
body: JSON.stringify({ password: pw }),
|
||
});
|
||
var data = await res.json().catch(function () { return ({}); });
|
||
if (res.ok && data.ok) {
|
||
var authDlg = document.getElementById("dlg-console-auth");
|
||
if (authDlg) authDlg.close();
|
||
var tail = "";
|
||
if (pendingOpenDlgId) {
|
||
tail = "?opendlg=" + encodeURIComponent(pendingOpenDlgId.replace(/^dlg-/, ""));
|
||
pendingOpenDlgId = null;
|
||
}
|
||
if (authPwd) authPwd.value = "";
|
||
window.location.href = appHomeWithQuery(tail);
|
||
} else {
|
||
if (authErr) {
|
||
authErr.textContent = (data && data.error) ? data.error : "验证失败";
|
||
authErr.style.display = "block";
|
||
}
|
||
}
|
||
} catch (_e) {
|
||
if (authErr) {
|
||
authErr.textContent = "网络错误";
|
||
authErr.style.display = "block";
|
||
}
|
||
}
|
||
});
|
||
})();
|
||
|
||
(function () {
|
||
var p = new URLSearchParams(window.location.search);
|
||
var o = p.get("opendlg");
|
||
if (!o) return;
|
||
var el = document.getElementById("dlg-" + o);
|
||
if (el && typeof el.showModal === "function") el.showModal();
|
||
p.delete("opendlg");
|
||
var clean = window.location.pathname + (p.toString() ? "?" + p.toString() : "");
|
||
history.replaceState(null, "", clean);
|
||
})();
|
||
|
||
document.querySelectorAll("[data-close]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
var d = btn.closest("dialog");
|
||
if (d) d.close();
|
||
});
|
||
});
|
||
|
||
var chartMsg = null;
|
||
var chartHire = null;
|
||
var chartCh = null;
|
||
|
||
function lineChartOption(x, y, lineColor, areaColor) {
|
||
return {
|
||
textStyle: { color: "#94a3b8", fontFamily: "Inter, sans-serif" },
|
||
tooltip: { trigger: "axis" },
|
||
grid: { left: 48, right: 20, top: 28, bottom: 28 },
|
||
xAxis: {
|
||
type: "category",
|
||
data: x,
|
||
axisLine: { lineStyle: { color: "#475569" } },
|
||
axisLabel: { color: "#94a3b8", rotate: 45 },
|
||
},
|
||
yAxis: {
|
||
type: "value",
|
||
axisLine: { show: false },
|
||
splitLine: { lineStyle: { color: "rgba(51,65,85,0.5)" } },
|
||
axisLabel: { color: "#94a3b8" },
|
||
},
|
||
series: [{
|
||
type: "line",
|
||
smooth: 0.35,
|
||
areaStyle: { color: areaColor },
|
||
lineStyle: { color: lineColor, width: 2 },
|
||
itemStyle: { color: lineColor },
|
||
data: y,
|
||
}],
|
||
};
|
||
}
|
||
|
||
function applyStats(data) {
|
||
var errEl = document.getElementById("stat-error");
|
||
if (errEl) {
|
||
errEl.classList.remove("is-visible");
|
||
errEl.textContent = "";
|
||
}
|
||
|
||
document.getElementById("kpi-range-msg").textContent = formatInt(data.messages_in_range);
|
||
document.getElementById("kpi-range-meta").textContent =
|
||
(data.range_start || "") + " ~ " + (data.range_end || "");
|
||
document.getElementById("kpi-job-like").textContent = formatInt(data.job_like_in_range);
|
||
var kws = (data.keywords_used || []).join("、");
|
||
document.getElementById("kpi-kw-meta").textContent = kws ? "关键词:" + kws : "关键词 OR";
|
||
document.getElementById("kpi-total-all").textContent = formatInt(data.total_messages_all_time);
|
||
document.getElementById("kpi-db-meta").textContent =
|
||
"数据频道 " + formatInt(data.database_count) + " 个";
|
||
document.getElementById("kpi-channels").textContent =
|
||
formatInt(data.state_monitored_count) + " / " + formatInt(data.database_count);
|
||
|
||
var days = data.daily_messages || [];
|
||
var daysH = data.daily_job_like || [];
|
||
var x = days.map(function (p) { return p[0]; });
|
||
var y1 = days.map(function (p) { return p[1]; });
|
||
var y2 = daysH.map(function (p) { return p[1]; });
|
||
|
||
if (!chartMsg && window.echarts) {
|
||
chartMsg = echarts.init(document.getElementById("chart-messages"), null, { renderer: "canvas" });
|
||
}
|
||
if (chartMsg) {
|
||
chartMsg.setOption(lineChartOption(x, y1, "#22c55e", "rgba(34,197,94,0.12)"), true);
|
||
}
|
||
|
||
if (!chartHire && window.echarts) {
|
||
chartHire = echarts.init(document.getElementById("chart-hiring"), null, { renderer: "canvas" });
|
||
}
|
||
if (chartHire) {
|
||
chartHire.setOption(lineChartOption(x, y2, "#38bdf8", "rgba(56,189,248,0.12)"), true);
|
||
}
|
||
|
||
var byCh = data.by_channel || [];
|
||
function truncLabel(s, n) {
|
||
s = (s == null ? "" : String(s)).trim();
|
||
if (!s) return "(未命名)";
|
||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||
}
|
||
var labels = byCh.map(function (c) {
|
||
return truncLabel(c.display_name, 28);
|
||
});
|
||
var vals = byCh.map(function (c) { return c.messages_in_range; });
|
||
|
||
if (!chartCh && window.echarts) {
|
||
chartCh = echarts.init(document.getElementById("chart-channels"), null, { renderer: "canvas" });
|
||
}
|
||
if (chartCh) {
|
||
chartCh.setOption({
|
||
textStyle: { color: "#94a3b8", fontFamily: "Inter, sans-serif" },
|
||
tooltip: {
|
||
trigger: "axis",
|
||
axisPointer: { type: "shadow" },
|
||
formatter: function (params) {
|
||
var p = params[0];
|
||
var i = p.dataIndex;
|
||
var full = (byCh[i] && byCh[i].display_name) ? byCh[i].display_name : p.name;
|
||
return full + "<br/>消息:" + formatInt(p.value) + "<br/>招聘类估算:" + formatInt(byCh[i] ? byCh[i].job_like_in_range : 0);
|
||
},
|
||
},
|
||
grid: { left: 200, right: 24, top: 16, bottom: 24 },
|
||
xAxis: {
|
||
type: "value",
|
||
axisLine: { show: false },
|
||
splitLine: { lineStyle: { color: "rgba(51,65,85,0.5)" } },
|
||
axisLabel: { color: "#94a3b8" },
|
||
},
|
||
yAxis: {
|
||
type: "category",
|
||
data: labels,
|
||
axisLine: { lineStyle: { color: "#475569" } },
|
||
axisLabel: { color: "#94a3b8", fontSize: 11 },
|
||
inverse: true,
|
||
},
|
||
series: [{
|
||
type: "bar",
|
||
data: vals,
|
||
itemStyle: { color: "#64748b", borderRadius: [0, 4, 4, 0] },
|
||
}],
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadStats() {
|
||
var errEl = document.getElementById("stat-error");
|
||
var daysEl = document.getElementById("stats-days");
|
||
var kwEl = document.getElementById("stats-keywords");
|
||
var days = daysEl ? daysEl.value : "30";
|
||
var kw = kwEl ? kwEl.value.trim() : "";
|
||
var qs = new URLSearchParams({ days: String(days) });
|
||
if (kw) qs.set("keywords", kw);
|
||
try {
|
||
var res = await fetch(appPath("/api/stats/overview") + "?" + qs.toString());
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
var data = await res.json();
|
||
applyStats(data);
|
||
} catch (e) {
|
||
if (errEl) {
|
||
errEl.textContent = "统计加载失败:" + (e && e.message ? e.message : e);
|
||
errEl.classList.add("is-visible");
|
||
}
|
||
}
|
||
}
|
||
|
||
function onResizeCharts() {
|
||
if (chartMsg) chartMsg.resize();
|
||
if (chartHire) chartHire.resize();
|
||
if (chartCh) chartCh.resize();
|
||
}
|
||
|
||
window.addEventListener("resize", onResizeCharts);
|
||
|
||
document.getElementById("stats-refresh")?.addEventListener("click", loadStats);
|
||
document.getElementById("stats-days")?.addEventListener("change", loadStats);
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", loadStats);
|
||
} else {
|
||
loadStats();
|
||
}
|
||
|
||
setInterval(loadStats, 60000);
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
try {
|
||
var statusResp = await fetch(appPath("/api/jobs/status"), { credentials: "same-origin" });
|
||
if (statusResp.ok) {
|
||
var status = await statusResp.json();
|
||
var runningEl = document.getElementById("job-running");
|
||
var connectEl = document.getElementById("connect-status");
|
||
var nameEl = document.getElementById("job-name");
|
||
var uptimeEl = document.getElementById("job-uptime");
|
||
var continuousEl = document.getElementById("continuous-running");
|
||
var continuousUptimeEl = document.getElementById("continuous-uptime");
|
||
var logsEl = document.getElementById("logs-box");
|
||
if (connectEl) {
|
||
if (status.connected) {
|
||
connectEl.textContent = "已连接";
|
||
connectEl.className = "status-pill status-pill--ok";
|
||
} else {
|
||
connectEl.textContent = "未连接";
|
||
connectEl.className = "status-pill status-pill--down";
|
||
}
|
||
}
|
||
if (runningEl && nameEl && uptimeEl) {
|
||
if (status.job_running) {
|
||
runningEl.textContent = "任务运行中";
|
||
runningEl.className = "status-pill status-pill--ok";
|
||
nameEl.textContent = status.job_name || "-";
|
||
uptimeEl.textContent = status.job_uptime || "-";
|
||
} else {
|
||
runningEl.textContent = "任务空闲";
|
||
runningEl.className = "status-pill status-pill--down";
|
||
nameEl.textContent = "无";
|
||
uptimeEl.textContent = "-";
|
||
}
|
||
}
|
||
if (continuousEl && continuousUptimeEl) {
|
||
if (status.continuous_running) {
|
||
continuousEl.textContent = "持续抓取中";
|
||
continuousEl.className = "status-pill status-pill--ok";
|
||
continuousUptimeEl.textContent = status.continuous_uptime || "-";
|
||
} else {
|
||
continuousEl.textContent = "持续抓取关";
|
||
continuousEl.className = "status-pill status-pill--down";
|
||
continuousUptimeEl.textContent = "-";
|
||
}
|
||
}
|
||
if (logsEl && Array.isArray(status.logs)) {
|
||
logsEl.textContent = status.logs.join("\n");
|
||
}
|
||
}
|
||
|
||
var channelsResp = await fetch(appPath("/api/channels/monitored"), { credentials: "same-origin" });
|
||
if (channelsResp.ok) {
|
||
var payload = await channelsResp.json();
|
||
var wrap = document.getElementById("monitored-list");
|
||
if (wrap) {
|
||
var items = Array.isArray(payload.items) ? payload.items : [];
|
||
if (items.length === 0) {
|
||
wrap.innerHTML = '<div class="line" role="listitem">暂无</div>';
|
||
} else {
|
||
wrap.innerHTML = items.map(function (ch, i) {
|
||
var name = ch.display_name || ch.username || "未命名频道";
|
||
return '<div class="line" role="listitem">[' + (i + 1) + "] " + escapeHtml(name) + " · 进度消息 ID " + escapeHtml(String(ch.last_message_id)) + "</div>";
|
||
}).join("");
|
||
}
|
||
}
|
||
}
|
||
} catch (_e) {}
|
||
}
|
||
|
||
setInterval(refreshStatus, 3000);
|
||
|
||
(function () {
|
||
var form = document.querySelector(".account-pick-form");
|
||
if (!form) return;
|
||
var allBtn = document.getElementById("account-select-all");
|
||
var noneBtn = document.getElementById("account-select-none");
|
||
function selectableBoxes() {
|
||
return form.querySelectorAll('input[name="channel_id"]:not(:disabled)');
|
||
}
|
||
if (allBtn) {
|
||
allBtn.addEventListener("click", function () {
|
||
selectableBoxes().forEach(function (el) { el.checked = true; });
|
||
});
|
||
}
|
||
if (noneBtn) {
|
||
noneBtn.addEventListener("click", function () {
|
||
selectableBoxes().forEach(function (el) { el.checked = false; });
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|