mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-07-01 04:11:26 +08:00
Sync deployed SparkFlow reliability updates
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
ARG PLAYWRIGHT_BASE_IMAGE=mcr.microsoft.com/playwright/python:v1.56.0-jammy
|
||||
FROM ${PLAYWRIGHT_BASE_IMAGE}
|
||||
FROM mcr.microsoft.com/playwright/python:v1.56.0-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -26,12 +25,13 @@ ENV PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN set -eux; \
|
||||
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||
&& echo ${TZ} > /etc/timezone \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get update && apt-get install -y \
|
||||
cron \
|
||||
curl \
|
||||
fluxbox \
|
||||
fonts-wqy-zenhei \
|
||||
fonts-noto-cjk \
|
||||
@@ -39,6 +39,14 @@ RUN set -eux; \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xfonts-intl-chinese \
|
||||
&& curl -fsSL -x http://127.0.0.1:7890 https://download.docker.com/linux/static/stable/x86_64/docker-25.0.3.tgz -o docker.tgz \
|
||||
&& tar xzvf docker.tgz \
|
||||
&& mv docker/docker /usr/bin/docker \
|
||||
&& chmod +x /usr/bin/docker \
|
||||
&& rm -rf docker docker.tgz \
|
||||
&& mkdir -p /usr/local/lib/docker/cli-plugins \
|
||||
&& curl -SL -x http://127.0.0.1:7890 https://github.com/docker/compose/releases/download/v2.24.5/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-compose \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
@@ -12,6 +13,7 @@ from utils.config import DEBUG, Environment, get_environment
|
||||
|
||||
console = Console()
|
||||
PLAYWRIGHT_BROWSERS_PATH = "../chrome"
|
||||
DEFAULT_PROFILE_ROOT = "/opt/douyin-sparkflow/state/browser-profiles"
|
||||
|
||||
|
||||
def _local_browser_bundle_path():
|
||||
@@ -32,6 +34,38 @@ def configure_playwright_environment():
|
||||
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundle_path.resolve())
|
||||
|
||||
|
||||
def _headless_for(GUI=False):
|
||||
headless = not GUI
|
||||
if get_environment() == Environment.LOCAL and DEBUG:
|
||||
headless = False
|
||||
return headless
|
||||
|
||||
|
||||
def _browser_args():
|
||||
return [
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
]
|
||||
|
||||
|
||||
def sanitize_profile_name(value):
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
raw = "unknown"
|
||||
safe = re.sub(r"[^0-9A-Za-z._-]+", "_", raw)
|
||||
safe = safe.strip("._-") or "unknown"
|
||||
return safe[:80]
|
||||
|
||||
|
||||
def browser_profile_root(root=None):
|
||||
configured = (
|
||||
root
|
||||
or os.getenv("SPARKFLOW_BROWSER_PROFILE_ROOT")
|
||||
or DEFAULT_PROFILE_ROOT
|
||||
)
|
||||
return Path(configured)
|
||||
|
||||
|
||||
async def install_browser():
|
||||
try:
|
||||
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
||||
@@ -43,15 +77,11 @@ async def install_browser():
|
||||
async def get_browser(GUI=False):
|
||||
configure_playwright_environment()
|
||||
|
||||
headless = not GUI
|
||||
if get_environment() == Environment.LOCAL and DEBUG:
|
||||
headless = False
|
||||
|
||||
try:
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=headless,
|
||||
args=["--disable-dev-shm-usage"],
|
||||
headless=_headless_for(GUI),
|
||||
args=_browser_args(),
|
||||
)
|
||||
return playwright, browser
|
||||
except Exception as exc:
|
||||
@@ -61,3 +91,27 @@ async def get_browser(GUI=False):
|
||||
sys.exit(1)
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
async def get_persistent_browser_context(profile_name, GUI=False, root=None):
|
||||
configure_playwright_environment()
|
||||
|
||||
profile_dir = browser_profile_root(root) / sanitize_profile_name(profile_name)
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
playwright = await async_playwright().start()
|
||||
context = await playwright.chromium.launch_persistent_context(
|
||||
str(profile_dir),
|
||||
headless=_headless_for(GUI),
|
||||
viewport={"width": 1600, "height": 1000},
|
||||
args=_browser_args(),
|
||||
)
|
||||
return playwright, context, profile_dir
|
||||
except Exception as exc:
|
||||
if "Executable doesn't exist" in str(exc) and get_environment() != Environment.GITHUBACTION:
|
||||
console.print("[bold red]Playwright browser is missing.[/bold red]")
|
||||
await install_browser()
|
||||
sys.exit(1)
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from core.browser import get_browser
|
||||
|
||||
|
||||
CHAT_PAGE_URL = "https://creator.douyin.com/creator-micro/data/following/chat"
|
||||
FRIENDS_TAB_SELECTORS = (
|
||||
'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]',
|
||||
'xpath=//*[@id="sub-app"]//*[self::div or self::span or self::button][contains(normalize-space(.), "朋友私信") and string-length(normalize-space(.)) <= 20]',
|
||||
'xpath=//*[@id="sub-app"]//*[self::div or self::span or self::button][normalize-space()="朋友"]',
|
||||
FRIENDS_TAB_SELECTOR = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
|
||||
TARGET_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]'
|
||||
'//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
|
||||
)
|
||||
FRIEND_NAME_SELECTOR = 'xpath=//*[@id="sub-app"]//span[contains(@class, "item-header-name-")]'
|
||||
SCROLLABLE_FRIENDS_SELECTORS = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div',
|
||||
'xpath=//*[@id="sub-app"]//ul/div',
|
||||
'xpath=//*[@id="sub-app"]//ul',
|
||||
SCROLLABLE_FRIENDS_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
|
||||
)
|
||||
NO_MORE_SELECTOR = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
|
||||
LOADING_SELECTOR = 'xpath=//div[contains(@class, "semi-spin")]'
|
||||
FIRST_FRIEND_SELECTOR = (
|
||||
'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
|
||||
)
|
||||
FRIEND_NAME_SELECTOR = """xpath=.//span[contains(@class, "item-header-name-")]"""
|
||||
LOGIN_MASK_SELECTORS = [".login-mask", ".login-guide-container", ".login-img-code-wrapper"]
|
||||
EMPTY_LIST_KEYWORDS = ("暂无", "没有", "空空", "还没有")
|
||||
LOGIN_KEYWORDS = ("扫码登录", "登录抖音", "请登录", "登录已过期", "重新登录")
|
||||
|
||||
|
||||
def update_collection_progress(new_names_count, no_more_visible, scroll_moved, idle_rounds, stuck_rounds, idle_limit=5, stuck_limit=2):
|
||||
@@ -32,10 +30,6 @@ def update_collection_progress(new_names_count, no_more_visible, scroll_moved, i
|
||||
|
||||
|
||||
async def _ensure_logged_in(page):
|
||||
current_url = page.url or ""
|
||||
if "login" in current_url or "passport" in current_url:
|
||||
raise RuntimeError("账号登录已失效,请重新扫码登录")
|
||||
|
||||
for selector in LOGIN_MASK_SELECTORS:
|
||||
try:
|
||||
locator = page.locator(selector).first
|
||||
@@ -47,115 +41,12 @@ async def _ensure_logged_in(page):
|
||||
continue
|
||||
|
||||
|
||||
async def _body_text(page, limit=600):
|
||||
try:
|
||||
text = await page.locator("body").inner_text(timeout=2000)
|
||||
except Exception:
|
||||
return ""
|
||||
return " ".join(text.split())[:limit]
|
||||
|
||||
|
||||
async def _page_diagnosis(page):
|
||||
await _ensure_logged_in(page)
|
||||
body_text = await _body_text(page)
|
||||
if any(keyword in body_text for keyword in LOGIN_KEYWORDS):
|
||||
raise RuntimeError("账号登录已失效或页面要求重新登录,请重新扫码登录")
|
||||
if any(keyword in body_text for keyword in EMPTY_LIST_KEYWORDS):
|
||||
return "页面提示当前没有可读取的朋友私信好友"
|
||||
return f"未等到好友列表。当前URL={page.url},页面提示={body_text or '空'}"
|
||||
|
||||
|
||||
async def _open_friends_tab(page):
|
||||
if await page.locator(FRIEND_NAME_SELECTOR).count() > 0:
|
||||
return
|
||||
|
||||
last_error = None
|
||||
for selector in FRIENDS_TAB_SELECTORS:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
await locator.wait_for(state="visible", timeout=10000)
|
||||
await locator.click(timeout=5000)
|
||||
await asyncio.sleep(1.5)
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError(f"未找到“朋友私信”入口,可能页面结构变化或账号未登录。最后错误:{last_error}")
|
||||
|
||||
|
||||
async def _wait_for_friend_name_or_empty(page, timeout_ms=45000):
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
while time.monotonic() < deadline:
|
||||
await _ensure_logged_in(page)
|
||||
names = page.locator(FRIEND_NAME_SELECTOR)
|
||||
if await names.count() > 0:
|
||||
first = names.first
|
||||
try:
|
||||
if await first.is_visible():
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
body_text = await _body_text(page, limit=300)
|
||||
if any(keyword in body_text for keyword in EMPTY_LIST_KEYWORDS):
|
||||
return False
|
||||
if any(keyword in body_text for keyword in LOGIN_KEYWORDS):
|
||||
raise RuntimeError("账号登录已失效或页面要求重新登录,请重新扫码登录")
|
||||
|
||||
loading = page.locator(LOADING_SELECTOR).first
|
||||
if await loading.count() > 0 and await loading.is_visible():
|
||||
await asyncio.sleep(1.5)
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
diagnosis = await _page_diagnosis(page)
|
||||
raise RuntimeError(diagnosis)
|
||||
|
||||
|
||||
async def _collect_visible_friend_names(page):
|
||||
names = []
|
||||
for raw_name in await page.locator(FRIEND_NAME_SELECTOR).all_inner_texts():
|
||||
name = raw_name.strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
async def _find_scrollable_friends_element(page):
|
||||
for selector in SCROLLABLE_FRIENDS_SELECTORS:
|
||||
try:
|
||||
handle = await page.locator(selector).first.element_handle(timeout=2000)
|
||||
if handle:
|
||||
return handle
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
handle = await page.evaluate_handle(
|
||||
"""() => {
|
||||
const firstName = document.querySelector('#sub-app span[class*="item-header-name-"]');
|
||||
let node = firstName;
|
||||
while (node && node !== document.body) {
|
||||
const style = window.getComputedStyle(node);
|
||||
const overflow = `${style.overflow} ${style.overflowY}`;
|
||||
if (node.scrollHeight > node.clientHeight + 20 && /(auto|scroll|overlay)/.test(overflow)) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return document.scrollingElement || document.documentElement;
|
||||
}"""
|
||||
)
|
||||
return handle.as_element()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def collect_friend_names(page):
|
||||
await _open_friends_tab(page)
|
||||
has_friends = await _wait_for_friend_name_or_empty(page)
|
||||
if not has_friends:
|
||||
return []
|
||||
await page.wait_for_selector(FRIENDS_TAB_SELECTOR, timeout=30000)
|
||||
await page.locator(FRIENDS_TAB_SELECTOR).click()
|
||||
|
||||
await page.wait_for_selector(FIRST_FRIEND_SELECTOR, timeout=30000)
|
||||
await page.locator(FIRST_FRIEND_SELECTOR).click()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
found_names = []
|
||||
@@ -164,8 +55,13 @@ async def collect_friend_names(page):
|
||||
stuck_rounds = 0
|
||||
|
||||
while True:
|
||||
target_elements = await page.locator(TARGET_SELECTOR).all()
|
||||
new_names_count = 0
|
||||
for name in await _collect_visible_friend_names(page):
|
||||
for element in target_elements:
|
||||
try:
|
||||
name = (await element.locator(FRIEND_NAME_SELECTOR).inner_text()).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not name or name in seen_names:
|
||||
continue
|
||||
seen_names.add(name)
|
||||
@@ -180,12 +76,11 @@ async def collect_friend_names(page):
|
||||
if await loading.count() > 0 and await loading.is_visible():
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
scrollable_element = await _find_scrollable_friends_element(page)
|
||||
scrollable_element = await page.locator(SCROLLABLE_FRIENDS_SELECTOR).element_handle()
|
||||
if not scrollable_element:
|
||||
if found_names:
|
||||
return found_names
|
||||
diagnosis = await _page_diagnosis(page)
|
||||
raise RuntimeError(f"未找到好友列表滚动容器;{diagnosis}")
|
||||
raise RuntimeError("未找到好友列表滚动容器")
|
||||
|
||||
before_top = await page.evaluate("(element) => element.scrollTop", scrollable_element)
|
||||
await page.evaluate("(element) => element.scrollTop += 800", scrollable_element)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,22 @@ pkill -f "websockify --web=/usr/share/novnc ${LOGIN_DESKTOP_WEB_PORT}" >/dev/nul
|
||||
rm -f "/tmp/.X99-lock"
|
||||
rm -f "/tmp/.X11-unix/X99"
|
||||
|
||||
run_forever() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="/app/logs/login_desktop/${name}.log"
|
||||
: > "${log_file}"
|
||||
while true; do
|
||||
printf '[%s] starting %s\n' "$(date -Is)" "${name}" >> "${log_file}"
|
||||
set +e
|
||||
"$@" >> "${log_file}" 2>&1
|
||||
local status=$?
|
||||
set -e
|
||||
printf '[%s] %s exited with status %s; restarting in 2s\n' "$(date -Is)" "${name}" "${status}" >> "${log_file}"
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
Xvfb "${DISPLAY}" -screen 0 1600x1000x24 -ac +extension RANDR > /app/logs/login_desktop/xvfb.log 2>&1 &
|
||||
for _ in $(seq 1 30); do
|
||||
if [ -S /tmp/.X11-unix/X99 ]; then
|
||||
@@ -25,7 +41,7 @@ for _ in $(seq 1 30); do
|
||||
done
|
||||
|
||||
fluxbox > /app/logs/login_desktop/fluxbox.log 2>&1 &
|
||||
x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${LOGIN_DESKTOP_VNC_PORT}" -nopw > /app/logs/login_desktop/x11vnc.log 2>&1 &
|
||||
websockify --web=/usr/share/novnc "${LOGIN_DESKTOP_WEB_PORT}" "localhost:${LOGIN_DESKTOP_VNC_PORT}" > /app/logs/login_desktop/novnc.log 2>&1 &
|
||||
run_forever x11vnc x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${LOGIN_DESKTOP_VNC_PORT}" -localhost -nopw &
|
||||
run_forever novnc websockify --web=/usr/share/novnc "${LOGIN_DESKTOP_WEB_PORT}" "127.0.0.1:${LOGIN_DESKTOP_VNC_PORT}" &
|
||||
|
||||
exec python /app/login_desktop_server.py
|
||||
|
||||
@@ -26,6 +26,13 @@ DEFAULT_CONFIG = {
|
||||
"useProtocolSender": True,
|
||||
"protocolDryRun": False,
|
||||
"browserSenderAccounts": [],
|
||||
"persistentBrowserProfiles": {
|
||||
"enabled": False,
|
||||
"root": "/opt/douyin-sparkflow/state/browser-profiles",
|
||||
"seedCookiesWhenEmpty": True,
|
||||
"syncStoredCookiesBeforeRun": True,
|
||||
"refreshStoredCookiesAfterLogin": True,
|
||||
},
|
||||
"sendStrategy": {
|
||||
"shuffleTargets": True,
|
||||
"accountStartDelaySecondsMin": 0,
|
||||
@@ -56,10 +63,13 @@ DEFAULT_APP_SETTINGS = {
|
||||
"ui_host": "0.0.0.0",
|
||||
"ui_port": 8787,
|
||||
"login_poll_interval_seconds": 1,
|
||||
"ops_log_file": "/app/logs/douyin-sparkflow.log",
|
||||
"ops_log_file": "/var/log/douyin-sparkflow.log",
|
||||
"proxy_refresh_script": "/opt/douyin-sparkflow/refresh_proxy.sh",
|
||||
"local_login_helper_url": "http://127.0.0.1:18765",
|
||||
"login_desktop_api_url": "http://127.0.0.1:18090",
|
||||
"login_desktop_public_url": "",
|
||||
"login_desktop_public_scheme": "http",
|
||||
"login_desktop_public_port": 8788,
|
||||
"server_host": "",
|
||||
"server_username": "",
|
||||
"server_password": "",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -17,7 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.friends import fetch_account_friends
|
||||
from core.tasks import run_browser_tasks
|
||||
from core.tasks import run_browser_tasks, task_run_lock
|
||||
from utils.config import (
|
||||
get_app_settings,
|
||||
get_config,
|
||||
@@ -41,12 +40,15 @@ from webui.auth import (
|
||||
verify_password,
|
||||
)
|
||||
from webui.ops import (
|
||||
TASK_ALREADY_RUNNING,
|
||||
get_ops_snapshot,
|
||||
read_log_tail,
|
||||
refresh_proxy,
|
||||
restart_proxy,
|
||||
run_failed_retry_now,
|
||||
run_task_now,
|
||||
run_unsent_retry_now,
|
||||
task_run_lock_status,
|
||||
update_daily_schedule,
|
||||
)
|
||||
|
||||
@@ -133,13 +135,18 @@ def _target_sent_today(account, target_name):
|
||||
|
||||
def login_desktop_api_url():
|
||||
settings = get_app_settings(force_reload=True)
|
||||
return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_API_URL") or settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
|
||||
return str(settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
|
||||
|
||||
|
||||
def login_desktop_public_url(request: Request) -> str:
|
||||
settings = get_app_settings(force_reload=True)
|
||||
configured_url = str(settings.get("login_desktop_public_url") or "").strip()
|
||||
if configured_url:
|
||||
return configured_url
|
||||
|
||||
host = request.url.hostname or "127.0.0.1"
|
||||
scheme = request.url.scheme or "http"
|
||||
port = str(os.getenv("LOGIN_DESKTOP_PUBLIC_PORT") or "8788").strip() or "8788"
|
||||
scheme = str(settings.get("login_desktop_public_scheme") or "http").strip() or "http"
|
||||
port = coerce_int(settings.get("login_desktop_public_port"), 8788, minimum=1)
|
||||
return f"{scheme}://{host}:{port}/vnc.html?autoconnect=1&resize=scale&view_only=0"
|
||||
|
||||
|
||||
@@ -237,14 +244,6 @@ def create_app():
|
||||
return redirect("/login")
|
||||
return None
|
||||
|
||||
def console_context(request):
|
||||
return {
|
||||
"flash": pop_flash(request),
|
||||
"accounts": get_userData(force_reload=True),
|
||||
"runtime_config": get_config(force_reload=True),
|
||||
"ops": get_ops_snapshot(),
|
||||
}
|
||||
|
||||
def flash(request, message, level="info"):
|
||||
request.session["flash"] = {"message": message, "level": level}
|
||||
|
||||
@@ -267,7 +266,7 @@ def create_app():
|
||||
@app.post("/bootstrap")
|
||||
async def bootstrap(request: Request):
|
||||
if is_bootstrapped():
|
||||
flash(request, "管理员账号已初始化,请直接登录。", "warning")
|
||||
flash(request, "Admin login is already configured.", "warning")
|
||||
return redirect("/login")
|
||||
|
||||
form = await request.form()
|
||||
@@ -275,17 +274,17 @@ def create_app():
|
||||
password = str(form.get("password", ""))
|
||||
confirm = str(form.get("confirm_password", ""))
|
||||
if not password or password != confirm:
|
||||
flash(request, "初始化失败,请输入一致的管理员密码。", "error")
|
||||
flash(request, "Password setup failed. Please enter matching passwords.", "error")
|
||||
return redirect("/login")
|
||||
|
||||
bootstrap_admin_password(password, username=username)
|
||||
flash(request, "管理员账号已创建,请登录控制台。", "success")
|
||||
flash(request, "Admin credentials created. Please log in.", "success")
|
||||
return redirect("/login")
|
||||
|
||||
@app.post("/login")
|
||||
async def login_action(request: Request):
|
||||
if not is_bootstrapped():
|
||||
flash(request, "请先创建管理员密码。", "warning")
|
||||
flash(request, "Create the admin password first.", "warning")
|
||||
return redirect("/login")
|
||||
|
||||
form = await request.form()
|
||||
@@ -293,11 +292,11 @@ def create_app():
|
||||
password = str(form.get("password", ""))
|
||||
settings = get_app_settings(force_reload=True)
|
||||
if username != settings["admin_username"] or not verify_password(password, settings["admin_password_hash"]):
|
||||
flash(request, "用户名或密码不正确。", "error")
|
||||
flash(request, "Invalid username or password.", "error")
|
||||
return redirect("/login")
|
||||
|
||||
issue_session(request, username)
|
||||
flash(request, "已登录控制台。", "success")
|
||||
flash(request, "Signed in successfully.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/logout")
|
||||
@@ -314,43 +313,12 @@ def create_app():
|
||||
return render_template(
|
||||
request,
|
||||
"dashboard.html",
|
||||
console_context(request),
|
||||
)
|
||||
|
||||
@app.get("/login-workspace", response_class=HTMLResponse)
|
||||
async def login_workspace_page(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"login_workspace.html",
|
||||
console_context(request),
|
||||
)
|
||||
|
||||
@app.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"accounts.html",
|
||||
console_context(request),
|
||||
)
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"settings.html",
|
||||
console_context(request),
|
||||
{
|
||||
"flash": pop_flash(request),
|
||||
"accounts": get_userData(force_reload=True),
|
||||
"runtime_config": get_config(force_reload=True),
|
||||
"ops": get_ops_snapshot(),
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/ops/send-console", response_class=HTMLResponse)
|
||||
@@ -388,11 +356,11 @@ def create_app():
|
||||
account["targets"] = targets
|
||||
account["enabled"] = str(form.get("enabled", "")) == "on"
|
||||
save_userData(accounts)
|
||||
flash(request, f"已更新账号 {account['username']}。", "success")
|
||||
flash(request, f"Updated account {account['username']}.", "success")
|
||||
else:
|
||||
flash(request, "未找到账号。", "error")
|
||||
flash(request, "Account not found.", "error")
|
||||
|
||||
return redirect("/accounts")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/accounts/{unique_id}/toggle-enabled")
|
||||
async def toggle_account_enabled(request: Request, unique_id: str):
|
||||
@@ -407,8 +375,8 @@ def create_app():
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if not account:
|
||||
flash(request, "未找到账号。", "error")
|
||||
return redirect("/accounts")
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/")
|
||||
|
||||
account["enabled"] = not is_account_enabled(account)
|
||||
save_userData(accounts)
|
||||
@@ -417,7 +385,7 @@ def create_app():
|
||||
f"{account.get('username', 'Account')} 已{'启用' if account['enabled'] else '停用'}自动续火花。",
|
||||
"success",
|
||||
)
|
||||
return redirect("/accounts")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/accounts/{unique_id}/friends/refresh")
|
||||
async def refresh_account_friend_list(request: Request, unique_id: str):
|
||||
@@ -463,10 +431,10 @@ def create_app():
|
||||
updated_accounts = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalize_unique_id(unique_id)]
|
||||
if len(updated_accounts) != len(accounts):
|
||||
save_userData(updated_accounts)
|
||||
flash(request, "账号已删除。", "success")
|
||||
flash(request, "Account deleted.", "success")
|
||||
else:
|
||||
flash(request, "未找到账号。", "error")
|
||||
return redirect("/accounts")
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/accounts/{unique_id}/retry-target")
|
||||
async def retry_account_target(request: Request, unique_id: str):
|
||||
@@ -480,13 +448,18 @@ def create_app():
|
||||
|
||||
target_name = str(form.get("target", "")).strip()
|
||||
if not target_name:
|
||||
flash(request, "请选择需要重试的目标。", "error")
|
||||
flash(request, "Target is required for retry.", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
accounts = get_userData(force_reload=True)
|
||||
account = find_account(accounts, unique_id)
|
||||
if not account:
|
||||
flash(request, "未找到账号。", "error")
|
||||
flash(request, "Account not found.", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
lock_status = task_run_lock_status()
|
||||
if lock_status.get("running"):
|
||||
flash(request, "已有发送任务正在运行,本次单目标重试没有启动。请等当前任务结束后再试。", "warning")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
account_copy = dict(account)
|
||||
@@ -495,18 +468,24 @@ def create_app():
|
||||
config["taskCount"] = 1
|
||||
|
||||
try:
|
||||
await run_browser_tasks(config, [account_copy])
|
||||
with task_run_lock():
|
||||
await run_browser_tasks(config, [account_copy])
|
||||
except Exception as exc:
|
||||
flash(request, f"{account.get('username', '账号')} / {target_name} 重试失败:{exc}", "error")
|
||||
flash(request, f"Retry failed for {account.get('username', 'Account')} / {target_name}: {exc}", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
|
||||
if _target_sent_today(updated_account, target_name):
|
||||
flash(request, f"{account.get('username', '账号')} / {target_name} 已重试成功。", "success")
|
||||
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
|
||||
else:
|
||||
account_failure = dict(updated_account.get("account_failure") or {})
|
||||
affected_targets = list(account_failure.get("affectedTargets") or [])
|
||||
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
|
||||
reason = str(failure_entry.get("reason") or "重试未确认发送成功。")
|
||||
flash(request, f"{account.get('username', '账号')} / {target_name} 重试未成功:{reason}", "error")
|
||||
if target_name in affected_targets:
|
||||
reason = str(account_failure.get("reason") or "Account-level browser failure.")
|
||||
else:
|
||||
reason = str(failure_entry.get("reason") or "Retry did not confirm a successful send.")
|
||||
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/config")
|
||||
@@ -572,8 +551,8 @@ def create_app():
|
||||
config["happyNewYear"] = happy_new_year
|
||||
save_config(config)
|
||||
|
||||
flash(request, "运行配置已保存。", "success")
|
||||
return redirect("/settings")
|
||||
flash(request, "Runtime config saved.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/settings")
|
||||
async def save_panel_settings(request: Request):
|
||||
@@ -605,12 +584,12 @@ def create_app():
|
||||
confirm_password = str(form.get("confirm_password", ""))
|
||||
if new_password:
|
||||
if new_password != confirm_password:
|
||||
flash(request, "管理员密码未更新:两次输入不一致。", "error")
|
||||
return redirect("/settings")
|
||||
flash(request, "Admin password was not updated because the confirmation did not match.", "error")
|
||||
return redirect("/")
|
||||
update_admin_password(new_password)
|
||||
|
||||
flash(request, "面板与服务设置已保存。", "success")
|
||||
return redirect("/settings")
|
||||
flash(request, "Panel settings saved.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/run-now")
|
||||
async def run_now(request: Request):
|
||||
@@ -623,14 +602,35 @@ def create_app():
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
pid = run_task_now()
|
||||
if pid == -1:
|
||||
flash(request, "补发全部对象启动失败,请查看服务日志。", "error")
|
||||
if pid == TASK_ALREADY_RUNNING:
|
||||
flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning")
|
||||
elif pid == -1:
|
||||
flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
|
||||
else:
|
||||
flash(request, f"已启动补发全部对象任务(pid {pid})。", "success")
|
||||
flash(request, f"已启动补发全部对象后台任务(pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/run-failed")
|
||||
async def run_failed_retry(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
|
||||
form = await request.form()
|
||||
if not validate_csrf(request, str(form.get("csrf_token", ""))):
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
pid = run_failed_retry_now()
|
||||
if pid == TASK_ALREADY_RUNNING:
|
||||
flash(request, "已有发送任务正在运行,本次补发未成功目标没有启动。请等当前任务结束后再试。", "warning")
|
||||
elif pid == -1:
|
||||
flash(request, "Failed to start the failed-target retry run. Check server logs for details.", "error")
|
||||
else:
|
||||
flash(request, f"已启动补发未成功目标后台任务(pid {pid})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/ops/run-unsent")
|
||||
async def run_unsent(request: Request):
|
||||
async def run_unsent_retry(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
@@ -640,10 +640,12 @@ def create_app():
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
pid = run_unsent_retry_now()
|
||||
if pid == -1:
|
||||
flash(request, "补发未成功目标启动失败,请查看服务日志。", "error")
|
||||
if pid == TASK_ALREADY_RUNNING:
|
||||
flash(request, "A send task is already running; unsent retry was not started.", "warning")
|
||||
elif pid == -1:
|
||||
flash(request, "Failed to start the unsent-target retry run. Check server logs for details.", "error")
|
||||
else:
|
||||
flash(request, f"已启动补发未成功目标任务(pid {pid})。", "success")
|
||||
flash(request, f"Started unsent-target retry background task (pid {pid}). Refresh the send console for results.", "info")
|
||||
return redirect("/ops/send-console")
|
||||
|
||||
@app.post("/ops/proxy/refresh")
|
||||
@@ -657,8 +659,8 @@ def create_app():
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
refresh_proxy()
|
||||
flash(request, "代理订阅已刷新。", "success")
|
||||
return redirect("/settings")
|
||||
flash(request, "Proxy subscription refreshed.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/proxy/restart")
|
||||
async def proxy_restart(request: Request):
|
||||
@@ -671,8 +673,8 @@ def create_app():
|
||||
return Response("Invalid CSRF token", status_code=403)
|
||||
|
||||
restart_proxy()
|
||||
flash(request, "代理容器已重启。", "success")
|
||||
return redirect("/settings")
|
||||
flash(request, "Proxy container restarted.", "success")
|
||||
return redirect("/")
|
||||
|
||||
@app.post("/ops/schedule")
|
||||
async def save_schedule(request: Request):
|
||||
@@ -687,10 +689,10 @@ def create_app():
|
||||
time_string = str(form.get("daily_schedule", "")).strip()
|
||||
result = update_daily_schedule(time_string)
|
||||
if getattr(result, "returncode", 1) == 0:
|
||||
flash(request, f"发送窗口已更新为 {time_string}。", "success")
|
||||
flash(request, f"Updated the daily schedule to {time_string}.", "success")
|
||||
else:
|
||||
flash(request, f"发送窗口更新失败:{getattr(result, 'stderr', '')}", "error")
|
||||
return redirect("/settings")
|
||||
flash(request, f"Failed to update the daily schedule to {time_string}: {getattr(result, 'stderr', '')}", "error")
|
||||
return redirect("/")
|
||||
|
||||
@app.get("/ops/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -14,6 +15,8 @@ from utils.config import get_app_settings, get_config, get_userData, normalize_u
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TASK_ALREADY_RUNNING = -2
|
||||
|
||||
TASK_SCHEDULE_MARKERS = (
|
||||
"docker compose run --rm task",
|
||||
"docker compose run --rm douyin",
|
||||
@@ -61,6 +64,54 @@ def compose_command(*args):
|
||||
return base
|
||||
|
||||
|
||||
def _pid_is_alive(pid):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _parse_lock_pid(raw):
|
||||
try:
|
||||
return int(str(raw or "").strip().splitlines()[0])
|
||||
except (IndexError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def task_run_lock_status():
|
||||
lock_path = repo_root() / "logs" / "task.run.lock"
|
||||
if not lock_path.exists():
|
||||
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": 0, "staleRemoved": False}
|
||||
|
||||
raw = lock_path.read_text(encoding="utf-8", errors="ignore")
|
||||
pid = _parse_lock_pid(raw)
|
||||
try:
|
||||
age_seconds = max(0, int(datetime.now(timezone.utc).timestamp() - lock_path.stat().st_mtime))
|
||||
except OSError:
|
||||
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": 0, "staleRemoved": False}
|
||||
|
||||
if pid is not None and not _pid_is_alive(pid):
|
||||
try:
|
||||
lock_path.unlink()
|
||||
logger.warning("Removed stale task run lock owned by missing pid=%s", pid)
|
||||
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
except FileNotFoundError:
|
||||
return {"running": False, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
|
||||
if pid is None and age_seconds > 7200:
|
||||
try:
|
||||
lock_path.unlink()
|
||||
logger.warning("Removed stale unreadable task run lock contents=%r", raw[:80])
|
||||
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
except FileNotFoundError:
|
||||
return {"running": False, "path": str(lock_path), "pid": None, "ageSeconds": age_seconds, "staleRemoved": True}
|
||||
|
||||
return {"running": True, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": False}
|
||||
|
||||
|
||||
def build_task_run_spec():
|
||||
if running_in_container():
|
||||
return [sys.executable, "main.py", "--doTask"], repo_root()
|
||||
@@ -88,20 +139,22 @@ def _compose_env_args(extra_env=None):
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
|
||||
def _ops_log_file():
|
||||
return str(get_app_settings().get("ops_log_file") or "/app/logs/douyin-sparkflow.log")
|
||||
|
||||
|
||||
def build_scheduled_task_command(extra_env=None, trigger_label="scheduled send"):
|
||||
if running_in_container():
|
||||
task_command = _with_env_prefix("python main.py --doTask", extra_env)
|
||||
repo_root_quoted = shlex.quote(str(repo_root()))
|
||||
script = (
|
||||
"timestamp=$(date -Iseconds); "
|
||||
return (
|
||||
"/bin/bash -lc 'timestamp=$(date -Iseconds); "
|
||||
f"echo \"[AUTO_TRIGGER] $timestamp {trigger_label} start\"; "
|
||||
f"cd {repo_root_quoted} && {task_command}"
|
||||
"container=$(docker ps --format \"{{.Names}}\" | "
|
||||
"grep -E \"^(douyin-web-hostfix|douyin-web)$\" | head -n 1); "
|
||||
"if [ -z \"$container\" ]; then "
|
||||
"echo \"[AUTO_TRIGGER] $timestamp no matching container found\"; "
|
||||
"exit 1; "
|
||||
"fi; "
|
||||
"echo \"[AUTO_TRIGGER] $timestamp container=$container\"; "
|
||||
"docker exec \"$container\" sh -lc "
|
||||
f"\"cd /app && {task_command}\"'"
|
||||
)
|
||||
return f"/bin/bash -lc {shlex.quote(script)}"
|
||||
if compose_file_path():
|
||||
compose_root_quoted = shlex.quote(str(compose_root()))
|
||||
compose_env_args = _compose_env_args(extra_env)
|
||||
@@ -236,15 +289,26 @@ def get_task_container_rows():
|
||||
return []
|
||||
|
||||
|
||||
def run_task_now(*, unsent_only=False):
|
||||
def run_task_now(*, unsent_only=False, failed_only=False):
|
||||
try:
|
||||
log_file = Path(_ops_log_file())
|
||||
lock_status = task_run_lock_status()
|
||||
if lock_status.get("running"):
|
||||
logger.info(
|
||||
"Refusing to start manual task because task lock is active pid=%s age=%ss",
|
||||
lock_status.get("pid"),
|
||||
lock_status.get("ageSeconds"),
|
||||
)
|
||||
return TASK_ALREADY_RUNNING
|
||||
|
||||
log_file = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
|
||||
command, cwd = build_task_run_spec()
|
||||
run_env = {
|
||||
"SPARKFLOW_MANUAL_RUN": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
if unsent_only:
|
||||
if failed_only:
|
||||
run_env["SPARKFLOW_MANUAL_FAILED_ONLY"] = "1"
|
||||
elif unsent_only:
|
||||
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
|
||||
return run_background_command(
|
||||
command,
|
||||
@@ -259,6 +323,10 @@ def run_task_now(*, unsent_only=False):
|
||||
return -1
|
||||
|
||||
|
||||
def run_failed_retry_now():
|
||||
return run_task_now(failed_only=True)
|
||||
|
||||
|
||||
def run_unsent_retry_now():
|
||||
return run_task_now(unsent_only=True)
|
||||
|
||||
@@ -283,7 +351,7 @@ def restart_proxy():
|
||||
|
||||
|
||||
def read_log_tail(lines=200):
|
||||
log_path = Path(_ops_log_file())
|
||||
log_path = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
|
||||
if not log_path.exists():
|
||||
return ""
|
||||
content = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
@@ -348,7 +416,6 @@ def replace_douyin_cron_schedule(crontab_text, time_string):
|
||||
schedule = parse_schedule_string(time_string)
|
||||
scheduled_command = build_scheduled_task_command()
|
||||
fallback_command = build_unsent_fallback_task_command()
|
||||
log_redirect = f" >> {shlex.quote(_ops_log_file())} 2>&1"
|
||||
updated = []
|
||||
|
||||
for raw_line in crontab_text.splitlines():
|
||||
@@ -360,20 +427,20 @@ def replace_douyin_cron_schedule(crontab_text, time_string):
|
||||
if schedule["mode"] == "window":
|
||||
updated.append(
|
||||
f"*/{schedule['scheduleIntervalMinutes']} {schedule['startHour']}-{schedule['endHour'] - 1} * * * "
|
||||
f"{scheduled_command}{log_redirect}"
|
||||
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
updated.append(
|
||||
f"0 {schedule['endHour']} * * * "
|
||||
f"{scheduled_command}{log_redirect}"
|
||||
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
updated.append(
|
||||
f"{schedule['scheduleIntervalMinutes']} {schedule['endHour']} * * * "
|
||||
f"{fallback_command}{log_redirect}"
|
||||
f"{fallback_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
else:
|
||||
updated.append(
|
||||
f"{schedule['minute']} {schedule['hour']} * * * "
|
||||
f"{scheduled_command}{log_redirect}"
|
||||
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
|
||||
)
|
||||
|
||||
normalized = "\n".join(line for line in updated if line.strip())
|
||||
@@ -484,6 +551,70 @@ def _account_identity(user):
|
||||
return str(user.get("unique_id") or user.get("username") or "unknown").strip()
|
||||
|
||||
|
||||
def _coerce_attempt_count(entry):
|
||||
try:
|
||||
return int(dict(entry or {}).get("attemptCount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _account_failure_pause_after_attempts():
|
||||
raw_value = str(os.getenv("SPARKFLOW_ACCOUNT_FAILURE_PAUSE_AFTER_ATTEMPTS") or "2").strip()
|
||||
try:
|
||||
return max(1, int(raw_value))
|
||||
except ValueError:
|
||||
return 2
|
||||
|
||||
|
||||
def _account_failure_entry_today(account, now):
|
||||
entry = dict(account.get("account_failure") or {})
|
||||
last_attempt_at = _parse_sent_at(entry.get("lastAttemptAt"), now.tzinfo)
|
||||
if last_attempt_at and last_attempt_at.date() == now.date():
|
||||
entry["lastAttemptAt"] = last_attempt_at.isoformat(timespec="seconds")
|
||||
first_attempt_at = _parse_sent_at(entry.get("firstAttemptAt"), now.tzinfo)
|
||||
if first_attempt_at:
|
||||
entry["firstAttemptAt"] = first_attempt_at.isoformat(timespec="seconds")
|
||||
entry["attemptCount"] = _coerce_attempt_count(entry)
|
||||
entry["affectedTargets"] = list(entry.get("affectedTargets") or [])
|
||||
return entry
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_friend_index_key(value):
|
||||
raw = unicodedata.normalize("NFKC", str(value or ""))
|
||||
for token in ("\u200b", "\u200c", "\u200d", "\ufeff"):
|
||||
raw = raw.replace(token, "")
|
||||
raw = raw.replace("\xa0", " ")
|
||||
return " ".join(raw.split()).strip()
|
||||
|
||||
|
||||
def _friend_index_status(account, target_name):
|
||||
friend_index = dict(account.get("friend_index") or {})
|
||||
entry = dict(friend_index.get(_normalize_friend_index_key(target_name)) or {})
|
||||
return {
|
||||
"seen": bool(entry),
|
||||
"visibleName": str(entry.get("visibleName") or ""),
|
||||
"stableKeys": list(entry.get("stableKeys") or []),
|
||||
"lastSeenAt": str(entry.get("lastSeenAt") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _account_blocked_target_status(item, account_failure):
|
||||
blocked_item = dict(item)
|
||||
affected_targets = set(account_failure.get("affectedTargets") or [])
|
||||
blocked_item.update(
|
||||
{
|
||||
"status": "account_blocked",
|
||||
"category": str(account_failure.get("category") or ""),
|
||||
"reason": str(account_failure.get("reason") or ""),
|
||||
"attemptCount": _coerce_attempt_count(account_failure),
|
||||
"lastAttemptAt": str(account_failure.get("lastAttemptAt") or ""),
|
||||
"accountFailureAffected": blocked_item.get("target") in affected_targets,
|
||||
}
|
||||
)
|
||||
return blocked_item
|
||||
|
||||
|
||||
def _scheduled_send_time(user, target_name, send_window, now):
|
||||
window_minutes = max(1, (send_window["endHour"] - send_window["startHour"]) * 60)
|
||||
start_of_window = now.replace(
|
||||
@@ -501,6 +632,7 @@ def _scheduled_send_time(user, target_name, send_window, now):
|
||||
def _build_target_status(account, target_name, now, send_window):
|
||||
history = dict(account.get("message_history") or {})
|
||||
failure_queue = dict(account.get("failure_queue") or {})
|
||||
friend_index = _friend_index_status(account, target_name)
|
||||
|
||||
history_entry = history.get(target_name) or {}
|
||||
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
|
||||
@@ -515,6 +647,7 @@ def _build_target_status(account, target_name, now, send_window):
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": "",
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
failure_entry = failure_queue.get(target_name) or {}
|
||||
@@ -530,6 +663,7 @@ def _build_target_status(account, target_name, now, send_window):
|
||||
"reason": str(failure_entry.get("reason") or ""),
|
||||
"attemptCount": int(failure_entry.get("attemptCount") or 0),
|
||||
"scheduledAt": "",
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
scheduled_at = None
|
||||
@@ -546,6 +680,7 @@ def _build_target_status(account, target_name, now, send_window):
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -558,6 +693,7 @@ def _build_target_status(account, target_name, now, send_window):
|
||||
"reason": "",
|
||||
"attemptCount": 0,
|
||||
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
|
||||
"friendIndex": friend_index,
|
||||
}
|
||||
|
||||
|
||||
@@ -568,35 +704,83 @@ def get_send_console_snapshot():
|
||||
|
||||
summary = {
|
||||
"enabled_accounts": len(accounts),
|
||||
"total_targets": 0,
|
||||
"today_sent_targets": 0,
|
||||
"today_failed_targets": 0,
|
||||
"today_pending_targets": 0,
|
||||
"today_unprocessed_targets": 0,
|
||||
"today_account_blocked_targets": 0,
|
||||
"today_remaining_targets": 0,
|
||||
"today_account_failures": 0,
|
||||
"today_account_paused": 0,
|
||||
}
|
||||
account_rows = []
|
||||
account_failure_pause_after = _account_failure_pause_after_attempts()
|
||||
|
||||
for account in accounts:
|
||||
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in account.get("targets") or []]
|
||||
configured_targets = list(account.get("targets") or [])
|
||||
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in configured_targets]
|
||||
sent_targets = [item for item in statuses if item["status"] == "sent"]
|
||||
failed_targets = [item for item in statuses if item["status"] == "failed"]
|
||||
pending_targets = [item for item in statuses if item["status"] == "pending"]
|
||||
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"]
|
||||
account_failure = _account_failure_entry_today(account, now)
|
||||
account_paused = bool(account_failure and _coerce_attempt_count(account_failure) >= account_failure_pause_after)
|
||||
account_blocked_targets = []
|
||||
if account_paused:
|
||||
account_blocked_targets = [
|
||||
_account_blocked_target_status(item, account_failure)
|
||||
for item in statuses
|
||||
if item["status"] in {"pending", "unprocessed"}
|
||||
]
|
||||
pending_targets = []
|
||||
unprocessed_targets = []
|
||||
else:
|
||||
pending_targets = [item for item in statuses if item["status"] == "pending"]
|
||||
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"]
|
||||
friend_index_meta = dict(account.get("friend_index_meta") or {})
|
||||
friend_index_last_scan_at = _parse_sent_at(friend_index_meta.get("lastScanAt"), now.tzinfo)
|
||||
if friend_index_last_scan_at:
|
||||
friend_index_meta["lastScanAt"] = friend_index_last_scan_at.isoformat(timespec="seconds")
|
||||
friend_index_meta["missingTargets"] = list(friend_index_meta.get("missingTargets") or [])
|
||||
friend_index_meta["lastScanComplete"] = bool(friend_index_meta.get("lastScanComplete"))
|
||||
try:
|
||||
friend_index_meta["scannedCount"] = int(friend_index_meta.get("scannedCount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
friend_index_meta["scannedCount"] = 0
|
||||
|
||||
summary["total_targets"] += len(configured_targets)
|
||||
summary["today_sent_targets"] += len(sent_targets)
|
||||
summary["today_failed_targets"] += len(failed_targets)
|
||||
summary["today_pending_targets"] += len(pending_targets)
|
||||
summary["today_unprocessed_targets"] += len(unprocessed_targets)
|
||||
summary["today_account_blocked_targets"] += len(account_blocked_targets)
|
||||
summary["today_remaining_targets"] += (
|
||||
len(failed_targets)
|
||||
+ len(pending_targets)
|
||||
+ len(unprocessed_targets)
|
||||
+ len(account_blocked_targets)
|
||||
)
|
||||
if account_failure:
|
||||
summary["today_account_failures"] += 1
|
||||
if account_paused:
|
||||
summary["today_account_paused"] += 1
|
||||
|
||||
account_rows.append(
|
||||
{
|
||||
"unique_id": str(account.get("unique_id") or ""),
|
||||
"username": account.get("username") or "",
|
||||
"total_targets": len(configured_targets),
|
||||
"sent_targets": sent_targets,
|
||||
"failed_targets": failed_targets,
|
||||
"pending_targets": pending_targets,
|
||||
"unprocessed_targets": unprocessed_targets,
|
||||
"account_blocked_targets": account_blocked_targets,
|
||||
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
|
||||
"failure_queue": dict(account.get("failure_queue") or {}),
|
||||
"account_failure": account_failure,
|
||||
"account_paused": account_paused,
|
||||
"account_failure_pause_after": account_failure_pause_after,
|
||||
"friend_index_meta": friend_index_meta,
|
||||
"friend_index_count": len(dict(account.get("friend_index") or {})),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -633,6 +817,7 @@ def get_ops_snapshot():
|
||||
"containers": get_container_status(),
|
||||
"task_containers": get_task_container_rows(),
|
||||
"send_console": get_send_console_snapshot(),
|
||||
"task_lock": task_run_lock_status(),
|
||||
"daily_schedule": current_daily_schedule(),
|
||||
"crontab": read_crontab(),
|
||||
"log_tail": read_log_tail(120),
|
||||
|
||||
@@ -1,148 +1,521 @@
|
||||
{% set current_nav %}{% block nav_key %}dashboard{% endblock %}{% endset %}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}自动续火花{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}续火花{% endblock %}</title>
|
||||
<style>
|
||||
/* ============ DARK (default) ============ */
|
||||
:root {
|
||||
--bg: #0a0e1c;
|
||||
--bg-glow: rgba(255, 77, 109, 0.10);
|
||||
--surface: #161e34;
|
||||
--surface-subtle: #1a2440;
|
||||
--surface-tint: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #eaf0ff;
|
||||
--text-main: #eaf0ff;
|
||||
--text-soft: #9aa6c8;
|
||||
--text-faint: #64709a;
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--primary-deep: #ff2da8;
|
||||
--primary-soft: rgba(255, 122, 61, 0.14);
|
||||
--success: #2bd47f;
|
||||
--success-soft: rgba(43, 212, 127, 0.14);
|
||||
--danger: #ff5470;
|
||||
--danger-soft: rgba(255, 84, 112, 0.14);
|
||||
--warning: #ffb547;
|
||||
--warning-soft: rgba(255, 181, 71, 0.14);
|
||||
--info: #5b8bff;
|
||||
--info-soft: rgba(91, 139, 255, 0.16);
|
||||
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.30);
|
||||
--ring: 0 0 0 4px rgba(255, 122, 61, 0.22);
|
||||
--fire-grad: linear-gradient(135deg, #ffb04a 0%, #ff7a3d 30%, #ff4d6d 65%, #ff2da8 100%);
|
||||
--glow-fire: 0 18px 50px rgba(255, 77, 109, 0.28);
|
||||
--sidebar-bg: linear-gradient(200deg, rgba(22, 30, 52, 0.96) 0%, rgba(14, 20, 38, 0.96) 100%);
|
||||
--sidebar-text: rgba(255, 255, 255, 0.72);
|
||||
--sidebar-text-strong: #ffffff;
|
||||
--sidebar-faint: rgba(255, 255, 255, 0.42);
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.06);
|
||||
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.22), rgba(255, 45, 168, 0.10));
|
||||
--sidebar-active-border: rgba(255, 122, 61, 0.28);
|
||||
--panel-glass: rgba(22, 30, 52, 0.72);
|
||||
--glass-blur: blur(10px);
|
||||
--body-bg:
|
||||
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.16), transparent 50%),
|
||||
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.14), transparent 48%),
|
||||
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
|
||||
--title-grad: linear-gradient(120deg, #ffffff 0%, #ffd9c8 60%, #ff7a9d 120%);
|
||||
--th-bg: rgba(255, 255, 255, 0.04);
|
||||
--row-hover: rgba(255, 255, 255, 0.03);
|
||||
--table-border: rgba(255, 255, 255, 0.06);
|
||||
--input-bg: rgba(255, 255, 255, 0.04);
|
||||
--code-bg: #0c1426;
|
||||
--code-light-bg: rgba(255, 255, 255, 0.03);
|
||||
--empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 16px;
|
||||
--radius-sm: 12px;
|
||||
}
|
||||
|
||||
/* ============ LIGHT ============ */
|
||||
[data-theme="light"] {
|
||||
--bg: #eef2f9;
|
||||
--bg-glow: rgba(255, 122, 61, 0.08);
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f7faff;
|
||||
--surface-tint: #f0f4fb;
|
||||
--border: #e3e9f3;
|
||||
--border-strong: #d0d9e8;
|
||||
--text: #14213d;
|
||||
--text-main: #14213d;
|
||||
--text-soft: #566079;
|
||||
--text-faint: #8a96b3;
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--primary-deep: #ff2da8;
|
||||
--primary-soft: rgba(255, 122, 61, 0.10);
|
||||
--success: #18a558;
|
||||
--success-soft: rgba(24, 165, 88, 0.14);
|
||||
--danger: #e5484a;
|
||||
--danger-soft: rgba(229, 72, 74, 0.12);
|
||||
--warning: #e08a0c;
|
||||
--warning-soft: rgba(224, 138, 12, 0.14);
|
||||
--info: #3a66ff;
|
||||
--info-soft: rgba(58, 102, 255, 0.12);
|
||||
--shadow: 0 20px 45px rgba(20, 33, 61, 0.10);
|
||||
--shadow-soft: 0 8px 24px rgba(20, 33, 61, 0.06);
|
||||
--ring: 0 0 0 4px rgba(255, 122, 61, 0.18);
|
||||
--glow-fire: 0 16px 40px rgba(255, 77, 109, 0.20);
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-text: #566079;
|
||||
--sidebar-text-strong: #14213d;
|
||||
--sidebar-faint: #8a96b3;
|
||||
--sidebar-hover: rgba(20, 33, 61, 0.04);
|
||||
--sidebar-active-bg: linear-gradient(135deg, rgba(255, 122, 61, 0.16), rgba(255, 45, 168, 0.06));
|
||||
--sidebar-active-border: rgba(255, 122, 61, 0.30);
|
||||
--panel-glass: #ffffff;
|
||||
--glass-blur: none;
|
||||
--body-bg:
|
||||
radial-gradient(900px circle at 88% -8%, rgba(255, 77, 109, 0.10), transparent 50%),
|
||||
radial-gradient(760px circle at -6% 12%, rgba(91, 139, 255, 0.10), transparent 48%),
|
||||
linear-gradient(180deg, #f1f5fc 0%, #e9eef7 100%);
|
||||
--title-grad: linear-gradient(120deg, #14213d 0%, #c45a3a 60%, #e83e7c 120%);
|
||||
--th-bg: #f3f6fc;
|
||||
--row-hover: rgba(58, 102, 255, 0.04);
|
||||
--table-border: #eaeff7;
|
||||
--input-bg: #ffffff;
|
||||
--code-bg: #0f1728;
|
||||
--code-light-bg: #f7f9fd;
|
||||
--empty-bg: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--body-bg);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
::selection { background: rgba(255, 77, 109, 0.30); }
|
||||
|
||||
.main-area ::-webkit-scrollbar,
|
||||
.table-shell ::-webkit-scrollbar,
|
||||
.code-block::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
.main-area ::-webkit-scrollbar-thumb,
|
||||
.table-shell ::-webkit-scrollbar-thumb,
|
||||
.code-block::-webkit-scrollbar-thumb {
|
||||
background: var(--text-faint);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.main-area ::-webkit-scrollbar-thumb:hover { opacity: 0.8; background-clip: padding-box; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 236px minmax(0, 1fr); }
|
||||
|
||||
/* ---------- sidebar ---------- */
|
||||
.sidebar {
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
padding: 22px 14px 18px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 6px 14px; border-bottom: 1px solid var(--border); }
|
||||
.brand-mark {
|
||||
width: 40px; height: 40px; border-radius: 13px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 19px; font-weight: 800;
|
||||
box-shadow: var(--glow-fire);
|
||||
}
|
||||
.brand-title { font-size: 16px; font-weight: 800; letter-spacing: 0.01em; color: var(--sidebar-text-strong); }
|
||||
.brand-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
|
||||
.sidebar-section-title {
|
||||
margin: 0 10px 8px; font-size: 10.5px; color: var(--sidebar-faint);
|
||||
letter-spacing: 0.16em; text-transform: uppercase;
|
||||
}
|
||||
.nav-list { display: grid; gap: 4px; }
|
||||
.nav-item {
|
||||
position: relative; display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 14px; border-radius: 11px;
|
||||
color: var(--sidebar-text); font-size: 14px; font-weight: 600;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.nav-item:hover { background: var(--sidebar-hover); color: var(--sidebar-text-strong); }
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-text-strong); font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px var(--sidebar-active-border);
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: ""; position: absolute; left: 4px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 18px; border-radius: 3px; background: var(--fire-grad);
|
||||
}
|
||||
.nav-icon { width: 18px; text-align: center; font-size: 14px; flex: 0 0 18px; opacity: 0.9; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto; padding: 14px; border-radius: 16px;
|
||||
background: var(--surface-tint); border: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-footer-head { display: flex; align-items: center; gap: 10px; }
|
||||
.sidebar-footer-mark {
|
||||
width: 34px; height: 34px; border-radius: 10px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-weight: 800;
|
||||
}
|
||||
.sidebar-footer-title { font-size: 13.5px; font-weight: 700; color: var(--sidebar-text-strong); }
|
||||
.sidebar-footer-subtitle { margin-top: 2px; font-size: 11.5px; color: var(--sidebar-faint); }
|
||||
.sidebar-logout {
|
||||
width: 100%; justify-content: center; display: flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border); color: var(--sidebar-text);
|
||||
background: transparent; box-shadow: none; padding: 9px;
|
||||
}
|
||||
.sidebar-logout:hover {
|
||||
border-color: rgba(255, 84, 112, 0.4); color: var(--danger);
|
||||
background: var(--danger-soft); transform: none; box-shadow: none; filter: none;
|
||||
}
|
||||
|
||||
/* ---------- main ---------- */
|
||||
.main-area { min-width: 0; padding: 24px 28px 36px; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
|
||||
.page-header__title { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||
.page-title {
|
||||
font-size: 28px; line-height: 1.12; font-weight: 800; letter-spacing: -0.02em;
|
||||
background: var(--title-grad);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.page-subtitle { font-size: 14px; color: var(--text-soft); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
.page-header__right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.theme-toggle {
|
||||
width: 38px; height: 38px; border-radius: 11px; flex: 0 0 38px;
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--text-soft);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 17px; cursor: pointer; transition: 0.18s; padding: 0;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.theme-toggle:hover { color: var(--primary); border-color: var(--primary); transform: none; box-shadow: var(--shadow-soft); filter: none; }
|
||||
.theme-toggle .ico-moon { display: none }
|
||||
.theme-toggle .ico-sun { display: inline }
|
||||
[data-theme="light"] .theme-toggle .ico-moon { display: inline }
|
||||
[data-theme="light"] .theme-toggle .ico-sun { display: none }
|
||||
|
||||
.page-header__actions {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 6px; border-radius: 14px;
|
||||
background: var(--panel-glass); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.page-header__actions > form { margin: 0; }
|
||||
.page-header__actions button,
|
||||
.page-header__actions .link-button,
|
||||
.page-header__actions .ghost-button,
|
||||
.page-header__actions .soft-button {
|
||||
margin: 0; border-radius: 10px; padding: 9px 16px; font-size: 13.5px; font-weight: 700;
|
||||
box-shadow: none; background: transparent; color: var(--text-soft);
|
||||
border: 1px solid transparent; transform: none; filter: none;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.page-header__actions button:hover,
|
||||
.page-header__actions .link-button:hover,
|
||||
.page-header__actions .ghost-button:hover,
|
||||
.page-header__actions .soft-button:hover {
|
||||
transform: none; filter: none; box-shadow: none;
|
||||
background: var(--primary-soft); color: var(--primary); border-color: rgba(255, 122, 61, 0.22);
|
||||
}
|
||||
.page-header__actions button:active { transform: none; box-shadow: none; }
|
||||
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button) {
|
||||
background: var(--fire-grad); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 8px 18px rgba(255, 77, 109, 0.32);
|
||||
}
|
||||
.page-header__actions button[type="submit"]:not(.soft-button):not(.ghost-button):not(.danger-button):not(.success-button):hover {
|
||||
filter: brightness(1.06); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.40); transform: none;
|
||||
}
|
||||
.page-header__actions button:disabled {
|
||||
background: transparent; color: var(--text-faint);
|
||||
border: 1px dashed var(--border-strong); box-shadow: none; cursor: not-allowed; opacity: 1;
|
||||
}
|
||||
|
||||
.page-body { display: grid; gap: 22px; }
|
||||
.stack { display: grid; gap: 22px; }
|
||||
|
||||
/* ---------- panel ---------- */
|
||||
.panel {
|
||||
background: var(--panel-glass); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--glass-blur); padding: 22px;
|
||||
}
|
||||
.section-title-row,
|
||||
.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.section-title-row h2, .panel h2, .panel h3, .panel h4 { margin: 0; }
|
||||
.muted { color: var(--text-soft); }
|
||||
.muted.compact { margin: 6px 0 0; font-size: 13px; line-height: 1.6; }
|
||||
|
||||
/* ---------- stat cards (kept for send_console/logs compat) ---------- */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; }
|
||||
.stat-card {
|
||||
min-height: 124px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border); background: var(--panel-glass);
|
||||
box-shadow: var(--shadow-soft); backdrop-filter: var(--glass-blur);
|
||||
padding: 20px 22px; display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--sidebar-active-border); }
|
||||
.stat-meta { display: grid; gap: 10px; }
|
||||
.stat-label { font-size: 14px; color: var(--text-soft); }
|
||||
.stat-value { font-size: 28px; line-height: 1; font-weight: 800; letter-spacing: -0.03em; }
|
||||
.stat-help { font-size: 13px; color: var(--text-soft); }
|
||||
.stat-help.positive { color: var(--success); }
|
||||
.stat-help.negative { color: var(--danger); }
|
||||
.stat-icon {
|
||||
width: 58px; height: 58px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 24px; font-weight: 700;
|
||||
}
|
||||
.stat-icon.blue { background: var(--info-soft); color: var(--info); }
|
||||
.stat-icon.green { background: var(--success-soft); color: var(--success); }
|
||||
.stat-icon.red { background: var(--danger-soft); color: var(--danger); }
|
||||
.stat-icon.orange { background: var(--warning-soft); color: var(--warning); }
|
||||
|
||||
.layout-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
|
||||
.overview-time { display: inline-flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 14px; font-weight: 600; }
|
||||
.overview-time::before {
|
||||
content: ""; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--fire-grad); box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
|
||||
}
|
||||
|
||||
/* ---------- pills / chips ---------- */
|
||||
.status-pill, .pill, .status-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border-radius: 999px; padding: 6px 12px; font-size: 12px; font-weight: 700;
|
||||
color: var(--text-soft); background: var(--surface-tint); border: 1px solid var(--border);
|
||||
}
|
||||
.pill.soft, .status-pill.soft, .status-chip.success { color: var(--success); background: var(--success-soft); }
|
||||
.pill.warning, .status-pill.warning, .status-chip.warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.pill.danger, .status-pill.danger, .status-chip.danger { color: var(--danger); background: var(--danger-soft); }
|
||||
.status-chip.info { color: var(--info); background: var(--info-soft); }
|
||||
.status-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
button, .link-button {
|
||||
appearance: none; border: none; border-radius: 12px;
|
||||
background: var(--fire-grad); color: #fff; cursor: pointer;
|
||||
padding: 11px 18px; font-weight: 700; letter-spacing: 0.01em;
|
||||
box-shadow: 0 10px 22px rgba(255, 77, 109, 0.26);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
button:hover, .link-button:hover { transform: translateY(-1px); box-shadow: 0 16px 30px rgba(255, 77, 109, 0.36); filter: brightness(1.05); }
|
||||
button:active, .link-button:active { transform: translateY(0); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.24); }
|
||||
button:focus-visible, .link-button:focus-visible, .nav-item:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.5; transform: none; box-shadow: none; filter: grayscale(0.3); }
|
||||
|
||||
.ghost-button, .soft-button, .danger-button, .success-button { background: var(--surface); box-shadow: none; }
|
||||
.ghost-button { color: var(--text); border: 1px solid var(--border-strong); }
|
||||
.ghost-button:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); box-shadow: none; transform: none; filter: none; }
|
||||
.soft-button { color: var(--primary); background: var(--primary-soft); border: 1px solid rgba(255, 122, 61, 0.20); }
|
||||
.soft-button:hover { background: rgba(255, 122, 61, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
.danger-button { color: var(--danger); border: 1px solid rgba(255, 84, 112, 0.26); background: var(--danger-soft); }
|
||||
.danger-button:hover { background: rgba(255, 84, 112, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
.success-button { color: var(--success); border: 1px solid rgba(43, 212, 127, 0.28); background: var(--success-soft); }
|
||||
.success-button:hover { background: rgba(43, 212, 127, 0.20); box-shadow: none; transform: none; filter: none; }
|
||||
|
||||
.button-row, .button-grid { display: grid; gap: 12px; }
|
||||
.button-row.two, .button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
input[type="text"], input[type="password"], input[type="search"], input[type="number"], textarea, select {
|
||||
width: 100%; border: 1px solid var(--border-strong); border-radius: 12px;
|
||||
background: var(--input-bg); color: var(--text); padding: 11px 13px; outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--primary); box-shadow: var(--ring); }
|
||||
input::placeholder, textarea::placeholder { color: var(--text-faint); }
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
.stack-form { display: grid; gap: 14px; }
|
||||
.stack-form label { display: grid; gap: 8px; }
|
||||
.stack-form span { font-size: 13px; color: var(--text-soft); }
|
||||
.check-row { display: flex !important; align-items: center; gap: 10px; }
|
||||
.check-row span { color: var(--text); }
|
||||
|
||||
/* ---------- flash ---------- */
|
||||
.flash { border-radius: 14px; padding: 14px 16px; border: 1px solid var(--border); box-shadow: var(--shadow-soft); background: var(--surface); }
|
||||
.flash.success { background: var(--success-soft); color: var(--success); border-color: rgba(43, 212, 127, 0.24); }
|
||||
.flash.warning { background: var(--warning-soft); color: var(--warning); border-color: rgba(255, 181, 71, 0.24); }
|
||||
.flash.error { background: var(--danger-soft); color: var(--danger); border-color: rgba(255, 84, 112, 0.24); }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
.table-shell { overflow: hidden; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
|
||||
.table-shell.scrollable { overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--table-border); text-align: left; font-size: 13px; vertical-align: middle; }
|
||||
th { background: var(--th-bg); color: var(--text-soft); font-weight: 700; letter-spacing: 0.01em; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: var(--row-hover); }
|
||||
|
||||
/* ---------- code ---------- */
|
||||
.code-block {
|
||||
margin: 0; padding: 16px; border-radius: 14px;
|
||||
background: var(--code-bg); color: #cdd8f5; overflow: auto;
|
||||
font-size: 12px; line-height: 1.7;
|
||||
}
|
||||
.code-block.light { color: var(--text); background: var(--code-light-bg); border: 1px solid var(--border); }
|
||||
|
||||
.empty-state {
|
||||
padding: 34px 20px; text-align: center; border-radius: 16px;
|
||||
border: 1px dashed var(--border-strong); background: var(--empty-bg); color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* ---------- task banner ---------- */
|
||||
.task-state-banner {
|
||||
margin: 0 0 16px; padding: 12px 14px; border-radius: 12px;
|
||||
border: 1px solid var(--border); font-size: 13px; font-weight: 700;
|
||||
}
|
||||
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
|
||||
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.layout-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.main-area { padding: 18px 16px 28px; }
|
||||
.page-header { flex-direction: column; }
|
||||
.stats-grid, .button-grid, .button-row.two { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<div class="mobile-topbar">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
data-nav-toggle
|
||||
aria-label="打开导航"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="mobile-brand">
|
||||
<span class="brand-mark">⚡</span>
|
||||
<span>自动续火花</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-theme-toggle
|
||||
aria-label="切换黑夜模式"
|
||||
title="切换黑夜模式"
|
||||
>
|
||||
<span class="theme-icon theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon theme-moon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-backdrop" data-nav-close></div>
|
||||
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">⚡</div>
|
||||
<div>
|
||||
<div class="brand-title">自动续火花</div>
|
||||
<div class="brand-subtitle">多账号运维面板</div>
|
||||
<aside class="sidebar">
|
||||
<div>
|
||||
<div class="brand">
|
||||
<div class="brand-mark">🔥</div>
|
||||
<div>
|
||||
<div class="brand-title">续火花</div>
|
||||
<div class="brand-subtitle">多账号控制平台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="sidebar-section-title">导航</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
|
||||
<span class="nav-icon">⌂</span><span>首页总览</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
|
||||
<span class="nav-icon">✦</span><span>发送控制台</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
|
||||
<span class="nav-icon">☰</span><span>发送记录</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<p class="sidebar-section-title" style="margin-top:18px;">管理</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item" href="/#account-management"><span class="nav-icon">◉</span><span>账号管理</span></a>
|
||||
<a class="nav-item" href="/#config-panel"><span class="nav-icon">▣</span><span>运行配置</span></a>
|
||||
<a class="nav-item" href="/#interactive-login-section"><span class="nav-icon">⚡</span><span>登录抖音账号</span></a>
|
||||
<a class="nav-item" href="/#ops-panel"><span class="nav-icon">☷</span><span>运维操作</span></a>
|
||||
<a class="nav-item" href="/#settings-panel"><span class="nav-icon">⚙</span><span>系统设置</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups">
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">概览</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}"
|
||||
href="/"
|
||||
>
|
||||
<span class="nav-icon">⌂</span>
|
||||
<span>控制台概览</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">发送</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'login_workspace' %}active{% endif %}"
|
||||
href="/login-workspace"
|
||||
>
|
||||
<span class="nav-icon">◉</span>
|
||||
<span>登录工作区</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">账号</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'accounts' %}active{% endif %}"
|
||||
href="/accounts"
|
||||
>
|
||||
<span class="nav-icon">◎</span>
|
||||
<span>账号与目标</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">系统</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'settings' %}active{% endif %}"
|
||||
href="/settings"
|
||||
>
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>运行与系统</span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}"
|
||||
href="/ops/logs"
|
||||
>
|
||||
<span class="nav-icon">☰</span>
|
||||
<span>运行日志</span>
|
||||
</a>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer-title">运行状态</div>
|
||||
<div class="sidebar-footer-subtitle">批量发送与运维调度</div>
|
||||
<div class="sidebar-footer-head">
|
||||
<div class="sidebar-footer-mark">{{ (current_user or "A")[:1] }}</div>
|
||||
<div style="min-width:0;">
|
||||
<div class="sidebar-footer-title" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ current_user or "未登录" }}</div>
|
||||
<div class="sidebar-footer-subtitle">已登录管理员</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/logout" style="margin-top:14px;">
|
||||
<button type="submit" class="sidebar-logout"><span>⏻</span><span>退出登录</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-area">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow">
|
||||
{% block page_eyebrow %}SparkFlow Admin{% endblock %}
|
||||
</div>
|
||||
<div class="page-title-row">
|
||||
<h1 class="page-title">
|
||||
{% block page_title %}自动续火花{% endblock %}
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
{% block page_subtitle %}多账号发送控制平台{% endblock %}
|
||||
</p>
|
||||
<div class="page-header__title">
|
||||
<div class="page-title">{% block page_title %}续火花{% endblock %}</div>
|
||||
</div>
|
||||
<div class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-theme-toggle
|
||||
aria-label="切换黑夜模式"
|
||||
title="切换黑夜模式"
|
||||
>
|
||||
<span class="theme-icon theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon theme-moon" aria-hidden="true">☾</span>
|
||||
<div class="page-header__right">
|
||||
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
|
||||
<span class="ico-sun">☀</span><span class="ico-moon">☾</span>
|
||||
</button>
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
<div class="page-header__actions">
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-body">
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %} {% block content %}{% endblock %}
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script>
|
||||
(function(){
|
||||
var root = document.documentElement;
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var saved = 'dark';
|
||||
try { saved = localStorage.getItem('sparkflow-theme') || 'dark'; } catch(e){}
|
||||
function apply(t){ root.setAttribute('data-theme', t === 'light' ? 'light' : 'dark'); try{ localStorage.setItem('sparkflow-theme', t); }catch(e){} }
|
||||
apply(saved);
|
||||
if (btn) btn.addEventListener('click', function(){
|
||||
var cur = root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
apply(cur === 'light' ? 'dark' : 'light');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,262 +1,642 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}dashboard{% endblock %}
|
||||
{% block title %}控制台概览 | 自动续火花{% endblock %}
|
||||
{% block page_title %}控制台概览{% endblock %}
|
||||
{% block page_subtitle %}发送状态、账号健康和关键运维入口{% endblock %}
|
||||
{% block title %}续火花控制台{% endblock %}
|
||||
{% block page_title %}今日续火花概览{% endblock %}
|
||||
{% block page_subtitle %}多账号发送与运营管理{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/login-workspace">登录工作区</a>
|
||||
<a class="ghost-button" href="/ops/send-console">发送控制台</a>
|
||||
<form method="post" action="/ops/run-failed">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set task_lock = ops.task_lock %}
|
||||
{% set enabled_accounts = accounts | selectattr("enabled", "equalto", true) | list %}
|
||||
{% set send_summary = ops.send_console.summary %}
|
||||
{% set pending_total = send_summary.today_pending_targets + send_summary.today_unprocessed_targets %}
|
||||
{% set proxy_rows = ops.containers | selectattr("Names", "equalto", "mihomo") | list %}
|
||||
{% set total_targets = send_summary.total_targets or 0 %}
|
||||
{% set done_targets = send_summary.today_sent_targets or 0 %}
|
||||
{% set failed_targets = send_summary.today_failed_targets or 0 %}
|
||||
{% set pending_targets = send_summary.today_pending_targets or 0 %}
|
||||
{% set unproc_targets = send_summary.today_unprocessed_targets or 0 %}
|
||||
{% set block_targets = send_summary.today_account_blocked_targets or 0 %}
|
||||
{% set ring_total = total_targets if total_targets > 0 else 1 %}
|
||||
{% set ring_pct = (done_targets / ring_total * 100) | round(0) | int %}
|
||||
{% set ring_circ = 490 %}
|
||||
{% set ring_offset = (ring_circ * (1 - ring_pct / 100)) | round(1) %}
|
||||
{% set success_rate = (done_targets / ring_total * 100) | round(0) | int if total_targets else 0 %}
|
||||
{% set remaining = total_targets - done_targets %}
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<section class="stats-grid" aria-label="今日概览">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value">{{ enabled_accounts|length }}</strong>
|
||||
<span class="stat-help">总账号 {{ accounts|length }}</span>
|
||||
</div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
</article>
|
||||
<style>
|
||||
.dash-stack { display: grid; gap: 20px; }
|
||||
.bento { display: grid; grid-template-columns: 1.05fr 0.95fr 1fr; gap: 18px; }
|
||||
.card-h { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.card-h h3 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
.card-h .tag { font-size: 11.5px; color: var(--text-faint); font-weight: 700; }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_sent_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">浏览器确认发送成功</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
</article>
|
||||
/* ring */
|
||||
.ring-card { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 260px; position: relative; overflow: hidden; }
|
||||
.ring-card::after { content: ""; position: absolute; right: -90px; bottom: -90px; width: 240px; height: 240px; border-radius: 50%; background: radial-gradient(circle, rgba(255, 77, 109, 0.22), transparent 68%); pointer-events: none; }
|
||||
.ring { position: relative; width: 184px; height: 184px; z-index: 1; }
|
||||
.ring svg { transform: rotate(-90deg); }
|
||||
.ring .track { stroke: var(--surface-tint); }
|
||||
.ring .fill { stroke: url(#fireGrad); stroke-linecap: round; filter: drop-shadow(0 0 10px rgba(255, 77, 109, 0.5)); }
|
||||
.ring-c { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
|
||||
.ring-num { font-size: 40px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
|
||||
.ring-num small { font-size: 19px; color: var(--text-soft); font-weight: 700; }
|
||||
.ring-lab { font-size: 12.5px; color: var(--text-soft); font-weight: 700; margin-top: 4px; }
|
||||
.ring-pct { margin-top: 8px; font-size: 13px; font-weight: 800; background: var(--fire-grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_failed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">进入重试队列</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
</article>
|
||||
/* streak / overview card */
|
||||
.ov-card { display: flex; flex-direction: column; gap: 16px; min-height: 260px; }
|
||||
.ov-hero { display: flex; align-items: center; gap: 16px; }
|
||||
.ov-flame { width: 70px; height: 70px; border-radius: 20px; flex: 0 0 70px; background: var(--fire-grad); display: flex; align-items: center; justify-content: center; font-size: 34px; box-shadow: var(--glow-fire); }
|
||||
.ov-num { font-size: 42px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
|
||||
.ov-lab { font-size: 13px; color: var(--text-soft); font-weight: 700; margin-top: 4px; }
|
||||
.ov-list { display: grid; gap: 10px; margin-top: auto; }
|
||||
.ov-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-soft); }
|
||||
.ov-row b { color: var(--text); }
|
||||
.sparkdots { display: flex; gap: 5px; }
|
||||
.sparkdots i { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-tint); }
|
||||
.sparkdots i.on { background: var(--fire-grad); box-shadow: 0 0 8px rgba(255, 77, 109, 0.6); }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_pending_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">{{ ops.daily_schedule or "未配置窗口" }}</span>
|
||||
</div>
|
||||
<div class="stat-icon orange">◔</div>
|
||||
</article>
|
||||
/* status tiles */
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.tile { border: 1px solid var(--border); border-radius: 16px; padding: 14px 16px; background: var(--surface-tint); position: relative; overflow: hidden; }
|
||||
.tile::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
|
||||
.tile.s::before { background: var(--success); }
|
||||
.tile.f::before { background: var(--danger); }
|
||||
.tile.p::before { background: var(--warning); }
|
||||
.tile.u::before { background: var(--info); }
|
||||
.tile-lab { font-size: 12px; color: var(--text-soft); font-weight: 700; }
|
||||
.tile-val { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; margin-top: 6px; }
|
||||
.tile-help { font-size: 11px; color: var(--text-faint); margin-top: 4px; }
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">未处理</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_summary.today_unprocessed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">等待后续调度</span>
|
||||
</div>
|
||||
<div class="stat-icon gray">…</div>
|
||||
</article>
|
||||
</section>
|
||||
/* section header */
|
||||
.sect-h { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
|
||||
.sect-h h2 { margin: 0; font-size: 18px; font-weight: 800; }
|
||||
.sect-h .link { font-size: 13px; font-weight: 700; color: var(--primary); text-decoration: none; }
|
||||
.sect-h .link:hover { text-decoration: underline; }
|
||||
|
||||
<div class="layout-grid">
|
||||
<div class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>今日发送摘要</h2>
|
||||
<p class="muted compact">
|
||||
按账号展示今日发送、失败和待处理状态,详细明细可继续展开查看。
|
||||
</p>
|
||||
</div>
|
||||
<a class="link-button" href="/ops/send-console">查看明细</a>
|
||||
/* summary table extras */
|
||||
.summary-table td:first-child strong { display: inline-block; margin-bottom: 4px; }
|
||||
.summary-table td:first-child span { display: inline-block; font-size: 12px; color: var(--text-soft); }
|
||||
.mini-bar { height: 6px; border-radius: 999px; background: var(--surface-tint); overflow: hidden; min-width: 80px; }
|
||||
.mini-bar > i { display: block; height: 100%; border-radius: 999px; background: var(--fire-grad); }
|
||||
|
||||
/* collapse login */
|
||||
.collapse-panel { border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--panel-glass); box-shadow: var(--shadow); backdrop-filter: var(--glass-blur); overflow: hidden; }
|
||||
.collapse-panel > summary { list-style: none; cursor: pointer; padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.collapse-panel > summary::-webkit-details-marker { display: none; }
|
||||
.collapse-summary-title { display: flex; align-items: center; gap: 12px; }
|
||||
.collapse-summary-title h2 { margin: 0; font-size: 18px; }
|
||||
.collapse-summary-title .chev { width: 26px; height: 26px; border-radius: 8px; background: var(--surface-tint); color: var(--text-soft); display: inline-flex; align-items: center; justify-content: center; font-size: 13px; transition: transform 0.18s ease; }
|
||||
.collapse-panel[open] > summary .chev { transform: rotate(90deg); }
|
||||
.collapse-body { padding: 0 22px 22px; }
|
||||
.login-stepbar { display: flex; align-items: center; gap: 12px; padding: 0 0 16px; flex-wrap: wrap; }
|
||||
.step-item { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-soft); font-weight: 700; white-space: nowrap; }
|
||||
.step-index { width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: var(--fire-grad); box-shadow: 0 8px 16px rgba(255, 77, 109, 0.26); font-size: 12px; }
|
||||
.step-connector { flex: 1; min-width: 24px; height: 2px; border-radius: 999px; background: linear-gradient(90deg, rgba(255, 122, 61, 0.3), var(--border)); }
|
||||
.login-grid { display: grid; grid-template-columns: 320px minmax(0, 1fr) 300px; gap: 18px; align-items: start; }
|
||||
.login-box { border: 1px solid var(--border); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow-soft); padding: 18px; min-height: 300px; }
|
||||
.login-box.warning { background: var(--warning-soft); }
|
||||
.login-box.highlight { background: var(--success-soft); }
|
||||
.login-lead { color: var(--text-soft); font-size: 14px; line-height: 1.8; }
|
||||
.feature-list { margin: 0; padding-left: 18px; color: var(--text); line-height: 1.8; }
|
||||
.url-field { display: flex; gap: 10px; align-items: center; }
|
||||
.url-field input { flex: 1; }
|
||||
.url-copy { width: 46px; padding: 11px 0; }
|
||||
.desktop-frame-wrap { overflow: hidden; border-radius: 18px; border: 1px solid var(--border); background: #0d1525; }
|
||||
.desktop-frame { width: 100%; aspect-ratio: 16 / 10; border: none; display: block; background: #0d1525; }
|
||||
.status-list { display: grid; gap: 12px; margin: 8px 0 18px; }
|
||||
.status-item { display: flex; align-items: center; gap: 10px; color: var(--text-soft); font-size: 14px; font-weight: 700; }
|
||||
.status-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--surface-tint); border: 1px solid var(--border-strong); }
|
||||
.status-item.pending .status-dot { background: var(--info-soft); border-color: var(--info); }
|
||||
.status-item.success .status-dot { background: var(--success-soft); border-color: var(--success); }
|
||||
.status-item.error .status-dot { background: var(--danger-soft); border-color: var(--danger); }
|
||||
.info-card { border-radius: 16px; border: 1px solid rgba(43, 212, 127, 0.22); background: var(--success-soft); padding: 16px; color: var(--text); line-height: 1.7; font-size: 13px; }
|
||||
.action-stack { display: grid; gap: 12px; }
|
||||
|
||||
/* layout */
|
||||
.dashboard-layout { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
|
||||
.dashboard-main, .dashboard-side { display: grid; gap: 22px; }
|
||||
|
||||
/* activity timeline */
|
||||
.tl { display: grid; gap: 10px; }
|
||||
.tl-row { display: flex; align-items: center; gap: 14px; padding: 12px 14px; border-radius: 14px; background: var(--surface-tint); border: 1px solid var(--border); }
|
||||
.tl-ico { width: 30px; height: 30px; border-radius: 9px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; flex: 0 0 30px; }
|
||||
.tl-ico.ok { background: var(--success-soft); color: var(--success); }
|
||||
.tl-ico.no { background: var(--danger-soft); color: var(--danger); }
|
||||
.tl-ico.wait { background: var(--warning-soft); color: var(--warning); }
|
||||
.tl-main { flex: 1; min-width: 0; }
|
||||
.tl-t { font-size: 13.5px; font-weight: 600; }
|
||||
.tl-t b { font-weight: 800; }
|
||||
.tl-s { font-size: 11.5px; color: var(--text-faint); margin-top: 2px; }
|
||||
.tl-time { font-size: 12px; color: var(--text-faint); white-space: nowrap; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; }
|
||||
|
||||
/* account details compact */
|
||||
.account-details { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-soft); overflow: hidden; }
|
||||
.account-details > summary { list-style: none; cursor: pointer; padding: 14px 18px; display: flex; align-items: center; gap: 16px; }
|
||||
.account-details > summary::-webkit-details-marker { display: none; }
|
||||
.account-summary { flex: 1; display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
.avatar-photo { width: 40px; height: 40px; border-radius: 12px; background: var(--fire-grad); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; font-weight: 800; flex: 0 0 40px; }
|
||||
.account-summary-name { min-width: 0; }
|
||||
.account-summary-name strong { font-size: 16px; font-weight: 700; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.account-summary-name span { font-size: 12px; color: var(--text-soft); }
|
||||
.account-summary-metrics { display: flex; gap: 16px; flex-wrap: wrap; margin-left: auto; }
|
||||
.account-metric { text-align: center; }
|
||||
.account-metric b { display: block; font-size: 17px; font-weight: 800; }
|
||||
.account-metric span { font-size: 11px; color: var(--text-faint); }
|
||||
.account-chev { color: var(--text-faint); font-size: 16px; transition: transform 0.18s ease; }
|
||||
.account-details[open] > summary .account-chev { transform: rotate(90deg); }
|
||||
.account-card { padding: 18px; border-top: 1px solid var(--border); display: grid; gap: 16px; }
|
||||
.friend-picker { border-radius: 18px; border: 1px solid var(--border); background: var(--surface-tint); padding: 14px; }
|
||||
.friend-picker-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
|
||||
.friend-picker-title { font-size: 14px; font-weight: 700; }
|
||||
.friend-picker-list { max-height: 220px; overflow: auto; border-radius: 14px; border: 1px solid var(--border); background: var(--surface); padding: 8px; display: grid; gap: 6px; }
|
||||
.friend-option { display: flex; align-items: center; justify-content: space-between; gap: 10px; border-radius: 12px; border: 1px solid transparent; padding: 9px 10px; }
|
||||
.friend-option input[type="checkbox"] { position: static; inset: auto; width: 16px; height: 16px; opacity: 1; pointer-events: auto; accent-color: var(--primary); flex: 0 0 auto; }
|
||||
.friend-option.selected { background: var(--primary-soft); border-color: rgba(255, 122, 61, 0.18); }
|
||||
.friend-picker-empty { padding: 18px 12px; text-align: center; color: var(--text-soft); }
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.ops-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
||||
.ops-card-grid .panel { padding: 20px; }
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.bento { grid-template-columns: 1fr 1fr; }
|
||||
.ring-card { grid-column: 1 / 2; }
|
||||
.ops-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.dashboard-layout { grid-template-columns: 1fr; }
|
||||
.login-grid { grid-template-columns: 1fr; }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.bento { grid-template-columns: 1fr; }
|
||||
.account-summary-metrics { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<svg width="0" height="0" style="position:absolute"><defs>
|
||||
<linearGradient id="fireGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffb04a"/><stop offset="40%" stop-color="#ff7a3d"/>
|
||||
<stop offset="75%" stop-color="#ff4d6d"/><stop offset="100%" stop-color="#ff2da8"/>
|
||||
</linearGradient>
|
||||
</defs></svg>
|
||||
|
||||
<div class="dash-stack">
|
||||
{# ---- task state ---- #}
|
||||
{% if task_lock and task_lock.running %}
|
||||
<div class="task-state-banner warning">发送任务运行中:pid {{ task_lock.pid or "unknown" }},已运行约 {{ task_lock.ageSeconds }} 秒。补发按钮会等任务结束后再可用。</div>
|
||||
{% elif task_lock and task_lock.staleRemoved %}
|
||||
<div class="task-state-banner info">已自动清理一个过期任务锁,可以重新发起补发。</div>
|
||||
{% else %}
|
||||
<div class="task-state-banner success">当前没有发送任务运行。</div>
|
||||
{% endif %}
|
||||
|
||||
{# ---- bento hero ---- #}
|
||||
<div class="bento">
|
||||
<div class="panel ring-card">
|
||||
<div class="card-h" style="position:relative;z-index:1;width:100%;justify-content:center"><h3>今日续火花进度</h3><span class="tag">实时</span></div>
|
||||
<div class="ring">
|
||||
<svg width="184" height="184" viewBox="0 0 184 184">
|
||||
<circle class="track" cx="92" cy="92" r="78" fill="none" stroke-width="14"/>
|
||||
<circle class="fill" cx="92" cy="92" r="78" fill="none" stroke-width="14"
|
||||
stroke-dasharray="{{ ring_circ }}" stroke-dashoffset="{{ ring_offset }}"/>
|
||||
</svg>
|
||||
<div class="ring-c">
|
||||
<div class="ring-num">{{ done_targets }}<small>/{{ total_targets }}</small></div>
|
||||
<div class="ring-lab">今日成功 / 总目标</div>
|
||||
<div class="ring-pct">{{ ring_pct }}% 已完成</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号</th>
|
||||
<th>今日成功</th>
|
||||
<th>失败待补发</th>
|
||||
<th>待发送</th>
|
||||
<th>未处理</th>
|
||||
<th>最近失败原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in ops.send_console.accounts %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ row.username }}</strong><br />
|
||||
<span class="muted">{{ row.unique_id }}</span>
|
||||
</td>
|
||||
<td>{{ row.sent_targets|length }}</td>
|
||||
<td>{{ row.failed_targets|length }}</td>
|
||||
<td>{{ row.pending_targets|length }}</td>
|
||||
<td>{{ row.unprocessed_targets|length }}</td>
|
||||
<td>{{ row.last_failure_reason or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">当前没有发送摘要数据。</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>账号健康</h2>
|
||||
<p class="muted compact">
|
||||
快速确认账号启用状态、目标好友数量和登录态缓存规模。
|
||||
</p>
|
||||
</div>
|
||||
<a class="ghost-button" href="/accounts">管理账号</a>
|
||||
</div>
|
||||
|
||||
{% if accounts %}
|
||||
<div class="content-grid">
|
||||
{% for account in accounts %}
|
||||
<article class="quick-card">
|
||||
<div class="account-head">
|
||||
<div class="account-ident">
|
||||
<div class="avatar-photo">
|
||||
{{ account.username[:1] if account.username else "A" }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="account-name">{{ account.username }}</div>
|
||||
<div class="account-sub">
|
||||
unique_id: {{ account.unique_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="status-chip {% if account.enabled|default(true) %}success{% else %}warning{% endif %}"
|
||||
>
|
||||
{% if account.enabled|default(true) %}已启用{% else %}已停用{%
|
||||
endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="account-metrics">
|
||||
<div class="metric-box">
|
||||
<span class="label">目标</span
|
||||
><strong>{{ account.targets|length }}</strong>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">好友缓存</span
|
||||
><strong
|
||||
>{{ account.friends_cache|default([], true)|length }}</strong
|
||||
>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<span class="label">Cookies</span
|
||||
><strong>{{ account.cookies|length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
还没有账号。前往登录工作区完成扫码登录并保存账号。
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>关键操作</h2>
|
||||
<p class="muted compact">
|
||||
常用入口和高风险动作分开处理,避免误触。
|
||||
</p>
|
||||
<div class="panel ov-card">
|
||||
<div class="card-h"><h3>火花概览</h3><span class="tag">全部账号</span></div>
|
||||
<div class="ov-hero">
|
||||
<div class="ov-flame">🔥</div>
|
||||
<div>
|
||||
<div class="ov-num">{{ enabled_accounts|length }}</div>
|
||||
<div class="ov-lab">启用账号 · 共 {{ accounts|length }} 个</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-list">
|
||||
<div class="ov-row"><span>今日成功率</span><b>{{ success_rate }}%</b></div>
|
||||
<div class="ov-row"><span>未完成目标</span><b>{{ remaining }}</b></div>
|
||||
<div class="ov-row"><span>今日火花状态</span>
|
||||
<span class="sparkdots">
|
||||
{% for i in range(5) %}<i class="{% if loop.index0 < (done_targets * 5 // ring_total) %}on{% endif %}"></i>{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="card-h"><h3>今日状态分布</h3><span class="tag">{{ total_targets }} 个目标</span></div>
|
||||
<div class="tiles">
|
||||
<div class="tile s"><div class="tile-lab">今日成功</div><div class="tile-val">{{ done_targets }}</div><div class="tile-help">已完成 {{ done_targets }}/{{ total_targets }}</div></div>
|
||||
<div class="tile f"><div class="tile-lab">失败待补发</div><div class="tile-val">{{ failed_targets }}</div><div class="tile-help">进入重试队列</div></div>
|
||||
<div class="tile p"><div class="tile-lab">待发送</div><div class="tile-val">{{ pending_targets }}</div><div class="tile-help">排队等待中</div></div>
|
||||
<div class="tile u"><div class="tile-lab">未处理</div><div class="tile-val">{{ unproc_targets }}</div><div class="tile-help">尚未进入流程</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- send summary table ---- #}
|
||||
<section class="panel" id="send-console-summary">
|
||||
<div class="sect-h">
|
||||
<div><h2>发送控制台摘要</h2><p class="muted compact">每个账号今天的发送状态分布,进入发送控制台可做批量补发或针对性重试。</p></div>
|
||||
<a class="link" href="/ops/send-console">打开发送控制台 →</a>
|
||||
</div>
|
||||
<div class="table-shell summary-table">
|
||||
<table>
|
||||
<thead><tr><th>账号</th><th>完成进度</th><th>今日成功</th><th>失败待补发</th><th>待发送</th><th>未处理</th><th>账号异常</th><th>最近失败原因</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in ops.send_console.accounts %}
|
||||
{% set row_total = row.total_targets or 1 %}
|
||||
{% set row_done = row.sent_targets|length %}
|
||||
{% set row_pct = (row_done / row_total * 100)|round(0)|int %}
|
||||
<tr>
|
||||
<td><strong>{{ row.username }}</strong><br><span>{{ row.unique_id }}</span></td>
|
||||
<td><div style="display:flex;align-items:center;gap:10px;"><span>{{ row_done }}/{{ row.total_targets }}</span><span class="mini-bar" style="width:90px;"><i style="width:{{ row_pct }}%"></i></span></div></td>
|
||||
<td><span class="status-chip success">{{ row_done }}</span></td>
|
||||
<td>{% if row.failed_targets|length %}<span class="status-chip danger">{{ row.failed_targets|length }}</span>{% else %}<span class="muted">0</span>{% endif %}</td>
|
||||
<td>{{ row.pending_targets|length }}</td>
|
||||
<td>{{ row.unprocessed_targets|length }}</td>
|
||||
<td>{% if row.account_blocked_targets|length %}<span class="status-chip warning">{{ row.account_blocked_targets|length }}</span>{% else %}<span class="muted">0</span>{% endif %}</td>
|
||||
<td>{% if row.last_failure_reason %}{{ row.last_failure_reason }}{% elif row.account_failure %}账号异常:{{ row.account_failure.category or "-" }}{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">当前没有发送摘要数据。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- activity timeline ---- #}
|
||||
<section>
|
||||
<div class="sect-h"><h2>实时发送动态</h2><a class="link" href="/ops/send-console">打开发送控制台 →</a></div>
|
||||
<div class="panel" style="padding:18px">
|
||||
{% set ns = namespace(rows=[]) %}
|
||||
{% for acct in ops.send_console.accounts %}
|
||||
{% for item in acct.sent_targets[:6] %}{% set ns.rows = ns.rows + [{type:'ok', acc:acct.username, target:item.target, msg:item.message, time:item.sentAt}] %}{% endfor %}
|
||||
{% for item in acct.failed_targets[:4] %}{% set ns.rows = ns.rows + [{type:'no', acc:acct.username, target:item.target, msg:item.reason, time:item.category}] %}{% endfor %}
|
||||
{% for item in acct.pending_targets[:4] %}{% set ns.rows = ns.rows + [{type:'wait', acc:acct.username, target:item.target, msg:item.scheduledAt, time:'待发送'}] %}{% endfor %}
|
||||
{% endfor %}
|
||||
{% if ns.rows %}
|
||||
<div class="tl">
|
||||
{% for r in ns.rows[:10] %}
|
||||
<div class="tl-row">
|
||||
<div class="tl-ico {{ r.type }}">{% if r.type == 'ok' %}✓{% elif r.type == 'no' %}✗{% else %}◔{% endif %}</div>
|
||||
<div class="tl-main">
|
||||
<div class="tl-t">{{ r.acc }} → <b>{{ r.target }}</b> {% if r.type == 'ok' %}续火花成功{% elif r.type == 'no' %}发送失败{% else %}等待发送{% endif %}</div>
|
||||
<div class="tl-s">{% if r.type == 'ok' and r.msg %}消息:{{ r.msg }}{% elif r.type == 'no' and r.msg %}原因:{{ r.msg }}{% elif r.type == 'wait' and r.msg %}预计 {{ r.msg }} 发送{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
<div class="tl-time">{{ r.time or '—' }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">今日暂无发送动态。任务运行后会在这里实时显示成功 / 失败 / 待发送记录。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- interactive login browser (full width) ---- #}
|
||||
<section>
|
||||
<div class="sect-h"><h2>登录抖音账号</h2></div>
|
||||
<details class="collapse-panel" id="interactive-login-section" open>
|
||||
<summary>
|
||||
<div class="collapse-summary-title"><span class="chev">▸</span><h2 style="font-size:16px">交互式登录浏览器</h2></div>
|
||||
<span class="pill" id="login-desktop-runtime-state">检查中</span>
|
||||
</summary>
|
||||
<div class="collapse-body">
|
||||
<p class="muted compact" style="margin:-4px 0 16px;">通过 noVNC 打开远端浏览器,手动扫码登录抖音,再把登录态保存回系统。适合登录态失效或批量修复。</p>
|
||||
<div class="login-grid">
|
||||
<div class="login-box warning">
|
||||
<div class="action-stack">
|
||||
<div class="status-line"><span class="pill soft">推荐模式</span><span class="pill">noVNC :8788</span></div>
|
||||
<div class="login-lead">在网页中操作远端浏览器完成抖音登录,登录态同步回后台账号系统。</div>
|
||||
<ul class="feature-list"><li>支持扫码、验证码、短信验证等人工步骤</li><li>登录成功后可保存到新账号或覆盖已有账号</li><li>适合登录态失效、环境异常的人工接管</li></ul>
|
||||
<div class="button-row">
|
||||
<button type="button" class="success-button login-desktop-open" data-relogin-unique-id="">打开交互式登录浏览器 ↗</button>
|
||||
<button type="button" class="ghost-button login-desktop-reset">重置登录桌面 ↻</button>
|
||||
<button type="button" class="soft-button login-desktop-save" data-relogin-unique-id="">保存当前登录账号</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<div class="stack-form">
|
||||
<strong>浏览器工作区</strong>
|
||||
<div class="url-field"><input id="desktop-public-url" type="text" value="{{ login_desktop_public_url }}" readonly><button type="button" class="ghost-button url-copy" id="copy-public-url">⧉</button></div>
|
||||
<div class="desktop-frame-wrap"><iframe class="desktop-frame" src="{{ login_desktop_public_url }}" title="交互式登录浏览器工作区" loading="lazy"></iframe></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-box highlight" id="login-desktop-controls" data-public-url="{{ login_desktop_public_url }}" data-csrf-token="{{ csrf_token }}">
|
||||
<div class="card-h" style="margin-bottom:10px"><h3 style="font-size:14px">登录状态</h3></div>
|
||||
<div class="status-list">
|
||||
<div class="status-item" id="desktop-status-checking"><span class="status-dot"></span><span>检查中</span></div>
|
||||
<div class="status-item pending" id="desktop-status-pending"><span class="status-dot"></span><span>待登录</span></div>
|
||||
<div class="status-item success" id="desktop-status-success"><span class="status-dot"></span><span>已登录</span></div>
|
||||
<div class="status-item error" id="desktop-status-error"><span class="status-dot"></span><span>异常</span></div>
|
||||
</div>
|
||||
<div class="info-card"><div id="login-desktop-status-text">正在检查交互式登录桌面状态。</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-list">
|
||||
<a class="link-button" href="/login-workspace">打开登录工作区</a>
|
||||
<a class="ghost-button" href="/accounts">账号与目标管理</a>
|
||||
<a class="ghost-button" href="/settings">运行与系统设置</a>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="soft-button" type="submit">补发未成功目标</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/ops/run-now"
|
||||
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">补发全部对象</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{# ---- account management ---- #}
|
||||
<section class="panel" id="account-management">
|
||||
<div class="sect-h"><div><h2>账号管理</h2><p class="muted compact">点击账号展开管理开关、目标好友、好友缓存、登录同步与删除。共 {{ accounts|length }} 个账号。</p></div></div>
|
||||
{% if accounts %}
|
||||
<div style="display:grid;gap:14px;">
|
||||
{% for account in accounts %}
|
||||
<details class="account-details">
|
||||
<summary>
|
||||
<div class="account-summary">
|
||||
<div class="avatar-photo">{{ account.username[:1] if account.username else "A" }}</div>
|
||||
<div class="account-summary-name"><strong>{{ account.username }}</strong><span>unique_id: {{ account.unique_id }}</span></div>
|
||||
<span class="status-chip {% if account.enabled|default(true) %}success{% else %}warning{% endif %}">{% if account.enabled|default(true) %}已启用{% else %}已停用{% endif %}</span>
|
||||
<div class="account-summary-metrics">
|
||||
<div class="account-metric"><b>{{ account.cookies|length }}</b><span>Cookies</span></div>
|
||||
<div class="account-metric"><b>{{ account.targets|length }}</b><span>目标</span></div>
|
||||
<div class="account-metric"><b>{{ account.friends_cache|default([], true)|length }}</b><span>好友缓存</span></div>
|
||||
</div>
|
||||
<span class="account-chev">▸</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="account-card">
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/update" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><span>显示名</span><input type="text" name="username" value="{{ account.username }}"></label>
|
||||
<label class="check-row"><input type="checkbox" name="enabled" {% if account.enabled|default(true) %}checked{% endif %}><span>启用自动续火花</span></label>
|
||||
<label><span>目标好友(每行一个,可手动编辑或从下方勾选)</span><textarea class="targets-textarea" name="targets" rows="5">{{ account.targets|default([], true)|join('\n') }}</textarea></label>
|
||||
<p class="muted compact">未在好友缓存中的昵称也会保留在目标列表里。</p>
|
||||
<div class="friend-picker" data-account-id="{{ account.unique_id }}" data-refresh-url="/accounts/{{ account.unique_id }}/friends/refresh" data-csrf-token="{{ csrf_token }}" data-updated-at="{{ account.friends_cache_updated_at|default('', true) }}">
|
||||
<div class="friend-picker-toolbar">
|
||||
<div><span class="friend-picker-title">好友选择器</span><p class="muted compact friend-picker-status">{% if account.friends_cache_updated_at %}上次刷新:{{ account.friends_cache_updated_at }}{% else %}还没有读取好友列表{% endif %}</p></div>
|
||||
<button type="button" class="ghost-button friend-refresh-button">刷新好友列表</button>
|
||||
</div>
|
||||
<label class="stack-form" style="gap: 8px;"><span>搜索好友</span><input type="search" class="friend-search-input" placeholder="输入昵称筛选好友"></label>
|
||||
<p class="friend-picker-current-targets muted compact"><strong>当前目标好友:</strong><span>{{ account.targets|join('、') if account.targets else '未选择' }}</span></p>
|
||||
<p class="friend-picker-summary muted compact">已选 0 人</p>
|
||||
<div class="friend-selected-inputs"></div>
|
||||
<div class="friend-picker-list"></div>
|
||||
<script type="application/json" id="friends-cache-{{ account.unique_id }}">{{ account.friends_cache|default([], true)|tojson }}</script>
|
||||
<script type="application/json" id="selected-targets-{{ account.unique_id }}">{{ account.targets|default([], true)|tojson }}</script>
|
||||
</div>
|
||||
<button type="submit">保存账号</button>
|
||||
</form>
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost-button login-desktop-open" data-relogin-unique-id="{{ account.unique_id }}" data-account-name="{{ account.username }}">打开登录浏览器</button>
|
||||
<button type="button" class="ghost-button login-desktop-save" data-relogin-unique-id="{{ account.unique_id }}" data-account-name="{{ account.username }}">保存当前浏览器登录</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/toggle-enabled"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="{% if account.enabled|default(true) %}soft-button{% else %}success-button{% endif %}" type="submit">{% if account.enabled|default(true) %}停用自动续火花{% else %}启用自动续火花{% endif %}</button></form>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/delete"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger-button" type="submit">删除账号</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">当前没有账号。先通过交互式登录浏览器登录并保存账号。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<div class="dashboard-main">
|
||||
<div class="ops-card-grid">
|
||||
<section class="panel"><h2>容器状态</h2><div class="table-shell scrollable" style="margin-top:16px;"><table><thead><tr><th>Name</th><th>Status</th><th>Image</th></tr></thead><tbody>{% for row in ops.containers %}<tr><td>{{ row.Names }}</td><td><span class="pill {% if 'Up' in row.Status %}soft{% else %}warning{% endif %}">{{ row.Status }}</span></td><td>{{ row.Image }}</td></tr>{% else %}<tr><td colspan="3">当前没有可见容器状态。</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="panel"><h2>Cron / 调度</h2><pre class="code-block" style="margin-top:16px;">{{ ops.crontab or "当前没有 crontab 任务。" }}</pre></section>
|
||||
<section class="panel"><h2>日志预览</h2><pre class="code-block light" style="margin-top:16px;min-height:260px;">{{ ops.log_tail or "暂无日志" }}</pre></section>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="dashboard-side">
|
||||
<section class="panel" id="config-panel">
|
||||
<div class="sect-h"><div><h2>运行配置</h2><p class="muted compact">消息模板、随机策略、时间间隔等核心发送参数。</p></div></div>
|
||||
<form method="post" action="/config" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><span>固定消息模板</span><input type="text" name="messageTemplate" value="{{ runtime_config.messageTemplate }}"></label>
|
||||
<label><span>消息变体(每行一个)</span><textarea name="messageVariants" rows="5">{{ runtime_config.sendStrategy.messageVariants|join('\n') }}</textarea></label>
|
||||
<label><span>是否随机打乱目标顺序</span><select name="shuffleTargets"><option value="on" {% if runtime_config.sendStrategy.shuffleTargets %}selected{% endif %}>是</option><option value="" {% if not runtime_config.sendStrategy.shuffleTargets %}selected{% endif %}>否</option></select></label>
|
||||
<div class="settings-grid">
|
||||
<label><span>消息最小间隔(秒)</span><input type="number" name="messageIntervalSecondsMin" min="0" value="{{ runtime_config.sendStrategy.messageIntervalSecondsMin }}"></label>
|
||||
<label><span>消息最大间隔(秒)</span><input type="number" name="messageIntervalSecondsMax" min="0" value="{{ runtime_config.sendStrategy.messageIntervalSecondsMax }}"></label>
|
||||
</div>
|
||||
<button type="submit">保存运行配置</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="ops-panel">
|
||||
<div class="sect-h"><div><h2>运维操作</h2><p class="muted compact">立即补发、刷新代理、查看日志和调整自动发送窗口。</p></div></div>
|
||||
<div class="button-grid">
|
||||
<form method="post" action="/ops/run-failed"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button></form>
|
||||
<form method="post" action="/ops/run-now"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button></form>
|
||||
<form method="post" action="/ops/proxy/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost-button" type="submit">刷新代理订阅</button></form>
|
||||
<form method="post" action="/ops/proxy/restart"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger-button" type="submit">重启代理容器</button></form>
|
||||
<a class="link-button" href="/ops/logs">查看详细日志</a>
|
||||
</div>
|
||||
<form method="post" action="/ops/schedule" class="stack-form" style="margin-top:18px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><span>发送窗口(北京时间,例如 10:00-18:00/10m)</span><input type="text" name="daily_schedule" value="{{ ops.daily_schedule }}" placeholder="10:00-18:00/10m"></label>
|
||||
<button type="submit" class="ghost-button">更新发送窗口</button>
|
||||
</form>
|
||||
<div class="stack-form" style="margin-top:18px;">
|
||||
<label><span>Compose 根目录</span><input type="text" value="{{ ops.compose_root }}" readonly></label>
|
||||
<label><span>Compose 文件路径</span><input type="text" value="{{ ops.compose_file or '未检测到' }}" readonly></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2>服务状态</h2>
|
||||
<p class="muted compact">代理、调度和发送窗口的轻量巡检。</p>
|
||||
<section class="panel" id="settings-panel">
|
||||
<div class="sect-h"><div><h2>面板与服务设置</h2><p class="muted compact">服务连接、日志路径、交互式登录 API 与管理员配置。</p></div></div>
|
||||
<form method="post" action="/settings" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="settings-grid">
|
||||
<label><span>服务器 Host</span><input type="text" name="server_host" value="{{ app_settings.server_host }}"></label>
|
||||
<label><span>服务器用户名</span><input type="text" name="server_username" value="{{ app_settings.server_username }}"></label>
|
||||
<label><span>服务器密码</span><input type="password" name="server_password" value="{{ app_settings.server_password }}"></label>
|
||||
<label><span>Compose 根目录</span><input type="text" name="compose_root" value="{{ app_settings.compose_root }}"></label>
|
||||
<label><span>任务日志文件</span><input type="text" name="ops_log_file" value="{{ app_settings.ops_log_file }}"></label>
|
||||
<label><span>代理刷新脚本</span><input type="text" name="proxy_refresh_script" value="{{ app_settings.proxy_refresh_script }}"></label>
|
||||
<label><span>交互式登录桌面 API 地址</span><input type="text" name="login_desktop_api_url" value="{{ app_settings.login_desktop_api_url }}"></label>
|
||||
<label><span>Web UI 端口</span><input type="number" min="1" max="65535" name="ui_port" value="{{ app_settings.ui_port }}"></label>
|
||||
<label><span>修改管理员密码</span><input type="password" name="new_password" placeholder="留空表示不修改"></label>
|
||||
<label><span>确认新密码</span><input type="password" name="confirm_password" placeholder="再次输入新密码"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div class="summary-row">
|
||||
<div>
|
||||
<strong>代理容器</strong>
|
||||
<span
|
||||
>{{ proxy_rows[0].Status if proxy_rows else "未检测到 mihomo"
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="status-chip {% if proxy_rows and 'Up' in proxy_rows[0].Status %}success{% else %}warning{% endif %}"
|
||||
>
|
||||
{% if proxy_rows and 'Up' in proxy_rows[0].Status %}运行中{% else
|
||||
%}需检查{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<div>
|
||||
<strong>发送窗口</strong>
|
||||
<span>{{ ops.daily_schedule or "未配置" }}</span>
|
||||
</div>
|
||||
<a class="soft-button" href="/settings">调整</a>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<div>
|
||||
<strong>待处理目标</strong>
|
||||
<span>{{ pending_total }} 个目标等待发送或调度</span>
|
||||
</div>
|
||||
<a class="ghost-button" href="/ops/send-console">查看</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;"><button type="submit">保存设置</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
const csrfToken = root.dataset.csrfToken || "";
|
||||
const publicUrl = root.dataset.publicUrl || "";
|
||||
const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
|
||||
const statusTextEl = document.getElementById("login-desktop-status-text");
|
||||
const openButtons = document.querySelectorAll(".login-desktop-open");
|
||||
const saveButtons = document.querySelectorAll(".login-desktop-save");
|
||||
const resetButtons = document.querySelectorAll(".login-desktop-reset");
|
||||
const copyPublicUrlButton = document.getElementById("copy-public-url");
|
||||
const statusMap = {
|
||||
checking: document.getElementById("desktop-status-checking"),
|
||||
pending: document.getElementById("desktop-status-pending"),
|
||||
success: document.getElementById("desktop-status-success"),
|
||||
error: document.getElementById("desktop-status-error"),
|
||||
};
|
||||
const setVisualStatus = (state) => {
|
||||
Object.values(statusMap).forEach((node) => { if (node) node.style.opacity = "0.42"; });
|
||||
if (statusMap[state]) statusMap[state].style.opacity = "1";
|
||||
};
|
||||
const setStatus = (text, tone = "", state = "checking") => {
|
||||
if (statusTextEl) statusTextEl.textContent = text;
|
||||
if (runtimeStateEl) {
|
||||
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
runtimeStateEl.textContent = state === "success" ? "已登录" : (state === "error" ? "异常" : (state === "pending" ? "待登录" : "检查中"));
|
||||
}
|
||||
setVisualStatus(state);
|
||||
};
|
||||
const postForm = async (url, payload = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
Object.entries(payload).forEach(([key, value]) => formData.set(key, String(value ?? "")));
|
||||
const response = await fetch(url, { method: "POST", body: formData, credentials: "same-origin" });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false) throw new Error(data.error || `request failed: ${response.status}`);
|
||||
return data;
|
||||
};
|
||||
const openDesktopWindow = () => {
|
||||
const popup = window.open(publicUrl, "_blank");
|
||||
if (!popup) { setStatus("浏览器阻止了交互式登录窗口,请允许弹窗后再试。", "danger", "error"); return false; }
|
||||
return true;
|
||||
};
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/login-desktop/status", { credentials: "same-origin" });
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) { setStatus(data.error || "交互式登录桌面不可用", "warning", "error"); return; }
|
||||
if (data.logged_in) setStatus(`当前浏览器已登录:${data.username}(${data.unique_id})`, "soft", "success");
|
||||
else setStatus("当前浏览器尚未登录,可打开交互式登录浏览器开始人工登录。", "", "pending");
|
||||
} catch (error) { setStatus(`状态检查失败:${error.message}`, "danger", "error"); }
|
||||
};
|
||||
if (copyPublicUrlButton) {
|
||||
copyPublicUrlButton.addEventListener("click", async () => {
|
||||
try { await navigator.clipboard.writeText(publicUrl); setStatus("交互式登录地址已复制,可在新标签页打开。", "soft", "pending"); }
|
||||
catch (error) { setStatus(`复制失败:${error.message}`, "warning", "error"); }
|
||||
});
|
||||
}
|
||||
openButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(button.dataset.reloginUniqueId || "").trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
openDesktopWindow();
|
||||
setStatus(reloginUniqueId ? `已打开交互式登录浏览器,请使用账号 ${accountName || reloginUniqueId} 完成登录。` : "已打开交互式登录浏览器,请在远端浏览器中完成抖音创作者中心登录。", "", "pending");
|
||||
} catch (error) { setStatus(`打开交互式登录浏览器失败:${error.message}`, "danger", "error"); }
|
||||
});
|
||||
});
|
||||
saveButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(button.dataset.reloginUniqueId || "").trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
const data = await postForm("/login-desktop/save", { relogin_unique_id: reloginUniqueId });
|
||||
setStatus(reloginUniqueId ? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}` : `已保存当前登录账号:${data.account?.username || ""}`, "soft", "success");
|
||||
window.setTimeout(() => window.location.reload(), 800);
|
||||
} catch (error) { setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error"); }
|
||||
});
|
||||
});
|
||||
resetButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try { await postForm("/login-desktop/reset"); setStatus("交互式登录桌面已重置,正在重新初始化浏览器。", "warning", "checking"); await pollStatus(); }
|
||||
catch (error) { setStatus(`重置交互式登录桌面失败:${error.message}`, "danger", "error"); }
|
||||
});
|
||||
});
|
||||
setVisualStatus("checking");
|
||||
pollStatus();
|
||||
window.setInterval(pollStatus, 5000);
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const pickers = document.querySelectorAll(".friend-picker");
|
||||
if (!pickers.length) return;
|
||||
const parseJsonScript = (id) => { const el = document.getElementById(id); if (!el) return []; try { return JSON.parse(el.textContent || "[]"); } catch (e) { return []; } };
|
||||
const escapeHtml = (v) => v.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
|
||||
pickers.forEach((picker) => {
|
||||
const accountId = picker.dataset.accountId;
|
||||
const refreshUrl = picker.dataset.refreshUrl;
|
||||
const csrfToken = picker.dataset.csrfToken;
|
||||
const searchInput = picker.querySelector(".friend-search-input");
|
||||
const refreshButton = picker.querySelector(".friend-refresh-button");
|
||||
const listEl = picker.querySelector(".friend-picker-list");
|
||||
const summaryEl = picker.querySelector(".friend-picker-summary");
|
||||
const statusEl = picker.querySelector(".friend-picker-status");
|
||||
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
|
||||
const formEl = picker.closest("form");
|
||||
const targetsTextarea = formEl?.querySelector(".targets-textarea");
|
||||
const currentTargetsEl = picker.querySelector(".friend-picker-current-targets span");
|
||||
let friends = parseJsonScript(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
|
||||
const combinedFriends = () => { const merged = []; const seen = new Set(); [...selected, ...friends].forEach((name) => { if (!name || seen.has(name)) return; seen.add(name); merged.push(name); }); return merged; };
|
||||
const splitTargetText = (value) => { const seen = new Set(); return String(value || "").replaceAll(",", "\n").split(/\r?\n/).map((n) => n.trim()).filter((n) => { if (!n || seen.has(n)) return false; seen.add(n); return true; }); };
|
||||
const syncTextareaFromSelected = () => { if (targetsTextarea) targetsTextarea.value = [...selected].join("\n"); };
|
||||
const syncSelectedFromTextarea = () => { if (targetsTextarea) selected = new Set(splitTargetText(targetsTextarea.value)); };
|
||||
const renderHiddenInputs = () => { hiddenInputsEl.innerHTML = ""; [...selected].forEach((name) => { const input = document.createElement("input"); input.type = "hidden"; input.name = "targets"; input.value = name; hiddenInputsEl.appendChild(input); }); };
|
||||
const updateSummary = () => { summaryEl.textContent = `已选 ${selected.size} 人`; if (currentTargetsEl) currentTargetsEl.textContent = selected.size ? [...selected].join("、") : "未选择"; };
|
||||
const renderList = () => {
|
||||
const query = (searchInput.value || "").trim().toLowerCase();
|
||||
const allNames = combinedFriends();
|
||||
const displayNames = allNames.filter((n) => n.toLowerCase().includes(query));
|
||||
renderHiddenInputs(); updateSummary();
|
||||
if (!allNames.length) { listEl.innerHTML = '<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>'; return; }
|
||||
if (!displayNames.length) { listEl.innerHTML = '<div class="friend-picker-empty">没有匹配的好友。</div>'; return; }
|
||||
listEl.innerHTML = displayNames.map((name) => `<label class="friend-option ${selected.has(name) ? "selected" : ""}"><span>${escapeHtml(name)}</span><input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}></label>`).join("");
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const option = cb.closest(".friend-option"); const value = cb.value;
|
||||
if (cb.checked) { selected.add(value); option?.classList.add("selected"); } else { selected.delete(value); option?.classList.remove("selected"); }
|
||||
syncTextareaFromSelected(); renderHiddenInputs(); updateSummary();
|
||||
});
|
||||
});
|
||||
};
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
refreshButton.disabled = true; const orig = refreshButton.textContent; refreshButton.textContent = "刷新中...";
|
||||
statusEl.textContent = "正在实时读取好友列表…";
|
||||
try {
|
||||
const formData = new FormData(); formData.set("csrf_token", csrfToken);
|
||||
const response = await fetch(refreshUrl, { method: "POST", body: formData, credentials: "same-origin" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "刷新好友列表失败");
|
||||
friends = Array.isArray(payload.friends) ? payload.friends : [];
|
||||
statusEl.textContent = payload.message || `已刷新 ${friends.length} 个好友`;
|
||||
renderList();
|
||||
} catch (error) { statusEl.textContent = error.message || "刷新好友列表失败"; }
|
||||
finally { refreshButton.disabled = false; refreshButton.textContent = orig; }
|
||||
});
|
||||
searchInput.addEventListener("input", renderList);
|
||||
if (targetsTextarea) targetsTextarea.addEventListener("input", () => { syncSelectedFromTextarea(); renderList(); });
|
||||
syncSelectedFromTextarea(); renderList();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,71 +1,168 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 | 自动续火花控制台</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle login-theme-toggle"
|
||||
data-theme-toggle
|
||||
aria-label="切换黑夜模式"
|
||||
title="切换黑夜模式"
|
||||
>
|
||||
<span class="theme-icon theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon theme-moon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 | 续火花控制台</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #ff7a3d;
|
||||
--primary-strong: #ff4d6d;
|
||||
--text: #eaf0ff;
|
||||
--text-soft: #9aa6c8;
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--fire-grad: linear-gradient(135deg,#ffb04a 0%,#ff7a3d 30%,#ff4d6d 65%,#ff2da8 100%);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
||||
font-family: "Inter", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
<main class="login-card">
|
||||
<div class="brand login-brand">
|
||||
<div class="brand-mark">⚡</div>
|
||||
/* ---- left brand panel ---- */
|
||||
.brand-panel {
|
||||
position: relative; overflow: hidden;
|
||||
padding: 48px 52px;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(680px circle at 12% 12%, rgba(255,122,61,0.45), transparent 55%),
|
||||
radial-gradient(620px circle at 95% 92%, rgba(255,45,168,0.32), transparent 50%),
|
||||
linear-gradient(205deg, #1a1230 0%, #140e26 56%, #0c0a1a 100%);
|
||||
display: flex; flex-direction: column; justify-content: space-between; gap: 32px;
|
||||
}
|
||||
.brand-panel::after {
|
||||
content: ""; position: absolute; right: -120px; bottom: -120px;
|
||||
width: 360px; height: 360px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,77,109,0.32), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-top { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
|
||||
.brand-mark {
|
||||
width: 46px; height: 46px; border-radius: 14px;
|
||||
background: var(--fire-grad); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; font-weight: 800;
|
||||
box-shadow: 0 16px 32px rgba(255,77,109,0.45);
|
||||
}
|
||||
.brand-title { font-size: 18px; font-weight: 800; letter-spacing: 0.01em; }
|
||||
.brand-sub { margin-top: 3px; font-size: 12.5px; color: rgba(255,255,255,0.55); }
|
||||
|
||||
.brand-hero { position: relative; z-index: 1; max-width: 440px; }
|
||||
.brand-hero h1 {
|
||||
margin: 0 0 16px; font-size: 36px; line-height: 1.18; font-weight: 800; letter-spacing: -0.02em;
|
||||
}
|
||||
.brand-hero h1 em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(120deg,#ffd9a0,#ff7a3d 50%,#ff2da8);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.brand-hero p { margin: 0; font-size: 15px; line-height: 1.8; color: rgba(255,255,255,0.72); }
|
||||
.brand-features { position: relative; z-index: 1; display: grid; gap: 14px; margin-top: 8px; }
|
||||
.brand-feature { display: flex; align-items: center; gap: 12px; font-size: 13.5px; color: rgba(255,255,255,0.82); }
|
||||
.brand-feature i {
|
||||
font-style: normal; width: 26px; height: 26px; border-radius: 8px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14);
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
}
|
||||
.brand-foot { position: relative; z-index: 1; font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* ---- right form panel ---- */
|
||||
.form-panel {
|
||||
display: flex; align-items: center; justify-content: center; padding: 40px 28px;
|
||||
background:
|
||||
radial-gradient(720px circle at 100% -10%, rgba(255,122,61,0.10), transparent 45%),
|
||||
linear-gradient(180deg, #0a0e1c 0%, #0b1120 100%);
|
||||
}
|
||||
.card { width: min(94vw, 420px); }
|
||||
.card-title { margin: 0 0 6px; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
|
||||
.card-lead { margin: 0 0 24px; color: var(--text-soft); line-height: 1.6; font-size: 14px; }
|
||||
form { display: grid; gap: 15px; }
|
||||
label { display: grid; gap: 7px; }
|
||||
label > span { font-size: 13px; color: var(--text-soft); font-weight: 600; }
|
||||
input {
|
||||
width: 100%; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 12px 14px; color: var(--text); outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
input::placeholder { color: var(--text-soft); }
|
||||
input:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(255,122,61,0.22); }
|
||||
button {
|
||||
margin-top: 4px; border: none; border-radius: 12px; padding: 13px 14px;
|
||||
background: var(--fire-grad); color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
|
||||
box-shadow: 0 14px 28px rgba(255,77,109,0.36);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 18px 34px rgba(255,77,109,0.44); filter: brightness(1.05); }
|
||||
button:active { transform: translateY(0); }
|
||||
.flash { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; font-size: 13px; border: 1px solid transparent; }
|
||||
.flash.success { background: rgba(43,212,127,0.14); color: #2bd47f; border-color: rgba(43,212,127,0.28); }
|
||||
.flash.warning { background: rgba(255,181,71,0.14); color: #ffb547; border-color: rgba(255,181,71,0.28); }
|
||||
.flash.error { background: rgba(255,84,112,0.14); color: #ff5470; border-color: rgba(255,84,112,0.28); }
|
||||
.foot { margin-top: 20px; text-align: center; font-size: 12px; color: #64709a; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body { grid-template-columns: 1fr; }
|
||||
.brand-panel { padding: 32px 28px; }
|
||||
.brand-hero h1 { font-size: 26px; }
|
||||
.brand-features { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="brand-panel">
|
||||
<div class="brand-top">
|
||||
<div class="brand-mark">🔥</div>
|
||||
<div>
|
||||
<div class="brand-title">自动续火花</div>
|
||||
<div class="brand-subtitle">多账号运维面板</div>
|
||||
<div class="brand-title">续火花</div>
|
||||
<div class="brand-sub">多账号控制平台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>控制台登录</h1>
|
||||
<p class="muted">
|
||||
用于管理抖音多账号、目标好友、自动续火花任务与运维配置。
|
||||
</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }} login-flash">
|
||||
{{ flash.message }}
|
||||
<div class="brand-hero">
|
||||
<h1>让每个好友<br><em>都不再断火花</em></h1>
|
||||
<p>集中管理抖音多账号、目标好友与自动续火花任务,定时发送、失败补发、登录同步一站式运维。</p>
|
||||
<div class="brand-features">
|
||||
<div class="brand-feature"><i>✦</i><span>多账号批量发送与定时调度</span></div>
|
||||
<div class="brand-feature"><i>↻</i><span>失败目标自动补发与重试队列</span></div>
|
||||
<div class="brand-feature"><i>🔥</i><span>网页内交互式登录与登录态同步</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-foot">DouYin Spark Flow · Admin Console</div>
|
||||
</aside>
|
||||
|
||||
<main class="form-panel">
|
||||
<div class="card">
|
||||
<h2 class="card-title">控制台登录</h2>
|
||||
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap">
|
||||
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
|
||||
<label><span>管理员密码</span><input type="password" name="password"></label>
|
||||
<label><span>确认密码</span><input type="password" name="confirm_password"></label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login">
|
||||
<label><span>管理员用户名</span><input type="text" name="username" value="admin"></label>
|
||||
<label><span>管理员密码</span><input type="password" name="password"></label>
|
||||
<button type="submit">登录控制台</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div>
|
||||
</div>
|
||||
{% endif %} {% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<label>
|
||||
<span>确认密码</span>
|
||||
<input type="password" name="confirm_password" />
|
||||
</label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<button type="submit">登录控制台</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</main>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,19 +3,216 @@
|
||||
{% block nav_key %}logs{% endblock %}
|
||||
{% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %}
|
||||
{% block page_title %}运行日志{% endblock %}
|
||||
{% block page_subtitle %}查看最近任务输出和服务诊断信息{% endblock %}
|
||||
{% block page_subtitle %}登录同步 · 发送过程 · 容器状态{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/">⌂ 返回首页</a>
|
||||
<a class="ghost-button" href="/ops/send-console">✦ 发送控制台</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.logs-toolbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logs-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.logs-meta .dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--fire-grad);
|
||||
box-shadow: 0 0 0 4px rgba(255, 122, 61, 0.14);
|
||||
}
|
||||
.logs-meta .dot.live {
|
||||
background: linear-gradient(135deg, #18a558, #4fd08a);
|
||||
box-shadow: 0 0 0 4px rgba(24, 165, 88, 0.16);
|
||||
animation: livePulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
.toggle-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.toggle-track {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-strong);
|
||||
position: relative;
|
||||
transition: background 0.18s ease;
|
||||
flex: 0 0 42px;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 5px rgba(20, 35, 70, 0.18);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.toggle-switch input { display: none; }
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: linear-gradient(180deg, var(--primary), var(--primary-strong));
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.logs-code {
|
||||
margin: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: #0f1728;
|
||||
color: #d8e2fb;
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
min-height: 420px;
|
||||
max-height: 70vh;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
tab-size: 2;
|
||||
}
|
||||
.logs-code:empty::before {
|
||||
content: "暂无日志输出";
|
||||
color: #5f6f8c;
|
||||
}
|
||||
.logs-empty {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--empty-bg);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>详细日志</h2>
|
||||
<p class="muted compact">
|
||||
展示最近的任务输出,便于检查登录同步、发送过程和容器状态。
|
||||
</p>
|
||||
<p class="muted compact">展示最近的任务输出,便于检查登录同步、发送过程和容器状态。</p>
|
||||
</div>
|
||||
<a class="link-button" href="/settings">返回系统设置</a>
|
||||
</div>
|
||||
<pre class="code-block tall">{{ log_tail or "暂无日志" }}</pre>
|
||||
|
||||
<div class="logs-toolbar">
|
||||
<div class="logs-toolbar__left">
|
||||
<span class="logs-meta"><span class="dot" id="logs-live-dot"></span><span id="logs-status-text">最近更新:刚刚</span></span>
|
||||
<span class="logs-meta"><span class="dot"></span><span id="logs-line-count">{{ (log_tail|trim).splitlines()|length }} 行</span></span>
|
||||
</div>
|
||||
<div class="logs-toolbar__left">
|
||||
<label class="toggle-switch" title="每 5 秒自动刷新日志">
|
||||
<input type="checkbox" id="logs-autorefresh">
|
||||
<span class="toggle-track"></span>
|
||||
<span>自动刷新</span>
|
||||
</label>
|
||||
<button type="button" class="ghost-button" id="logs-refresh-btn">↻ 刷新</button>
|
||||
<a class="ghost-button" id="logs-download-btn" href="data:text/plain;charset=utf-8,{{ log_tail|urlencode }}">⬇ 下载日志</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if log_tail and log_tail.strip() %}
|
||||
<pre class="logs-code" id="logs-code">{{ log_tail }}</pre>
|
||||
{% else %}
|
||||
<div class="logs-empty" id="logs-empty">当前没有日志输出。任务运行后这里会实时显示。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const codeEl = document.getElementById("logs-code");
|
||||
const emptyEl = document.getElementById("logs-empty");
|
||||
const statusEl = document.getElementById("logs-status-text");
|
||||
const lineEl = document.getElementById("logs-line-count");
|
||||
const liveDot = document.getElementById("logs-live-dot");
|
||||
const toggle = document.getElementById("logs-autorefresh");
|
||||
const refreshBtn = document.getElementById("logs-refresh-btn");
|
||||
let timer = null;
|
||||
|
||||
const fmtTime = () => {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `最近更新:${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
const renderBody = (text) => {
|
||||
const has = !!(text && text.trim());
|
||||
if (has) {
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
if (codeEl) {
|
||||
codeEl.style.display = "";
|
||||
codeEl.textContent = text;
|
||||
}
|
||||
if (lineEl) lineEl.textContent = `${text.trim().split(/\r?\n/).length} 行`;
|
||||
} else {
|
||||
if (codeEl) codeEl.style.display = "none";
|
||||
if (emptyEl) emptyEl.style.display = "";
|
||||
if (lineEl) lineEl.textContent = "0 行";
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const res = await fetch("/ops/logs", { credentials: "same-origin", headers: { "Accept": "text/html" } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const fetched = doc.getElementById("logs-code");
|
||||
const fetchedEmpty = doc.getElementById("logs-empty");
|
||||
if (fetched) {
|
||||
renderBody(fetched.textContent);
|
||||
} else if (fetchedEmpty) {
|
||||
renderBody("");
|
||||
}
|
||||
if (statusEl) statusEl.textContent = fmtTime();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = `刷新失败:${err.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
const setLive = (on) => {
|
||||
if (liveDot) liveDot.classList.toggle("live", on);
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (on) {
|
||||
timer = setInterval(refresh, 5000);
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
if (toggle) toggle.addEventListener("change", () => setLive(toggle.checked));
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", refresh);
|
||||
if (statusEl) statusEl.textContent = fmtTime();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,96 +1,424 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}send_console{% endblock %}
|
||||
{% block title %}发送明细 | 自动续火花{% endblock %}
|
||||
{% block page_title %}发送明细{% endblock %}
|
||||
{% block page_subtitle %}今日发送明细、失败队列和针对性重试{% endblock %}
|
||||
{% block title %}发送控制台{% endblock %}
|
||||
{% block page_title %}发送控制台{% endblock %}
|
||||
{% block page_subtitle %}今日发送总览{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/">返回概览</a>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="soft-button" type="submit">补发未成功目标</button>
|
||||
<a class="ghost-button" href="/">返回首页</a>
|
||||
<form method="post" action="/ops/run-failed">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未成功目标</button>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/ops/run-now"
|
||||
data-confirm="补发全部对象会对所有启用账号的全部目标重新发送一遍,确认继续?"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="danger-button" type="submit">补发全部对象</button>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="soft-button" type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发未发送目标</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" {% if ops.task_lock and ops.task_lock.running %}disabled{% endif %}>补发全部对象</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.task-state-banner {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.task-state-banner.success { background: var(--success-soft); color: var(--success); }
|
||||
.task-state-banner.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.task-state-banner.info { background: var(--info-soft); color: var(--info); }
|
||||
</style>
|
||||
|
||||
{% set task_lock = ops.task_lock %}
|
||||
{% if task_lock and task_lock.running %}
|
||||
<div class="task-state-banner warning">发送任务运行中:pid {{ task_lock.pid or "unknown" }},已运行约 {{ task_lock.ageSeconds }} 秒。补发按钮会等任务结束后再可用。</div>
|
||||
{% elif task_lock and task_lock.staleRemoved %}
|
||||
<div class="task-state-banner info">已自动清理一个过期任务锁,可以重新发起补发。</div>
|
||||
{% else %}
|
||||
<div class="task-state-banner success">当前没有发送任务运行。</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% set send_console = ops.send_console %}
|
||||
<style>
|
||||
.console-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.console-overview {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.console-stats .stat-card {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.console-kpi-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.console-account-list {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
padding: 20px 20px 22px;
|
||||
border-left: 4px solid var(--border-strong);
|
||||
}
|
||||
.account-panel.is-success { border-left-color: var(--success); }
|
||||
.account-panel.is-warning { border-left-color: var(--warning); }
|
||||
.account-panel.is-danger { border-left-color: var(--danger); }
|
||||
|
||||
.account-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: -2px 0 16px;
|
||||
}
|
||||
.account-progress__bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-tint);
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-progress__bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--fire-grad);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.account-progress__label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.account-progress__label b { color: var(--text); }
|
||||
|
||||
.account-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.account-panel__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--fire-grad);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 16px rgba(255, 77, 109, 0.28);
|
||||
}
|
||||
|
||||
.account-panel__name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.account-panel__subline {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-panel__subline code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
background: var(--surface-tint);
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.account-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-panel__collapse {
|
||||
color: var(--text-faint);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.account-alert,
|
||||
.friend-scan-meta {
|
||||
margin: -4px 0 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text-main);
|
||||
background: var(--surface-tint);
|
||||
}
|
||||
|
||||
.account-alert {
|
||||
border-color: rgba(255, 185, 70, 0.42);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.account-alert strong,
|
||||
.friend-scan-meta strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.account-alert__line,
|
||||
.friend-scan-meta__line {
|
||||
margin-top: 4px;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 12px;
|
||||
}
|
||||
|
||||
.data-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__title i {
|
||||
font-style: normal;
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.data-card__title.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.data-card__title.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.data-card__title.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.data-card__title.info {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.data-card__table {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.data-card__table td:first-child,
|
||||
.data-card__table th:first-child {
|
||||
min-width: 176px;
|
||||
}
|
||||
|
||||
.data-card__table td:last-child,
|
||||
.data-card__table th:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-card__footer {
|
||||
padding: 10px 18px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__footer-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__footer-button:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.data-card__footer-button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-card__extra {
|
||||
display: none;
|
||||
padding: 0 18px 16px;
|
||||
}
|
||||
|
||||
.data-card__extra.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-card__extra .table-shell {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.console-empty {
|
||||
padding: 46px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
border-radius: 18px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--empty-bg);
|
||||
}
|
||||
|
||||
.console-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
padding: 6px 0 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.console-pagination {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-page {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.console-page.active {
|
||||
background: linear-gradient(180deg, #3d79ff, #2f67ff);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 10px 18px rgba(47, 103, 255, 0.18);
|
||||
}
|
||||
|
||||
.console-page.ghost {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.account-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="console-shell">
|
||||
<section class="console-overview">
|
||||
<div class="overview-time muted">
|
||||
当前时间:{{ send_console.now|replace('T', ' ') }}
|
||||
</div>
|
||||
<div class="overview-time">当前时间:{{ send_console.now|replace('T', ' ') }}</div>
|
||||
|
||||
<div class="stats-grid console-stats">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.enabled_accounts }}</strong
|
||||
>
|
||||
<span class="stat-help">当前可发送账号</span>
|
||||
<span class="stat-label">总目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.total_targets }}</strong>
|
||||
<span class="stat-help">启用账号 {{ send_console.summary.enabled_accounts }} 个 · 今日发送账本总量</span>
|
||||
</div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
<div class="stat-icon blue"><span class="console-kpi-icon">👥</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_sent_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">写回发送历史</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_console.summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
|
||||
>
|
||||
✓
|
||||
<span class="stat-label">今日成功目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
|
||||
<span class="stat-help positive">已完成 {{ send_console.summary.today_sent_targets }}/{{ send_console.summary.total_targets }}</span>
|
||||
</div>
|
||||
<div class="stat-icon green"><span class="console-kpi-icon">🎯</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_failed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">按失败分类重试</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_console.summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
|
||||
>
|
||||
!
|
||||
<strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
|
||||
<span class="stat-help negative">按失败分类进入补发与重试</span>
|
||||
</div>
|
||||
<div class="stat-icon red"><span class="console-kpi-icon">⚠</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_pending_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">窗口内按计划发送</span>
|
||||
<span class="stat-label">未完成目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_remaining_targets }}</strong>
|
||||
<span class="stat-help">正常待发 {{ send_console.summary.today_pending_targets + send_console.summary.today_unprocessed_targets }} 个 · 账号异常影响 {{ send_console.summary.today_account_blocked_targets or 0 }} 个</span>
|
||||
</div>
|
||||
<div class="stat-icon orange">◔</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">未处理</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_unprocessed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">等待调度</span>
|
||||
</div>
|
||||
<div class="stat-icon gray">…</div>
|
||||
<div class="stat-icon orange"><span class="console-kpi-icon">◔</span></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -98,40 +426,81 @@
|
||||
{% if send_console.accounts %}
|
||||
<section class="console-account-list">
|
||||
{% for account in send_console.accounts %}
|
||||
<article class="panel account-panel">
|
||||
{% set at = account.total_targets or 1 %}
|
||||
{% set ad = account.sent_targets|length %}
|
||||
{% set ap = (ad / at * 100)|round(0)|int %}
|
||||
{% set panel_state = 'is-danger' if (account.account_paused or account.account_failure) else ('is-warning' if account.failed_targets|length else ('is-success' if ad == account.total_targets else 'is-warning')) %}
|
||||
<article class="panel account-panel {{ panel_state }}">
|
||||
<div class="account-panel__header">
|
||||
<div class="account-panel__meta">
|
||||
<div class="account-avatar">{{ (account.username or 'A')[:1] }}</div>
|
||||
<div>
|
||||
<div class="account-panel__name">{{ account.username }}</div>
|
||||
<div class="account-panel__subline">
|
||||
unique_id: {{ account.unique_id }}
|
||||
<span>unique_id:</span>
|
||||
<code>{{ account.unique_id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<span class="status-chip success"
|
||||
>成功 {{ account.sent_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip danger"
|
||||
>失败 {{ account.failed_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip warning"
|
||||
>待发送 {{ account.pending_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip info"
|
||||
>未处理 {{ account.unprocessed_targets|length }}</span
|
||||
>
|
||||
<div class="account-panel__status">
|
||||
<span class="status-chip success">成功 {{ account.sent_targets|length }}</span>
|
||||
<span class="status-chip info">完成 {{ account.sent_targets|length }}/{{ account.total_targets }}</span>
|
||||
<span class="status-chip danger">失败 {{ account.failed_targets|length }}</span>
|
||||
<span class="status-chip warning">待发送 {{ account.pending_targets|length }}</span>
|
||||
<span class="status-chip info">未处理 {{ account.unprocessed_targets|length }}</span>
|
||||
{% if account.account_blocked_targets %}
|
||||
<span class="status-chip warning">账号影响 {{ account.account_blocked_targets|length }}</span>
|
||||
{% endif %}
|
||||
{% if account.account_paused %}
|
||||
<span class="status-chip danger">账号暂停</span>
|
||||
{% elif account.account_failure %}
|
||||
<span class="status-chip warning">账号异常</span>
|
||||
{% endif %}
|
||||
<span class="account-panel__collapse">⌃</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if account.account_failure %}
|
||||
<div class="account-alert">
|
||||
<strong>账号级页面异常:{{ account.account_failure.category or "-" }}</strong>
|
||||
<div class="account-alert__line">
|
||||
今日尝试 {{ account.account_failure.attemptCount or 0 }}/{{ account.account_failure_pause_after }}
|
||||
{% if account.account_paused %},已暂停今日自动发送{% endif %}
|
||||
· lastAttemptAt {{ account.account_failure.lastAttemptAt or "-" }}
|
||||
</div>
|
||||
<div class="account-alert__line">原因:{{ account.account_failure.reason or "-" }}</div>
|
||||
{% if account.account_failure.affectedTargets %}
|
||||
<div class="account-alert__line">影响目标:{{ account.account_failure.affectedTargets|join(", ") }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if account.friend_index_meta.lastScanAt %}
|
||||
<div class="friend-scan-meta">
|
||||
<strong>
|
||||
好友索引:{{ "完整" if account.friend_index_meta.lastScanComplete else "未完整" }}
|
||||
· 已记录 {{ account.friend_index_count }} 个
|
||||
</strong>
|
||||
<div class="friend-scan-meta__line">
|
||||
lastScanAt {{ account.friend_index_meta.lastScanAt }}
|
||||
· scannedCount {{ account.friend_index_meta.scannedCount }}
|
||||
</div>
|
||||
{% if account.friend_index_meta.missingTargets %}
|
||||
<div class="friend-scan-meta__line">索引扫描后仍缺失:{{ account.friend_index_meta.missingTargets|join(", ") }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="account-progress">
|
||||
<span class="account-progress__label">完成进度 <b>{{ ad }}/{{ account.total_targets }}</b> · {{ ap }}%</span>
|
||||
<span class="account-progress__bar"><i style="width:{{ ap }}%"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="account-grid">
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title success">
|
||||
<i>●</i><span>今日已成功</span>
|
||||
</div>
|
||||
<div class="data-card__title success"><i>●</i><span>A. 今日已成功</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -150,27 +519,22 @@
|
||||
<td>{{ item.sentAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">今日暂无成功目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="3">今日暂无成功目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.sent_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="sent-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.sent_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.sent_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.sent_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.sent_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="sent-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -195,11 +559,9 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title danger">
|
||||
<i>▲</i><span>失败待补发</span>
|
||||
</div>
|
||||
<div class="data-card__title danger"><i>▲</i><span>B. 失败待补发</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<div class="table-shell data-card__table scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -218,46 +580,30 @@
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/retry-target"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token }}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="target"
|
||||
value="{{ item.target }}"
|
||||
/>
|
||||
<button type="submit" class="soft-button">重试</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="soft-button retry-button">重试此目标</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">暂无失败待补发目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="5">暂无失败待补发目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.failed_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="failed-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.failed_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.failed_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.failed_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.failed_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="failed-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -276,21 +622,10 @@
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/retry-target"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token }}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="target"
|
||||
value="{{ item.target }}"
|
||||
/>
|
||||
<button type="submit" class="soft-button">重试</button>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="soft-button retry-button">重试此目标</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -303,9 +638,7 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title warning">
|
||||
<i>◔</i><span>今日待发送</span>
|
||||
</div>
|
||||
<div class="data-card__title warning"><i>◔</i><span>C. 今日待发送</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -322,27 +655,22 @@
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">今日暂无待发送目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="2">今日暂无待发送目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.pending_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="pending-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.pending_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.pending_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.pending_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.pending_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="pending-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -365,9 +693,7 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title info">
|
||||
<i>◌</i><span>今日未处理</span>
|
||||
</div>
|
||||
<div class="data-card__title info"><i>◌</i><span>D. 今日未处理</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -384,27 +710,22 @@
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">今日暂无未处理目标</td>
|
||||
</tr>
|
||||
<tr><td colspan="2">今日暂无未处理目标</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.unprocessed_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="unprocessed-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.unprocessed_targets|length }})"
|
||||
>
|
||||
查看全部({{ account.unprocessed_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if account.unprocessed_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.unprocessed_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="unprocessed-{{ loop.index0 }}">
|
||||
<div class="table-shell">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -424,16 +745,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if account.account_blocked_targets %}
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title warning"><i>!</i><span>E. 账号异常影响</span></div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>scheduledAt</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.account_blocked_targets[:5] %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
<td>{{ "本次账号异常重试目标" if item.accountFailureAffected else "因账号暂停尚未处理" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="blocked-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.account_blocked_targets|length }})"
|
||||
{% if account.account_blocked_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.account_blocked_targets|length }})</button>
|
||||
</div>
|
||||
<div class="data-card__extra" id="blocked-{{ loop.index0 }}">
|
||||
<div class="table-shell scrollable">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标</th>
|
||||
<th>scheduledAt</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in account.account_blocked_targets %}
|
||||
<tr>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
<td>{{ "本次账号异常重试目标" if item.accountFailureAffected else "因账号暂停尚未处理" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="console-footer">共 {{ send_console.accounts|length }} 个账号</div>
|
||||
{% else %}
|
||||
<div class="console-empty empty-state">
|
||||
当前没有启用账号,暂无可展示的发送明细。
|
||||
<div class="console-footer">
|
||||
<span>共 {{ send_console.accounts|length }} 个启用账号 · 数据按今日发送状态实时汇总</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="console-empty">当前没有启用账号,暂无可展示的发送控制台数据。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.expandTarget;
|
||||
if (!targetId) return;
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle("is-open");
|
||||
button.textContent = isOpen ? "收起" : (button.getAttribute("data-total-label") || "查看全部");
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user