Files
douyin-sparkflow/DouYinSparkFlow/webui/static/app.js
T

747 lines
27 KiB
JavaScript

(() => {
const root = document.documentElement;
const storageKey = "sparkflow-theme";
const storedTheme = () => {
try {
return localStorage.getItem(storageKey);
} catch {
return null;
}
};
const applyTheme = (theme) => {
const value = theme === "light" ? "light" : "dark";
root.dataset.theme = value;
root.style.colorScheme = value;
try {
localStorage.setItem(storageKey, value);
} catch {
// The active page can still switch themes when storage is unavailable.
}
};
applyTheme(storedTheme() || "dark");
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
applyTheme(root.dataset.theme === "light" ? "dark" : "light");
});
});
})();
(() => {
const body = document.body;
document.querySelectorAll("[data-nav-toggle]").forEach((button) => {
button.addEventListener("click", () => body.classList.add("nav-open"));
});
document.querySelectorAll("[data-nav-close]").forEach((button) => {
button.addEventListener("click", () => body.classList.remove("nav-open"));
});
document.querySelectorAll(".nav-item").forEach((link) => {
link.addEventListener("click", () => body.classList.remove("nav-open"));
});
})();
(() => {
const dialog = document.getElementById("confirm-dialog");
if (!dialog) return;
const title = document.getElementById("confirm-title");
const message = document.getElementById("confirm-message");
const accept = dialog.querySelector("[data-confirm-accept]");
const cancel = dialog.querySelector("[data-confirm-cancel]");
let pendingForm = null;
let pendingLink = "";
let pendingButton = null;
const openDialog = (node) => {
const source = node.closest("[data-confirm]") || node;
title.textContent = source.dataset.confirmTitle || "确认操作";
message.textContent =
source.dataset.confirm ||
"该操作会立即影响续火花任务,请确认是否继续。";
accept.textContent = source.dataset.confirmAccept || "确认执行";
accept.className =
source.dataset.confirmTone === "primary"
? "button button-primary"
: "button button-danger";
dialog.showModal();
};
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
pendingForm = form;
pendingLink = "";
pendingButton = null;
openDialog(form);
});
});
document.querySelectorAll("a[data-confirm]").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
pendingForm = null;
pendingLink = link.href;
pendingButton = null;
openDialog(link);
});
});
document.querySelectorAll("button[data-confirm]").forEach((button) => {
button.addEventListener(
"click",
(event) => {
if (button.dataset.confirmApproved === "1") {
delete button.dataset.confirmApproved;
return;
}
event.preventDefault();
event.stopImmediatePropagation();
pendingForm = null;
pendingLink = "";
pendingButton = button;
openDialog(button);
},
true,
);
});
cancel.addEventListener("click", () => {
pendingForm = null;
pendingLink = "";
pendingButton = null;
dialog.close();
});
accept.addEventListener("click", () => {
const form = pendingForm;
const href = pendingLink;
const button = pendingButton;
pendingForm = null;
pendingLink = "";
pendingButton = null;
dialog.close();
if (form) {
HTMLFormElement.prototype.submit.call(form);
} else if (href) {
window.location.assign(href);
} else if (button) {
button.dataset.confirmApproved = "1";
button.click();
}
});
dialog.addEventListener("cancel", () => {
pendingForm = null;
pendingLink = "";
pendingButton = null;
});
})();
(() => {
document.querySelectorAll("[data-segment-group]").forEach((group) => {
const buttons = [...group.querySelectorAll("[data-segment-target]")];
const owner = group.closest("[data-segment-owner]") || document;
const panels = [...owner.querySelectorAll("[data-segment-panel]")];
const activate = (name) => {
buttons.forEach((button) => {
const active = button.dataset.segmentTarget === name;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.segmentPanel !== name;
});
};
buttons.forEach((button) => {
button.addEventListener("click", () =>
activate(button.dataset.segmentTarget),
);
});
const initial =
buttons.find((button) => button.classList.contains("active")) ||
buttons[0];
if (initial) activate(initial.dataset.segmentTarget);
});
})();
(() => {
const overviewRoots = document.querySelectorAll("[data-overview-root]");
if (!overviewRoots.length) return;
let previousRunning = null;
let timer = null;
const formatTime = (raw) => {
if (!raw) return "-";
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(parsed);
};
const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((node) => {
node.textContent = String(value ?? "");
});
};
const updateTaskBanner = (task) => {
document.querySelectorAll("[data-task-banner]").forEach((banner) => {
banner.className = "status-banner";
if (task.running) {
banner.classList.add("warning");
banner.querySelector("[data-task-text]").textContent =
`发送任务运行中,已运行约 ${task.ageSeconds || 0} 秒`;
} else if (task.stale) {
banner.classList.add("info");
banner.querySelector("[data-task-text]").textContent =
"检测到过期任务锁,下次启动任务时会自动清理";
} else {
banner.classList.add("success");
banner.querySelector("[data-task-text]").textContent =
"当前没有发送任务运行";
}
});
};
const updateAccounts = (accounts) => {
accounts.forEach((account) => {
const selector = `[data-account-overview="${CSS.escape(account.uniqueId)}"]`;
document.querySelectorAll(selector).forEach((row) => {
row.dataset.accountState = account.state;
row.querySelectorAll("[data-account-confirmed]").forEach((node) => {
node.textContent = account.confirmed;
});
row.querySelectorAll("[data-account-attention]").forEach((node) => {
node.textContent = account.attention;
});
row.querySelectorAll("[data-account-pending]").forEach((node) => {
node.textContent = account.pending;
});
row.querySelectorAll("[data-account-progress]").forEach((node) => {
const pct = account.total
? Math.round((account.confirmed / account.total) * 100)
: 0;
node.style.width = `${pct}%`;
});
row.querySelectorAll("[data-account-progress-text]").forEach((node) => {
node.textContent = `${account.confirmed}/${account.total}`;
});
});
});
};
const updateActions = (summary, running) => {
const counts = {
attention: summary.attention,
pending: summary.pending + summary.unprocessed,
total: summary.total,
};
document.querySelectorAll("[data-action-count-source]").forEach((button) => {
const count = counts[button.dataset.actionCountSource] || 0;
button.disabled = running || count <= 0;
const countNode = button.querySelector("[data-action-count]");
if (countNode) countNode.textContent = count;
});
document.querySelectorAll("[data-disable-while-running]").forEach((button) => {
if (!button.dataset.actionCountSource) {
button.disabled = running;
}
});
};
const render = (data) => {
const summary = data.summary || {};
const task = data.task || {};
updateTaskBanner(task);
setText("[data-overview-value='total']", summary.total || 0);
setText("[data-overview-value='confirmed']", summary.confirmed || 0);
setText("[data-overview-value='attention']", summary.attention || 0);
setText(
"[data-overview-value='pending']",
(summary.pending || 0) + (summary.unprocessed || 0),
);
setText("[data-overview-value='remaining']", summary.remaining || 0);
setText(
"[data-overview-value='progress']",
`${summary.confirmed || 0}/${summary.total || 0}`,
);
setText(
"[data-overview-value='progressPercent']",
summary.total
? `${Math.round((summary.confirmed / summary.total) * 100)}%`
: "0%",
);
setText(
"[data-overview-value='lastConfirmedAt']",
formatTime(summary.lastConfirmedAt),
);
setText(
"[data-overview-value='nextTriggerAt']",
formatTime(data.schedule?.nextTriggerAt),
);
setText(
"[data-overview-value='scheduleLabel']",
data.schedule?.label || "-",
);
updateAccounts(data.accounts || []);
updateActions(summary, Boolean(task.running));
if (previousRunning === true && !task.running) {
document
.querySelectorAll("[data-refresh-notice]")
.forEach((node) => node.classList.add("visible"));
}
previousRunning = Boolean(task.running);
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
node.textContent = "实时";
node.classList.remove("poll-stale");
});
};
const refresh = async () => {
if (document.visibilityState !== "visible") return;
try {
const response = await fetch("/api/ops/overview", {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
} catch {
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
node.textContent = "更新延迟";
node.classList.add("poll-stale");
});
}
};
document.querySelectorAll("[data-refresh-page]").forEach((button) => {
button.addEventListener("click", () => window.location.reload());
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refresh();
});
refresh();
timer = window.setInterval(refresh, 10000);
window.addEventListener("pagehide", () => window.clearInterval(timer));
})();
(() => {
const root = document.getElementById("login-desktop-controls");
if (!root) return;
const section = document.getElementById("interactive-login-section");
const csrfToken = root.dataset.csrfToken || "";
const displayMode = root.dataset.displayMode || "novnc";
const configuredPublicUrl = root.dataset.publicUrl || "";
const publicUrl = (() => {
if (!configuredPublicUrl) return "";
try {
return new URL(configuredPublicUrl, window.location.href).href;
} catch {
return configuredPublicUrl;
}
})();
const runtimeState = document.getElementById("login-desktop-runtime-state");
const statusText = document.getElementById("login-desktop-status-text");
const frame = document.querySelector("[data-login-frame]");
const frameWrap = document.querySelector(".desktop-frame-wrap");
const nativePanel = document.querySelector("[data-native-login]");
const copyLoginUrlButton = document.querySelector("[data-copy-login-url]");
const qrImage = document.querySelector("[data-login-qr]");
const qrStatus = document.querySelector("[data-login-qr-status]");
let timer = null;
let heartbeatTimer = null;
let countdownTimer = null;
let qrRefreshTimer = null;
let workspace = { state: "closed", active: false, position: 0, ticket: "" };
if (displayMode === "native" && copyLoginUrlButton) copyLoginUrlButton.hidden = true;
if (displayMode === "native" && frameWrap) frameWrap.hidden = true;
const setStatus = (text, tone = "") => {
if (statusText) statusText.textContent = text;
if (runtimeState) {
runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
runtimeState.textContent = tone === "success" ? "使用中" : tone === "danger" ? "异常" : tone === "warning" ? "排队中" : "已关闭";
}
};
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 loadFrame = (force = false) => {
if (displayMode === "native") return;
if (frame && (force || frame.dataset.loaded !== "1") && frame.dataset.src) {
frame.src = frame.dataset.src;
frame.dataset.loaded = "1";
}
};
const closeFrame = () => {
if (nativePanel) nativePanel.hidden = true;
if (frameWrap) {
frameWrap.classList.remove("native-login-mode");
if (displayMode === "native") frameWrap.hidden = true;
}
if (qrImage) {
qrImage.hidden = true;
const previous = qrImage.dataset.objectUrl || "";
if (previous) URL.revokeObjectURL(previous);
delete qrImage.dataset.objectUrl;
qrImage.removeAttribute("src");
}
if (!frame) return;
frame.removeAttribute("src");
frame.dataset.loaded = "0";
};
const renderWorkspace = (next) => {
workspace = next || { state: "closed", active: false, position: 0, ticket: "" };
if (workspace.state === "queued") {
setStatus(`登录工作区排队中,前面还有 ${Math.max(0, Number(workspace.position || 1) - 1)} 人。`, "warning");
if (qrStatus) qrStatus.textContent = "排队成功,轮到你后会自动打开登录二维码。";
return;
}
if (workspace.state === "resetting") {
setStatus("正在清理上一位用户的登录环境,请稍候。", "warning");
return;
}
if (workspace.state === "active" && workspace.active) {
const remaining = Math.max(0, Number(workspace.remaining_seconds || 0));
const tone = remaining > 0 && remaining <= 60 ? "warning" : "success";
if (displayMode === "native") {
if (nativePanel) nativePanel.hidden = false;
if (frameWrap) {
frameWrap.hidden = false;
frameWrap.classList.add("native-login-mode");
}
setStatus(`Windows 本地登录浏览器已打开,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, tone);
} else {
setStatus(`登录工作区已分配给当前会话,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, tone);
}
return;
}
setStatus("登录工作区当前关闭。请从账号卡片点击“重新登录”。");
};
const refreshLoginQr = async (delay = 0, retries = 40) => {
if (!qrImage || workspace.state !== "active" || !workspace.active) return;
window.clearTimeout(qrRefreshTimer);
qrRefreshTimer = window.setTimeout(async () => {
if (qrStatus) qrStatus.textContent = "正在读取登录二维码...";
try {
const response = await fetch(`/login-desktop/qr?t=${Date.now()}`, { credentials: "same-origin", cache: "no-store" });
if (response.status === 202) {
if (retries > 1 && workspace.state === "active") {
if (qrStatus) qrStatus.textContent = "浏览器正在生成二维码,继续等待...";
refreshLoginQr(1400, retries - 1);
} else if (qrStatus) {
qrStatus.textContent = "登录页面在规定时间内没有生成二维码,请稍后重试。";
}
return;
}
if (response.status === 409) {
if (qrStatus) qrStatus.textContent = "二维码已过期,请点击刷新二维码。";
return;
}
if (response.status === 502) {
const data = await response.json().catch(() => ({}));
if (qrStatus) qrStatus.textContent = data.error || "无法访问抖音创作者中心,请检查服务器网络出口。";
return;
}
if (!response.ok) throw new Error(String(response.status));
const blob = await response.blob();
const previous = qrImage.dataset.objectUrl || "";
const objectUrl = URL.createObjectURL(blob);
qrImage.src = objectUrl;
qrImage.dataset.objectUrl = objectUrl;
qrImage.hidden = false;
if (previous) URL.revokeObjectURL(previous);
if (qrStatus) qrStatus.textContent = "二维码已加载。如果过期,点击刷新。";
} catch {
if (retries > 1 && workspace.state === "active") {
if (qrStatus) qrStatus.textContent = "登录页正在加载,继续等待二维码...";
refreshLoginQr(1400, retries - 1);
} else if (qrStatus) {
qrStatus.textContent = "二维码还未准备好,请确认自己已经获得登录工作区。";
}
}
}, delay);
};
const pollStatus = async () => {
if (document.visibilityState !== "visible") return;
try {
const statusUrl = workspace.state === "active" ? "/login-desktop/status" : "/login-desktop/workspace-status";
const response = await fetch(statusUrl, { credentials: "same-origin", cache: "no-store" });
const data = await response.json();
if (!response.ok || data.ok === false) {
setStatus(data.error || "登录工作区不可用,请检查 login-desktop 服务。", "danger");
return;
}
renderWorkspace(data.workspace);
if (workspace.state === "active" && workspace.active) {
loadFrame();
if (data.logged_in) setStatus(`当前浏览器已登录:${data.username},请保存登录态。`, "success");
} else {
closeFrame();
}
} catch (error) {
setStatus(`状态检查失败:${error.message}`, "danger");
}
};
const heartbeat = async () => {
if (workspace.state !== "active" || !workspace.active || !workspace.ticket) return;
try {
const data = await postForm("/login-desktop/heartbeat", { ticket: workspace.ticket });
renderWorkspace(data.workspace);
} catch (error) {
workspace = { state: "closed", active: false, position: 0, ticket: "" };
closeFrame();
setStatus(`登录工作区已释放:${error.message}`, "danger");
}
};
document.querySelectorAll(".login-desktop-open").forEach((button) => {
button.addEventListener("click", async () => {
if (section) section.open = true;
try {
const reloginUniqueId = button.dataset.reloginUniqueId || "";
const mode = button.dataset.loginMode || (reloginUniqueId ? "relogin" : "add");
const data = await postForm("/login-desktop/open", {
mode,
...(reloginUniqueId ? { relogin_unique_id: reloginUniqueId } : {}),
});
renderWorkspace(data.workspace);
if (data.state === "queued") return;
loadFrame(true);
refreshLoginQr(500);
if (frame) frame.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) {
setStatus(`申请登录工作区失败:${error.message}`, "danger");
}
});
});
document.querySelectorAll("[data-focus-native-browser]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
await postForm("/login-desktop/focus", { ticket: workspace.ticket });
setStatus("已请求显示本机登录浏览器,请在桌面窗口中继续操作。", "success");
} catch (error) {
setStatus(`显示登录浏览器失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
});
});
document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
await postForm("/login-desktop/qr/refresh", { ticket: workspace.ticket });
refreshLoginQr(500);
} catch (error) {
if (qrStatus) qrStatus.textContent = `刷新二维码失败:${error.message}`;
} finally {
button.disabled = false;
}
});
});
document.querySelectorAll(".login-desktop-save").forEach((button) => {
button.addEventListener("click", async () => {
try {
const data = await postForm("/login-desktop/save", { relogin_unique_id: button.dataset.reloginUniqueId || "" });
renderWorkspace(data.workspace);
setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
closeFrame();
window.setTimeout(() => window.location.reload(), 800);
} catch (error) {
setStatus(`保存登录账号失败:${error.message}`, "danger");
}
});
});
document.querySelectorAll(".login-desktop-close").forEach((button) => {
button.addEventListener("click", async () => {
try {
const data = await postForm("/login-desktop/close");
renderWorkspace(data.workspace);
closeFrame();
if (qrImage) qrImage.hidden = true;
} catch (error) {
setStatus(`关闭登录界面失败:${error.message}`, "danger");
}
});
});
document.querySelectorAll(".login-desktop-reset").forEach((button) => {
button.addEventListener("click", async () => {
try {
const data = await postForm("/login-desktop/reset");
renderWorkspace(data.workspace);
closeFrame();
if (qrImage) qrImage.hidden = true;
} catch (error) {
setStatus(`结束登录流程失败:${error.message}`, "danger");
}
});
});
document.querySelectorAll("[data-copy-login-url]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(publicUrl);
setStatus("登录工作区地址已复制。", "success");
} catch (error) {
setStatus(`复制失败:${error.message}`, "danger");
}
});
});
if (section) section.addEventListener("toggle", () => { if (section.open) pollStatus(); });
pollStatus();
timer = window.setInterval(pollStatus, 5000);
heartbeatTimer = window.setInterval(heartbeat, 5000);
countdownTimer = window.setInterval(() => {
if (workspace.state !== "active" || !workspace.active) return;
workspace.remaining_seconds = Math.max(0, Number(workspace.remaining_seconds || 0) - 1);
const remaining = workspace.remaining_seconds;
setStatus(`登录工作区已分配给当前会话,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, remaining <= 60 ? "warning" : "success");
}, 1000);
window.addEventListener("pagehide", () => {
window.clearInterval(timer);
window.clearInterval(heartbeatTimer);
window.clearInterval(countdownTimer);
});
})();
(() => {
const parseJson = (id) => {
const node = document.getElementById(id);
if (!node) return [];
try {
return JSON.parse(node.textContent || "[]");
} catch {
return [];
}
};
document.querySelectorAll(".friend-picker").forEach((picker) => {
const accountId = picker.dataset.accountId;
const refreshUrl = picker.dataset.refreshUrl;
const csrfToken = picker.dataset.csrfToken;
const form = picker.closest("form");
const textarea = form?.querySelector(".targets-textarea");
const search = picker.querySelector(".friend-search-input");
const refreshButton = picker.querySelector(".friend-refresh-button");
const list = picker.querySelector(".friend-picker-list");
const summary = picker.querySelector(".friend-picker-summary");
const status = picker.querySelector(".friend-picker-status");
let friends = parseJson(`friends-cache-${accountId}`);
let selected = new Set(parseJson(`selected-targets-${accountId}`));
const parseTargets = (value) =>
[...new Set(
String(value || "")
.replaceAll(",", "\n")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean),
)];
const combined = () => [...new Set([...selected, ...friends])];
const syncTextarea = () => {
if (textarea) textarea.value = [...selected].join("\n");
};
const render = () => {
const query = String(search?.value || "").trim().toLowerCase();
const names = combined().filter((name) =>
name.toLowerCase().includes(query),
);
if (summary) summary.textContent = `已选 ${selected.size} 人`;
list.innerHTML = "";
if (!names.length) {
const empty = document.createElement("div");
empty.className = "friend-picker-empty";
empty.textContent = combined().length
? "没有匹配的好友。"
: "点击“刷新好友列表”后再选择目标。";
list.appendChild(empty);
return;
}
names.forEach((name) => {
const label = document.createElement("label");
label.className = `friend-option${selected.has(name) ? " selected" : ""}`;
const text = document.createElement("span");
text.textContent = name;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selected.has(name);
checkbox.addEventListener("change", () => {
if (checkbox.checked) selected.add(name);
else selected.delete(name);
syncTextarea();
render();
});
label.append(text, checkbox);
list.appendChild(label);
});
};
textarea?.addEventListener("input", () => {
selected = new Set(parseTargets(textarea.value));
render();
});
search?.addEventListener("input", render);
refreshButton?.addEventListener("click", async () => {
refreshButton.disabled = true;
if (status) status.textContent = "正在读取好友列表...";
try {
const formData = new FormData();
formData.set("csrf_token", csrfToken);
const response = await fetch(refreshUrl, {
method: "POST",
body: formData,
credentials: "same-origin",
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "刷新失败");
friends = data.friends || [];
if (status) status.textContent = data.message || "好友列表已刷新";
render();
} catch (error) {
if (status) status.textContent = `刷新失败:${error.message}`;
} finally {
refreshButton.disabled = false;
}
});
render();
});
})();
window.addEventListener("DOMContentLoaded", () => {
if (window.lucide) {
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
});