Files
MapStudio/cache-admin.html
dearsky a36b14dc45 上传文件至「/」
将所有图层处理为瓦片,并新增瓦片管理能力
2026-11-09 00:01:21 +08:00

1410 lines
65 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>