1410 lines
65 KiB
HTML
1410 lines
65 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Map Studio 缓存管理</title>
|
||
<link rel="stylesheet" href="vendor/leaflet/leaflet.css?v=1.9.4">
|
||
<style>
|
||
:root { color-scheme: light; font-family: "Microsoft YaHei", Inter, sans-serif; color: #17201b; background: #f2f5f1; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; padding: 24px; }
|
||
main { max-width: 1120px; margin: auto; }
|
||
header, section { background: #fff; border: 1px solid #d8e0d8; border-radius: 16px; padding: 20px; box-shadow: 0 8px 28px rgba(24,48,32,.06); }
|
||
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||
h1 { margin: 0; font-size: 22px; }
|
||
h2 { margin: 0 0 13px; font-size: 16px; }
|
||
p { color: #607066; line-height: 1.65; }
|
||
.header-copy p { margin: 5px 0 0; }
|
||
.header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||
.status { padding: 8px 12px; border-radius: 999px; background: #edf3ee; color: #526158; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||
.status.ok { color: #1f6f49; background: #eaf6ed; }
|
||
.status.warn { color: #9a5b1f; background: #fff4df; }
|
||
button { padding: 9px 13px; border: 1px solid #cbd7ce; border-radius: 9px; background: #fff; color: #25372b; font: inherit; font-weight: 750; cursor: pointer; }
|
||
button.primary { color: #fff; background: #205c46; border-color: #205c46; }
|
||
button.danger { color: #8b3024; border-color: #e2b8b0; background: #fff8f6; }
|
||
button:disabled { opacity: .42; cursor: not-allowed; }
|
||
input[type="password"] { width: 100%; padding: 10px; border: 1px solid #d4ddd5; border-radius: 8px; font: inherit; }
|
||
.login-row { display: flex; gap: 9px; max-width: 560px; }
|
||
.login-row input { flex: 1; }
|
||
#loginError { min-height: 1.5em; margin: 8px 0 0; color: #a64232; }
|
||
body.auth-locked { display: grid; min-height: 100vh; place-items: center; padding: 20px; }
|
||
body.auth-locked main { width: min(560px, 100%); }
|
||
body.auth-locked header, body.auth-locked #dashboard { display: none !important; }
|
||
body.auth-locked #loginPanel { margin: 0 !important; }
|
||
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 16px 0; }
|
||
.summary-card { padding: 14px; border: 1px solid #e0e7e0; border-radius: 12px; background: #f8fbf7; }
|
||
.summary-card span { display: block; color: #718077; font-size: 10px; font-weight: 700; }
|
||
.summary-card strong { display: block; margin-top: 5px; font-size: 19px; font-variant-numeric: tabular-nums; }
|
||
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; padding: 12px; border-radius: 11px; background: #f6f9f5; }
|
||
.toolbar label { display: inline-flex; align-items: center; gap: 6px; color: #536259; font-size: 11px; font-weight: 700; }
|
||
.toolbar .spacer { flex: 1; }
|
||
.category-list { display: grid; gap: 8px; margin-top: 14px; }
|
||
.category { display: grid; grid-template-columns: auto minmax(0, 1fr) repeat(3, minmax(82px, auto)); gap: 12px; align-items: center; padding: 12px; border: 1px solid #dfe6df; border-radius: 11px; background: #fff; }
|
||
.category:hover { border-color: #bfcec2; background: #fcfdfb; }
|
||
.category input { margin: 0; accent-color: #205c46; }
|
||
.category-title { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||
.category p { margin: 3px 0 0; font-size: 10px; line-height: 1.5; }
|
||
.category-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
|
||
.category-actions button { padding: 5px 8px; border-radius: 7px; font-size: 9px; }
|
||
.badge { padding: 3px 6px; border-radius: 999px; color: #28654a; background: #e8f4ec; font-size: 9px; font-weight: 800; }
|
||
.badge.delete-only { color: #745b30; background: #f6efdf; }
|
||
.metric { text-align: right; }
|
||
.metric span { display: block; color: #7b877f; font-size: 9px; }
|
||
.metric strong { display: block; margin-top: 2px; font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.activity { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, .8fr); gap: 12px; margin-top: 14px; }
|
||
.activity-box { min-height: 90px; padding: 13px; border-radius: 11px; background: #17231b; color: #dce9df; font-size: 11px; line-height: 1.7; white-space: pre-wrap; }
|
||
.safety { margin: 0; padding: 13px; border: 1px solid #ead9b8; border-radius: 11px; background: #fffaf0; color: #6d552e; font-size: 11px; line-height: 1.7; }
|
||
code { padding: 2px 5px; border-radius: 5px; background: #edf2ed; }
|
||
.coverage-panel { margin: 14px 0; padding: 0; overflow: hidden; border: 1px solid #dfe6df; border-radius: 12px; background: #fbfdfa; }
|
||
.coverage-panel > summary { padding: 12px 14px; cursor: pointer; color: #294337; font-size: 12px; font-weight: 800; list-style-position: inside; }
|
||
.coverage-panel[open] > summary { border-bottom: 1px solid #e3e9e3; }
|
||
.coverage-body { padding: 12px; }
|
||
.coverage-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 9px; }
|
||
.coverage-toolbar label { display: inline-flex; align-items: center; gap: 6px; color: #536259; font-size: 10px; font-weight: 750; }
|
||
.coverage-toolbar select { padding: 7px 9px; border: 1px solid #cbd7ce; border-radius: 8px; color: #25372b; background: #fff; font: inherit; font-size: 11px; }
|
||
.coverage-toolbar button { padding: 7px 10px; font-size: 10px; }
|
||
.coverage-range-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 9px; padding: 9px; border: 1px solid #e1e7df; border-radius: 9px; background: #f7faf5; }
|
||
.coverage-range-actions button { padding: 7px 10px; font-size: 10px; }
|
||
.coverage-range-actions label { display: inline-flex; align-items: center; gap: 5px; color: #66746a; font-size: 10px; font-weight: 700; }
|
||
.coverage-range-actions button.active { color: #fff; border-color: #205c46; background: #205c46; }
|
||
.coverage-range-status { flex: 1 1 260px; min-width: 190px; color: #526158; font-size: 10px; line-height: 1.5; }
|
||
.coverage-response-controls { margin: 0 0 9px; padding: 9px 10px; border: 1px solid #dce6df; border-radius: 9px; background: #f5f9f4; }
|
||
.coverage-response-controls[hidden] { display: none; }
|
||
.coverage-response-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 10px; margin-bottom: 7px; }
|
||
.coverage-response-heading strong { color: #30463a; font-size: 10px; }
|
||
.coverage-response-heading span { color: #69786e; font-size: 10px; line-height: 1.45; }
|
||
.coverage-layer-filters { display: flex; flex-wrap: wrap; gap: 6px 12px; }
|
||
.coverage-layer-filters label { display: inline-flex; align-items: center; gap: 5px; color: #526158; font-size: 10px; font-weight: 700; }
|
||
.coverage-layer-filters .coverage-swatch { flex: none; }
|
||
#cacheCoverageMap.coverage-select-mode, #cacheCoverageMap.coverage-select-mode .leaflet-grab { cursor: crosshair !important; }
|
||
.coverage-map-shell { position: relative; }
|
||
#cacheCoverageMap { width: 100%; height: 390px; overflow: hidden; border: 1px solid #d4ddd5; border-radius: 11px; background: #e5ebe6; }
|
||
.coverage-tile-canvas { position: absolute; inset: 0; pointer-events: none; }
|
||
.coverage-tile-tooltip { font: 700 10px/1.4 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; }
|
||
.coverage-map-message { position: absolute; inset: 0; z-index: 500; display: grid; place-items: center; padding: 22px; border: 1px solid #d4ddd5; border-radius: 11px; color: #44554a; background: rgba(245, 249, 245, .92); font-size: 12px; font-weight: 700; line-height: 1.7; text-align: center; pointer-events: none; }
|
||
.coverage-map-message[hidden] { display: none; }
|
||
.coverage-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; min-height: 22px; margin-top: 9px; color: #536259; font-size: 10px; }
|
||
.coverage-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||
.coverage-swatch { width: 12px; height: 9px; border: 1px solid rgba(20,35,25,.28); border-radius: 2px; }
|
||
.coverage-status { margin: 0 0 9px; color: #66766c; font-size: 10px; line-height: 1.55; }
|
||
@media (max-width: 820px) {
|
||
body { padding: 12px; }
|
||
header { align-items: flex-start; flex-direction: column; }
|
||
.summary-grid { grid-template-columns: 1fr 1fr; }
|
||
.category { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||
.category .metric.files { grid-column: 2; text-align: left; }
|
||
.category .metric.updated { display: none; }
|
||
.activity { grid-template-columns: 1fr; }
|
||
.login-row { flex-direction: column; }
|
||
#cacheCoverageMap { height: 300px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="auth-locked">
|
||
<main>
|
||
<header>
|
||
<div class="header-copy">
|
||
<h1>缓存管理</h1>
|
||
<p>仅管理员可更新或删除 Map Studio 的运行缓存。导入的天地图本地矢量数据不在删除范围内。</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<span class="status" id="authStatus">正在检查管理员登录…</span>
|
||
<button type="button" id="openMapDownload">开放地图下载</button>
|
||
<button type="button" id="logoutButton" hidden>退出</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section id="loginPanel">
|
||
<h2>管理员登录</h2>
|
||
<p>缓存更新会向数据源请求内容,删除后相关区域会在下次浏览时重新下载或生成。</p>
|
||
<form id="loginForm" class="login-row">
|
||
<input id="adminPassword" type="password" autocomplete="current-password" placeholder="管理员密码" aria-label="管理员密码">
|
||
<button type="submit" class="primary">登录</button>
|
||
</form>
|
||
<p id="loginError"></p>
|
||
</section>
|
||
|
||
<section id="dashboard" hidden>
|
||
<div class="summary-grid">
|
||
<div class="summary-card"><span>托管缓存瓦片</span><strong id="totalFiles">—</strong></div>
|
||
<div class="summary-card"><span>占用空间</span><strong id="totalBytes">—</strong></div>
|
||
<div class="summary-card"><span>缓存策略</span><strong id="cachePolicy">—</strong></div>
|
||
<div class="summary-card"><span>下载任务</span><strong id="activeJobs">—</strong></div>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<label><input type="checkbox" id="selectAll"> 全选</label>
|
||
<label><input type="checkbox" id="forceRefresh"> 更新全部已有瓦片(忽略过期时间)</label>
|
||
<span class="spacer"></span>
|
||
<button type="button" id="refreshStats">重新统计</button>
|
||
<button type="button" class="primary" id="refreshSelected" disabled>更新所选瓦片</button>
|
||
<label><input type="checkbox" id="deleteConfirm"> 确认可删除</label>
|
||
<button type="button" class="danger" id="deleteSelected" disabled>删除所选缓存</button>
|
||
</div>
|
||
|
||
<div class="category-list" id="categoryList" aria-live="polite"></div>
|
||
|
||
<details class="coverage-panel" id="coveragePanel">
|
||
<summary>查看缓存覆盖范围</summary>
|
||
<div class="coverage-body">
|
||
<div class="coverage-toolbar">
|
||
<label>显示级别
|
||
<select id="coverageZoom" disabled>
|
||
<option value="all">范围总览(全部级别)</option>
|
||
</select>
|
||
</label>
|
||
<button type="button" id="showSelectedCoverage">显示所选类别</button>
|
||
<button type="button" id="showAllCoverage">显示全部类别</button>
|
||
<button type="button" id="refreshCoverage">重新扫描覆盖范围</button>
|
||
<button type="button" id="fitCoverage" disabled>定位到覆盖范围</button>
|
||
<button type="button" id="fitCoverageTiles" disabled>聚焦主要瓦片区</button>
|
||
</div>
|
||
<div class="coverage-response-controls" id="coverageResponseControls" hidden>
|
||
<div class="coverage-response-heading">
|
||
<strong>查询响应专题图层</strong>
|
||
<span>这些范围只来自已访问、已查询过的视窗,不代表全球完整覆盖;全球专题数据请查看 OpenFreeMap 全球原始矢量瓦片。</span>
|
||
</div>
|
||
<div class="coverage-layer-filters" id="coverageLayerFilters"></div>
|
||
</div>
|
||
<div class="coverage-range-actions">
|
||
<button type="button" id="selectCoverageRange">在地图上框选范围</button>
|
||
<button type="button" id="clearCoverageRange" disabled>清除框选</button>
|
||
<span class="coverage-range-status" id="coverageSelectionStatus">尚未框选。点击“在地图上框选范围”后,按住鼠标拖出矩形。</span>
|
||
<label><input type="checkbox" id="coverageDeleteConfirm"> 确认删除范围内缓存</label>
|
||
<button type="button" id="refreshCoverageRange" disabled>更新范围内缓存</button>
|
||
<button type="button" class="danger" id="deleteCoverageRange" disabled>删除范围内缓存</button>
|
||
</div>
|
||
<p class="coverage-status" id="coverageStatus" role="status" aria-live="polite">打开此面板后加载缓存范围。勾选上方类别可只查看所选缓存。</p>
|
||
<div class="coverage-map-shell">
|
||
<div id="cacheCoverageMap" role="application" aria-label="缓存覆盖范围地图"></div>
|
||
<div class="coverage-map-message" id="coverageMapMessage">正在准备缓存范围地图…</div>
|
||
</div>
|
||
<div class="coverage-legend" id="coverageLegend"></div>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="activity">
|
||
<div class="activity-box" id="activityLog">等待操作…</div>
|
||
<p class="safety">删除仅作用于 <code>cache/</code> 白名单目录。底图、矢量、铁路和环境缓存可在后续浏览时重建;天地图瓦片需重新通过下载后台获取。统计缓存每 30 秒复用一次,点击“重新统计”可立即刷新。</p>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<script src="vendor/leaflet/leaflet.js?v=1.9.4"></script>
|
||
<script src="compliance-gate.js?v=20261108-1"></script>
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
const serviceBase = new URL("./", document.baseURI).href.replace(/\/$/, "");
|
||
const api = (path) => serviceBase + "/" + String(path).replace(/^\/+/, "");
|
||
const $ = (id) => document.getElementById(id);
|
||
let currentStatus = null;
|
||
let busy = false;
|
||
let coverageData = null;
|
||
let coverageMap = null;
|
||
let coverageLayer = null;
|
||
let coverageLoading = false;
|
||
let coverageShowAll = true;
|
||
let coverageDisplayBounds = null;
|
||
let coverageFocusBounds = null;
|
||
let coverageTileCategories = [];
|
||
let coverageTileZoom = null;
|
||
let coverageTileCanvas = null;
|
||
let coverageTileContext = null;
|
||
let coverageTileDrawFrame = 0;
|
||
let coverageTileRenderStats = null;
|
||
let coverageHoverTooltip = null;
|
||
let coverageStatusBase = "";
|
||
let coverageResizeObserver = null;
|
||
let coverageLayoutTimer = 0;
|
||
let coverageSelectionLayer = null;
|
||
let coverageSelectionRect = null;
|
||
let coverageSelectedBounds = null;
|
||
let coverageSelectionStart = null;
|
||
let coverageSelectionMode = false;
|
||
let coveragePointerId = null;
|
||
const coverageHiddenIds = new Set();
|
||
|
||
const COVERAGE_COLORS = {
|
||
raster: "#d97706",
|
||
tianditu: "#dc2626",
|
||
vector: "#2563eb",
|
||
rail: "#16a34a",
|
||
environment: "#0891b2",
|
||
boundaries: "#7c3aed",
|
||
"rail-national": "#0f766e",
|
||
"rail-metro": "#9333ea",
|
||
"environment-water": "#0284c7",
|
||
"environment-vegetation": "#65a30d",
|
||
"environment-ports": "#d97706",
|
||
"rail-derived": "#15803d",
|
||
"dynamic-vector": "#db2777",
|
||
pending: "#64748b",
|
||
staging: "#94a3b8"
|
||
};
|
||
|
||
const formatBytes = (value) => {
|
||
const bytes = Number(value) || 0;
|
||
if (bytes < 1024) return bytes + " B";
|
||
const units = ["KB", "MB", "GB", "TB"];
|
||
let size = bytes / 1024;
|
||
let unit = units[0];
|
||
for (let index = 1; index < units.length && size >= 1024; index += 1) {
|
||
size /= 1024;
|
||
unit = units[index];
|
||
}
|
||
return size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2) + " " + unit;
|
||
};
|
||
|
||
const formatTime = (value) => {
|
||
if (!value) return "暂无";
|
||
const date = new Date(Number(value) * 1000);
|
||
if (!Number.isFinite(date.getTime())) return "暂无";
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
};
|
||
|
||
const setActivity = (message) => {
|
||
$("activityLog").textContent = message;
|
||
};
|
||
|
||
const setBusy = (value) => {
|
||
busy = value;
|
||
document.querySelectorAll("#dashboard button").forEach((button) => {
|
||
if (button.id !== "logoutButton") button.disabled = value;
|
||
});
|
||
if (!value) syncControls();
|
||
};
|
||
|
||
const showLogin = (message) => {
|
||
document.body.classList.add("auth-locked");
|
||
$("loginPanel").hidden = false;
|
||
$("dashboard").hidden = true;
|
||
$("logoutButton").hidden = true;
|
||
$("authStatus").textContent = message || "需要管理员登录";
|
||
$("authStatus").className = "status warn";
|
||
$("adminPassword").focus();
|
||
};
|
||
|
||
const showDashboard = (data) => {
|
||
currentStatus = data;
|
||
document.body.classList.remove("auth-locked");
|
||
$("loginPanel").hidden = true;
|
||
$("dashboard").hidden = false;
|
||
$("logoutButton").hidden = false;
|
||
$("authStatus").textContent = "管理员已登录";
|
||
$("authStatus").className = "status ok";
|
||
$("totalFiles").textContent = Number(data.totalTiles || 0).toLocaleString("zh-CN");
|
||
$("totalBytes").textContent = formatBytes(data.totalBytes);
|
||
$("cachePolicy").textContent = data.cacheOnly ? "仅缓存" : "允许联网";
|
||
$("activeJobs").textContent = Number(data.activeJobs || 0) + " 个";
|
||
renderCategories(data.categories || []);
|
||
syncControls();
|
||
};
|
||
|
||
const categoryRow = (category) => {
|
||
const row = document.createElement("label");
|
||
row.className = "category";
|
||
const queryResponse = Boolean(category.queryResponse);
|
||
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.dataset.category = category.id;
|
||
checkbox.addEventListener("change", syncControls);
|
||
|
||
const copy = document.createElement("div");
|
||
const heading = document.createElement("div");
|
||
heading.className = "category-title";
|
||
const title = document.createElement("strong");
|
||
title.textContent = category.label;
|
||
const badge = document.createElement("span");
|
||
badge.className = "badge" + (category.refreshable ? "" : " delete-only");
|
||
badge.textContent = category.refreshable ? "可更新" : "可删除";
|
||
heading.append(title, badge);
|
||
const description = document.createElement("p");
|
||
description.textContent = category.description;
|
||
copy.append(heading, description);
|
||
if (queryResponse) {
|
||
const actions = document.createElement("div");
|
||
actions.className = "category-actions";
|
||
const viewTiles = document.createElement("button");
|
||
viewTiles.type = "button";
|
||
viewTiles.textContent = "查看瓦片";
|
||
viewTiles.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
document.querySelectorAll("#categoryList input[data-category]").forEach((input) => {
|
||
input.checked = input === checkbox;
|
||
});
|
||
checkbox.checked = true;
|
||
syncControls();
|
||
$("coveragePanel").open = true;
|
||
$("coveragePanel").dispatchEvent(new Event("toggle"));
|
||
$("coveragePanel").scrollIntoView({ block: "start", behavior: "smooth" });
|
||
});
|
||
const download = document.createElement("button");
|
||
download.type = "button";
|
||
download.textContent = "下载这些瓦片";
|
||
download.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const downloadWindow = window.open("", "_blank");
|
||
download.disabled = true;
|
||
setActivity("正在创建按瓦片下载任务:" + category.label + "…");
|
||
try {
|
||
const data = await adminPost("api/openmap/topic-download", {
|
||
category: category.id,
|
||
force: $("forceRefresh").checked
|
||
});
|
||
const jobUrl = "openmap-admin.html?provider=openfreemap&source=" +
|
||
encodeURIComponent(category.id) + "&job=" + encodeURIComponent(data.job.id);
|
||
if (downloadWindow) {
|
||
downloadWindow.opener = null;
|
||
downloadWindow.location.href = jobUrl;
|
||
}
|
||
setActivity(
|
||
"已按瓦片创建下载任务:共 " + Number(data.job.total || 0).toLocaleString("zh-CN") +
|
||
" 个 Z/X/Y 来源瓦片。已在新页面显示任务进度。"
|
||
);
|
||
} catch (error) {
|
||
if (downloadWindow) downloadWindow.close();
|
||
setActivity("按瓦片下载失败:" + error.message);
|
||
} finally {
|
||
download.disabled = false;
|
||
}
|
||
});
|
||
actions.append(viewTiles, download);
|
||
copy.appendChild(actions);
|
||
}
|
||
|
||
const files = document.createElement("div");
|
||
files.className = "metric files";
|
||
const filesLabel = document.createElement("span");
|
||
const tileBacked = queryResponse || Number(category.tileCount || 0) > 0;
|
||
filesLabel.textContent = queryResponse ? "来源瓦片" : (tileBacked ? "瓦片" : "文件");
|
||
const filesValue = document.createElement("strong");
|
||
filesValue.textContent = Number(
|
||
queryResponse ? category.sourceTiles || 0 : (tileBacked ? category.tileCount || 0 : category.files || 0)
|
||
).toLocaleString("zh-CN");
|
||
files.append(filesLabel, filesValue);
|
||
|
||
const size = document.createElement("div");
|
||
size.className = "metric";
|
||
const sizeLabel = document.createElement("span");
|
||
sizeLabel.textContent = queryResponse ? "响应索引" : "空间";
|
||
const sizeValue = document.createElement("strong");
|
||
sizeValue.textContent = queryResponse ? Number(category.files || 0).toLocaleString("zh-CN") : formatBytes(category.bytes);
|
||
size.append(sizeLabel, sizeValue);
|
||
|
||
const updated = document.createElement("div");
|
||
updated.className = "metric updated";
|
||
const updatedLabel = document.createElement("span");
|
||
updatedLabel.textContent = queryResponse ? "视窗范围" : "最近更新";
|
||
const updatedValue = document.createElement("strong");
|
||
updatedValue.textContent = queryResponse ? Number(category.windows || 0).toLocaleString("zh-CN") : formatTime(category.updatedAt);
|
||
updated.append(updatedLabel, updatedValue);
|
||
|
||
row.append(checkbox, copy, files, size, updated);
|
||
return row;
|
||
};
|
||
|
||
const renderCategories = (categories) => {
|
||
$("categoryList").replaceChildren(...categories.map(categoryRow));
|
||
setActivity(
|
||
"已读取瓦片统计。所选更新按 Z/X/Y 瓦片处理;查询响应索引会在来源瓦片更新后自动失效并在浏览时重建。"
|
||
);
|
||
};
|
||
|
||
const selectedIds = () => Array.from(
|
||
document.querySelectorAll("#categoryList input[data-category]:checked")
|
||
).map((input) => input.dataset.category);
|
||
|
||
const selectedRefreshableIds = () => {
|
||
if (!currentStatus) return [];
|
||
const refreshable = new Set(
|
||
currentStatus.categories.filter((item) => item.refreshable).map((item) => item.id)
|
||
);
|
||
return selectedIds().filter((id) => refreshable.has(id));
|
||
};
|
||
|
||
const syncControls = () => {
|
||
const all = Array.from(document.querySelectorAll("#categoryList input[data-category]"));
|
||
const checked = all.filter((input) => input.checked);
|
||
$("selectAll").checked = all.length > 0 && checked.length === all.length;
|
||
$("selectAll").indeterminate = checked.length > 0 && checked.length < all.length;
|
||
$("refreshSelected").disabled = busy || !currentStatus || currentStatus.cacheOnly ||
|
||
selectedRefreshableIds().length === 0;
|
||
$("deleteSelected").disabled = busy || selectedIds().length === 0 || !$("deleteConfirm").checked;
|
||
$("refreshStats").disabled = busy;
|
||
syncCoverageRangeControls();
|
||
};
|
||
|
||
const parseResponse = async (response) => {
|
||
const data = await response.json().catch(() => ({}));
|
||
if (response.status === 401) {
|
||
showLogin(data.error || "管理员会话已过期");
|
||
throw new Error(data.error || "需要管理员登录");
|
||
}
|
||
if (!response.ok) throw new Error(data.error || "HTTP " + response.status);
|
||
return data;
|
||
};
|
||
|
||
const tileBounds = (zoom, x, y) => {
|
||
const count = 2 ** zoom;
|
||
const west = x / count * 360 - 180;
|
||
const east = (x + 1) / count * 360 - 180;
|
||
const north = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / count))) * 180 / Math.PI;
|
||
const south = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / count))) * 180 / Math.PI;
|
||
return L.latLngBounds([south, west], [north, east]);
|
||
};
|
||
|
||
const resizeCoverageTileCanvas = () => {
|
||
if (!coverageMap || !coverageTileCanvas || !coverageTileContext) return null;
|
||
const size = coverageMap.getSize();
|
||
const ratio = Math.max(1, Number(window.devicePixelRatio) || 1);
|
||
const width = Math.max(1, Math.round(size.x * ratio));
|
||
const height = Math.max(1, Math.round(size.y * ratio));
|
||
if (coverageTileCanvas.width !== width || coverageTileCanvas.height !== height) {
|
||
coverageTileCanvas.width = width;
|
||
coverageTileCanvas.height = height;
|
||
}
|
||
coverageTileCanvas.style.width = size.x + "px";
|
||
coverageTileCanvas.style.height = size.y + "px";
|
||
coverageTileContext.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
return size;
|
||
};
|
||
|
||
const coverageTileLookup = (zoomData) => {
|
||
const rows = new Map();
|
||
zoomData.runs.forEach(([y, x0, x1]) => {
|
||
if (!rows.has(y)) rows.set(y, []);
|
||
rows.get(y).push([x0, x1]);
|
||
});
|
||
return rows;
|
||
};
|
||
|
||
const tileIndexAtLatLng = (zoom, latlng) => {
|
||
const count = 2 ** zoom;
|
||
const lat = Math.max(-85.05112878, Math.min(85.05112878, latlng.lat));
|
||
const lon = ((latlng.lng + 180) % 360 + 360) % 360 - 180;
|
||
const x = Math.max(0, Math.min(count - 1, Math.floor((lon + 180) / 360 * count)));
|
||
const radians = lat * Math.PI / 180;
|
||
const y = Math.max(0, Math.min(count - 1, Math.floor(
|
||
(1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2 * count
|
||
)));
|
||
return { x, y };
|
||
};
|
||
|
||
const handleCoverageTileHover = (event) => {
|
||
if (!coverageHoverTooltip || coverageSelectionMode || coverageTileZoom === null) return;
|
||
const index = tileIndexAtLatLng(coverageTileZoom, event.latlng);
|
||
const match = coverageTileCategories.find((item) => {
|
||
const runs = item.runRows.get(index.y) || [];
|
||
return runs.some(([x0, x1]) => index.x >= x0 && index.x <= x1);
|
||
});
|
||
if (!match) {
|
||
if (coverageMap.hasLayer(coverageHoverTooltip)) coverageMap.removeLayer(coverageHoverTooltip);
|
||
return;
|
||
}
|
||
const content = document.createElement("div");
|
||
const title = document.createElement("strong");
|
||
title.textContent = match.label;
|
||
const detail = document.createElement("div");
|
||
detail.textContent = "Z" + coverageTileZoom + " · X " + index.x + " · Y " + index.y;
|
||
content.append(title, detail);
|
||
coverageHoverTooltip.setLatLng(event.latlng).setContent(content);
|
||
if (!coverageMap.hasLayer(coverageHoverTooltip)) coverageHoverTooltip.addTo(coverageMap);
|
||
};
|
||
|
||
const updateCoverageTileStatus = () => {
|
||
if (!coverageStatusBase) return;
|
||
if (coverageTileZoom === null || !coverageTileRenderStats) {
|
||
$("coverageStatus").textContent = coverageStatusBase;
|
||
return;
|
||
}
|
||
const stats = coverageTileRenderStats;
|
||
let suffix = " 当前视野:";
|
||
if (stats.borderTiles) {
|
||
suffix += Number(stats.borderTiles).toLocaleString("zh-CN") + " 个瓦片显示独立边框";
|
||
}
|
||
if (stats.mergedTiles) {
|
||
suffix += (stats.borderTiles ? ";" : "") +
|
||
Number(stats.mergedTiles).toLocaleString("zh-CN") +
|
||
" 个瓦片小于 2 像素,暂时合并填充;放大后会自动显示逐个边框";
|
||
}
|
||
$("coverageStatus").textContent = coverageStatusBase + suffix + "。";
|
||
};
|
||
|
||
const drawCoverageTileCanvas = () => {
|
||
coverageTileDrawFrame = 0;
|
||
const size = resizeCoverageTileCanvas();
|
||
if (!size || !coverageTileContext) return;
|
||
const context = coverageTileContext;
|
||
context.clearRect(0, 0, size.x, size.y);
|
||
coverageTileRenderStats = { borderTiles: 0, mergedTiles: 0 };
|
||
if (coverageTileZoom === null || !coverageTileCategories.length) {
|
||
updateCoverageTileStatus();
|
||
return;
|
||
}
|
||
const mapBounds = coverageMap.getBounds().pad(0.12);
|
||
coverageTileCategories.forEach((item) => {
|
||
const rectangles = [];
|
||
const merged = [];
|
||
item.zoomData.runs.forEach(([y, x0, x1]) => {
|
||
const northWest = tileBounds(coverageTileZoom, x0, y).getNorthWest();
|
||
const southEast = tileBounds(coverageTileZoom, x1, y).getSouthEast();
|
||
const runBounds = L.latLngBounds(northWest, southEast);
|
||
if (!mapBounds.intersects(runBounds)) return;
|
||
const topLeft = coverageMap.latLngToLayerPoint(northWest);
|
||
const bottomRight = coverageMap.latLngToLayerPoint(southEast);
|
||
const tileWidth = (bottomRight.x - topLeft.x) / (x1 - x0 + 1);
|
||
const tileHeight = bottomRight.y - topLeft.y;
|
||
if (!Number.isFinite(tileWidth) || !Number.isFinite(tileHeight)) return;
|
||
if (tileWidth < 2 || tileHeight < 2) {
|
||
merged.push({
|
||
left: Math.min(topLeft.x, bottomRight.x),
|
||
top: Math.min(topLeft.y, bottomRight.y),
|
||
right: Math.max(topLeft.x, bottomRight.x),
|
||
bottom: Math.max(topLeft.y, bottomRight.y),
|
||
tiles: x1 - x0 + 1
|
||
});
|
||
coverageTileRenderStats.mergedTiles += x1 - x0 + 1;
|
||
return;
|
||
}
|
||
for (let x = x0; x <= x1; x += 1) {
|
||
const left = Math.round(topLeft.x + (x - x0) * tileWidth);
|
||
const right = Math.round(topLeft.x + (x - x0 + 1) * tileWidth);
|
||
const top = Math.round(topLeft.y);
|
||
const bottom = Math.round(bottomRight.y);
|
||
if (right < 0 || left > size.x || bottom < 0 || top > size.y) continue;
|
||
rectangles.push({ left, top, right: Math.max(left + 1, right), bottom: Math.max(top + 1, bottom) });
|
||
}
|
||
});
|
||
context.save();
|
||
context.fillStyle = item.color;
|
||
context.globalAlpha = 0.13;
|
||
merged.forEach((rect) => {
|
||
context.fillRect(rect.left, rect.top, Math.max(1, rect.right - rect.left), Math.max(1, rect.bottom - rect.top));
|
||
});
|
||
rectangles.forEach((rect) => {
|
||
context.fillRect(rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top);
|
||
});
|
||
context.globalAlpha = 0.9;
|
||
context.strokeStyle = item.color;
|
||
context.lineWidth = 1;
|
||
rectangles.forEach((rect) => {
|
||
if (rect.right - rect.left < 2 || rect.bottom - rect.top < 2) return;
|
||
context.strokeRect(rect.left + 0.5, rect.top + 0.5, rect.right - rect.left - 1, rect.bottom - rect.top - 1);
|
||
});
|
||
context.restore();
|
||
coverageTileRenderStats.borderTiles += rectangles.length;
|
||
});
|
||
updateCoverageTileStatus();
|
||
};
|
||
|
||
const scheduleCoverageTileDraw = () => {
|
||
if (!coverageMap) return;
|
||
window.cancelAnimationFrame(coverageTileDrawFrame);
|
||
coverageTileDrawFrame = window.requestAnimationFrame(drawCoverageTileCanvas);
|
||
};
|
||
|
||
const findCoverageFocusBounds = (exactZoom) => {
|
||
if (!coverageMap || exactZoom === null || !coverageTileCategories.length) return null;
|
||
const size = coverageMap.getSize();
|
||
const bucketWidth = Math.max(8, Math.floor(Math.max(320, size.x) / 16));
|
||
const bucketHeight = Math.max(6, Math.floor(Math.max(220, size.y) / 16));
|
||
const buckets = new Map();
|
||
coverageTileCategories.forEach((item) => {
|
||
item.zoomData.runs.forEach(([y, x0, x1]) => {
|
||
for (let x = x0; x <= x1; x += 1) {
|
||
const bx = Math.floor(x / bucketWidth);
|
||
const by = Math.floor(y / bucketHeight);
|
||
const key = bx + "," + by;
|
||
const entry = buckets.get(key) || { bx, by, count: 0 };
|
||
entry.count += 1;
|
||
buckets.set(key, entry);
|
||
}
|
||
});
|
||
});
|
||
if (!buckets.size) return null;
|
||
let anchor = null;
|
||
let score = -1;
|
||
buckets.forEach((entry) => {
|
||
let total = 0;
|
||
for (let dx = 0; dx <= 1; dx += 1) {
|
||
for (let dy = 0; dy <= 1; dy += 1) {
|
||
total += buckets.get((entry.bx + dx) + "," + (entry.by + dy))?.count || 0;
|
||
}
|
||
}
|
||
if (total > score) {
|
||
score = total;
|
||
anchor = entry;
|
||
}
|
||
});
|
||
if (!anchor) return null;
|
||
const minX = anchor.bx * bucketWidth;
|
||
const minY = anchor.by * bucketHeight;
|
||
const maxX = minX + bucketWidth * 2 - 1;
|
||
const maxY = minY + bucketHeight * 2 - 1;
|
||
const northWest = tileBounds(exactZoom, minX, minY).getNorthWest();
|
||
const southEast = tileBounds(exactZoom, Math.min(maxX, 2 ** exactZoom - 1), Math.min(maxY, 2 ** exactZoom - 1)).getSouthEast();
|
||
return { bounds: L.latLngBounds(northWest, southEast), tiles: score };
|
||
};
|
||
|
||
const focusCoverageTiles = ({ reveal = false } = {}) => {
|
||
const exactZoom = Number($("coverageZoom").value);
|
||
if (!coverageMap || !Number.isFinite(exactZoom)) return false;
|
||
const focus = findCoverageFocusBounds(exactZoom);
|
||
coverageFocusBounds = focus?.bounds || null;
|
||
if (!focus) return false;
|
||
coverageMap.fitBounds(focus.bounds, { padding: [22, 22], maxZoom: exactZoom });
|
||
const minimumZoom = Math.max(coverageMap.getMinZoom(), exactZoom - 5);
|
||
if (coverageMap.getZoom() < minimumZoom) {
|
||
coverageMap.setView(focus.bounds.getCenter(), Math.min(exactZoom, minimumZoom), { animate: false });
|
||
}
|
||
if (reveal) $("coveragePanel").scrollIntoView({ block: "start", behavior: "auto" });
|
||
scheduleCoverageTileDraw();
|
||
return true;
|
||
};
|
||
|
||
const setCoverageMapMessage = (message) => {
|
||
const element = $("coverageMapMessage");
|
||
element.textContent = message || "";
|
||
element.hidden = !message;
|
||
};
|
||
|
||
const scheduleCoverageLayout = ({ fit = false, reveal = false } = {}) => {
|
||
window.clearTimeout(coverageLayoutTimer);
|
||
coverageLayoutTimer = window.setTimeout(() => {
|
||
if (!coverageMap) return;
|
||
coverageMap.invalidateSize(false);
|
||
if (fit && coverageTileZoom !== null && coverageFocusBounds) {
|
||
focusCoverageTiles();
|
||
} else if (fit && coverageDisplayBounds) {
|
||
coverageMap.fitBounds(coverageDisplayBounds, { padding: [24, 24], maxZoom: 14 });
|
||
}
|
||
if (reveal) {
|
||
$("coveragePanel").scrollIntoView({ block: "start", behavior: "auto" });
|
||
}
|
||
}, 120);
|
||
};
|
||
|
||
const normalizeLongitude = (value) => ((value + 180) % 360 + 360) % 360 - 180;
|
||
|
||
const coverageBoundsPayload = () => {
|
||
if (!coverageSelectedBounds || !coverageSelectedBounds.isValid()) return null;
|
||
const south = Math.max(-85.05112878, Math.min(85.05112878, coverageSelectedBounds.getSouth()));
|
||
const north = Math.max(-85.05112878, Math.min(85.05112878, coverageSelectedBounds.getNorth()));
|
||
const west = normalizeLongitude(coverageSelectedBounds.getWest());
|
||
const east = normalizeLongitude(coverageSelectedBounds.getEast());
|
||
if (south >= north || west >= east) return null;
|
||
return [south, west, north, east].map((value) => Number(value.toFixed(6)));
|
||
};
|
||
|
||
const formatCoverageSelection = (bounds) => {
|
||
if (!bounds) return "";
|
||
const [south, west, north, east] = bounds;
|
||
return "已框选:南 " + south.toFixed(5) + "°、西 " + west.toFixed(5) +
|
||
"°、北 " + north.toFixed(5) + "°、东 " + east.toFixed(5) + "°。";
|
||
};
|
||
|
||
const syncCoverageRangeControls = () => {
|
||
const bounds = coverageBoundsPayload();
|
||
const refreshable = selectedRefreshableIds();
|
||
const deletable = selectedIds();
|
||
const rangeApiReady = Boolean(coverageData && coverageData.rangeOperations);
|
||
$("clearCoverageRange").disabled = busy || !coverageSelectedBounds;
|
||
$("refreshCoverageRange").disabled = busy || !rangeApiReady || !bounds ||
|
||
refreshable.length === 0 || !currentStatus || currentStatus.cacheOnly;
|
||
$("deleteCoverageRange").disabled = busy || !rangeApiReady || !bounds ||
|
||
deletable.length === 0 || !$("coverageDeleteConfirm").checked;
|
||
if (coverageSelectionMode) {
|
||
$("coverageSelectionStatus").textContent = "框选模式:在覆盖地图上按住鼠标并拖动,松开后完成范围选择。";
|
||
} else if (coverageSelectedBounds && !bounds) {
|
||
$("coverageSelectionStatus").textContent = "该范围跨过 180° 经线或面积无效,请清除后重新框选。";
|
||
} else if (bounds) {
|
||
const categoryHint = !deletable.length
|
||
? "请在上方勾选要操作的缓存类别。"
|
||
: refreshable.length ? "" : "所选类别只能删除,不能在线更新。";
|
||
const compatibilityHint = rangeApiReady
|
||
? ""
|
||
: "服务器尚未加载范围操作后端,请重启 Map Studio 服务后再试。";
|
||
$("coverageSelectionStatus").textContent = formatCoverageSelection(bounds) + categoryHint + compatibilityHint;
|
||
} else {
|
||
$("coverageSelectionStatus").textContent = "尚未框选。点击“在地图上框选范围”后,按住鼠标拖出矩形。";
|
||
}
|
||
};
|
||
|
||
const setCoverageSelectionMode = (enabled) => {
|
||
coverageSelectionMode = Boolean(enabled);
|
||
$("selectCoverageRange").classList.toggle("active", coverageSelectionMode);
|
||
$("selectCoverageRange").textContent = coverageSelectionMode ? "退出框选" : "在地图上框选范围";
|
||
$("cacheCoverageMap").classList.toggle("coverage-select-mode", coverageSelectionMode);
|
||
if (coverageMap) {
|
||
if (coverageSelectionMode) {
|
||
coverageMap.dragging.disable();
|
||
coverageMap.boxZoom.disable();
|
||
} else {
|
||
coverageMap.dragging.enable();
|
||
coverageMap.boxZoom.enable();
|
||
}
|
||
}
|
||
syncCoverageRangeControls();
|
||
};
|
||
|
||
const clearCoverageSelection = () => {
|
||
coverageSelectedBounds = null;
|
||
coverageSelectionStart = null;
|
||
coveragePointerId = null;
|
||
if (coverageSelectionLayer) coverageSelectionLayer.clearLayers();
|
||
coverageSelectionRect = null;
|
||
$("coverageDeleteConfirm").checked = false;
|
||
setCoverageSelectionMode(false);
|
||
};
|
||
|
||
const coveragePointerLatLng = (container, event) => {
|
||
const rect = container.getBoundingClientRect();
|
||
return coverageMap.containerPointToLatLng(L.point(event.clientX - rect.left, event.clientY - rect.top));
|
||
};
|
||
|
||
const beginCoverageSelection = (event) => {
|
||
if (!coverageSelectionMode || !coverageMap || (event.pointerType === "mouse" && event.button !== 0)) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
coverageSelectionLayer.clearLayers();
|
||
coverageSelectedBounds = null;
|
||
coverageSelectionStart = {
|
||
latlng: coveragePointerLatLng(event.currentTarget, event),
|
||
point: L.point(event.clientX, event.clientY)
|
||
};
|
||
coveragePointerId = event.pointerId;
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
coverageSelectionRect = L.rectangle(
|
||
L.latLngBounds(coverageSelectionStart.latlng, coverageSelectionStart.latlng),
|
||
{
|
||
color: "#b45309",
|
||
weight: 2,
|
||
dashArray: "7 4",
|
||
fillColor: "#f59e0b",
|
||
fillOpacity: 0.12,
|
||
interactive: false
|
||
}
|
||
).addTo(coverageSelectionLayer);
|
||
syncCoverageRangeControls();
|
||
};
|
||
|
||
const moveCoverageSelection = (event) => {
|
||
if (!coverageSelectionStart || event.pointerId !== coveragePointerId || !coverageSelectionRect) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const current = coveragePointerLatLng(event.currentTarget, event);
|
||
coverageSelectionRect.setBounds(L.latLngBounds(coverageSelectionStart.latlng, current));
|
||
};
|
||
|
||
const endCoverageSelection = (event) => {
|
||
if (!coverageSelectionStart || event.pointerId !== coveragePointerId) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const endPoint = L.point(event.clientX, event.clientY);
|
||
if (coverageSelectionStart.point.distanceTo(endPoint) < 5) {
|
||
coverageSelectionLayer.clearLayers();
|
||
coverageSelectionRect = null;
|
||
coverageSelectionStart = null;
|
||
coveragePointerId = null;
|
||
$("coverageSelectionStatus").textContent = "拖动范围太小,请重新拖出一个矩形。";
|
||
return;
|
||
}
|
||
coverageSelectedBounds = L.latLngBounds(
|
||
coverageSelectionStart.latlng,
|
||
coveragePointerLatLng(event.currentTarget, event)
|
||
);
|
||
coverageSelectionStart = null;
|
||
coveragePointerId = null;
|
||
if (coverageSelectionRect) {
|
||
coverageSelectionRect.setStyle({ color: "#b45309", weight: 2, fillOpacity: 0.14 });
|
||
}
|
||
setCoverageSelectionMode(false);
|
||
};
|
||
|
||
const initializeCoverageMap = () => {
|
||
if (coverageMap) return true;
|
||
if (!window.L) {
|
||
$("coverageStatus").textContent = "地图组件未加载,暂时无法显示覆盖范围。";
|
||
return false;
|
||
}
|
||
coverageMap = L.map("cacheCoverageMap", {
|
||
center: [35, 104],
|
||
zoom: 4,
|
||
minZoom: 2,
|
||
maxZoom: 19,
|
||
worldCopyJump: true,
|
||
preferCanvas: true
|
||
});
|
||
L.tileLayer(api("tiles/clean/{z}/{x}/{y}.png"), {
|
||
minZoom: 2,
|
||
maxZoom: 19,
|
||
attribution: "Map Studio 缓存底图"
|
||
}).addTo(coverageMap);
|
||
L.tileLayer(api("tiles/clean-labels/{z}/{x}/{y}.png"), {
|
||
minZoom: 2,
|
||
maxZoom: 19,
|
||
opacity: 0.82
|
||
}).addTo(coverageMap);
|
||
const tilePane = coverageMap.createPane("coverageTilePane");
|
||
tilePane.style.zIndex = "350";
|
||
tilePane.style.pointerEvents = "none";
|
||
coverageTileCanvas = document.createElement("canvas");
|
||
coverageTileCanvas.className = "coverage-tile-canvas";
|
||
coverageTileCanvas.setAttribute("aria-hidden", "true");
|
||
tilePane.appendChild(coverageTileCanvas);
|
||
coverageTileContext = coverageTileCanvas.getContext("2d");
|
||
coverageLayer = L.layerGroup().addTo(coverageMap);
|
||
coverageSelectionLayer = L.layerGroup().addTo(coverageMap);
|
||
coverageHoverTooltip = L.tooltip({
|
||
direction: "top",
|
||
offset: [0, -8],
|
||
opacity: 0.96,
|
||
className: "coverage-tile-tooltip"
|
||
});
|
||
coverageMap.on("moveend zoomend resize viewreset", () => {
|
||
scheduleCoverageTileDraw();
|
||
});
|
||
coverageMap.on("mousemove", handleCoverageTileHover);
|
||
coverageMap.on("mouseout", () => {
|
||
if (coverageHoverTooltip && coverageMap.hasLayer(coverageHoverTooltip)) {
|
||
coverageMap.removeLayer(coverageHoverTooltip);
|
||
}
|
||
});
|
||
const coverageContainer = coverageMap.getContainer();
|
||
coverageContainer.addEventListener("pointerdown", beginCoverageSelection, true);
|
||
coverageContainer.addEventListener("pointermove", moveCoverageSelection, true);
|
||
coverageContainer.addEventListener("pointerup", endCoverageSelection, true);
|
||
coverageContainer.addEventListener("pointercancel", (event) => {
|
||
if (event.pointerId !== coveragePointerId) return;
|
||
coverageSelectionStart = null;
|
||
coveragePointerId = null;
|
||
}, true);
|
||
if ("ResizeObserver" in window) {
|
||
coverageResizeObserver = new ResizeObserver(() => {
|
||
if (coverageMap && $("cacheCoverageMap").clientWidth > 0) {
|
||
coverageMap.invalidateSize(false);
|
||
}
|
||
});
|
||
coverageResizeObserver.observe($("cacheCoverageMap"));
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const populateCoverageZooms = (categoryIdSet = null) => {
|
||
const previousZoom = $("coverageZoom").value || "all";
|
||
const counts = new Map();
|
||
const categories = coverageData.categories.filter((category) =>
|
||
category.spatial && (!categoryIdSet || categoryIdSet.has(category.id))
|
||
);
|
||
const rangeCount = categories.reduce((total, category) => {
|
||
const responseRange = category.responseOnly || category.parentId || category.id === "boundaries";
|
||
return total + (category.mode === "areas" || responseRange ? Number(category.mappedFiles || 0) : 0);
|
||
}, 0);
|
||
categories.forEach((category) => {
|
||
category.zooms.forEach((entry) => {
|
||
counts.set(entry.zoom, (counts.get(entry.zoom) || 0) + Number(entry.tiles || 0));
|
||
});
|
||
});
|
||
const options = [new Option("范围总览(全部级别)", "all")];
|
||
Array.from(counts.keys()).sort((a, b) => a - b).forEach((zoom) => {
|
||
const tileCount = Number(counts.get(zoom) || 0);
|
||
const parts = [tileCount.toLocaleString("zh-CN") + " 个瓦片"];
|
||
if (rangeCount) {
|
||
parts.push(rangeCount.toLocaleString("zh-CN") + " 个结果范围");
|
||
}
|
||
options.push(new Option(`Z${zoom} · ${parts.join(" + ")}`, String(zoom)));
|
||
});
|
||
$("coverageZoom").replaceChildren(...options);
|
||
$("coverageZoom").disabled = counts.size === 0;
|
||
if (Array.from($("coverageZoom").options).some((option) => option.value === previousZoom)) {
|
||
$("coverageZoom").value = previousZoom;
|
||
}
|
||
};
|
||
|
||
const coverageResponseCategories = () => (coverageData?.categories || []).filter((category) =>
|
||
category.spatial && (category.responseOnly || category.parentId || category.id === "boundaries")
|
||
);
|
||
|
||
const syncCoverageLayerFilterControls = () => {
|
||
document.querySelectorAll("#coverageLayerFilters input[data-coverage-layer]").forEach((input) => {
|
||
input.checked = !coverageHiddenIds.has(input.dataset.coverageLayer);
|
||
});
|
||
};
|
||
|
||
const populateCoverageLayerFilters = () => {
|
||
const categories = coverageResponseCategories();
|
||
const available = new Set(categories.map((category) => category.id));
|
||
Array.from(coverageHiddenIds).forEach((id) => {
|
||
if (!available.has(id)) coverageHiddenIds.delete(id);
|
||
});
|
||
$("coverageResponseControls").hidden = categories.length === 0;
|
||
const controls = categories.map((category) => {
|
||
const label = document.createElement("label");
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.dataset.coverageLayer = category.id;
|
||
checkbox.checked = !coverageHiddenIds.has(category.id);
|
||
const swatch = document.createElement("i");
|
||
swatch.className = "coverage-swatch";
|
||
swatch.style.background = COVERAGE_COLORS[category.id] || "#205c46";
|
||
const text = document.createElement("span");
|
||
const rangeCount = Number(category.mappedFiles || 0).toLocaleString("zh-CN");
|
||
const tileCount = Number(category.totalTiles || 0).toLocaleString("zh-CN");
|
||
text.textContent = category.label + " · " + rangeCount + " 个视窗范围" +
|
||
(Number(category.totalTiles || 0) ? " · " + tileCount + " 个来源瓦片" : "");
|
||
checkbox.addEventListener("change", () => {
|
||
if (checkbox.checked) coverageHiddenIds.delete(category.id);
|
||
else coverageHiddenIds.add(category.id);
|
||
if (coverageData && coverageMap) renderCoverage(coverageShowAll, false);
|
||
});
|
||
label.append(checkbox, swatch, text);
|
||
return label;
|
||
});
|
||
$("coverageLayerFilters").replaceChildren(...controls);
|
||
};
|
||
|
||
const coverageCategoryIds = (showAll) => {
|
||
const spatial = coverageData.categories.filter((category) => category.spatial);
|
||
const selected = new Set(selectedIds());
|
||
return new Set(spatial.filter((category) =>
|
||
!coverageHiddenIds.has(category.id) &&
|
||
(showAll || selected.has(category.id) || selected.has(category.parentId))
|
||
).map((category) => category.id));
|
||
};
|
||
|
||
const renderCoverage = (showAll, fit = true) => {
|
||
if (!coverageData || !initializeCoverageMap()) return;
|
||
coverageShowAll = showAll;
|
||
let categoryIds = coverageCategoryIds(showAll);
|
||
let fallbackToAll = false;
|
||
if (!categoryIds.size && coverageHiddenIds.size === 0) {
|
||
categoryIds = coverageCategoryIds(true);
|
||
fallbackToAll = true;
|
||
}
|
||
populateCoverageZooms(categoryIds);
|
||
coverageLayer.clearLayers();
|
||
coverageDisplayBounds = null;
|
||
coverageFocusBounds = null;
|
||
coverageTileCategories = [];
|
||
coverageTileRenderStats = null;
|
||
if (coverageHoverTooltip && coverageMap.hasLayer(coverageHoverTooltip)) {
|
||
coverageMap.removeLayer(coverageHoverTooltip);
|
||
}
|
||
coverageMap.invalidateSize(false);
|
||
const legend = [];
|
||
let drawn = 0;
|
||
let drawnRanges = 0;
|
||
const extendBounds = (bounds) => {
|
||
if (!bounds || !bounds.isValid()) return;
|
||
if (!coverageDisplayBounds) coverageDisplayBounds = L.latLngBounds(bounds);
|
||
else coverageDisplayBounds.extend(bounds);
|
||
};
|
||
const zoomValue = $("coverageZoom").value;
|
||
const exactZoom = zoomValue === "all" ? null : Number(zoomValue);
|
||
coverageTileZoom = exactZoom;
|
||
coverageData.categories.forEach((category) => {
|
||
if (!categoryIds.has(category.id) || !category.spatial) return;
|
||
const color = COVERAGE_COLORS[category.id] || "#205c46";
|
||
const responseRange = category.responseOnly || category.parentId || category.id === "boundaries";
|
||
const zoomData = exactZoom === null
|
||
? null
|
||
: (category.zooms || []).find((entry) => entry.zoom === exactZoom);
|
||
if (zoomData) {
|
||
if (zoomData.bounds) {
|
||
extendBounds(L.latLngBounds(
|
||
[zoomData.bounds[0], zoomData.bounds[1]],
|
||
[zoomData.bounds[2], zoomData.bounds[3]]
|
||
));
|
||
}
|
||
coverageTileCategories.push({
|
||
id: category.id,
|
||
label: category.label,
|
||
color,
|
||
zoomData,
|
||
runRows: coverageTileLookup(zoomData)
|
||
});
|
||
drawn += zoomData.tiles;
|
||
if (responseRange) {
|
||
drawnRanges += Number(category.mappedFiles || 0);
|
||
}
|
||
} else if (category.bounds) {
|
||
const bounds = L.latLngBounds(
|
||
[category.bounds[0], category.bounds[1]],
|
||
[category.bounds[2], category.bounds[3]]
|
||
);
|
||
if (exactZoom === null) {
|
||
L.rectangle(bounds, {
|
||
color,
|
||
weight: 2,
|
||
dashArray: category.mode === "areas" || responseRange ? "6 4" : null,
|
||
fillColor: color,
|
||
fillOpacity: category.mode === "areas" || responseRange ? 0.06 : 0.1,
|
||
interactive: true
|
||
}).bindTooltip(category.label).addTo(coverageLayer);
|
||
}
|
||
extendBounds(bounds);
|
||
if ((category.areas || []).length) {
|
||
category.areas.forEach((area) => {
|
||
const areaBounds = L.latLngBounds([area[0], area[1]], [area[2], area[3]]);
|
||
L.rectangle(areaBounds, {
|
||
color,
|
||
weight: 0.8,
|
||
opacity: 0.3,
|
||
fillOpacity: 0,
|
||
dashArray: "3 4",
|
||
interactive: false
|
||
}).addTo(coverageLayer);
|
||
});
|
||
}
|
||
if (responseRange) drawnRanges += Number(category.mappedFiles || 0);
|
||
else if (category.mode === "tiles") drawn += Number(category.totalTiles || 0);
|
||
else drawnRanges += Number(category.mappedFiles || 0);
|
||
}
|
||
legend.push({ category, color });
|
||
});
|
||
$("coverageLegend").replaceChildren(...legend.map((item) => {
|
||
const label = document.createElement("span");
|
||
const swatch = document.createElement("i");
|
||
swatch.className = "coverage-swatch";
|
||
swatch.style.background = item.color;
|
||
label.append(swatch, document.createTextNode(item.category.label));
|
||
return label;
|
||
}));
|
||
$("fitCoverage").disabled = !coverageDisplayBounds;
|
||
coverageFocusBounds = findCoverageFocusBounds(exactZoom)?.bounds || null;
|
||
$("fitCoverageTiles").disabled = !coverageFocusBounds;
|
||
if (fit && exactZoom !== null && coverageFocusBounds) {
|
||
focusCoverageTiles();
|
||
} else if (fit && coverageDisplayBounds) {
|
||
coverageMap.fitBounds(coverageDisplayBounds, { padding: [24, 24], maxZoom: exactZoom || 12 });
|
||
}
|
||
const scope = fallbackToAll
|
||
? "未勾选可显示类别,已显示全部空间范围。"
|
||
: showAll ? "显示全部类别。" : "显示所选类别。";
|
||
const responseZoomShown = exactZoom !== null && coverageData.categories.some((category) =>
|
||
categoryIds.has(category.id) && (category.zooms || []).some((entry) => entry.zoom === exactZoom)
|
||
);
|
||
const detail = exactZoom === null
|
||
? "范围总览:瓦片类别为整体边界,查询结果类别为结果要素范围。"
|
||
: responseZoomShown
|
||
? "Z" + exactZoom + ":查询响应按所属来源瓦片显示,可查看每个瓦片边框。"
|
||
: "Z" + exactZoom + ":按实际缓存瓦片显示空白与覆盖区域。";
|
||
const drawnParts = [Number(drawn || 0).toLocaleString("zh-CN") + " 个瓦片"];
|
||
if (drawnRanges) {
|
||
drawnParts.push(Number(drawnRanges).toLocaleString("zh-CN") + " 个查询结果范围");
|
||
}
|
||
const responseLayerShown = coverageData.categories.some((category) =>
|
||
categoryIds.has(category.id) && (category.responseOnly || category.parentId || category.id === "boundaries")
|
||
);
|
||
coverageStatusBase = scope + detail + " 已绘制 " +
|
||
drawnParts.join(" + ") + "。索引生成于 " + formatTime(coverageData.generatedAt) +
|
||
(coverageData.elapsedMs ? ",扫描耗时 " + coverageData.elapsedMs + " ms。" : "。") +
|
||
(responseLayerShown ? " 查询响应只覆盖已查询视窗,空白不代表全球无数据。" : "");
|
||
updateCoverageTileStatus();
|
||
if (coverageDisplayBounds) {
|
||
setCoverageMapMessage("");
|
||
} else {
|
||
const emptyMessage = coverageHiddenIds.size
|
||
? "当前没有可显示的图层:请在“查询响应专题图层”中重新勾选要查看的范围。"
|
||
: "当前缓存索引中没有可定位的空间数据。请先浏览地图或下载瓦片缓存,再点“重新扫描覆盖范围”。";
|
||
setCoverageMapMessage(emptyMessage);
|
||
coverageStatusBase = "";
|
||
$("coverageStatus").textContent = emptyMessage;
|
||
}
|
||
scheduleCoverageLayout();
|
||
scheduleCoverageTileDraw();
|
||
syncCoverageRangeControls();
|
||
};
|
||
|
||
const loadCoverage = async (refresh = false, showAll = true) => {
|
||
if (coverageLoading || !initializeCoverageMap()) return;
|
||
coverageLoading = true;
|
||
$("coveragePanel").setAttribute("aria-busy", "true");
|
||
$("refreshCoverage").disabled = true;
|
||
setCoverageMapMessage(refresh
|
||
? "正在扫描缓存文件,请稍候…"
|
||
: "正在读取缓存覆盖索引…");
|
||
$("coverageStatus").textContent = refresh
|
||
? "正在重新扫描缓存文件并生成覆盖索引,首次扫描大型缓存可能需要一些时间…"
|
||
: "正在读取缓存覆盖索引…";
|
||
try {
|
||
const response = await fetch(
|
||
api("api/cache-admin/coverage" + (refresh ? "?refresh=1" : "")),
|
||
{ cache: "no-store", credentials: "same-origin", headers: { Accept: "application/json" } }
|
||
);
|
||
coverageData = await parseResponse(response);
|
||
populateCoverageLayerFilters();
|
||
populateCoverageZooms();
|
||
$("coverageZoom").value = "all";
|
||
coverageMap.invalidateSize();
|
||
renderCoverage(showAll, true);
|
||
scheduleCoverageLayout({ fit: true, reveal: true });
|
||
} catch (error) {
|
||
const message = "加载覆盖范围失败:" + error.message;
|
||
$("coverageStatus").textContent = message;
|
||
setCoverageMapMessage(message);
|
||
} finally {
|
||
coverageLoading = false;
|
||
$("coveragePanel").removeAttribute("aria-busy");
|
||
$("refreshCoverage").disabled = false;
|
||
}
|
||
};
|
||
|
||
const loadStatus = async (refreshStats, ignoreBusy = false) => {
|
||
if (busy && !ignoreBusy) return;
|
||
setBusy(true);
|
||
try {
|
||
const response = await fetch(
|
||
api("api/cache-admin/status" + (refreshStats ? "?refresh=1" : "")),
|
||
{ cache: "no-store", credentials: "same-origin", headers: { Accept: "application/json" } }
|
||
);
|
||
const data = await parseResponse(response);
|
||
showDashboard(data);
|
||
} catch (error) {
|
||
if (!document.body.classList.contains("auth-locked")) showLogin(error.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
$("loginForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
$("loginError").textContent = "";
|
||
const button = event.submitter || $("loginForm").querySelector("button");
|
||
button.disabled = true;
|
||
try {
|
||
const response = await fetch(api("api/admin/login"), {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({ password: $("adminPassword").value })
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.error || "登录失败");
|
||
$("adminPassword").value = "";
|
||
await loadStatus(false);
|
||
} catch (error) {
|
||
$("loginError").textContent = error.message;
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
|
||
$("logoutButton").addEventListener("click", async () => {
|
||
$("logoutButton").disabled = true;
|
||
try {
|
||
const cookiePath = new URL("./", document.baseURI).pathname;
|
||
const response = await fetch(api("api/admin/logout"), {
|
||
method: "POST",
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({ cookiePath })
|
||
});
|
||
await parseResponse(response);
|
||
} catch (error) {
|
||
// The local UI still returns to the login screen after a logout attempt.
|
||
}
|
||
currentStatus = null;
|
||
showLogin("已退出管理员会话");
|
||
$("logoutButton").disabled = false;
|
||
});
|
||
|
||
$("openMapDownload").addEventListener("click", () => {
|
||
window.open("openmap-admin.html", "_blank", "noopener");
|
||
});
|
||
$("selectAll").addEventListener("change", (event) => {
|
||
document.querySelectorAll("#categoryList input[data-category]").forEach((input) => {
|
||
input.checked = event.currentTarget.checked;
|
||
});
|
||
syncControls();
|
||
});
|
||
|
||
$("deleteConfirm").addEventListener("change", syncControls);
|
||
|
||
$("coveragePanel").addEventListener("toggle", () => {
|
||
if (!$("coveragePanel").open) {
|
||
window.clearTimeout(coverageLayoutTimer);
|
||
return;
|
||
}
|
||
$("coveragePanel").scrollIntoView({ block: "start", behavior: "auto" });
|
||
if (!coverageData) {
|
||
void loadCoverage(false, selectedIds().length === 0);
|
||
} else if (coverageMap) {
|
||
scheduleCoverageLayout({ fit: true, reveal: true });
|
||
}
|
||
});
|
||
|
||
$("coverageZoom").addEventListener("change", () => {
|
||
if (coverageData) renderCoverage(coverageShowAll, true);
|
||
});
|
||
|
||
$("showSelectedCoverage").addEventListener("click", async () => {
|
||
if (!coverageData) await loadCoverage(false, false);
|
||
else renderCoverage(false, true);
|
||
});
|
||
|
||
$("showAllCoverage").addEventListener("click", async () => {
|
||
coverageHiddenIds.clear();
|
||
syncCoverageLayerFilterControls();
|
||
if (!coverageData) await loadCoverage(false, true);
|
||
else renderCoverage(true, true);
|
||
});
|
||
|
||
$("refreshCoverage").addEventListener("click", async () => {
|
||
await loadCoverage(true, coverageShowAll);
|
||
});
|
||
|
||
$("fitCoverage").addEventListener("click", () => {
|
||
if (coverageMap && coverageDisplayBounds) {
|
||
coverageMap.fitBounds(coverageDisplayBounds, { padding: [24, 24], maxZoom: 14 });
|
||
scheduleCoverageTileDraw();
|
||
}
|
||
});
|
||
|
||
$("fitCoverageTiles").addEventListener("click", () => {
|
||
focusCoverageTiles({ reveal: true });
|
||
});
|
||
|
||
$("refreshStats").addEventListener("click", async () => {
|
||
setActivity("正在重新统计托管缓存…");
|
||
await loadStatus(true);
|
||
});
|
||
|
||
const adminPost = async (path, payload) => {
|
||
const response = await fetch(api(path), {
|
||
method: "POST",
|
||
cache: "no-store",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
return parseResponse(response);
|
||
};
|
||
|
||
$("selectCoverageRange").addEventListener("click", () => {
|
||
setCoverageSelectionMode(!coverageSelectionMode);
|
||
});
|
||
|
||
$("clearCoverageRange").addEventListener("click", clearCoverageSelection);
|
||
$("coverageDeleteConfirm").addEventListener("change", syncCoverageRangeControls);
|
||
|
||
$("refreshCoverageRange").addEventListener("click", async () => {
|
||
const bounds = coverageBoundsPayload();
|
||
const categories = selectedRefreshableIds();
|
||
if (busy || !bounds || !categories.length) return;
|
||
setBusy(true);
|
||
setActivity("正在安排所选地图范围内的缓存更新…");
|
||
try {
|
||
const data = await adminPost("api/cache-admin/refresh", {
|
||
categories,
|
||
force: $("forceRefresh").checked,
|
||
bounds
|
||
});
|
||
const scheduled = Number(data.scheduled || 0);
|
||
setActivity(
|
||
"地图范围更新已安排:共 " + scheduled.toLocaleString("zh-CN") + " 个 Z/X/Y 瓦片。\n" +
|
||
"只处理框选范围内、已勾选且允许在线更新的瓦片。" +
|
||
(scheduled ? " 服务器会在后台按瓦片分批请求;可稍后重新扫描覆盖范围查看状态。" : " 没有发现需要更新的瓦片。")
|
||
);
|
||
} catch (error) {
|
||
setActivity("地图范围更新失败:" + error.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
$("deleteCoverageRange").addEventListener("click", async () => {
|
||
const bounds = coverageBoundsPayload();
|
||
const categories = selectedIds();
|
||
if (busy || !bounds || !categories.length || !$("coverageDeleteConfirm").checked) return;
|
||
setBusy(true);
|
||
setActivity("正在删除所选地图范围内的缓存…");
|
||
try {
|
||
const data = await adminPost("api/cache-admin/delete", {
|
||
categories,
|
||
bounds,
|
||
confirmation: "DELETE_CACHE"
|
||
});
|
||
clearCoverageSelection();
|
||
await loadStatus(true, true);
|
||
coverageData = null;
|
||
if ($("coveragePanel").open) await loadCoverage(true, coverageShowAll);
|
||
setActivity(
|
||
"地图范围缓存已删除:共 " + Number(data.deletedFiles || 0).toLocaleString("zh-CN") +
|
||
" 个文件," + formatBytes(data.deletedBytes) + "。\n" +
|
||
"范围外缓存未受影响;覆盖地图已重新扫描。"
|
||
);
|
||
} catch (error) {
|
||
setActivity("地图范围删除失败:" + error.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
$("refreshSelected").addEventListener("click", async () => {
|
||
if (busy) return;
|
||
const categories = selectedRefreshableIds();
|
||
if (!categories.length) return;
|
||
setBusy(true);
|
||
setActivity("正在安排缓存更新…");
|
||
try {
|
||
const data = await adminPost("api/cache-admin/refresh", {
|
||
categories,
|
||
force: $("forceRefresh").checked
|
||
});
|
||
const scheduled = Number(data.scheduled || 0);
|
||
if (scheduled) {
|
||
setActivity(
|
||
"已安排 " + scheduled + " 个 Z/X/Y 瓦片更新。\n" +
|
||
"服务器会在后台按瓦片分批向数据源请求;可稍后点击“重新统计”查看结果。"
|
||
);
|
||
} else {
|
||
setActivity("没有发现需要更新的瓦片。取消“更新全部已有瓦片”只会处理已过期内容。");
|
||
}
|
||
} catch (error) {
|
||
setActivity("更新失败:" + error.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
$("deleteSelected").addEventListener("click", async () => {
|
||
if (busy || !$("deleteConfirm").checked) return;
|
||
const categories = selectedIds();
|
||
if (!categories.length) return;
|
||
setBusy(true);
|
||
setActivity("正在删除所选缓存…");
|
||
try {
|
||
const data = await adminPost("api/cache-admin/delete", {
|
||
categories,
|
||
confirmation: "DELETE_CACHE"
|
||
});
|
||
$("deleteConfirm").checked = false;
|
||
await loadStatus(true, true);
|
||
coverageData = null;
|
||
if ($("coveragePanel").open) {
|
||
$("coverageStatus").textContent = "缓存内容已变化,请点击“重新扫描覆盖范围”更新地图。";
|
||
}
|
||
setActivity(
|
||
"缓存已删除:共 " + Number(data.deletedFiles || 0).toLocaleString("zh-CN") +
|
||
" 个文件," + formatBytes(data.deletedBytes) + "。"
|
||
);
|
||
} catch (error) {
|
||
setActivity("删除失败:" + error.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
loadStatus(false);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|