mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-07-02 21:01:26 +08:00
feat: redesign web admin layout
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.env
|
||||
state/
|
||||
logs/
|
||||
output/
|
||||
proxy/config.yaml
|
||||
*.bak-*
|
||||
*.tar.gz
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
1177
DouYinSparkFlow/webui/static/app.css
Normal file
1177
DouYinSparkFlow/webui/static/app.css
Normal file
File diff suppressed because it is too large
Load Diff
411
DouYinSparkFlow/webui/static/app.js
Normal file
411
DouYinSparkFlow/webui/static/app.js
Normal file
@@ -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 =
|
||||
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">没有匹配的好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = displayNames
|
||||
.map(
|
||||
(name) => `
|
||||
<label class="friend-option ${selected.has(name) ? "selected" : ""}">
|
||||
<span>${escapeHtml(name)}</span>
|
||||
<input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}>
|
||||
</label>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((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();
|
||||
});
|
||||
})();
|
||||
184
DouYinSparkFlow/webui/templates/accounts.html
Normal file
184
DouYinSparkFlow/webui/templates/accounts.html
Normal file
@@ -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 %}
|
||||
<a class="ghost-button" href="/login-workspace">登录工作区</a>
|
||||
<a class="soft-button" href="/ops/send-console">发送控制台</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 %}
|
||||
@@ -2,758 +2,121 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}自动续火花{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f8fd;
|
||||
--bg-glow: rgba(61, 118, 255, 0.08);
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f8fbff;
|
||||
--surface-tint: #f3f7ff;
|
||||
--border: #e3ebf5;
|
||||
--border-strong: #ced9ea;
|
||||
--text: #1a2740;
|
||||
--text-soft: #5e6e8c;
|
||||
--primary: #2f67ff;
|
||||
--primary-strong: #2158f5;
|
||||
--primary-soft: #ecf3ff;
|
||||
--success: #26b064;
|
||||
--success-soft: #ebfbf2;
|
||||
--danger: #ff6b5f;
|
||||
--danger-soft: #fff1ee;
|
||||
--warning: #ffad3b;
|
||||
--warning-soft: #fff6e9;
|
||||
--info: #6f92ff;
|
||||
--info-soft: #eef3ff;
|
||||
--shadow: 0 18px 48px rgba(24, 40, 72, 0.08);
|
||||
--shadow-soft: 0 10px 26px rgba(24, 40, 72, 0.05);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 16px;
|
||||
--radius-sm: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(75, 130, 255, 0.16), transparent 24%),
|
||||
radial-gradient(circle at right center, rgba(255, 190, 120, 0.12), transparent 20%),
|
||||
linear-gradient(180deg, #f9fbff 0%, #f4f7fc 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 232px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 18px 16px 20px;
|
||||
border-right: 1px solid rgba(225, 232, 243, 0.92);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.96));
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 4px 4px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary), #5a8aff);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10px 20px rgba(47, 103, 255, 0.28);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
margin: 0 10px 8px;
|
||||
font-size: 11px;
|
||||
color: #93a3bf;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
color: var(--text-soft);
|
||||
transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(47, 103, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(180deg, #eef4ff, #e8f0ff);
|
||||
color: var(--primary-strong);
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px rgba(47, 103, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 14px 12px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, #ffffff, #f4f8ff);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.sidebar-footer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4e7dff, #7096ff);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-footer-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-footer-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.main-area {
|
||||
min-width: 0;
|
||||
padding: 26px 28px 32px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(226, 234, 245, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
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;
|
||||
}
|
||||
|
||||
.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 rgba(227, 235, 245, 0.96);
|
||||
background: linear-gradient(180deg, #ffffff, #fcfdff);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.stat-icon.blue {
|
||||
background: linear-gradient(180deg, #eef4ff, #dfe9ff);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.stat-icon.green {
|
||||
background: linear-gradient(180deg, #edfcf3, #dcf7e7);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-icon.red {
|
||||
background: linear-gradient(180deg, #fff1ee, #ffe3de);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-icon.orange {
|
||||
background: linear-gradient(180deg, #fff7ea, #ffeed2);
|
||||
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: linear-gradient(135deg, var(--primary), #8fb0ff);
|
||||
box-shadow: 0 0 0 4px rgba(47, 103, 255, 0.12);
|
||||
}
|
||||
|
||||
.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: #f3f6fb;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
button,
|
||||
.link-button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #3d79ff, #2f67ff);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 12px 18px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 12px 22px rgba(47, 103, 255, 0.18);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.link-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 28px rgba(47, 103, 255, 0.22);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.soft-button,
|
||||
.danger-button {
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.soft-button {
|
||||
color: var(--primary-strong);
|
||||
background: var(--primary-soft);
|
||||
border: 1px solid rgba(47, 103, 255, 0.1);
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(255, 107, 95, 0.18);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.button-row,
|
||||
.button-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button-row.two,
|
||||
.button-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
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: #fff;
|
||||
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: rgba(47, 103, 255, 0.55);
|
||||
box-shadow: 0 0 0 4px rgba(47, 103, 255, 0.1);
|
||||
}
|
||||
|
||||
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 {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.flash.success {
|
||||
background: var(--success-soft);
|
||||
color: #1b6d42;
|
||||
border-color: rgba(38, 176, 100, 0.18);
|
||||
}
|
||||
|
||||
.flash.warning {
|
||||
background: var(--warning-soft);
|
||||
color: #9a6307;
|
||||
border-color: rgba(255, 173, 59, 0.18);
|
||||
}
|
||||
|
||||
.flash.error {
|
||||
background: var(--danger-soft);
|
||||
color: #a33b32;
|
||||
border-color: rgba(255, 107, 95, 0.16);
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(227, 235, 245, 0.96);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.table-shell.scrollable {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(232, 238, 247, 0.96);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #fbfcff;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: rgba(248, 251, 255, 0.78);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #0f1728;
|
||||
color: #dbe4fb;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.code-block.light {
|
||||
color: var(--text);
|
||||
background: #f7f9fd;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 34px 20px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@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" />
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<div class="mobile-topbar">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
data-nav-toggle
|
||||
aria-label="打开导航"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="mobile-brand">
|
||||
<span class="brand-mark">⚡</span>
|
||||
<span>自动续火花</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="sidebar-section-title">导航</p>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}" href="/">
|
||||
<div class="sidebar-backdrop" data-nav-close></div>
|
||||
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">⚡</div>
|
||||
<div>
|
||||
<div class="brand-title">自动续火花</div>
|
||||
<div class="brand-subtitle">多账号发送控制台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups">
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">概览</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'dashboard' %}active{% endif %}"
|
||||
href="/"
|
||||
>
|
||||
<span class="nav-icon">⌂</span>
|
||||
<span>首页</span>
|
||||
<span>控制台概览</span>
|
||||
</a>
|
||||
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">发送</p>
|
||||
<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" href="/#ops-panel">
|
||||
<span class="nav-icon">☷</span>
|
||||
<span>任务管理</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#account-management">
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'login_workspace' %}active{% endif %}"
|
||||
href="/login-workspace"
|
||||
>
|
||||
<span class="nav-icon">◉</span>
|
||||
<span>账号管理</span>
|
||||
<span>登录工作区</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#account-management">
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">账号</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'accounts' %}active{% endif %}"
|
||||
href="/accounts"
|
||||
>
|
||||
<span class="nav-icon">◎</span>
|
||||
<span>目标管理</span>
|
||||
<span>账号与目标</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#config-panel">
|
||||
<span class="nav-icon">▣</span>
|
||||
<span>内容管理</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/ops/logs">
|
||||
<span class="nav-icon">☰</span>
|
||||
<span>发送记录</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/ops/send-console">
|
||||
<span class="nav-icon">⚠</span>
|
||||
<span>失败管理</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/ops/send-console">
|
||||
<span class="nav-icon">⌗</span>
|
||||
<span>数据统计</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/#settings-panel">
|
||||
</section>
|
||||
|
||||
<section class="nav-group">
|
||||
<p class="nav-group-title">系统</p>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'settings' %}active{% endif %}"
|
||||
href="/settings"
|
||||
>
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>系统设置</span>
|
||||
<span>运行与系统</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<a
|
||||
class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}"
|
||||
href="/ops/logs"
|
||||
>
|
||||
<span class="nav-icon">☰</span>
|
||||
<span>运行日志</span>
|
||||
</a>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer-head">
|
||||
<div class="sidebar-footer-mark">⚡</div>
|
||||
<div>
|
||||
<div class="sidebar-footer-title">运营团队</div>
|
||||
<div class="sidebar-footer-subtitle">批量管理与调度</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-footer-title">运行状态</div>
|
||||
<div class="sidebar-footer-subtitle">批量发送与运维调度</div>
|
||||
</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 class="page-subtitle">{% block page_subtitle %}多账号发送控制平台{% endblock %}</div>
|
||||
<div class="eyebrow">
|
||||
{% block page_eyebrow %}SparkFlow Admin{% endblock %}
|
||||
</div>
|
||||
<div class="page-title-row">
|
||||
<h1 class="page-title">
|
||||
{% block page_title %}自动续火花{% endblock %}
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
{% block page_subtitle %}多账号发送控制平台{% endblock %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<div class="page-header-actions">
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
@@ -761,12 +124,12 @@
|
||||
<div class="page-body">
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
{% endif %} {% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,117 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 | 抖音多账号续火花控制台</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(45, 107, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at bottom left, rgba(43, 162, 76, 0.1), transparent 20%),
|
||||
#f4f7fb;
|
||||
color: #162033;
|
||||
}
|
||||
.card {
|
||||
width: min(92vw, 420px);
|
||||
background: #fff;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 16px 38px rgba(17, 35, 68, 0.12);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
color: #66748f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #66748f;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #cfd9ea;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(180deg, #3677ff, #2d6bff);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flash {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.flash.success { background: #edf9f0; color: #13632b; }
|
||||
.flash.warning { background: #fff5e8; color: #915700; }
|
||||
.flash.error { background: #fff0f0; color: #8d2e2e; }
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 | 自动续火花控制台</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<body class="login-page">
|
||||
<main class="login-card">
|
||||
<div class="brand login-brand">
|
||||
<div class="brand-mark">⚡</div>
|
||||
<div>
|
||||
<div class="brand-title">自动续火花</div>
|
||||
<div class="brand-subtitle">多账号发送控制台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>控制台登录</h1>
|
||||
<p>用于管理抖音多账号、目标好友、自动续火花任务与运维配置。</p>
|
||||
<p class="muted">
|
||||
用于管理抖音多账号、目标好友、自动续火花任务与运维配置。
|
||||
</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap">
|
||||
<div class="flash {{ flash.level }} login-flash">
|
||||
{{ flash.message }}
|
||||
</div>
|
||||
{% endif %} {% if not bootstrapped %}
|
||||
<form method="post" action="/bootstrap" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin">
|
||||
<input type="text" name="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password">
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<label>
|
||||
<span>确认密码</span>
|
||||
<input type="password" name="confirm_password">
|
||||
<input type="password" name="confirm_password" />
|
||||
</label>
|
||||
<button type="submit">初始化管理员账号</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/login">
|
||||
<form method="post" action="/login" class="stack-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input type="text" name="username" value="admin">
|
||||
<input type="text" name="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password">
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<button type="submit">登录控制台</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
150
DouYinSparkFlow/webui/templates/login_workspace.html
Normal file
150
DouYinSparkFlow/webui/templates/login_workspace.html
Normal file
@@ -0,0 +1,150 @@
|
||||
{% 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>
|
||||
<a class="soft-button" href="/ops/send-console">发送控制台</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 %}
|
||||
@@ -1,16 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}logs{% endblock %}
|
||||
{% block title %}运行日志 | 抖音多账号续火花控制台{% endblock %}
|
||||
{% block page_title %}运行日志{% endblock %}
|
||||
{% block page_subtitle %}查看最近任务输出和服务诊断信息{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>详细日志</h2>
|
||||
<p class="muted compact">展示最近的任务输出,便于检查登录同步、发送过程和容器状态。</p>
|
||||
<p class="muted compact">
|
||||
展示最近的任务输出,便于检查登录同步、发送过程和容器状态。
|
||||
</p>
|
||||
</div>
|
||||
<a class="link-button" href="/">返回控制台</a>
|
||||
<a class="link-button" href="/settings">返回系统设置</a>
|
||||
</div>
|
||||
<pre class="code-block tall">{{ log_tail or "暂无日志" }}</pre>
|
||||
</section>
|
||||
|
||||
@@ -1,334 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block nav_key %}send_console{% endblock %}
|
||||
{% block title %}发送控制台{% endblock %}
|
||||
{% block title %}发送控制台 | 自动续火花{% endblock %}
|
||||
{% block page_title %}发送控制台{% endblock %}
|
||||
{% block page_subtitle %}今日发送总览{% endblock %}
|
||||
{% block page_subtitle %}今日发送明细、失败队列和针对性重试{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<a class="ghost-button" href="/">⌂ 返回首页</a>
|
||||
<a class="ghost-button" href="/">返回概览</a>
|
||||
<form method="post" action="/ops/run-unsent">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="soft-button" type="submit">补发未成功目标</button>
|
||||
</form>
|
||||
<form method="post" action="/ops/run-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">✈ 补发全部对象</button>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set send_console = ops.send_console %}
|
||||
<style>
|
||||
.console-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.console-overview {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.console-stats .stat-card {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.console-kpi-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.console-account-list {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
padding: 20px 20px 22px;
|
||||
}
|
||||
|
||||
.account-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.account-panel__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #7ea9ff, #5888ff);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 10px 18px rgba(47, 103, 255, 0.22);
|
||||
}
|
||||
|
||||
.account-panel__name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.account-panel__subline {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-panel__subline code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #7486a4;
|
||||
background: #f5f8fd;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.account-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-panel__collapse {
|
||||
color: #98a8c4;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
border: 1px solid rgba(228, 236, 246, 0.96);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, #ffffff, #fdfdff);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 12px;
|
||||
}
|
||||
|
||||
.data-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__title i {
|
||||
font-style: normal;
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.data-card__title.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.data-card__title.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.data-card__title.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.data-card__title.info {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.data-card__table {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.data-card__table td:first-child,
|
||||
.data-card__table th:first-child {
|
||||
min-width: 176px;
|
||||
}
|
||||
|
||||
.data-card__table td:last-child,
|
||||
.data-card__table th:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-card__footer {
|
||||
padding: 10px 18px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__footer-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-card__footer-button:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.data-card__footer-button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-card__extra {
|
||||
display: none;
|
||||
padding: 0 18px 16px;
|
||||
}
|
||||
|
||||
.data-card__extra.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-card__extra .table-shell {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.console-empty {
|
||||
padding: 46px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
border-radius: 18px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.console-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
padding: 6px 0 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.console-pagination {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-page {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.console-page.active {
|
||||
background: linear-gradient(180deg, #3d79ff, #2f67ff);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 10px 18px rgba(47, 103, 255, 0.18);
|
||||
}
|
||||
|
||||
.console-page.ghost {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.account-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="console-shell">
|
||||
<section class="console-overview">
|
||||
<div class="overview-time">当前时间:{{ send_console.now|replace('T', ' ') }}</div>
|
||||
<div class="overview-time muted">
|
||||
当前时间:{{ send_console.now|replace('T', ' ') }}
|
||||
</div>
|
||||
|
||||
<div class="stats-grid console-stats">
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">启用账号</span>
|
||||
<strong class="stat-value">{{ send_console.summary.enabled_accounts }}</strong>
|
||||
<span class="stat-help">实时统计 · 当前可发送账号</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.enabled_accounts }}</strong
|
||||
>
|
||||
<span class="stat-help">当前可发送账号</span>
|
||||
</div>
|
||||
<div class="stat-icon blue"><span class="console-kpi-icon">👥</span></div>
|
||||
<div class="stat-icon blue">A</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">今日成功目标</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
|
||||
<span class="stat-help positive">发送成功后立即写回历史</span>
|
||||
<span class="stat-label">今日成功</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_sent_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">写回发送历史</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_console.summary.today_sent_targets > 0 %}green{% else %}gray{% endif %}"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<div class="stat-icon green"><span class="console-kpi-icon">🎯</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">失败待补发</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
|
||||
<span class="stat-help negative">按失败分类进入补发与重试</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_failed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">按失败分类重试</span>
|
||||
</div>
|
||||
<div
|
||||
class="stat-icon {% if send_console.summary.today_failed_targets > 0 %}red{% else %}gray{% endif %}"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
<div class="stat-icon red"><span class="console-kpi-icon">⚠</span></div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">待发送 / 未处理</span>
|
||||
<strong class="stat-value">{{ send_console.summary.today_pending_targets + send_console.summary.today_unprocessed_targets }}</strong>
|
||||
<span class="stat-help">窗口内按计划发送,窗口外展示剩余目标</span>
|
||||
<span class="stat-label">待发送</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_pending_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">窗口内按计划发送</span>
|
||||
</div>
|
||||
<div class="stat-icon orange"><span class="console-kpi-icon">◔</span></div>
|
||||
<div class="stat-icon orange">◔</div>
|
||||
</article>
|
||||
|
||||
<article class="stat-card">
|
||||
<div class="stat-meta">
|
||||
<span class="stat-label">未处理</span>
|
||||
<strong class="stat-value"
|
||||
>{{ send_console.summary.today_unprocessed_targets }}</strong
|
||||
>
|
||||
<span class="stat-help">等待调度</span>
|
||||
</div>
|
||||
<div class="stat-icon gray">…</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -343,25 +105,33 @@
|
||||
<div>
|
||||
<div class="account-panel__name">{{ account.username }}</div>
|
||||
<div class="account-panel__subline">
|
||||
<span>unique_id:</span>
|
||||
<code>{{ account.unique_id }}</code>
|
||||
unique_id: {{ account.unique_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-panel__status">
|
||||
<span class="status-chip success">成功 {{ account.sent_targets|length }}</span>
|
||||
<span class="status-chip danger">失败 {{ account.failed_targets|length }}</span>
|
||||
<span class="status-chip warning">待发送 {{ account.pending_targets|length }}</span>
|
||||
<span class="status-chip info">未处理 {{ account.unprocessed_targets|length }}</span>
|
||||
<span class="account-panel__collapse">⌃</span>
|
||||
<div class="button-row">
|
||||
<span class="status-chip success"
|
||||
>成功 {{ account.sent_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip danger"
|
||||
>失败 {{ account.failed_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip warning"
|
||||
>待发送 {{ account.pending_targets|length }}</span
|
||||
>
|
||||
<span class="status-chip info"
|
||||
>未处理 {{ account.unprocessed_targets|length }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-grid">
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title success"><i>●</i><span>A. 今日已成功</span></div>
|
||||
<div class="data-card__title success">
|
||||
<i>●</i><span>今日已成功</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -380,22 +150,27 @@
|
||||
<td>{{ item.sentAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">今日暂无成功目标</td></tr>
|
||||
<tr>
|
||||
<td colspan="3">今日暂无成功目标</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.sent_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="sent-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.sent_targets|length }})"
|
||||
{% if account.sent_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.sent_targets|length }})</button>
|
||||
>
|
||||
查看全部({{ account.sent_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="data-card__extra" id="sent-{{ loop.index0 }}">
|
||||
<div class="table-shell scrollable">
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -420,9 +195,11 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title danger"><i>▲</i><span>B. 失败待补发</span></div>
|
||||
<div class="data-card__title danger">
|
||||
<i>▲</i><span>失败待补发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table scrollable">
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -441,30 +218,46 @@
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="soft-button retry-button">重试此目标</button>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/retry-target"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token }}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="target"
|
||||
value="{{ item.target }}"
|
||||
/>
|
||||
<button type="submit" class="soft-button">重试</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">暂无失败待补发目标</td></tr>
|
||||
<tr>
|
||||
<td colspan="5">暂无失败待补发目标</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.failed_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="failed-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.failed_targets|length }})"
|
||||
{% if account.failed_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.failed_targets|length }})</button>
|
||||
>
|
||||
查看全部({{ account.failed_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="data-card__extra" id="failed-{{ loop.index0 }}">
|
||||
<div class="table-shell scrollable">
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -483,10 +276,21 @@
|
||||
<td>{{ item.reason or "-" }}</td>
|
||||
<td>{{ item.attemptCount or 0 }}</td>
|
||||
<td>
|
||||
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="{{ item.target }}">
|
||||
<button type="submit" class="soft-button retry-button">重试此目标</button>
|
||||
<form
|
||||
method="post"
|
||||
action="/accounts/{{ account.unique_id }}/retry-target"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value="{{ csrf_token }}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="target"
|
||||
value="{{ item.target }}"
|
||||
/>
|
||||
<button type="submit" class="soft-button">重试</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -499,7 +303,9 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title warning"><i>◔</i><span>C. 今日待发送</span></div>
|
||||
<div class="data-card__title warning">
|
||||
<i>◔</i><span>今日待发送</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -516,22 +322,27 @@
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">今日暂无待发送目标</td></tr>
|
||||
<tr>
|
||||
<td colspan="2">今日暂无待发送目标</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.pending_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="pending-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.pending_targets|length }})"
|
||||
{% if account.pending_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.pending_targets|length }})</button>
|
||||
>
|
||||
查看全部({{ account.pending_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="data-card__extra" id="pending-{{ loop.index0 }}">
|
||||
<div class="table-shell scrollable">
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -554,7 +365,9 @@
|
||||
|
||||
<section class="data-card">
|
||||
<div class="data-card__head">
|
||||
<div class="data-card__title info"><i>◌</i><span>D. 今日未处理</span></div>
|
||||
<div class="data-card__title info">
|
||||
<i>◌</i><span>今日未处理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-shell data-card__table">
|
||||
<table>
|
||||
@@ -571,22 +384,27 @@
|
||||
<td>{{ item.scheduledAt or "-" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">今日暂无未处理目标</td></tr>
|
||||
<tr>
|
||||
<td colspan="2">今日暂无未处理目标</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="data-card__footer">
|
||||
{% if account.unprocessed_targets|length > 5 %}
|
||||
<button
|
||||
type="button"
|
||||
class="data-card__footer-button"
|
||||
data-expand-target="unprocessed-{{ loop.index0 }}"
|
||||
data-total-label="查看全部({{ account.unprocessed_targets|length }})"
|
||||
{% if account.unprocessed_targets|length <= 5 %}hidden{% endif %}
|
||||
>查看全部({{ account.unprocessed_targets|length }})</button>
|
||||
>
|
||||
查看全部({{ account.unprocessed_targets|length }})
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="data-card__extra" id="unprocessed-{{ loop.index0 }}">
|
||||
<div class="table-shell scrollable">
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -611,32 +429,11 @@
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="console-footer">
|
||||
<span>共 {{ send_console.accounts|length }} 个账号</span>
|
||||
<div class="console-pagination">
|
||||
<span class="console-page ghost">‹</span>
|
||||
<span class="console-page active">1</span>
|
||||
<span class="console-page">2</span>
|
||||
<span class="console-page">3</span>
|
||||
<span class="console-page ghost">›</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-footer">共 {{ send_console.accounts|length }} 个账号</div>
|
||||
{% else %}
|
||||
<div class="console-empty">当前没有启用账号,暂无可展示的发送控制台数据。</div>
|
||||
<div class="console-empty empty-state">
|
||||
当前没有启用账号,暂无可展示的发送控制台数据。
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.expandTarget;
|
||||
if (!targetId) return;
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle("is-open");
|
||||
button.textContent = isOpen ? "收起" : (button.getAttribute("data-total-label") || "查看全部");
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
335
DouYinSparkFlow/webui/templates/settings.html
Normal file
335
DouYinSparkFlow/webui/templates/settings.html
Normal file
@@ -0,0 +1,335 @@
|
||||
{% 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>
|
||||
<a class="soft-button" href="/ops/send-console">发送控制台</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