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

902 lines
44 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>地图离线预下载</title>
<link rel="stylesheet" href="vendor/leaflet/leaflet.css">
<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 14px; font-size: 16px; }
p { color: #607066; line-height: 1.65; }
.status { padding: 8px 12px; border-radius: 999px; background: #edf3ee; font-size: 12px; font-weight: 700; white-space: nowrap; }
.status.ok { color: #1f6f49; background: #eaf6ed; }
.status.warn { color: #9a5b1f; background: #fff4df; }
.layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(300px, .8fr); gap: 16px; }
.map-section { margin-bottom: 16px; }
.map-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.map-heading h2 { margin: 0; }
.map-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.basemap-row { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin: 0 0 10px; }
.basemap-row strong { margin-right: 4px; font-size: 11px; color: #536259; }
.basemap-option { padding: 7px 10px; border-radius: 8px; font-size: 11px; }
.basemap-option.active { color: #fff; background: #205c46; border-color: #205c46; }
.basemap-note { margin: -3px 0 9px; color: #718077; font-size: 10px; }
.basemap-status { margin: 0 0 8px; color: #718077; font-size: 10px; }
#tileSelectionMap { width: 100%; height: 400px; border: 1px solid #d4ddd5; border-radius: 12px; overflow: hidden; background: #eef2ee; }
#tileSelectionMap.selection-active { cursor: crosshair; }
.map-hint { margin: 8px 0 0; font-size: 11px; }
.providers { display: grid; gap: 8px; }
.provider-set-actions { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 11px; }
.provider-set-actions button { padding: 7px 10px; font-size: 10px; }
.provider { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 10px; align-items: start; padding: 11px; border: 1px solid #dfe6df; border-radius: 10px; cursor: pointer; }
.provider:hover { border-color: #b8cbbd; background: #fbfdfb; }
.provider small { display: block; color: #718077; margin-top: 3px; line-height: 1.45; }
.count { color: #516158; font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
label { color: #536259; font-size: 11px; font-weight: 700; }
input[type="number"], input[type="text"], input[type="password"] { display: block; width: 100%; margin-top: 5px; padding: 9px; border: 1px solid #d4ddd5; border-radius: 8px; font: inherit; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin-top: 16px; }
button { padding: 10px 14px; 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:disabled { opacity: .45; cursor: not-allowed; }
.force-option { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.estimate { margin: 14px 0 0; padding: 12px; border-radius: 10px; background: #f5f8f4; color: #405148; font-size: 12px; }
.estimate.over { color: #a64232; background: #fff0ed; }
.zoom-ranges { margin-top: 10px; padding: 10px 12px; border-radius: 9px; background: #f7f9f6; color: #526158; font-size: 10px; line-height: 1.65; white-space: pre-wrap; }
progress { width: 100%; height: 14px; margin-top: 12px; accent-color: #205c46; }
pre { margin: 12px 0 0; padding: 12px; max-height: 240px; overflow: auto; border-radius: 10px; background: #17231b; color: #dce9df; font-size: 11px; white-space: pre-wrap; }
code { padding: 2px 5px; border-radius: 5px; background: #edf2ed; }
.source-summary { margin: 16px 0 0; padding: 12px; border: 1px solid #e2e8e2; border-radius: 10px; background: #fafcf9; font-size: 11px; line-height: 1.7; }
.source-summary strong { color: #2d4736; }
.source-summary span { display: block; color: #627168; }
.upstream-proxy { margin: 0 0 14px; padding: 12px; border: 1px solid #dfe6df; border-radius: 10px; background: #fafcf9; }
.upstream-proxy strong { display: block; margin-bottom: 4px; font-size: 11px; }
.upstream-proxy p { margin: 0 0 9px; font-size: 10px; }
.upstream-proxy-mode { display: grid; gap: 5px; margin-bottom: 9px; color: #536259; font-size: 10px; font-weight: 750; }
.upstream-proxy-mode select { width: 100%; padding: 8px; border: 1px solid #d4ddd5; border-radius: 8px; color: #25372b; background: #fff; font: inherit; }
.upstream-proxy-row { display: flex; gap: 7px; }
.upstream-proxy-row input { flex: 1; min-width: 0; }
.upstream-proxy-row button { padding: 7px 10px; font-size: 10px; white-space: nowrap; }
.upstream-proxy-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 7px; }
.upstream-proxy-actions button { padding: 6px 9px; font-size: 10px; }
.upstream-proxy-status { margin: 8px 0 0 !important; color: #718077; font-size: 10px !important; }
body.auth-locked { display: grid; min-height: 100vh; place-items: center; padding: 20px; }
body.auth-locked main { width: min(520px, 100%); }
body.auth-locked header,
body.auth-locked .map-section,
body.auth-locked .layout { display: none !important; }
body.auth-locked #loginPanel { margin: 0 !important; }
@media (max-width: 820px) {
body { padding: 12px; }
.layout { grid-template-columns: 1fr; }
header, .map-heading { align-items: flex-start; flex-direction: column; }
.fields { grid-template-columns: 1fr 1fr; }
#tileSelectionMap { height: 320px; }
}
</style>
</head>
<body class="auth-locked">
<main>
<header>
<div>
<h1>地图离线预下载</h1>
<p>管理员可提前缓存开放地图、OpenFreeMap 矢量以及 Esri 底图和地形瓦片。浏览器始终只访问本服务。</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<span class="status" id="cachePolicyStatus">缓存策略检查中…</span>
<span class="status" id="authStatus">正在检查管理员登录…</span>
<a href="cache-admin.html" target="_blank" rel="noopener noreferrer">缓存管理</a>
<button type="button" id="logoutButton" hidden>退出</button>
</div>
</header>
<section id="loginPanel" style="margin-bottom:16px">
<h2>管理员登录</h2>
<p>请输入地图下载后台管理员密码。用户名固定为 <strong>admin</strong>。</p>
<form id="loginForm" style="display:flex;gap:9px;max-width:520px">
<input id="adminPassword" type="password" autocomplete="current-password" placeholder="管理员密码" style="flex:1;padding:10px;border:1px solid #d4ddd5;border-radius:8px;font:inherit">
<button type="submit" class="primary">登录</button>
</form>
<p id="loginError" style="color:#a64232;margin-bottom:0"></p>
</section>
<section class="map-section">
<div class="map-heading">
<div>
<h2>1. 在地图上框选下载范围</h2>
<p class="map-hint">点击“开始框选”后拖动鼠标。当前地图缩放会同步为下载缩放范围。</p>
</div>
<div class="map-actions">
<button type="button" id="startSelection">开始框选</button>
<button type="button" id="clearSelection">清除选区</button>
<button type="button" id="fillView">使用主地图视窗</button>
</div>
</div>
<div class="basemap-row">
<strong>框选预览背景</strong>
<button type="button" class="basemap-option active" data-admin-base="clean">简洁底图</button>
<button type="button" class="basemap-option" data-admin-base="osm">标准地图</button>
<button type="button" class="basemap-option" data-admin-base="topo">地形图</button>
<button type="button" class="basemap-option" data-admin-base="satellite">卫星影像</button>
<button type="button" class="basemap-option" data-admin-base="tdt-vec">天地图矢量</button>
<button type="button" class="basemap-option" data-admin-base="tdt-img">天地图影像</button>
<button type="button" class="basemap-option" data-admin-base="tdt-ter">天地图地形</button>
</div>
<p class="basemap-note">背景只用于定位和框选,不是实际下载列表;天地图背景只显示服务器上已经下载的瓦片。</p>
<p class="basemap-status" id="basemapStatus">正在加载简洁底图…</p>
<div id="tileSelectionMap" aria-label="地图离线下载范围选择地图"></div>
</section>
<div class="layout">
<section>
<h2>2. 选择开放与预览图层</h2>
<div class="provider-set-actions">
<button type="button" id="selectPreviewSet">选择离线底图套装</button>
<button type="button" id="selectTopoPreview">仅选 Esri 地形图</button>
<button type="button" id="clearProviderSelection">清空选择</button>
</div>
<div class="providers" id="providers"></div>
<div class="fields">
<label>起始缩放 Z<input id="zoomMin" type="number" min="3" max="14" value="3"></label>
<label>结束缩放 Z<input id="zoomMax" type="number" min="3" max="14" value="14"></label>
<label>范围来源<input id="rangeSource" type="text" value="尚未框选" readonly></label>
</div>
<div class="zoom-ranges" id="zoomTileRanges">框选后显示各缩放级别的瓦片范围</div>
<div class="estimate" id="estimate">请先登录并框选下载范围。</div>
<div class="actions">
<button type="button" class="primary" id="download" disabled>下载已选择的瓦片</button>
<button type="button" id="refreshStatus">刷新缓存统计</button>
<label class="force-option"><input type="checkbox" id="forceDownload"> 覆盖已有瓦片</label>
</div>
</section>
<section>
<h2>3. 后台任务</h2>
<div class="upstream-proxy">
<strong>外网下载代理</strong>
<p>所有服务器外网瓦片、DEM、天地图和 Overpass 下载都将通过此 HTTP/HTTPS 代理;不支持 socks5://。</p>
<label class="upstream-proxy-mode">代理使用方式
<select id="upstreamProxyMode">
<option value="default">使用服务器默认(留空 / 不更改)</option>
<option value="temporary">临时设置(不写入服务器配置)</option>
</select>
</label>
<div class="upstream-proxy-row">
<input id="upstreamProxyInput" type="text" placeholder="例如 http://127.0.0.1:7890" autocomplete="off" disabled>
<button type="button" id="applyTemporaryProxy" disabled>临时应用</button>
</div>
<div class="upstream-proxy-actions">
<button type="button" id="saveUpstreamProxy" disabled>保存为服务器默认</button>
<button type="button" id="useDirectProxy">临时直连</button>
<button type="button" id="resetUpstreamProxy">恢复默认</button>
</div>
<p class="upstream-proxy-status" id="upstreamProxyStatus">正在读取代理配置…</p>
</div>
<label class="force-option" style="margin:0 0 12px">
<input type="checkbox" id="cacheOnlyToggle">
仅使用缓存(禁止云端请求,缺失瓦片显示占位图)
</label>
<p id="jobText">尚未创建下载任务。</p>
<div class="actions">
<button type="button" id="cancelDownload" disabled>中断下载</button>
</div>
<progress id="progress" max="1" value="0"></progress>
<pre id="jobLog">等待任务…</pre>
<div class="source-summary">
<strong>当前可用的开放与预览来源</strong>
<span>OpenStreetMap:标准栅格底图,ODbL。</span>
<span>OpenTopoMap:开放地形下载项;范围预览使用稳定的 Esri 地形背景。</span>
<span>OpenRailwayMap:铁路基础设施叠加层。</span>
<span>OpenFreeMap:全球 MVT 水域、植被、港口、铁路和边界。</span>
<span>AWS Terrain Tiles:等高线、地形晕渲和视线分析。</span>
<span>Esri 简洁底图、简洁底图注记、地形图和卫星影像也可预下载;它们不属于开放许可图层,仅用于本项目离线预览。</span>
<span>完全离线建议下载:简洁底图 + 简洁底图注记 + 卫星影像,以及另一任务中的地形图(最高 Z13)。</span>
</div>
</section>
</div>
</main>
<script src="vendor/leaflet/leaflet.js"></script>
<script src="compliance-gate.js?v=20261108-1"></script>
<script>
(function () {
const serviceBase = new URL("./", document.baseURI).href.replace(/\/$/, "");
const api = (path) => serviceBase + "/" + String(path).replace(/^\/+/, "");
const $ = (id) => document.getElementById(id);
let providers = [];
let providerById = new Map();
let activeJobId = null;
let monitorTimer = null;
let cacheOnlyEnabled = false;
let upstreamProxyTemporary = false;
const pageParams = new URL(window.location.href).searchParams;
const requestedProvider = pageParams.get("provider");
const requestedJobId = pageParams.get("job");
let requestedProviderPending = Boolean(requestedProvider);
let requestedJobPending = Boolean(requestedJobId);
const savedState = (() => {
try { return JSON.parse(localStorage.getItem("mapStudio.workspace.v1") || "null"); }
catch (error) { return null; }
})();
const initialCenter = Array.isArray(savedState?.map?.center)
? savedState.map.center
: [39.9042, 116.4074];
const initialZoom = Number.isFinite(Number(savedState?.map?.zoom))
? Math.round(Number(savedState.map.zoom))
: 11;
const tileMap = L.map("tileSelectionMap", { zoomControl: true })
.setView(initialCenter, Math.max(3, Math.min(18, initialZoom)));
const adminBaseLayers = {
clean: L.tileLayer(api("tiles/clean/{z}/{x}/{y}.png"), {
maxZoom: 19,
attribution: "&copy; Esri"
}),
osm: L.tileLayer(api("tiles/osm/{z}/{x}/{y}.png"), {
maxZoom: 19,
attribution: "&copy; OpenStreetMap contributors"
}),
topo: L.tileLayer(api("tiles/topo-preview/{z}/{x}/{y}.png"), {
maxZoom: 19,
maxNativeZoom: 13,
attribution: "Tiles &copy; Esri"
}),
satellite: L.tileLayer(api("tiles/satellite/{z}/{x}/{y}.png"), {
maxZoom: 19,
attribution: "Tiles &copy; Esri"
}),
"tdt-vec": L.tileLayer(api("tiles/tdt-vec/{z}/{x}/{y}.png"), {
maxZoom: 18,
attribution: "&copy; 天地图"
}),
"tdt-img": L.tileLayer(api("tiles/tdt-img/{z}/{x}/{y}.png"), {
maxZoom: 18,
attribution: "&copy; 天地图"
}),
"tdt-ter": L.tileLayer(api("tiles/tdt-ter/{z}/{x}/{y}.png"), {
maxZoom: 18,
attribution: "&copy; 天地图"
})
};
const adminLabelLayers = {
clean: L.tileLayer(api("tiles/clean-labels/{z}/{x}/{y}.png"), { maxZoom: 19, opacity: 0.88 }),
"tdt-vec": L.tileLayer(api("tiles/tdt-vec-label/{z}/{x}/{y}.png"), { maxZoom: 18, opacity: 0.95 }),
"tdt-img": L.tileLayer(api("tiles/tdt-img-label/{z}/{x}/{y}.png"), { maxZoom: 18, opacity: 0.95 }),
"tdt-ter": L.tileLayer(api("tiles/tdt-ter-label/{z}/{x}/{y}.png"), { maxZoom: 18, opacity: 0.95 })
};
const adminBaseNames = {
clean: "简洁底图",
osm: "标准地图",
topo: "地形图",
satellite: "卫星影像",
"tdt-vec": "天地图矢量",
"tdt-img": "天地图影像",
"tdt-ter": "天地图地形"
};
const adminBaseFallbacks = {
topo: "clean",
satellite: "clean",
"tdt-vec": "osm",
"tdt-img": "satellite",
"tdt-ter": "topo"
};
let activeAdminBase = "clean";
const setAdminBase = (name) => {
if (!adminBaseLayers[name]) return;
Object.values(adminBaseLayers).forEach((layer) => layer.off("tileload load tileerror"));
Object.values(adminBaseLayers).forEach((layer) => {
if (tileMap.hasLayer(layer)) tileMap.removeLayer(layer);
});
Object.values(adminLabelLayers).forEach((layer) => {
if (tileMap.hasLayer(layer)) tileMap.removeLayer(layer);
});
activeAdminBase = name;
const layer = adminBaseLayers[name];
layer.addTo(tileMap);
const labelLayer = adminLabelLayers[name];
if (labelLayer) labelLayer.addTo(tileMap);
layer.bringToBack();
document.querySelectorAll("[data-admin-base]").forEach((button) => {
button.classList.toggle("active", button.dataset.adminBase === name);
});
$("basemapStatus").textContent = "正在加载" + adminBaseNames[name] + ",地图 Z" + tileMap.getZoom() + "…";
let tileLoaded = false;
let tileErrors = 0;
let switching = false;
layer.on("tileload", () => {
tileLoaded = true;
});
layer.on("load", () => {
if (activeAdminBase !== name) return;
$("basemapStatus").textContent = adminBaseNames[activeAdminBase] + "已加载 · 当前 Z" + tileMap.getZoom() + ",可在地图上框选。";
});
layer.on("tileerror", () => {
if (activeAdminBase !== name) return;
tileErrors += 1;
const fallback = adminBaseFallbacks[name];
if (!tileLoaded && tileErrors >= 3 && fallback && !switching) {
switching = true;
const failedName = adminBaseNames[name];
setAdminBase(fallback);
$("basemapStatus").textContent = failedName + "无法加载,已自动切换到" + adminBaseNames[fallback] + "。";
return;
}
$("basemapStatus").textContent = adminBaseNames[activeAdminBase] + "部分瓦片加载失败,可切换其他背景地图。";
});
};
let selectionBounds = null;
let selectionRectangle = null;
let selecting = false;
let selectionStart = null;
document.querySelectorAll("[data-admin-base]").forEach((button) => {
button.addEventListener("click", () => setAdminBase(button.dataset.adminBase));
});
setAdminBase("clean");
const tileX = (lon, z) => Math.floor((lon + 180) / 360 * Math.pow(2, z));
const tileY = (lat, z) => {
const radians = lat * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2 * Math.pow(2, z));
};
const selectedProviders = () => Array.from(document.querySelectorAll(".provider input:checked")).map((input) => input.value);
const selectProviderIds = (ids) => {
const wanted = new Set(ids);
document.querySelectorAll(".provider input").forEach((input) => {
input.checked = wanted.has(input.value);
});
syncZoomLimits();
updateEstimate();
};
const applyProviderPreset = (ids, label) => {
const available = new Set(providers.map((provider) => provider.id));
const selected = ids.filter((id) => available.has(id));
const missing = ids.filter((id) => !available.has(id));
if (!selected.length) {
$("jobLog").textContent = label + "所需的图层尚未在当前服务端加载。请重启 Python 服务后刷新本页,已有勾选不会被清空。";
return;
}
selectProviderIds(selected);
if (missing.length) {
$("jobLog").textContent = "已选择当前可用的图层;服务端缺少:" + missing.join("、") + "。请重启 Python 服务后刷新本页。";
}
};
const selectedProviderRecords = () => selectedProviders().map((id) => providerById.get(id)).filter(Boolean);
const allowedZoomBounds = () => {
const selected = selectedProviderRecords();
if (!selected.length) return { min: 3, max: 14 };
return {
min: Math.max(...selected.map((provider) => provider.minZoom)),
max: Math.min(...selected.map((provider) => provider.maxZoom))
};
};
const syncZoomLimits = () => {
const bounds = allowedZoomBounds();
$("zoomMin").min = bounds.min;
$("zoomMin").max = bounds.max;
$("zoomMax").min = bounds.min;
$("zoomMax").max = bounds.max;
let min = Number($("zoomMin").value);
let max = Number($("zoomMax").value);
if (!Number.isFinite(min)) min = bounds.min;
if (!Number.isFinite(max)) max = bounds.max;
min = Math.max(bounds.min, Math.min(bounds.max, Math.round(min)));
max = Math.max(bounds.min, Math.min(bounds.max, Math.round(max)));
if (min > max) max = min;
$("zoomMin").value = min;
$("zoomMax").value = max;
updateEstimate();
};
const resizeSelectionMap = () => {
window.requestAnimationFrame(() => {
tileMap.invalidateSize({ animate: false, pan: false });
window.setTimeout(() => tileMap.invalidateSize({ animate: false, pan: false }), 120);
});
};
const drawSelection = (bounds, source) => {
selectionBounds = L.latLngBounds(bounds.getSouthWest(), bounds.getNorthEast());
if (selectionRectangle) tileMap.removeLayer(selectionRectangle);
selectionRectangle = L.rectangle(selectionBounds, {
color: "#d25a39", weight: 2, fillColor: "#e98b4b", fillOpacity: 0.12, interactive: false
}).addTo(tileMap);
$("rangeSource").value = source;
const z = Math.max(1, Math.min(19, Math.round(tileMap.getZoom())));
$("zoomMin").value = z;
$("zoomMax").value = z;
syncZoomLimits();
};
const clearSelection = () => {
selectionBounds = null;
if (selectionRectangle) tileMap.removeLayer(selectionRectangle);
selectionRectangle = null;
$("rangeSource").value = "尚未框选";
updateEstimate();
};
const setSelectionMode = (active) => {
selecting = active;
selectionStart = null;
$("startSelection").textContent = active ? "正在框选…" : "开始框选";
$("startSelection").classList.toggle("primary", active);
tileMap.getContainer().classList.toggle("selection-active", active);
if (active) tileMap.dragging.disable();
else tileMap.dragging.enable();
};
const showLogin = (message) => {
document.body.classList.add("auth-locked");
$("loginPanel").hidden = false;
$("logoutButton").hidden = true;
$("authStatus").textContent = message || "需要管理员登录";
$("authStatus").className = "status warn";
$("adminPassword").focus();
};
const updateUpstreamProxyPanel = (data) => {
const hasProxyState = Object.prototype.hasOwnProperty.call(data, "upstreamProxyConfigured") ||
Object.prototype.hasOwnProperty.call(data, "configured");
if (!hasProxyState) {
$("upstreamProxyStatus").textContent = "当前服务端未返回代理状态,请重启 Python 服务后刷新页面。";
$("upstreamProxyMode").value = "default";
$("upstreamProxyInput").disabled = true;
$("applyTemporaryProxy").disabled = true;
$("saveUpstreamProxy").disabled = true;
$("resetUpstreamProxy").disabled = true;
return;
}
upstreamProxyTemporary = Boolean(
data.upstreamProxyTemporary ?? data.temporary ?? false
);
const current = data.upstreamProxy ?? data.proxy ?? "";
const defaultProxy = data.upstreamProxyDefault ?? data.defaultProxy ?? "";
if (upstreamProxyTemporary) {
$("upstreamProxyStatus").textContent =
"当前临时代理:" + (current || "直连") +
";服务器默认:" + (defaultProxy || "直连");
} else {
$("upstreamProxyStatus").textContent = defaultProxy
? "正在使用服务器默认代理:" + defaultProxy
: "当前使用服务器默认:直连。";
}
$("upstreamProxyMode").value = upstreamProxyTemporary ? "temporary" : "default";
const temporary = $("upstreamProxyMode").value === "temporary";
$("upstreamProxyInput").disabled = !temporary;
$("applyTemporaryProxy").disabled = !temporary;
$("saveUpstreamProxy").disabled = !temporary;
$("resetUpstreamProxy").disabled = !upstreamProxyTemporary;
};
const submitUpstreamProxy = async (payload) => {
const response = await fetch(api("api/upstream-proxy"), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "HTTP " + response.status);
$("upstreamProxyInput").value = "";
updateUpstreamProxyPanel(data);
};
const selectedTileRanges = () => {
if (!selectionBounds) return { ranges: [], error: "" };
const zoomMin = Math.round(Number($("zoomMin").value));
const zoomMax = Math.round(Number($("zoomMax").value));
const bounds = allowedZoomBounds();
if (!Number.isFinite(zoomMin) || !Number.isFinite(zoomMax)) {
return { ranges: [], error: "请输入有效的缩放范围。" };
}
if (zoomMin > zoomMax) return { ranges: [], error: "起始缩放不能大于结束缩放。" };
if (zoomMin < bounds.min || zoomMax > bounds.max) {
return { ranges: [], error: "所选图层共同支持 Z" + bounds.min + "–Z" + bounds.max + "。" };
}
const ranges = [];
for (let z = zoomMin; z <= zoomMax; z += 1) {
const x0 = tileX(selectionBounds.getWest(), z);
const x1 = tileX(selectionBounds.getEast(), z);
const y0 = tileY(selectionBounds.getNorth(), z);
const y1 = tileY(selectionBounds.getSouth(), z);
ranges.push({ z, x0, x1, y0, y1, count: (x1 - x0 + 1) * (y1 - y0 + 1) });
}
return { ranges, error: "" };
};
function updateEstimate() {
const selected = selectedProviders();
const result = selectedTileRanges();
const estimate = $("estimate");
if (!selectionBounds) {
estimate.textContent = "请先在地图上拖拽框选下载范围。";
estimate.classList.remove("over");
$("zoomTileRanges").textContent = "框选后显示各缩放级别的瓦片范围";
$("download").disabled = true;
return;
}
if (result.error) {
estimate.textContent = result.error;
estimate.classList.add("over");
$("zoomTileRanges").textContent = result.error;
$("download").disabled = true;
return;
}
const perProvider = result.ranges.reduce((sum, range) => sum + range.count, 0);
const total = perProvider * selected.length;
estimate.textContent = selected.length
? "已选择 " + selected.length + " 个图层,共 " + total + " 个瓦片任务;已有缓存默认跳过。"
: "尚未选择图层。";
estimate.classList.toggle("over", selected.length === 0);
$("zoomTileRanges").textContent = result.ranges.map((range) =>
"Z" + range.z + ":X " + range.x0 + "–" + range.x1 + ",Y " + range.y0 + "–" + range.y1 +
"(每图层 " + range.count + " 个瓦片)"
).join("\n");
$("download").disabled = cacheOnlyEnabled || !providers.length || !selected.length || total === 0 || Boolean(activeJobId);
}
const renderProviders = (items) => {
const previousSelection = new Set(selectedProviders());
const hadProviders = providers.length > 0;
providers = items;
providerById = new Map(items.map((provider) => [provider.id, provider]));
$("providers").replaceChildren();
items.forEach((provider, index) => {
const label = document.createElement("label");
label.className = "provider";
const input = document.createElement("input");
input.type = "checkbox";
input.value = provider.id;
input.checked = hadProviders
? previousSelection.has(provider.id)
: requestedProviderPending
? provider.id === requestedProvider
: provider.id === "osm" || (index === 0 && !items.some((item) => item.id === "osm"));
input.addEventListener("change", () => { syncZoomLimits(); updateEstimate(); });
const copy = document.createElement("span");
const name = document.createElement("strong");
name.textContent = provider.label;
const detail = document.createElement("small");
detail.textContent = provider.description + " · " + provider.source + " · " + provider.license +
" · Z" + provider.minZoom + "–Z" + provider.maxZoom;
copy.append(name, detail);
const count = document.createElement("span");
count.className = "count";
count.textContent = provider.cachedTiles + (provider.kind === "vector" ? " 个 PBF" : " 张");
label.append(input, copy, count);
$("providers").appendChild(label);
});
requestedProviderPending = false;
syncZoomLimits();
};
const loadStatus = async () => {
const response = await fetch(api("api/openmap/status"), { cache: "no-store", credentials: "same-origin" });
if (response.status === 401) { showLogin("需要管理员登录"); return false; }
if (response.status === 503) {
const data = await response.json().catch(() => ({}));
showLogin(data.error || "管理员密码尚未配置");
return false;
}
if (!response.ok) throw new Error("HTTP " + response.status);
const data = await response.json();
cacheOnlyEnabled = Boolean(data.cacheOnly);
$("cacheOnlyToggle").checked = cacheOnlyEnabled;
$("cachePolicyStatus").textContent = cacheOnlyEnabled ? "仅缓存:禁止云端请求" : "缓存策略:允许联网";
$("cachePolicyStatus").className = "status " + (cacheOnlyEnabled ? "warn" : "ok");
updateUpstreamProxyPanel(data);
document.body.classList.remove("auth-locked");
$("loginPanel").hidden = true;
$("logoutButton").hidden = false;
const providerItems = data.providers || [];
const availableIds = new Set(providerItems.map((provider) => provider.id));
const expectedPreviewIds = ["clean", "clean-labels", "topo-preview", "satellite"];
const missingPreviewIds = expectedPreviewIds.filter((id) => !availableIds.has(id));
$("authStatus").textContent = missingPreviewIds.length
? "管理员已登录 · 服务端缺少 Esri 下载项,请重启服务"
: "管理员已登录 · 活动任务 " + (data.activeJobs || 0);
$("authStatus").className = "status " + (missingPreviewIds.length ? "warn" : "ok");
renderProviders(providerItems);
if (requestedJobPending && requestedJobId) {
requestedJobPending = false;
activeJobId = requestedJobId;
monitorJob();
} else if (data.activeJob && !activeJobId) {
activeJobId = data.activeJob.id;
monitorJob();
}
resizeSelectionMap();
updateEstimate();
return true;
};
const fillCurrentView = () => {
const raw = localStorage.getItem("mapStudio.workspace.v1");
if (!raw) throw new Error("主地图还没有保存视窗状态,请先打开一次主地图。");
const state = JSON.parse(raw);
const z = Number(state.map?.zoom);
const bounds = state.map?.bounds;
if (!bounds || !Number.isFinite(z)) throw new Error("主地图视窗缺少边界信息,请先在主地图移动一次。");
const targetBounds = L.latLngBounds([bounds[0], bounds[1]], [bounds[2], bounds[3]]);
tileMap.setView(targetBounds.getCenter(), Math.max(3, Math.min(18, z)));
drawSelection(targetBounds, "主地图当前视窗");
};
const monitorJob = async () => {
if (!activeJobId) return;
window.clearTimeout(monitorTimer);
try {
const response = await fetch(api("api/openmap/job?id=" + encodeURIComponent(activeJobId)), {
cache: "no-store", credentials: "same-origin"
});
if (!response.ok) throw new Error("HTTP " + response.status);
const { job } = await response.json();
$("progress").max = Math.max(1, job.total);
$("progress").value = job.completed;
const statusNames = { queued: "排队中", running: "下载中", completed: "已完成", partial: "部分完成", cancelled: "已中断" };
const idleSeconds = Math.max(0, Math.round(Date.now() / 1000 - Number(job.updatedAt || Date.now() / 1000)));
const sourceLabel = job.sourceLabel ? job.sourceLabel + " · " : "";
$("jobText").textContent = sourceLabel + (statusNames[job.status] || job.status) + " · Z" + job.zoomMin + "–Z" + job.zoomMax +
" · " + job.completed + "/" + job.total + " · 成功 " + job.succeeded +
" · 跳过 " + job.skipped + " · 失败 " + job.failed +
" · 同时下载 " + (job.downloadWorkers || 0) + " 个瓦片" +
(job.activeRequests ? " · 正在请求 " + job.activeRequests + " 个瓦片" : "") +
(idleSeconds >= 5 ? " · 已 " + idleSeconds + " 秒无新进度" : "");
const activeTilePreview = Array.isArray(job.activeTiles) && job.activeTiles.length
? " 当前: " + job.activeTiles.slice(0, 4).join("、")
: "";
$("jobLog").textContent = job.errors?.length
? job.errors.join("\n") + activeTilePreview
: job.status === "completed"
? "任务完成,缓存已写入服务器。"
: job.status === "partial"
? "任务已结束,但有瓦片下载失败;可稍后重试,已有缓存会自动跳过。"
: idleSeconds >= 10
? "正在等待当前瓦片响应。单个上游地址最多等待 8 秒,任务不会无限保持运行状态。" + activeTilePreview
: "正在写入服务器缓存…" + activeTilePreview;
const running = job.status === "queued" || job.status === "running";
$("cancelDownload").disabled = !running;
if (running) {
$("download").disabled = true;
monitorTimer = window.setTimeout(monitorJob, 1200);
} else {
activeJobId = null;
updateEstimate();
loadStatus().catch(() => {});
}
} catch (error) {
$("jobLog").textContent = error.message;
monitorTimer = window.setTimeout(monitorJob, 2500);
}
};
$("download").addEventListener("click", async () => {
if (!selectionBounds) return;
$("download").disabled = true;
$("jobText").textContent = "正在创建下载任务…";
try {
const payload = {
providers: selectedProviders(),
bounds: {
south: selectionBounds.getSouth(), west: selectionBounds.getWest(),
north: selectionBounds.getNorth(), east: selectionBounds.getEast()
},
zoomMin: Number($("zoomMin").value),
zoomMax: Number($("zoomMax").value),
force: $("forceDownload").checked
};
const response = await fetch(api("api/openmap/download"), {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "HTTP " + response.status);
activeJobId = data.job.id;
$("cancelDownload").disabled = false;
$("jobLog").textContent = "任务已进入服务器下载队列。";
monitorJob();
} catch (error) {
$("jobText").textContent = "下载任务创建失败";
$("jobLog").textContent = error.message;
$("cancelDownload").disabled = true;
updateEstimate();
}
});
$("cancelDownload").addEventListener("click", async () => {
if (!activeJobId) return;
$("cancelDownload").disabled = true;
$("jobLog").textContent = "正在中断下载任务…";
try {
const response = await fetch(api("api/openmap/job/cancel?id=" + encodeURIComponent(activeJobId)), {
method: "POST",
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "HTTP " + response.status);
$("jobLog").textContent = "任务已中断;已经完整写入的瓦片会保留。";
monitorJob();
} catch (error) {
$("jobLog").textContent = "中断失败:" + error.message;
$("cancelDownload").disabled = false;
}
});
$("loginForm").addEventListener("submit", async (event) => {
event.preventDefault();
$("loginError").textContent = "";
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();
} catch (error) {
$("loginError").textContent = error.message;
}
});
$("logoutButton").addEventListener("click", async () => {
const button = $("logoutButton");
button.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 })
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "退出请求失败");
window.clearTimeout(monitorTimer);
activeJobId = null;
$("cancelDownload").disabled = true;
providers = [];
providerById.clear();
$("providers").replaceChildren();
$("progress").value = 0;
$("progress").max = 1;
$("jobText").textContent = "已退出管理员登录。";
$("jobLog").textContent = "等待重新登录。";
showLogin("已退出管理员登录");
updateEstimate();
} catch (error) {
$("loginError").textContent = error.message || "退出登录失败";
} finally {
button.disabled = false;
}
});
["zoomMin", "zoomMax"].forEach((id) => $(id).addEventListener("input", updateEstimate));
$("selectPreviewSet").addEventListener("click", () => applyProviderPreset(["clean", "clean-labels", "satellite"], "离线底图套装"));
$("selectTopoPreview").addEventListener("click", () => applyProviderPreset(["topo-preview"], "Esri 地形图"));
$("clearProviderSelection").addEventListener("click", () => selectProviderIds([]));
$("startSelection").addEventListener("click", () => setSelectionMode(!selecting));
$("clearSelection").addEventListener("click", () => { setSelectionMode(false); clearSelection(); });
tileMap.on("mousedown", (event) => {
if (!selecting || event.originalEvent.target.closest(".leaflet-control")) return;
L.DomEvent.preventDefault(event.originalEvent);
selectionStart = event.latlng;
if (selectionRectangle) tileMap.removeLayer(selectionRectangle);
selectionRectangle = L.rectangle(L.latLngBounds(selectionStart, selectionStart), {
color: "#d25a39", weight: 2, fillColor: "#e98b4b", fillOpacity: 0.12, interactive: false
}).addTo(tileMap);
});
tileMap.on("mousemove", (event) => {
if (!selecting || !selectionStart || !selectionRectangle) return;
selectionRectangle.setBounds(L.latLngBounds(selectionStart, event.latlng));
});
tileMap.on("mouseup", (event) => {
if (!selecting || !selectionStart) return;
const bounds = L.latLngBounds(selectionStart, event.latlng);
selectionStart = null;
setSelectionMode(false);
drawSelection(bounds, "地图框选");
});
tileMap.on("zoomend", () => {
$("basemapStatus").textContent = adminBaseNames[activeAdminBase] + " · 当前 Z" + tileMap.getZoom() + ",可在地图上框选。";
});
window.addEventListener("resize", resizeSelectionMap);
$("fillView").addEventListener("click", () => {
try { fillCurrentView(); } catch (error) { $("jobLog").textContent = error.message; }
});
$("refreshStatus").addEventListener("click", () => loadStatus().catch((error) => { $("jobLog").textContent = error.message; }));
$("cacheOnlyToggle").addEventListener("change", async (event) => {
const toggle = event.target;
toggle.disabled = true;
try {
const response = await fetch(api("api/cache-policy"), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ cacheOnly: toggle.checked })
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "HTTP " + response.status);
cacheOnlyEnabled = Boolean(data.cacheOnly);
toggle.checked = cacheOnlyEnabled;
$("cachePolicyStatus").textContent = cacheOnlyEnabled ? "仅缓存:禁止云端请求" : "缓存策略:允许联网";
$("cachePolicyStatus").className = "status " + (cacheOnlyEnabled ? "warn" : "ok");
$("jobLog").textContent = cacheOnlyEnabled
? "服务器已切换为仅缓存模式;缺失的栅格显示占位图,矢量专题显示空结果。"
: "服务器已恢复联网缓存模式。";
updateEstimate();
} catch (error) {
toggle.checked = cacheOnlyEnabled;
$("jobLog").textContent = error.message;
} finally {
toggle.disabled = false;
}
});
$("upstreamProxyMode").addEventListener("change", () => {
const temporary = $("upstreamProxyMode").value === "temporary";
$("upstreamProxyInput").disabled = !temporary;
$("applyTemporaryProxy").disabled = !temporary;
$("saveUpstreamProxy").disabled = !temporary;
});
$("applyTemporaryProxy").addEventListener("click", async () => {
const proxy = $("upstreamProxyInput").value.trim();
if (!proxy) {
$("upstreamProxyStatus").textContent = "请输入临时代理地址,例如 http://127.0.0.1:7890";
return;
}
try {
await submitUpstreamProxy({ proxy, persist: false });
} catch (error) {
$("upstreamProxyStatus").textContent = "临时代理应用失败:" + error.message;
}
});
$("saveUpstreamProxy").addEventListener("click", async () => {
const proxy = $("upstreamProxyInput").value.trim();
if (!proxy) {
$("upstreamProxyStatus").textContent = "请输入要保存的代理地址。";
return;
}
try {
await submitUpstreamProxy({ proxy, persist: true });
} catch (error) {
$("upstreamProxyStatus").textContent = "默认代理保存失败:" + error.message;
}
});
$("useDirectProxy").addEventListener("click", async () => {
try {
await submitUpstreamProxy({ proxy: "", persist: false });
} catch (error) {
$("upstreamProxyStatus").textContent = "临时直连设置失败:" + error.message;
}
});
$("resetUpstreamProxy").addEventListener("click", async () => {
try {
await submitUpstreamProxy({ reset: true });
} catch (error) {
$("upstreamProxyStatus").textContent = "恢复默认代理失败:" + error.message;
}
});
loadStatus().catch((error) => { $("authStatus").textContent = "服务未连接"; $("jobLog").textContent = error.message; });
})();
</script>
</body>
</html>