Sync deployed SparkFlow reliability updates

This commit is contained in:
Rixuan Shao
2026-06-20 01:59:01 +08:00
parent 285db46cd9
commit b5e61a14ea
13 changed files with 3891 additions and 1042 deletions

View File

@@ -1,5 +1,4 @@
ARG PLAYWRIGHT_BASE_IMAGE=mcr.microsoft.com/playwright/python:v1.56.0-jammy
FROM ${PLAYWRIGHT_BASE_IMAGE}
FROM mcr.microsoft.com/playwright/python:v1.56.0-jammy
WORKDIR /app
@@ -26,12 +25,13 @@ ENV PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN set -eux; \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone \
&& apt-get update && apt-get install -y --no-install-recommends \
&& apt-get update && apt-get install -y \
cron \
curl \
fluxbox \
fonts-wqy-zenhei \
fonts-noto-cjk \
@@ -39,6 +39,14 @@ RUN set -eux; \
websockify \
x11vnc \
xfonts-intl-chinese \
&& curl -fsSL -x http://127.0.0.1:7890 https://download.docker.com/linux/static/stable/x86_64/docker-25.0.3.tgz -o docker.tgz \
&& tar xzvf docker.tgz \
&& mv docker/docker /usr/bin/docker \
&& chmod +x /usr/bin/docker \
&& rm -rf docker docker.tgz \
&& mkdir -p /usr/local/lib/docker/cli-plugins \
&& curl -SL -x http://127.0.0.1:7890 https://github.com/docker/compose/releases/download/v2.24.5/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-compose \
&& rm -rf /var/lib/apt/lists/*
COPY . .

View File

@@ -1,4 +1,5 @@
import os
import re
import subprocess
import sys
import traceback
@@ -12,6 +13,7 @@ from utils.config import DEBUG, Environment, get_environment
console = Console()
PLAYWRIGHT_BROWSERS_PATH = "../chrome"
DEFAULT_PROFILE_ROOT = "/opt/douyin-sparkflow/state/browser-profiles"
def _local_browser_bundle_path():
@@ -32,6 +34,38 @@ def configure_playwright_environment():
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundle_path.resolve())
def _headless_for(GUI=False):
headless = not GUI
if get_environment() == Environment.LOCAL and DEBUG:
headless = False
return headless
def _browser_args():
return [
"--disable-dev-shm-usage",
"--no-sandbox",
]
def sanitize_profile_name(value):
raw = str(value or "").strip()
if not raw:
raw = "unknown"
safe = re.sub(r"[^0-9A-Za-z._-]+", "_", raw)
safe = safe.strip("._-") or "unknown"
return safe[:80]
def browser_profile_root(root=None):
configured = (
root
or os.getenv("SPARKFLOW_BROWSER_PROFILE_ROOT")
or DEFAULT_PROFILE_ROOT
)
return Path(configured)
async def install_browser():
try:
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
@@ -43,15 +77,11 @@ async def install_browser():
async def get_browser(GUI=False):
configure_playwright_environment()
headless = not GUI
if get_environment() == Environment.LOCAL and DEBUG:
headless = False
try:
playwright = await async_playwright().start()
browser = await playwright.chromium.launch(
headless=headless,
args=["--disable-dev-shm-usage"],
headless=_headless_for(GUI),
args=_browser_args(),
)
return playwright, browser
except Exception as exc:
@@ -61,3 +91,27 @@ async def get_browser(GUI=False):
sys.exit(1)
traceback.print_exc()
raise
async def get_persistent_browser_context(profile_name, GUI=False, root=None):
configure_playwright_environment()
profile_dir = browser_profile_root(root) / sanitize_profile_name(profile_name)
profile_dir.mkdir(parents=True, exist_ok=True)
try:
playwright = await async_playwright().start()
context = await playwright.chromium.launch_persistent_context(
str(profile_dir),
headless=_headless_for(GUI),
viewport={"width": 1600, "height": 1000},
args=_browser_args(),
)
return playwright, context, profile_dir
except Exception as exc:
if "Executable doesn't exist" in str(exc) and get_environment() != Environment.GITHUBACTION:
console.print("[bold red]Playwright browser is missing.[/bold red]")
await install_browser()
sys.exit(1)
traceback.print_exc()
raise

View File

@@ -1,27 +1,25 @@
import asyncio
from pathlib import Path
import time
from core.browser import get_browser
CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat"
FRIENDS_TAB_SELECTORS = (
'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]',
'xpath=//*[@id="sub-app"]//*[self::div or self::span or self::button][contains(normalize-space(.), "朋友私信") and string-length(normalize-space(.)) <= 20]',
'xpath=//*[@id="sub-app"]//*[self::div or self::span or self::button][normalize-space()="朋友"]',
FRIENDS_TAB_SELECTOR = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
TARGET_SELECTOR = (
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]'
'//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
)
FRIEND_NAME_SELECTOR = 'xpath=//*[@id="sub-app"]//span[contains(@class, "item-header-name-")]'
SCROLLABLE_FRIENDS_SELECTORS = (
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div',
'xpath=//*[@id="sub-app"]//ul/div',
'xpath=//*[@id="sub-app"]//ul',
SCROLLABLE_FRIENDS_SELECTOR = (
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
)
NO_MORE_SELECTOR = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
LOADING_SELECTOR = 'xpath=//div[contains(@class, "semi-spin")]'
FIRST_FRIEND_SELECTOR = (
'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
)
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"]
EMPTY_LIST_KEYWORDS = ("暂无", "没有", "空空", "还没有")
LOGIN_KEYWORDS = ("扫码登录", "登录抖音", "请登录", "登录已过期", "重新登录")
def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2):
@@ -32,10 +30,6 @@ def update_collection_progress(new_names_count, no_more_visible, scroll_moved, i
async def _ensure_logged_in(page):
current_url = page.url or ""
if "login" in current_url or "passport" in current_url:
raise RuntimeError("账号登录已失效,请重新扫码登录")
for selector in LOGIN_MASK_SELECTORS:
try:
locator = page.locator(selector).first
@@ -47,115 +41,12 @@ async def _ensure_logged_in(page):
continue
async def _body_text(page, limit=600):
try:
text = await page.locator("body").inner_text(timeout=2000)
except Exception:
return ""
return " ".join(text.split())[:limit]
async def _page_diagnosis(page):
await _ensure_logged_in(page)
body_text = await _body_text(page)
if any(keyword in body_text for keyword in LOGIN_KEYWORDS):
raise RuntimeError("账号登录已失效或页面要求重新登录,请重新扫码登录")
if any(keyword in body_text for keyword in EMPTY_LIST_KEYWORDS):
return "页面提示当前没有可读取的朋友私信好友"
return f"未等到好友列表。当前URL={page.url},页面提示={body_text or ''}"
async def _open_friends_tab(page):
if await page.locator(FRIEND_NAME_SELECTOR).count() > 0:
return
last_error = None
for selector in FRIENDS_TAB_SELECTORS:
locator = page.locator(selector).first
try:
await locator.wait_for(state="visible", timeout=10000)
await locator.click(timeout=5000)
await asyncio.sleep(1.5)
return
except Exception as exc:
last_error = exc
raise RuntimeError(f"未找到“朋友私信”入口,可能页面结构变化或账号未登录。最后错误:{last_error}")
async def _wait_for_friend_name_or_empty(page, timeout_ms=45000):
deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline:
await _ensure_logged_in(page)
names = page.locator(FRIEND_NAME_SELECTOR)
if await names.count() > 0:
first = names.first
try:
if await first.is_visible():
return True
except Exception:
return True
body_text = await _body_text(page, limit=300)
if any(keyword in body_text for keyword in EMPTY_LIST_KEYWORDS):
return False
if any(keyword in body_text for keyword in LOGIN_KEYWORDS):
raise RuntimeError("账号登录已失效或页面要求重新登录,请重新扫码登录")
loading = page.locator(LOADING_SELECTOR).first
if await loading.count() > 0 and await loading.is_visible():
await asyncio.sleep(1.5)
else:
await asyncio.sleep(1)
diagnosis = await _page_diagnosis(page)
raise RuntimeError(diagnosis)
async def _collect_visible_friend_names(page):
names = []
for raw_name in await page.locator(FRIEND_NAME_SELECTOR).all_inner_texts():
name = raw_name.strip()
if name:
names.append(name)
return names
async def _find_scrollable_friends_element(page):
for selector in SCROLLABLE_FRIENDS_SELECTORS:
try:
handle = await page.locator(selector).first.element_handle(timeout=2000)
if handle:
return handle
except Exception:
continue
try:
handle = await page.evaluate_handle(
"""() => {
const firstName = document.querySelector('#sub-app span[class*="item-header-name-"]');
let node = firstName;
while (node && node !== document.body) {
const style = window.getComputedStyle(node);
const overflow = `${style.overflow} ${style.overflowY}`;
if (node.scrollHeight > node.clientHeight + 20 && /(auto|scroll|overlay)/.test(overflow)) {
return node;
}
node = node.parentElement;
}
return document.scrollingElement || document.documentElement;
}"""
)
return handle.as_element()
except Exception:
return None
async def collect_friend_names(page):
await _open_friends_tab(page)
has_friends = await _wait_for_friend_name_or_empty(page)
if not has_friends:
return []
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
await page.locator(FRIENDS_TAB_SELECTOR).click()
await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
await page.locator(FIRST_FRIEND_SELECTOR).click()
await asyncio.sleep(2)
found_names = []
@@ -164,8 +55,13 @@ async def collect_friend_names(page):
stuck_rounds = 0
while True:
target_elements = await page.locator(TARGET_SELECTOR).all()
new_names_count = 0
for name in await _collect_visible_friend_names(page):
for element in target_elements:
try:
name = (await element.locator(FRIEND_NAME_SELECTOR).inner_text()).strip()
except Exception:
continue
if not name or name in seen_names:
continue
seen_names.add(name)
@@ -180,12 +76,11 @@ async def collect_friend_names(page):
if await loading.count() > 0 and await loading.is_visible():
await asyncio.sleep(1.5)
scrollable_element = await _find_scrollable_friends_element(page)
scrollable_element = await page.locator(SCROLLABLE_FRIENDS_SELECTOR).element_handle()
if not scrollable_element:
if found_names:
return found_names
diagnosis = await _page_diagnosis(page)
raise RuntimeError(f"未找到好友列表滚动容器;{diagnosis}")
raise RuntimeError("未找到好友列表滚动容器")
before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,22 @@ pkill -f "websockify --web=/usr/share/novnc ${LOGIN_DESKTOP_WEB_PORT}" >/dev/nul
rm -f "/tmp/.X99-lock"
rm -f "/tmp/.X11-unix/X99"
run_forever() {
local name="$1"
shift
local log_file="/app/logs/login_desktop/${name}.log"
: > "${log_file}"
while true; do
printf '[%s] starting %s\n' "$(date -Is)" "${name}" >> "${log_file}"
set +e
"$@" >> "${log_file}" 2>&1
local status=$?
set -e
printf '[%s] %s exited with status %s; restarting in 2s\n' "$(date -Is)" "${name}" "${status}" >> "${log_file}"
sleep 2
done
}
Xvfb "${DISPLAY}" -screen 0 1600x1000x24 -ac +extension RANDR > /app/logs/login_desktop/xvfb.log 2>&1 &
for _ in $(seq 1 30); do
if [ -S /tmp/.X11-unix/X99 ]; then
@@ -25,7 +41,7 @@ for _ in $(seq 1 30); do
done
fluxbox > /app/logs/login_desktop/fluxbox.log 2>&1 &
x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${LOGIN_DESKTOP_VNC_PORT}" -nopw > /app/logs/login_desktop/x11vnc.log 2>&1 &
websockify --web=/usr/share/novnc "${LOGIN_DESKTOP_WEB_PORT}" "localhost:${LOGIN_DESKTOP_VNC_PORT}" > /app/logs/login_desktop/novnc.log 2>&1 &
run_forever x11vnc x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${LOGIN_DESKTOP_VNC_PORT}" -localhost -nopw &
run_forever novnc websockify --web=/usr/share/novnc "${LOGIN_DESKTOP_WEB_PORT}" "127.0.0.1:${LOGIN_DESKTOP_VNC_PORT}" &
exec python /app/login_desktop_server.py

View File

@@ -26,6 +26,13 @@ DEFAULT_CONFIG = {
"useProtocolSender": True,
"protocolDryRun": False,
"browserSenderAccounts": [],
"persistentBrowserProfiles": {
"enabled": False,
"root": "/opt/douyin-sparkflow/state/browser-profiles",
"seedCookiesWhenEmpty": True,
"syncStoredCookiesBeforeRun": True,
"refreshStoredCookiesAfterLogin": True,
},
"sendStrategy": {
"shuffleTargets": True,
"accountStartDelaySecondsMin": 0,
@@ -56,10 +63,13 @@ DEFAULT_APP_SETTINGS = {
"ui_host": "0.0.0.0",
"ui_port": 8787,
"login_poll_interval_seconds": 1,
"ops_log_file": "/app/logs/douyin-sparkflow.log",
"ops_log_file": "/var/log/douyin-sparkflow.log",
"proxy_refresh_script": "/opt/douyin-sparkflow/refresh_proxy.sh",
"local_login_helper_url": "http://127.0.0.1:18765",
"login_desktop_api_url": "http://127.0.0.1:18090",
"login_desktop_public_url": "",
"login_desktop_public_scheme": "http",
"login_desktop_public_port": 8788,
"server_host": "",
"server_username": "",
"server_password": "",

View File

@@ -1,6 +1,5 @@
import json
import logging
import os
import traceback
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -17,7 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends
from core.tasks import run_browser_tasks
from core.tasks import run_browser_tasks, task_run_lock
from utils.config import (
get_app_settings,
get_config,
@@ -41,12 +40,15 @@ from webui.auth import (
verify_password,
)
from webui.ops import (
TASK_ALREADY_RUNNING,
get_ops_snapshot,
read_log_tail,
refresh_proxy,
restart_proxy,
run_failed_retry_now,
run_task_now,
run_unsent_retry_now,
task_run_lock_status,
update_daily_schedule,
)
@@ -133,13 +135,18 @@ def _target_sent_today(account, target_name):
def login_desktop_api_url():
settings = get_app_settings(force_reload=True)
return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_API_URL") or settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
return str(settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
def login_desktop_public_url(request: Request) -> str:
settings = get_app_settings(force_reload=True)
configured_url = str(settings.get("login_desktop_public_url") or "").strip()
if configured_url:
return configured_url
host = request.url.hostname or "127.0.0.1"
scheme = request.url.scheme or "http"
port = str(os.getenv("LOGIN_DESKTOP_PUBLIC_PORT") or "8788").strip() or "8788"
scheme = str(settings.get("login_desktop_public_scheme") or "http").strip() or "http"
port = coerce_int(settings.get("login_desktop_public_port"), 8788, minimum=1)
return f"{scheme}://{host}:{port}/vnc.html?autoconnect=1&resize=scale&view_only=0"
@@ -237,14 +244,6 @@ def create_app():
return redirect("/login")
return None
def console_context(request):
return {
"flash": pop_flash(request),
"accounts": get_userData(force_reload=True),
"runtime_config": get_config(force_reload=True),
"ops": get_ops_snapshot(),
}
def flash(request, message, level="info"):
request.session["flash"] = {"message": message, "level": level}
@@ -267,7 +266,7 @@ def create_app():
@app.post("/bootstrap")
async def bootstrap(request: Request):
if is_bootstrapped():
flash(request, "管理员账号已初始化,请直接登录。", "warning")
flash(request, "Admin login is already configured.", "warning")
return redirect("/login")
form = await request.form()
@@ -275,17 +274,17 @@ def create_app():
password = str(form.get("password", ""))
confirm = str(form.get("confirm_password", ""))
if not password or password != confirm:
flash(request, "初始化失败,请输入一致的管理员密码。", "error")
flash(request, "Password setup failed. Please enter matching passwords.", "error")
return redirect("/login")
bootstrap_admin_password(password, username=username)
flash(request, "管理员账号已创建,请登录控制台。", "success")
flash(request, "Admin credentials created. Please log in.", "success")
return redirect("/login")
@app.post("/login")
async def login_action(request: Request):
if not is_bootstrapped():
flash(request, "请先创建管理员密码。", "warning")
flash(request, "Create the admin password first.", "warning")
return redirect("/login")
form = await request.form()
@@ -293,11 +292,11 @@ def create_app():
password = str(form.get("password", ""))
settings = get_app_settings(force_reload=True)
if username != settings["admin_username"] or not verify_password(password, settings["admin_password_hash"]):
flash(request, "用户名或密码不正确。", "error")
flash(request, "Invalid username or password.", "error")
return redirect("/login")
issue_session(request, username)
flash(request, "已登录控制台。", "success")
flash(request, "Signed in successfully.", "success")
return redirect("/")
@app.post("/logout")
@@ -314,43 +313,12 @@ def create_app():
return render_template(
request,
"dashboard.html",
console_context(request),
)
@app.get("/login-workspace", response_class=HTMLResponse)
async def login_workspace_page(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
return render_template(
request,
"login_workspace.html",
console_context(request),
)
@app.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
return render_template(
request,
"accounts.html",
console_context(request),
)
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
return render_template(
request,
"settings.html",
console_context(request),
{
"flash": pop_flash(request),
"accounts": get_userData(force_reload=True),
"runtime_config": get_config(force_reload=True),
"ops": get_ops_snapshot(),
},
)
@app.get("/ops/send-console", response_class=HTMLResponse)
@@ -388,11 +356,11 @@ def create_app():
account["targets"] = targets
account["enabled"] = str(form.get("enabled", "")) == "on"
save_userData(accounts)
flash(request, f"已更新账号 {account['username']}", "success")
flash(request, f"Updated account {account['username']}.", "success")
else:
flash(request, "未找到账号。", "error")
flash(request, "Account not found.", "error")
return redirect("/accounts")
return redirect("/")
@app.post("/accounts/{unique_id}/toggle-enabled")
async def toggle_account_enabled(request: Request, unique_id: str):
@@ -407,8 +375,8 @@ def create_app():
accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id)
if not account:
flash(request, "未找到账号。", "error")
return redirect("/accounts")
flash(request, "Account not found.", "error")
return redirect("/")
account["enabled"] = not is_account_enabled(account)
save_userData(accounts)
@@ -417,7 +385,7 @@ def create_app():
f"{account.get('username', 'Account')}{'启用' if account['enabled'] else '停用'}自动续火花。",
"success",
)
return redirect("/accounts")
return redirect("/")
@app.post("/accounts/{unique_id}/friends/refresh")
async def refresh_account_friend_list(request: Request, unique_id: str):
@@ -463,10 +431,10 @@ def create_app():
updated_accounts = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalize_unique_id(unique_id)]
if len(updated_accounts) != len(accounts):
save_userData(updated_accounts)
flash(request, "账号已删除。", "success")
flash(request, "Account deleted.", "success")
else:
flash(request, "未找到账号。", "error")
return redirect("/accounts")
flash(request, "Account not found.", "error")
return redirect("/")
@app.post("/accounts/{unique_id}/retry-target")
async def retry_account_target(request: Request, unique_id: str):
@@ -480,13 +448,18 @@ def create_app():
target_name = str(form.get("target", "")).strip()
if not target_name:
flash(request, "请选择需要重试的目标。", "error")
flash(request, "Target is required for retry.", "error")
return redirect("/ops/send-console")
accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id)
if not account:
flash(request, "未找到账号。", "error")
flash(request, "Account not found.", "error")
return redirect("/ops/send-console")
lock_status = task_run_lock_status()
if lock_status.get("running"):
flash(request, "已有发送任务正在运行,本次单目标重试没有启动。请等当前任务结束后再试。", "warning")
return redirect("/ops/send-console")
account_copy = dict(account)
@@ -495,18 +468,24 @@ def create_app():
config["taskCount"] = 1
try:
await run_browser_tasks(config, [account_copy])
with task_run_lock():
await run_browser_tasks(config, [account_copy])
except Exception as exc:
flash(request, f"{account.get('username', '账号')} / {target_name} 重试失败:{exc}", "error")
flash(request, f"Retry failed for {account.get('username', 'Account')} / {target_name}: {exc}", "error")
return redirect("/ops/send-console")
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name):
flash(request, f"{account.get('username', '账号')} / {target_name} 已重试成功。", "success")
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
else:
account_failure = dict(updated_account.get("account_failure") or {})
affected_targets = list(account_failure.get("affectedTargets") or [])
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
reason = str(failure_entry.get("reason") or "重试未确认发送成功。")
flash(request, f"{account.get('username', '账号')} / {target_name} 重试未成功:{reason}", "error")
if target_name in affected_targets:
reason = str(account_failure.get("reason") or "Account-level browser failure.")
else:
reason = str(failure_entry.get("reason") or "Retry did not confirm a successful send.")
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
return redirect("/ops/send-console")
@app.post("/config")
@@ -572,8 +551,8 @@ def create_app():
config["happyNewYear"] = happy_new_year
save_config(config)
flash(request, "运行配置已保存。", "success")
return redirect("/settings")
flash(request, "Runtime config saved.", "success")
return redirect("/")
@app.post("/settings")
async def save_panel_settings(request: Request):
@@ -605,12 +584,12 @@ def create_app():
confirm_password = str(form.get("confirm_password", ""))
if new_password:
if new_password != confirm_password:
flash(request, "管理员密码未更新:两次输入不一致。", "error")
return redirect("/settings")
flash(request, "Admin password was not updated because the confirmation did not match.", "error")
return redirect("/")
update_admin_password(new_password)
flash(request, "面板与服务设置已保存。", "success")
return redirect("/settings")
flash(request, "Panel settings saved.", "success")
return redirect("/")
@app.post("/ops/run-now")
async def run_now(request: Request):
@@ -623,14 +602,35 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
pid = run_task_now()
if pid == -1:
flash(request, "补发全部对象启动失败,请查看服务日志", "error")
if pid == TASK_ALREADY_RUNNING:
flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试", "warning")
elif pid == -1:
flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
else:
flash(request, f"已启动补发全部对象任务pid {pid})。", "success")
flash(request, f"已启动补发全部对象后台任务pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
return redirect("/")
@app.post("/ops/run-failed")
async def run_failed_retry(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
pid = run_failed_retry_now()
if pid == TASK_ALREADY_RUNNING:
flash(request, "已有发送任务正在运行,本次补发未成功目标没有启动。请等当前任务结束后再试。", "warning")
elif pid == -1:
flash(request, "Failed to start the failed-target retry run. Check server logs for details.", "error")
else:
flash(request, f"已启动补发未成功目标后台任务pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
return redirect("/ops/send-console")
@app.post("/ops/run-unsent")
async def run_unsent(request: Request):
async def run_unsent_retry(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
@@ -640,10 +640,12 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
pid = run_unsent_retry_now()
if pid == -1:
flash(request, "补发未成功目标启动失败,请查看服务日志。", "error")
if pid == TASK_ALREADY_RUNNING:
flash(request, "A send task is already running; unsent retry was not started.", "warning")
elif pid == -1:
flash(request, "Failed to start the unsent-target retry run. Check server logs for details.", "error")
else:
flash(request, f"已启动补发未成功目标任务pid {pid})。", "success")
flash(request, f"Started unsent-target retry background task (pid {pid}). Refresh the send console for results.", "info")
return redirect("/ops/send-console")
@app.post("/ops/proxy/refresh")
@@ -657,8 +659,8 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
refresh_proxy()
flash(request, "代理订阅已刷新。", "success")
return redirect("/settings")
flash(request, "Proxy subscription refreshed.", "success")
return redirect("/")
@app.post("/ops/proxy/restart")
async def proxy_restart(request: Request):
@@ -671,8 +673,8 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
restart_proxy()
flash(request, "代理容器已重启。", "success")
return redirect("/settings")
flash(request, "Proxy container restarted.", "success")
return redirect("/")
@app.post("/ops/schedule")
async def save_schedule(request: Request):
@@ -687,10 +689,10 @@ def create_app():
time_string = str(form.get("daily_schedule", "")).strip()
result = update_daily_schedule(time_string)
if getattr(result, "returncode", 1) == 0:
flash(request, f"发送窗口已更新为 {time_string}", "success")
flash(request, f"Updated the daily schedule to {time_string}.", "success")
else:
flash(request, f"发送窗口更新失败:{getattr(result, 'stderr', '')}", "error")
return redirect("/settings")
flash(request, f"Failed to update the daily schedule to {time_string}: {getattr(result, 'stderr', '')}", "error")
return redirect("/")
@app.get("/ops/logs", response_class=HTMLResponse)
async def logs_page(request: Request):

View File

@@ -6,6 +6,7 @@ import re
import shlex
import subprocess
import sys
import unicodedata
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -14,6 +15,8 @@ from utils.config import get_app_settings, get_config, get_userData, normalize_u
logger = logging.getLogger(__name__)
TASK_ALREADY_RUNNING = -2
TASK_SCHEDULE_MARKERS = (
"docker compose run --rm task",
"docker compose run --rm douyin",
@@ -61,6 +64,54 @@ def compose_command(*args):
return base
def _pid_is_alive(pid):
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _parse_lock_pid(raw):
try:
return int(str(raw or "").strip().splitlines()[0])
except (IndexError, TypeError, ValueError):
return None
def task_run_lock_status():
lock_path = repo_root() / "logs" / "task.run.lock"
if not lock_path.exists():
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": 0, "staleRemoved": False}
raw = lock_path.read_text(encoding="utf-8", errors="ignore")
pid = _parse_lock_pid(raw)
try:
age_seconds = max(0, int(datetime.now(timezone.utc).timestamp() - lock_path.stat().st_mtime))
except OSError:
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": 0, "staleRemoved": False}
if pid is not None and not _pid_is_alive(pid):
try:
lock_path.unlink()
logger.warning("Removed stale task run lock owned by missing pid=%s", pid)
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
except FileNotFoundError:
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
if pid is None and age_seconds > 7200:
try:
lock_path.unlink()
logger.warning("Removed stale unreadable task run lock contents=%r", raw[:80])
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
except FileNotFoundError:
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
return {"running": True, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": False}
def build_task_run_spec():
if running_in_container():
return [sys.executable, "main.py", "--doTask"], repo_root()
@@ -88,20 +139,22 @@ def _compose_env_args(extra_env=None):
return " ".join(shlex.quote(part) for part in parts)
def _ops_log_file():
return str(get_app_settings().get("ops_log_file") or "/app/logs/douyin-sparkflow.log")
def build_scheduled_task_command(extra_env=None, trigger_label="scheduled send"):
if running_in_container():
task_command = _with_env_prefix("python main.py --doTask", extra_env)
repo_root_quoted = shlex.quote(str(repo_root()))
script = (
"timestamp=$(date -Iseconds); "
return (
"/bin/bash -lc 'timestamp=$(date -Iseconds); "
f"echo \"[AUTO_TRIGGER] $timestamp {trigger_label} start\"; "
f"cd {repo_root_quoted} && {task_command}"
"container=$(docker ps --format \"{{.Names}}\" | "
"grep -E \"^(douyin-web-hostfix|douyin-web)$\" | head -n 1); "
"if [ -z \"$container\" ]; then "
"echo \"[AUTO_TRIGGER] $timestamp no matching container found\"; "
"exit 1; "
"fi; "
"echo \"[AUTO_TRIGGER] $timestamp container=$container\"; "
"docker exec \"$container\" sh -lc "
f"\"cd /app && {task_command}\"'"
)
return f"/bin/bash -lc {shlex.quote(script)}"
if compose_file_path():
compose_root_quoted = shlex.quote(str(compose_root()))
compose_env_args = _compose_env_args(extra_env)
@@ -236,15 +289,26 @@ def get_task_container_rows():
return []
def run_task_now(*, unsent_only=False):
def run_task_now(*, unsent_only=False, failed_only=False):
try:
log_file = Path(_ops_log_file())
lock_status = task_run_lock_status()
if lock_status.get("running"):
logger.info(
"Refusing to start manual task because task lock is active pid=%s age=%ss",
lock_status.get("pid"),
lock_status.get("ageSeconds"),
)
return TASK_ALREADY_RUNNING
log_file = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
command, cwd = build_task_run_spec()
run_env = {
"SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "1",
}
if unsent_only:
if failed_only:
run_env["SPARKFLOW_MANUAL_FAILED_ONLY"] = "1"
elif unsent_only:
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
return run_background_command(
command,
@@ -259,6 +323,10 @@ def run_task_now(*, unsent_only=False):
return -1
def run_failed_retry_now():
return run_task_now(failed_only=True)
def run_unsent_retry_now():
return run_task_now(unsent_only=True)
@@ -283,7 +351,7 @@ def restart_proxy():
def read_log_tail(lines=200):
log_path = Path(_ops_log_file())
log_path = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
if not log_path.exists():
return ""
content = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
@@ -348,7 +416,6 @@ def replace_douyin_cron_schedule(crontab_text, time_string):
schedule = parse_schedule_string(time_string)
scheduled_command = build_scheduled_task_command()
fallback_command = build_unsent_fallback_task_command()
log_redirect = f" >> {shlex.quote(_ops_log_file())} 2>&1"
updated = []
for raw_line in crontab_text.splitlines():
@@ -360,20 +427,20 @@ def replace_douyin_cron_schedule(crontab_text, time_string):
if schedule["mode"] == "window":
updated.append(
f"*/{schedule['scheduleIntervalMinutes']} {schedule['startHour']}-{schedule['endHour'] - 1} * * * "
f"{scheduled_command}{log_redirect}"
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
)
updated.append(
f"0 {schedule['endHour']} * * * "
f"{scheduled_command}{log_redirect}"
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
)
updated.append(
f"{schedule['scheduleIntervalMinutes']} {schedule['endHour']} * * * "
f"{fallback_command}{log_redirect}"
f"{fallback_command} >> /var/log/douyin-sparkflow.log 2>&1"
)
else:
updated.append(
f"{schedule['minute']} {schedule['hour']} * * * "
f"{scheduled_command}{log_redirect}"
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
)
normalized = "\n".join(line for line in updated if line.strip())
@@ -484,6 +551,70 @@ def _account_identity(user):
return str(user.get("unique_id") or user.get("username") or "unknown").strip()
def _coerce_attempt_count(entry):
try:
return int(dict(entry or {}).get("attemptCount") or 0)
except (TypeError, ValueError):
return 0
def _account_failure_pause_after_attempts():
raw_value = str(os.getenv("SPARKFLOW_ACCOUNT_FAILURE_PAUSE_AFTER_ATTEMPTS") or "2").strip()
try:
return max(1, int(raw_value))
except ValueError:
return 2
def _account_failure_entry_today(account, now):
entry = dict(account.get("account_failure") or {})
last_attempt_at = _parse_sent_at(entry.get("lastAttemptAt"), now.tzinfo)
if last_attempt_at and last_attempt_at.date() == now.date():
entry["lastAttemptAt"] = last_attempt_at.isoformat(timespec="seconds")
first_attempt_at = _parse_sent_at(entry.get("firstAttemptAt"), now.tzinfo)
if first_attempt_at:
entry["firstAttemptAt"] = first_attempt_at.isoformat(timespec="seconds")
entry["attemptCount"] = _coerce_attempt_count(entry)
entry["affectedTargets"] = list(entry.get("affectedTargets") or [])
return entry
return {}
def _normalize_friend_index_key(value):
raw = unicodedata.normalize("NFKC", str(value or ""))
for token in ("\u200b", "\u200c", "\u200d", "\ufeff"):
raw = raw.replace(token, "")
raw = raw.replace("\xa0", " ")
return " ".join(raw.split()).strip()
def _friend_index_status(account, target_name):
friend_index = dict(account.get("friend_index") or {})
entry = dict(friend_index.get(_normalize_friend_index_key(target_name)) or {})
return {
"seen": bool(entry),
"visibleName": str(entry.get("visibleName") or ""),
"stableKeys": list(entry.get("stableKeys") or []),
"lastSeenAt": str(entry.get("lastSeenAt") or ""),
}
def _account_blocked_target_status(item, account_failure):
blocked_item = dict(item)
affected_targets = set(account_failure.get("affectedTargets") or [])
blocked_item.update(
{
"status": "account_blocked",
"category": str(account_failure.get("category") or ""),
"reason": str(account_failure.get("reason") or ""),
"attemptCount": _coerce_attempt_count(account_failure),
"lastAttemptAt": str(account_failure.get("lastAttemptAt") or ""),
"accountFailureAffected": blocked_item.get("target") in affected_targets,
}
)
return blocked_item
def _scheduled_send_time(user, target_name, send_window, now):
window_minutes = max(1, (send_window["endHour"] - send_window["startHour"]) * 60)
start_of_window = now.replace(
@@ -501,6 +632,7 @@ def _scheduled_send_time(user, target_name, send_window, now):
def _build_target_status(account, target_name, now, send_window):
history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {})
friend_index = _friend_index_status(account, target_name)
history_entry = history.get(target_name) or {}
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
@@ -515,6 +647,7 @@ def _build_target_status(account, target_name, now, send_window):
"reason": "",
"attemptCount": 0,
"scheduledAt": "",
"friendIndex": friend_index,
}
failure_entry = failure_queue.get(target_name) or {}
@@ -530,6 +663,7 @@ def _build_target_status(account, target_name, now, send_window):
"reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"scheduledAt": "",
"friendIndex": friend_index,
}
scheduled_at = None
@@ -546,6 +680,7 @@ def _build_target_status(account, target_name, now, send_window):
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
"friendIndex": friend_index,
}
return {
@@ -558,6 +693,7 @@ def _build_target_status(account, target_name, now, send_window):
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
"friendIndex": friend_index,
}
@@ -568,35 +704,83 @@ def get_send_console_snapshot():
summary = {
"enabled_accounts": len(accounts),
"total_targets": 0,
"today_sent_targets": 0,
"today_failed_targets": 0,
"today_pending_targets": 0,
"today_unprocessed_targets": 0,
"today_account_blocked_targets": 0,
"today_remaining_targets": 0,
"today_account_failures": 0,
"today_account_paused": 0,
}
account_rows = []
account_failure_pause_after = _account_failure_pause_after_attempts()
for account in accounts:
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in account.get("targets") or []]
configured_targets = list(account.get("targets") or [])
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in configured_targets]
sent_targets = [item for item in statuses if item["status"] == "sent"]
failed_targets = [item for item in statuses if item["status"] == "failed"]
pending_targets = [item for item in statuses if item["status"] == "pending"]
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"]
account_failure = _account_failure_entry_today(account, now)
account_paused = bool(account_failure and _coerce_attempt_count(account_failure) >= account_failure_pause_after)
account_blocked_targets = []
if account_paused:
account_blocked_targets = [
_account_blocked_target_status(item, account_failure)
for item in statuses
if item["status"] in {"pending", "unprocessed"}
]
pending_targets = []
unprocessed_targets = []
else:
pending_targets = [item for item in statuses if item["status"] == "pending"]
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"]
friend_index_meta = dict(account.get("friend_index_meta") or {})
friend_index_last_scan_at = _parse_sent_at(friend_index_meta.get("lastScanAt"), now.tzinfo)
if friend_index_last_scan_at:
friend_index_meta["lastScanAt"] = friend_index_last_scan_at.isoformat(timespec="seconds")
friend_index_meta["missingTargets"] = list(friend_index_meta.get("missingTargets") or [])
friend_index_meta["lastScanComplete"] = bool(friend_index_meta.get("lastScanComplete"))
try:
friend_index_meta["scannedCount"] = int(friend_index_meta.get("scannedCount") or 0)
except (TypeError, ValueError):
friend_index_meta["scannedCount"] = 0
summary["total_targets"] += len(configured_targets)
summary["today_sent_targets"] += len(sent_targets)
summary["today_failed_targets"] += len(failed_targets)
summary["today_pending_targets"] += len(pending_targets)
summary["today_unprocessed_targets"] += len(unprocessed_targets)
summary["today_account_blocked_targets"] += len(account_blocked_targets)
summary["today_remaining_targets"] += (
len(failed_targets)
+ len(pending_targets)
+ len(unprocessed_targets)
+ len(account_blocked_targets)
)
if account_failure:
summary["today_account_failures"] += 1
if account_paused:
summary["today_account_paused"] += 1
account_rows.append(
{
"unique_id": str(account.get("unique_id") or ""),
"username": account.get("username") or "",
"total_targets": len(configured_targets),
"sent_targets": sent_targets,
"failed_targets": failed_targets,
"pending_targets": pending_targets,
"unprocessed_targets": unprocessed_targets,
"account_blocked_targets": account_blocked_targets,
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
"failure_queue": dict(account.get("failure_queue") or {}),
"account_failure": account_failure,
"account_paused": account_paused,
"account_failure_pause_after": account_failure_pause_after,
"friend_index_meta": friend_index_meta,
"friend_index_count": len(dict(account.get("friend_index") or {})),
}
)
@@ -633,6 +817,7 @@ def get_ops_snapshot():
"containers": get_container_status(),
"task_containers": get_task_container_rows(),
"send_console": get_send_console_snapshot(),
"task_lock": task_run_lock_status(),
"daily_schedule": current_daily_schedule(),
"crontab": read_crontab(),
"log_tail": read_log_tail(120),

View File

@@ -1,148 +1,521 @@
{% set current_nav %}{% block nav_key %}dashboard{% endblock %}{% endset %}
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}自动续火花{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}续火花{% endblock %}</title>
<style>
/* ============ DARK (default) ============ */
:root {
--bg: #0a0e1c;
--bg-glow: rgba(255, 77, 109, 0.10);
--surface: #161e34;
--surface-subtle: #1a2440;
--surface-tint: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--text: #eaf0ff;
--text-main: #eaf0ff;
--text-soft: #9aa6c8;
--text-faint: #64709a;
--primary: #ff7a3d;
--primary-strong: #ff4d6d;
--primary-deep: #ff2da8;
--primary-soft: rgba(255, 122, 61, 0.14);
--success: #2bd47f;
--success-soft: rgba(43, 212, 127, 0.14);
--danger: #ff5470;
--danger-soft: rgba(255, 84, 112, 0.14);
--warning: #ffb547;
--warning-soft: rgba(255, 181, 71, 0.14);
--info: #5b8bff;
--info-soft: rgba(91, 139, 255, 0.16);
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.30);
--ring: 0 0 0 4px rgba(255, 122, 61, 0.22);
--fire-grad: linear-gradient(135deg, #ffb04a 0%, #ff7a3d 30%, #ff4d6d 65%, #ff2da8 100%);
--glow-fire: 0 18px 50px rgba(255, 77, 109, 0.28);
--sidebar-bg: linear-gradient(200deg, rgba(22, 30, 52, 0.96) 0%, rgba(14, 20, 38, 0.96) 100%);
--sidebar-text: rgba(255, 255, 255, 0.72);
--sidebar-text-strong: #ffffff;
--sidebar-faint: rgba(255, 255, 255, 0.42);
--sidebar-hover: rgba(255, 255, 255, 0.06);
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.22), rgba(255, 45, 168, 0.10));
--sidebar-active-border: rgba(255, 122, 61, 0.28);
--panel-glass: rgba(22, 30, 52, 0.72);
--glass-blur: blur(10px);
--body-bg:
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.16), transparent 50%),
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.14), transparent 48%),
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
--title-grad: linear-gradient(120deg, #ffffff 0%, #ffd9c8 60%, #ff7a9d 120%);
--th-bg: rgba(255, 255, 255, 0.04);
--row-hover: rgba(255, 255, 255, 0.03);
--table-border: rgba(255, 255, 255, 0.06);
--input-bg: rgba(255, 255, 255, 0.04);
--code-bg: #0c1426;
--code-light-bg: rgba(255, 255, 255, 0.03);
--empty-bg: rgba(255, 255, 255, 0.02);
--radius-xl: 24px;
--radius-lg: 20px;
--radius-md: 16px;
--radius-sm: 12px;
}
/* ============ LIGHT ============ */
[data-theme="light"] {
--bg: #eef2f9;
--bg-glow: rgba(255, 122, 61, 0.08);
--surface: #ffffff;
--surface-subtle: #f7faff;
--surface-tint: #f0f4fb;
--border: #e3e9f3;
--border-strong: #d0d9e8;
--text: #14213d;
--text-main: #14213d;
--text-soft: #566079;
--text-faint: #8a96b3;
--primary: #ff7a3d;
--primary-strong: #ff4d6d;
--primary-deep: #ff2da8;
--primary-soft: rgba(255, 122, 61, 0.10);
--success: #18a558;
--success-soft: rgba(24, 165, 88, 0.14);
--danger: #e5484a;
--danger-soft: rgba(229, 72, 74, 0.12);
--warning: #e08a0c;
--warning-soft: rgba(224, 138, 12, 0.14);
--info: #3a66ff;
--info-soft: rgba(58, 102, 255, 0.12);
--shadow: 0 20px 45px rgba(20, 33, 61, 0.10);
--shadow-soft: 0 8px 24px rgba(20, 33, 61, 0.06);
--ring: 0 0 0 4px rgba(255, 122, 61, 0.18);
--glow-fire: 0 16px 40px rgba(255, 77, 109, 0.20);
--sidebar-bg: #ffffff;
--sidebar-text: #566079;
--sidebar-text-strong: #14213d;
--sidebar-faint: #8a96b3;
--sidebar-hover: rgba(20, 33, 61, 0.04);
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.16), rgba(255, 45, 168, 0.06));
--sidebar-active-border: rgba(255, 122, 61, 0.30);
--panel-glass: #ffffff;
--glass-blur: none;
--body-bg:
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.10), transparent 50%),
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.10), transparent 48%),
linear-gradient(180deg, #f1f5fc 0%, #e9eef7 100%);
--title-grad: linear-gradient(120deg, #14213d 0%, #c45a3a 60%, #e83e7c 120%);
--th-bg: #f3f6fc;
--row-hover: rgba(58, 102, 255, 0.04);
--table-border: #eaeff7;
--input-bg: #ffffff;
--code-bg: #0f1728;
--code-light-bg: #f7f9fd;
--empty-bg: rgba(255, 255, 255, 0.72);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background: var(--body-bg);
background-attachment: fixed;
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; }
::selection { background: rgba(255, 77, 109, 0.30); }
.main-area ::-webkit-scrollbar,
.table-shell ::-webkit-scrollbar,
.code-block::-webkit-scrollbar { width: 10px; height: 10px; }
.main-area ::-webkit-scrollbar-thumb,
.table-shell ::-webkit-scrollbar-thumb,
.code-block::-webkit-scrollbar-thumb {
background: var(--text-faint);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
opacity: 0.5;
}
.main-area ::-webkit-scrollbar-thumb:hover { opacity: 0.8; background-clip: padding-box; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 236px minmax(0, 1fr); }
/* ---------- sidebar ---------- */
.sidebar {
position: sticky; top: 0; height: 100vh;
padding: 22px 14px 18px;
border-right: 1px solid var(--border);
background: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex; flex-direction: column; gap: 22px;
backdrop-filter: var(--glass-blur);
}
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 6px 14px; border-bottom: 1px solid var(--border); }
.brand-mark {
width: 40px; height: 40px; border-radius: 13px;
background: var(--fire-grad); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 19px; font-weight: 800;
box-shadow: var(--glow-fire);
}
.brand-title { font-size: 16px; font-weight: 800; letter-spacing: 0.01em; color: var(--sidebar-text-strong); }
.brand-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
.sidebar-section-title {
margin: 0 10px 8px; font-size: 10.5px; color: var(--sidebar-faint);
letter-spacing: 0.16em; text-transform: uppercase;
}
.nav-list { display: grid; gap: 4px; }
.nav-item {
position: relative; display: flex; align-items: center; gap: 12px;
padding: 11px 14px; border-radius: 11px;
color: var(--sidebar-text); font-size: 14px; font-weight: 600;
transition: background 0.18s ease, color 0.18s ease;
}
.nav-item:hover { background: var(--sidebar-hover); color: var(--sidebar-text-strong); }
.nav-item.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-text-strong); font-weight: 700;
box-shadow: inset 0 0 0 1px var(--sidebar-active-border);
}
.nav-item.active::before {
content: ""; position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; border-radius: 3px; background: var(--fire-grad);
}
.nav-icon { width: 18px; text-align: center; font-size: 14px; flex: 0 0 18px; opacity: 0.9; }
.sidebar-footer {
margin-top: auto; padding: 14px; border-radius: 16px;
background: var(--surface-tint); border: 1px solid var(--border);
}
.sidebar-footer-head { display: flex; align-items: center; gap: 10px; }
.sidebar-footer-mark {
width: 34px; height: 34px; border-radius: 10px;
background: var(--fire-grad); color: #fff;
display: inline-flex; align-items: center; justify-content: center; font-weight: 800;
}
.sidebar-footer-title { font-size: 13.5px; font-weight: 700; color: var(--sidebar-text-strong); }
.sidebar-footer-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
.sidebar-logout {
width: 100%; justify-content: center; display: flex; align-items: center; gap: 8px;
border: 1px solid var(--border); color: var(--sidebar-text);
background: transparent; box-shadow: none; padding: 9px;
}
.sidebar-logout:hover {
border-color: rgba(255, 84, 112, 0.4); color: var(--danger);
background: var(--danger-soft); transform: none; box-shadow: none; filter: none;
}
/* ---------- main ---------- */
.main-area { min-width: 0; padding: 24px 28px 36px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
.page-header__title { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.page-title {
font-size: 28px; line-height: 1.12; font-weight: 800; letter-spacing: -0.02em;
background: var(--title-grad);
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
}
.page-subtitle { font-size: 14px; color: var(--text-soft); font-weight: 600; margin-top: 4px; }
.page-header__right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.theme-toggle {
width: 38px; height: 38px; border-radius: 11px; flex: 0 0 38px;
border: 1px solid var(--border); background: var(--surface); color: var(--text-soft);
display: inline-flex; align-items: center; justify-content: center;
font-size: 17px; cursor: pointer; transition: 0.18s; padding: 0;
box-shadow: var(--shadow-soft);
}
.theme-toggle:hover { color: var(--primary); border-color: var(--primary); transform: none; box-shadow: var(--shadow-soft); filter: none; }
.theme-toggle .ico-moon { display: none }
.theme-toggle .ico-sun { display: inline }
[data-theme="light"] .theme-toggle .ico-moon { display: inline }
[data-theme="light"] .theme-toggle .ico-sun { display: none }
.page-header__actions {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 6px; border-radius: 14px;
background: var(--panel-glass); border: 1px solid var(--border);
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
}
.page-header__actions > form { margin: 0; }
.page-header__actions button,
.page-header__actions .link-button,
.page-header__actions .ghost-button,
.page-header__actions .soft-button {
margin: 0; border-radius: 10px; padding: 9px 16px; font-size: 13.5px; font-weight: 700;
box-shadow: none; background: transparent; color: var(--text-soft);
border: 1px solid transparent; transform: none; filter: none;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.page-header__actions button:hover,
.page-header__actions .link-button:hover,
.page-header__actions .ghost-button:hover,
.page-header__actions .soft-button:hover {
transform: none; filter: none; box-shadow: none;
background: var(--primary-soft); color: var(--primary); border-color: rgba(255, 122, 61, 0.22);
}
.page-header__actions button:active { transform: none; box-shadow: none; }
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button) {
background: var(--fire-grad); color: #fff; border-color: transparent;
box-shadow: 0 8px 18px rgba(255, 77, 109, 0.32);
}
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button):hover {
filter: brightness(1.06); color: #fff; border-color: transparent;
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.40); transform: none;
}
.page-header__actions button:disabled {
background: transparent; color: var(--text-faint);
border: 1px dashed var(--border-strong); box-shadow: none; cursor: not-allowed; opacity: 1;
}
.page-body { display: grid; gap: 22px; }
.stack { display: grid; gap: 22px; }
/* ---------- panel ---------- */
.panel {
background: var(--panel-glass); border: 1px solid var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow);
backdrop-filter: var(--glass-blur); padding: 22px;
}
.section-title-row,
.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.section-title-row h2, .panel h2, .panel h3, .panel h4 { margin: 0; }
.muted { color: var(--text-soft); }
.muted.compact { margin: 6px 0 0; font-size: 13px; line-height: 1.6; }
/* ---------- stat cards (kept for send_console/logs compat) ---------- */
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
.stat-card {
min-height: 124px; border-radius: var(--radius-md);
border: 1px solid var(--border); background: var(--panel-glass);
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
padding: 20px 22px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--sidebar-active-border); }
.stat-meta { display: grid; gap: 10px; }
.stat-label { font-size: 14px; color: var(--text-soft); }
.stat-value { font-size: 28px; line-height: 1; font-weight: 800; letter-spacing: -0.03em; }
.stat-help { font-size: 13px; color: var(--text-soft); }
.stat-help.positive { color: var(--success); }
.stat-help.negative { color: var(--danger); }
.stat-icon {
width: 58px; height: 58px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 24px; font-weight: 700;
}
.stat-icon.blue { background: var(--info-soft); color: var(--info); }
.stat-icon.green { background: var(--success-soft); color: var(--success); }
.stat-icon.red { background: var(--danger-soft); color: var(--danger); }
.stat-icon.orange { background: var(--warning-soft); color: var(--warning); }
.layout-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
.overview-time { display: inline-flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 14px; font-weight: 600; }
.overview-time::before {
content: ""; width: 10px; height: 10px; border-radius: 50%;
background: var(--fire-grad); box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
}
/* ---------- pills / chips ---------- */
.status-pill, .pill, .status-chip {
display: inline-flex; align-items: center; gap: 6px;
border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 700;
color: var(--text-soft); background: var(--surface-tint); border: 1px solid var(--border);
}
.pill.soft, .status-pill.soft, .status-chip.success { color: var(--success); background: var(--success-soft); }
.pill.warning, .status-pill.warning, .status-chip.warning { color: var(--warning); background: var(--warning-soft); }
.pill.danger, .status-pill.danger, .status-chip.danger { color: var(--danger); background: var(--danger-soft); }
.status-chip.info { color: var(--info); background: var(--info-soft); }
.status-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* ---------- buttons ---------- */
button, .link-button {
appearance: none; border: none; border-radius: 12px;
background: var(--fire-grad); color: #fff; cursor: pointer;
padding: 11px 18px; font-weight: 700; letter-spacing: 0.01em;
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.26);
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
}
button:hover, .link-button:hover { transform: translateY(-1px); box-shadow: 0 16px 30px rgba(255, 77, 109, 0.36); filter: brightness(1.05); }
button:active, .link-button:active { transform: translateY(0); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.24); }
button:focus-visible, .link-button:focus-visible, .nav-item:focus-visible { outline: none; box-shadow: var(--ring); }
button:disabled { cursor: not-allowed; opacity: 0.5; transform: none; box-shadow: none; filter: grayscale(0.3); }
.ghost-button, .soft-button, .danger-button, .success-button { background: var(--surface); box-shadow: none; }
.ghost-button { color: var(--text); border: 1px solid var(--border-strong); }
.ghost-button:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); box-shadow: none; transform: none; filter: none; }
.soft-button { color: var(--primary); background: var(--primary-soft); border: 1px solid rgba(255, 122, 61, 0.20); }
.soft-button:hover { background: rgba(255, 122, 61, 0.20); box-shadow: none; transform: none; filter: none; }
.danger-button { color: var(--danger); border: 1px solid rgba(255, 84, 112, 0.26); background: var(--danger-soft); }
.danger-button:hover { background: rgba(255, 84, 112, 0.20); box-shadow: none; transform: none; filter: none; }
.success-button { color: var(--success); border: 1px solid rgba(43, 212, 127, 0.28); background: var(--success-soft); }
.success-button:hover { background: rgba(43, 212, 127, 0.20); box-shadow: none; transform: none; filter: none; }
.button-row, .button-grid { display: grid; gap: 12px; }
.button-row.two, .button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* ---------- forms ---------- */
input[type="text"], input[type="password"], input[type="search"], input[type="number"], textarea, select {
width: 100%; border: 1px solid var(--border-strong); border-radius: 12px;
background: var(--input-bg); color: var(--text); padding: 11px 13px; outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus, textarea:focus, select:focus { border-color: var(--primary); box-shadow: var(--ring); }
input::placeholder, textarea::placeholder { color: var(--text-faint); }
textarea { min-height: 96px; resize: vertical; }
.stack-form { display: grid; gap: 14px; }
.stack-form label { display: grid; gap: 8px; }
.stack-form span { font-size: 13px; color: var(--text-soft); }
.check-row { display: flex !important; align-items: center; gap: 10px; }
.check-row span { color: var(--text); }
/* ---------- flash ---------- */
.flash { border-radius: 14px; padding: 14px 16px; border: 1px solid var(--border); box-shadow: var(--shadow-soft); background: var(--surface); }
.flash.success { background: var(--success-soft); color: var(--success); border-color: rgba(43, 212, 127, 0.24); }
.flash.warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(255, 181, 71, 0.24); }
.flash.error { background: var(--danger-soft); color: var(--danger); border-color: rgba(255, 84, 112, 0.24); }
/* ---------- tables ---------- */
.table-shell { overflow: hidden; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
.table-shell.scrollable { overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--table-border); text-align: left; font-size: 13px; vertical-align: middle; }
th { background: var(--th-bg); color: var(--text-soft); font-weight: 700; letter-spacing: 0.01em; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--row-hover); }
/* ---------- code ---------- */
.code-block {
margin: 0; padding: 16px; border-radius: 14px;
background: var(--code-bg); color: #cdd8f5; overflow: auto;
font-size: 12px; line-height: 1.7;
}
.code-block.light { color: var(--text); background: var(--code-light-bg); border: 1px solid var(--border); }
.empty-state {
padding: 34px 20px; text-align: center; border-radius: 16px;
border: 1px dashed var(--border-strong); background: var(--empty-bg); color: var(--text-soft);
}
/* ---------- task banner ---------- */
.task-state-banner {
margin: 0 0 16px; padding: 12px 14px; border-radius: 12px;
border: 1px solid var(--border); font-size: 13px; font-weight: 700;
}
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
@media (max-width: 1380px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.layout-grid { grid-template-columns: 1fr; }
}
@media (max-width: 1080px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.main-area { padding: 18px 16px 28px; }
.page-header { flex-direction: column; }
.stats-grid, .button-grid, .button-row.two { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app-shell">
<div class="mobile-topbar">
<button
type="button"
class="icon-button"
data-nav-toggle
aria-label="打开导航"
>
</button>
<div class="mobile-brand">
<span class="brand-mark"></span>
<span>自动续火花</span>
</div>
<button
type="button"
class="theme-toggle"
data-theme-toggle
aria-label="切换黑夜模式"
title="切换黑夜模式"
>
<span class="theme-icon theme-sun" aria-hidden="true"></span>
<span class="theme-icon theme-moon" aria-hidden="true"></span>
</button>
</div>
<div class="sidebar-backdrop" data-nav-close></div>
<aside class="sidebar" aria-label="主导航">
<div class="brand">
<div class="brand-mark"></div>
<div>
<div class="brand-title">自动续火花</div>
<div class="brand-subtitle">多账号运维面板</div>
<aside class="sidebar">
<div>
<div class="brand">
<div class="brand-mark">🔥</div>
<div>
<div class="brand-title">续火花</div>
<div class="brand-subtitle">多账号控制平台</div>
</div>
</div>
<p class="sidebar-section-title">导航</p>
<nav class="nav-list">
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
<span class="nav-icon"></span><span>首页总览</span>
</a>
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
<span class="nav-icon"></span><span>发送控制台</span>
</a>
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
<span class="nav-icon"></span><span>发送记录</span>
</a>
</nav>
<p class="sidebar-section-title" style="margin-top:18px;">管理</p>
<nav class="nav-list">
<a class="nav-item" href="/#account-management"><span class="nav-icon"></span><span>账号管理</span></a>
<a class="nav-item" href="/#config-panel"><span class="nav-icon"></span><span>运行配置</span></a>
<a class="nav-item" href="/#interactive-login-section"><span class="nav-icon"></span><span>登录抖音账号</span></a>
<a class="nav-item" href="/#ops-panel"><span class="nav-icon"></span><span>运维操作</span></a>
<a class="nav-item" href="/#settings-panel"><span class="nav-icon"></span><span>系统设置</span></a>
</nav>
</div>
<nav class="nav-groups">
<section class="nav-group">
<p class="nav-group-title">概览</p>
<a
class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}"
href="/"
>
<span class="nav-icon"></span>
<span>控制台概览</span>
</a>
</section>
<section class="nav-group">
<p class="nav-group-title">发送</p>
<a
class="nav-item {% if current_nav|trim == 'login_workspace' %}active{% endif %}"
href="/login-workspace"
>
<span class="nav-icon"></span>
<span>登录工作区</span>
</a>
</section>
<section class="nav-group">
<p class="nav-group-title">账号</p>
<a
class="nav-item {% if current_nav|trim == 'accounts' %}active{% endif %}"
href="/accounts"
>
<span class="nav-icon"></span>
<span>账号与目标</span>
</a>
</section>
<section class="nav-group">
<p class="nav-group-title">系统</p>
<a
class="nav-item {% if current_nav|trim == 'settings' %}active{% endif %}"
href="/settings"
>
<span class="nav-icon"></span>
<span>运行与系统</span>
</a>
<a
class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}"
href="/ops/logs"
>
<span class="nav-icon"></span>
<span>运行日志</span>
</a>
</section>
</nav>
<div class="sidebar-footer">
<div class="sidebar-footer-title">运行状态</div>
<div class="sidebar-footer-subtitle">批量发送与运维调度</div>
<div class="sidebar-footer-head">
<div class="sidebar-footer-mark">{{ (current_user or "A")[:1] }}</div>
<div style="min-width:0;">
<div class="sidebar-footer-title" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ current_user or "未登录" }}</div>
<div class="sidebar-footer-subtitle">已登录管理员</div>
</div>
</div>
<form method="post" action="/logout" style="margin-top:14px;">
<button type="submit" class="sidebar-logout"><span></span><span>退出登录</span></button>
</form>
</div>
</aside>
<main class="main-area">
<header class="page-header">
<div>
<div class="eyebrow">
{% block page_eyebrow %}SparkFlow Admin{% endblock %}
</div>
<div class="page-title-row">
<h1 class="page-title">
{% block page_title %}自动续火花{% endblock %}
</h1>
<p class="page-subtitle">
{% block page_subtitle %}多账号发送控制平台{% endblock %}
</p>
<div class="page-header__title">
<div class="page-title">{% block page_title %}续火花{% endblock %}</div>
</div>
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
</div>
<div class="page-header-actions">
<button
type="button"
class="theme-toggle"
data-theme-toggle
aria-label="切换黑夜模式"
title="切换黑夜模式"
>
<span class="theme-icon theme-sun" aria-hidden="true"></span>
<span class="theme-icon theme-moon" aria-hidden="true"></span>
<div class="page-header__right">
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
<span class="ico-sun"></span><span class="ico-moon"></span>
</button>
{% block topbar_actions %}{% endblock %}
<div class="page-header__actions">
{% block topbar_actions %}{% endblock %}
</div>
</div>
</header>
<div class="page-body">
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %} {% block content %}{% endblock %}
{% endif %}
{% block content %}{% endblock %}
</div>
</main>
</div>
<script src="/static/app.js" defer></script>
{% block scripts %}{% endblock %}
<script>
(function(){
var root = document.documentElement;
var btn = document.getElementById('themeToggle');
var saved = 'dark';
try { saved = localStorage.getItem('sparkflow-theme') || 'dark'; } catch(e){}
function apply(t){ root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark'); try{ localStorage.setItem('sparkflow-theme', t); }catch(e){} }
apply(saved);
if (btn) btn.addEventListener('click', function(){
var cur = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
apply(cur === 'light' ? 'dark' : 'light');
});
})();
</script>
</body>
</html>

View File

@@ -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("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#39;");
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 %}

View File

@@ -1,71 +1,168 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 | 自动续火花控制台</title>
<link rel="stylesheet" href="/static/app.css" />
</head>
<body class="login-page">
<button
type="button"
class="theme-toggle login-theme-toggle"
data-theme-toggle
aria-label="切换黑夜模式"
title="切换黑夜模式"
>
<span class="theme-icon theme-sun" aria-hidden="true"></span>
<span class="theme-icon theme-moon" aria-hidden="true"></span>
</button>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 | 续火花控制台</title>
<style>
:root {
--primary: #ff7a3d;
--primary-strong: #ff4d6d;
--text: #eaf0ff;
--text-soft: #9aa6c8;
--border: rgba(255,255,255,0.12);
--fire-grad: linear-gradient(135deg,#ffb04a 0%,#ff7a3d 30%,#ff4d6d 65%,#ff2da8 100%);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
}
<main class="login-card">
<div class="brand login-brand">
<div class="brand-mark"></div>
/* ---- left brand panel ---- */
.brand-panel {
position: relative; overflow: hidden;
padding: 48px 52px;
color: #fff;
background:
radial-gradient(680px circle at 12% 12%, rgba(255,122,61,0.45), transparent 55%),
radial-gradient(620px circle at 95% 92%, rgba(255,45,168,0.32), transparent 50%),
linear-gradient(205deg, #1a1230 0%, #140e26 56%, #0c0a1a 100%);
display: flex; flex-direction: column; justify-content: space-between; gap: 32px;
}
.brand-panel::after {
content: ""; position: absolute; right: -120px; bottom: -120px;
width: 360px; height: 360px; border-radius: 50%;
background: radial-gradient(circle, rgba(255,77,109,0.32), transparent 68%);
pointer-events: none;
}
.brand-top { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
.brand-mark {
width: 46px; height: 46px; border-radius: 14px;
background: var(--fire-grad); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 800;
box-shadow: 0 16px 32px rgba(255,77,109,0.45);
}
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: 0.01em; }
.brand-sub { margin-top: 3px; font-size: 12.5px; color: rgba(255,255,255,0.55); }
.brand-hero { position: relative; z-index: 1; max-width: 440px; }
.brand-hero h1 {
margin: 0 0 16px; font-size: 36px; line-height: 1.18; font-weight: 800; letter-spacing: -0.02em;
}
.brand-hero h1 em {
font-style: normal;
background: linear-gradient(120deg,#ffd9a0,#ff7a3d 50%,#ff2da8);
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
}
.brand-hero p { margin: 0; font-size: 15px; line-height: 1.8; color: rgba(255,255,255,0.72); }
.brand-features { position: relative; z-index: 1; display: grid; gap: 14px; margin-top: 8px; }
.brand-feature { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: rgba(255,255,255,0.82); }
.brand-feature i {
font-style: normal; width: 26px; height: 26px; border-radius: 8px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14);
display: inline-flex; align-items: center; justify-content: center; font-size: 14px;
}
.brand-foot { position: relative; z-index: 1; font-size: 12px; color: rgba(255,255,255,0.4); }
/* ---- right form panel ---- */
.form-panel {
display: flex; align-items: center; justify-content: center; padding: 40px 28px;
background:
radial-gradient(720px circle at 100% -10%, rgba(255,122,61,0.10), transparent 45%),
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
}
.card { width: min(94vw, 420px); }
.card-title { margin: 0 0 6px; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
.card-lead { margin: 0 0 24px; color: var(--text-soft); line-height: 1.6; font-size: 14px; }
form { display: grid; gap: 15px; }
label { display: grid; gap: 7px; }
label > span { font-size: 13px; color: var(--text-soft); font-weight: 600; }
input {
width: 100%; border: 1px solid var(--border); border-radius: 12px;
padding: 12px 14px; color: var(--text); outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
background: rgba(255,255,255,0.04);
}
input::placeholder { color: var(--text-soft); }
input:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(255,122,61,0.22); }
button {
margin-top: 4px; border: none; border-radius: 12px; padding: 13px 14px;
background: var(--fire-grad); color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
box-shadow: 0 14px 28px rgba(255,77,109,0.36);
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
}
button:hover { transform: translateY(-1px); box-shadow: 0 18px 34px rgba(255,77,109,0.44); filter: brightness(1.05); }
button:active { transform: translateY(0); }
.flash { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; font-size: 13px; border: 1px solid transparent; }
.flash.success { background: rgba(43,212,127,0.14); color: #2bd47f; border-color: rgba(43,212,127,0.28); }
.flash.warning { background: rgba(255,181,71,0.14); color: #ffb547; border-color: rgba(255,181,71,0.28); }
.flash.error { background: rgba(255,84,112,0.14); color: #ff5470; border-color: rgba(255,84,112,0.28); }
.foot { margin-top: 20px; text-align: center; font-size: 12px; color: #64709a; }
@media (max-width: 900px) {
body { grid-template-columns: 1fr; }
.brand-panel { padding: 32px 28px; }
.brand-hero h1 { font-size: 26px; }
.brand-features { display: none; }
}
</style>
</head>
<body>
<aside class="brand-panel">
<div class="brand-top">
<div class="brand-mark">🔥</div>
<div>
<div class="brand-title">自动续火花</div>
<div class="brand-subtitle">多账号运维面板</div>
<div class="brand-title">续火花</div>
<div class="brand-sub">多账号控制平台</div>
</div>
</div>
<h1>控制台登录</h1>
<p class="muted">
用于管理抖音多账号、目标好友自动续火花任务与运维配置。
</p>
{% if flash %}
<div class="flash {{ flash.level }} login-flash">
{{ flash.message }}
<div class="brand-hero">
<h1>让每个好友<br><em>都不再断火花</em></h1>
<p>集中管理抖音多账号、目标好友自动续火花任务,定时发送、失败补发、登录同步一站式运维。</p>
<div class="brand-features">
<div class="brand-feature"><i></i><span>多账号批量发送与定时调度</span></div>
<div class="brand-feature"><i></i><span>失败目标自动补发与重试队列</span></div>
<div class="brand-feature"><i>🔥</i><span>网页内交互式登录与登录态同步</span></div>
</div>
</div>
<div class="brand-foot">DouYin Spark Flow · Admin Console</div>
</aside>
<main class="form-panel">
<div class="card">
<h2 class="card-title">控制台登录</h2>
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %}
{% if not bootstrapped %}
<form method="post" action="/bootstrap">
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
<label><span>管理员密码</span><input type="password" name="password"></label>
<label><span>确认密码</span><input type="password" name="confirm_password"></label>
<button type="submit">初始化管理员账号</button>
</form>
{% else %}
<form method="post" action="/login">
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
<label><span>管理员密码</span><input type="password" name="password"></label>
<button type="submit">登录控制台</button>
</form>
{% endif %}
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div>
</div>
{% endif %} {% if not bootstrapped %}
<form method="post" action="/bootstrap" class="stack-form">
<label>
<span>管理员用户名</span>
<input type="text" name="username" value="admin" />
</label>
<label>
<span>管理员密码</span>
<input type="password" name="password" />
</label>
<label>
<span>确认密码</span>
<input type="password" name="confirm_password" />
</label>
<button type="submit">初始化管理员账号</button>
</form>
{% else %}
<form method="post" action="/login" class="stack-form">
<label>
<span>管理员用户名</span>
<input type="text" name="username" value="admin" />
</label>
<label>
<span>管理员密码</span>
<input type="password" name="password" />
</label>
<button type="submit">登录控制台</button>
</form>
{% endif %}
</main>
<script src="/static/app.js" defer></script>
</body>
</html>

View File

@@ -3,19 +3,216 @@
{% block nav_key %}logs{% endblock %}
{% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %}
{% block page_title %}运行日志{% endblock %}
{% block page_subtitle %}查看最近任务输出和服务诊断信息{% endblock %}
{% block page_subtitle %}登录同步 · 发送过程 · 容器状态{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/">⌂ 返回首页</a>
<a class="ghost-button" href="/ops/send-console">✦ 发送控制台</a>
{% endblock %}
{% block content %}
<style>
.logs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.logs-toolbar__left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.logs-meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-soft);
font-size: 13px;
font-weight: 600;
}
.logs-meta .dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--fire-grad);
box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
}
.logs-meta .dot.live {
background: linear-gradient(135deg, #18a558, #4fd08a);
box-shadow: 0 0 0 4px rgba(24, 165, 88, 0.16);
animation: livePulse 1.6s ease-in-out infinite;
}
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.toggle-switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.toggle-track {
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--border-strong);
position: relative;
transition: background 0.18s ease;
flex: 0 0 42px;
}
.toggle-track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 5px rgba(20, 35, 70, 0.18);
transition: transform 0.18s ease;
}
.toggle-switch input { display: none; }
.toggle-switch input:checked + .toggle-track {
background: linear-gradient(180deg, var(--primary), var(--primary-strong));
}
.toggle-switch input:checked + .toggle-track::after {
transform: translateX(18px);
}
.logs-code {
margin: 0;
border-radius: var(--radius-md);
background: #0f1728;
color: #d8e2fb;
padding: 18px 20px;
overflow: auto;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
min-height: 420px;
max-height: 70vh;
white-space: pre-wrap;
word-break: break-word;
tab-size: 2;
}
.logs-code:empty::before {
content: "暂无日志输出";
color: #5f6f8c;
}
.logs-empty {
padding: 60px 24px;
text-align: center;
border-radius: var(--radius-md);
border: 1px dashed var(--border-strong);
background: var(--empty-bg);
color: var(--text-soft);
}
</style>
<section class="panel">
<div class="panel-header">
<div>
<h2>详细日志</h2>
<p class="muted compact">
展示最近的任务输出,便于检查登录同步、发送过程和容器状态。
</p>
<p class="muted compact">展示最近的任务输出,便于检查登录同步、发送过程和容器状态。</p>
</div>
<a class="link-button" href="/settings">返回系统设置</a>
</div>
<pre class="code-block tall">{{ log_tail or "暂无日志" }}</pre>
<div class="logs-toolbar">
<div class="logs-toolbar__left">
<span class="logs-meta"><span class="dot" id="logs-live-dot"></span><span id="logs-status-text">最近更新:刚刚</span></span>
<span class="logs-meta"><span class="dot"></span><span id="logs-line-count">{{ (log_tail|trim).splitlines()|length }} 行</span></span>
</div>
<div class="logs-toolbar__left">
<label class="toggle-switch" title="每 5 秒自动刷新日志">
<input type="checkbox" id="logs-autorefresh">
<span class="toggle-track"></span>
<span>自动刷新</span>
</label>
<button type="button" class="ghost-button" id="logs-refresh-btn">↻ 刷新</button>
<a class="ghost-button" id="logs-download-btn" href="data:text/plain;charset=utf-8,{{ log_tail|urlencode }}">⬇ 下载日志</a>
</div>
</div>
{% if log_tail and log_tail.strip() %}
<pre class="logs-code" id="logs-code">{{ log_tail }}</pre>
{% else %}
<div class="logs-empty" id="logs-empty">当前没有日志输出。任务运行后这里会实时显示。</div>
{% endif %}
</section>
<script>
(() => {
const codeEl = document.getElementById("logs-code");
const emptyEl = document.getElementById("logs-empty");
const statusEl = document.getElementById("logs-status-text");
const lineEl = document.getElementById("logs-line-count");
const liveDot = document.getElementById("logs-live-dot");
const toggle = document.getElementById("logs-autorefresh");
const refreshBtn = document.getElementById("logs-refresh-btn");
let timer = null;
const fmtTime = () => {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `最近更新:${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const renderBody = (text) => {
const has = !!(text && text.trim());
if (has) {
if (emptyEl) emptyEl.style.display = "none";
if (codeEl) {
codeEl.style.display = "";
codeEl.textContent = text;
}
if (lineEl) lineEl.textContent = `${text.trim().split(/\r?\n/).length}`;
} else {
if (codeEl) codeEl.style.display = "none";
if (emptyEl) emptyEl.style.display = "";
if (lineEl) lineEl.textContent = "0 行";
}
};
const refresh = async () => {
try {
const res = await fetch("/ops/logs", { credentials: "same-origin", headers: { "Accept": "text/html" } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, "text/html");
const fetched = doc.getElementById("logs-code");
const fetchedEmpty = doc.getElementById("logs-empty");
if (fetched) {
renderBody(fetched.textContent);
} else if (fetchedEmpty) {
renderBody("");
}
if (statusEl) statusEl.textContent = fmtTime();
} catch (err) {
if (statusEl) statusEl.textContent = `刷新失败:${err.message}`;
}
};
const setLive = (on) => {
if (liveDot) liveDot.classList.toggle("live", on);
if (timer) { clearInterval(timer); timer = null; }
if (on) {
timer = setInterval(refresh, 5000);
refresh();
}
};
if (toggle) toggle.addEventListener("change", () => setLive(toggle.checked));
if (refreshBtn) refreshBtn.addEventListener("click", refresh);
if (statusEl) statusEl.textContent = fmtTime();
})();
</script>
{% endblock %}

View File

@@ -1,96 +1,424 @@
{% extends "base.html" %}
{% block nav_key %}send_console{% endblock %}
{% block title %}发送明细 | 自动续火花{% endblock %}
{% block page_title %}发送明细{% endblock %}
{% block page_subtitle %}今日发送明细、失败队列和针对性重试{% endblock %}
{% block title %}发送控制台{% endblock %}
{% block page_title %}发送控制台{% endblock %}
{% block page_subtitle %}今日发送总览{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/">返回概览</a>
<form method="post" action="/ops/run-unsent">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="soft-button" type="submit">补发未成功目标</button>
<a class="ghost-button" href="/">返回首页</a>
<form method="post" action="/ops/run-failed">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button>
</form>
<form
method="post"
action="/ops/run-now"
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="danger-button" type="submit">补发全部对象</button>
<form method="post" action="/ops/run-unsent">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未发送目标</button>
</form>
<form method="post" action="/ops/run-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button>
</form>
{% endblock %}
{% block content %}
<style>
.task-state-banner {
margin: 0 0 16px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
font-size: 13px;
font-weight: 700;
}
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
</style>
{% set task_lock = ops.task_lock %}
{% if task_lock and task_lock.running %}
<div class="task-state-banner warning">发送任务运行中pid {{ task_lock.pid or "unknown" }},已运行约 {{ task_lock.ageSeconds }} 秒。补发按钮会等任务结束后再可用。</div>
{% elif task_lock and task_lock.staleRemoved %}
<div class="task-state-banner info">已自动清理一个过期任务锁,可以重新发起补发。</div>
{% else %}
<div class="task-state-banner success">当前没有发送任务运行。</div>
{% endif %}
{% set send_console = ops.send_console %}
<style>
.console-shell {
display: grid;
gap: 18px;
}
.console-overview {
display: grid;
gap: 18px;
}
.console-stats .stat-card {
min-height: 132px;
}
.console-kpi-icon {
font-size: 26px;
}
.console-account-list {
display: grid;
gap: 22px;
}
.account-panel {
padding: 20px 20px 22px;
border-left: 4px solid var(--border-strong);
}
.account-panel.is-success { border-left-color: var(--success); }
.account-panel.is-warning { border-left-color: var(--warning); }
.account-panel.is-danger { border-left-color: var(--danger); }
.account-progress {
display: flex;
align-items: center;
gap: 10px;
margin: -2px 0 16px;
}
.account-progress__bar {
flex: 1;
height: 8px;
border-radius: 999px;
background: var(--surface-tint);
overflow: hidden;
}
.account-progress__bar > i {
display: block;
height: 100%;
border-radius: 999px;
background: var(--fire-grad);
transition: width 0.6s ease;
}
.account-progress__label {
font-size: 13px;
font-weight: 700;
color: var(--text-soft);
white-space: nowrap;
}
.account-progress__label b { color: var(--text); }
.account-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.account-panel__meta {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.account-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--fire-grad);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
box-shadow: 0 8px 16px rgba(255, 77, 109, 0.28);
}
.account-panel__name {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.account-panel__subline {
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-soft);
font-size: 13px;
flex-wrap: wrap;
}
.account-panel__subline code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--text-faint);
background: var(--surface-tint);
padding: 2px 6px;
border-radius: 999px;
}
.account-panel__status {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.account-panel__collapse {
color: var(--text-faint);
font-size: 18px;
line-height: 1;
}
.account-alert,
.friend-scan-meta {
margin: -4px 0 18px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border);
font-size: 13px;
color: var(--text-main);
background: var(--surface-tint);
}
.account-alert {
border-color: rgba(255, 185, 70, 0.42);
background: var(--warning-soft);
}
.account-alert strong,
.friend-scan-meta strong {
display: block;
margin-bottom: 6px;
}
.account-alert__line,
.friend-scan-meta__line {
margin-top: 4px;
color: var(--text-soft);
line-height: 1.45;
word-break: break-word;
}
.account-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.data-card {
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.data-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px;
}
.data-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
}
.data-card__title i {
font-style: normal;
display: inline-flex;
width: 18px;
justify-content: center;
}
.data-card__title.success {
color: var(--success);
}
.data-card__title.danger {
color: var(--danger);
}
.data-card__title.warning {
color: var(--warning);
}
.data-card__title.info {
color: var(--info);
}
.data-card__table {
border-left: none;
border-right: none;
border-radius: 0;
}
.data-card__table td:first-child,
.data-card__table th:first-child {
min-width: 176px;
}
.data-card__table td:last-child,
.data-card__table th:last-child {
white-space: nowrap;
}
.data-card__footer {
padding: 10px 18px 14px;
display: flex;
justify-content: flex-end;
font-size: 13px;
color: var(--primary);
font-weight: 700;
}
.data-card__footer-button {
border: none;
background: transparent;
color: var(--primary);
padding: 0;
box-shadow: none;
border-radius: 0;
font-size: 13px;
font-weight: 700;
}
.data-card__footer-button:hover {
transform: none;
box-shadow: none;
text-decoration: underline;
}
.data-card__footer-button[hidden] {
display: none;
}
.data-card__extra {
display: none;
padding: 0 18px 16px;
}
.data-card__extra.is-open {
display: block;
}
.data-card__extra .table-shell {
border-radius: 14px;
}
.retry-button {
padding: 8px 12px;
border-radius: 10px;
font-size: 12px;
white-space: nowrap;
}
.console-empty {
padding: 46px 24px;
text-align: center;
color: var(--text-soft);
border-radius: 18px;
border: 1px dashed var(--border-strong);
background: var(--empty-bg);
}
.console-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
padding: 6px 0 0;
color: var(--text-soft);
font-size: 13px;
}
.console-pagination {
display: inline-flex;
align-items: center;
gap: 8px;
}
.console-page {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--border);
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-soft);
font-weight: 700;
}
.console-page.active {
background: linear-gradient(180deg, #3d79ff, #2f67ff);
color: #fff;
border-color: transparent;
box-shadow: 0 10px 18px rgba(47, 103, 255, 0.18);
}
.console-page.ghost {
width: auto;
padding: 0 12px;
}
@media (max-width: 1380px) {
.account-grid {
grid-template-columns: 1fr;
}
}
</style>
<div class="console-shell">
<section class="console-overview">
<div class="overview-time muted">
当前时间:{{ send_console.now|replace('T', ' ') }}
</div>
<div class="overview-time">当前时间:{{ send_console.now|replace('T', ' ') }}</div>
<div class="stats-grid console-stats">
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">启用账号</span>
<strong class="stat-value"
>{{ send_console.summary.enabled_accounts }}</strong
>
<span class="stat-help">当前可发送账号</span>
<span class="stat-label">总目标</span>
<strong class="stat-value">{{ send_console.summary.total_targets }}</strong>
<span class="stat-help">启用账号 {{ send_console.summary.enabled_accounts }} 个 · 今日发送账本总量</span>
</div>
<div class="stat-icon blue">A</div>
<div class="stat-icon blue"><span class="console-kpi-icon">👥</span></div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">今日成功</span>
<strong class="stat-value"
>{{ send_console.summary.today_sent_targets }}</strong
>
<span class="stat-help">写回发送历史</span>
</div>
<div
class="stat-icon {% if send_console.summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
>
<span class="stat-label">今日成功目标</span>
<strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
<span class="stat-help positive">已完成 {{ send_console.summary.today_sent_targets }}/{{ send_console.summary.total_targets }}</span>
</div>
<div class="stat-icon green"><span class="console-kpi-icon">🎯</span></div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">失败待补发</span>
<strong class="stat-value"
>{{ send_console.summary.today_failed_targets }}</strong
>
<span class="stat-help">按失败分类重试</span>
</div>
<div
class="stat-icon {% if send_console.summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
>
!
<strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
<span class="stat-help negative">按失败分类进入补发与重试</span>
</div>
<div class="stat-icon red"><span class="console-kpi-icon"></span></div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">待发送</span>
<strong class="stat-value"
>{{ send_console.summary.today_pending_targets }}</strong
>
<span class="stat-help">窗口内按计划发送</span>
<span class="stat-label">未完成目标</span>
<strong class="stat-value">{{ send_console.summary.today_remaining_targets }}</strong>
<span class="stat-help">正常待发 {{ send_console.summary.today_pending_targets + send_console.summary.today_unprocessed_targets }} 个 · 账号异常影响 {{ send_console.summary.today_account_blocked_targets or 0 }} 个</span>
</div>
<div class="stat-icon orange"></div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">未处理</span>
<strong class="stat-value"
>{{ send_console.summary.today_unprocessed_targets }}</strong
>
<span class="stat-help">等待调度</span>
</div>
<div class="stat-icon gray"></div>
<div class="stat-icon orange"><span class="console-kpi-icon"></span></div>
</article>
</div>
</section>
@@ -98,40 +426,81 @@
{% if send_console.accounts %}
<section class="console-account-list">
{% for account in send_console.accounts %}
<article class="panel account-panel">
{% set at = account.total_targets or 1 %}
{% set ad = account.sent_targets|length %}
{% set ap = (ad / at * 100)|round(0)|int %}
{% set panel_state = 'is-danger' if (account.account_paused or account.account_failure) else ('is-warning' if account.failed_targets|length else ('is-success' if ad == account.total_targets else 'is-warning')) %}
<article class="panel account-panel {{ panel_state }}">
<div class="account-panel__header">
<div class="account-panel__meta">
<div class="account-avatar">{{ (account.username or 'A')[:1] }}</div>
<div>
<div class="account-panel__name">{{ account.username }}</div>
<div class="account-panel__subline">
unique_id: {{ account.unique_id }}
<span>unique_id:</span>
<code>{{ account.unique_id }}</code>
</div>
</div>
</div>
<div class="button-row">
<span class="status-chip success"
> {{ account.sent_targets|length }}</span
>
<span class="status-chip danger"
>失败 {{ account.failed_targets|length }}</span
>
<span class="status-chip warning"
>待发送 {{ account.pending_targets|length }}</span
>
<span class="status-chip info"
>未处理 {{ account.unprocessed_targets|length }}</span
>
<div class="account-panel__status">
<span class="status-chip success">成功 {{ account.sent_targets|length }}</span>
<span class="status-chip info">成 {{ account.sent_targets|length }}/{{ account.total_targets }}</span>
<span class="status-chip danger">失败 {{ account.failed_targets|length }}</span>
<span class="status-chip warning">待发送 {{ account.pending_targets|length }}</span>
<span class="status-chip info">未处理 {{ account.unprocessed_targets|length }}</span>
{% if account.account_blocked_targets %}
<span class="status-chip warning">账号影响 {{ account.account_blocked_targets|length }}</span>
{% endif %}
{% if account.account_paused %}
<span class="status-chip danger">账号暂停</span>
{% elif account.account_failure %}
<span class="status-chip warning">账号异常</span>
{% endif %}
<span class="account-panel__collapse"></span>
</div>
</div>
{% if account.account_failure %}
<div class="account-alert">
<strong>账号级页面异常:{{ account.account_failure.category or "-" }}</strong>
<div class="account-alert__line">
今日尝试 {{ account.account_failure.attemptCount or 0 }}/{{ account.account_failure_pause_after }}
{% if account.account_paused %},已暂停今日自动发送{% endif %}
· lastAttemptAt {{ account.account_failure.lastAttemptAt or "-" }}
</div>
<div class="account-alert__line">原因:{{ account.account_failure.reason or "-" }}</div>
{% if account.account_failure.affectedTargets %}
<div class="account-alert__line">影响目标:{{ account.account_failure.affectedTargets|join(", ") }}</div>
{% endif %}
</div>
{% endif %}
{% if account.friend_index_meta.lastScanAt %}
<div class="friend-scan-meta">
<strong>
好友索引:{{ "完整" if account.friend_index_meta.lastScanComplete else "未完整" }}
· 已记录 {{ account.friend_index_count }} 个
</strong>
<div class="friend-scan-meta__line">
lastScanAt {{ account.friend_index_meta.lastScanAt }}
· scannedCount {{ account.friend_index_meta.scannedCount }}
</div>
{% if account.friend_index_meta.missingTargets %}
<div class="friend-scan-meta__line">索引扫描后仍缺失:{{ account.friend_index_meta.missingTargets|join(", ") }}</div>
{% endif %}
</div>
{% endif %}
<div class="account-progress">
<span class="account-progress__label">完成进度 <b>{{ ad }}/{{ account.total_targets }}</b> · {{ ap }}%</span>
<span class="account-progress__bar"><i style="width:{{ ap }}%"></i></span>
</div>
<div class="account-grid">
<section class="data-card">
<div class="data-card__head">
<div class="data-card__title success">
<i></i><span>今日已成功</span>
</div>
<div class="data-card__title success"><i></i><span>A. 今日已成功</span></div>
</div>
<div class="table-shell data-card__table">
<table>
@@ -150,27 +519,22 @@
<td>{{ item.sentAt or "-" }}</td>
</tr>
{% else %}
<tr>
<td colspan="3">今日暂无成功目标</td>
</tr>
<tr><td colspan="3">今日暂无成功目标</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="data-card__footer">
{% if account.sent_targets|length > 5 %}
<button
type="button"
class="data-card__footer-button"
data-expand-target="sent-{{ loop.index0 }}"
data-total-label="查看全部({{ account.sent_targets|length }}"
>
查看全部({{ account.sent_targets|length }}
</button>
{% endif %}
{% if account.sent_targets|length <= 5 %}hidden{% endif %}
>查看全部({{ account.sent_targets|length }}</button>
</div>
<div class="data-card__extra" id="sent-{{ loop.index0 }}">
<div class="table-shell">
<div class="table-shell scrollable">
<table>
<thead>
<tr>
@@ -195,11 +559,9 @@
<section class="data-card">
<div class="data-card__head">
<div class="data-card__title danger">
<i></i><span>失败待补发</span>
</div>
<div class="data-card__title danger"><i></i><span>B. 失败待补发</span></div>
</div>
<div class="table-shell data-card__table">
<div class="table-shell data-card__table scrollable">
<table>
<thead>
<tr>
@@ -218,46 +580,30 @@
<td>{{ item.reason or "-" }}</td>
<td>{{ item.attemptCount or 0 }}</td>
<td>
<form
method="post"
action="/accounts/{{ account.unique_id }}/retry-target"
>
<input
type="hidden"
name="csrf_token"
value="{{ csrf_token }}"
/>
<input
type="hidden"
name="target"
value="{{ item.target }}"
/>
<button type="submit" class="soft-button">重试</button>
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="{{ item.target }}">
<button type="submit" class="soft-button retry-button">重试此目标</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">暂无失败待补发目标</td>
</tr>
<tr><td colspan="5">暂无失败待补发目标</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="data-card__footer">
{% if account.failed_targets|length > 5 %}
<button
type="button"
class="data-card__footer-button"
data-expand-target="failed-{{ loop.index0 }}"
data-total-label="查看全部({{ account.failed_targets|length }}"
>
查看全部({{ account.failed_targets|length }}
</button>
{% endif %}
{% if account.failed_targets|length <= 5 %}hidden{% endif %}
>查看全部({{ account.failed_targets|length }}</button>
</div>
<div class="data-card__extra" id="failed-{{ loop.index0 }}">
<div class="table-shell">
<div class="table-shell scrollable">
<table>
<thead>
<tr>
@@ -276,21 +622,10 @@
<td>{{ item.reason or "-" }}</td>
<td>{{ item.attemptCount or 0 }}</td>
<td>
<form
method="post"
action="/accounts/{{ account.unique_id }}/retry-target"
>
<input
type="hidden"
name="csrf_token"
value="{{ csrf_token }}"
/>
<input
type="hidden"
name="target"
value="{{ item.target }}"
/>
<button type="submit" class="soft-button">重试</button>
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="{{ item.target }}">
<button type="submit" class="soft-button retry-button">重试此目标</button>
</form>
</td>
</tr>
@@ -303,9 +638,7 @@
<section class="data-card">
<div class="data-card__head">
<div class="data-card__title warning">
<i></i><span>今日待发送</span>
</div>
<div class="data-card__title warning"><i></i><span>C. 今日待发送</span></div>
</div>
<div class="table-shell data-card__table">
<table>
@@ -322,27 +655,22 @@
<td>{{ item.scheduledAt or "-" }}</td>
</tr>
{% else %}
<tr>
<td colspan="2">今日暂无待发送目标</td>
</tr>
<tr><td colspan="2">今日暂无待发送目标</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="data-card__footer">
{% if account.pending_targets|length > 5 %}
<button
type="button"
class="data-card__footer-button"
data-expand-target="pending-{{ loop.index0 }}"
data-total-label="查看全部({{ account.pending_targets|length }}"
>
查看全部({{ account.pending_targets|length }}
</button>
{% endif %}
{% if account.pending_targets|length <= 5 %}hidden{% endif %}
>查看全部({{ account.pending_targets|length }}</button>
</div>
<div class="data-card__extra" id="pending-{{ loop.index0 }}">
<div class="table-shell">
<div class="table-shell scrollable">
<table>
<thead>
<tr>
@@ -365,9 +693,7 @@
<section class="data-card">
<div class="data-card__head">
<div class="data-card__title info">
<i></i><span>今日未处理</span>
</div>
<div class="data-card__title info"><i></i><span>D. 今日未处理</span></div>
</div>
<div class="table-shell data-card__table">
<table>
@@ -384,27 +710,22 @@
<td>{{ item.scheduledAt or "-" }}</td>
</tr>
{% else %}
<tr>
<td colspan="2">今日暂无未处理目标</td>
</tr>
<tr><td colspan="2">今日暂无未处理目标</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="data-card__footer">
{% if account.unprocessed_targets|length > 5 %}
<button
type="button"
class="data-card__footer-button"
data-expand-target="unprocessed-{{ loop.index0 }}"
data-total-label="查看全部({{ account.unprocessed_targets|length }}"
>
查看全部({{ account.unprocessed_targets|length }}
</button>
{% endif %}
{% if account.unprocessed_targets|length <= 5 %}hidden{% endif %}
>查看全部({{ account.unprocessed_targets|length }}</button>
</div>
<div class="data-card__extra" id="unprocessed-{{ loop.index0 }}">
<div class="table-shell">
<div class="table-shell scrollable">
<table>
<thead>
<tr>
@@ -424,16 +745,89 @@
</div>
</div>
</section>
{% if account.account_blocked_targets %}
<section class="data-card">
<div class="data-card__head">
<div class="data-card__title warning"><i>!</i><span>E. 账号异常影响</span></div>
</div>
<div class="table-shell data-card__table">
<table>
<thead>
<tr>
<th>目标</th>
<th>scheduledAt</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{% for item in account.account_blocked_targets[:5] %}
<tr>
<td>{{ item.target }}</td>
<td>{{ item.scheduledAt or "-" }}</td>
<td>{{ "本次账号异常重试目标" if item.accountFailureAffected else "因账号暂停尚未处理" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="data-card__footer">
<button
type="button"
class="data-card__footer-button"
data-expand-target="blocked-{{ loop.index0 }}"
data-total-label="查看全部({{ account.account_blocked_targets|length }}"
{% if account.account_blocked_targets|length <= 5 %}hidden{% endif %}
>查看全部({{ account.account_blocked_targets|length }}</button>
</div>
<div class="data-card__extra" id="blocked-{{ loop.index0 }}">
<div class="table-shell scrollable">
<table>
<thead>
<tr>
<th>目标</th>
<th>scheduledAt</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{% for item in account.account_blocked_targets %}
<tr>
<td>{{ item.target }}</td>
<td>{{ item.scheduledAt or "-" }}</td>
<td>{{ "本次账号异常重试目标" if item.accountFailureAffected else "因账号暂停尚未处理" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
</div>
</article>
{% endfor %}
</section>
<div class="console-footer">共 {{ send_console.accounts|length }} 个账号</div>
{% else %}
<div class="console-empty empty-state">
当前没有启用账号,暂无可展示的发送明细。
<div class="console-footer">
<span>共 {{ send_console.accounts|length }} 个启用账号 · 数据按今日发送状态实时汇总</span>
</div>
{% else %}
<div class="console-empty">当前没有启用账号,暂无可展示的发送控制台数据。</div>
{% endif %}
</div>
<script>
(() => {
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.expandTarget;
if (!targetId) return;
const panel = document.getElementById(targetId);
if (!panel) return;
const isOpen = panel.classList.toggle("is-open");
button.textContent = isOpen ? "收起" : (button.getAttribute("data-total-label") || "查看全部");
});
});
})();
</script>
{% endblock %}