mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 07:57:09 +08:00
Sync deployed SparkFlow reliability updates
This commit is contained in:
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user