2251 lines
80 KiB
JavaScript
2251 lines
80 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 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;
|
||
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();
|
||
}
|
||
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();
|
||
});
|
||
|
||
initializeApp();
|
||
document.addEventListener("hotness:programs-changed", refreshPrograms);
|
||
|
||
async function initializeApp() {
|
||
if (!(await ensureAccessAuth())) return;
|
||
startApp();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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("/api/auth/login", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload.error || "访问密码不正确");
|
||
if (payload.token) localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, payload.token);
|
||
if (authPassword) authPassword.value = "";
|
||
setAuthMessage("登录成功,正在进入...");
|
||
hideAuthGate();
|
||
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;
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function escapeAttribute(value) {
|
||
return escapeHtml(value).replace(/`/g, "`");
|
||
}
|