Files
Resume-python/templates/index.html
2026-04-27 12:06:02 +08:00

1565 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{# app_urlJinja 宏,不依赖 Python 往 context 注入;前缀来自中间件 request.state.web_url_prefixWEB_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 %}
<p class="hint" style="margin-bottom:12px;">
初始化可能较慢(网络、扫码登录等)。结果会显示在下方灰色框内,无需只看浏览器网络里的 303
无头登录请开 <code>TELEGRAM_HEADLESS_QR=1</code> 并在<strong>运行日志</strong>中打开二维码链接。
</p>
<div id="conn-action-result" class="list" style="display:none;margin-bottom:14px;max-height:200px;font-size:0.8125rem;" role="status" aria-live="polite"></div>
<div class="btn-row">
<div>
<button class="btn-primary" type="button" id="btn-session-init">初始化 / 连接 Telegram</button>
</div>
<div>
<button class="btn-danger" type="button" id="btn-session-disconnect">断开 Telegram</button>
</div>
<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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 initBtn = document.getElementById("btn-session-init");
var discBtn = document.getElementById("btn-session-disconnect");
var out = document.getElementById("conn-action-result");
if (!initBtn || !out) return;
function showConnResult(html, isErr) {
out.style.display = "block";
out.innerHTML = html;
out.style.borderColor = isErr ? "rgba(239,68,68,0.45)" : "rgba(34,197,94,0.35)";
}
function renderInitPayload(data, httpStatus) {
var ok = data && data.ok === true;
var lines = [];
if (ok) {
lines.push(
"<p class=\"line\" style=\"margin:0 0 8px;color:#86efac;font-weight:600;\">成功:" +
escapeHtml(data.message || "已就绪") +
"</p>"
);
lines.push(
"<p class=\"line\" style=\"margin:0 0 4px;color:#94a3b8;\">连接状态:" +
(data.connected ? "已连接" : "未连接(请查看日志)") +
"</p>"
);
} else {
var errText =
(data && data.error) ||
(httpStatus && httpStatus !== 200 ? "HTTP " + httpStatus : "") ||
"未知错误";
lines.push(
"<p class=\"line\" style=\"margin:0 0 8px;color:#fca5a5;font-weight:600;\">失败:" +
escapeHtml(String(errText)) +
"</p>"
);
lines.push(
"<p class=\"line\" style=\"margin:0 0 4px;color:#94a3b8;\">连接状态:" +
(data && data.connected ? "已连接" : "未连接") +
"</p>"
);
}
if (data && data.logs_tail && data.logs_tail.length) {
lines.push(
"<pre style=\"margin:8px 0 0;font-size:0.75rem;color:#bae6fd;white-space:pre-wrap;word-break:break-word;\">" +
escapeHtml(data.logs_tail.join("\n")) +
"</pre>"
);
}
return { html: lines.join(""), isErr: !ok };
}
initBtn.addEventListener("click", async function () {
initBtn.disabled = true;
if (discBtn) discBtn.disabled = true;
showConnResult("<p class=\"line\" style=\"margin:0;color:#94a3b8;\">正在连接,请稍候(扫码场景可能需数分钟)…</p>", false);
try {
var res = await fetch(appPath("/api/session/init"), {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json" },
});
var data = await res.json().catch(function () {
return {};
});
var r = renderInitPayload(data, res.status);
showConnResult(r.html, r.isErr);
await refreshStatus();
} catch (e) {
showConnResult(
"<p class=\"line\" style=\"margin:0;color:#fca5a5;\">请求失败:" +
escapeHtml(e && e.message ? e.message : String(e)) +
"</p>",
true
);
} finally {
initBtn.disabled = false;
if (discBtn) discBtn.disabled = false;
}
});
if (discBtn) {
discBtn.addEventListener("click", async function () {
initBtn.disabled = true;
discBtn.disabled = true;
showConnResult("<p class=\"line\" style=\"margin:0;color:#94a3b8;\">正在断开…</p>", false);
try {
var res = await fetch(appPath("/api/session/disconnect"), {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json" },
});
var data = await res.json().catch(function () {
return {};
});
var ok = data && data.ok === true;
var lines = [];
if (ok) {
lines.push(
"<p class=\"line\" style=\"margin:0 0 8px;color:#86efac;font-weight:600;\">" +
escapeHtml(data.message || "已断开") +
"</p>"
);
} else {
lines.push(
"<p class=\"line\" style=\"margin:0;color:#fca5a5;font-weight:600;\">失败:" +
escapeHtml((data && data.error) || "未知错误") +
"</p>"
);
}
if (data && data.logs_tail && data.logs_tail.length) {
lines.push(
"<pre style=\"margin:8px 0 0;font-size:0.75rem;color:#bae6fd;white-space:pre-wrap;word-break:break-word;\">" +
escapeHtml(data.logs_tail.join("\n")) +
"</pre>"
);
}
showConnResult(lines.join(""), !ok);
await refreshStatus();
} catch (e) {
showConnResult(
"<p class=\"line\" style=\"margin:0;color:#fca5a5;\">请求失败:" +
escapeHtml(e && e.message ? e.message : String(e)) +
"</p>",
true
);
} finally {
initBtn.disabled = false;
discBtn.disabled = false;
}
});
}
})();
(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>