902 lines
44 KiB
HTML
902 lines
44 KiB
HTML
<!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: "© Esri"
|
||
}),
|
||
osm: L.tileLayer(api("tiles/osm/{z}/{x}/{y}.png"), {
|
||
maxZoom: 19,
|
||
attribution: "© OpenStreetMap contributors"
|
||
}),
|
||
topo: L.tileLayer(api("tiles/topo-preview/{z}/{x}/{y}.png"), {
|
||
maxZoom: 19,
|
||
maxNativeZoom: 13,
|
||
attribution: "Tiles © Esri"
|
||
}),
|
||
satellite: L.tileLayer(api("tiles/satellite/{z}/{x}/{y}.png"), {
|
||
maxZoom: 19,
|
||
attribution: "Tiles © Esri"
|
||
}),
|
||
"tdt-vec": L.tileLayer(api("tiles/tdt-vec/{z}/{x}/{y}.png"), {
|
||
maxZoom: 18,
|
||
attribution: "© 天地图"
|
||
}),
|
||
"tdt-img": L.tileLayer(api("tiles/tdt-img/{z}/{x}/{y}.png"), {
|
||
maxZoom: 18,
|
||
attribution: "© 天地图"
|
||
}),
|
||
"tdt-ter": L.tileLayer(api("tiles/tdt-ter/{z}/{x}/{y}.png"), {
|
||
maxZoom: 18,
|
||
attribution: "© 天地图"
|
||
})
|
||
};
|
||
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>
|