feat: publish refreshed SparkFlow console and send safety

This commit is contained in:
Rixuan Shao
2026-07-11 02:13:14 +08:00
parent 3ae9738c46
commit c496d90039
28 changed files with 4757 additions and 4135 deletions
+189 -31
View File
@@ -1,6 +1,5 @@
import json
import logging
import traceback
from datetime import datetime, timedelta, timezone
from pathlib import Path
import urllib.error
@@ -16,6 +15,7 @@ from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from core.tasks import run_browser_tasks, task_run_lock
from utils.config import (
get_app_settings,
@@ -41,6 +41,7 @@ from webui.auth import (
)
from webui.ops import (
TASK_ALREADY_RUNNING,
get_overview_snapshot,
get_ops_snapshot,
read_log_tail,
refresh_proxy,
@@ -113,24 +114,78 @@ def _schedule_timezone():
def _parse_sent_at(raw_value):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=_schedule_timezone())
return parsed.astimezone(_schedule_timezone())
return parse_sent_at(raw_value, _schedule_timezone())
def _history_entry_strong_confirmed_today(entry):
return history_entry_is_strong_confirmed_today(
entry,
datetime.now(_schedule_timezone()),
)
def _target_sent_today(account, target_name):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
return _history_entry_strong_confirmed_today(entry)
def _target_unconfirmed_today(account, target_name):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
sent_at = _parse_sent_at(entry.get("sentAt"))
return bool(sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date())
if sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date() and not _history_entry_strong_confirmed_today(entry):
return True
failure_entry = dict(account.get("failure_queue") or {}).get(target_name) or {}
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"))
return bool(
last_attempt_at
and last_attempt_at.date() == datetime.now(_schedule_timezone()).date()
and str(failure_entry.get("category") or "") == "send_unconfirmed"
)
def mark_target_unconfirmed(account, target_name, *, reason="manual_reset_possible_false_positive", force=False):
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
history = dict(account.get("message_history") or {})
existing = dict(history.get(target_name) or {})
sent_at = _parse_sent_at(existing.get("sentAt"))
today = datetime.now(_schedule_timezone()).date()
if existing and sent_at and sent_at.date() != today and not force:
return False
if existing and _history_entry_strong_confirmed_today(existing) and not force:
return False
previous_status = existing.get("status") or ("legacy_sentAt_only" if existing else "missing_history")
message = str(existing.get("message") or "")
history[target_name] = {
**existing,
"message": message,
"sentAt": existing.get("sentAt") or now,
"status": "unconfirmed",
"confirmationLevel": existing.get("confirmationLevel") or "legacy",
"confirmationSource": existing.get("confirmationSource") or "manual_reset",
"confirmationDetail": existing.get("confirmationDetail") or "已手动标记为待核验/待补发。",
"needsVerification": True,
"resetAt": now,
"resetReason": reason,
"previousStatus": previous_status,
}
account["message_history"] = history
queue = dict(account.get("failure_queue") or {})
existing_failure = dict(queue.get(target_name) or {})
queue[target_name] = {
"category": "send_unconfirmed",
"reason": reason,
"message": message,
"firstAttemptAt": existing_failure.get("firstAttemptAt") or now,
"lastAttemptAt": now,
"attemptCount": int(existing_failure.get("attemptCount") or 0) + 1,
"lastRunMode": "manual_reset",
"confirmationLevel": history[target_name].get("confirmationLevel"),
"confirmationSource": history[target_name].get("confirmationSource"),
}
account["failure_queue"] = queue
return True
def login_desktop_api_url():
@@ -201,6 +256,22 @@ def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "
return account, "created"
def public_app_settings():
settings = get_app_settings(force_reload=True)
allowed_keys = (
"compose_root",
"ui_host",
"ui_port",
"ops_log_file",
"proxy_refresh_script",
"login_desktop_api_url",
"login_desktop_public_url",
"login_desktop_public_scheme",
"login_desktop_public_port",
)
return {key: settings.get(key) for key in allowed_keys}
def create_app():
settings = get_app_settings()
app = FastAPI(title="DouYin Spark Flow Admin")
@@ -213,28 +284,35 @@ def create_app():
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
DEBUG_ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/debug-artifacts", StaticFiles(directory=str(DEBUG_ARTIFACTS_DIR)), name="debug-artifacts")
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
tb_text = "".join(tb)
logger.error("Unhandled exception on %s %s:\n%s", request.method, request.url.path, tb_text)
return PlainTextResponse(f"Internal Server Error\n\n{tb_text}", status_code=500)
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
return PlainTextResponse(
"Internal Server Error",
status_code=500,
headers={"Cache-Control": "no-store"},
)
def render_template(request, template_name, context=None, status_code=200):
base_context = context or {}
base_context = dict(context or {})
base_context.update(
{
"request": request,
"current_user": current_user(request),
"csrf_token": csrf_token(request) if current_user(request) else "",
"is_https": is_https_request(request),
"app_settings": get_app_settings(force_reload=True),
"app_settings": public_app_settings(),
"login_desktop_public_url": login_desktop_public_url(request),
}
)
return templates.TemplateResponse(request, template_name, base_context, status_code=status_code)
return templates.TemplateResponse(
request,
template_name,
base_context,
status_code=status_code,
headers={"Cache-Control": "no-store"},
)
def redirect(path="/", status_code=303):
return RedirectResponse(url=path, status_code=status_code)
@@ -250,6 +328,17 @@ def create_app():
def pop_flash(request):
return request.session.pop("flash", None)
@app.get("/debug-artifacts/{artifact_path:path}")
async def debug_artifact(request: Request, artifact_path: str):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
root = DEBUG_ARTIFACTS_DIR.resolve()
candidate = (root / artifact_path).resolve()
if root not in candidate.parents or not candidate.is_file():
return PlainTextResponse("Not found", status_code=404)
return FileResponse(candidate, headers={"Cache-Control": "no-store"})
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if current_user(request):
@@ -304,6 +393,19 @@ def create_app():
clear_session(request)
return redirect("/login")
@app.get("/api/ops/overview")
async def ops_overview(request: Request):
if not current_user(request):
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return JSONResponse(
get_overview_snapshot(),
headers={"Cache-Control": "no-store"},
)
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
maybe_redirect = require_user(request)
@@ -476,7 +578,11 @@ def create_app():
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name):
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
flash(request, f"已重试 {account.get('username', 'Account')} / {target_name},并获得强证据确认。", "success")
elif _target_unconfirmed_today(updated_account, target_name):
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
reason = str(failure_entry.get("reason") or "Retry ran but did not get strong confirmation.")
flash(request, f"已执行 {account.get('username', 'Account')} / {target_name},但未强确认,已进入待核验/待补发:{reason}", "warning")
else:
account_failure = dict(updated_account.get("account_failure") or {})
affected_targets = list(account_failure.get("affectedTargets") or [])
@@ -488,6 +594,64 @@ def create_app():
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
return redirect("/ops/send-console")
@app.post("/accounts/{unique_id}/mark-target-unconfirmed")
async def mark_account_target_unconfirmed(request: Request, unique_id: str):
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)
target_name = str(form.get("target", "")).strip()
if not target_name:
flash(request, "Target is required.", "error")
return redirect("/ops/send-console")
accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id)
if not account:
flash(request, "Account not found.", "error")
return redirect("/ops/send-console")
changed = mark_target_unconfirmed(account, target_name)
if changed:
save_userData(accounts)
flash(request, f"已将 {account.get('username', 'Account')} / {target_name} 标记为待核验/待补发。", "warning")
else:
flash(request, f"{target_name} 已是强确认记录或不是今日记录,未自动重置。", "info")
return redirect("/ops/send-console")
@app.post("/ops/reset-today-unconfirmed")
async def reset_today_unconfirmed(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)
accounts = get_userData(force_reload=True)
changed_count = 0
for account in accounts:
for target_name in list(account.get("targets") or []):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
sent_at = _parse_sent_at(entry.get("sentAt"))
if not sent_at or sent_at.date() != datetime.now(_schedule_timezone()).date():
continue
if _history_entry_strong_confirmed_today(entry):
continue
if mark_target_unconfirmed(account, target_name, reason="batch_reset_today_suspicious_success"):
changed_count += 1
if changed_count:
save_userData(accounts)
flash(request, f"已将 {changed_count} 条今日可疑成功记录标记为待核验/待补发。", "warning")
else:
flash(request, "没有找到需要重置的今日可疑成功记录。", "info")
return redirect("/ops/send-console")
@app.post("/config")
async def save_runtime_config(request: Request):
maybe_redirect = require_user(request)
@@ -565,15 +729,9 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
settings = get_app_settings(force_reload=True)
settings["server_host"] = str(form.get("server_host", "")).strip()
settings["server_username"] = str(form.get("server_username", "")).strip()
settings["server_password"] = str(form.get("server_password", "")).strip()
settings["compose_root"] = str(form.get("compose_root", settings.get("compose_root", ""))).strip()
settings["ops_log_file"] = str(form.get("ops_log_file", settings.get("ops_log_file", ""))).strip()
settings["proxy_refresh_script"] = str(form.get("proxy_refresh_script", settings.get("proxy_refresh_script", ""))).strip()
settings["local_login_helper_url"] = str(
form.get("local_login_helper_url", settings.get("local_login_helper_url", "http://127.0.0.1:18765"))
).strip()
settings["login_desktop_api_url"] = str(
form.get("login_desktop_api_url", settings.get("login_desktop_api_url", "http://127.0.0.1:18090"))
).strip()
@@ -601,14 +759,14 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
pid = run_task_now()
pid = run_task_now(force_all=True)
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})。这只表示任务已启动,实际成功数请刷新发送控制台查看。", "info")
return redirect("/")
return redirect("/ops/send-console")
@app.post("/ops/run-failed")
async def run_failed_retry(request: Request):
+411 -99
View File
@@ -1,3 +1,4 @@
import errno
import json
import hashlib
import logging
@@ -11,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from core.send_state import history_entry_is_strong_confirmed_today, parse_sent_at
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
logger = logging.getLogger(__name__)
@@ -25,6 +27,26 @@ TASK_SCHEDULE_MARKERS = (
HOST_CRONTAB_PATH = Path("/host-spool-cron/root")
WINDOWED_SCHEDULE_RE = re.compile(r"^(\d{2}):(\d{2})-(\d{2}):(\d{2})/(\d+)m$", re.IGNORECASE)
CONFIRMATION_LABELS = {
"cdp_message_send_receipt": "服务端回执",
"browser_visible_count_increased": "页面回显",
"legacy_sentAt_only": "旧记录待核验",
"manual_reset": "人工标记待核验",
}
FAILURE_CATEGORY_LABELS = {
"send_unconfirmed": "待核验",
"login_required": "登录失效",
"friend_not_found": "未找到好友",
"friend_list_unavailable": "好友列表不可用",
"timeout": "执行超时",
"navigation": "页面访问失败",
"selector": "页面结构变化",
"browser_crash": "浏览器异常",
"protocol_user_blocked": "对方限制私信",
"protocol_user_not_in_conversation": "不在会话中",
}
def running_in_container():
return Path("/.dockerenv").exists()
@@ -71,6 +93,12 @@ def _pid_is_alive(pid):
return False
except PermissionError:
return True
except OSError as exc:
if getattr(exc, "winerror", None) == 87 or exc.errno == errno.ESRCH:
return False
if exc.errno in (errno.EPERM, errno.EACCES):
return True
raise
return True
@@ -84,32 +112,62 @@ def _parse_lock_pid(raw):
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}
return {
"running": False,
"path": str(lock_path),
"pid": None,
"ageSeconds": 0,
"stale": False,
"staleReason": "",
"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}
return {
"running": False,
"path": str(lock_path),
"pid": pid,
"ageSeconds": 0,
"stale": True,
"staleReason": "lock_stat_failed",
"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}
return {
"running": False,
"path": str(lock_path),
"pid": pid,
"ageSeconds": age_seconds,
"stale": True,
"staleReason": "owner_pid_missing",
"staleRemoved": False,
}
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": False,
"path": str(lock_path),
"pid": None,
"ageSeconds": age_seconds,
"stale": True,
"staleReason": "unreadable_lock",
"staleRemoved": False,
}
return {"running": True, "path": str(lock_path), "pid": pid, "ageSeconds": age_seconds, "staleRemoved": False}
return {
"running": True,
"path": str(lock_path),
"pid": pid,
"ageSeconds": age_seconds,
"stale": False,
"staleReason": "",
"staleRemoved": False,
}
def build_task_run_spec():
@@ -219,18 +277,30 @@ def _empty_result(stdout="", stderr=""):
def run_background_command(args, log_path, cwd=None, env=None):
log_path = Path(log_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
handle = log_path.open("ab")
cwd_path = Path(cwd) if cwd else compose_root()
child_env = os.environ.copy()
if env:
child_env.update(env)
process = subprocess.Popen(
args,
cwd=str(Path(cwd) if cwd else compose_root()),
stdout=handle,
stderr=subprocess.STDOUT,
env=child_env,
)
handle.close()
with log_path.open("ab") as handle:
started_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
env_keys = ",".join(sorted((env or {}).keys())) or "none"
handle.write(
(
f"[WEB_TRIGGER] {started_at} start cwd={cwd_path} "
f"env_keys={env_keys} command={shlex.join([str(part) for part in args])}\n"
).encode("utf-8", errors="replace")
)
handle.flush()
process = subprocess.Popen(
args,
cwd=str(cwd_path),
stdout=handle,
stderr=subprocess.STDOUT,
env=child_env,
)
handle.write(f"[WEB_TRIGGER] {started_at} pid={process.pid}\n".encode("utf-8", errors="replace"))
handle.flush()
return process.pid
@@ -289,7 +359,7 @@ def get_task_container_rows():
return []
def run_task_now(*, unsent_only=False, failed_only=False):
def run_task_now(*, unsent_only=False, failed_only=False, force_all=False):
try:
lock_status = task_run_lock_status()
if lock_status.get("running"):
@@ -306,10 +376,19 @@ def run_task_now(*, unsent_only=False, failed_only=False):
"SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "1",
}
if failed_only:
if force_all:
run_env["SPARKFLOW_MANUAL_FORCE_ALL"] = "1"
elif failed_only:
run_env["SPARKFLOW_MANUAL_FAILED_ONLY"] = "1"
elif unsent_only:
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
logger.info(
"Starting background task command=%s cwd=%s env=%s log=%s",
command,
cwd,
{key: run_env[key] for key in sorted(run_env)},
log_file,
)
return run_background_command(
command,
log_file,
@@ -508,6 +587,49 @@ def current_daily_schedule():
return ""
def _next_window_trigger(now, window):
interval = max(1, int(window["scheduleIntervalMinutes"]))
candidates = []
for hour in range(int(window["startHour"]), int(window["endHour"])):
for minute in range(0, 60, interval):
candidates.append(now.replace(hour=hour, minute=minute, second=0, microsecond=0))
end_hour = int(window["endHour"])
candidates.append(now.replace(hour=end_hour, minute=0, second=0, microsecond=0))
if interval < 60:
candidates.append(now.replace(hour=end_hour, minute=interval, second=0, microsecond=0))
for candidate in sorted(set(candidates)):
if candidate > now:
return candidate
tomorrow = now + timedelta(days=1)
return tomorrow.replace(
hour=int(window["startHour"]),
minute=0,
second=0,
microsecond=0,
)
def get_schedule_snapshot(now=None):
now = now or datetime.now(_schedule_timezone())
window = _normalize_send_window()
label = current_daily_schedule()
if window.get("enabled"):
next_trigger = _next_window_trigger(now, window)
else:
try:
hour, minute = [int(part) for part in label.split(":", 1)]
next_trigger = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_trigger <= now:
next_trigger += timedelta(days=1)
except (TypeError, ValueError):
next_trigger = None
return {
"label": label,
"nextTriggerAt": next_trigger.isoformat(timespec="seconds") if next_trigger else "",
"nextTriggerDisplay": next_trigger.strftime("%m-%d %H:%M") if next_trigger else "",
}
def _schedule_timezone():
timezone_name = (
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
@@ -533,18 +655,7 @@ def _normalize_send_window():
def _parse_sent_at(raw_value, local_tz):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
return parse_sent_at(raw_value, local_tz)
def _account_identity(user):
@@ -629,74 +740,154 @@ def _scheduled_send_time(user, target_name, send_window, now):
return start_of_window + timedelta(minutes=offset_minutes)
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)
if sent_at and sent_at.date() == now.date():
return {
"target": target_name,
"status": "sent",
"message": str(history_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": "",
"friendIndex": friend_index,
}
failure_entry = failure_queue.get(target_name) or {}
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
if last_attempt_at and last_attempt_at.date() == now.date():
return {
"target": target_name,
"status": "failed",
"message": str(failure_entry.get("message") or ""),
"sentAt": "",
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
"category": str(failure_entry.get("category") or ""),
"reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"scheduledAt": "",
"friendIndex": friend_index,
}
scheduled_at = None
if send_window.get("enabled"):
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
if scheduled_at > now:
return {
"target": target_name,
"status": "pending",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
"friendIndex": friend_index,
}
def _base_target_status(account, target_name, now):
return {
"target": target_name,
"status": "unprocessed",
"status": "",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
"friendIndex": friend_index,
"scheduledAt": "",
"friendIndex": _friend_index_status(account, target_name),
"confirmationLevel": "",
"confirmationSource": "",
"confirmationDetail": "",
"needsVerification": False,
"legacyUnverified": False,
"displaySentAt": "",
"displayLastAttemptAt": "",
"displayScheduledAt": "",
"confirmationLabel": "",
"categoryLabel": "",
}
def _history_entry_is_strong_confirmed(history_entry, sent_at, now):
return history_entry_is_strong_confirmed_today(history_entry, now)
def _format_short_time(raw_value, now):
parsed = _parse_sent_at(raw_value, now.tzinfo)
if not parsed:
return ""
if parsed.date() == now.date():
return parsed.strftime("%H:%M:%S")
return parsed.strftime("%m-%d %H:%M")
def _finalize_target_status(item, now):
item = dict(item)
item["displaySentAt"] = _format_short_time(item.get("sentAt"), now)
item["displayLastAttemptAt"] = _format_short_time(item.get("lastAttemptAt"), now)
item["displayScheduledAt"] = _format_short_time(item.get("scheduledAt"), now)
source = str(item.get("confirmationSource") or "")
category = str(item.get("category") or "")
item["confirmationLabel"] = CONFIRMATION_LABELS.get(source, source or "-")
item["categoryLabel"] = FAILURE_CATEGORY_LABELS.get(category, category or "-")
return item
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 {})
item = _base_target_status(account, target_name, now)
history_entry = dict(history.get(target_name) or {})
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
if _history_entry_is_strong_confirmed(history_entry, sent_at, now):
item.update(
{
"status": "sent",
"message": str(history_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"confirmationLevel": str(history_entry.get("confirmationLevel") or "strong"),
"confirmationSource": str(history_entry.get("confirmationSource") or "browser_visible_count_increased"),
"confirmationDetail": str(history_entry.get("confirmationDetail") or ""),
}
)
return _finalize_target_status(item, now)
failure_entry = dict(failure_queue.get(target_name) or {})
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
failure_is_today = bool(last_attempt_at and last_attempt_at.date() == now.date())
if sent_at and sent_at.date() == now.date():
confirmation_level = str(history_entry.get("confirmationLevel") or "legacy")
confirmation_source = str(history_entry.get("confirmationSource") or "legacy_sentAt_only")
confirmation_detail = str(history_entry.get("confirmationDetail") or "")
legacy_unverified = not history_entry.get("confirmationLevel")
if legacy_unverified:
confirmation_detail = confirmation_detail or "旧格式发送账本缺少强确认字段,已降级为待核验。"
item.update(
{
"status": "unconfirmed",
"message": str(history_entry.get("message") or failure_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds") if failure_is_today else "",
"category": str(failure_entry.get("category") or "send_unconfirmed"),
"reason": str(failure_entry.get("reason") or confirmation_detail or "发送记录缺少强确认,需要核验。"),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"confirmationLevel": confirmation_level,
"confirmationSource": confirmation_source,
"confirmationDetail": confirmation_detail,
"needsVerification": True,
"legacyUnverified": legacy_unverified,
}
)
return _finalize_target_status(item, now)
if failure_is_today:
category = str(failure_entry.get("category") or "")
status = "unconfirmed" if category == "send_unconfirmed" else "failed"
item.update(
{
"status": status,
"message": str(failure_entry.get("message") or ""),
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
"category": category,
"reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"confirmationLevel": str(failure_entry.get("confirmationLevel") or ("weak" if status == "unconfirmed" else "")),
"confirmationSource": str(failure_entry.get("confirmationSource") or ""),
"confirmationDetail": str(failure_entry.get("reason") or ""),
"needsVerification": status == "unconfirmed",
}
)
return _finalize_target_status(item, now)
scheduled_at = None
if send_window.get("enabled"):
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
if scheduled_at > now:
item.update(
{
"status": "pending",
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
}
)
return _finalize_target_status(item, now)
item.update(
{
"status": "unprocessed",
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
}
)
return _finalize_target_status(item, now)
def _orphan_records(account, configured_targets):
configured_target_set = {str(target) for target in configured_targets}
history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {})
orphan_history = sorted(str(target) for target in history if str(target) not in configured_target_set)
orphan_failure = sorted(str(target) for target in failure_queue if str(target) not in configured_target_set)
return orphan_history, orphan_failure
def get_send_console_snapshot():
accounts = [account for account in get_userData(force_reload=True) if account.get("enabled", True)]
send_window = _normalize_send_window()
@@ -706,13 +897,23 @@ def get_send_console_snapshot():
"enabled_accounts": len(accounts),
"total_targets": 0,
"today_sent_targets": 0,
"today_confirmed_targets": 0,
"today_unconfirmed_targets": 0,
"today_legacy_unverified_targets": 0,
"today_failed_targets": 0,
"today_pending_targets": 0,
"today_unprocessed_targets": 0,
"today_account_blocked_targets": 0,
"today_attention_targets": 0,
"today_remaining_targets": 0,
"today_account_failures": 0,
"today_account_paused": 0,
"today_warning_count": 0,
"orphan_history_records": 0,
"orphan_failure_records": 0,
"last_confirmed_at": "",
"last_confirmed_display": "",
"all_confirmed": False,
}
account_rows = []
account_failure_pause_after = _account_failure_pause_after_attempts()
@@ -720,14 +921,16 @@ def get_send_console_snapshot():
for account in accounts:
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"]
confirmed_targets = [item for item in statuses if item["status"] == "sent"]
sent_targets = confirmed_targets
unconfirmed_targets = [item for item in statuses if item["status"] == "unconfirmed"]
failed_targets = [item for item in statuses if item["status"] == "failed"]
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)
_finalize_target_status(_account_blocked_target_status(item, account_failure), now)
for item in statuses
if item["status"] in {"pending", "unprocessed"}
]
@@ -747,22 +950,63 @@ def get_send_console_snapshot():
except (TypeError, ValueError):
friend_index_meta["scannedCount"] = 0
orphan_history, orphan_failure = _orphan_records(account, configured_targets)
warnings = []
if not configured_targets:
warnings.append({"category": "no_targets", "message": "该启用账号没有配置目标,不能代表全部续上。"})
if orphan_history:
warnings.append({"category": "orphan_history", "message": f"{len(orphan_history)} 条发送账本不在当前目标列表中。"})
if orphan_failure:
warnings.append({"category": "orphan_failure", "message": f"{len(orphan_failure)} 条失败队列记录不在当前目标列表中。"})
legacy_unverified_targets = [item for item in unconfirmed_targets if item.get("legacyUnverified")]
attention_count = len(unconfirmed_targets) + len(failed_targets) + len(account_blocked_targets)
pending_count = len(pending_targets) + len(unprocessed_targets)
confirmed_times = [
_parse_sent_at(item.get("sentAt"), now.tzinfo)
for item in confirmed_targets
if item.get("sentAt")
]
confirmed_times = [item for item in confirmed_times if item]
last_confirmed_at = max(confirmed_times).isoformat(timespec="seconds") if confirmed_times else ""
if account_paused:
account_state = "paused"
elif attention_count:
account_state = "attention"
elif warnings:
account_state = "warning"
elif pending_count:
account_state = "pending"
else:
account_state = "healthy"
summary["total_targets"] += len(configured_targets)
summary["today_sent_targets"] += len(sent_targets)
summary["today_confirmed_targets"] += len(confirmed_targets)
summary["today_unconfirmed_targets"] += len(unconfirmed_targets)
summary["today_legacy_unverified_targets"] += len(legacy_unverified_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_attention_targets"] += attention_count
summary["today_remaining_targets"] += (
len(failed_targets)
len(unconfirmed_targets)
+ len(failed_targets)
+ len(pending_targets)
+ len(unprocessed_targets)
+ len(account_blocked_targets)
)
summary["today_warning_count"] += len(warnings)
summary["orphan_history_records"] += len(orphan_history)
summary["orphan_failure_records"] += len(orphan_failure)
if account_failure:
summary["today_account_failures"] += 1
if account_paused:
summary["today_account_paused"] += 1
if last_confirmed_at and (
not summary["last_confirmed_at"] or last_confirmed_at > summary["last_confirmed_at"]
):
summary["last_confirmed_at"] = last_confirmed_at
account_rows.append(
{
@@ -770,27 +1014,93 @@ def get_send_console_snapshot():
"username": account.get("username") or "",
"total_targets": len(configured_targets),
"sent_targets": sent_targets,
"confirmed_targets": confirmed_targets,
"unconfirmed_targets": unconfirmed_targets,
"legacy_unverified_targets": legacy_unverified_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 "",
"last_unconfirmed_reason": unconfirmed_targets[0]["reason"] if unconfirmed_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,
"state": account_state,
"attention_count": attention_count,
"pending_count": pending_count,
"last_confirmed_at": last_confirmed_at,
"last_confirmed_display": _format_short_time(last_confirmed_at, now),
"friend_index_meta": friend_index_meta,
"friend_index_count": len(dict(account.get("friend_index") or {})),
"warnings": warnings,
"orphan_history_records": orphan_history,
"orphan_failure_records": orphan_failure,
}
)
state_rank = {"paused": 0, "attention": 1, "warning": 2, "pending": 3, "healthy": 4}
account_rows.sort(key=lambda row: (state_rank.get(row.get("state"), 9), str(row.get("username") or "")))
summary["all_confirmed"] = bool(
summary["total_targets"] > 0
and summary["today_confirmed_targets"] == summary["total_targets"]
and summary["today_remaining_targets"] == 0
and summary["today_warning_count"] == 0
and summary["orphan_history_records"] == 0
and summary["orphan_failure_records"] == 0
)
summary["last_confirmed_display"] = _format_short_time(summary["last_confirmed_at"], now)
return {
"now": now.isoformat(timespec="seconds"),
"nowDisplay": now.strftime("%m-%d %H:%M"),
"summary": summary,
"accounts": account_rows,
}
def get_overview_snapshot():
send_console = get_send_console_snapshot()
summary = dict(send_console["summary"])
accounts = []
for row in send_console["accounts"]:
accounts.append(
{
"uniqueId": row["unique_id"],
"displayName": row["username"],
"state": row["state"],
"total": row["total_targets"],
"confirmed": len(row["confirmed_targets"]),
"attention": row["attention_count"],
"pending": row["pending_count"],
"lastConfirmedAt": row["last_confirmed_at"],
}
)
return {
"now": send_console["now"],
"schedule": get_schedule_snapshot(),
"task": task_run_lock_status(),
"summary": {
"enabledAccounts": summary["enabled_accounts"],
"total": summary["total_targets"],
"confirmed": summary["today_confirmed_targets"],
"unconfirmed": summary["today_unconfirmed_targets"],
"failed": summary["today_failed_targets"],
"blocked": summary["today_account_blocked_targets"],
"attention": summary["today_attention_targets"],
"pending": summary["today_pending_targets"],
"unprocessed": summary["today_unprocessed_targets"],
"remaining": summary["today_remaining_targets"],
"warnings": summary["today_warning_count"],
"lastConfirmedAt": summary["last_confirmed_at"],
"allConfirmed": summary["all_confirmed"],
},
"accounts": accounts,
}
def _check_image_present():
"""Return True if the douyin-sparkflow:local image exists."""
try:
@@ -811,14 +1121,16 @@ def get_ops_snapshot():
Every external call is individually guarded so the dashboard always
renders, even when Docker or crontab are not available.
"""
send_console = get_send_console_snapshot()
return {
"compose_root": str(compose_root()),
"compose_file": str(compose_file_path() or ""),
"containers": get_container_status(),
"task_containers": get_task_container_rows(),
"send_console": get_send_console_snapshot(),
"send_console": send_console,
"task_lock": task_run_lock_status(),
"daily_schedule": current_daily_schedule(),
"schedule": get_schedule_snapshot(),
"crontab": read_crontab(),
"log_tail": read_log_tail(120),
"image_present": _check_image_present(),
File diff suppressed because it is too large Load Diff
+435 -318
View File
@@ -1,138 +1,360 @@
(() => {
const storageKey = "sparkflow-theme";
const root = document.documentElement;
const storageKey = "sparkflow-theme";
const readStoredTheme = () => {
const storedTheme = () => {
try {
return window.localStorage.getItem(storageKey);
return localStorage.getItem(storageKey);
} catch {
return null;
}
};
const writeStoredTheme = (theme) => {
const applyTheme = (theme) => {
const value = theme === "light" ? "light" : "dark";
root.dataset.theme = value;
root.style.colorScheme = value;
try {
window.localStorage.setItem(storageKey, theme);
localStorage.setItem(storageKey, value);
} catch {
// Ignore storage restrictions; the current page can still switch theme.
// The active page can still switch themes when storage is unavailable.
}
};
const preferredTheme = () => {
const stored = readStoredTheme();
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
const applyTheme = (theme) => {
const normalized = theme === "dark" ? "dark" : "light";
root.dataset.theme = normalized;
root.style.colorScheme = normalized;
const nextLabel = normalized === "dark" ? "切换白天模式" : "切换黑夜模式";
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.setAttribute("aria-label", nextLabel);
button.setAttribute("title", nextLabel);
button.setAttribute(
"aria-pressed",
normalized === "dark" ? "true" : "false",
);
});
};
applyTheme(preferredTheme());
applyTheme(storedTheme() || "dark");
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = root.dataset.theme === "dark" ? "light" : "dark";
writeStoredTheme(next);
applyTheme(next);
applyTheme(root.dataset.theme === "light" ? "dark" : "light");
});
});
})();
(() => {
const body = document.body;
const navToggle = document.querySelector("[data-nav-toggle]");
const navClose = document.querySelector("[data-nav-close]");
navToggle?.addEventListener("click", () => body.classList.toggle("nav-open"));
navClose?.addEventListener("click", () => body.classList.remove("nav-open"));
document.querySelectorAll(".nav-item").forEach((item) => {
item.addEventListener("click", () => body.classList.remove("nav-open"));
document.querySelectorAll("[data-nav-toggle]").forEach((button) => {
button.addEventListener("click", () => body.classList.add("nav-open"));
});
document.querySelectorAll("[data-nav-close]").forEach((button) => {
button.addEventListener("click", () => body.classList.remove("nav-open"));
});
document.querySelectorAll(".nav-item").forEach((link) => {
link.addEventListener("click", () => body.classList.remove("nav-open"));
});
})();
(() => {
document.querySelectorAll("[data-confirm]").forEach((node) => {
const message = node.getAttribute("data-confirm") || "确认执行此操作?";
const handler = (event) => {
if (!window.confirm(message)) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
if (node.tagName === "FORM") {
node.addEventListener("submit", handler);
} else {
node.addEventListener("click", handler);
}
});
})();
const dialog = document.getElementById("confirm-dialog");
if (!dialog) return;
const title = document.getElementById("confirm-title");
const message = document.getElementById("confirm-message");
const accept = dialog.querySelector("[data-confirm-accept]");
const cancel = dialog.querySelector("[data-confirm-cancel]");
let pendingForm = null;
let pendingLink = "";
let pendingButton = null;
(() => {
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.dataset.totalLabel || "查看全部";
const openDialog = (node) => {
const source = node.closest("[data-confirm]") || node;
title.textContent = source.dataset.confirmTitle || "确认操作";
message.textContent =
source.dataset.confirm ||
"该操作会立即影响续火花任务,请确认是否继续。";
accept.textContent = source.dataset.confirmAccept || "确认执行";
accept.className =
source.dataset.confirmTone === "primary"
? "button button-primary"
: "button button-danger";
dialog.showModal();
};
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
pendingForm = form;
pendingLink = "";
pendingButton = null;
openDialog(form);
});
});
document.querySelectorAll("a[data-confirm]").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
pendingForm = null;
pendingLink = link.href;
pendingButton = null;
openDialog(link);
});
});
document.querySelectorAll("button[data-confirm]").forEach((button) => {
button.addEventListener(
"click",
(event) => {
if (button.dataset.confirmApproved === "1") {
delete button.dataset.confirmApproved;
return;
}
event.preventDefault();
event.stopImmediatePropagation();
pendingForm = null;
pendingLink = "";
pendingButton = button;
openDialog(button);
},
true,
);
});
cancel.addEventListener("click", () => {
pendingForm = null;
pendingLink = "";
pendingButton = null;
dialog.close();
});
accept.addEventListener("click", () => {
const form = pendingForm;
const href = pendingLink;
const button = pendingButton;
pendingForm = null;
pendingLink = "";
pendingButton = null;
dialog.close();
if (form) {
HTMLFormElement.prototype.submit.call(form);
} else if (href) {
window.location.assign(href);
} else if (button) {
button.dataset.confirmApproved = "1";
button.click();
}
});
dialog.addEventListener("cancel", () => {
pendingForm = null;
pendingLink = "";
pendingButton = null;
});
})();
(() => {
document.querySelectorAll("[data-segment-group]").forEach((group) => {
const buttons = [...group.querySelectorAll("[data-segment-target]")];
const owner = group.closest("[data-segment-owner]") || document;
const panels = [...owner.querySelectorAll("[data-segment-panel]")];
const activate = (name) => {
buttons.forEach((button) => {
const active = button.dataset.segmentTarget === name;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.segmentPanel !== name;
});
};
buttons.forEach((button) => {
button.addEventListener("click", () =>
activate(button.dataset.segmentTarget),
);
});
const initial =
buttons.find((button) => button.classList.contains("active")) ||
buttons[0];
if (initial) activate(initial.dataset.segmentTarget);
});
})();
(() => {
const overviewRoots = document.querySelectorAll("[data-overview-root]");
if (!overviewRoots.length) return;
let previousRunning = null;
let timer = null;
const formatTime = (raw) => {
if (!raw) return "-";
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
};
const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((node) => {
node.textContent = String(value ?? "");
});
};
const updateTaskBanner = (task) => {
document.querySelectorAll("[data-task-banner]").forEach((banner) => {
banner.className = "status-banner";
if (task.running) {
banner.classList.add("warning");
banner.querySelector("[data-task-text]").textContent =
`发送任务运行中,已运行约 ${task.ageSeconds || 0}`;
} else if (task.stale) {
banner.classList.add("info");
banner.querySelector("[data-task-text]").textContent =
"检测到过期任务锁,下次启动任务时会自动清理";
} else {
banner.classList.add("success");
banner.querySelector("[data-task-text]").textContent =
"当前没有发送任务运行";
}
});
};
const updateAccounts = (accounts) => {
accounts.forEach((account) => {
const selector = `[data-account-overview="${CSS.escape(account.uniqueId)}"]`;
document.querySelectorAll(selector).forEach((row) => {
row.dataset.accountState = account.state;
row.querySelectorAll("[data-account-confirmed]").forEach((node) => {
node.textContent = account.confirmed;
});
row.querySelectorAll("[data-account-attention]").forEach((node) => {
node.textContent = account.attention;
});
row.querySelectorAll("[data-account-pending]").forEach((node) => {
node.textContent = account.pending;
});
row.querySelectorAll("[data-account-progress]").forEach((node) => {
const pct = account.total
? Math.round((account.confirmed / account.total) * 100)
: 0;
node.style.width = `${pct}%`;
});
row.querySelectorAll("[data-account-progress-text]").forEach((node) => {
node.textContent = `${account.confirmed}/${account.total}`;
});
});
});
};
const updateActions = (summary, running) => {
const counts = {
attention: summary.attention,
pending: summary.pending + summary.unprocessed,
total: summary.total,
};
document.querySelectorAll("[data-action-count-source]").forEach((button) => {
const count = counts[button.dataset.actionCountSource] || 0;
button.disabled = running || count <= 0;
const countNode = button.querySelector("[data-action-count]");
if (countNode) countNode.textContent = count;
});
document.querySelectorAll("[data-disable-while-running]").forEach((button) => {
if (!button.dataset.actionCountSource) {
button.disabled = running;
}
});
};
const render = (data) => {
const summary = data.summary || {};
const task = data.task || {};
updateTaskBanner(task);
setText("[data-overview-value='total']", summary.total || 0);
setText("[data-overview-value='confirmed']", summary.confirmed || 0);
setText("[data-overview-value='attention']", summary.attention || 0);
setText(
"[data-overview-value='pending']",
(summary.pending || 0) + (summary.unprocessed || 0),
);
setText("[data-overview-value='remaining']", summary.remaining || 0);
setText(
"[data-overview-value='progress']",
`${summary.confirmed || 0}/${summary.total || 0}`,
);
setText(
"[data-overview-value='progressPercent']",
summary.total
? `${Math.round((summary.confirmed / summary.total) * 100)}%`
: "0%",
);
setText(
"[data-overview-value='lastConfirmedAt']",
formatTime(summary.lastConfirmedAt),
);
setText(
"[data-overview-value='nextTriggerAt']",
formatTime(data.schedule?.nextTriggerAt),
);
setText(
"[data-overview-value='scheduleLabel']",
data.schedule?.label || "-",
);
updateAccounts(data.accounts || []);
updateActions(summary, Boolean(task.running));
if (previousRunning === true && !task.running) {
document
.querySelectorAll("[data-refresh-notice]")
.forEach((node) => node.classList.add("visible"));
}
previousRunning = Boolean(task.running);
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
node.textContent = "实时";
node.classList.remove("poll-stale");
});
};
const refresh = async () => {
if (document.visibilityState !== "visible") return;
try {
const response = await fetch("/api/ops/overview", {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
} catch {
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
node.textContent = "更新延迟";
node.classList.add("poll-stale");
});
}
};
document.querySelectorAll("[data-refresh-page]").forEach((button) => {
button.addEventListener("click", () => window.location.reload());
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refresh();
});
refresh();
timer = window.setInterval(refresh, 10000);
window.addEventListener("pagehide", () => window.clearInterval(timer));
})();
(() => {
const root = document.getElementById("login-desktop-controls");
if (!root) return;
const section = document.getElementById("interactive-login-section");
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 runtimeState = document.getElementById(
"login-desktop-runtime-state",
);
const statusText = document.getElementById("login-desktop-status-text");
const frame = document.querySelector("[data-login-frame]");
let timer = null;
const setVisualStatus = (state) => {
Object.values(statusMap).forEach((node) => {
if (!node) return;
node.style.opacity = "0.46";
});
if (statusMap[state]) {
statusMap[state].style.opacity = "1";
const setStatus = (text, tone = "") => {
if (statusText) statusText.textContent = text;
if (runtimeState) {
runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
runtimeState.textContent =
tone === "success" ? "已登录" : tone === "danger" ? "异常" : "待登录";
}
};
const setStatus = (text, tone = "", state = "checking") => {
if (statusTextEl) statusTextEl.textContent = text;
if (runtimeStateEl) {
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
}
setVisualStatus(state);
};
const postForm = async (url, payload = {}) => {
const formData = new FormData();
formData.set("csrf_token", csrfToken);
@@ -151,284 +373,185 @@
return data;
};
const openDesktopWindow = () => {
const popup = window.open(publicUrl, "_blank");
if (!popup) {
setStatus(
"浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
"danger",
"error",
);
return false;
const loadFrame = () => {
if (frame && frame.dataset.loaded !== "1" && frame.dataset.src) {
frame.src = frame.dataset.src;
frame.dataset.loaded = "1";
}
return true;
};
const pollStatus = async () => {
if (document.visibilityState !== "visible" || (section && !section.open)) {
return;
}
try {
const response = await fetch("/login-desktop/status", {
credentials: "same-origin",
cache: "no-store",
});
const data = await response.json();
if (!response.ok || data.ok === false) {
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
setStatus(
data.error || "登录工作区不可用,请检查 login-desktop 服务。",
"warning",
"error",
"danger",
);
return;
}
if (runtimeStateEl)
runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
if (data.logged_in) {
setStatus(
`当前浏览器已登录:${data.username}${data.unique_id}`,
"success",
"success",
);
setStatus(`当前浏览器已登录:${data.username}`, "success");
} else {
setStatus(
"当前浏览器尚未登录,可打开登录工作区开始人工登录。",
"",
"pending",
);
setStatus("当前浏览器尚未登录,可打开工作区开始人工登录。");
}
} catch (error) {
if (runtimeStateEl) runtimeStateEl.textContent = "异常";
setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
setStatus(`状态检查失败:${error.message}`, "danger");
}
};
copyPublicUrlButton?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(publicUrl);
setStatus("登录工作区地址已复制。", "success", "pending");
} catch (error) {
setStatus(`复制失败:${error.message}`, "warning", "error");
}
});
openButtons.forEach((button) => {
document.querySelectorAll(".login-desktop-open").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",
);
loadFrame();
window.open(publicUrl, "_blank", "noopener");
setStatus("请在登录工作区完成登录,然后保存登录态。");
} catch (error) {
setStatus(`打开登录工作区失败:${error.message}`, "danger", "error");
setStatus(`打开登录工作区失败:${error.message}`, "danger");
}
});
});
saveButtons.forEach((button) => {
document.querySelectorAll(".login-desktop-save").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,
relogin_unique_id: button.dataset.reloginUniqueId || "",
});
setStatus(
reloginUniqueId
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
: `已保存当前登录账号:${data.account?.username || ""}`,
"success",
"success",
);
window.setTimeout(() => window.location.reload(), 900);
setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
window.setTimeout(() => window.location.reload(), 800);
} catch (error) {
setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
setStatus(`保存登录账号失败:${error.message}`, "danger");
}
});
});
resetButtons.forEach((button) => {
document.querySelectorAll(".login-desktop-reset").forEach((button) => {
button.addEventListener("click", async () => {
try {
await postForm("/login-desktop/reset");
setStatus(
"登录工作区已重置,正在重新初始化浏览器。",
"warning",
"checking",
);
setStatus("登录工作区已重置,正在重新初始化。");
await pollStatus();
} catch (error) {
setStatus(`重置登录工作区失败:${error.message}`, "danger", "error");
setStatus(`重置登录工作区失败:${error.message}`, "danger");
}
});
});
setVisualStatus("checking");
pollStatus();
window.setInterval(pollStatus, 5000);
document.querySelectorAll("[data-copy-login-url]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(publicUrl);
setStatus("登录工作区地址已复制。", "success");
} catch (error) {
setStatus(`复制失败:${error.message}`, "danger");
}
});
});
if (section) {
section.addEventListener("toggle", () => {
if (section.open) {
loadFrame();
pollStatus();
}
});
}
timer = window.setInterval(pollStatus, 5000);
window.addEventListener("pagehide", () => window.clearInterval(timer));
})();
(() => {
const pickers = document.querySelectorAll(".friend-picker");
if (!pickers.length) return;
const parseJsonScript = (id) => {
const el = document.getElementById(id);
if (!el) return [];
const parseJson = (id) => {
const node = document.getElementById(id);
if (!node) return [];
try {
return JSON.parse(el.textContent || "[]");
} catch (error) {
console.error("Failed to parse friend picker JSON", id, error);
return JSON.parse(node.textContent || "[]");
} catch {
return [];
}
};
const escapeHtml = (value) =>
String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
pickers.forEach((picker) => {
document.querySelectorAll(".friend-picker").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 form = picker.closest("form");
const textarea = form?.querySelector(".targets-textarea");
const search = 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",
);
const list = picker.querySelector(".friend-picker-list");
const summary = picker.querySelector(".friend-picker-summary");
const status = picker.querySelector(".friend-picker-status");
let friends = parseJson(`friends-cache-${accountId}`);
let selected = new Set(parseJson(`selected-targets-${accountId}`));
let friends = parseJsonScript(`friends-cache-${accountId}`);
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
const parseTargets = (value) =>
[...new Set(
String(value || "")
.replaceAll(",", "\n")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean),
)];
const splitTargetText = (value) => {
const seen = new Set();
return String(value || "")
.replaceAll(",", "\n")
.split(/\r?\n/)
.map((name) => name.trim())
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
});
const combined = () => [...new Set([...selected, ...friends])];
const syncTextarea = () => {
if (textarea) textarea.value = [...selected].join("\n");
};
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 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((name) =>
const render = () => {
const query = String(search?.value || "").trim().toLowerCase();
const names = combined().filter((name) =>
name.toLowerCase().includes(query),
);
renderHiddenInputs();
updateSummary();
if (!allNames.length) {
listEl.innerHTML =
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
if (summary) summary.textContent = `已选 ${selected.size}`;
list.innerHTML = "";
if (!names.length) {
const empty = document.createElement("div");
empty.className = "friend-picker-empty";
empty.textContent = combined().length
? "没有匹配的好友。"
: "点击“刷新好友列表”后再选择目标。";
list.appendChild(empty);
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((checkbox) => {
names.forEach((name) => {
const label = document.createElement("label");
label.className = `friend-option${selected.has(name) ? " selected" : ""}`;
const text = document.createElement("span");
text.textContent = name;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selected.has(name);
checkbox.addEventListener("change", () => {
const option = checkbox.closest(".friend-option");
const value = checkbox.value;
if (checkbox.checked) {
selected.add(value);
option?.classList.add("selected");
} else {
selected.delete(value);
option?.classList.remove("selected");
}
syncTextareaFromSelected();
renderHiddenInputs();
updateSummary();
if (checkbox.checked) selected.add(name);
else selected.delete(name);
syncTextarea();
render();
});
label.append(text, checkbox);
list.appendChild(label);
});
};
refreshButton.addEventListener("click", async () => {
textarea?.addEventListener("input", () => {
selected = new Set(parseTargets(textarea.value));
render();
});
search?.addEventListener("input", render);
refreshButton?.addEventListener("click", async () => {
refreshButton.disabled = true;
const originalText = refreshButton.textContent;
refreshButton.textContent = "刷新中...";
statusEl.textContent = "正在读取好友列表...";
if (status) status.textContent = "正在读取好友列表...";
try {
const formData = new FormData();
formData.set("csrf_token", csrfToken);
@@ -437,29 +560,23 @@
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();
const data = await response.json();
if (!response.ok) throw new Error(data.error || "刷新失败");
friends = data.friends || [];
if (status) status.textContent = data.message || "好友列表已刷新";
render();
} catch (error) {
statusEl.textContent = error.message || "刷新好友列表失败";
if (status) status.textContent = `刷新失败:${error.message}`;
} finally {
refreshButton.disabled = false;
refreshButton.textContent = originalText;
}
});
searchInput.addEventListener("input", renderList);
targetsTextarea?.addEventListener("input", () => {
syncSelectedFromTextarea();
renderList();
});
syncSelectedFromTextarea();
renderList();
render();
});
})();
window.addEventListener("DOMContentLoaded", () => {
if (window.lucide) {
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
});
@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -1,183 +0,0 @@
{% extends "base.html" %}
{% block nav_key %}accounts{% endblock %}
{% block title %}账号与目标 | 自动续火花{% endblock %}
{% block page_title %}账号与目标{% endblock %}
{% block page_subtitle %}集中维护账号开关、目标好友、好友缓存和登录态同步{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/login-workspace">登录工作区</a>
{% endblock %}
{% block content %}
<div
id="login-desktop-controls"
data-public-url="{{ login_desktop_public_url }}"
data-csrf-token="{{ csrf_token }}"
hidden
></div>
{% if accounts %}
<section class="account-list">
{% for account in accounts %}
<article class="account-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">Cookies</span
><strong>{{ account.cookies|length }}</strong>
</div>
<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>
<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>
<div
class="friend-picker"
data-account-id="{{ account.unique_id }}"
data-refresh-url="/accounts/{{ account.unique_id }}/friends/refresh"
data-csrf-token="{{ csrf_token }}"
>
<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>
<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"
data-confirm="确认删除账号 {{ account.username }}?此操作会移除该账号配置。"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="danger-button" type="submit">删除账号</button>
</form>
</div>
</article>
{% endfor %}
</section>
{% else %}
<section class="empty-state">
当前没有账号。请先前往登录工作区完成扫码登录并保存账号。
</section>
{% endif %} {% endblock %}
+102 -486
View File
@@ -4,518 +4,134 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<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>
<link rel="stylesheet" href="/static/app.css?v=20260710">
</head>
<body>
<body data-page="{% block page_key %}dashboard{% endblock %}">
<header class="mobile-topbar">
<button class="icon-button" type="button" data-nav-toggle aria-label="打开导航" title="打开导航">
<i data-lucide="menu"></i>
</button>
<a class="mobile-brand" href="/">
<span class="brand-mark"><i data-lucide="flame"></i></span>
<span>续火花</span>
</a>
<button class="icon-button" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
<i data-lucide="sun" class="theme-light-icon"></i>
<i data-lucide="moon" class="theme-dark-icon"></i>
</button>
</header>
<div class="sidebar-backdrop" data-nav-close></div>
<div class="app-shell">
<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>
<aside class="sidebar" id="app-sidebar" aria-label="主导航">
<div class="sidebar-head">
<a class="brand" href="/">
<span class="brand-mark"><i data-lucide="flame"></i></span>
<span>
<strong class="brand-title">续火花</strong>
<small class="brand-subtitle">多账号控制平台</small>
</span>
</a>
<button class="icon-button sidebar-close" type="button" data-nav-close aria-label="关闭导航" title="关闭导航">
<i data-lucide="x"></i>
</button>
</div>
<div class="sidebar-footer">
<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>
<nav class="nav-groups">
<section>
<p class="nav-group-title">工作台</p>
<div class="nav-group">
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
<i data-lucide="layout-dashboard"></i><span>首页总览</span>
</a>
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
<i data-lucide="send"></i><span>发送控制台</span>
</a>
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
<i data-lucide="scroll-text"></i><span>发送记录</span>
</a>
</div>
</section>
<section>
<p class="nav-group-title">管理</p>
<div class="nav-group">
<a class="nav-item" href="/#account-management">
<i data-lucide="users"></i><span>账号管理</span>
</a>
<a class="nav-item" href="/#interactive-login-section">
<i data-lucide="scan-line"></i><span>登录抖音账号</span>
</a>
<a class="nav-item" href="/#config-panel">
<i data-lucide="sliders-horizontal"></i><span>运行配置</span>
</a>
<a class="nav-item" href="/#ops-panel">
<i data-lucide="wrench"></i><span>运维操作</span>
</a>
<a class="nav-item" href="/#settings-panel">
<i data-lucide="settings"></i><span>系统设置</span>
</a>
</div>
</section>
</nav>
<div class="sidebar-footer">
<div class="user-summary">
<span class="user-avatar">{{ (current_user or "A")[:1] }}</span>
<span class="user-copy">
<strong>{{ current_user or "未登录" }}</strong>
<small>管理员</small>
</span>
</div>
<form method="post" action="/logout" style="margin-top:14px;">
<button type="submit" class="sidebar-logout"><span></span><span>退出登录</span></button>
<form method="post" action="/logout">
<button class="button button-quiet button-block" type="submit">
<i data-lucide="log-out"></i><span>退出登录</span>
</button>
</form>
</div>
</aside>
<main class="main-area">
<header class="page-header">
<div>
<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 class="page-heading">
<h1>{% block page_title %}续火花{% endblock %}</h1>
<p>{% block page_subtitle %}多账号发送控制平台{% endblock %}</p>
</div>
<div class="page-header__right">
<button class="theme-toggle" id="themeToggle" title="切换白天/夜间模式" aria-label="切换主题">
<span class="ico-sun"></span><span class="ico-moon"></span>
<div class="page-header-actions">
<button class="icon-button desktop-theme-toggle" type="button" data-theme-toggle aria-label="切换主题" title="切换主题">
<i data-lucide="sun" class="theme-light-icon"></i>
<i data-lucide="moon" class="theme-dark-icon"></i>
</button>
<div class="page-header__actions">
<div class="action-bar">
{% block topbar_actions %}{% endblock %}
</div>
</div>
</header>
{% if flash %}
<div class="flash flash-{{ flash.level }}" role="status">{{ flash.message }}</div>
{% endif %}
<div class="page-body">
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</main>
</div>
<dialog class="confirm-dialog" id="confirm-dialog" aria-labelledby="confirm-title">
<div class="confirm-icon"><i data-lucide="triangle-alert"></i></div>
<h2 id="confirm-title">确认操作</h2>
<p id="confirm-message">请确认是否继续。</p>
<div class="confirm-actions">
<button class="button button-quiet" type="button" data-confirm-cancel>取消</button>
<button class="button button-danger" type="button" data-confirm-accept>确认执行</button>
</div>
</dialog>
<script defer src="/static/lucide.min.js?v=20260710"></script>
<script defer src="/static/app.js?v=20260710"></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>
File diff suppressed because it is too large Load Diff
+60 -145
View File
@@ -1,168 +1,83 @@
<!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">
<meta name="color-scheme" content="dark light">
<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;
}
/* ---- 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>
<link rel="stylesheet" href="/static/app.css?v=20260710">
</head>
<body>
<aside class="brand-panel">
<div class="brand-top">
<div class="brand-mark">🔥</div>
<div>
<div class="brand-title">续火花</div>
<div class="brand-sub">多账号控制平台</div>
</div>
<body class="login-page">
<aside class="login-brand-panel">
<a class="brand" href="/login">
<span class="brand-mark"><i data-lucide="flame"></i></span>
<span>
<strong class="brand-title">续火花</strong>
<small class="brand-subtitle">多账号控制平台</small>
</span>
</a>
<div class="login-brand-copy">
<h1>让每个好友<br><span>都不再断火花</span></h1>
<p>集中查看多账号发送进度、异常目标和登录状态,在一个控制台完成续火花运维。</p>
<ul class="login-features">
<li><i data-lucide="calendar-clock"></i><span>分时调度与当日未发送兜底</span></li>
<li><i data-lucide="badge-check"></i><span>服务端回执与强确认账本</span></li>
<li><i data-lucide="scan-line"></i><span>网页内扫码登录与登录态同步</span></li>
</ul>
</div>
<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>
<small class="muted">DouYin Spark Flow · Admin Console</small>
</aside>
<main class="form-panel">
<div class="card">
<h2 class="card-title">控制台登录</h2>
<p class="card-lead">用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
<main class="login-form-panel">
<section class="login-card">
<h2>{% if bootstrapped %}控制台登录{% else %}初始化管理员{% endif %}</h2>
<p>登录后可管理账号、目标好友、发送任务与运维配置。</p>
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
<div class="flash flash-{{ flash.level }}" role="status">{{ 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 method="post" action="/bootstrap" class="stack-form">
<label>
<span>管理员用户名</span>
<input type="text" name="username" value="admin" autocomplete="username">
</label>
<label>
<span>管理员密码</span>
<input type="password" name="password" autocomplete="new-password">
</label>
<label>
<span>确认密码</span>
<input type="password" name="confirm_password" autocomplete="new-password">
</label>
<button class="button button-primary button-block" type="submit">
<i data-lucide="shield-check"></i><span>初始化管理员账号</span>
</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 method="post" action="/login" class="stack-form">
<label>
<span>管理员用户名</span>
<input type="text" name="username" value="admin" autocomplete="username">
</label>
<label>
<span>管理员密码</span>
<input type="password" name="password" autocomplete="current-password">
</label>
<button class="button button-primary button-block" type="submit">
<i data-lucide="log-in"></i><span>登录控制台</span>
</button>
</form>
{% endif %}
<div class="foot">登录后可访问首页总览、发送控制台与系统设置</div>
</div>
<div class="login-foot">认证页面不会展示服务器凭据或登录态数据。</div>
</section>
</main>
<script defer src="/static/lucide.min.js?v=20260710"></script>
<script defer src="/static/app.js?v=20260710"></script>
</body>
</html>
@@ -1,149 +0,0 @@
{% extends "base.html" %}
{% block nav_key %}login_workspace{% endblock %}
{% block title %}登录工作区 | 自动续火花{% endblock %}
{% block page_title %}登录工作区{% endblock %}
{% block page_subtitle %}通过远端浏览器完成扫码、验证和登录态保存{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/accounts">账号管理</a>
{% endblock %}
{% block content %}
<div class="login-workspace-grid">
<aside class="stack">
<section class="panel">
<div class="section-title-row">
<div>
<h2>登录流程</h2>
<p class="muted compact">
适合新增账号、登录态失效或需要人工接管验证码的场景。
</p>
</div>
</div>
<div class="summary-list">
<div class="summary-row">
<strong>1. 打开浏览器</strong><span>启动远端交互式桌面</span>
</div>
<div class="summary-row">
<strong>2. 扫码与验证</strong><span>在 noVNC 中完成人工步骤</span>
</div>
<div class="summary-row">
<strong>3. 保存登录态</strong><span>写入新账号或覆盖已有账号</span>
</div>
</div>
</section>
<section
class="panel"
id="login-desktop-controls"
data-public-url="{{ login_desktop_public_url }}"
data-csrf-token="{{ csrf_token }}"
>
<div class="section-title-row">
<div>
<h2>桌面状态</h2>
<p class="muted compact">后台会持续轮询登录工作区状态。</p>
</div>
</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 class="button-row space-below-sm">
<span class="pill" id="login-desktop-runtime-state">检查中</span>
</div>
<div id="login-desktop-status-text">正在检查登录工作区状态。</div>
</div>
<div class="button-row space-above-md">
<button
type="button"
class="success-button login-desktop-open"
data-relogin-unique-id=""
>
打开新窗口
</button>
<button
type="button"
class="ghost-button login-desktop-reset"
data-confirm="确认重置登录工作区?当前远端浏览器状态会被重置。"
>
重置桌面
</button>
<button
type="button"
class="soft-button login-desktop-save"
data-relogin-unique-id=""
>
保存当前账号
</button>
</div>
</section>
</aside>
<section class="panel">
<div class="section-title-row">
<div>
<h2>浏览器工作区</h2>
<p class="muted compact">
如果内嵌 noVNC 无法连接,请使用新窗口打开或检查 login-desktop 服务。
</p>
</div>
</div>
<div class="url-field space-below-sm">
<input
id="desktop-public-url"
type="text"
value="{{ login_desktop_public_url }}"
readonly
/>
<button
type="button"
class="ghost-button copy-button"
id="copy-public-url"
aria-label="复制地址"
>
</button>
<a
class="soft-button"
href="{{ login_desktop_public_url }}"
target="_blank"
rel="noreferrer"
>新窗口打开</a
>
</div>
<div class="browser-frame-shell">
<div class="browser-frame-toolbar">
<span>noVNC 登录桌面</span>
<span class="pill info">端口 8788</span>
</div>
<iframe
class="desktop-frame"
src="{{ login_desktop_public_url }}"
title="交互式登录浏览器工作区"
loading="lazy"
></iframe>
<div class="frame-help">
连接失败时,请先点击“打开新窗口”;仍失败则重置桌面或检查容器状态。
</div>
</div>
</section>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -1,334 +0,0 @@
{% extends "base.html" %}
{% block nav_key %}settings{% endblock %}
{% block title %}运行与系统 | 自动续火花{% endblock %}
{% block page_title %}运行与系统{% endblock %}
{% block page_subtitle %}发送参数、手动任务、代理维护和面板服务设置{% endblock %}
{% block topbar_actions %}
<a class="ghost-button" href="/ops/logs">运行日志</a>
{% endblock %}
{% block content %}
<div class="settings-layout">
<div class="settings-stack">
<section class="panel">
<div class="section-title-row">
<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>
<div class="form-actions">
<button type="submit">保存运行配置</button>
</div>
</form>
</section>
<section class="panel">
<div class="section-title-row">
<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 class="form-actions">
<button type="submit">保存系统设置</button>
</div>
</form>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>容器与调度</h2>
<p class="muted compact">用于确认部署服务状态和当前调度任务。</p>
</div>
</div>
<div class="content-grid">
<div class="table-shell">
<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 %}success{% else %}warning{% endif %}"
>{{ row.Status }}</span
>
</td>
<td>{{ row.Image }}</td>
</tr>
{% else %}
<tr>
<td colspan="3">当前没有可见容器状态。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<pre class="code-block">
{{ ops.crontab or "当前没有 crontab 任务。" }}</pre
>
</div>
</section>
</div>
<aside class="settings-stack">
<section class="panel">
<div class="section-title-row">
<div>
<h2>手动任务</h2>
<p class="muted compact">
批量发送动作会影响多个账号,执行前请确认目标范围。
</p>
</div>
</div>
<div class="link-list">
<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>
</form>
</div>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>代理维护</h2>
<p class="muted compact">刷新订阅或重启代理容器。</p>
</div>
</div>
<div class="link-list">
<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"
data-confirm="确认重启代理容器?重启期间发送任务可能短暂受影响。"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="danger-button" type="submit">重启代理容器</button>
</form>
</div>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>发送窗口</h2>
<p class="muted compact">北京时间,例如 10:00-18:00/10m。</p>
</div>
</div>
<form method="post" action="/ops/schedule" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
<span>当前发送窗口</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>
</section>
<section class="panel">
<div class="section-title-row">
<div>
<h2>部署路径</h2>
<p class="muted compact">只读展示当前检测到的 Compose 信息。</p>
</div>
</div>
<div class="stack-form">
<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>
</aside>
</div>
{% endblock %}