2219 lines
80 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 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 runBulkButton = document.querySelector("#run-bulk-button");
const runBulkBar = document.querySelector("#run-bulk-bar");
const runDeleteSelected = document.querySelector("#run-delete-selected");
const runCancelBulk = document.querySelector("#run-cancel-bulk");
const runCollapseToggle = document.querySelector("#run-collapse-toggle");
const runCollapseNote = document.querySelector("#run-collapse-note");
const table = document.querySelector("#hotness-table");
const programList = document.querySelector("#program-list");
const collectHistoryButton = document.querySelector("#collect-history-button");
const retryPendingButton = document.querySelector("#retry-pending-button");
const temporaryFileInput = document.querySelector("#temporary-file-input");
const temporaryQueryText = document.querySelector("#temporary-query-text");
const temporaryQueryButton = document.querySelector("#temporary-query-button");
const temporaryExportButton = document.querySelector("#temporary-export-button");
const temporarySaveLinks = document.querySelector("#temporary-save-links");
const temporaryQueryResult = document.querySelector("#temporary-query-result");
const historyBulkButton = document.querySelector("#history-bulk-button");
const historyBulkBar = document.querySelector("#history-bulk-bar");
const historyCollectSelected = document.querySelector("#history-collect-selected");
const historyDeleteSelected = document.querySelector("#history-delete-selected");
const historyCancelBulk = document.querySelector("#history-cancel-bulk");
const filterBar = document.querySelector(".platform-filters");
const collectPlatformBox = document.querySelector(".collect-platforms");
const collectPlatformAll = document.querySelector("#collect-platform-all");
const aliasInput = document.querySelector("#alias-input");
const saveLibraryButton = document.querySelector("#save-library-button");
const libraryStatus = document.querySelector("#library-status");
const linkCandidates = document.querySelector("#link-candidates");
const trendCharts = document.querySelector("#trend-charts");
const compareList = document.querySelector("#compare-list");
const comparePlatform = document.querySelector("#compare-platform");
const compareRange = document.querySelector("#compare-range");
const compareChart = document.querySelector("#compare-chart");
const compareTable = document.querySelector("#compare-table");
const detailDialog = document.querySelector("#detail-dialog");
const detailTitle = document.querySelector("#detail-title");
const detailBody = document.querySelector("#detail-body");
const dashboardProgramCount = document.querySelector("#dashboard-program-count");
const dashboardLastCapture = document.querySelector("#dashboard-last-capture");
const dashboardPendingCount = document.querySelector("#dashboard-pending-count");
const taskQueuePanel = document.querySelector("#task-queue-panel");
const taskCurrent = document.querySelector("#task-current");
const taskRatio = document.querySelector("#task-ratio");
const taskProgressFill = document.querySelector("#task-progress-fill");
const taskOkCount = document.querySelector("#task-ok-count");
const taskMissingCount = document.querySelector("#task-missing-count");
const taskErrorCount = document.querySelector("#task-error-count");
const mobileSyncList = document.querySelector("#mobile-sync-list");
const dutyAutoRetry = document.querySelector("#duty-auto-retry");
const dutyAutoCollect = document.querySelector("#duty-auto-collect");
const dutyAutoExport = document.querySelector("#duty-auto-export");
const dutyRunTime = document.querySelector("#duty-run-time");
const dutySaveSettings = document.querySelector("#duty-save-settings");
const dutyRunNow = document.querySelector("#duty-run-now");
const dutyStatus = document.querySelector("#duty-status");
const appStatusPort = document.querySelector("#app-status-port");
const appStatusPortDock = document.querySelector("#app-status-port-dock");
const appStatusText = document.querySelector("#app-status-text");
const APP_BUILD_LABEL = "桌面开发版";
const VISIBLE_RECENT_RUNS = 12;
const urlInputs = {
tencent: document.querySelector("#url-tencent"),
youku: document.querySelector("#url-youku"),
iqiyi: document.querySelector("#url-iqiyi"),
mgtv: document.querySelector("#url-mgtv"),
};
const platformOrder = ["tencent", "youku", "iqiyi", "mgtv"];
const platformLabels = {
tencent: "腾讯视频",
youku: "优酷",
iqiyi: "爱奇艺",
mgtv: "芒果TV",
};
const metricLabels = {
tencent: "热度值",
youku: "热度值",
iqiyi: "内容热度",
mgtv: "播放次数",
};
let activeName = "";
let activeHistory = null;
let selectedPlatforms = new Set(platformOrder);
let collectPlatforms = new Set(platformOrder);
let dirtyUrlInputs = new Set();
let programsCache = [];
let historyBulkMode = false;
let historyDeleteMode = false;
let selectedHistoryPrograms = new Set();
let runBulkMode = false;
let selectedRuns = new Set();
let showAllRuns = false;
let compareNames = new Set();
let compareHistories = new Map();
let resolveTimer = 0;
let resolveRequestId = 0;
let temporaryQueryItems = [];
let appStarted = false;
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();
}
scheduleResolveLinks(name);
});
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}`);
const summary = { total: 1, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 };
updateTaskQueue({ active: true, current: `正在采集:${name}`, completed: 0, total: 1, summary });
try {
const currentUrls = readUrlInputs();
const missingSelectedUrl = platforms.some((platform) => !currentUrls[platform]);
if (missingSelectedUrl) await resolveLinks(name);
const payload = await postJson("/api/collect", { name, urls: readUrlInputs(), platforms });
countCollection(summary, payload.collection);
updateTaskQueue({ active: true, current: `已完成:${name}`, completed: 1, total: 1, summary });
renderHistory(payload.history);
await refreshPrograms();
setStatus("ok", `已新增 ${formatTime(payload.collection.captured_at)} 这一列`);
} catch (error) {
summary.error += 1;
updateTaskQueue({ active: true, current: `采集失败:${name}`, completed: 1, total: 1, summary });
setStatus("error", error.message);
} finally {
setBusy(false);
updateTaskQueue({ active: false, current: "采集任务完成", completed: 1, total: 1, summary });
}
});
collectPlatformBox.addEventListener("change", (event) => {
if (!event.target.matches("input[type='checkbox']")) return;
collectPlatforms = new Set(readCollectPlatforms());
updateCollectPlatformState();
});
collectPlatformAll.addEventListener("click", () => {
const shouldSelectAll = readCollectPlatforms().length !== platformOrder.length;
for (const checkbox of collectPlatformBox.querySelectorAll("input[type='checkbox']")) {
checkbox.checked = shouldSelectAll;
}
collectPlatforms = new Set(readCollectPlatforms());
updateCollectPlatformState();
});
saveLibraryButton.addEventListener("click", async () => {
const name = input.value.trim() || activeName;
if (!name) {
setStatus("error", "请先输入节目名");
return;
}
saveLibraryButton.disabled = true;
libraryStatus.textContent = "正在保存";
try {
const payload = await postJson("/api/link-library", {
name,
aliases: aliasInput.value,
urls: readAllUrlInputs(),
});
syncLibraryInputs(payload.entry);
setStatus("ok", `已保存《${name}》链接库`);
libraryStatus.textContent = "已保存";
} catch (error) {
setStatus("error", error.message);
libraryStatus.textContent = "保存失败";
} finally {
saveLibraryButton.disabled = false;
}
});
collectHistoryButton.addEventListener("click", async () => {
const names = programsCache.map((program) => program.name).filter(Boolean);
if (names.length === 0) {
setStatus("error", "暂无历史节目可采集");
return;
}
const ok = window.confirm(`确定按当前勾选平台采集全部 ${names.length} 个历史节目吗?`);
if (!ok) return;
await collectHistoryPrograms(names);
});
retryPendingButton.addEventListener("click", async () => {
const platforms = readCollectPlatforms();
if (platforms.length === 0) {
setStatus("error", "请至少选择一个复查平台");
return;
}
const ok = window.confirm("确定复查历史里未匹配、无指标、风控或错误的平台吗?\n只会重试这些失败平台已正常的平台不会重复采集。");
if (!ok) return;
setBulkBusy(true);
setStatus("busy", "正在复查无数据平台");
try {
const payload = await postJson("/api/retry-pending", { platforms });
const summary = { total: payload.items.length, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 };
let lastHistory = null;
for (const item of payload.items) {
lastHistory = item.history || lastHistory;
countCollection(summary, item.collection);
}
await refreshPrograms();
if (activeName) {
await loadHistory(activeName);
} else if (lastHistory) {
renderHistory(lastHistory);
}
setStatus("ok", pendingRetrySummaryText(summary));
} catch (error) {
setStatus("error", error.message);
} finally {
setBulkBusy(false);
}
});
historyCollectSelected.addEventListener("click", async () => {
const names = [...selectedHistoryPrograms];
if (names.length === 0) {
setStatus("error", "请先勾选要采集的历史节目");
return;
}
const ok = window.confirm(`确定按当前勾选平台采集选中的 ${names.length} 个历史节目吗?`);
if (!ok) return;
await collectHistoryPrograms(names);
});
temporaryQueryButton.addEventListener("click", async () => {
const names = temporaryNames();
const platforms = readCollectPlatforms();
if (names.length === 0) {
setStatus("error", "请先粘贴要临时查询的节目名");
return;
}
if (platforms.length === 0) {
setStatus("error", "请至少选择一个查询平台");
return;
}
temporaryQueryButton.disabled = true;
temporaryExportButton.disabled = true;
temporaryQueryResult.classList.add("empty");
temporaryQueryResult.textContent = `正在临时查询 ${names.length} 个节目`;
setStatus("busy", `正在临时查询 ${names.length} 个节目`);
try {
await runTemporaryQueryProgressively(names, platforms);
setStatus("ok", `临时查询完成:${temporaryQueryItems.length} 个节目`);
} catch (error) {
setStatus("error", error.message);
temporaryQueryResult.textContent = error.message;
} finally {
temporaryQueryButton.disabled = false;
temporaryExportButton.disabled = temporaryQueryItems.length === 0;
}
});
temporaryFileInput.addEventListener("change", async () => {
const file = temporaryFileInput.files?.[0];
if (!file) return;
try {
temporaryQueryText.value = await loadTemporaryImportFile(file);
setStatus("ok", `已导入 ${file.name}`);
} catch (error) {
setStatus("error", `导入失败:${error.message}`);
} finally {
temporaryFileInput.value = "";
}
});
temporaryQueryText.addEventListener("dragover", (event) => {
event.preventDefault();
temporaryQueryText.classList.add("drag-over");
});
temporaryQueryText.addEventListener("dragleave", () => {
temporaryQueryText.classList.remove("drag-over");
});
temporaryQueryText.addEventListener("drop", async (event) => {
event.preventDefault();
temporaryQueryText.classList.remove("drag-over");
const file = event.dataTransfer?.files?.[0];
const text = event.dataTransfer?.getData("text/plain") || "";
try {
temporaryQueryText.value = file
? await loadTemporaryImportFile(file)
: normalizeTemporaryListText(text);
setStatus("ok", file ? `已导入 ${file.name}` : "已导入拖入文本");
} catch (error) {
setStatus("error", `导入失败:${error.message}`);
}
});
temporaryExportButton.addEventListener("click", () => {
downloadTemporaryCsv(temporaryQueryItems);
});
historyBulkButton.addEventListener("click", () => {
if (historyBulkMode || historyDeleteMode || selectedHistoryPrograms.size) {
clearHistorySelection();
return;
}
historyBulkMode = true;
selectedHistoryPrograms = new Set(programsCache.map((program) => program.name));
renderPrograms(programsCache);
});
historyCancelBulk.addEventListener("click", () => {
clearHistorySelection();
});
historyDeleteSelected.addEventListener("click", async () => {
if (!historyDeleteMode) {
historyDeleteMode = true;
renderPrograms(programsCache);
return;
}
const names = [...selectedHistoryPrograms];
if (names.length === 0) {
setStatus("error", "请先勾选要删除的历史节目");
return;
}
const ok = window.confirm(`确定彻底删除选中的 ${names.length} 个历史节目及其全部采集记录吗?`);
if (!ok) return;
const deleteLibrary = window.confirm("是否同时删除这些节目的链接库?\n确定历史和链接库都删除\n取消只删除历史记录");
setStatus("busy", `正在删除 ${names.length} 个历史节目`);
historyDeleteSelected.disabled = true;
try {
const payload = await postJson("/api/delete-programs", { names, deleteLibrary });
for (const name of names) {
compareNames.delete(name);
compareHistories.delete(name);
}
if (names.includes(activeName)) {
activeName = "";
activeHistory = payload.history;
clearUrlInputs();
renderHistory(payload.history);
input.value = "";
}
historyBulkMode = false;
historyDeleteMode = false;
selectedHistoryPrograms.clear();
programsCache = payload.programs || [];
renderPrograms(programsCache);
renderCompareList(programsCache);
await renderCompare();
setStatus("ok", deleteLibrary ? `已删除 ${names.length} 个节目历史和链接库` : `已删除 ${names.length} 个节目历史`);
} catch (error) {
setStatus("error", error.message);
} finally {
historyDeleteSelected.disabled = false;
}
});
programList.addEventListener("click", async (event) => {
const checkbox = event.target.closest("[data-select-program]");
if (checkbox) {
const name = checkbox.dataset.selectProgram;
if (checkbox.checked) selectedHistoryPrograms.add(name);
else selectedHistoryPrograms.delete(name);
updateHistoryBulkBar();
return;
}
const deleteButton = event.target.closest("[data-delete-program]");
if (deleteButton) {
const name = deleteButton.dataset.deleteProgram;
if (!name) return;
const ok = window.confirm(`确定删除《${name}》的全部历史记录吗?`);
if (!ok) return;
const deleteLibrary = window.confirm("是否同时删除这个节目的链接库?\n确定历史和链接库都删除\n取消只删除历史记录");
setStatus("busy", `正在删除《${name}`);
try {
const payload = await postJson("/api/delete-program", { name, deleteLibrary });
if (activeName === name) {
activeName = "";
activeHistory = payload.history;
clearUrlInputs();
renderHistory(payload.history);
input.value = "";
}
programsCache = payload.programs || [];
renderPrograms(programsCache);
renderCompareList(programsCache);
compareNames.delete(name);
compareHistories.delete(name);
await renderCompare();
setStatus("ok", deleteLibrary ? `已删除《${name}》历史和链接库` : `已删除《${name}》历史`);
} catch (error) {
setStatus("error", error.message);
}
return;
}
const item = event.target.closest("[data-name]");
if (!item) return;
activeName = item.dataset.name;
input.value = activeName;
await loadHistory(activeName);
});
filterBar.addEventListener("click", (event) => {
const button = event.target.closest("[data-platform-filter]");
if (!button) return;
const platform = button.dataset.platformFilter;
if (platform === "all") {
selectedPlatforms = new Set(platformOrder);
} else if (selectedPlatforms.has(platform)) {
selectedPlatforms.delete(platform);
} else {
selectedPlatforms.add(platform);
}
if (selectedPlatforms.size === 0) selectedPlatforms = new Set(platformOrder);
syncFilterButtons();
if (activeHistory) renderHistory(activeHistory);
});
table.addEventListener("click", async (event) => {
const expandButton = event.target.closest("[data-expand-runs]");
if (expandButton) {
showAllRuns = true;
if (activeHistory) renderHistory(activeHistory);
return;
}
const detailButton = event.target.closest("[data-detail-run]");
if (detailButton) {
showDetail(detailButton.dataset.detailPlatform, detailButton.dataset.detailRun);
return;
}
const checkbox = event.target.closest("[data-select-run]");
if (checkbox) {
const run = checkbox.dataset.selectRun;
if (checkbox.checked) selectedRuns.add(run);
else selectedRuns.delete(run);
updateRunBulkBar();
return;
}
const button = event.target.closest("[data-delete-run]");
if (!button || !activeHistory?.name) return;
const run = button.dataset.deleteRun;
const shownTime = formatTime(run);
const ok = window.confirm(`确定删除《${activeHistory.name}》在 ${shownTime} 的整列数据吗?`);
if (!ok) return;
setStatus("busy", `正在删除 ${shownTime} 这一列`);
try {
const payload = await postJson("/api/delete-run", { name: activeHistory.name, run });
renderHistory(payload.history);
await refreshPrograms();
setStatus("ok", `已删除 ${shownTime} 这一列`);
} catch (error) {
setStatus("error", error.message);
}
});
runBulkButton.addEventListener("click", () => {
if (!activeHistory?.name || (activeHistory.runs || []).length === 0) {
setStatus("error", "当前节目暂无可删除的时间列");
return;
}
runBulkMode = true;
selectedRuns.clear();
renderHistory(activeHistory);
});
runCancelBulk.addEventListener("click", () => {
runBulkMode = false;
selectedRuns.clear();
renderHistory(activeHistory);
});
runCollapseToggle.addEventListener("click", () => {
showAllRuns = !showAllRuns;
if (activeHistory) renderHistory(activeHistory);
});
runDeleteSelected.addEventListener("click", async () => {
const runs = [...selectedRuns];
if (!activeHistory?.name) return;
if (runs.length === 0) {
setStatus("error", "请先勾选要删除的时间列");
return;
}
const ok = window.confirm(`确定删除《${activeHistory.name}》选中的 ${runs.length} 个时间列吗?`);
if (!ok) return;
setStatus("busy", `正在删除 ${runs.length} 个时间列`);
runDeleteSelected.disabled = true;
try {
const payload = await postJson("/api/delete-runs", { name: activeHistory.name, runs });
runBulkMode = false;
selectedRuns.clear();
renderHistory(payload.history);
await refreshPrograms();
setStatus("ok", `已删除 ${runs.length} 个时间列`);
} catch (error) {
setStatus("error", error.message);
} finally {
runDeleteSelected.disabled = false;
}
});
detailDialog.addEventListener("click", async (event) => {
const button = event.target.closest("[data-use-candidate-url]");
if (!button) return;
const platform = button.dataset.useCandidatePlatform;
const url = button.dataset.useCandidateUrl;
const input = urlInputs[platform];
if (!input || !url) return;
input.value = url;
dirtyUrlInputs.add(platform);
detailDialog.close();
setStatus("ok", `已填入${platformLabels[platform] || platform}链接,可保存链接库或直接重新采集`);
});
linkCandidates.addEventListener("click", (event) => {
const button = event.target.closest("[data-fill-platform]");
if (!button) return;
const platform = button.dataset.fillPlatform;
const url = button.dataset.fillUrl;
fillPlatformUrl(platform, url, { manual: true });
setStatus("ok", `已采用${platformLabels[platform] || platform}候选链接`);
});
compareList.addEventListener("change", async (event) => {
const checkbox = event.target.closest("[data-compare-name]");
if (!checkbox) return;
if (checkbox.checked) compareNames.add(checkbox.dataset.compareName);
else compareNames.delete(checkbox.dataset.compareName);
await renderCompare();
});
comparePlatform.addEventListener("change", () => {
renderCompare();
});
compareRange.addEventListener("change", () => {
renderCompare();
});
mobileSyncList?.addEventListener("click", (event) => {
const button = event.target.closest("[data-mobile-sync-name]");
if (!button) return;
input.value = button.dataset.mobileSyncName || "";
activeName = input.value.trim();
fillMobileSyncUrls(button.dataset.mobileSyncUrls || "{}");
scheduleResolveLinks(activeName);
setStatus("ok", `已填入手机同步节目:${activeName}`);
window.location.hash = "collect-form";
input.focus();
});
dutySaveSettings?.addEventListener("click", () => {
saveDutySettings();
});
dutyRunNow?.addEventListener("click", () => {
runDutyNow();
});
consumeRedirectedAccessToken();
initializeApp();
document.addEventListener("hotness:programs-changed", refreshPrograms);
async function initializeApp() {
if (!(await ensureAccessAuth())) return;
startApp();
}
function consumeRedirectedAccessToken() {
const params = new URLSearchParams(window.location.search);
const token = params.get("access_token");
if (!token) return;
localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, token);
params.delete("access_token");
const search = params.toString();
const cleanUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
history.replaceState(null, "", cleanUrl || "/");
}
function startApp() {
if (appStarted) return;
appStarted = true;
updateCollectPlatformState();
renderAppStatusDock();
refreshPrograms();
loadMobileSyncDrafts();
loadDutySettings();
}
async function loadHistory(name) {
setStatus("busy", `正在读取《${name}》历史`);
try {
const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`);
renderHistory(payload.history);
await loadLinkLibrary(name);
setStatus("ok", `已载入《${name}`);
} catch (error) {
setStatus("error", error.message);
}
}
async function refreshPrograms() {
try {
const payload = await getJson("/api/programs");
programsCache = payload.programs || [];
renderPrograms(programsCache);
renderCompareList(programsCache);
renderDesktopDashboard();
} catch {
programsCache = [];
renderPrograms([]);
renderCompareList([]);
renderDesktopDashboard();
}
}
async function loadMobileSyncDrafts() {
if (!mobileSyncList) return;
try {
const payload = await getJson("/api/mobile-sync");
renderMobileSyncDrafts(payload.items || []);
} catch {
renderMobileSyncDrafts([]);
}
}
function renderMobileSyncDrafts(items) {
if (!mobileSyncList) return;
const pendingItems = (items || []).filter((item) => item.status !== "done");
if (pendingItems.length === 0) {
mobileSyncList.classList.add("empty");
mobileSyncList.textContent = "暂无手机同步记录";
return;
}
mobileSyncList.classList.remove("empty");
mobileSyncList.innerHTML = pendingItems.slice(0, 30).map((item) => {
const urls = item.urls || {};
const urlCount = Object.values(urls).filter(Boolean).length;
const platformText = (item.platforms || []).map((platform) => platformLabels[platform] || platform).join("、") || "未选平台";
const note = item.note ? `<div class="mobile-sync-note">${escapeHtml(item.note)}</div>` : "";
return `
<article class="mobile-sync-item">
<div class="mobile-sync-main">
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.device_name || "mobile")} · ${formatTime(item.received_at || item.created_at)}</span>
</div>
<div class="mobile-sync-meta">${urlCount} 个链接 · ${escapeHtml(platformText)}</div>
${note}
<button class="mini-button" type="button" data-mobile-sync-name="${escapeAttribute(item.name)}" data-mobile-sync-urls="${escapeAttribute(JSON.stringify(urls))}">填入采集栏</button>
</article>
`;
}).join("");
}
function fillMobileSyncUrls(serializedUrls) {
let urls = {};
try {
urls = JSON.parse(serializedUrls);
} catch {
urls = {};
}
dirtyUrlInputs.clear();
for (const platform of platformOrder) {
const value = String(urls?.[platform] || "").trim();
urlInputs[platform].value = value;
if (value) dirtyUrlInputs.add(platform);
}
}
async function loadDutySettings() {
if (!dutyStatus) return;
try {
const payload = await getJson("/api/duty-settings");
applyDutySettings(payload.settings || {});
renderDutyStatus(payload.status || null);
} catch {
dutyStatus.textContent = "值班设置读取失败";
}
}
async function saveDutySettings() {
const settings = readDutySettingsForm();
dutySaveSettings.disabled = true;
try {
const payload = await postJson("/api/duty-settings", { settings });
applyDutySettings(payload.settings || settings);
renderDutyStatus(payload.status || null);
setStatus("ok", "已保存值班设置");
} catch (error) {
setStatus("error", error.message);
} finally {
dutySaveSettings.disabled = false;
}
}
async function runDutyNow() {
dutyRunNow.disabled = true;
dutyStatus.textContent = "正在执行值班任务";
try {
const payload = await postJson("/api/duty-run", { settings: readDutySettingsForm() });
renderDutyStatus(payload.status || null);
await refreshPrograms();
if (activeName) await loadHistory(activeName);
setStatus("ok", "值班任务执行完成");
} catch (error) {
dutyStatus.textContent = `值班执行失败:${error.message}`;
setStatus("error", error.message);
} finally {
dutyRunNow.disabled = false;
}
}
function readDutySettingsForm() {
return {
autoRetry: Boolean(dutyAutoRetry?.checked),
autoCollect: Boolean(dutyAutoCollect?.checked),
autoExport: Boolean(dutyAutoExport?.checked),
runTime: dutyRunTime?.value || "09:30",
};
}
function applyDutySettings(settings) {
dutyAutoRetry.checked = Boolean(settings.autoRetry);
dutyAutoCollect.checked = Boolean(settings.autoCollect);
dutyAutoExport.checked = Boolean(settings.autoExport);
dutyRunTime.value = settings.runTime || "09:30";
}
function renderDutyStatus(status) {
if (!dutyStatus) return;
if (!status?.last_run_at) {
dutyStatus.textContent = "尚未执行值班任务";
return;
}
const exportText = status.export_path ? ` · 已导出 ${status.export_path}` : "";
dutyStatus.textContent = `上次执行 ${formatTime(status.last_run_at)} · 复查 ${status.retry_count || 0} · 采集 ${status.collect_count || 0}${exportText}`;
}
async function loadLinkLibrary(name) {
try {
const payload = await getJson(`/api/link-library?name=${encodeURIComponent(name)}`);
syncLibraryInputs(payload.entry);
} catch {
syncLibraryInputs(null);
}
}
function scheduleResolveLinks(name) {
window.clearTimeout(resolveTimer);
const cleanName = String(name || "").trim();
if (!cleanName) {
renderLinkCandidates(null);
return;
}
resolveTimer = window.setTimeout(() => {
resolveLinks(cleanName);
}, 650);
}
async function resolveLinks(name) {
const requestId = ++resolveRequestId;
libraryStatus.textContent = "正在自动搜索链接";
renderLinkCandidates({ loading: true });
try {
const payload = await getJson(`/api/resolve-links?name=${encodeURIComponent(name)}`);
if (requestId !== resolveRequestId || input.value.trim() !== name) return;
for (const platform of platformOrder) {
const result = payload.results?.[platform];
if (!result?.url) continue;
fillPlatformUrl(platform, result.url, { manual: false });
}
renderLinkCandidates(payload.results || {});
libraryStatus.textContent = "已自动匹配链接";
} catch (error) {
if (requestId !== resolveRequestId) return;
renderLinkCandidates(null);
libraryStatus.textContent = "自动搜索失败";
setStatus("error", error.message);
}
}
function fillPlatformUrl(platform, url, { manual }) {
const input = urlInputs[platform];
if (!input || !url) return;
if (!manual && dirtyUrlInputs.has(platform)) return;
input.value = url;
if (manual) dirtyUrlInputs.add(platform);
}
function renderLinkCandidates(results) {
if (!linkCandidates) return;
if (!results) {
linkCandidates.innerHTML = "";
return;
}
if (results.loading) {
linkCandidates.innerHTML = "";
return;
}
const matchedPlatforms = platformOrder.filter((platform) => (results[platform]?.candidates || []).length);
if (matchedPlatforms.length === 0) {
linkCandidates.innerHTML = "";
return;
}
linkCandidates.innerHTML = matchedPlatforms.map((platform) => {
const result = results[platform];
const candidates = result?.candidates || [];
return `
<section class="link-candidate-card">
<div class="link-candidate-head">
<strong>${escapeHtml(platformLabels[platform] || platform)}</strong>
<span>${escapeHtml(candidateSourceLabel(result))}</span>
</div>
<div class="link-candidate-list">
${candidates.slice(0, 4).map((candidate, index) => `
<button type="button" data-fill-platform="${escapeAttribute(platform)}" data-fill-url="${escapeAttribute(candidate.url)}">
<span>${index === 0 ? "推荐" : `候选${index + 1}`}</span>
<small>${escapeHtml(candidate.pageTitle || candidate.url)}</small>
</button>
`).join("")}
</div>
</section>
`;
}).join("");
}
function candidateSourceLabel(result) {
if (!result?.url) return "未匹配";
return {
builtin: "内置",
library: "链接库",
history: "历史",
search: "搜索",
}[result.source] || "已匹配";
}
function renderPrograms(programs) {
if (programs.length === 0) {
programList.innerHTML = `<div class="empty">暂无历史</div>`;
updateHistoryBulkBar();
return;
}
programList.innerHTML = programs.map((program) => `
<div class="program-item-row ${program.name === activeName ? "active" : ""} ${historyBulkMode && !historyDeleteMode ? "bulk" : ""}">
<label class="program-select"><input data-select-program="${escapeAttribute(program.name)}" type="checkbox" ${selectedHistoryPrograms.has(program.name) ? "checked" : ""} aria-label="选择 ${escapeAttribute(program.name)}"></label>
<button class="program-item" data-name="${escapeAttribute(program.name)}" type="button" title="${escapeAttribute(program.name)}">
<span class="program-name-text">${escapeHtml(program.name)}</span>
</button>
${historyDeleteMode ? `<button class="delete-program" data-delete-program="${escapeAttribute(program.name)}" type="button" title="删除这个历史节目">删除</button>` : ""}
</div>
`).join("");
updateHistoryBulkBar();
}
function renderDesktopDashboard() {
if (!dashboardProgramCount) return;
dashboardProgramCount.textContent = String(programsCache.length || 0);
const latest = programsCache.find((program) => program.updated_at)?.updated_at || "";
dashboardLastCapture.textContent = latest ? formatTime(latest) : "--";
dashboardPendingCount.textContent = activeHistory ? String(countPendingResults(activeHistory)) : "--";
}
function countPendingResults(history) {
let count = 0;
for (const row of Object.values(history?.platforms || {})) {
for (const value of Object.values(row?.values || {})) {
if (["blocked", "no_match", "no_metric", "error"].includes(value?.status)) count += 1;
}
}
return count;
}
function updateTaskQueue({ active = false, current = "", completed = 0, total = 0, summary = null } = {}) {
if (!taskQueuePanel) return;
const safeTotal = Math.max(0, Number(total) || 0);
const safeCompleted = Math.min(Math.max(0, Number(completed) || 0), safeTotal);
const percent = safeTotal ? Math.round((safeCompleted / safeTotal) * 100) : 0;
taskQueuePanel.classList.toggle("idle", !active && safeCompleted === 0);
taskCurrent.textContent = current || (active ? "正在准备采集任务" : "暂无运行中的采集任务");
taskRatio.textContent = `${safeCompleted}/${safeTotal}`;
taskProgressFill.style.width = `${percent}%`;
taskOkCount.textContent = String(summary?.ok || 0);
taskMissingCount.textContent = String((summary?.no_match || 0) + (summary?.no_metric || 0));
taskErrorCount.textContent = String((summary?.blocked || 0) + (summary?.error || 0));
}
async function collectHistoryPrograms(names) {
const platforms = readCollectPlatforms();
if (platforms.length === 0) {
setStatus("error", "请至少选择一个采集平台");
return;
}
setBulkBusy(true);
const summary = { total: names.length, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 };
let lastHistory = null;
updateTaskQueue({ active: true, current: "正在准备历史节目采集", completed: 0, total: names.length, summary });
try {
for (const [index, name] of names.entries()) {
updateTaskQueue({ active: true, current: `正在采集:${name}`, completed: index, total: names.length, summary });
setStatus("busy", `正在采集 ${index + 1}/${names.length}:《${name}`);
try {
const payload = await postJson("/api/collect", { name, urls: {}, platforms });
lastHistory = payload.history;
countCollection(summary, payload.collection);
if (activeName === name) renderHistory(payload.history);
} catch {
summary.error += 1;
}
updateTaskQueue({ active: true, current: `已完成:${name}`, completed: index + 1, total: names.length, summary });
}
await refreshPrograms();
if (activeName) {
await loadHistory(activeName);
} else if (lastHistory) {
renderHistory(lastHistory);
}
setStatus("ok", bulkSummaryText(summary));
} finally {
setBulkBusy(false);
updateTaskQueue({ active: false, current: "采集任务完成", completed: names.length, total: names.length, summary });
}
}
function setBulkBusy(isBusy) {
collectHistoryButton.disabled = isBusy;
retryPendingButton.disabled = isBusy;
historyBulkButton.disabled = isBusy;
historyCollectSelected.disabled = isBusy;
historyDeleteSelected.disabled = isBusy;
runBulkButton.disabled = isBusy;
button.disabled = isBusy;
}
function updateRunBulkBar() {
if (!runBulkBar) return;
const hasRuns = Boolean(activeHistory?.name && (activeHistory.runs || []).length);
runBulkButton.hidden = runBulkMode || !hasRuns;
runBulkBar.hidden = !runBulkMode;
runDeleteSelected.textContent = selectedRuns.size ? `删除选中列(${selectedRuns.size})` : "删除选中列";
}
function updateHistoryBulkBar() {
if (!historyBulkBar) return;
historyBulkMode = selectedHistoryPrograms.size > 0 || historyDeleteMode;
historyBulkBar.hidden = !historyBulkMode;
historyBulkButton.hidden = false;
historyBulkButton.textContent = historyBulkMode ? "取消选择" : "批量选择";
historyCollectSelected.textContent = selectedHistoryPrograms.size ? `采集选中(${selectedHistoryPrograms.size})` : "采集选中";
historyDeleteSelected.textContent = historyDeleteMode
? (selectedHistoryPrograms.size ? `确认删除(${selectedHistoryPrograms.size})` : "确认删除")
: (selectedHistoryPrograms.size ? `删除选中(${selectedHistoryPrograms.size})` : "删除选中");
}
function clearHistorySelection() {
historyBulkMode = false;
historyDeleteMode = false;
selectedHistoryPrograms.clear();
renderPrograms(programsCache);
}
function countCollection(summary, collection) {
const results = collection?.results || [];
if (results.some((result) => result.status === "ok")) summary.ok += 1;
for (const result of results) {
if (result.status === "blocked") summary.blocked += 1;
else if (result.status === "no_match") summary.no_match += 1;
else if (result.status === "no_metric") summary.no_metric += 1;
else if (result.status === "error") summary.error += 1;
}
}
function bulkSummaryText(summary) {
const issues = [
summary.blocked ? `风控 ${summary.blocked}` : "",
summary.no_match ? `未匹配 ${summary.no_match}` : "",
summary.no_metric ? `无指标 ${summary.no_metric}` : "",
summary.error ? `错误 ${summary.error}` : "",
].filter(Boolean).join("");
return `历史节目采集完成:${summary.ok}/${summary.total} 个节目有有效数据${issues ? `${issues}` : ""}`;
}
function pendingRetrySummaryText(summary) {
if (summary.total === 0) return "没有需要复查的无数据平台";
const issues = [
summary.blocked ? `仍被风控 ${summary.blocked}` : "",
summary.no_match ? `仍未匹配 ${summary.no_match}` : "",
summary.no_metric ? `仍无指标 ${summary.no_metric}` : "",
summary.error ? `错误 ${summary.error}` : "",
].filter(Boolean).join("");
return `复查完成:${summary.ok}/${summary.total} 个节目恢复有效数据${issues ? `${issues}` : ""}`;
}
function temporaryNames() {
return [...new Set(normalizeTemporaryListText(temporaryQueryText.value)
.split(/[\r\n]+/)
.map((name) => name.trim())
.filter(Boolean))];
}
async function runTemporaryQueryProgressively(names, platforms) {
temporaryQueryItems = names.map((name) => ({
name,
collection: {
name,
captured_at: new Date().toISOString(),
results: [],
},
}));
renderTemporaryResults(temporaryQueryItems);
const tasks = temporaryQueryTasks(names, platforms);
const summary = { total: tasks.length, ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 };
let completed = 0;
updateTaskQueue({ active: true, current: "正在准备临时查询", completed, total: tasks.length, summary });
await clientMapLimit(tasks, 6, async (task) => {
let payload = null;
updateTaskQueue({ active: true, current: `临时查询:${task.name} / ${platformLabels[task.platform] || task.platform}`, completed, total: tasks.length, summary });
try {
payload = await postJson("/api/query-once", {
names: [task.name],
platforms: [task.platform],
saveLinks: temporarySaveLinks.checked,
});
mergeTemporaryQueryResult(task.name, payload.items?.[0], task.platform);
} catch (error) {
mergeTemporaryQueryResult(task.name, {
collection: {
captured_at: new Date().toISOString(),
results: [temporaryPlatformError(task, error)],
},
}, task.platform);
} finally {
completed += 1;
Object.assign(summary, summarizeTemporaryTaskQueue());
renderTemporaryResults(temporaryQueryItems);
temporaryExportButton.disabled = temporaryRows(temporaryQueryItems).length === 0;
updateTaskQueue({ active: true, current: `临时查询进度 ${completed}/${tasks.length}`, completed, total: tasks.length, summary });
setStatus("busy", `临时查询进度 ${completed}/${tasks.length}`);
}
});
updateTaskQueue({ active: false, current: "临时查询完成", completed, total: tasks.length, summary });
}
function temporaryQueryTasks(names, platforms) {
return names.flatMap((name) => platforms.map((platform) => ({ name, platform })));
}
function summarizeTemporaryTaskQueue() {
const summary = { ok: 0, blocked: 0, no_match: 0, no_metric: 0, error: 0 };
for (const item of temporaryQueryItems) {
for (const result of item.collection?.results || []) {
if (result.status === "ok") summary.ok += 1;
else if (result.status === "blocked") summary.blocked += 1;
else if (result.status === "no_match") summary.no_match += 1;
else if (result.status === "no_metric") summary.no_metric += 1;
else if (result.status === "error") summary.error += 1;
}
}
return summary;
}
function mergeTemporaryQueryResult(name, item, platform) {
const target = temporaryQueryItems.find((entry) => entry.name === name);
if (!target) return;
const incoming = item?.collection?.results?.[0] || temporaryPlatformError({ name, platform }, new Error("没有返回结果"));
const results = (target.collection.results || []).filter((result) => result.platform !== platform);
target.collection = {
...target.collection,
captured_at: item?.collection?.captured_at || target.collection.captured_at,
results: [...results, incoming].sort((left, right) =>
platformOrder.indexOf(left.platform) - platformOrder.indexOf(right.platform),
),
};
}
function temporaryPlatformError(task, error) {
return {
platform: task.platform,
platform_label: platformLabels[task.platform] || task.platform,
metric_label: metricLabels[task.platform] || "",
name: task.name,
url: "",
page_title: "",
hotness_raw: "",
hotness_number: "",
unit: "",
confidence: "",
evidence: "",
status: "error",
fetched_at: new Date().toISOString(),
error: error?.message || "query failed",
};
}
async function clientMapLimit(items, limit, worker) {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
await worker(items[index], index);
}
});
await Promise.all(workers);
}
async function loadTemporaryImportFile(file) {
if (!file) return "";
const name = String(file.name || "").toLowerCase();
const type = String(file.type || "").toLowerCase();
if (type.startsWith("image/")) {
const payload = await postJson("/api/temporary-ocr", {
filename: file.name,
type: file.type,
data: await readFileAsDataUrl(file),
});
return normalizeTemporaryListText(payload.text || "");
}
if (name.endsWith(".xlsx") || type.includes("spreadsheetml")) {
return normalizeTemporaryListText(await extractXlsxText(await file.arrayBuffer()));
}
return normalizeTemporaryListText(await file.text());
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取截图失败"));
reader.readAsDataURL(file);
});
}
function normalizeTemporaryListText(text) {
const seen = new Set();
const names = [];
for (const line of String(text || "").split(/[\r\n]+/)) {
const cells = splitTemporaryListLine(line);
const value = (cells.find((cell) => cell.trim()) || "").trim();
if (!value || isTemporaryListHeader(value) || seen.has(value)) continue;
seen.add(value);
names.push(value);
}
return names.join("\n");
}
function splitTemporaryListLine(line) {
const text = String(line || "").trim();
if (text.includes("\t")) return text.split("\t");
if (!text.includes(",")) return [text];
const cells = [];
let current = "";
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === "\"") {
if (quoted && text[index + 1] === "\"") {
current += "\"";
index += 1;
} else {
quoted = !quoted;
}
} else if (char === "," && !quoted) {
cells.push(current);
current = "";
} else {
current += char;
}
}
cells.push(current);
return cells;
}
function isTemporaryListHeader(value) {
return new Set(["节目", "节目名", "节目名称", "片名", "名称", "name", "program", "title"]).has(
String(value || "").trim().toLowerCase(),
);
}
async function extractXlsxText(arrayBuffer) {
const entries = await unzipXlsxEntries(arrayBuffer);
const sharedStrings = parseSharedStrings(entries.get("xl/sharedStrings.xml") || "");
const worksheetName = [...entries.keys()].find((name) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(name));
if (!worksheetName) throw new Error("没有在 Excel 中找到工作表");
const names = parseWorksheetNames(entries.get(worksheetName), sharedStrings);
if (names.length === 0) throw new Error("没有在 Excel 第一列找到节目名");
return names.join("\n");
}
async function unzipXlsxEntries(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const view = new DataView(arrayBuffer);
const decoder = new TextDecoder("utf-8");
const eocdOffset = findZipEndOfCentralDirectory(view);
if (eocdOffset < 0) throw new Error("无法识别 Excel 文件");
const entryCount = view.getUint16(eocdOffset + 10, true);
let offset = view.getUint32(eocdOffset + 16, true);
const entries = new Map();
for (let index = 0; index < entryCount; index += 1) {
if (view.getUint32(offset, true) !== 0x02014b50) throw new Error("Excel 文件结构异常");
const method = view.getUint16(offset + 10, true);
const compressedSize = view.getUint32(offset + 20, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const localOffset = view.getUint32(offset + 42, true);
const path = decoder.decode(bytes.slice(offset + 46, offset + 46 + nameLength));
const content = await readZipEntry(bytes, view, localOffset, compressedSize, method, decoder);
entries.set(path, content);
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
function findZipEndOfCentralDirectory(view) {
for (let offset = view.byteLength - 22; offset >= 0; offset -= 1) {
if (view.getUint32(offset, true) === 0x06054b50) return offset;
}
return -1;
}
async function readZipEntry(bytes, view, offset, compressedSize, method, decoder) {
if (view.getUint32(offset, true) !== 0x04034b50) throw new Error("Excel 文件内容异常");
const nameLength = view.getUint16(offset + 26, true);
const extraLength = view.getUint16(offset + 28, true);
const start = offset + 30 + nameLength + extraLength;
const compressed = bytes.slice(start, start + compressedSize);
if (method === 0) return decoder.decode(compressed);
if (method !== 8) throw new Error("暂不支持这个 Excel 压缩格式");
if (typeof DecompressionStream !== "function") {
throw new Error("当前浏览器不支持直接读取 xlsx请另存为 CSV 后导入。");
}
const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return decoder.decode(await new Response(stream).arrayBuffer());
}
function parseSharedStrings(xml) {
if (!xml) return [];
const doc = new DOMParser().parseFromString(xml, "application/xml");
return [...doc.getElementsByTagName("si")].map((item) =>
[...item.getElementsByTagName("t")].map((node) => node.textContent || "").join("").trim(),
);
}
function parseWorksheetNames(xml, sharedStrings) {
const doc = new DOMParser().parseFromString(xml || "", "application/xml");
const names = [];
for (const row of doc.getElementsByTagName("row")) {
const cells = [...row.getElementsByTagName("c")].map((cell) => worksheetCellText(cell, sharedStrings));
const value = (cells.find(Boolean) || "").trim();
if (value && !isTemporaryListHeader(value)) names.push(value);
}
return [...new Set(names)];
}
function worksheetCellText(cell, sharedStrings) {
const type = cell.getAttribute("t");
if (type === "inlineStr") {
return [...cell.getElementsByTagName("t")].map((node) => node.textContent || "").join("").trim();
}
const value = cell.getElementsByTagName("v")[0]?.textContent?.trim() || "";
if (type === "s") return sharedStrings[Number(value)] || "";
return value;
}
function renderTemporaryResults(items) {
const rows = temporaryRows(items);
if (rows.length === 0) {
temporaryQueryResult.classList.add("empty");
temporaryQueryResult.textContent = "暂无临时查询结果";
return;
}
temporaryQueryResult.classList.remove("empty");
temporaryQueryResult.innerHTML = `
<div class="temporary-result-wrap">
<table class="temporary-table">
<thead>
<tr>
<th>节目</th>
<th>平台</th>
<th>指标</th>
<th>数值</th>
<th>状态</th>
<th>节目页</th>
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
<td>${escapeHtml(row.name)}</td>
<td>${escapeHtml(row.platform_label)}</td>
<td>${escapeHtml(row.metric_label)}</td>
<td>${escapeHtml(row.value)}</td>
<td>${escapeHtml(row.status_label)}</td>
<td>${row.url ? `<a href="${escapeAttribute(row.url)}" target="_blank" rel="noreferrer">打开</a>` : ""}</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
`;
}
function temporaryRows(items) {
return (items || []).flatMap((item) => (item.collection?.results || []).map((result) => ({
name: item.name || result.name || "",
platform: result.platform || "",
platform_label: result.platform_label || platformLabels[result.platform] || result.platform || "",
metric_label: result.metric_label || metricLabels[result.platform] || "",
value: result.status === "ok" ? (result.hotness_raw || result.hotness_number || "") : "",
hotness_raw: result.hotness_raw || "",
hotness_number: result.hotness_number || "",
unit: result.unit || "",
status: result.status || "",
status_label: statusLabel(result.status),
confidence: result.confidence || "",
credibility: result.credibility?.label || "",
page_title: result.page_title || "",
evidence: result.evidence || "",
error: result.error || "",
url: result.url || "",
fetched_at: result.fetched_at || item.collection?.captured_at || "",
})));
}
function downloadTemporaryCsv(items) {
const rows = temporaryRows(items);
if (rows.length === 0) return;
const headers = [
"节目",
"平台",
"指标",
"数值",
"状态",
"可信度",
"节目页",
"页面标题",
"采集时间",
"错误",
"证据",
];
const csv = [
headers,
...rows.map((row) => [
row.name,
row.platform_label,
row.metric_label,
row.value,
row.status_label,
row.credibility,
row.url,
row.page_title,
row.fetched_at,
row.error,
row.evidence,
]),
].map((row) => row.map(csvEscape).join(",")).join("\n");
const blob = new Blob([`\ufeff${csv}\n`], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `临时查询-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function csvEscape(value) {
const text = String(value ?? "");
if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, "\"\"")}"`;
return text;
}
function renderHistory(history) {
activeHistory = history;
renderDesktopDashboard();
const runs = history.runs || [];
const visibleRuns = visibleRunsForTable(runs);
const collapsedCount = hiddenRunCount(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);
updateRunCollapseControls(runs);
updateRunBulkBar();
if (runs.length === 0) {
runBulkMode = false;
selectedRuns.clear();
updateRunBulkBar();
table.querySelector("thead").innerHTML = "";
table.querySelector("tbody").innerHTML = `<tr><td class="empty" colspan="4">暂无采集结果</td></tr>`;
renderTrendCharts(history);
return;
}
table.querySelector("thead").innerHTML = `
<tr>
<th>平台</th>
<th>指标口径</th>
<th>节目页</th>
${collapsedCount > 0 ? `<th class="run-collapse-cell">${collapsedCount} 个旧列已隐藏</th>` : ""}
${visibleRuns.map((run) => `
<th>
<span class="run-head">
${runBulkMode ? `<label class="run-select"><input data-select-run="${escapeAttribute(run)}" type="checkbox" ${selectedRuns.has(run) ? "checked" : ""} aria-label="选择 ${formatTime(run)}"></label>` : ""}
<span>${formatTime(run)}</span>
${runBulkMode ? "" : `<button class="delete-run" type="button" data-delete-run="${escapeAttribute(run)}">删除</button>`}
</span>
</th>
`).join("")}
</tr>
`;
table.querySelector("tbody").innerHTML = platformOrder.filter((platform) => selectedPlatforms.has(platform)).map((platform) => {
const row = history.platforms?.[platform] || { values: {} };
return `
<tr>
<td>${escapeHtml(row.platform_label || platformLabels[platform] || platform)}</td>
<td>${renderMetricCell(row, platform)}</td>
<td class="url-cell">${renderUrl(row.url)}</td>
${collapsedCount > 0 ? `<td class="run-collapse-cell"><button type="button" data-expand-runs>展开旧列</button></td>` : ""}
${visibleRuns.map((run) => renderValueCell(row.values?.[run], platform, run)).join("")}
</tr>
`;
}).join("");
renderTrendCharts(history);
}
function visibleRunsForTable(runs) {
if (showAllRuns || runBulkMode) return runs;
if (runs.length <= VISIBLE_RECENT_RUNS) return runs;
return runs.slice(-VISIBLE_RECENT_RUNS);
}
function hiddenRunCount(runs) {
if (showAllRuns || runBulkMode) return 0;
return Math.max(0, runs.length - VISIBLE_RECENT_RUNS);
}
function updateRunCollapseControls(runs) {
const hiddenCount = Math.max(0, runs.length - VISIBLE_RECENT_RUNS);
if (hiddenCount === 0 || runBulkMode) {
runCollapseToggle.hidden = true;
runCollapseNote.textContent = "";
return;
}
runCollapseToggle.hidden = false;
runCollapseToggle.textContent = showAllRuns ? "收起旧列" : "展开旧列";
runCollapseNote.textContent = showAllRuns
? `已展开全部 ${runs.length}`
: `默认显示最近 ${VISIBLE_RECENT_RUNS} 次,隐藏 ${hiddenCount} 个旧列`;
}
function syncFilterButtons() {
for (const button of filterBar.querySelectorAll("[data-platform-filter]")) {
const platform = button.dataset.platformFilter;
const active = platform === "all"
? selectedPlatforms.size === platformOrder.length
: selectedPlatforms.has(platform);
button.classList.toggle("active", active);
}
}
function syncUrlInputs(history) {
for (const platform of platformOrder) {
const input = urlInputs[platform];
if (!input) continue;
input.value = history.platforms?.[platform]?.url || "";
}
dirtyUrlInputs.clear();
}
function syncLibraryInputs(entry) {
aliasInput.value = (entry?.aliases || []).join("");
libraryStatus.textContent = entry?.source === "library"
? "已载入链接库"
: (entry?.source === "builtin" ? "已载入内置链接" : "");
for (const platform of platformOrder) {
const input = urlInputs[platform];
if (!input || input.value.trim()) continue;
input.value = entry?.urls?.[platform] || "";
}
dirtyUrlInputs.clear();
}
function readUrlInputs() {
return Object.fromEntries(platformOrder
.map((platform) => [
platform,
urlInputs[platform]?.value.trim() || "",
])
.filter(([, url]) => url));
}
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() {
const selected = readCollectPlatforms();
collectPlatformAll.textContent = selected.length === platformOrder.length ? "取消全选" : "全选";
collectPlatformAll.classList.toggle("warn", selected.length === 0);
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 = "";
}
aliasInput.value = "";
libraryStatus.textContent = "";
renderLinkCandidates(null);
dirtyUrlInputs.clear();
}
function renderMetricCell(row, platform) {
const label = row.metric_label || metricLabels[platform] || "指标值";
const description = row.metric_description || "";
return `
<span class="compact-label" title="${escapeAttribute(description || label)}">${escapeHtml(shortMetricLabel(label))}</span>
`;
}
function shortMetricLabel(label) {
if (label.includes("播放")) return "播放数";
if (label.includes("内容")) return "内容热";
if (label.includes("热度")) return "热度值";
return label.length > 5 ? `${label.slice(0, 5)}...` : label;
}
function renderValueCellLegacy(value, platform, run) {
if (!value) return `<td class="heat-cell"><span class="short-status muted" title="本次未选择该平台,或历史中没有这一列数据">未采集</span></td>`;
const detailTitle = [value.page_title, value.evidence].filter(Boolean).join("\n");
const anomalyBadge = value.anomaly ? `<span class="anomaly-badge">异常</span>` : "";
const credibilityBadge = renderCredibilityBadge(value.credibility);
const detailButton = `<button class="detail-link" type="button" data-detail-platform="${escapeAttribute(platform)}" data-detail-run="${escapeAttribute(run)}">详情</button>`;
if (value.status === "ok") {
const shown = value.raw || value.number || "";
const meta = value.number && String(value.number) !== String(value.raw) ? value.number : "ok";
return `
<td class="heat-cell" title="${escapeAttribute(detailTitle)}">
<span class="heat-value status-ok">${escapeHtml(shown)} ${anomalyBadge}</span>
${credibilityBadge}
${detailButton}
</td>
`;
}
const tone = value.status === "blocked" ? "status-warn" : "status-bad";
const statusText = {
no_match: "未找到",
no_metric: "无指标",
blocked: "被拦截",
error: "抓取错",
}[value.status] || value.status || "error";
const fullReason = [
value.page_title ? `页面标题:${value.page_title}` : "",
value.credibility?.reason ? `可信度:${value.credibility.reason}` : "",
value.error ? `错误:${value.error}` : "",
].filter(Boolean).join("\n");
return `
<td class="heat-cell" title="${escapeAttribute(fullReason || value.status || "")}">
<span class="short-status ${tone}">${escapeHtml(statusText)}</span>
${credibilityBadge}
${detailButton}
</td>
`;
}
function renderValueCell(value, platform, run) {
const display = compactValueDisplay(value);
return `
<td class="heat-cell" title="${escapeAttribute(display.reason)}">
<span class="${escapeAttribute(display.className)}" title="${escapeAttribute(display.reason)}">${escapeHtml(display.text)}</span>
</td>
`;
}
function compactValueDisplay(value) {
if (!value) {
return {
text: "无",
className: "short-status muted",
reason: "本次未选择该平台,或历史中没有这一列数据",
};
}
const reason = valueTooltip(value);
if (value.status === "ok") {
const shown = value.raw || value.number || "";
return {
text: shown ? String(shown) : "无",
className: shown ? "heat-value status-ok" : "short-status muted",
reason,
};
}
return {
text: "无",
className: `short-status ${value.status === "blocked" ? "status-warn" : "status-bad"}`,
reason,
};
}
function valueTooltip(value) {
return [
value.status ? `状态:${statusLabel(value.status)}` : "",
value.page_title ? `页面标题:${value.page_title}` : "",
value.credibility?.label ? `可信度:${value.credibility.label}` : "",
value.credibility?.reason ? `说明:${value.credibility.reason}` : "",
value.error ? `错误:${value.error}` : "",
value.anomaly?.message ? `异常:${value.anomaly.message}` : "",
value.evidence ? `证据:${value.evidence}` : "",
].filter(Boolean).join("\n") || "无可用数据";
}
function statusLabel(status) {
return {
ok: "已抓取",
no_match: "未找到",
no_metric: "无指标",
blocked: "被拒绝",
error: "抓取错误",
}[status] || status || "未知";
}
function renderUrl(url) {
if (!url) return `<span>未匹配</span>`;
return `<a href="${escapeAttribute(url)}" target="_blank" rel="noreferrer">打开节目页</a>`;
}
function showDetail(platform, run) {
const row = activeHistory?.platforms?.[platform];
const value = row?.values?.[run];
if (!value) return;
detailTitle.textContent = `${row.platform_label || platformLabels[platform] || platform} · ${formatTime(run)}`;
const candidates = value.search_candidates || [];
detailBody.innerHTML = `
${detailLine("状态", value.status || "")}
${detailLine("指标", row.metric_label || metricLabels[platform] || "")}
${detailLine("指标口径", row.metric_description || "")}
${detailLine("可信度", value.credibility?.label || "")}
${detailLine("可信度说明", value.credibility?.reason || "")}
${detailLine("数值", value.raw || value.number || "")}
${detailLine("页面标题", value.page_title || "")}
${detailLine("节目页", value.url || row.url || "", true)}
${detailLine("搜索页", value.search_url || "", true)}
${detailLine("证据", value.evidence || "")}
${value.anomaly ? detailLine("异常提示", value.anomaly.message || "") : ""}
${detailLine("错误", value.error || "")}
${candidates.length ? `
<div class="detail-block">
<div class="detail-label">搜索候选</div>
<ol class="candidate-list">
${candidates.slice(0, 5).map((candidate) => `
<li>
<div class="candidate-actions">
<a href="${escapeAttribute(candidate.url)}" target="_blank" rel="noreferrer">${escapeHtml(candidate.pageTitle || candidate.url)}</a>
<button type="button" data-use-candidate-platform="${escapeAttribute(platform)}" data-use-candidate-url="${escapeAttribute(candidate.url)}">使用这个链接</button>
</div>
<span>${escapeHtml(candidate.evidence || "")}</span>
</li>
`).join("")}
</ol>
</div>
` : ""}
`;
detailDialog.showModal();
}
function renderCredibilityBadge(credibility) {
if (!credibility?.label) return "";
return `<span class="credibility-badge ${escapeAttribute(credibility.level || "")}" title="${escapeAttribute(credibility.reason || credibility.label)}">${escapeHtml(shortCredibilityLabel(credibility.label))}</span>`;
}
function shortCredibilityLabel(label) {
return {
"高可信": "高信",
"中可信": "中信",
"低可信": "低信",
"已确认节目页": "确认",
"拒绝": "拒绝",
}[label] || label;
}
function detailLine(label, value, asLink = false) {
if (!value) return "";
const content = asLink
? `<a href="${escapeAttribute(value)}" target="_blank" rel="noreferrer">${escapeHtml(value)}</a>`
: escapeHtml(value);
return `
<div class="detail-block">
<div class="detail-label">${escapeHtml(label)}</div>
<div class="detail-value">${content}</div>
</div>
`;
}
function renderTrendCharts(history) {
const runs = history?.runs || [];
if (!history?.name || runs.length === 0) {
trendCharts.innerHTML = `<div class="empty">暂无趋势</div>`;
return;
}
trendCharts.innerHTML = platformOrder
.filter((platform) => selectedPlatforms.has(platform))
.map((platform) => renderPlatformTrend(platform, history.platforms?.[platform], runs))
.join("");
}
function renderPlatformTrend(platform, row, runs) {
const points = runs.map((run, index) => {
const value = row?.values?.[run];
const number = value?.status === "ok" ? Number(value.number) : NaN;
return Number.isFinite(number) ? { index, run, number, raw: value.raw || String(number) } : null;
}).filter(Boolean);
if (points.length === 0) {
return `
<article class="trend-card">
<div class="trend-title">${escapeHtml(row?.platform_label || platformLabels[platform] || platform)}</div>
<div class="empty">暂无有效数据</div>
</article>
`;
}
return `
<article class="trend-card">
<div class="trend-title">${escapeHtml(row?.platform_label || platformLabels[platform] || platform)}</div>
${lineSvg(points)}
<div class="trend-meta">最新:${escapeHtml(points[points.length - 1].raw)}</div>
</article>
`;
}
function lineSvg(points) {
const width = 320;
const height = 120;
const pad = 18;
const min = Math.min(...points.map((point) => point.number));
const max = Math.max(...points.map((point) => point.number));
const span = max - min || 1;
const lastIndex = Math.max(...points.map((point) => point.index), 1);
const coordinates = points.map((point) => {
const x = pad + (point.index / lastIndex) * (width - pad * 2);
const y = height - pad - ((point.number - min) / span) * (height - pad * 2);
return `${round(x)},${round(y)}`;
}).join(" ");
return `
<svg class="line-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="趋势图">
<line x1="${pad}" y1="${height - pad}" x2="${width - pad}" y2="${height - pad}" />
<polyline points="${coordinates}" />
${points.map((point) => {
const [x, y] = coordinates.split(" ")[points.indexOf(point)].split(",");
return `<circle cx="${x}" cy="${y}" r="3"><title>${escapeHtml(formatTime(point.run))} ${escapeHtml(point.raw)}</title></circle>`;
}).join("")}
</svg>
`;
}
function renderCompareList(programs) {
if (!compareList) return;
compareList.innerHTML = programs.length
? programs.map((program) => `
<label class="compare-check">
<input type="checkbox" data-compare-name="${escapeAttribute(program.name)}" ${compareNames.has(program.name) ? "checked" : ""}>
<span>${escapeHtml(program.name)}</span>
</label>
`).join("")
: `<div class="empty">暂无历史节目</div>`;
}
async function renderCompare() {
try {
const names = [...compareNames];
if (names.length === 0) {
compareChart.className = "compare-chart empty";
compareChart.textContent = "选择节目后显示对比";
compareTable.innerHTML = "";
return;
}
for (const name of names) {
if (compareHistories.has(name)) continue;
const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`);
compareHistories.set(name, payload.history);
}
const platform = comparePlatform.value;
const range = compareRange.value;
const histories = names.map((name) => compareHistories.get(name)).filter(Boolean);
const sourceSeries = histories.map((history) => comparePlatformSeries(history, platform)).filter((item) => item.points.length);
const series = filterCompareSeriesByRange(sourceSeries, range).filter((item) => item.points.length);
compareChart.className = "compare-chart compare-line-chart";
compareChart.innerHTML = renderCompareLineChart(series);
compareTable.innerHTML = "";
} catch (error) {
setStatus("error", error.message);
}
}
function latestPlatformValue(history, platform) {
const row = history?.platforms?.[platform];
const runs = [...(history?.runs || [])].reverse();
const latestRun = runs.find((run) => row?.values?.[run]);
const value = latestRun ? row.values[latestRun] : null;
return {
name: history?.name || "",
run: latestRun || "",
status: value?.status || "未采集",
raw: value?.status === "ok" ? (value.raw || value.number || "") : "",
number: value?.status === "ok" ? Number(value.number) : NaN,
};
}
function filterCompareSeriesByRange(series, range) {
if (range === "all") return series;
const allPoints = series.flatMap((item) => item.points).filter((point) => Number.isFinite(point.time));
if (allPoints.length === 0) return series;
const latestTime = Math.max(...allPoints.map((point) => point.time));
const cutoff = compareRangeCutoff(latestTime, range);
return series.map((item) => ({
...item,
points: item.points.filter((point) => point.time >= cutoff && point.time <= latestTime),
}));
}
function compareRangeCutoff(latestTime, range) {
if (range === "today") {
const start = new Date(latestTime);
start.setHours(0, 0, 0, 0);
return start.getTime();
}
const days = range === "3d" ? 3 : 7;
return latestTime - days * 24 * 60 * 60 * 1000;
}
function comparePlatformSeries(history, platform) {
const row = history?.platforms?.[platform];
const points = (history?.runs || [])
.map((run) => {
const value = row?.values?.[run];
const number = value?.status === "ok" ? Number(value.number) : NaN;
if (!Number.isFinite(number)) return null;
return {
run,
time: new Date(run).getTime(),
number,
raw: value.raw || String(value.number || ""),
};
})
.filter(Boolean)
.sort((a, b) => a.time - b.time);
return {
name: history?.name || "",
points,
};
}
function renderCompareLineChart(series) {
const validSeries = series.filter((item) => item.points.length);
const allPoints = validSeries.flatMap((item) => item.points);
if (allPoints.length === 0) return `<div class="empty">所选节目在该平台暂无有效数据</div>`;
const width = 920;
const height = 188;
const pad = { left: 44, right: 18, top: 22, bottom: 30 };
const times = allPoints.map((point) => point.time).filter(Number.isFinite);
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const { minValue, maxValue } = buildCompareValueDomain(allPoints);
const colors = ["#0f766e", "#2563eb", "#b45309", "#7c3aed", "#dc2626", "#0891b2", "#4d7c0f", "#be185d"];
const x = (time) => {
if (maxTime === minTime) return (pad.left + width - pad.right) / 2;
return pad.left + ((time - minTime) / (maxTime - minTime)) * (width - pad.left - pad.right);
};
const y = (value) => height - pad.bottom - ((value - minValue) / (maxValue - minValue)) * (height - pad.top - pad.bottom);
const yTicks = [minValue, (minValue + maxValue) / 2, maxValue].map(round);
const timeTicks = buildCompareTimeTicks(times, 10);
const timeLabelsUseTimeOnly = compareTimesOnSameDay(timeTicks);
const grid = yTicks.map((tick) => {
const yy = y(tick);
return `<g class="compare-grid-line"><line x1="${pad.left}" y1="${yy}" x2="${width - pad.right}" y2="${yy}"></line><text x="${pad.left - 10}" y="${yy + 4}">${escapeHtml(formatCompactNumber(tick))}</text></g>`;
}).join("");
const lines = validSeries.map((item, index) => {
const color = colors[index % colors.length];
const path = item.points.map((point) => `${round(x(point.time))},${round(y(point.number))}`).join(" ");
const labeledPointIndexes = buildCompareLabelIndexes(item.points, 13);
const circles = item.points.map((point, pointIndex) => {
const cx = round(x(point.time));
const cy = round(y(point.number));
const labelY = Math.max(7, Math.min(height - pad.bottom - 3, cy + (index % 2 === 0 ? -4 : 8)));
const labelAnchor = pointIndex === item.points.length - 1 ? "end" : "middle";
const label = labeledPointIndexes.has(pointIndex)
? `<text class="compare-point-value" x="${cx}" y="${labelY}" text-anchor="${labelAnchor}" fill="${color}">${escapeHtml(formatCompactNumber(point.number))}</text>`
: "";
return `
<circle cx="${cx}" cy="${cy}" r="2.2" fill="${color}">
<title>${escapeHtml(`${item.name} ${formatShortDate(point.time)} ${point.raw}`)}</title>
</circle>
${label}
`;
}).join("");
return `
<g class="compare-series">
${item.points.length > 1 ? `<polyline points="${path}" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></polyline>` : ""}
${circles}
</g>
`;
}).join("");
const legend = validSeries.map((item, index) => `
<span><i style="background:${colors[index % colors.length]}"></i>${escapeHtml(item.name)}</span>
`).join("");
const timeAxis = timeTicks.map((time, index) => {
const xx = round(x(time));
const anchor = index === 0 ? "start" : (index === timeTicks.length - 1 ? "end" : "middle");
const label = formatCompareTimeLabel(time, timeLabelsUseTimeOnly);
return `
<g class="compare-time-tick">
<line x1="${xx}" y1="${height - pad.bottom}" x2="${xx}" y2="${height - pad.bottom + 4}"></line>
<text class="compare-time-label" x="${xx}" y="${height - 12}" text-anchor="${anchor}">
<tspan x="${xx}">${escapeHtml(label.primary)}</tspan>
${label.secondary ? `<tspan x="${xx}" dy="8">${escapeHtml(label.secondary)}</tspan>` : ""}
</text>
</g>
`;
}).join("");
return `
<svg class="compare-line-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="节目历史曲线对比">
<rect x="${pad.left}" y="${pad.top}" width="${width - pad.left - pad.right}" height="${height - pad.top - pad.bottom}" rx="8" class="compare-plot-bg"></rect>
${grid}
<line class="compare-axis" x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}"></line>
${timeAxis}
${lines}
</svg>
<div class="compare-legend">${legend}</div>
`;
}
function buildCompareLabelIndexes(points, maxLabels = 13) {
if (points.length <= maxLabels) return new Set(points.map((_, index) => index));
const indexes = new Set();
const lastIndex = points.length - 1;
for (let i = 0; i < maxLabels; i += 1) {
indexes.add(Math.round((i / (maxLabels - 1)) * lastIndex));
}
return indexes;
}
function buildCompareValueDomain(points) {
const values = points.map((point) => point.number).filter(Number.isFinite);
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || Math.max(Math.abs(max) * 0.02, 1);
const padding = span * 0.12;
return {
minValue: Math.max(0, min - padding),
maxValue: max + padding,
};
}
function buildCompareTimeTicks(times, maxTicks = 10) {
const uniqueTimes = [...new Set(times.filter(Number.isFinite))].sort((a, b) => a - b);
if (uniqueTimes.length <= 2) return uniqueTimes;
const minTime = uniqueTimes[0];
const maxTime = uniqueTimes[uniqueTimes.length - 1];
const minimumGap = 92;
const minGapMs = ((maxTime - minTime) * minimumGap) / 920;
let ticks = [minTime];
for (const time of uniqueTimes.slice(1, -1)) {
const last = ticks[ticks.length - 1];
if (time - last >= minGapMs && maxTime - time >= minGapMs * 0.65) ticks.push(time);
}
ticks.push(maxTime);
if (ticks.length <= maxTicks) return ticks;
const sampled = [];
const lastIndex = ticks.length - 1;
for (let i = 0; i < maxTicks; i += 1) {
sampled.push(ticks[Math.round((i / (maxTicks - 1)) * lastIndex)]);
}
return [...new Set(sampled)].sort((a, b) => a - b);
}
function compareTimesOnSameDay(times) {
if (!times.length) return false;
const first = new Date(times[0]);
return times.every((time) => {
const date = new Date(time);
return date.getFullYear() === first.getFullYear()
&& date.getMonth() === first.getMonth()
&& date.getDate() === first.getDate();
});
}
function formatCompareTimeLabel(value, timeOnly = false) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return { primary: "", secondary: "" };
const monthDay = `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const hourMinute = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
return timeOnly
? { primary: hourMinute, secondary: "" }
: { primary: monthDay, secondary: hourMinute };
}
function formatShortDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function formatCompactNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) return "";
if (Math.abs(number) >= 100_000_000) return `${round(number / 100_000_000)}亿`;
if (Math.abs(number) >= 10_000) return `${round(number / 10_000)}`;
return String(round(number));
}
async function getJson(url) {
const response = await fetch(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(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("/api/auth/status", { headers: authHeaders() });
const payload = await response.json();
if (!payload.enabled || payload.authorized) {
hideAuthGate();
return true;
}
} catch {}
showAuthGate("");
return 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;
if (appStatusText) appStatusText.textContent = text;
}
function renderAppStatusDock() {
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
if (appStatusPort) appStatusPort.textContent = port;
if (appStatusPortDock) appStatusPortDock.textContent = port;
}
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 round(value) {
return Math.round(value * 100) / 100;
}
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;");
}