mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 16:07:22 +08:00
feat: publish refreshed SparkFlow console and send safety
This commit is contained in:
+1267
-880
File diff suppressed because it is too large
Load Diff
+435
-318
@@ -1,138 +1,360 @@
|
||||
(() => {
|
||||
const storageKey = "sparkflow-theme";
|
||||
const root = document.documentElement;
|
||||
const storageKey = "sparkflow-theme";
|
||||
|
||||
const readStoredTheme = () => {
|
||||
const storedTheme = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
return localStorage.getItem(storageKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStoredTheme = (theme) => {
|
||||
const applyTheme = (theme) => {
|
||||
const value = theme === "light" ? "light" : "dark";
|
||||
root.dataset.theme = value;
|
||||
root.style.colorScheme = value;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
localStorage.setItem(storageKey, value);
|
||||
} catch {
|
||||
// Ignore storage restrictions; the current page can still switch theme.
|
||||
// The active page can still switch themes when storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const preferredTheme = () => {
|
||||
const stored = readStoredTheme();
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
};
|
||||
|
||||
const applyTheme = (theme) => {
|
||||
const normalized = theme === "dark" ? "dark" : "light";
|
||||
root.dataset.theme = normalized;
|
||||
root.style.colorScheme = normalized;
|
||||
const nextLabel = normalized === "dark" ? "切换白天模式" : "切换黑夜模式";
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
button.setAttribute("aria-label", nextLabel);
|
||||
button.setAttribute("title", nextLabel);
|
||||
button.setAttribute(
|
||||
"aria-pressed",
|
||||
normalized === "dark" ? "true" : "false",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
applyTheme(preferredTheme());
|
||||
|
||||
applyTheme(storedTheme() || "dark");
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const next = root.dataset.theme === "dark" ? "light" : "dark";
|
||||
writeStoredTheme(next);
|
||||
applyTheme(next);
|
||||
applyTheme(root.dataset.theme === "light" ? "dark" : "light");
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const navToggle = document.querySelector("[data-nav-toggle]");
|
||||
const navClose = document.querySelector("[data-nav-close]");
|
||||
|
||||
navToggle?.addEventListener("click", () => body.classList.toggle("nav-open"));
|
||||
navClose?.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
document.querySelectorAll(".nav-item").forEach((item) => {
|
||||
item.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
document.querySelectorAll("[data-nav-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => body.classList.add("nav-open"));
|
||||
});
|
||||
document.querySelectorAll("[data-nav-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
});
|
||||
document.querySelectorAll(".nav-item").forEach((link) => {
|
||||
link.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll("[data-confirm]").forEach((node) => {
|
||||
const message = node.getAttribute("data-confirm") || "确认执行此操作?";
|
||||
const handler = (event) => {
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
if (node.tagName === "FORM") {
|
||||
node.addEventListener("submit", handler);
|
||||
} else {
|
||||
node.addEventListener("click", handler);
|
||||
}
|
||||
});
|
||||
})();
|
||||
const dialog = document.getElementById("confirm-dialog");
|
||||
if (!dialog) return;
|
||||
const title = document.getElementById("confirm-title");
|
||||
const message = document.getElementById("confirm-message");
|
||||
const accept = dialog.querySelector("[data-confirm-accept]");
|
||||
const cancel = dialog.querySelector("[data-confirm-cancel]");
|
||||
let pendingForm = null;
|
||||
let pendingLink = "";
|
||||
let pendingButton = null;
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.expandTarget;
|
||||
if (!targetId) return;
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle("is-open");
|
||||
button.textContent = isOpen
|
||||
? "收起"
|
||||
: button.dataset.totalLabel || "查看全部";
|
||||
const openDialog = (node) => {
|
||||
const source = node.closest("[data-confirm]") || node;
|
||||
title.textContent = source.dataset.confirmTitle || "确认操作";
|
||||
message.textContent =
|
||||
source.dataset.confirm ||
|
||||
"该操作会立即影响续火花任务,请确认是否继续。";
|
||||
accept.textContent = source.dataset.confirmAccept || "确认执行";
|
||||
accept.className =
|
||||
source.dataset.confirmTone === "primary"
|
||||
? "button button-primary"
|
||||
: "button button-danger";
|
||||
dialog.showModal();
|
||||
};
|
||||
|
||||
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
pendingForm = form;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
openDialog(form);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("a[data-confirm]").forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
pendingForm = null;
|
||||
pendingLink = link.href;
|
||||
pendingButton = null;
|
||||
openDialog(link);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("button[data-confirm]").forEach((button) => {
|
||||
button.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (button.dataset.confirmApproved === "1") {
|
||||
delete button.dataset.confirmApproved;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = button;
|
||||
openDialog(button);
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
cancel.addEventListener("click", () => {
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
accept.addEventListener("click", () => {
|
||||
const form = pendingForm;
|
||||
const href = pendingLink;
|
||||
const button = pendingButton;
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
dialog.close();
|
||||
if (form) {
|
||||
HTMLFormElement.prototype.submit.call(form);
|
||||
} else if (href) {
|
||||
window.location.assign(href);
|
||||
} else if (button) {
|
||||
button.dataset.confirmApproved = "1";
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", () => {
|
||||
pendingForm = null;
|
||||
pendingLink = "";
|
||||
pendingButton = null;
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll("[data-segment-group]").forEach((group) => {
|
||||
const buttons = [...group.querySelectorAll("[data-segment-target]")];
|
||||
const owner = group.closest("[data-segment-owner]") || document;
|
||||
const panels = [...owner.querySelectorAll("[data-segment-panel]")];
|
||||
const activate = (name) => {
|
||||
buttons.forEach((button) => {
|
||||
const active = button.dataset.segmentTarget === name;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", active ? "true" : "false");
|
||||
});
|
||||
panels.forEach((panel) => {
|
||||
panel.hidden = panel.dataset.segmentPanel !== name;
|
||||
});
|
||||
};
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", () =>
|
||||
activate(button.dataset.segmentTarget),
|
||||
);
|
||||
});
|
||||
const initial =
|
||||
buttons.find((button) => button.classList.contains("active")) ||
|
||||
buttons[0];
|
||||
if (initial) activate(initial.dataset.segmentTarget);
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const overviewRoots = document.querySelectorAll("[data-overview-root]");
|
||||
if (!overviewRoots.length) return;
|
||||
let previousRunning = null;
|
||||
let timer = null;
|
||||
|
||||
const formatTime = (raw) => {
|
||||
if (!raw) return "-";
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) return raw;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
const setText = (selector, value) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
node.textContent = String(value ?? "");
|
||||
});
|
||||
};
|
||||
|
||||
const updateTaskBanner = (task) => {
|
||||
document.querySelectorAll("[data-task-banner]").forEach((banner) => {
|
||||
banner.className = "status-banner";
|
||||
if (task.running) {
|
||||
banner.classList.add("warning");
|
||||
banner.querySelector("[data-task-text]").textContent =
|
||||
`发送任务运行中,已运行约 ${task.ageSeconds || 0} 秒`;
|
||||
} else if (task.stale) {
|
||||
banner.classList.add("info");
|
||||
banner.querySelector("[data-task-text]").textContent =
|
||||
"检测到过期任务锁,下次启动任务时会自动清理";
|
||||
} else {
|
||||
banner.classList.add("success");
|
||||
banner.querySelector("[data-task-text]").textContent =
|
||||
"当前没有发送任务运行";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateAccounts = (accounts) => {
|
||||
accounts.forEach((account) => {
|
||||
const selector = `[data-account-overview="${CSS.escape(account.uniqueId)}"]`;
|
||||
document.querySelectorAll(selector).forEach((row) => {
|
||||
row.dataset.accountState = account.state;
|
||||
row.querySelectorAll("[data-account-confirmed]").forEach((node) => {
|
||||
node.textContent = account.confirmed;
|
||||
});
|
||||
row.querySelectorAll("[data-account-attention]").forEach((node) => {
|
||||
node.textContent = account.attention;
|
||||
});
|
||||
row.querySelectorAll("[data-account-pending]").forEach((node) => {
|
||||
node.textContent = account.pending;
|
||||
});
|
||||
row.querySelectorAll("[data-account-progress]").forEach((node) => {
|
||||
const pct = account.total
|
||||
? Math.round((account.confirmed / account.total) * 100)
|
||||
: 0;
|
||||
node.style.width = `${pct}%`;
|
||||
});
|
||||
row.querySelectorAll("[data-account-progress-text]").forEach((node) => {
|
||||
node.textContent = `${account.confirmed}/${account.total}`;
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateActions = (summary, running) => {
|
||||
const counts = {
|
||||
attention: summary.attention,
|
||||
pending: summary.pending + summary.unprocessed,
|
||||
total: summary.total,
|
||||
};
|
||||
document.querySelectorAll("[data-action-count-source]").forEach((button) => {
|
||||
const count = counts[button.dataset.actionCountSource] || 0;
|
||||
button.disabled = running || count <= 0;
|
||||
const countNode = button.querySelector("[data-action-count]");
|
||||
if (countNode) countNode.textContent = count;
|
||||
});
|
||||
document.querySelectorAll("[data-disable-while-running]").forEach((button) => {
|
||||
if (!button.dataset.actionCountSource) {
|
||||
button.disabled = running;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const render = (data) => {
|
||||
const summary = data.summary || {};
|
||||
const task = data.task || {};
|
||||
updateTaskBanner(task);
|
||||
setText("[data-overview-value='total']", summary.total || 0);
|
||||
setText("[data-overview-value='confirmed']", summary.confirmed || 0);
|
||||
setText("[data-overview-value='attention']", summary.attention || 0);
|
||||
setText(
|
||||
"[data-overview-value='pending']",
|
||||
(summary.pending || 0) + (summary.unprocessed || 0),
|
||||
);
|
||||
setText("[data-overview-value='remaining']", summary.remaining || 0);
|
||||
setText(
|
||||
"[data-overview-value='progress']",
|
||||
`${summary.confirmed || 0}/${summary.total || 0}`,
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='progressPercent']",
|
||||
summary.total
|
||||
? `${Math.round((summary.confirmed / summary.total) * 100)}%`
|
||||
: "0%",
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='lastConfirmedAt']",
|
||||
formatTime(summary.lastConfirmedAt),
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='nextTriggerAt']",
|
||||
formatTime(data.schedule?.nextTriggerAt),
|
||||
);
|
||||
setText(
|
||||
"[data-overview-value='scheduleLabel']",
|
||||
data.schedule?.label || "-",
|
||||
);
|
||||
updateAccounts(data.accounts || []);
|
||||
updateActions(summary, Boolean(task.running));
|
||||
|
||||
if (previousRunning === true && !task.running) {
|
||||
document
|
||||
.querySelectorAll("[data-refresh-notice]")
|
||||
.forEach((node) => node.classList.add("visible"));
|
||||
}
|
||||
previousRunning = Boolean(task.running);
|
||||
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
|
||||
node.textContent = "实时";
|
||||
node.classList.remove("poll-stale");
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
try {
|
||||
const response = await fetch("/api/ops/overview", {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
render(await response.json());
|
||||
} catch {
|
||||
document.querySelectorAll("[data-overview-live-state]").forEach((node) => {
|
||||
node.textContent = "更新延迟";
|
||||
node.classList.add("poll-stale");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-refresh-page]").forEach((button) => {
|
||||
button.addEventListener("click", () => window.location.reload());
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
});
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, 10000);
|
||||
window.addEventListener("pagehide", () => window.clearInterval(timer));
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
|
||||
const section = document.getElementById("interactive-login-section");
|
||||
const csrfToken = root.dataset.csrfToken || "";
|
||||
const publicUrl = root.dataset.publicUrl || "";
|
||||
const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
|
||||
const statusTextEl = document.getElementById("login-desktop-status-text");
|
||||
const openButtons = document.querySelectorAll(".login-desktop-open");
|
||||
const saveButtons = document.querySelectorAll(".login-desktop-save");
|
||||
const resetButtons = document.querySelectorAll(".login-desktop-reset");
|
||||
const copyPublicUrlButton = document.getElementById("copy-public-url");
|
||||
const statusMap = {
|
||||
checking: document.getElementById("desktop-status-checking"),
|
||||
pending: document.getElementById("desktop-status-pending"),
|
||||
success: document.getElementById("desktop-status-success"),
|
||||
error: document.getElementById("desktop-status-error"),
|
||||
};
|
||||
const runtimeState = document.getElementById(
|
||||
"login-desktop-runtime-state",
|
||||
);
|
||||
const statusText = document.getElementById("login-desktop-status-text");
|
||||
const frame = document.querySelector("[data-login-frame]");
|
||||
let timer = null;
|
||||
|
||||
const setVisualStatus = (state) => {
|
||||
Object.values(statusMap).forEach((node) => {
|
||||
if (!node) return;
|
||||
node.style.opacity = "0.46";
|
||||
});
|
||||
if (statusMap[state]) {
|
||||
statusMap[state].style.opacity = "1";
|
||||
const setStatus = (text, tone = "") => {
|
||||
if (statusText) statusText.textContent = text;
|
||||
if (runtimeState) {
|
||||
runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
runtimeState.textContent =
|
||||
tone === "success" ? "已登录" : tone === "danger" ? "异常" : "待登录";
|
||||
}
|
||||
};
|
||||
|
||||
const setStatus = (text, tone = "", state = "checking") => {
|
||||
if (statusTextEl) statusTextEl.textContent = text;
|
||||
if (runtimeStateEl) {
|
||||
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
}
|
||||
setVisualStatus(state);
|
||||
};
|
||||
|
||||
const postForm = async (url, payload = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
@@ -151,284 +373,185 @@
|
||||
return data;
|
||||
};
|
||||
|
||||
const openDesktopWindow = () => {
|
||||
const popup = window.open(publicUrl, "_blank");
|
||||
if (!popup) {
|
||||
setStatus(
|
||||
"浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
|
||||
"danger",
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
const loadFrame = () => {
|
||||
if (frame && frame.dataset.loaded !== "1" && frame.dataset.src) {
|
||||
frame.src = frame.dataset.src;
|
||||
frame.dataset.loaded = "1";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (document.visibilityState !== "visible" || (section && !section.open)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/login-desktop/status", {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
|
||||
setStatus(
|
||||
data.error || "登录工作区不可用,请检查 login-desktop 服务。",
|
||||
"warning",
|
||||
"error",
|
||||
"danger",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (runtimeStateEl)
|
||||
runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
|
||||
if (data.logged_in) {
|
||||
setStatus(
|
||||
`当前浏览器已登录:${data.username}(${data.unique_id})`,
|
||||
"success",
|
||||
"success",
|
||||
);
|
||||
setStatus(`当前浏览器已登录:${data.username}`, "success");
|
||||
} else {
|
||||
setStatus(
|
||||
"当前浏览器尚未登录,可打开登录工作区开始人工登录。",
|
||||
"",
|
||||
"pending",
|
||||
);
|
||||
setStatus("当前浏览器尚未登录,可打开工作区开始人工登录。");
|
||||
}
|
||||
} catch (error) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "异常";
|
||||
setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
|
||||
setStatus(`状态检查失败:${error.message}`, "danger");
|
||||
}
|
||||
};
|
||||
|
||||
copyPublicUrlButton?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setStatus("登录工作区地址已复制。", "success", "pending");
|
||||
} catch (error) {
|
||||
setStatus(`复制失败:${error.message}`, "warning", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openButtons.forEach((button) => {
|
||||
document.querySelectorAll(".login-desktop-open").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(
|
||||
button.dataset.reloginUniqueId || "",
|
||||
).trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
openDesktopWindow();
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `请使用账号 ${accountName || reloginUniqueId} 完成登录,然后保存登录态。`
|
||||
: "请在远端浏览器中完成抖音创作者中心登录,然后保存账号。",
|
||||
"",
|
||||
"pending",
|
||||
);
|
||||
loadFrame();
|
||||
window.open(publicUrl, "_blank", "noopener");
|
||||
setStatus("请在登录工作区完成登录,然后保存登录态。");
|
||||
} catch (error) {
|
||||
setStatus(`打开登录工作区失败:${error.message}`, "danger", "error");
|
||||
setStatus(`打开登录工作区失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
saveButtons.forEach((button) => {
|
||||
document.querySelectorAll(".login-desktop-save").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(
|
||||
button.dataset.reloginUniqueId || "",
|
||||
).trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
const data = await postForm("/login-desktop/save", {
|
||||
relogin_unique_id: reloginUniqueId,
|
||||
relogin_unique_id: button.dataset.reloginUniqueId || "",
|
||||
});
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
|
||||
: `已保存当前登录账号:${data.account?.username || ""}`,
|
||||
"success",
|
||||
"success",
|
||||
);
|
||||
window.setTimeout(() => window.location.reload(), 900);
|
||||
setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
|
||||
window.setTimeout(() => window.location.reload(), 800);
|
||||
} catch (error) {
|
||||
setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
|
||||
setStatus(`保存登录账号失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
resetButtons.forEach((button) => {
|
||||
document.querySelectorAll(".login-desktop-reset").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await postForm("/login-desktop/reset");
|
||||
setStatus(
|
||||
"登录工作区已重置,正在重新初始化浏览器。",
|
||||
"warning",
|
||||
"checking",
|
||||
);
|
||||
setStatus("登录工作区已重置,正在重新初始化。");
|
||||
await pollStatus();
|
||||
} catch (error) {
|
||||
setStatus(`重置登录工作区失败:${error.message}`, "danger", "error");
|
||||
setStatus(`重置登录工作区失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setVisualStatus("checking");
|
||||
pollStatus();
|
||||
window.setInterval(pollStatus, 5000);
|
||||
document.querySelectorAll("[data-copy-login-url]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setStatus("登录工作区地址已复制。", "success");
|
||||
} catch (error) {
|
||||
setStatus(`复制失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (section) {
|
||||
section.addEventListener("toggle", () => {
|
||||
if (section.open) {
|
||||
loadFrame();
|
||||
pollStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
timer = window.setInterval(pollStatus, 5000);
|
||||
window.addEventListener("pagehide", () => window.clearInterval(timer));
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const pickers = document.querySelectorAll(".friend-picker");
|
||||
if (!pickers.length) return;
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
const parseJson = (id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return [];
|
||||
try {
|
||||
return JSON.parse(el.textContent || "[]");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse friend picker JSON", id, error);
|
||||
return JSON.parse(node.textContent || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
pickers.forEach((picker) => {
|
||||
document.querySelectorAll(".friend-picker").forEach((picker) => {
|
||||
const accountId = picker.dataset.accountId;
|
||||
const refreshUrl = picker.dataset.refreshUrl;
|
||||
const csrfToken = picker.dataset.csrfToken;
|
||||
const searchInput = picker.querySelector(".friend-search-input");
|
||||
const form = picker.closest("form");
|
||||
const textarea = form?.querySelector(".targets-textarea");
|
||||
const search = picker.querySelector(".friend-search-input");
|
||||
const refreshButton = picker.querySelector(".friend-refresh-button");
|
||||
const listEl = picker.querySelector(".friend-picker-list");
|
||||
const summaryEl = picker.querySelector(".friend-picker-summary");
|
||||
const statusEl = picker.querySelector(".friend-picker-status");
|
||||
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
|
||||
const formEl = picker.closest("form");
|
||||
const targetsTextarea = formEl?.querySelector(".targets-textarea");
|
||||
const currentTargetsEl = picker.querySelector(
|
||||
".friend-picker-current-targets span",
|
||||
);
|
||||
const list = picker.querySelector(".friend-picker-list");
|
||||
const summary = picker.querySelector(".friend-picker-summary");
|
||||
const status = picker.querySelector(".friend-picker-status");
|
||||
let friends = parseJson(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJson(`selected-targets-${accountId}`));
|
||||
|
||||
let friends = parseJsonScript(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
|
||||
const parseTargets = (value) =>
|
||||
[...new Set(
|
||||
String(value || "")
|
||||
.replaceAll(",", "\n")
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
|
||||
const splitTargetText = (value) => {
|
||||
const seen = new Set();
|
||||
return String(value || "")
|
||||
.replaceAll(",", "\n")
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => {
|
||||
if (!name || seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
return true;
|
||||
});
|
||||
const combined = () => [...new Set([...selected, ...friends])];
|
||||
|
||||
const syncTextarea = () => {
|
||||
if (textarea) textarea.value = [...selected].join("\n");
|
||||
};
|
||||
|
||||
const combinedFriends = () => {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
[...selected, ...friends].forEach((name) => {
|
||||
if (!name || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
merged.push(name);
|
||||
});
|
||||
return merged;
|
||||
};
|
||||
|
||||
const syncTextareaFromSelected = () => {
|
||||
if (targetsTextarea) {
|
||||
targetsTextarea.value = [...selected].join("\n");
|
||||
}
|
||||
};
|
||||
|
||||
const syncSelectedFromTextarea = () => {
|
||||
if (targetsTextarea) {
|
||||
selected = new Set(splitTargetText(targetsTextarea.value));
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
hiddenInputsEl.innerHTML = "";
|
||||
[...selected].forEach((name) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "targets";
|
||||
input.value = name;
|
||||
hiddenInputsEl.appendChild(input);
|
||||
});
|
||||
};
|
||||
|
||||
const updateSummary = () => {
|
||||
summaryEl.textContent = `已选 ${selected.size} 人`;
|
||||
if (currentTargetsEl) {
|
||||
currentTargetsEl.textContent = selected.size
|
||||
? [...selected].join("、")
|
||||
: "未选择";
|
||||
}
|
||||
};
|
||||
|
||||
const renderList = () => {
|
||||
const query = (searchInput.value || "").trim().toLowerCase();
|
||||
const allNames = combinedFriends();
|
||||
const displayNames = allNames.filter((name) =>
|
||||
const render = () => {
|
||||
const query = String(search?.value || "").trim().toLowerCase();
|
||||
const names = combined().filter((name) =>
|
||||
name.toLowerCase().includes(query),
|
||||
);
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
|
||||
if (!allNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
|
||||
if (summary) summary.textContent = `已选 ${selected.size} 人`;
|
||||
list.innerHTML = "";
|
||||
if (!names.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "friend-picker-empty";
|
||||
empty.textContent = combined().length
|
||||
? "没有匹配的好友。"
|
||||
: "点击“刷新好友列表”后再选择目标。";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">没有匹配的好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = displayNames
|
||||
.map(
|
||||
(name) => `
|
||||
<label class="friend-option ${selected.has(name) ? "selected" : ""}">
|
||||
<span>${escapeHtml(name)}</span>
|
||||
<input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}>
|
||||
</label>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
||||
names.forEach((name) => {
|
||||
const label = document.createElement("label");
|
||||
label.className = `friend-option${selected.has(name) ? " selected" : ""}`;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = name;
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selected.has(name);
|
||||
checkbox.addEventListener("change", () => {
|
||||
const option = checkbox.closest(".friend-option");
|
||||
const value = checkbox.value;
|
||||
if (checkbox.checked) {
|
||||
selected.add(value);
|
||||
option?.classList.add("selected");
|
||||
} else {
|
||||
selected.delete(value);
|
||||
option?.classList.remove("selected");
|
||||
}
|
||||
syncTextareaFromSelected();
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
if (checkbox.checked) selected.add(name);
|
||||
else selected.delete(name);
|
||||
syncTextarea();
|
||||
render();
|
||||
});
|
||||
label.append(text, checkbox);
|
||||
list.appendChild(label);
|
||||
});
|
||||
};
|
||||
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
textarea?.addEventListener("input", () => {
|
||||
selected = new Set(parseTargets(textarea.value));
|
||||
render();
|
||||
});
|
||||
search?.addEventListener("input", render);
|
||||
refreshButton?.addEventListener("click", async () => {
|
||||
refreshButton.disabled = true;
|
||||
const originalText = refreshButton.textContent;
|
||||
refreshButton.textContent = "刷新中...";
|
||||
statusEl.textContent = "正在读取好友列表...";
|
||||
if (status) status.textContent = "正在读取好友列表...";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
@@ -437,29 +560,23 @@
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "刷新好友列表失败");
|
||||
}
|
||||
friends = Array.isArray(payload.friends) ? payload.friends : [];
|
||||
statusEl.textContent =
|
||||
payload.message || `已刷新 ${friends.length} 个好友`;
|
||||
renderList();
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "刷新失败");
|
||||
friends = data.friends || [];
|
||||
if (status) status.textContent = data.message || "好友列表已刷新";
|
||||
render();
|
||||
} catch (error) {
|
||||
statusEl.textContent = error.message || "刷新好友列表失败";
|
||||
if (status) status.textContent = `刷新失败:${error.message}`;
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
refreshButton.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", renderList);
|
||||
targetsTextarea?.addEventListener("input", () => {
|
||||
syncSelectedFromTextarea();
|
||||
renderList();
|
||||
});
|
||||
|
||||
syncSelectedFromTextarea();
|
||||
renderList();
|
||||
render();
|
||||
});
|
||||
})();
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+12
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user