feat: publish refreshed SparkFlow console and send safety

This commit is contained in:
Rixuan Shao
2026-07-11 02:13:14 +08:00
parent 3ae9738c46
commit c496d90039
28 changed files with 4757 additions and 4135 deletions
File diff suppressed because it is too large Load Diff
+435 -318
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
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.
File diff suppressed because one or more lines are too long