mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 16:07:22 +08:00
feat: publish refreshed SparkFlow console and send safety
This commit is contained in:
+189
-31
@@ -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
@@ -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(),
|
||||
|
||||
+1267
-880
File diff suppressed because it is too large
Load Diff
+435
-318
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
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.
|
||||
+12
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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
Reference in New Issue
Block a user