feat: redesign web admin layout

This commit is contained in:
haeyupi
2026-05-30 02:00:07 +08:00
parent c486d6736c
commit 2b84433d9e
13 changed files with 2781 additions and 2441 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.env
state/
logs/
output/
proxy/config.yaml
*.bak-*
*.tar.gz

View File

@@ -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.

View File

@@ -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):

File diff suppressed because it is too large Load Diff

View 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
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();
});
})();

View 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 %}

View File

@@ -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

View File

@@ -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>

View 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 %}

View File

@@ -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>

View File

@@ -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 %}

View 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 %}