diff --git a/.gitignore b/.gitignore
index e49275a..e20482a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.env
state/
logs/
+output/
proxy/config.yaml
*.bak-*
*.tar.gz
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a7fc51..b763a3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## 2026-05-30
+- Redesigned the Web UI into dedicated overview, login workspace, accounts, send console, logs, and settings views.
+- Moved the console styling and interaction scripts into `DouYinSparkFlow/webui/static/` with a denser admin-console layout and mobile drawer navigation.
+- Added safer confirmations for manual operations and localized the main Web UI feedback messages.
- Added a project disclaimer to the root README.
- Added a Linux Do friendly link and GitHub star history chart.
- Refined the root README wording and structure for an open source project style.
diff --git a/DouYinSparkFlow/webui/app.py b/DouYinSparkFlow/webui/app.py
index 7efcf50..036ae5c 100644
--- a/DouYinSparkFlow/webui/app.py
+++ b/DouYinSparkFlow/webui/app.py
@@ -237,6 +237,14 @@ def create_app():
return redirect("/login")
return None
+ def console_context(request):
+ return {
+ "flash": pop_flash(request),
+ "accounts": get_userData(force_reload=True),
+ "runtime_config": get_config(force_reload=True),
+ "ops": get_ops_snapshot(),
+ }
+
def flash(request, message, level="info"):
request.session["flash"] = {"message": message, "level": level}
@@ -259,7 +267,7 @@ def create_app():
@app.post("/bootstrap")
async def bootstrap(request: Request):
if is_bootstrapped():
- flash(request, "Admin login is already configured.", "warning")
+ flash(request, "管理员账号已初始化,请直接登录。", "warning")
return redirect("/login")
form = await request.form()
@@ -267,17 +275,17 @@ def create_app():
password = str(form.get("password", ""))
confirm = str(form.get("confirm_password", ""))
if not password or password != confirm:
- flash(request, "Password setup failed. Please enter matching passwords.", "error")
+ flash(request, "初始化失败,请输入一致的管理员密码。", "error")
return redirect("/login")
bootstrap_admin_password(password, username=username)
- flash(request, "Admin credentials created. Please log in.", "success")
+ flash(request, "管理员账号已创建,请登录控制台。", "success")
return redirect("/login")
@app.post("/login")
async def login_action(request: Request):
if not is_bootstrapped():
- flash(request, "Create the admin password first.", "warning")
+ flash(request, "请先创建管理员密码。", "warning")
return redirect("/login")
form = await request.form()
@@ -285,11 +293,11 @@ def create_app():
password = str(form.get("password", ""))
settings = get_app_settings(force_reload=True)
if username != settings["admin_username"] or not verify_password(password, settings["admin_password_hash"]):
- flash(request, "Invalid username or password.", "error")
+ flash(request, "用户名或密码不正确。", "error")
return redirect("/login")
issue_session(request, username)
- flash(request, "Signed in successfully.", "success")
+ flash(request, "已登录控制台。", "success")
return redirect("/")
@app.post("/logout")
@@ -306,12 +314,43 @@ def create_app():
return render_template(
request,
"dashboard.html",
- {
- "flash": pop_flash(request),
- "accounts": get_userData(force_reload=True),
- "runtime_config": get_config(force_reload=True),
- "ops": get_ops_snapshot(),
- },
+ console_context(request),
+ )
+
+ @app.get("/login-workspace", response_class=HTMLResponse)
+ async def login_workspace_page(request: Request):
+ maybe_redirect = require_user(request)
+ if maybe_redirect:
+ return maybe_redirect
+
+ return render_template(
+ request,
+ "login_workspace.html",
+ console_context(request),
+ )
+
+ @app.get("/accounts", response_class=HTMLResponse)
+ async def accounts_page(request: Request):
+ maybe_redirect = require_user(request)
+ if maybe_redirect:
+ return maybe_redirect
+
+ return render_template(
+ request,
+ "accounts.html",
+ console_context(request),
+ )
+
+ @app.get("/settings", response_class=HTMLResponse)
+ async def settings_page(request: Request):
+ maybe_redirect = require_user(request)
+ if maybe_redirect:
+ return maybe_redirect
+
+ return render_template(
+ request,
+ "settings.html",
+ console_context(request),
)
@app.get("/ops/send-console", response_class=HTMLResponse)
@@ -349,11 +388,11 @@ def create_app():
account["targets"] = targets
account["enabled"] = str(form.get("enabled", "")) == "on"
save_userData(accounts)
- flash(request, f"Updated account {account['username']}.", "success")
+ flash(request, f"已更新账号 {account['username']}。", "success")
else:
- flash(request, "Account not found.", "error")
+ flash(request, "未找到账号。", "error")
- return redirect("/")
+ return redirect("/accounts")
@app.post("/accounts/{unique_id}/toggle-enabled")
async def toggle_account_enabled(request: Request, unique_id: str):
@@ -368,8 +407,8 @@ def create_app():
accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id)
if not account:
- flash(request, "Account not found.", "error")
- return redirect("/")
+ flash(request, "未找到账号。", "error")
+ return redirect("/accounts")
account["enabled"] = not is_account_enabled(account)
save_userData(accounts)
@@ -378,7 +417,7 @@ def create_app():
f"{account.get('username', 'Account')} 已{'启用' if account['enabled'] else '停用'}自动续火花。",
"success",
)
- return redirect("/")
+ return redirect("/accounts")
@app.post("/accounts/{unique_id}/friends/refresh")
async def refresh_account_friend_list(request: Request, unique_id: str):
@@ -424,10 +463,10 @@ def create_app():
updated_accounts = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalize_unique_id(unique_id)]
if len(updated_accounts) != len(accounts):
save_userData(updated_accounts)
- flash(request, "Account deleted.", "success")
+ flash(request, "账号已删除。", "success")
else:
- flash(request, "Account not found.", "error")
- return redirect("/")
+ flash(request, "未找到账号。", "error")
+ return redirect("/accounts")
@app.post("/accounts/{unique_id}/retry-target")
async def retry_account_target(request: Request, unique_id: str):
@@ -441,13 +480,13 @@ def create_app():
target_name = str(form.get("target", "")).strip()
if not target_name:
- flash(request, "Target is required for retry.", "error")
+ flash(request, "请选择需要重试的目标。", "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")
+ flash(request, "未找到账号。", "error")
return redirect("/ops/send-console")
account_copy = dict(account)
@@ -458,16 +497,16 @@ def create_app():
try:
await run_browser_tasks(config, [account_copy])
except Exception as exc:
- flash(request, f"Retry failed for {account.get('username', 'Account')} / {target_name}: {exc}", "error")
+ flash(request, f"{account.get('username', '账号')} / {target_name} 重试失败:{exc}", "error")
return redirect("/ops/send-console")
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name):
- flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
+ flash(request, f"{account.get('username', '账号')} / {target_name} 已重试成功。", "success")
else:
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
- reason = str(failure_entry.get("reason") or "Retry did not confirm a successful send.")
- flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
+ reason = str(failure_entry.get("reason") or "重试未确认发送成功。")
+ flash(request, f"{account.get('username', '账号')} / {target_name} 重试未成功:{reason}", "error")
return redirect("/ops/send-console")
@app.post("/config")
@@ -533,8 +572,8 @@ def create_app():
config["happyNewYear"] = happy_new_year
save_config(config)
- flash(request, "Runtime config saved.", "success")
- return redirect("/")
+ flash(request, "运行配置已保存。", "success")
+ return redirect("/settings")
@app.post("/settings")
async def save_panel_settings(request: Request):
@@ -566,12 +605,12 @@ def create_app():
confirm_password = str(form.get("confirm_password", ""))
if new_password:
if new_password != confirm_password:
- flash(request, "Admin password was not updated because the confirmation did not match.", "error")
- return redirect("/")
+ flash(request, "管理员密码未更新:两次输入不一致。", "error")
+ return redirect("/settings")
update_admin_password(new_password)
- flash(request, "Panel settings saved.", "success")
- return redirect("/")
+ flash(request, "面板与服务设置已保存。", "success")
+ return redirect("/settings")
@app.post("/ops/run-now")
async def run_now(request: Request):
@@ -585,10 +624,10 @@ def create_app():
pid = run_task_now()
if pid == -1:
- flash(request, "Failed to start the full resend run. Check server logs for details.", "error")
+ flash(request, "补发全部对象启动失败,请查看服务日志。", "error")
else:
- flash(request, f"Triggered a full resend run in the background (pid {pid}).", "success")
- return redirect("/")
+ flash(request, f"已启动补发全部对象任务(pid {pid})。", "success")
+ return redirect("/ops/send-console")
@app.post("/ops/run-unsent")
async def run_unsent(request: Request):
@@ -602,9 +641,9 @@ def create_app():
pid = run_unsent_retry_now()
if pid == -1:
- flash(request, "Failed to start the unsent-target fallback run. Check server logs for details.", "error")
+ flash(request, "补发未成功目标启动失败,请查看服务日志。", "error")
else:
- flash(request, f"Triggered an unsent-target fallback run in the background (pid {pid}).", "success")
+ flash(request, f"已启动补发未成功目标任务(pid {pid})。", "success")
return redirect("/ops/send-console")
@app.post("/ops/proxy/refresh")
@@ -618,8 +657,8 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
refresh_proxy()
- flash(request, "Proxy subscription refreshed.", "success")
- return redirect("/")
+ flash(request, "代理订阅已刷新。", "success")
+ return redirect("/settings")
@app.post("/ops/proxy/restart")
async def proxy_restart(request: Request):
@@ -632,8 +671,8 @@ def create_app():
return Response("Invalid CSRF token", status_code=403)
restart_proxy()
- flash(request, "Proxy container restarted.", "success")
- return redirect("/")
+ flash(request, "代理容器已重启。", "success")
+ return redirect("/settings")
@app.post("/ops/schedule")
async def save_schedule(request: Request):
@@ -648,10 +687,10 @@ def create_app():
time_string = str(form.get("daily_schedule", "")).strip()
result = update_daily_schedule(time_string)
if getattr(result, "returncode", 1) == 0:
- flash(request, f"Updated the daily schedule to {time_string}.", "success")
+ flash(request, f"发送窗口已更新为 {time_string}。", "success")
else:
- flash(request, f"Failed to update the daily schedule to {time_string}: {getattr(result, 'stderr', '')}", "error")
- return redirect("/")
+ flash(request, f"发送窗口更新失败:{getattr(result, 'stderr', '')}", "error")
+ return redirect("/settings")
@app.get("/ops/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
diff --git a/DouYinSparkFlow/webui/static/app.css b/DouYinSparkFlow/webui/static/app.css
new file mode 100644
index 0000000..39fc7ff
--- /dev/null
+++ b/DouYinSparkFlow/webui/static/app.css
@@ -0,0 +1,1177 @@
+:root {
+ --bg: #f5f7fb;
+ --surface: #ffffff;
+ --surface-muted: #f8fafc;
+ --surface-soft: #f1f5f9;
+ --border: #e2e8f0;
+ --border-strong: #cbd5e1;
+ --text: #0f172a;
+ --text-muted: #64748b;
+ --text-soft: #94a3b8;
+ --primary: #2563eb;
+ --primary-hover: #1d4ed8;
+ --primary-soft: #eff6ff;
+ --success: #16a34a;
+ --success-soft: #ecfdf3;
+ --danger: #dc2626;
+ --danger-soft: #fef2f2;
+ --warning: #d97706;
+ --warning-soft: #fff7ed;
+ --info: #0284c7;
+ --info-soft: #eff6ff;
+ --shadow: 0 14px 34px rgba(15, 23, 42, 0.07);
+ --shadow-soft: 0 8px 22px rgba(15, 23, 42, 0.05);
+ --radius-lg: 18px;
+ --radius-md: 14px;
+ --radius-sm: 10px;
+ --sidebar-width: 248px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ min-height: 100%;
+ margin: 0;
+}
+
+body {
+ color: var(--text);
+ background: linear-gradient(180deg, #f8fafc 0%, var(--bg) 100%);
+ font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+ font-size: 14px;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 0;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
+ min-height: 100vh;
+}
+
+.sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ padding: 22px 16px;
+ border-right: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.92);
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ z-index: 20;
+}
+
+.brand,
+.mobile-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.brand-mark {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--primary);
+ color: #fff;
+ font-weight: 800;
+ box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
+}
+
+.brand-title {
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.brand-subtitle,
+.sidebar-footer-subtitle {
+ margin-top: 3px;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.nav-groups {
+ display: grid;
+ gap: 18px;
+}
+
+.nav-group-title {
+ margin: 0 10px 8px;
+ color: var(--text-soft);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.nav-group {
+ display: grid;
+ gap: 6px;
+}
+
+.nav-item {
+ min-height: 42px;
+ border-radius: 12px;
+ padding: 10px 12px;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ transition:
+ background 0.16s ease,
+ color 0.16s ease;
+}
+
+.nav-item:hover {
+ background: var(--surface-muted);
+ color: var(--text);
+}
+
+.nav-item.active {
+ background: var(--primary-soft);
+ color: var(--primary);
+ font-weight: 800;
+}
+
+.nav-icon {
+ width: 18px;
+ text-align: center;
+ flex: 0 0 18px;
+}
+
+.sidebar-footer {
+ margin-top: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 14px;
+ background: var(--surface-muted);
+}
+
+.sidebar-footer-title {
+ font-weight: 800;
+}
+
+.main-area {
+ min-width: 0;
+ padding: 28px;
+}
+
+.page-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 24px;
+}
+
+.eyebrow {
+ color: var(--primary);
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.page-title-row {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 6px;
+}
+
+.page-title {
+ margin: 0;
+ font-size: 30px;
+ line-height: 1.15;
+ letter-spacing: 0;
+}
+
+.page-subtitle {
+ margin: 0;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.page-header-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.page-body,
+.stack,
+.dashboard-shell,
+.console-shell,
+.account-list,
+.settings-stack {
+ display: grid;
+ gap: 18px;
+}
+
+.mobile-topbar,
+.sidebar-backdrop {
+ display: none;
+}
+
+.panel,
+.data-card,
+.account-card,
+.quick-card,
+.empty-state {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+ box-shadow: var(--shadow-soft);
+}
+
+.panel {
+ padding: 22px;
+}
+
+.section-title-row,
+.panel-header,
+.card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+h2,
+h3,
+h4,
+p {
+ margin-top: 0;
+}
+
+h2 {
+ margin-bottom: 4px;
+ font-size: 22px;
+}
+
+h3 {
+ margin-bottom: 4px;
+ font-size: 16px;
+}
+
+.muted {
+ color: var(--text-muted);
+}
+
+.compact {
+ margin-bottom: 0;
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.stat-card {
+ min-height: 112px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ padding: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.stat-meta {
+ min-width: 0;
+ display: grid;
+ gap: 7px;
+}
+
+.stat-label {
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.stat-value {
+ font-size: 28px;
+ line-height: 1;
+ font-weight: 850;
+}
+
+.stat-help {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.stat-icon {
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 42px;
+ font-weight: 900;
+}
+
+.stat-icon.blue {
+ color: var(--primary);
+ background: var(--primary-soft);
+}
+
+.stat-icon.green {
+ color: var(--success);
+ background: var(--success-soft);
+}
+
+.stat-icon.red {
+ color: var(--danger);
+ background: var(--danger-soft);
+}
+
+.stat-icon.orange {
+ color: var(--warning);
+ background: var(--warning-soft);
+}
+
+.stat-icon.gray {
+ color: var(--text-muted);
+ background: var(--surface-soft);
+}
+
+.layout-grid,
+.dashboard-layout,
+.settings-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 360px;
+ gap: 18px;
+ align-items: start;
+}
+
+.content-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.quick-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.quick-card {
+ padding: 16px;
+ display: grid;
+ gap: 12px;
+}
+
+.quick-card strong {
+ font-size: 16px;
+}
+
+.link-list {
+ display: grid;
+ gap: 10px;
+}
+
+.link-list > a,
+.link-list > button,
+.link-list > form > button {
+ width: 100%;
+}
+
+.summary-list {
+ display: grid;
+ gap: 12px;
+}
+
+.summary-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ align-items: center;
+}
+
+.summary-row strong {
+ display: block;
+}
+
+.summary-row span {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.button-row,
+.button-grid,
+.form-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.button-grid {
+ align-items: stretch;
+}
+
+.button-grid > form,
+.button-grid > a,
+.button-grid > button {
+ flex: 1 1 150px;
+}
+
+.space-below-sm {
+ margin-bottom: 12px;
+}
+
+.space-above-md {
+ margin-top: 14px;
+}
+
+button,
+.link-button,
+.ghost-button,
+.soft-button,
+.danger-button,
+.success-button {
+ min-height: 40px;
+ border-radius: 10px;
+ padding: 10px 14px;
+ background: var(--primary);
+ color: #fff;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-weight: 800;
+ white-space: nowrap;
+ transition:
+ background 0.16s ease,
+ border-color 0.16s ease,
+ color 0.16s ease;
+}
+
+button:hover,
+.link-button:hover,
+.ghost-button:hover,
+.soft-button:hover,
+.danger-button:hover,
+.success-button:hover {
+ background: var(--primary-hover);
+}
+
+.ghost-button,
+.soft-button,
+.danger-button,
+.success-button {
+ border: 1px solid var(--border);
+ box-shadow: none;
+}
+
+.ghost-button {
+ color: var(--text);
+ background: #fff;
+}
+
+.ghost-button:hover {
+ background: var(--surface-muted);
+}
+
+.soft-button {
+ color: var(--primary);
+ background: var(--primary-soft);
+ border-color: #bfdbfe;
+}
+
+.soft-button:hover {
+ color: #fff;
+ background: var(--primary);
+}
+
+.danger-button {
+ color: var(--danger);
+ background: var(--danger-soft);
+ border-color: #fecaca;
+}
+
+.danger-button:hover {
+ color: #fff;
+ background: var(--danger);
+}
+
+.success-button {
+ color: var(--success);
+ background: var(--success-soft);
+ border-color: #bbf7d0;
+}
+
+.success-button:hover {
+ color: #fff;
+ background: var(--success);
+}
+
+.icon-button,
+.copy-button {
+ width: 40px;
+ min-width: 40px;
+ padding: 0;
+}
+
+.stack-form {
+ display: grid;
+ gap: 14px;
+}
+
+.form-grid,
+.settings-grid,
+.metric-row,
+.account-metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.metric-row,
+.account-metrics {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+label {
+ display: grid;
+ gap: 7px;
+}
+
+label span,
+.field-label {
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+input[type="text"],
+input[type="password"],
+input[type="search"],
+input[type="number"],
+textarea,
+select {
+ width: 100%;
+ min-height: 40px;
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ background: #fff;
+ color: var(--text);
+ padding: 10px 12px;
+ outline: none;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+}
+
+textarea {
+ min-height: 96px;
+ resize: vertical;
+}
+
+.check-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.check-row input {
+ width: 16px;
+ height: 16px;
+}
+
+.pill,
+.status-chip,
+.status-pill {
+ min-height: 26px;
+ border-radius: 999px;
+ padding: 4px 10px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--text-muted);
+ background: var(--surface-soft);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.pill.success,
+.status-chip.success {
+ color: var(--success);
+ background: var(--success-soft);
+}
+
+.pill.warning,
+.status-chip.warning {
+ color: var(--warning);
+ background: var(--warning-soft);
+}
+
+.pill.danger,
+.status-chip.danger {
+ color: var(--danger);
+ background: var(--danger-soft);
+}
+
+.pill.info,
+.status-chip.info {
+ color: var(--info);
+ background: var(--info-soft);
+}
+
+.flash {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 12px 14px;
+ background: #fff;
+ box-shadow: var(--shadow-soft);
+}
+
+.flash.success {
+ color: #166534;
+ background: var(--success-soft);
+ border-color: #bbf7d0;
+}
+
+.flash.warning {
+ color: #92400e;
+ background: var(--warning-soft);
+ border-color: #fed7aa;
+}
+
+.flash.error {
+ color: #991b1b;
+ background: var(--danger-soft);
+ border-color: #fecaca;
+}
+
+.table-shell {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: #fff;
+}
+
+table {
+ width: 100%;
+ min-width: 720px;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: middle;
+ font-size: 13px;
+}
+
+th {
+ color: var(--text-muted);
+ background: var(--surface-muted);
+ font-weight: 800;
+}
+
+tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.account-card,
+.data-card {
+ padding: 18px;
+}
+
+.account-card {
+ display: grid;
+ gap: 16px;
+}
+
+.account-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.account-head,
+.account-panel__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.account-ident,
+.account-panel__meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.avatar-photo,
+.account-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 40px;
+ color: #fff;
+ background: var(--primary);
+ font-weight: 850;
+}
+
+.account-name,
+.account-panel__name {
+ font-size: 17px;
+ font-weight: 850;
+}
+
+.account-sub,
+.account-panel__subline {
+ margin-top: 4px;
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.metric-box {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px;
+ background: var(--surface-muted);
+ text-align: center;
+}
+
+.metric-box .label {
+ display: block;
+ color: var(--text-muted);
+ font-size: 12px;
+ margin-bottom: 6px;
+}
+
+.metric-box strong {
+ font-size: 18px;
+}
+
+.friend-picker {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-muted);
+ padding: 14px;
+ display: grid;
+ gap: 12px;
+}
+
+.friend-picker-toolbar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.friend-picker-title {
+ color: var(--text);
+ font-weight: 850;
+}
+
+.friend-picker-list {
+ max-height: 240px;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: #fff;
+ padding: 8px;
+ display: grid;
+ gap: 6px;
+}
+
+.friend-option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ padding: 8px 10px;
+}
+
+.friend-option.selected {
+ border-color: #bfdbfe;
+ background: var(--primary-soft);
+}
+
+.friend-option input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--primary);
+}
+
+.friend-picker-empty,
+.empty-state {
+ padding: 24px 16px;
+ text-align: center;
+ color: var(--text-muted);
+ border-style: dashed;
+ background: var(--surface-muted);
+}
+
+.login-workspace-grid {
+ display: grid;
+ grid-template-columns: 340px minmax(0, 1fr);
+ gap: 18px;
+ align-items: start;
+}
+
+.browser-frame-shell {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background: #0f172a;
+}
+
+.browser-frame-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ background: #111827;
+ color: #e5e7eb;
+}
+
+.url-field {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.desktop-frame {
+ width: 100%;
+ height: min(68vh, 720px);
+ min-height: 520px;
+ border: 0;
+ display: block;
+ background: #0f172a;
+}
+
+.frame-help {
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 12px 14px;
+ color: #cbd5e1;
+ background: #111827;
+ font-size: 13px;
+}
+
+.status-list {
+ display: grid;
+ gap: 10px;
+}
+
+.status-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-muted);
+ font-weight: 700;
+}
+
+.status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: var(--border-strong);
+}
+
+.status-item.pending .status-dot {
+ background: var(--primary);
+}
+
+.status-item.success .status-dot {
+ background: var(--success);
+}
+
+.status-item.error .status-dot {
+ background: var(--danger);
+}
+
+.info-card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-muted);
+ padding: 14px;
+ line-height: 1.65;
+}
+
+.data-card {
+ overflow: hidden;
+}
+
+.data-card__head,
+.data-card__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.data-card__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 850;
+}
+
+.data-card__title.success {
+ color: var(--success);
+}
+
+.data-card__title.danger {
+ color: var(--danger);
+}
+
+.data-card__title.warning {
+ color: var(--warning);
+}
+
+.data-card__title.info {
+ color: var(--info);
+}
+
+.data-card__table {
+ margin-top: 12px;
+}
+
+.data-card__extra {
+ display: none;
+ margin-top: 12px;
+}
+
+.data-card__extra.is-open {
+ display: block;
+}
+
+.data-card__footer {
+ justify-content: flex-end;
+ margin-top: 12px;
+}
+
+.data-card__footer-button {
+ min-height: auto;
+ padding: 0;
+ color: var(--primary);
+ background: transparent;
+}
+
+.data-card__footer-button:hover {
+ background: transparent;
+ color: var(--primary-hover);
+ text-decoration: underline;
+}
+
+.account-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.console-account-list {
+ display: grid;
+ gap: 18px;
+}
+
+.console-footer {
+ color: var(--text-muted);
+ text-align: center;
+}
+
+.code-block {
+ margin: 0;
+ max-height: 520px;
+ overflow: auto;
+ border-radius: var(--radius-md);
+ padding: 14px;
+ background: #0f172a;
+ color: #dbeafe;
+ font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.code-block.light {
+ color: var(--text);
+ background: var(--surface-muted);
+ border: 1px solid var(--border);
+}
+
+.code-block.tall {
+ min-height: 420px;
+}
+
+.login-page {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
+}
+
+.login-card {
+ width: min(100%, 420px);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 28px;
+ background: #fff;
+ box-shadow: var(--shadow);
+}
+
+.login-card h1 {
+ margin: 0 0 10px;
+ font-size: 28px;
+}
+
+.login-brand {
+ margin-bottom: 22px;
+}
+
+.login-flash {
+ margin: 16px 0;
+}
+
+@media (max-width: 1480px) {
+ .stats-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .content-grid,
+ .account-list,
+ .account-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 1180px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .mobile-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 25;
+ height: 58px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.94);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ width: min(86vw, 300px);
+ transform: translateX(-100%);
+ transition: transform 0.2s ease;
+ }
+
+ body.nav-open .sidebar {
+ transform: translateX(0);
+ }
+
+ .sidebar-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 15;
+ background: rgba(15, 23, 42, 0.32);
+ }
+
+ body.nav-open .sidebar-backdrop {
+ display: block;
+ }
+
+ .main-area {
+ padding: 20px 16px 28px;
+ }
+
+ .page-header,
+ .section-title-row,
+ .panel-header,
+ .account-head,
+ .account-panel__header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .layout-grid,
+ .dashboard-layout,
+ .settings-layout,
+ .login-workspace-grid,
+ .quick-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 720px) {
+ .stats-grid,
+ .form-grid,
+ .settings-grid,
+ .metric-row,
+ .account-metrics {
+ grid-template-columns: 1fr;
+ }
+
+ .page-title {
+ font-size: 25px;
+ }
+
+ .panel,
+ .account-card,
+ .data-card {
+ padding: 16px;
+ }
+
+ table {
+ min-width: 640px;
+ }
+
+ .desktop-frame {
+ min-height: 420px;
+ }
+}
diff --git a/DouYinSparkFlow/webui/static/app.js b/DouYinSparkFlow/webui/static/app.js
new file mode 100644
index 0000000..b943fb4
--- /dev/null
+++ b/DouYinSparkFlow/webui/static/app.js
@@ -0,0 +1,411 @@
+(() => {
+ 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-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);
+ }
+ });
+})();
+
+(() => {
+ 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 root = document.getElementById("login-desktop-controls");
+ if (!root) return;
+
+ const csrfToken = root.dataset.csrfToken || "";
+ const publicUrl = root.dataset.publicUrl || "";
+ const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
+ const statusTextEl = document.getElementById("login-desktop-status-text");
+ const openButtons = document.querySelectorAll(".login-desktop-open");
+ const saveButtons = document.querySelectorAll(".login-desktop-save");
+ const resetButtons = document.querySelectorAll(".login-desktop-reset");
+ const copyPublicUrlButton = document.getElementById("copy-public-url");
+ const statusMap = {
+ checking: document.getElementById("desktop-status-checking"),
+ pending: document.getElementById("desktop-status-pending"),
+ success: document.getElementById("desktop-status-success"),
+ error: document.getElementById("desktop-status-error"),
+ };
+
+ const setVisualStatus = (state) => {
+ Object.values(statusMap).forEach((node) => {
+ if (!node) return;
+ node.style.opacity = "0.46";
+ });
+ if (statusMap[state]) {
+ statusMap[state].style.opacity = "1";
+ }
+ };
+
+ const setStatus = (text, tone = "", state = "checking") => {
+ if (statusTextEl) statusTextEl.textContent = text;
+ if (runtimeStateEl) {
+ runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
+ }
+ setVisualStatus(state);
+ };
+
+ const postForm = async (url, payload = {}) => {
+ const formData = new FormData();
+ formData.set("csrf_token", csrfToken);
+ Object.entries(payload).forEach(([key, value]) => {
+ formData.set(key, String(value ?? ""));
+ });
+ const response = await fetch(url, {
+ method: "POST",
+ body: formData,
+ credentials: "same-origin",
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || data.ok === false) {
+ throw new Error(data.error || `请求失败:${response.status}`);
+ }
+ return data;
+ };
+
+ const openDesktopWindow = () => {
+ const popup = window.open(publicUrl, "_blank");
+ if (!popup) {
+ setStatus(
+ "浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
+ "danger",
+ "error",
+ );
+ return false;
+ }
+ return true;
+ };
+
+ const pollStatus = async () => {
+ try {
+ const response = await fetch("/login-desktop/status", {
+ credentials: "same-origin",
+ });
+ const data = await response.json();
+ if (!response.ok || data.ok === false) {
+ if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
+ setStatus(
+ data.error || "登录工作区不可用,请检查 login-desktop 服务。",
+ "warning",
+ "error",
+ );
+ return;
+ }
+ if (runtimeStateEl)
+ runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
+ if (data.logged_in) {
+ setStatus(
+ `当前浏览器已登录:${data.username}(${data.unique_id})`,
+ "success",
+ "success",
+ );
+ } else {
+ setStatus(
+ "当前浏览器尚未登录,可打开登录工作区开始人工登录。",
+ "",
+ "pending",
+ );
+ }
+ } catch (error) {
+ if (runtimeStateEl) runtimeStateEl.textContent = "异常";
+ setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
+ }
+ };
+
+ copyPublicUrlButton?.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(publicUrl);
+ setStatus("登录工作区地址已复制。", "success", "pending");
+ } catch (error) {
+ setStatus(`复制失败:${error.message}`, "warning", "error");
+ }
+ });
+
+ openButtons.forEach((button) => {
+ button.addEventListener("click", async () => {
+ const reloginUniqueId = String(
+ button.dataset.reloginUniqueId || "",
+ ).trim();
+ const accountName = String(button.dataset.accountName || "").trim();
+ try {
+ await postForm("/login-desktop/open");
+ openDesktopWindow();
+ setStatus(
+ reloginUniqueId
+ ? `请使用账号 ${accountName || reloginUniqueId} 完成登录,然后保存登录态。`
+ : "请在远端浏览器中完成抖音创作者中心登录,然后保存账号。",
+ "",
+ "pending",
+ );
+ } catch (error) {
+ setStatus(`打开登录工作区失败:${error.message}`, "danger", "error");
+ }
+ });
+ });
+
+ saveButtons.forEach((button) => {
+ button.addEventListener("click", async () => {
+ const reloginUniqueId = String(
+ button.dataset.reloginUniqueId || "",
+ ).trim();
+ const accountName = String(button.dataset.accountName || "").trim();
+ try {
+ const data = await postForm("/login-desktop/save", {
+ relogin_unique_id: reloginUniqueId,
+ });
+ setStatus(
+ reloginUniqueId
+ ? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
+ : `已保存当前登录账号:${data.account?.username || ""}`,
+ "success",
+ "success",
+ );
+ window.setTimeout(() => window.location.reload(), 900);
+ } catch (error) {
+ setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
+ }
+ });
+ });
+
+ resetButtons.forEach((button) => {
+ button.addEventListener("click", async () => {
+ try {
+ await postForm("/login-desktop/reset");
+ setStatus(
+ "登录工作区已重置,正在重新初始化浏览器。",
+ "warning",
+ "checking",
+ );
+ await pollStatus();
+ } catch (error) {
+ setStatus(`重置登录工作区失败:${error.message}`, "danger", "error");
+ }
+ });
+ });
+
+ setVisualStatus("checking");
+ pollStatus();
+ window.setInterval(pollStatus, 5000);
+})();
+
+(() => {
+ const pickers = document.querySelectorAll(".friend-picker");
+ if (!pickers.length) return;
+
+ const parseJsonScript = (id) => {
+ const el = document.getElementById(id);
+ if (!el) return [];
+ try {
+ return JSON.parse(el.textContent || "[]");
+ } catch (error) {
+ console.error("Failed to parse friend picker JSON", id, error);
+ return [];
+ }
+ };
+
+ const escapeHtml = (value) =>
+ String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+
+ pickers.forEach((picker) => {
+ const accountId = picker.dataset.accountId;
+ const refreshUrl = picker.dataset.refreshUrl;
+ const csrfToken = picker.dataset.csrfToken;
+ const searchInput = picker.querySelector(".friend-search-input");
+ const refreshButton = picker.querySelector(".friend-refresh-button");
+ const listEl = picker.querySelector(".friend-picker-list");
+ const summaryEl = picker.querySelector(".friend-picker-summary");
+ const statusEl = picker.querySelector(".friend-picker-status");
+ const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
+ const formEl = picker.closest("form");
+ const targetsTextarea = formEl?.querySelector(".targets-textarea");
+ const currentTargetsEl = picker.querySelector(
+ ".friend-picker-current-targets span",
+ );
+
+ let friends = parseJsonScript(`friends-cache-${accountId}`);
+ let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
+
+ const 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 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) =>
+ name.toLowerCase().includes(query),
+ );
+ renderHiddenInputs();
+ updateSummary();
+
+ if (!allNames.length) {
+ listEl.innerHTML =
+ '
点击“刷新好友列表”后再勾选目标好友。
';
+ return;
+ }
+
+ if (!displayNames.length) {
+ listEl.innerHTML =
+ '没有匹配的好友。
';
+ return;
+ }
+
+ listEl.innerHTML = displayNames
+ .map(
+ (name) => `
+
+ `,
+ )
+ .join("");
+
+ listEl.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
+ 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();
+ });
+ });
+ };
+
+ refreshButton.addEventListener("click", async () => {
+ refreshButton.disabled = true;
+ const originalText = refreshButton.textContent;
+ refreshButton.textContent = "刷新中...";
+ statusEl.textContent = "正在读取好友列表...";
+ try {
+ const formData = new FormData();
+ formData.set("csrf_token", csrfToken);
+ const response = await fetch(refreshUrl, {
+ method: "POST",
+ body: formData,
+ credentials: "same-origin",
+ });
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || "刷新好友列表失败");
+ }
+ friends = Array.isArray(payload.friends) ? payload.friends : [];
+ statusEl.textContent =
+ payload.message || `已刷新 ${friends.length} 个好友`;
+ renderList();
+ } catch (error) {
+ statusEl.textContent = error.message || "刷新好友列表失败";
+ } finally {
+ refreshButton.disabled = false;
+ refreshButton.textContent = originalText;
+ }
+ });
+
+ searchInput.addEventListener("input", renderList);
+ targetsTextarea?.addEventListener("input", () => {
+ syncSelectedFromTextarea();
+ renderList();
+ });
+
+ syncSelectedFromTextarea();
+ renderList();
+ });
+})();
diff --git a/DouYinSparkFlow/webui/templates/accounts.html b/DouYinSparkFlow/webui/templates/accounts.html
new file mode 100644
index 0000000..5a9cf55
--- /dev/null
+++ b/DouYinSparkFlow/webui/templates/accounts.html
@@ -0,0 +1,184 @@
+{% extends "base.html" %}
+
+{% block nav_key %}accounts{% endblock %}
+{% block title %}账号与目标 | 自动续火花{% endblock %}
+{% block page_title %}账号与目标{% endblock %}
+{% block page_subtitle %}集中维护账号开关、目标好友、好友缓存和登录态同步{% endblock %}
+
+{% block topbar_actions %}
+登录工作区
+发送控制台
+{% endblock %}
+
+{% block content %}
+
+
+{% if accounts %}
+
+ {% for account in accounts %}
+
+
+
+
+ {{ account.username[:1] if account.username else "A" }}
+
+
+
{{ account.username }}
+
unique_id: {{ account.unique_id }}
+
+
+
+ {% if account.enabled|default(true) %}已启用{% else %}已停用{% endif %}
+
+
+
+
+
+ Cookies{{ account.cookies|length }}
+
+
+ 目标好友{{ account.targets|length }}
+
+
+ 好友缓存{{ account.friends_cache|default([], true)|length }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
+{% else %}
+
+ 当前没有账号。请先前往登录工作区完成扫码登录并保存账号。
+
+{% endif %} {% endblock %}
diff --git a/DouYinSparkFlow/webui/templates/base.html b/DouYinSparkFlow/webui/templates/base.html
index d364ca7..1713170 100644
--- a/DouYinSparkFlow/webui/templates/base.html
+++ b/DouYinSparkFlow/webui/templates/base.html
@@ -2,758 +2,121 @@
-
-
+
+
{% block title %}自动续火花{% endblock %}
-
+
-