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
+13 -5
View File
@@ -1,5 +1,4 @@
ARG PLAYWRIGHT_BASE_IMAGE=mcr.microsoft.com/playwright/python:v1.56.0-jammy FROM mcr.microsoft.com/playwright/python:v1.56.0-jammy
FROM ${PLAYWRIGHT_BASE_IMAGE}
WORKDIR /app WORKDIR /app
@@ -26,12 +25,13 @@ ENV PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN set -eux; \ RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
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 \ && sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \ && ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone \ && echo ${TZ} > /etc/timezone \
&& apt-get update && apt-get install -y --no-install-recommends \ && apt-get update && apt-get install -y \
cron \
curl \
fluxbox \ fluxbox \
fonts-wqy-zenhei \ fonts-wqy-zenhei \
fonts-noto-cjk \ fonts-noto-cjk \
@@ -39,6 +39,14 @@ RUN set -eux; \
websockify \ websockify \
x11vnc \ x11vnc \
xfonts-intl-chinese \ 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/* && rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
+60 -6
View File
@@ -1,4 +1,5 @@
import os import os
import re
import subprocess import subprocess
import sys import sys
import traceback import traceback
@@ -12,6 +13,7 @@ from utils.config import DEBUG, Environment, get_environment
console = Console() console = Console()
PLAYWRIGHT_BROWSERS_PATH = "../chrome" PLAYWRIGHT_BROWSERS_PATH = "../chrome"
DEFAULT_PROFILE_ROOT = "/opt/douyin-sparkflow/state/browser-profiles"
def _local_browser_bundle_path(): def _local_browser_bundle_path():
@@ -32,6 +34,38 @@ def configure_playwright_environment():
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundle_path.resolve()) 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(): async def install_browser():
try: try:
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True) subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
@@ -43,15 +77,11 @@ async def install_browser():
async def get_browser(GUI=False): async def get_browser(GUI=False):
configure_playwright_environment() configure_playwright_environment()
headless = not GUI
if get_environment() == Environment.LOCAL and DEBUG:
headless = False
try: try:
playwright = await async_playwright().start() playwright = await async_playwright().start()
browser = await playwright.chromium.launch( browser = await playwright.chromium.launch(
headless=headless, headless=_headless_for(GUI),
args=["--disable-dev-shm-usage"], args=_browser_args(),
) )
return playwright, browser return playwright, browser
except Exception as exc: except Exception as exc:
@@ -61,3 +91,27 @@ async def get_browser(GUI=False):
sys.exit(1) sys.exit(1)
traceback.print_exc() traceback.print_exc()
raise 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
+23 -128
View File
@@ -1,27 +1,25 @@
import asyncio import asyncio
from pathlib import Path from pathlib import Path
import time
from core.browser import get_browser from core.browser import get_browser
CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat" CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat"
FRIENDS_TAB_SELECTORS = ( FRIENDS_TAB_SELECTOR = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]', TARGET_SELECTOR = (
'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"]/div/div[1]/div[2]/div[2]'
'xpath=//*[@id="sub-app"]//*[self::div or self::span or self::button][normalize-space()="朋友"]', '//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_SELECTOR = (
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"]/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',
) )
NO_MORE_SELECTOR = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]' NO_MORE_SELECTOR = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
LOADING_SELECTOR = 'xpath=//div[contains(@class, "semi-spin")]' 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"] 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): 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): 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: for selector in LOGIN_MASK_SELECTORS:
try: try:
locator = page.locator(selector).first locator = page.locator(selector).first
@@ -47,115 +41,12 @@ async def _ensure_logged_in(page):
continue 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): async def collect_friend_names(page):
await _open_friends_tab(page) await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
has_friends = await _wait_for_friend_name_or_empty(page) await page.locator(FRIENDS_TAB_SELECTOR).click()
if not has_friends:
return [] await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
await page.locator(FIRST_FRIEND_SELECTOR).click()
await asyncio.sleep(2) await asyncio.sleep(2)
found_names = [] found_names = []
@@ -164,8 +55,13 @@ async def collect_friend_names(page):
stuck_rounds = 0 stuck_rounds = 0
while True: while True:
target_elements = await page.locator(TARGET_SELECTOR).all()
new_names_count = 0 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: if not name or name in seen_names:
continue continue
seen_names.add(name) seen_names.add(name)
@@ -180,12 +76,11 @@ async def collect_friend_names(page):
if await loading.count() > 0 and await loading.is_visible(): if await loading.count() > 0 and await loading.is_visible():
await asyncio.sleep(1.5) 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 not scrollable_element:
if found_names: if found_names:
return found_names return found_names
diagnosis = await _page_diagnosis(page) raise RuntimeError("未找到好友列表滚动容器")
raise RuntimeError(f"未找到好友列表滚动容器;{diagnosis}")
before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element) before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element) await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)
File diff suppressed because it is too large Load Diff
+18 -2
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/.X99-lock"
rm -f "/tmp/.X11-unix/X99" 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 & Xvfb "${DISPLAY}" -screen 0 1600x1000x24 -ac +extension RANDR > /app/logs/login_desktop/xvfb.log 2>&1 &
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if [ -S /tmp/.X11-unix/X99 ]; then if [ -S /tmp/.X11-unix/X99 ]; then
@@ -25,7 +41,7 @@ for _ in $(seq 1 30); do
done done
fluxbox > /app/logs/login_desktop/fluxbox.log 2>&1 & 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 & run_forever x11vnc x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${LOGIN_DESKTOP_VNC_PORT}" -localhost -nopw &
websockify --web=/usr/share/novnc "${LOGIN_DESKTOP_WEB_PORT}" "localhost:${LOGIN_DESKTOP_VNC_PORT}" > /app/logs/login_desktop/novnc.log 2>&1 & 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 exec python /app/login_desktop_server.py
+11 -1
View File
@@ -26,6 +26,13 @@ DEFAULT_CONFIG = {
"useProtocolSender": True, "useProtocolSender": True,
"protocolDryRun": False, "protocolDryRun": False,
"browserSenderAccounts": [], "browserSenderAccounts": [],
"persistentBrowserProfiles": {
"enabled": False,
"root": "/opt/douyin-sparkflow/state/browser-profiles",
"seedCookiesWhenEmpty": True,
"syncStoredCookiesBeforeRun": True,
"refreshStoredCookiesAfterLogin": True,
},
"sendStrategy": { "sendStrategy": {
"shuffleTargets": True, "shuffleTargets": True,
"accountStartDelaySecondsMin": 0, "accountStartDelaySecondsMin": 0,
@@ -56,10 +63,13 @@ DEFAULT_APP_SETTINGS = {
"ui_host": "0.0.0.0", "ui_host": "0.0.0.0",
"ui_port": 8787, "ui_port": 8787,
"login_poll_interval_seconds": 1, "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", "proxy_refresh_script": "/opt/douyin-sparkflow/refresh_proxy.sh",
"local_login_helper_url": "http://127.0.0.1:18765", "local_login_helper_url": "http://127.0.0.1:18765",
"login_desktop_api_url": "http://127.0.0.1:18090", "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_host": "",
"server_username": "", "server_username": "",
"server_password": "", "server_password": "",
+94 -92
View File
@@ -1,6 +1,5 @@
import json import json
import logging import logging
import os
import traceback import traceback
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -17,7 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends 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 ( from utils.config import (
get_app_settings, get_app_settings,
get_config, get_config,
@@ -41,12 +40,15 @@ from webui.auth import (
verify_password, verify_password,
) )
from webui.ops import ( from webui.ops import (
TASK_ALREADY_RUNNING,
get_ops_snapshot, get_ops_snapshot,
read_log_tail, read_log_tail,
refresh_proxy, refresh_proxy,
restart_proxy, restart_proxy,
run_failed_retry_now,
run_task_now, run_task_now,
run_unsent_retry_now, run_unsent_retry_now,
task_run_lock_status,
update_daily_schedule, update_daily_schedule,
) )
@@ -133,13 +135,18 @@ def _target_sent_today(account, target_name):
def login_desktop_api_url(): def login_desktop_api_url():
settings = get_app_settings(force_reload=True) 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: 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" host = request.url.hostname or "127.0.0.1"
scheme = request.url.scheme or "http" scheme = str(settings.get("login_desktop_public_scheme") or "http").strip() or "http"
port = str(os.getenv("LOGIN_DESKTOP_PUBLIC_PORT") or "8788").strip() or "8788" 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" 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 redirect("/login")
return None 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"): def flash(request, message, level="info"):
request.session["flash"] = {"message": message, "level": level} request.session["flash"] = {"message": message, "level": level}
@@ -267,7 +266,7 @@ def create_app():
@app.post("/bootstrap") @app.post("/bootstrap")
async def bootstrap(request: Request): async def bootstrap(request: Request):
if is_bootstrapped(): if is_bootstrapped():
flash(request, "管理员账号已初始化,请直接登录。", "warning") flash(request, "Admin login is already configured.", "warning")
return redirect("/login") return redirect("/login")
form = await request.form() form = await request.form()
@@ -275,17 +274,17 @@ def create_app():
password = str(form.get("password", "")) password = str(form.get("password", ""))
confirm = str(form.get("confirm_password", "")) confirm = str(form.get("confirm_password", ""))
if not password or password != confirm: if not password or password != confirm:
flash(request, "初始化失败,请输入一致的管理员密码。", "error") flash(request, "Password setup failed. Please enter matching passwords.", "error")
return redirect("/login") return redirect("/login")
bootstrap_admin_password(password, username=username) bootstrap_admin_password(password, username=username)
flash(request, "管理员账号已创建,请登录控制台。", "success") flash(request, "Admin credentials created. Please log in.", "success")
return redirect("/login") return redirect("/login")
@app.post("/login") @app.post("/login")
async def login_action(request: Request): async def login_action(request: Request):
if not is_bootstrapped(): if not is_bootstrapped():
flash(request, "请先创建管理员密码。", "warning") flash(request, "Create the admin password first.", "warning")
return redirect("/login") return redirect("/login")
form = await request.form() form = await request.form()
@@ -293,11 +292,11 @@ def create_app():
password = str(form.get("password", "")) password = str(form.get("password", ""))
settings = get_app_settings(force_reload=True) settings = get_app_settings(force_reload=True)
if username != settings["admin_username"] or not verify_password(password, settings["admin_password_hash"]): 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") return redirect("/login")
issue_session(request, username) issue_session(request, username)
flash(request, "已登录控制台。", "success") flash(request, "Signed in successfully.", "success")
return redirect("/") return redirect("/")
@app.post("/logout") @app.post("/logout")
@@ -314,43 +313,12 @@ def create_app():
return render_template( return render_template(
request, request,
"dashboard.html", "dashboard.html",
console_context(request), {
) "flash": pop_flash(request),
"accounts": get_userData(force_reload=True),
@app.get("/login-workspace", response_class=HTMLResponse) "runtime_config": get_config(force_reload=True),
async def login_workspace_page(request: Request): "ops": get_ops_snapshot(),
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),
) )
@app.get("/ops/send-console", response_class=HTMLResponse) @app.get("/ops/send-console", response_class=HTMLResponse)
@@ -388,11 +356,11 @@ def create_app():
account["targets"] = targets account["targets"] = targets
account["enabled"] = str(form.get("enabled", "")) == "on" account["enabled"] = str(form.get("enabled", "")) == "on"
save_userData(accounts) save_userData(accounts)
flash(request, f"已更新账号 {account['username']}", "success") flash(request, f"Updated account {account['username']}.", "success")
else: else:
flash(request, "未找到账号。", "error") flash(request, "Account not found.", "error")
return redirect("/accounts") return redirect("/")
@app.post("/accounts/{unique_id}/toggle-enabled") @app.post("/accounts/{unique_id}/toggle-enabled")
async def toggle_account_enabled(request: Request, unique_id: str): async def toggle_account_enabled(request: Request, unique_id: str):
@@ -407,8 +375,8 @@ def create_app():
accounts = get_userData(force_reload=True) accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id) account = find_account(accounts, unique_id)
if not account: if not account:
flash(request, "未找到账号。", "error") flash(request, "Account not found.", "error")
return redirect("/accounts") return redirect("/")
account["enabled"] = not is_account_enabled(account) account["enabled"] = not is_account_enabled(account)
save_userData(accounts) save_userData(accounts)
@@ -417,7 +385,7 @@ def create_app():
f"{account.get('username', 'Account')}{'启用' if account['enabled'] else '停用'}自动续火花。", f"{account.get('username', 'Account')}{'启用' if account['enabled'] else '停用'}自动续火花。",
"success", "success",
) )
return redirect("/accounts") return redirect("/")
@app.post("/accounts/{unique_id}/friends/refresh") @app.post("/accounts/{unique_id}/friends/refresh")
async def refresh_account_friend_list(request: Request, unique_id: str): 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)] 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): if len(updated_accounts) != len(accounts):
save_userData(updated_accounts) save_userData(updated_accounts)
flash(request, "账号已删除。", "success") flash(request, "Account deleted.", "success")
else: else:
flash(request, "未找到账号。", "error") flash(request, "Account not found.", "error")
return redirect("/accounts") return redirect("/")
@app.post("/accounts/{unique_id}/retry-target") @app.post("/accounts/{unique_id}/retry-target")
async def retry_account_target(request: Request, unique_id: str): async def retry_account_target(request: Request, unique_id: str):
@@ -480,13 +448,18 @@ def create_app():
target_name = str(form.get("target", "")).strip() target_name = str(form.get("target", "")).strip()
if not target_name: if not target_name:
flash(request, "请选择需要重试的目标。", "error") flash(request, "Target is required for retry.", "error")
return redirect("/ops/send-console") return redirect("/ops/send-console")
accounts = get_userData(force_reload=True) accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id) account = find_account(accounts, unique_id)
if not account: 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") return redirect("/ops/send-console")
account_copy = dict(account) account_copy = dict(account)
@@ -495,18 +468,24 @@ def create_app():
config["taskCount"] = 1 config["taskCount"] = 1
try: try:
await run_browser_tasks(config, [account_copy]) with task_run_lock():
await run_browser_tasks(config, [account_copy])
except Exception as exc: 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") return redirect("/ops/send-console")
updated_account = find_account(get_userData(force_reload=True), unique_id) or {} updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name): 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: 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 {} failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
reason = str(failure_entry.get("reason") or "重试未确认发送成功。") if target_name in affected_targets:
flash(request, f"{account.get('username', '账号')} / {target_name} 重试未成功:{reason}", "error") 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") return redirect("/ops/send-console")
@app.post("/config") @app.post("/config")
@@ -572,8 +551,8 @@ def create_app():
config["happyNewYear"] = happy_new_year config["happyNewYear"] = happy_new_year
save_config(config) save_config(config)
flash(request, "运行配置已保存。", "success") flash(request, "Runtime config saved.", "success")
return redirect("/settings") return redirect("/")
@app.post("/settings") @app.post("/settings")
async def save_panel_settings(request: Request): async def save_panel_settings(request: Request):
@@ -605,12 +584,12 @@ def create_app():
confirm_password = str(form.get("confirm_password", "")) confirm_password = str(form.get("confirm_password", ""))
if new_password: if new_password:
if new_password != confirm_password: if new_password != confirm_password:
flash(request, "管理员密码未更新:两次输入不一致。", "error") flash(request, "Admin password was not updated because the confirmation did not match.", "error")
return redirect("/settings") return redirect("/")
update_admin_password(new_password) update_admin_password(new_password)
flash(request, "面板与服务设置已保存。", "success") flash(request, "Panel settings saved.", "success")
return redirect("/settings") return redirect("/")
@app.post("/ops/run-now") @app.post("/ops/run-now")
async def run_now(request: Request): async def run_now(request: Request):
@@ -623,14 +602,35 @@ def create_app():
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
pid = run_task_now() pid = run_task_now()
if pid == -1: if pid == TASK_ALREADY_RUNNING:
flash(request, "补发全部对象启动失败,请查看服务日志", "error") flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试", "warning")
elif pid == -1:
flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
else: 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") return redirect("/ops/send-console")
@app.post("/ops/run-unsent") @app.post("/ops/run-unsent")
async def run_unsent(request: Request): async def run_unsent_retry(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -640,10 +640,12 @@ def create_app():
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
pid = run_unsent_retry_now() pid = run_unsent_retry_now()
if pid == -1: if pid == TASK_ALREADY_RUNNING:
flash(request, "补发未成功目标启动失败,请查看服务日志。", "error") 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: 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") return redirect("/ops/send-console")
@app.post("/ops/proxy/refresh") @app.post("/ops/proxy/refresh")
@@ -657,8 +659,8 @@ def create_app():
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
refresh_proxy() refresh_proxy()
flash(request, "代理订阅已刷新。", "success") flash(request, "Proxy subscription refreshed.", "success")
return redirect("/settings") return redirect("/")
@app.post("/ops/proxy/restart") @app.post("/ops/proxy/restart")
async def proxy_restart(request: Request): async def proxy_restart(request: Request):
@@ -671,8 +673,8 @@ def create_app():
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
restart_proxy() restart_proxy()
flash(request, "代理容器已重启。", "success") flash(request, "Proxy container restarted.", "success")
return redirect("/settings") return redirect("/")
@app.post("/ops/schedule") @app.post("/ops/schedule")
async def save_schedule(request: Request): async def save_schedule(request: Request):
@@ -687,10 +689,10 @@ def create_app():
time_string = str(form.get("daily_schedule", "")).strip() time_string = str(form.get("daily_schedule", "")).strip()
result = update_daily_schedule(time_string) result = update_daily_schedule(time_string)
if getattr(result, "returncode", 1) == 0: if getattr(result, "returncode", 1) == 0:
flash(request, f"发送窗口已更新为 {time_string}", "success") flash(request, f"Updated the daily schedule to {time_string}.", "success")
else: else:
flash(request, f"发送窗口更新失败:{getattr(result, 'stderr', '')}", "error") flash(request, f"Failed to update the daily schedule to {time_string}: {getattr(result, 'stderr', '')}", "error")
return redirect("/settings") return redirect("/")
@app.get("/ops/logs", response_class=HTMLResponse) @app.get("/ops/logs", response_class=HTMLResponse)
async def logs_page(request: Request): async def logs_page(request: Request):
+206 -21
View File
@@ -6,6 +6,7 @@ import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
import unicodedata
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo 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__) logger = logging.getLogger(__name__)
TASK_ALREADY_RUNNING = -2
TASK_SCHEDULE_MARKERS = ( TASK_SCHEDULE_MARKERS = (
"docker compose run --rm task", "docker compose run --rm task",
"docker compose run --rm douyin", "docker compose run --rm douyin",
@@ -61,6 +64,54 @@ def compose_command(*args):
return base 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(): def build_task_run_spec():
if running_in_container(): if running_in_container():
return [sys.executable, "main.py", "--doTask"], repo_root() 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) 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"): def build_scheduled_task_command(extra_env=None, trigger_label="scheduled send"):
if running_in_container(): if running_in_container():
task_command = _with_env_prefix("python main.py --doTask", extra_env) task_command = _with_env_prefix("python main.py --doTask", extra_env)
repo_root_quoted = shlex.quote(str(repo_root())) return (
script = ( "/bin/bash -lc 'timestamp=$(date -Iseconds); "
"timestamp=$(date -Iseconds); "
f"echo \"[AUTO_TRIGGER] $timestamp {trigger_label} start\"; " 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(): if compose_file_path():
compose_root_quoted = shlex.quote(str(compose_root())) compose_root_quoted = shlex.quote(str(compose_root()))
compose_env_args = _compose_env_args(extra_env) compose_env_args = _compose_env_args(extra_env)
@@ -236,15 +289,26 @@ def get_task_container_rows():
return [] return []
def run_task_now(*, unsent_only=False): def run_task_now(*, unsent_only=False, failed_only=False):
try: 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() command, cwd = build_task_run_spec()
run_env = { run_env = {
"SPARKFLOW_MANUAL_RUN": "1", "SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "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" run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
return run_background_command( return run_background_command(
command, command,
@@ -259,6 +323,10 @@ def run_task_now(*, unsent_only=False):
return -1 return -1
def run_failed_retry_now():
return run_task_now(failed_only=True)
def run_unsent_retry_now(): def run_unsent_retry_now():
return run_task_now(unsent_only=True) return run_task_now(unsent_only=True)
@@ -283,7 +351,7 @@ def restart_proxy():
def read_log_tail(lines=200): 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(): if not log_path.exists():
return "" return ""
content = log_path.read_text(encoding="utf-8", errors="replace").splitlines() 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) schedule = parse_schedule_string(time_string)
scheduled_command = build_scheduled_task_command() scheduled_command = build_scheduled_task_command()
fallback_command = build_unsent_fallback_task_command() fallback_command = build_unsent_fallback_task_command()
log_redirect = f" >> {shlex.quote(_ops_log_file())} 2>&1"
updated = [] updated = []
for raw_line in crontab_text.splitlines(): for raw_line in crontab_text.splitlines():
@@ -360,20 +427,20 @@ def replace_douyin_cron_schedule(crontab_text, time_string):
if schedule["mode"] == "window": if schedule["mode"] == "window":
updated.append( updated.append(
f"*/{schedule['scheduleIntervalMinutes']} {schedule['startHour']}-{schedule['endHour'] - 1} * * * " 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( updated.append(
f"0 {schedule['endHour']} * * * " f"0 {schedule['endHour']} * * * "
f"{scheduled_command}{log_redirect}" f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
) )
updated.append( updated.append(
f"{schedule['scheduleIntervalMinutes']} {schedule['endHour']} * * * " f"{schedule['scheduleIntervalMinutes']} {schedule['endHour']} * * * "
f"{fallback_command}{log_redirect}" f"{fallback_command} >> /var/log/douyin-sparkflow.log 2>&1"
) )
else: else:
updated.append( updated.append(
f"{schedule['minute']} {schedule['hour']} * * * " 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()) 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() 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): def _scheduled_send_time(user, target_name, send_window, now):
window_minutes = max(1, (send_window["endHour"] - send_window["startHour"]) * 60) window_minutes = max(1, (send_window["endHour"] - send_window["startHour"]) * 60)
start_of_window = now.replace( 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): def _build_target_status(account, target_name, now, send_window):
history = dict(account.get("message_history") or {}) history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {}) failure_queue = dict(account.get("failure_queue") or {})
friend_index = _friend_index_status(account, target_name)
history_entry = history.get(target_name) or {} history_entry = history.get(target_name) or {}
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo) 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": "", "reason": "",
"attemptCount": 0, "attemptCount": 0,
"scheduledAt": "", "scheduledAt": "",
"friendIndex": friend_index,
} }
failure_entry = failure_queue.get(target_name) or {} 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 ""), "reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0), "attemptCount": int(failure_entry.get("attemptCount") or 0),
"scheduledAt": "", "scheduledAt": "",
"friendIndex": friend_index,
} }
scheduled_at = None scheduled_at = None
@@ -546,6 +680,7 @@ def _build_target_status(account, target_name, now, send_window):
"reason": "", "reason": "",
"attemptCount": 0, "attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds"), "scheduledAt": scheduled_at.isoformat(timespec="seconds"),
"friendIndex": friend_index,
} }
return { return {
@@ -558,6 +693,7 @@ def _build_target_status(account, target_name, now, send_window):
"reason": "", "reason": "",
"attemptCount": 0, "attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "", "scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
"friendIndex": friend_index,
} }
@@ -568,35 +704,83 @@ def get_send_console_snapshot():
summary = { summary = {
"enabled_accounts": len(accounts), "enabled_accounts": len(accounts),
"total_targets": 0,
"today_sent_targets": 0, "today_sent_targets": 0,
"today_failed_targets": 0, "today_failed_targets": 0,
"today_pending_targets": 0, "today_pending_targets": 0,
"today_unprocessed_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_rows = []
account_failure_pause_after = _account_failure_pause_after_attempts()
for account in accounts: 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"] sent_targets = [item for item in statuses if item["status"] == "sent"]
failed_targets = [item for item in statuses if item["status"] == "failed"] failed_targets = [item for item in statuses if item["status"] == "failed"]
pending_targets = [item for item in statuses if item["status"] == "pending"] account_failure = _account_failure_entry_today(account, now)
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"] 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_sent_targets"] += len(sent_targets)
summary["today_failed_targets"] += len(failed_targets) summary["today_failed_targets"] += len(failed_targets)
summary["today_pending_targets"] += len(pending_targets) summary["today_pending_targets"] += len(pending_targets)
summary["today_unprocessed_targets"] += len(unprocessed_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( account_rows.append(
{ {
"unique_id": str(account.get("unique_id") or ""), "unique_id": str(account.get("unique_id") or ""),
"username": account.get("username") or "", "username": account.get("username") or "",
"total_targets": len(configured_targets),
"sent_targets": sent_targets, "sent_targets": sent_targets,
"failed_targets": failed_targets, "failed_targets": failed_targets,
"pending_targets": pending_targets, "pending_targets": pending_targets,
"unprocessed_targets": unprocessed_targets, "unprocessed_targets": unprocessed_targets,
"account_blocked_targets": account_blocked_targets,
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "", "last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
"failure_queue": dict(account.get("failure_queue") or {}), "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(), "containers": get_container_status(),
"task_containers": get_task_container_rows(), "task_containers": get_task_container_rows(),
"send_console": get_send_console_snapshot(), "send_console": get_send_console_snapshot(),
"task_lock": task_run_lock_status(),
"daily_schedule": current_daily_schedule(), "daily_schedule": current_daily_schedule(),
"crontab": read_crontab(), "crontab": read_crontab(),
"log_tail": read_log_tail(120), "log_tail": read_log_tail(120),
+489 -116
View File
@@ -1,148 +1,521 @@
{% set current_nav %}{% block nav_key %}dashboard{% endblock %}{% endset %} {% set current_nav %}{% block nav_key %}dashboard{% endblock %}{% endset %}
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" data-theme="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}自动续火花{% endblock %}</title> <title>{% block title %}续火花{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css" /> <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> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<div class="mobile-topbar"> <aside class="sidebar">
<button <div>
type="button" <div class="brand">
class="icon-button" <div class="brand-mark">🔥</div>
data-nav-toggle <div>
aria-label="打开导航" <div class="brand-title">续火花</div>
> <div class="brand-subtitle">多账号控制平台</div>
</div>
</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>
</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> </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">
<div class="sidebar-footer-title">运行状态</div> <div class="sidebar-footer-head">
<div class="sidebar-footer-subtitle">批量发送与运维调度</div> <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> </div>
</aside> </aside>
<main class="main-area"> <main class="main-area">
<header class="page-header"> <header class="page-header">
<div> <div>
<div class="eyebrow"> <div class="page-header__title">
{% block page_eyebrow %}SparkFlow Admin{% endblock %} <div class="page-title">{% block page_title %}续火花{% endblock %}</div>
</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> </div>
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
</div> </div>
<div class="page-header-actions"> <div class="page-header__right">
<button <button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
type="button" <span class="ico-sun"></span><span class="ico-moon"></span>
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> </button>
{% block topbar_actions %}{% endblock %} <div class="page-header__actions">
{% block topbar_actions %}{% endblock %}
</div>
</div> </div>
</header> </header>
<div class="page-body"> <div class="page-body">
{% if flash %} {% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div> <div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %} {% block content %}{% endblock %} {% endif %}
{% block content %}{% endblock %}
</div> </div>
</main> </main>
</div> </div>
<script src="/static/app.js" defer></script>
{% block scripts %}{% endblock %} {% 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> </body>
</html> </html>
+611 -231
View File
@@ -1,262 +1,642 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block nav_key %}dashboard{% endblock %} {% block nav_key %}dashboard{% endblock %}
{% block title %}控制台概览 | 自动续火花{% endblock %} {% block title %}续火花控制台{% endblock %}
{% block page_title %}控制台概览{% endblock %} {% block page_title %}今日续火花概览{% endblock %}
{% block page_subtitle %}发送状态、账号健康和关键运维入口{% endblock %} {% block page_subtitle %}多账号发送与运营管理{% endblock %}
{% block topbar_actions %} {% 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 %} {% endblock %}
{% block content %} {% block content %}
{% set task_lock = ops.task_lock %}
{% set enabled_accounts = accounts | selectattr("enabled", "equalto", true) | list %} {% set enabled_accounts = accounts | selectattr("enabled", "equalto", true) | list %}
{% set send_summary = ops.send_console.summary %} {% set send_summary = ops.send_console.summary %}
{% set pending_total = send_summary.today_pending_targets + send_summary.today_unprocessed_targets %} {% set total_targets = send_summary.total_targets or 0 %}
{% set proxy_rows = ops.containers | selectattr("Names", "equalto", "mihomo") | list %} {% 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"> <style>
<section class="stats-grid" aria-label="今日概览"> .dash-stack { display: grid; gap: 20px; }
<article class="stat-card"> .bento { display: grid; grid-template-columns: 1.05fr 0.95fr 1fr; gap: 18px; }
<div class="stat-meta"> .card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
<span class="stat-label">启用账号</span> .card-h h3 { margin: 0; font-size: 15px; font-weight: 700; }
<strong class="stat-value">{{ enabled_accounts|length }}</strong> .card-h .tag { font-size: 11.5px; color: var(--text-faint); font-weight: 700; }
<span class="stat-help">总账号 {{ accounts|length }}</span>
</div>
<div class="stat-icon blue">A</div>
</article>
<article class="stat-card"> /* ring */
<div class="stat-meta"> .ring-card { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 260px; position: relative; overflow: hidden; }
<span class="stat-label">今日成功</span> .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; }
<strong class="stat-value" .ring { position: relative; width: 184px; height: 184px; z-index: 1; }
>{{ send_summary.today_sent_targets }}</strong .ring svg { transform: rotate(-90deg); }
> .ring .track { stroke: var(--surface-tint); }
<span class="stat-help">浏览器确认发送成功</span> .ring .fill { stroke: url(#fireGrad); stroke-linecap: round; filter: drop-shadow(0 0 10px rgba(255, 77, 109, 0.5)); }
</div> .ring-c { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
<div .ring-num { font-size: 40px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
class="stat-icon {% if send_summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}" .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; }
</div>
</article>
<article class="stat-card"> /* streak / overview card */
<div class="stat-meta"> .ov-card { display: flex; flex-direction: column; gap: 16px; min-height: 260px; }
<span class="stat-label">失败待补发</span> .ov-hero { display: flex; align-items: center; gap: 16px; }
<strong class="stat-value" .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); }
>{{ send_summary.today_failed_targets }}</strong .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; }
<span class="stat-help">进入重试队列</span> .ov-list { display: grid; gap: 10px; margin-top: auto; }
</div> .ov-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-soft); }
<div .ov-row b { color: var(--text); }
class="stat-icon {% if send_summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}" .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); }
</div>
</article>
<article class="stat-card"> /* status tiles */
<div class="stat-meta"> .tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
<span class="stat-label">待发送</span> .tile { border: 1px solid var(--border); border-radius: 16px; padding: 14px 16px; background: var(--surface-tint); position: relative; overflow: hidden; }
<strong class="stat-value" .tile::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
>{{ send_summary.today_pending_targets }}</strong .tile.s::before { background: var(--success); }
> .tile.f::before { background: var(--danger); }
<span class="stat-help">{{ ops.daily_schedule or "未配置窗口" }}</span> .tile.p::before { background: var(--warning); }
</div> .tile.u::before { background: var(--info); }
<div class="stat-icon orange"></div> .tile-lab { font-size: 12px; color: var(--text-soft); font-weight: 700; }
</article> .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"> /* section header */
<div class="stat-meta"> .sect-h { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
<span class="stat-label">未处理</span> .sect-h h2 { margin: 0; font-size: 18px; font-weight: 800; }
<strong class="stat-value" .sect-h .link { font-size: 13px; font-weight: 700; color: var(--primary); text-decoration: none; }
>{{ send_summary.today_unprocessed_targets }}</strong .sect-h .link:hover { text-decoration: underline; }
>
<span class="stat-help">等待后续调度</span>
</div>
<div class="stat-icon gray"></div>
</article>
</section>
<div class="layout-grid"> /* summary table extras */
<div class="stack"> .summary-table td:first-child strong { display: inline-block; margin-bottom: 4px; }
<section class="panel"> .summary-table td:first-child span { display: inline-block; font-size: 12px; color: var(--text-soft); }
<div class="section-title-row"> .mini-bar { height: 6px; border-radius: 999px; background: var(--surface-tint); overflow: hidden; min-width: 80px; }
<div> .mini-bar > i { display: block; height: 100%; border-radius: 999px; background: var(--fire-grad); }
<h2>今日发送摘要</h2>
<p class="muted compact"> /* 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; }
</p> .collapse-panel > summary { list-style: none; cursor: pointer; padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
</div> .collapse-panel > summary::-webkit-details-marker { display: none; }
<a class="link-button" href="/ops/send-console">查看明细</a> .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>
</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"> <div class="panel ov-card">
<section class="panel"> <div class="card-h"><h3>火花概览</h3><span class="tag">全部账号</span></div>
<div class="section-title-row"> <div class="ov-hero">
<div> <div class="ov-flame">🔥</div>
<h2>关键操作</h2> <div>
<p class="muted compact"> <div class="ov-num">{{ enabled_accounts|length }}</div>
常用入口和高风险动作分开处理,避免误触。 <div class="ov-lab">启用账号 · 共 {{ accounts|length }} 个</div>
</p> </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> </div>
<div class="link-list"> </div>
<a class="link-button" href="/login-workspace">打开登录工作区</a> </details>
<a class="ghost-button" href="/accounts">账号与目标管理</a> </section>
<a class="ghost-button" href="/settings">运行与系统设置</a>
<form method="post" action="/ops/run-unsent"> {# ---- account management ---- #}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <section class="panel" id="account-management">
<button class="soft-button" type="submit">补发未成功目标</button> <div class="sect-h"><div><h2>账号管理</h2><p class="muted compact">点击账号展开管理开关、目标好友、好友缓存、登录同步与删除。共 {{ accounts|length }} 个账号。</p></div></div>
</form> {% if accounts %}
<form <div style="display:grid;gap:14px;">
method="post" {% for account in accounts %}
action="/ops/run-now" <details class="account-details">
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?" <summary>
> <div class="account-summary">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <div class="avatar-photo">{{ account.username[:1] if account.username else "A" }}</div>
<button class="danger-button" type="submit">补发全部对象</button> <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> </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> </div>
</section> </section>
<section class="panel"> <section class="panel" id="settings-panel">
<div class="section-title-row"> <div class="sect-h"><div><h2>面板与服务设置</h2><p class="muted compact">服务连接、日志路径、交互式登录 API 与管理员配置。</p></div></div>
<div> <form method="post" action="/settings" class="stack-form">
<h2>服务状态</h2> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p class="muted compact">代理、调度和发送窗口的轻量巡检。</p> <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> <div style="display:flex;justify-content:flex-end;"><button type="submit">保存设置</button></div>
<div class="summary-list"> </form>
<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>
</section> </section>
</aside> </aside>
</div> </div>
</div> </div>
{% endblock %} {% 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 %}
+156 -59
View File
@@ -1,71 +1,168 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 | 自动续火花控制台</title> <title>登录 | 续火花控制台</title>
<link rel="stylesheet" href="/static/app.css" /> <style>
</head> :root {
<body class="login-page"> --primary: #ff7a3d;
<button --primary-strong: #ff4d6d;
type="button" --text: #eaf0ff;
class="theme-toggle login-theme-toggle" --text-soft: #9aa6c8;
data-theme-toggle --border: rgba(255,255,255,0.12);
aria-label="切换黑夜模式" --fire-grad: linear-gradient(135deg,#ffb04a 0%,#ff7a3d 30%,#ff4d6d 65%,#ff2da8 100%);
title="切换黑夜模式" }
> * { box-sizing: border-box; }
<span class="theme-icon theme-sun" aria-hidden="true"></span> html, body { margin: 0; min-height: 100%; }
<span class="theme-icon theme-moon" aria-hidden="true"></span> body {
</button> 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"> /* ---- left brand panel ---- */
<div class="brand login-brand"> .brand-panel {
<div class="brand-mark"></div> 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>
<div class="brand-title">自动续火花</div> <div class="brand-title">续火花</div>
<div class="brand-subtitle">多账号运维面板</div> <div class="brand-sub">多账号控制平台</div>
</div> </div>
</div> </div>
<h1>控制台登录</h1> <div class="brand-hero">
<p class="muted"> <h1>让每个好友<br><em>都不再断火花</em></h1>
用于管理抖音多账号、目标好友自动续火花任务与运维配置。 <p>集中管理抖音多账号、目标好友自动续火花任务,定时发送、失败补发、登录同步一站式运维。</p>
</p> <div class="brand-features">
<div class="brand-feature"><i></i><span>多账号批量发送与定时调度</span></div>
{% if flash %} <div class="brand-feature"><i></i><span>失败目标自动补发与重试队列</span></div>
<div class="flash {{ flash.level }} login-flash"> <div class="brand-feature"><i>🔥</i><span>网页内交互式登录与登录态同步</span></div>
{{ flash.message }} </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> </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> </main>
<script src="/static/app.js" defer></script>
</body> </body>
</html> </html>
+203 -6
View File
@@ -3,19 +3,216 @@
{% block nav_key %}logs{% endblock %} {% block nav_key %}logs{% endblock %}
{% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %} {% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %}
{% block page_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 %} {% 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"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>详细日志</h2> <h2>详细日志</h2>
<p class="muted compact"> <p class="muted compact">展示最近的任务输出,便于检查登录同步、发送过程和容器状态。</p>
展示最近的任务输出,便于检查登录同步、发送过程和容器状态。
</p>
</div> </div>
<a class="link-button" href="/settings">返回系统设置</a>
</div> </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> </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 %} {% endblock %}
+551 -157
View File
@@ -1,96 +1,424 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block nav_key %}send_console{% endblock %} {% block nav_key %}send_console{% endblock %}
{% block title %}发送明细 | 自动续火花{% endblock %} {% block title %}发送控制台{% endblock %}
{% block page_title %}发送明细{% endblock %} {% block page_title %}发送控制台{% endblock %}
{% block page_subtitle %}今日发送明细、失败队列和针对性重试{% endblock %} {% block page_subtitle %}今日发送总览{% endblock %}
{% block topbar_actions %} {% block topbar_actions %}
<a class="ghost-button" href="/">返回概览</a> <a class="ghost-button" href="/">返回首页</a>
<form method="post" action="/ops/run-unsent"> <form method="post" action="/ops/run-failed">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="soft-button" type="submit">补发未成功目标</button> <button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button>
</form> </form>
<form <form method="post" action="/ops/run-unsent">
method="post" <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
action="/ops/run-now" <button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未发送目标</button>
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?" </form>
> <form method="post" action="/ops/run-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="danger-button" type="submit">补发全部对象</button> <button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button>
</form> </form>
{% endblock %} {% endblock %}
{% block content %} {% 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 %} {% 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"> <div class="console-shell">
<section class="console-overview"> <section class="console-overview">
<div class="overview-time muted"> <div class="overview-time">当前时间:{{ send_console.now|replace('T', ' ') }}</div>
当前时间:{{ send_console.now|replace('T', ' ') }}
</div>
<div class="stats-grid console-stats"> <div class="stats-grid console-stats">
<article class="stat-card"> <article class="stat-card">
<div class="stat-meta"> <div class="stat-meta">
<span class="stat-label">启用账号</span> <span class="stat-label">总目标</span>
<strong class="stat-value" <strong class="stat-value">{{ send_console.summary.total_targets }}</strong>
>{{ send_console.summary.enabled_accounts }}</strong <span class="stat-help">启用账号 {{ send_console.summary.enabled_accounts }} 个 · 今日发送账本总量</span>
>
<span class="stat-help">当前可发送账号</span>
</div> </div>
<div class="stat-icon blue">A</div> <div class="stat-icon blue"><span class="console-kpi-icon">👥</span></div>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<div class="stat-meta"> <div class="stat-meta">
<span class="stat-label">今日成功</span> <span class="stat-label">今日成功目标</span>
<strong class="stat-value" <strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
>{{ send_console.summary.today_sent_targets }}</strong <span class="stat-help positive">已完成 {{ send_console.summary.today_sent_targets }}/{{ send_console.summary.total_targets }}</span>
>
<span class="stat-help">写回发送历史</span>
</div>
<div
class="stat-icon {% if send_console.summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
>
</div> </div>
<div class="stat-icon green"><span class="console-kpi-icon">🎯</span></div>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<div class="stat-meta"> <div class="stat-meta">
<span class="stat-label">失败待补发</span> <span class="stat-label">失败待补发</span>
<strong class="stat-value" <strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
>{{ send_console.summary.today_failed_targets }}</strong <span class="stat-help negative">按失败分类进入补发与重试</span>
>
<span class="stat-help">按失败分类重试</span>
</div>
<div
class="stat-icon {% if send_console.summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
>
!
</div> </div>
<div class="stat-icon red"><span class="console-kpi-icon"></span></div>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<div class="stat-meta"> <div class="stat-meta">
<span class="stat-label">待发送</span> <span class="stat-label">未完成目标</span>
<strong class="stat-value" <strong class="stat-value">{{ send_console.summary.today_remaining_targets }}</strong>
>{{ send_console.summary.today_pending_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>
>
<span class="stat-help">窗口内按计划发送</span>
</div> </div>
<div class="stat-icon orange"></div> <div class="stat-icon orange"><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_unprocessed_targets }}</strong
>
<span class="stat-help">等待调度</span>
</div>
<div class="stat-icon gray"></div>
</article> </article>
</div> </div>
</section> </section>
@@ -98,40 +426,81 @@
{% if send_console.accounts %} {% if send_console.accounts %}
<section class="console-account-list"> <section class="console-account-list">
{% for account in send_console.accounts %} {% 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__header">
<div class="account-panel__meta"> <div class="account-panel__meta">
<div class="account-avatar">{{ (account.username or 'A')[:1] }}</div> <div class="account-avatar">{{ (account.username or 'A')[:1] }}</div>
<div> <div>
<div class="account-panel__name">{{ account.username }}</div> <div class="account-panel__name">{{ account.username }}</div>
<div class="account-panel__subline"> <div class="account-panel__subline">
unique_id: {{ account.unique_id }} <span>unique_id:</span>
<code>{{ account.unique_id }}</code>
</div> </div>
</div> </div>
</div> </div>
<div class="button-row"> <div class="account-panel__status">
<span class="status-chip success" <span class="status-chip success">成功 {{ account.sent_targets|length }}</span>
>成功 {{ 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 danger" <span class="status-chip warning">待发送 {{ account.pending_targets|length }}</span>
>失败 {{ account.failed_targets|length }}</span <span class="status-chip info">未处理 {{ account.unprocessed_targets|length }}</span>
> {% if account.account_blocked_targets %}
<span class="status-chip warning" <span class="status-chip warning">账号影响 {{ account.account_blocked_targets|length }}</span>
>待发送 {{ account.pending_targets|length }}</span {% endif %}
> {% if account.account_paused %}
<span class="status-chip info" <span class="status-chip danger">账号暂停</span>
>未处理 {{ account.unprocessed_targets|length }}</span {% elif account.account_failure %}
> <span class="status-chip warning">账号异常</span>
{% endif %}
<span class="account-panel__collapse"></span>
</div> </div>
</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"> <div class="account-grid">
<section class="data-card"> <section class="data-card">
<div class="data-card__head"> <div class="data-card__head">
<div class="data-card__title success"> <div class="data-card__title success"><i></i><span>A. 今日已成功</span></div>
<i></i><span>今日已成功</span>
</div>
</div> </div>
<div class="table-shell data-card__table"> <div class="table-shell data-card__table">
<table> <table>
@@ -150,27 +519,22 @@
<td>{{ item.sentAt or "-" }}</td> <td>{{ item.sentAt or "-" }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr><td colspan="3">今日暂无成功目标</td></tr>
<td colspan="3">今日暂无成功目标</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="data-card__footer"> <div class="data-card__footer">
{% if account.sent_targets|length > 5 %}
<button <button
type="button" type="button"
class="data-card__footer-button" class="data-card__footer-button"
data-expand-target="sent-{{ loop.index0 }}" data-expand-target="sent-{{ loop.index0 }}"
data-total-label="查看全部({{ account.sent_targets|length }}" data-total-label="查看全部({{ account.sent_targets|length }}"
> {% if account.sent_targets|length <= 5 %}hidden{% endif %}
查看全部({{ account.sent_targets|length }} >查看全部({{ account.sent_targets|length }}</button>
</button>
{% endif %}
</div> </div>
<div class="data-card__extra" id="sent-{{ loop.index0 }}"> <div class="data-card__extra" id="sent-{{ loop.index0 }}">
<div class="table-shell"> <div class="table-shell scrollable">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -195,11 +559,9 @@
<section class="data-card"> <section class="data-card">
<div class="data-card__head"> <div class="data-card__head">
<div class="data-card__title danger"> <div class="data-card__title danger"><i></i><span>B. 失败待补发</span></div>
<i></i><span>失败待补发</span>
</div>
</div> </div>
<div class="table-shell data-card__table"> <div class="table-shell data-card__table scrollable">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -218,46 +580,30 @@
<td>{{ item.reason or "-" }}</td> <td>{{ item.reason or "-" }}</td>
<td>{{ item.attemptCount or 0 }}</td> <td>{{ item.attemptCount or 0 }}</td>
<td> <td>
<form <form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
method="post" <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
action="/accounts/{{ account.unique_id }}/retry-target" <input type="hidden" name="target" value="{{ item.target }}">
> <button type="submit" class="soft-button retry-button">重试此目标</button>
<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> </form>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr><td colspan="5">暂无失败待补发目标</td></tr>
<td colspan="5">暂无失败待补发目标</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="data-card__footer"> <div class="data-card__footer">
{% if account.failed_targets|length > 5 %}
<button <button
type="button" type="button"
class="data-card__footer-button" class="data-card__footer-button"
data-expand-target="failed-{{ loop.index0 }}" data-expand-target="failed-{{ loop.index0 }}"
data-total-label="查看全部({{ account.failed_targets|length }}" data-total-label="查看全部({{ account.failed_targets|length }}"
> {% if account.failed_targets|length <= 5 %}hidden{% endif %}
查看全部({{ account.failed_targets|length }} >查看全部({{ account.failed_targets|length }}</button>
</button>
{% endif %}
</div> </div>
<div class="data-card__extra" id="failed-{{ loop.index0 }}"> <div class="data-card__extra" id="failed-{{ loop.index0 }}">
<div class="table-shell"> <div class="table-shell scrollable">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -276,21 +622,10 @@
<td>{{ item.reason or "-" }}</td> <td>{{ item.reason or "-" }}</td>
<td>{{ item.attemptCount or 0 }}</td> <td>{{ item.attemptCount or 0 }}</td>
<td> <td>
<form <form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
method="post" <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
action="/accounts/{{ account.unique_id }}/retry-target" <input type="hidden" name="target" value="{{ item.target }}">
> <button type="submit" class="soft-button retry-button">重试此目标</button>
<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> </form>
</td> </td>
</tr> </tr>
@@ -303,9 +638,7 @@
<section class="data-card"> <section class="data-card">
<div class="data-card__head"> <div class="data-card__head">
<div class="data-card__title warning"> <div class="data-card__title warning"><i></i><span>C. 今日待发送</span></div>
<i></i><span>今日待发送</span>
</div>
</div> </div>
<div class="table-shell data-card__table"> <div class="table-shell data-card__table">
<table> <table>
@@ -322,27 +655,22 @@
<td>{{ item.scheduledAt or "-" }}</td> <td>{{ item.scheduledAt or "-" }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr><td colspan="2">今日暂无待发送目标</td></tr>
<td colspan="2">今日暂无待发送目标</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="data-card__footer"> <div class="data-card__footer">
{% if account.pending_targets|length > 5 %}
<button <button
type="button" type="button"
class="data-card__footer-button" class="data-card__footer-button"
data-expand-target="pending-{{ loop.index0 }}" data-expand-target="pending-{{ loop.index0 }}"
data-total-label="查看全部({{ account.pending_targets|length }}" data-total-label="查看全部({{ account.pending_targets|length }}"
> {% if account.pending_targets|length <= 5 %}hidden{% endif %}
查看全部({{ account.pending_targets|length }} >查看全部({{ account.pending_targets|length }}</button>
</button>
{% endif %}
</div> </div>
<div class="data-card__extra" id="pending-{{ loop.index0 }}"> <div class="data-card__extra" id="pending-{{ loop.index0 }}">
<div class="table-shell"> <div class="table-shell scrollable">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -365,9 +693,7 @@
<section class="data-card"> <section class="data-card">
<div class="data-card__head"> <div class="data-card__head">
<div class="data-card__title info"> <div class="data-card__title info"><i></i><span>D. 今日未处理</span></div>
<i></i><span>今日未处理</span>
</div>
</div> </div>
<div class="table-shell data-card__table"> <div class="table-shell data-card__table">
<table> <table>
@@ -384,27 +710,22 @@
<td>{{ item.scheduledAt or "-" }}</td> <td>{{ item.scheduledAt or "-" }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr><td colspan="2">今日暂无未处理目标</td></tr>
<td colspan="2">今日暂无未处理目标</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="data-card__footer"> <div class="data-card__footer">
{% if account.unprocessed_targets|length > 5 %}
<button <button
type="button" type="button"
class="data-card__footer-button" class="data-card__footer-button"
data-expand-target="unprocessed-{{ loop.index0 }}" data-expand-target="unprocessed-{{ loop.index0 }}"
data-total-label="查看全部({{ account.unprocessed_targets|length }}" data-total-label="查看全部({{ account.unprocessed_targets|length }}"
> {% if account.unprocessed_targets|length <= 5 %}hidden{% endif %}
查看全部({{ account.unprocessed_targets|length }} >查看全部({{ account.unprocessed_targets|length }}</button>
</button>
{% endif %}
</div> </div>
<div class="data-card__extra" id="unprocessed-{{ loop.index0 }}"> <div class="data-card__extra" id="unprocessed-{{ loop.index0 }}">
<div class="table-shell"> <div class="table-shell scrollable">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -424,16 +745,89 @@
</div> </div>
</div> </div>
</section> </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> </div>
</article> </article>
{% endfor %} {% endfor %}
</section> </section>
<div class="console-footer">共 {{ send_console.accounts|length }} 个账号</div> <div class="console-footer">
{% else %} <span>共 {{ send_console.accounts|length }} 个启用账号 · 数据按今日发送状态实时汇总</span>
<div class="console-empty empty-state">
当前没有启用账号,暂无可展示的发送明细。
</div> </div>
{% else %}
<div class="console-empty">当前没有启用账号,暂无可展示的发送控制台数据。</div>
{% endif %} {% endif %}
</div> </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 %} {% endblock %}