845 lines
28 KiB
JavaScript
845 lines
28 KiB
JavaScript
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 authSubmit = document.querySelector("#auth-submit");
|
||
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;
|
||
let authSubmitting = false;
|
||
|
||
authForm?.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
await submitAccessPassword();
|
||
});
|
||
|
||
authSubmit?.addEventListener("click", 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() {
|
||
if (authSubmitting) return;
|
||
const password = authPassword?.value || "";
|
||
if (!password.trim()) {
|
||
showAuthGate("请输入访问密码");
|
||
return;
|
||
}
|
||
authSubmitting = true;
|
||
if (authSubmit) authSubmit.disabled = true;
|
||
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);
|
||
window.dispatchEvent(new CustomEvent("hotness:auth-updated"));
|
||
if (authPassword) authPassword.value = "";
|
||
setAuthMessage("登录成功,正在进入...");
|
||
hideAuthGate();
|
||
await startApp();
|
||
} catch (error) {
|
||
showAuthGate(error.message || "访问密码不正确");
|
||
} finally {
|
||
authSubmitting = false;
|
||
if (authSubmit) authSubmit.disabled = false;
|
||
}
|
||
}
|
||
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function escapeAttribute(value) {
|
||
return escapeHtml(value).replace(/`/g, "`");
|
||
}
|