kaikai_test/public/mobile.js
2026-05-14 19:43:12 +08:00

830 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1";
const authGate = document.querySelector("#auth-gate");
const authForm = document.querySelector("#auth-form");
const authPassword = document.querySelector("#auth-password");
const authMessage = document.querySelector("#auth-message");
const form = document.querySelector("#collect-form");
const input = document.querySelector("#program-name");
const button = document.querySelector("#collect-button");
const exportLink = document.querySelector("#export-link");
const statusDot = document.querySelector("#status-dot");
const statusText = document.querySelector("#status-text");
const tableTitle = document.querySelector("#table-title");
const runCount = document.querySelector("#run-count");
const cards = document.querySelector("#cards");
const programList = document.querySelector("#program-list");
const networkLinks = document.querySelector("#network-links");
const collectPlatformBox = document.querySelector(".collect-platforms");
const mobileNote = document.querySelector("#mobile-note");
const mobileDeviceNameInput = document.querySelector("#mobile-device-name");
const saveDeviceNameButton = document.querySelector("#save-device-name-button");
const saveOfflineButton = document.querySelector("#save-offline-button");
const offlineCount = document.querySelector("#offline-count");
const offlineList = document.querySelector("#offline-list");
const clearOfflineButton = document.querySelector("#clear-offline-button");
const syncOfflineButton = document.querySelector("#sync-offline-button");
const mobileBatchText = document.querySelector("#mobile-batch-text");
const saveBatchOfflineButton = document.querySelector("#save-batch-offline-button");
const offlineStatus = document.querySelector("#offline-status");
const installHint = document.querySelector("#install-hint");
const installStatus = document.querySelector("#install-status");
const installAppButton = document.querySelector("#install-app-button");
const mobileServerUrlInput = document.querySelector("#mobile-server-url");
const saveMobileServerButton = document.querySelector("#save-mobile-server-button");
const testMobileServerButton = document.querySelector("#test-mobile-server-button");
const mobileBindingSummary = document.querySelector("#mobile-binding-summary");
const mobileAppState = document.querySelector("#mobile-app-state");
const MOBILE_DRAFTS_KEY = "video-hotness-mobile-drafts-v1";
const MOBILE_DEVICE_KEY = "video-hotness-mobile-device-v1";
const MOBILE_SERVER_KEY = "video-hotness-mobile-server-v1";
const platformOrder = ["tencent", "youku", "iqiyi", "mgtv"];
const platformLabels = {
tencent: "腾讯视频",
youku: "优酷",
iqiyi: "爱奇艺",
mgtv: "芒果TV",
};
const metricLabels = {
tencent: "热度值",
youku: "热度值",
iqiyi: "内容热度",
mgtv: "播放次数",
};
const urlInputs = {
tencent: document.querySelector("#url-tencent"),
youku: document.querySelector("#url-youku"),
iqiyi: document.querySelector("#url-iqiyi"),
mgtv: document.querySelector("#url-mgtv"),
};
let activeName = "";
let dirtyUrlInputs = new Set();
let deferredInstallPrompt = null;
let appStarted = false;
authForm?.addEventListener("submit", async (event) => {
event.preventDefault();
await submitAccessPassword();
});
for (const [platform, element] of Object.entries(urlInputs)) {
element.addEventListener("input", () => {
dirtyUrlInputs.add(platform);
});
}
input.addEventListener("input", () => {
const name = input.value.trim();
if (activeName && name !== activeName) {
clearUrlInputs();
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const name = input.value.trim();
if (!name) return;
activeName = name;
const platforms = readCollectPlatforms();
if (platforms.length === 0) {
setStatus("error", "请至少选择一个采集平台");
return;
}
setBusy(true, `正在采集《${name}`);
try {
const payload = await postJson("/api/collect", { name, urls: readUrlInputs(), platforms });
renderHistory(payload.history);
await refreshPrograms();
setStatus("ok", `已新增 ${formatTime(payload.collection.captured_at)} 这一列`);
} catch (error) {
setStatus("error", error.message);
} finally {
setBusy(false);
}
});
collectPlatformBox.addEventListener("change", (event) => {
if (!event.target.matches("input[type='checkbox']")) return;
updateCollectPlatformState();
});
saveOfflineButton.addEventListener("click", () => {
saveOfflineDraft();
});
saveDeviceNameButton.addEventListener("click", () => {
saveMobileDeviceName();
});
saveMobileServerButton.addEventListener("click", () => {
saveMobileServerUrl();
});
testMobileServerButton.addEventListener("click", () => {
testMobileServerConnection();
});
installAppButton.addEventListener("click", () => {
installMobileApp();
});
saveBatchOfflineButton.addEventListener("click", () => {
saveBatchOfflineDrafts();
});
syncOfflineButton.addEventListener("click", () => {
syncOfflineDrafts();
});
clearOfflineButton.addEventListener("click", () => {
const drafts = readOfflineDrafts();
if (drafts.length === 0) return;
if (!window.confirm(`确定清空 ${drafts.length} 条手机待同步记录吗?`)) return;
localStorage.setItem(MOBILE_DRAFTS_KEY, "[]");
renderOfflineDrafts();
setStatus("ok", "已清空手机待同步列表");
});
offlineList.addEventListener("click", (event) => {
const editButton = event.target.closest("[data-edit-draft]");
if (editButton) {
editOfflineDraft(editButton.dataset.editDraft);
return;
}
const deleteButton = event.target.closest("[data-delete-draft]");
if (deleteButton) {
deleteOfflineDraft(deleteButton.dataset.deleteDraft);
}
});
programList.addEventListener("click", async (event) => {
const item = event.target.closest("[data-name]");
if (!item) return;
activeName = item.dataset.name;
input.value = activeName;
await loadHistory(activeName);
});
window.addEventListener("online", updateOfflineStatus);
window.addEventListener("offline", updateOfflineStatus);
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredInstallPrompt = event;
updateInstallPrompt("ready");
});
window.addEventListener("appinstalled", () => {
deferredInstallPrompt = null;
updateInstallPrompt("installed");
});
initializeApp();
async function initializeApp() {
if (!(await ensureAccessAuth())) return;
startApp();
}
async function startApp() {
if (appStarted) return;
appStarted = true;
updateCollectPlatformState();
mobileDeviceNameInput.value = mobileDeviceName();
mobileServerUrlInput.value = mobileServerBaseUrl();
registerMobileServiceWorker();
updateInstallPrompt(isStandaloneDisplay() ? "installed" : "manual");
updateOfflineStatus();
updateMobileBindingSummary();
renderOfflineDrafts();
await Promise.all([refreshPrograms(), loadNetworkLinks()]);
}
async function registerMobileServiceWorker() {
if (!("serviceWorker" in navigator)) {
if (installStatus) installStatus.textContent = "当前浏览器不支持离线缓存,可继续使用手机待同步列表。";
return;
}
try {
await navigator.serviceWorker.register("/mobile-sw.js");
} catch {
if (installStatus) installStatus.textContent = "离线缓存注册失败,可刷新后重试。";
}
}
async function installMobileApp() {
if (!deferredInstallPrompt) {
updateInstallPrompt("manual");
return;
}
installAppButton.disabled = true;
deferredInstallPrompt.prompt();
const choice = await deferredInstallPrompt.userChoice.catch(() => ({ outcome: "dismissed" }));
deferredInstallPrompt = null;
installAppButton.disabled = false;
updateInstallPrompt(choice.outcome === "accepted" ? "installed" : "manual");
}
function updateInstallPrompt(state) {
if (!installStatus || !installAppButton) return;
installHint.classList.toggle("install-ready", state === "ready");
if (state === "ready") {
installStatus.textContent = "当前浏览器支持直接安装,点击按钮后会添加到手机桌面。";
installAppButton.hidden = false;
return;
}
installAppButton.hidden = true;
installStatus.textContent = state === "installed"
? "已用 App 模式打开;离线录入和待同步列表可继续使用。"
: "如果没有安装按钮,请在手机浏览器菜单选择“添加到主屏幕”。";
}
function isStandaloneDisplay() {
return window.matchMedia?.("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function updateOfflineStatus() {
if (!offlineStatus) return;
const online = navigator.onLine;
const pendingDrafts = readOfflineDrafts().filter((draft) => draft.sync_status !== "synced");
if (mobileAppState) {
mobileAppState.textContent = online ? "在线" : "离线";
mobileAppState.classList.toggle("offline", !online);
}
offlineStatus.classList.toggle("offline", !online);
offlineStatus.innerHTML = online && pendingDrafts.length
? `<strong>有 ${pendingDrafts.length} 条可同步</strong><span>电脑可访问时点击“同步到电脑”,同步后会显示电脑已收到。</span>`
: online
? `<strong>离线录入已准备</strong><span>首次打开后会缓存手机版;离开局域网时仍可保存待同步。</span>`
: `<strong>当前离线</strong><span>可以继续录入并保存待同步;回到局域网后再同步到电脑。</span>`;
}
async function loadHistory(name) {
setStatus("busy", `正在读取《${name}》历史`);
try {
const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`);
renderHistory(payload.history);
setStatus("ok", `已载入《${name}`);
} catch (error) {
setStatus("error", error.message);
}
}
async function refreshPrograms() {
try {
const payload = await getJson("/api/programs");
renderPrograms(payload.programs || []);
} catch {
renderPrograms([]);
}
}
async function loadNetworkLinks() {
try {
const payload = await getJson("/api/network");
const urls = payload.urls || [];
networkLinks.innerHTML = urls.length
? urls.map((url) => `<a href="${escapeAttribute(url)}">${escapeHtml(url)}</a>`).join("<br>")
: "没有读取到局域网地址,可先用本机浏览器访问。";
} catch {
networkLinks.textContent = "局域网地址读取失败。";
}
}
function mobileServerBaseUrl() {
const saved = (localStorage.getItem(MOBILE_SERVER_KEY) || "").trim();
return saved || window.location.origin;
}
function normalizeServerUrl(value) {
const text = String(value || "").trim().replace(/\/+$/, "");
if (!text) return "";
try {
const parsed = new URL(text);
return parsed.origin;
} catch {
return "";
}
}
function apiUrl(path) {
return `${mobileServerBaseUrl()}${path}`;
}
function saveMobileServerUrl() {
const normalized = normalizeServerUrl(mobileServerUrlInput.value);
if (!normalized) {
setStatus("error", "请输入正确的电脑或 NAS 地址,例如 http://192.168.18.120:3001");
return;
}
localStorage.setItem(MOBILE_SERVER_KEY, normalized);
mobileServerUrlInput.value = normalized;
updateMobileBindingSummary();
setStatus("ok", `已绑定地址:${normalized}`);
}
async function testMobileServerConnection() {
const normalized = normalizeServerUrl(mobileServerUrlInput.value) || mobileServerBaseUrl();
if (normalized !== mobileServerBaseUrl()) {
localStorage.setItem(MOBILE_SERVER_KEY, normalized);
mobileServerUrlInput.value = normalized;
}
testMobileServerButton.disabled = true;
setStatus("busy", "正在测试电脑端连接");
try {
const payload = await getJson("/api/network");
updateMobileBindingSummary({ ok: true });
setStatus("ok", `连接正常,读取到 ${(payload.urls || []).length} 个手机访问地址`);
} catch (error) {
updateMobileBindingSummary({ ok: false, error: error.message });
setStatus("error", `连接失败:${error.message}`);
} finally {
testMobileServerButton.disabled = false;
}
}
function updateMobileBindingSummary(result = null) {
const base = mobileServerBaseUrl();
const pendingDrafts = readOfflineDrafts().filter((draft) => draft.sync_status !== "synced").length;
const resultText = result?.ok ? " · 连接正常" : result?.error ? ` · 连接失败:${result.error}` : "";
mobileBindingSummary.textContent = `当前绑定:${base} · 待同步 ${pendingDrafts}${resultText}`;
}
function renderPrograms(programs) {
if (programs.length === 0) {
programList.innerHTML = `<div class="empty">暂无历史</div>`;
return;
}
programList.innerHTML = programs.map((program) => `
<button class="program-item ${program.name === activeName ? "active" : ""}" data-name="${escapeHtml(program.name)}">
${escapeHtml(program.name)}
</button>
`).join("");
}
function saveOfflineDraft() {
const name = input.value.trim();
if (!name) {
setStatus("error", "请先输入节目名");
return;
}
const draft = createOfflineDraft({
name,
note: mobileNote.value.trim(),
urls: readAllUrlInputs(),
platforms: readCollectPlatforms(),
});
const drafts = readOfflineDrafts();
drafts.unshift(draft);
localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(drafts.slice(0, 200)));
renderOfflineDrafts();
updateOfflineStatus();
updateMobileBindingSummary();
setStatus("ok", `已保存《${name}》到手机待同步`);
}
function saveBatchOfflineDrafts() {
const names = parseMobileBatchNames(mobileBatchText.value);
if (names.length === 0) {
setStatus("error", "请先粘贴节目名单");
return;
}
const drafts = readOfflineDrafts();
const newDrafts = names.map((name) => createOfflineDraft({
name,
note: "批量离线录入",
urls: {},
platforms: readCollectPlatforms(),
}));
localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify([...newDrafts, ...drafts].slice(0, 200)));
mobileBatchText.value = "";
renderOfflineDrafts();
updateOfflineStatus();
updateMobileBindingSummary();
setStatus("ok", `已批量保存 ${newDrafts.length} 条待同步`);
}
function parseMobileBatchNames(text) {
const seen = new Set();
const names = [];
for (const line of String(text || "").split(/\r?\n/)) {
const name = line.split(/[,\t]/)[0].trim();
if (!name || seen.has(name) || /节目|名称|片名/.test(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
function createOfflineDraft({ name, note = "", urls = {}, platforms = [] }) {
return {
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
name,
note,
urls,
platforms,
device_name: mobileDeviceName(),
created_at: new Date().toISOString(),
sync_status: "pending",
};
}
function readOfflineDrafts() {
try {
const value = JSON.parse(localStorage.getItem(MOBILE_DRAFTS_KEY) || "[]");
return Array.isArray(value) ? value.filter((draft) => draft?.name) : [];
} catch {
return [];
}
}
function renderOfflineDrafts() {
const drafts = readOfflineDrafts();
const pendingDrafts = drafts.filter((draft) => draft.sync_status !== "synced");
offlineCount.textContent = String(drafts.length);
if (drafts.length === 0) {
offlineList.innerHTML = `<div class="empty">暂无手机待同步记录</div>`;
clearOfflineButton.disabled = true;
syncOfflineButton.disabled = true;
return;
}
clearOfflineButton.disabled = false;
syncOfflineButton.disabled = pendingDrafts.length === 0;
offlineList.innerHTML = drafts.slice(0, 20).map((draft) => {
const urlCount = Object.values(draft.urls || {}).filter(Boolean).length;
const note = draft.note ? `<div class="offline-meta">${escapeHtml(draft.note)}</div>` : "";
const isSynced = draft.sync_status === "synced";
const syncLabel = isSynced ? "已同步" : "待同步";
return `
<article class="offline-item ${isSynced ? "synced" : ""}">
<div class="offline-title">
<strong>${escapeHtml(draft.name)}</strong>
<span class="sync-status ${isSynced ? "synced" : "pending"}">${syncLabel}</span>
</div>
<div class="offline-meta">${formatTime(draft.created_at)} · ${urlCount} 个链接 · ${escapeHtml((draft.platforms || []).map((platform) => platformLabels[platform] || platform).join("、") || "未选平台")}</div>
${note}
<div class="offline-actions">
<button type="button" data-edit-draft="${escapeAttribute(draft.id)}">编辑</button>
<button type="button" data-delete-draft="${escapeAttribute(draft.id)}">删除</button>
</div>
</article>
`;
}).join("");
}
function editOfflineDraft(id) {
const drafts = readOfflineDrafts();
const draft = drafts.find((item) => item.id === id);
if (!draft) return;
const nextName = window.prompt("修改节目名", draft.name);
if (nextName === null) return;
const cleanName = nextName.trim();
if (!cleanName) {
setStatus("error", "节目名不能为空");
return;
}
const nextNote = window.prompt("修改备注", draft.note || "");
if (nextNote === null) return;
const updated = drafts.map((item) => item.id === id
? { ...item, name: cleanName, note: nextNote.trim(), sync_status: "pending", edited_at: new Date().toISOString() }
: item);
localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(updated));
renderOfflineDrafts();
updateOfflineStatus();
updateMobileBindingSummary();
setStatus("ok", `已更新《${cleanName}`);
}
function deleteOfflineDraft(id) {
const drafts = readOfflineDrafts();
const draft = drafts.find((item) => item.id === id);
if (!draft) return;
if (!window.confirm(`删除《${draft.name}》这条手机记录吗?`)) return;
localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(drafts.filter((item) => item.id !== id)));
renderOfflineDrafts();
updateOfflineStatus();
updateMobileBindingSummary();
setStatus("ok", `已删除《${draft.name}`);
}
async function syncOfflineDrafts() {
const drafts = readOfflineDrafts();
const pendingDrafts = drafts.filter((draft) => draft.sync_status !== "synced");
if (pendingDrafts.length === 0) {
setStatus("ok", "没有待同步的手机记录");
renderOfflineDrafts();
return;
}
syncOfflineButton.disabled = true;
setStatus("busy", `正在同步 ${pendingDrafts.length} 条到电脑`);
try {
const payload = await postJson("/api/mobile-sync", {
deviceName: mobileDeviceName(),
drafts: pendingDrafts,
});
const acceptedIds = new Set((payload.accepted || []).map((item) => item.id));
const syncedAt = new Date().toISOString();
const updatedDrafts = drafts.map((draft) => acceptedIds.has(draft.id)
? { ...draft, sync_status: "synced", synced_at: syncedAt }
: draft);
localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(updatedDrafts));
renderOfflineDrafts();
updateOfflineStatus();
updateMobileBindingSummary();
setStatus("ok", `电脑已收到 ${acceptedIds.size} 条,已进入待处理`);
} catch (error) {
setStatus("error", `同步失败:${error.message}`);
} finally {
syncOfflineButton.disabled = false;
renderOfflineDrafts();
updateMobileBindingSummary();
}
}
function mobileDeviceName() {
let deviceName = (mobileDeviceNameInput?.value || localStorage.getItem(MOBILE_DEVICE_KEY) || "").trim();
if (!deviceName) {
deviceName = `mobile-${Math.random().toString(16).slice(2, 8)}`;
localStorage.setItem(MOBILE_DEVICE_KEY, deviceName);
}
return deviceName;
}
function saveMobileDeviceName() {
const deviceName = mobileDeviceNameInput.value.trim();
if (!deviceName) {
setStatus("error", "请输入这台手机或录入人的名称");
return;
}
localStorage.setItem(MOBILE_DEVICE_KEY, deviceName);
renderOfflineDrafts();
setStatus("ok", `已保存手机名称:${deviceName}`);
}
function renderHistory(history) {
const runs = history.runs || [];
tableTitle.textContent = history.name ? `${history.name}` : "还没有采集结果";
runCount.textContent = `${runs.length}`;
exportLink.href = history.name ? `/api/export?name=${encodeURIComponent(history.name)}` : "#";
exportLink.setAttribute("aria-disabled", history.name && runs.length > 0 ? "false" : "true");
syncUrlInputs(history);
if (runs.length === 0) {
cards.innerHTML = `<div class="empty">暂无采集结果</div>`;
return;
}
cards.innerHTML = platformOrder.map((platform) => {
const row = history.platforms?.[platform] || { values: {} };
const latestRun = runs[runs.length - 1];
const latest = row.values?.[latestRun];
return renderPlatformCard(platform, row, latest, runs);
}).join("");
}
function renderPlatformCard(platform, row, latest, runs) {
const label = row.platform_label || platformLabels[platform] || platform;
const metric = row.metric_label || metricLabels[platform] || "指标值";
const url = row.url || latest?.url || "";
const latestHtml = renderLatest(latest);
const historyRows = runs.slice(-5).reverse().map((run) => {
const value = row.values?.[run];
const shown = value?.status === "ok" ? (value.raw || value.number || "") : statusLabel(value?.status);
return `
<div class="mini-row">
<span>${formatTime(run)}</span>
<strong>${escapeHtml(shown || "未采集")}</strong>
</div>
`;
}).join("");
return `
<article class="platform-card">
<div class="platform-row">
<div>
<div class="platform-name">${escapeHtml(label)}</div>
<div class="metric">${escapeHtml(metric)}</div>
${row.metric_description ? `<div class="metric-help">${escapeHtml(row.metric_description)}</div>` : ""}
</div>
${url ? `<a class="open-link" href="${escapeAttribute(url)}" target="_blank" rel="noreferrer">打开</a>` : ""}
</div>
${latestHtml}
<div class="mini-history">${historyRows}</div>
</article>
`;
}
function renderLatest(value) {
if (!value) return `<div class="latest-value warn">未采集</div>`;
if (value.status === "ok") {
const shown = value.raw || value.number || "";
const meta = value.number && String(value.number) !== String(value.raw) ? `标准化:${value.number}` : "";
const anomaly = value.anomaly ? `<span class="anomaly-badge">异常</span>` : "";
const credibility = renderCredibilityBadge(value.credibility);
return `
<div class="latest-value">${escapeHtml(shown)}${anomaly}</div>
${credibility}
${meta ? `<div class="meta">${escapeHtml(meta)}</div>` : ""}
${value.credibility?.reason ? `<div class="meta">${escapeHtml(value.credibility.reason)}</div>` : ""}
${value.anomaly ? `<div class="meta">${escapeHtml(value.anomaly.message || "")}</div>` : ""}
`;
}
const tone = value.status === "blocked" ? "warn" : "bad";
return `
<div class="latest-value ${tone}">${escapeHtml(statusLabel(value.status))}</div>
${renderCredibilityBadge(value.credibility)}
<div class="meta">${escapeHtml(value.error || "")}</div>
`;
}
function renderCredibilityBadge(credibility) {
if (!credibility?.label) return "";
return `<span class="credibility-badge ${escapeAttribute(credibility.level || "")}">${escapeHtml(credibility.label)}</span>`;
}
function syncUrlInputs(history) {
for (const platform of platformOrder) {
const input = urlInputs[platform];
if (!input) continue;
input.value = history.platforms?.[platform]?.url || "";
}
dirtyUrlInputs.clear();
}
function readUrlInputs() {
return Object.fromEntries(platformOrder
.filter((platform) => dirtyUrlInputs.has(platform))
.map((platform) => [
platform,
urlInputs[platform]?.value.trim() || "",
]));
}
function readAllUrlInputs() {
return Object.fromEntries(platformOrder.map((platform) => [
platform,
urlInputs[platform]?.value.trim() || "",
]));
}
function readCollectPlatforms() {
return [...collectPlatformBox.querySelectorAll("input[type='checkbox']:checked")]
.map((checkbox) => checkbox.value)
.filter((platform) => platformOrder.includes(platform));
}
function updateCollectPlatformState() {
for (const label of collectPlatformBox.querySelectorAll("label")) {
const checkbox = label.querySelector("input");
label.classList.toggle("active", checkbox.checked);
}
}
function clearUrlInputs() {
for (const input of Object.values(urlInputs)) {
input.value = "";
}
dirtyUrlInputs.clear();
}
async function getJson(url) {
const response = await fetch(apiUrl(url), { headers: authHeaders() });
const payload = await response.json();
if (handleAuthFailure(response, payload)) throw new Error(payload.error || "需要输入访问密码");
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
return payload;
}
async function postJson(url, body) {
const response = await fetch(apiUrl(url), {
method: "POST",
headers: { "content-type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
});
const payload = await response.json();
if (handleAuthFailure(response, payload)) throw new Error(payload.error || "需要输入访问密码");
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
return payload;
}
async function ensureAccessAuth() {
try {
const response = await fetch(apiUrl("/api/auth/status"), { headers: authHeaders() });
const payload = await response.json();
if (!payload.enabled || payload.authorized) {
hideAuthGate();
return true;
}
} catch {
return true;
}
showAuthGate("");
return false;
}
async function submitAccessPassword() {
const password = authPassword?.value || "";
if (!password.trim()) {
showAuthGate("请输入访问密码");
return;
}
setAuthMessage("正在验证...");
try {
const response = await fetch(apiUrl("/api/auth/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "访问密码不正确");
if (payload.token) localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, payload.token);
if (authPassword) authPassword.value = "";
hideAuthGate();
await startApp();
} catch (error) {
showAuthGate(error.message || "访问密码不正确");
}
}
function authHeaders() {
const token = localStorage.getItem(HOTNESS_AUTH_TOKEN_KEY) || "";
return token ? { "x-hotness-auth-token": token } : {};
}
function handleAuthFailure(response, payload) {
if (response.status !== 401 || !payload?.requires_auth) return false;
localStorage.removeItem(HOTNESS_AUTH_TOKEN_KEY);
showAuthGate(payload.error || "需要输入访问密码");
return true;
}
function showAuthGate(message = "") {
if (!authGate) return;
authGate.hidden = false;
setAuthMessage(message);
requestAnimationFrame(() => authPassword?.focus());
}
function hideAuthGate() {
if (authGate) authGate.hidden = true;
setAuthMessage("");
}
function setAuthMessage(message) {
if (authMessage) authMessage.textContent = message || "";
}
function setBusy(isBusy, text = "") {
button.disabled = isBusy;
if (isBusy) setStatus("busy", text);
}
function setStatus(type, text) {
statusDot.className = `dot ${type}`;
statusText.textContent = text;
}
function statusLabel(status) {
return {
no_match: "未找到",
blocked: "被拦截",
error: "错误",
}[status] || status || "";
}
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}