mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-06 16:07:22 +08:00
feat: redesign web admin layout
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const navToggle = document.querySelector("[data-nav-toggle]");
|
||||
const navClose = document.querySelector("[data-nav-close]");
|
||||
|
||||
navToggle?.addEventListener("click", () => body.classList.toggle("nav-open"));
|
||||
navClose?.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
document.querySelectorAll(".nav-item").forEach((item) => {
|
||||
item.addEventListener("click", () => body.classList.remove("nav-open"));
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll("[data-confirm]").forEach((node) => {
|
||||
const message = node.getAttribute("data-confirm") || "确认执行此操作?";
|
||||
const handler = (event) => {
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
if (node.tagName === "FORM") {
|
||||
node.addEventListener("submit", handler);
|
||||
} else {
|
||||
node.addEventListener("click", handler);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
document.querySelectorAll(".data-card__footer-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.expandTarget;
|
||||
if (!targetId) return;
|
||||
const panel = document.getElementById(targetId);
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle("is-open");
|
||||
button.textContent = isOpen
|
||||
? "收起"
|
||||
: button.dataset.totalLabel || "查看全部";
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const root = document.getElementById("login-desktop-controls");
|
||||
if (!root) return;
|
||||
|
||||
const csrfToken = root.dataset.csrfToken || "";
|
||||
const publicUrl = root.dataset.publicUrl || "";
|
||||
const runtimeStateEl = document.getElementById("login-desktop-runtime-state");
|
||||
const statusTextEl = document.getElementById("login-desktop-status-text");
|
||||
const openButtons = document.querySelectorAll(".login-desktop-open");
|
||||
const saveButtons = document.querySelectorAll(".login-desktop-save");
|
||||
const resetButtons = document.querySelectorAll(".login-desktop-reset");
|
||||
const copyPublicUrlButton = document.getElementById("copy-public-url");
|
||||
const statusMap = {
|
||||
checking: document.getElementById("desktop-status-checking"),
|
||||
pending: document.getElementById("desktop-status-pending"),
|
||||
success: document.getElementById("desktop-status-success"),
|
||||
error: document.getElementById("desktop-status-error"),
|
||||
};
|
||||
|
||||
const setVisualStatus = (state) => {
|
||||
Object.values(statusMap).forEach((node) => {
|
||||
if (!node) return;
|
||||
node.style.opacity = "0.46";
|
||||
});
|
||||
if (statusMap[state]) {
|
||||
statusMap[state].style.opacity = "1";
|
||||
}
|
||||
};
|
||||
|
||||
const setStatus = (text, tone = "", state = "checking") => {
|
||||
if (statusTextEl) statusTextEl.textContent = text;
|
||||
if (runtimeStateEl) {
|
||||
runtimeStateEl.className = `pill${tone ? ` ${tone}` : ""}`;
|
||||
}
|
||||
setVisualStatus(state);
|
||||
};
|
||||
|
||||
const postForm = async (url, payload = {}) => {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
formData.set(key, String(value ?? ""));
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false) {
|
||||
throw new Error(data.error || `请求失败:${response.status}`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const openDesktopWindow = () => {
|
||||
const popup = window.open(publicUrl, "_blank");
|
||||
if (!popup) {
|
||||
setStatus(
|
||||
"浏览器阻止了登录工作区弹窗,请允许弹窗后再试。",
|
||||
"danger",
|
||||
"error",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await fetch("/login-desktop/status", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.ok === false) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "不可用";
|
||||
setStatus(
|
||||
data.error || "登录工作区不可用,请检查 login-desktop 服务。",
|
||||
"warning",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (runtimeStateEl)
|
||||
runtimeStateEl.textContent = data.logged_in ? "已登录" : "待登录";
|
||||
if (data.logged_in) {
|
||||
setStatus(
|
||||
`当前浏览器已登录:${data.username}(${data.unique_id})`,
|
||||
"success",
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
setStatus(
|
||||
"当前浏览器尚未登录,可打开登录工作区开始人工登录。",
|
||||
"",
|
||||
"pending",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (runtimeStateEl) runtimeStateEl.textContent = "异常";
|
||||
setStatus(`登录工作区状态检查失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
};
|
||||
|
||||
copyPublicUrlButton?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
setStatus("登录工作区地址已复制。", "success", "pending");
|
||||
} catch (error) {
|
||||
setStatus(`复制失败:${error.message}`, "warning", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(
|
||||
button.dataset.reloginUniqueId || "",
|
||||
).trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
openDesktopWindow();
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `请使用账号 ${accountName || reloginUniqueId} 完成登录,然后保存登录态。`
|
||||
: "请在远端浏览器中完成抖音创作者中心登录,然后保存账号。",
|
||||
"",
|
||||
"pending",
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(`打开登录工作区失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
saveButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const reloginUniqueId = String(
|
||||
button.dataset.reloginUniqueId || "",
|
||||
).trim();
|
||||
const accountName = String(button.dataset.accountName || "").trim();
|
||||
try {
|
||||
const data = await postForm("/login-desktop/save", {
|
||||
relogin_unique_id: reloginUniqueId,
|
||||
});
|
||||
setStatus(
|
||||
reloginUniqueId
|
||||
? `已把当前浏览器登录保存到账号:${accountName || reloginUniqueId}`
|
||||
: `已保存当前登录账号:${data.account?.username || ""}`,
|
||||
"success",
|
||||
"success",
|
||||
);
|
||||
window.setTimeout(() => window.location.reload(), 900);
|
||||
} catch (error) {
|
||||
setStatus(`保存当前登录账号失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
resetButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await postForm("/login-desktop/reset");
|
||||
setStatus(
|
||||
"登录工作区已重置,正在重新初始化浏览器。",
|
||||
"warning",
|
||||
"checking",
|
||||
);
|
||||
await pollStatus();
|
||||
} catch (error) {
|
||||
setStatus(`重置登录工作区失败:${error.message}`, "danger", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setVisualStatus("checking");
|
||||
pollStatus();
|
||||
window.setInterval(pollStatus, 5000);
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const pickers = document.querySelectorAll(".friend-picker");
|
||||
if (!pickers.length) return;
|
||||
|
||||
const parseJsonScript = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
try {
|
||||
return JSON.parse(el.textContent || "[]");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse friend picker JSON", id, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
pickers.forEach((picker) => {
|
||||
const accountId = picker.dataset.accountId;
|
||||
const refreshUrl = picker.dataset.refreshUrl;
|
||||
const csrfToken = picker.dataset.csrfToken;
|
||||
const searchInput = picker.querySelector(".friend-search-input");
|
||||
const refreshButton = picker.querySelector(".friend-refresh-button");
|
||||
const listEl = picker.querySelector(".friend-picker-list");
|
||||
const summaryEl = picker.querySelector(".friend-picker-summary");
|
||||
const statusEl = picker.querySelector(".friend-picker-status");
|
||||
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
|
||||
const formEl = picker.closest("form");
|
||||
const targetsTextarea = formEl?.querySelector(".targets-textarea");
|
||||
const currentTargetsEl = picker.querySelector(
|
||||
".friend-picker-current-targets span",
|
||||
);
|
||||
|
||||
let friends = parseJsonScript(`friends-cache-${accountId}`);
|
||||
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
|
||||
|
||||
const splitTargetText = (value) => {
|
||||
const seen = new Set();
|
||||
return String(value || "")
|
||||
.replaceAll(",", "\n")
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => {
|
||||
if (!name || seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const combinedFriends = () => {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
[...selected, ...friends].forEach((name) => {
|
||||
if (!name || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
merged.push(name);
|
||||
});
|
||||
return merged;
|
||||
};
|
||||
|
||||
const syncTextareaFromSelected = () => {
|
||||
if (targetsTextarea) {
|
||||
targetsTextarea.value = [...selected].join("\n");
|
||||
}
|
||||
};
|
||||
|
||||
const syncSelectedFromTextarea = () => {
|
||||
if (targetsTextarea) {
|
||||
selected = new Set(splitTargetText(targetsTextarea.value));
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
hiddenInputsEl.innerHTML = "";
|
||||
[...selected].forEach((name) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "targets";
|
||||
input.value = name;
|
||||
hiddenInputsEl.appendChild(input);
|
||||
});
|
||||
};
|
||||
|
||||
const updateSummary = () => {
|
||||
summaryEl.textContent = `已选 ${selected.size} 人`;
|
||||
if (currentTargetsEl) {
|
||||
currentTargetsEl.textContent = selected.size
|
||||
? [...selected].join("、")
|
||||
: "未选择";
|
||||
}
|
||||
};
|
||||
|
||||
const renderList = () => {
|
||||
const query = (searchInput.value || "").trim().toLowerCase();
|
||||
const allNames = combinedFriends();
|
||||
const displayNames = allNames.filter((name) =>
|
||||
name.toLowerCase().includes(query),
|
||||
);
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
|
||||
if (!allNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">点击“刷新好友列表”后再勾选目标好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displayNames.length) {
|
||||
listEl.innerHTML =
|
||||
'<div class="friend-picker-empty">没有匹配的好友。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = displayNames
|
||||
.map(
|
||||
(name) => `
|
||||
<label class="friend-option ${selected.has(name) ? "selected" : ""}">
|
||||
<span>${escapeHtml(name)}</span>
|
||||
<input type="checkbox" value="${escapeHtml(name)}" ${selected.has(name) ? "checked" : ""}>
|
||||
</label>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", () => {
|
||||
const option = checkbox.closest(".friend-option");
|
||||
const value = checkbox.value;
|
||||
if (checkbox.checked) {
|
||||
selected.add(value);
|
||||
option?.classList.add("selected");
|
||||
} else {
|
||||
selected.delete(value);
|
||||
option?.classList.remove("selected");
|
||||
}
|
||||
syncTextareaFromSelected();
|
||||
renderHiddenInputs();
|
||||
updateSummary();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
refreshButton.addEventListener("click", async () => {
|
||||
refreshButton.disabled = true;
|
||||
const originalText = refreshButton.textContent;
|
||||
refreshButton.textContent = "刷新中...";
|
||||
statusEl.textContent = "正在读取好友列表...";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", csrfToken);
|
||||
const response = await fetch(refreshUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "刷新好友列表失败");
|
||||
}
|
||||
friends = Array.isArray(payload.friends) ? payload.friends : [];
|
||||
statusEl.textContent =
|
||||
payload.message || `已刷新 ${friends.length} 个好友`;
|
||||
renderList();
|
||||
} catch (error) {
|
||||
statusEl.textContent = error.message || "刷新好友列表失败";
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
refreshButton.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", renderList);
|
||||
targetsTextarea?.addEventListener("input", () => {
|
||||
syncSelectedFromTextarea();
|
||||
renderList();
|
||||
});
|
||||
|
||||
syncSelectedFromTextarea();
|
||||
renderList();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user