docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
commit
85e9a53750
65 files changed
+18603
No files matched your search
+334
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>iOS 虚拟定位 · 轨迹工具</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232f81f7'/%3E%3Cpath d='M16 6a6.5 6.5 0 0 0-6.5 6.5C9.5 17.4 16 26 16 26s6.5-8.6 6.5-13.5A6.5 6.5 0 0 0 16 6Zm0 8.8a2.4 2.4 0 1 1 0-4.8 2.4 2.4 0 0 1 0 4.8Z' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- ===================== 顶部:品牌 + 设备卡片 + 操作 ===================== -->
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7Zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5Z"/></svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1>iOS 虚拟定位 · 轨迹工具</h1>
|
||||
<p id="appSubtitle">本地服务未连接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="device-card" id="deviceCard">
|
||||
<div class="device-empty">正在读取设备信息…</div>
|
||||
</div>
|
||||
|
||||
<div class="top-actions">
|
||||
<span class="pill pill-mute" id="serverInfo">服务端 --</span>
|
||||
<span class="pill pill-err" id="connPill">未连接</span>
|
||||
<button type="button" class="btn" id="btnDevicesRefresh" title="重新扫描 USB / 网络设备">刷新设备</button>
|
||||
<button type="button" class="btn btn-primary" id="btnDeviceConnect" title="建立隧道并连接 DVT 定位服务">连接设备</button>
|
||||
<button type="button" class="btn btn-danger" id="btnDeviceDisconnect" title="释放会话并恢复真实定位">断开</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===================== 全局提示条 ===================== -->
|
||||
<div class="banner hidden" id="alertBanner">
|
||||
<span class="banner-icon" aria-hidden="true">!</span>
|
||||
<span class="banner-text" id="bannerText"></span>
|
||||
<button type="button" class="btn btn-small" id="bannerAction">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 主体 ===================== -->
|
||||
<main class="workspace" id="workspace">
|
||||
|
||||
<!-- ---------- 左侧:轨迹设置 ---------- -->
|
||||
<aside class="panel panel-left" id="leftPanel">
|
||||
<div class="panel-head">
|
||||
<h2>轨迹设置</h2>
|
||||
<span class="panel-sub" id="routeMeta">未命名轨迹</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
|
||||
<section class="block">
|
||||
<label class="block-label">出行模式</label>
|
||||
<div class="seg" id="modeGroup">
|
||||
<button type="button" class="seg-btn is-active" data-mode="driving">驾车</button>
|
||||
<button type="button" class="seg-btn" data-mode="cycling">骑行</button>
|
||||
<button type="button" class="seg-btn" data-mode="walking">步行</button>
|
||||
<button type="button" class="seg-btn" data-mode="flight">飞行</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<button type="button" class="btn btn-small" id="btnCampusPreset" title="步行 1.4 m/s、下发 10Hz、漂移 4m、浮动 ±8%、不循环">校园跑预设</button>
|
||||
<button type="button" class="btn btn-small" id="btnMarathonPreset" title="骑行 5.5 m/s、下发 10Hz、漂移 6m、浮动 ±10%">长跑预设</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="row row-between">
|
||||
<label class="block-label" for="jumpCoord">快速定位到坐标 <span class="hint-mini">校园跑:先填学校坐标</span></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="text" id="jumpCoord" class="input" placeholder="例如 30.2741, 120.1551" title="纬度, 经度(WGS84,逗号或空格分隔)">
|
||||
<button type="button" class="btn btn-small" id="btnJumpCoord">跳转</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:4px">
|
||||
<button type="button" class="btn btn-small" id="btnSavePlace">记住此地</button>
|
||||
<select id="placeSelect" class="input select" title="已记住的地点">
|
||||
<option value="">(暂无记住的地点)</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-small" id="btnGoPlace">前往</button>
|
||||
</div>
|
||||
<p class="hint-mini" style="margin:4px 0 0">跳转只是把地图移到该点,方便你在学校范围内画轨迹;不会改变手机定位。</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="row row-between">
|
||||
<label class="block-label" for="speedRange">播放倍速 <span class="hint-mini">0.1x – 50x</span></label>
|
||||
<span class="value-tag" id="speedValue">1.0x</span>
|
||||
</div>
|
||||
<input type="range" id="speedRange" class="range" min="0" max="1000" step="1" value="500" title="0.1x - 50x(指数刻度)">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="loopToggle">
|
||||
<span>循环播放(到达终点后回到起点)</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="row row-between">
|
||||
<label class="block-label">真实感模拟 <span class="hint-mini">校园跑等打卡类测试</span></label>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" id="driftToggle" checked>
|
||||
<span>定位漂移(模拟 GPS 误差缓慢游走)</span>
|
||||
</label>
|
||||
<div class="row row-between">
|
||||
<label class="hint-mini" for="driftRange">漂移半径</label>
|
||||
<span class="value-tag" id="driftValue">4 米</span>
|
||||
</div>
|
||||
<input type="range" id="driftRange" class="range" min="0" max="50" step="1" value="4" title="0 - 50 米">
|
||||
|
||||
<label class="check" style="margin-top:6px">
|
||||
<input type="checkbox" id="jitterToggle" checked>
|
||||
<span>速度浮动(瞬时速度在设定值上下波动)</span>
|
||||
</label>
|
||||
<div class="row row-between">
|
||||
<label class="hint-mini" for="jitterRange">浮动幅度</label>
|
||||
<span class="value-tag" id="jitterValue">±8%</span>
|
||||
</div>
|
||||
<input type="range" id="jitterRange" class="range" min="0" max="60" step="1" value="8" title="0 - 60%">
|
||||
|
||||
<label class="check" style="margin-top:6px">
|
||||
<input type="checkbox" id="timingJitterToggle" checked>
|
||||
<span>发送节奏抖动(真实 GPS 不是精确每秒一次)</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="deriveSpeedToggle" checked>
|
||||
<span>速度由位移推算(与 iOS 的算法一致)</span>
|
||||
</label>
|
||||
|
||||
<label class="check" style="margin-top:6px">
|
||||
<input type="checkbox" id="grossErrorToggle">
|
||||
<span>注入粗差跳点(测自己 App 的过滤逻辑)</span>
|
||||
</label>
|
||||
<div class="row row-between">
|
||||
<label class="hint-mini" for="grossErrorRange">粗差概率</label>
|
||||
<span class="value-tag" id="grossErrorValue">2%</span>
|
||||
</div>
|
||||
<input type="range" id="grossErrorRange" class="range" min="0" max="20" step="1" value="2" title="0 - 20%" disabled>
|
||||
|
||||
<p class="hint-mini" style="margin:6px 0 0">
|
||||
漂移用多尺度噪声(慢漂+中尺度+快抖),沿轨迹平滑游走;发送间隔带 ±15% 抖动与偶发丢采样;
|
||||
上报速度由实际位移÷时间推算,速度/位移/时间三者自洽。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<label class="block-label">路线概览</label>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="stat-k">总距离</span><span class="stat-v" id="statDistance">--</span></div>
|
||||
<div class="stat"><span class="stat-k">按速度预计</span><span class="stat-v" id="statDuration">--</span></div>
|
||||
<div class="stat"><span class="stat-k">按倍速播放</span><span class="stat-v" id="statPlayDuration">--</span></div>
|
||||
<div class="stat"><span class="stat-k">路径点数</span><span class="stat-v" id="statPoints">0</span></div>
|
||||
<div class="stat"><span class="stat-k">途经点</span><span class="stat-v" id="statWaypoints">0</span></div>
|
||||
<div class="stat"><span class="stat-k">路线引擎</span><span class="stat-v" id="statEngine">--</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="row">
|
||||
<label class="block-label inline" for="routeName">轨迹名称</label>
|
||||
<input type="text" id="routeName" class="input" maxlength="60" placeholder="未命名轨迹">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<div class="row row-between">
|
||||
<label class="block-label">途经点列表</label>
|
||||
<span class="hint-mini">拖拽排序 · 双击改坐标</span>
|
||||
</div>
|
||||
<div class="wp-list" id="wpList"></div>
|
||||
<div class="wp-empty" id="wpEmpty">还没有途经点:点地图工具条上的「添加途经点」在地图上点选,或导入 GPX。</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<label class="block-label" for="savedRoutes">本地保存的路线</label>
|
||||
<div class="row">
|
||||
<select class="input select" id="savedRoutes"></select>
|
||||
<button type="button" class="btn" id="btnSaveRoute" title="保存到浏览器 localStorage">保存</button>
|
||||
<button type="button" class="btn" id="btnLoadRoute" title="载入所选路线">载入</button>
|
||||
<button type="button" class="btn btn-danger" id="btnDeleteSaved" title="删除所选路线">删除</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<label class="block-label" for="tileSourceSelect">底图</label>
|
||||
<select class="input select" id="tileSourceSelect"></select>
|
||||
<div class="row row-wrap gap-top">
|
||||
<button type="button" class="btn btn-small" id="btnTunnelEnsure" title="iOS 17+ 建立 RSD 隧道(必要时需要管理员权限)">建立隧道</button>
|
||||
<button type="button" class="btn btn-small" id="btnTunnelManual" title="手动填写 RSD 主机与端口">手动 RSD</button>
|
||||
<button type="button" class="btn btn-small" id="btnMountImage" title="给 iOS 设备挂载开发者镜像(DVT 虚拟定位的前提,内置镜像素材)">挂载开发者镜像</button>
|
||||
<button type="button" class="btn btn-small" id="btnTilesClear" title="清理服务端瓦片磁盘缓存并重新拉取">清理瓦片缓存</button>
|
||||
</div>
|
||||
<p class="hint-mini" style="margin:6px 0 0">
|
||||
「挂载开发者镜像」是虚拟定位的前提(iOS 17+ 一般由 Xcode 自动完成)。
|
||||
应用内置了 iOS 17–27.x 全系共用的镜像素材,无需联网下载;签名由 Apple 在线签发。
|
||||
点击后过程实时输出到右侧运行日志。<b>仅支持 iOS 17 及以上</b>。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block block-muted">
|
||||
<label class="block-label">快捷键</label>
|
||||
<div class="keys">
|
||||
<span><kbd>空格</kbd> 播放 / 暂停</span>
|
||||
<span><kbd>Esc</kbd> 结束折线绘制</span>
|
||||
<span><kbd>Del</kbd> 删除选中点</span>
|
||||
<span><kbd>双击</kbd> 放大 / 结束绘制</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 固定操作条:始终可见的主操作 -->
|
||||
<section class="actions-sticky">
|
||||
<button type="button" class="btn btn-primary btn-block" id="btnBuildRoute">加载 OSRM 路线</button>
|
||||
<div class="btn-grid">
|
||||
<button type="button" class="btn" id="btnClear">清空</button>
|
||||
<button type="button" class="btn" id="btnExportGpx">导出 GPX</button>
|
||||
<button type="button" class="btn" id="btnImportGpx">导入 GPX</button>
|
||||
<button type="button" class="btn" id="btnClearLocation">恢复真实定位</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ---------- 中间:地图 ---------- -->
|
||||
<section class="map-area" id="mapArea">
|
||||
<div class="map-toolbar" id="drawToolbar">
|
||||
<button type="button" class="tool-btn is-active" id="toolSelect" data-tool="select" title="选择 / 拖拽途经点">选择</button>
|
||||
<button type="button" class="tool-btn" id="toolAdd" data-tool="add" title="在地图上点击添加途经点">添加途经点</button>
|
||||
<button type="button" class="tool-btn" id="toolPolyline" data-tool="polyline" title="连续点击加点,双击或 Esc 结束">折线绘制</button>
|
||||
<span class="tool-sep"></span>
|
||||
<button type="button" class="tool-btn" id="toolUndo" title="撤销上一步">撤销</button>
|
||||
<button type="button" class="tool-btn" id="toolDelete" title="删除选中的途经点">删除选中点</button>
|
||||
<button type="button" class="tool-btn" id="toolFit" title="缩放至完整轨迹">全览</button>
|
||||
<span class="tool-hint" id="toolHint">拖拽平移 · 滚轮缩放</span>
|
||||
</div>
|
||||
|
||||
<div class="map-chips" id="mapChips">
|
||||
<span class="chip" id="chipPlayState">未播放</span>
|
||||
<span class="chip" id="chipProgress">进度 0%</span>
|
||||
<span class="chip" id="chipSpeed">速度 --</span>
|
||||
<span class="chip" id="chipCoord">--</span>
|
||||
<span class="chip chip-mute" id="chipTrack">轨迹点 0</span>
|
||||
</div>
|
||||
|
||||
<div id="map" class="map-host"></div>
|
||||
</section>
|
||||
|
||||
<!-- ---------- 右侧:日志 ---------- -->
|
||||
<aside class="panel panel-right" id="logPanel">
|
||||
<div class="panel-head log-head">
|
||||
<h2 class="log-title">运行日志</h2>
|
||||
<span class="panel-sub" id="logCount">0 / 300</span>
|
||||
<div class="log-actions">
|
||||
<button type="button" class="btn btn-small" id="btnFetchLogs" title="向服务端拉取最近 200 条日志">拉取</button>
|
||||
<button type="button" class="btn btn-small" id="btnClearLog">清空</button>
|
||||
<button type="button" class="btn btn-small" id="btnToggleLog" title="折叠 / 展开日志面板">折叠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-body" id="logBody"></div>
|
||||
<div class="log-collapsed-label" id="logCollapsedLabel">运 行 日 志</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ===================== 底部播放条 ===================== -->
|
||||
<footer class="playbar">
|
||||
<div class="transport">
|
||||
<button type="button" class="tbtn tbtn-play" id="btnPlay" title="开始播放">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>
|
||||
<span>播放</span>
|
||||
</button>
|
||||
<button type="button" class="tbtn" id="btnPause" title="暂停" disabled>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M7 5h3.5v14H7V5Zm6.5 0H17v14h-3.5V5Z"/></svg>
|
||||
<span>暂停</span>
|
||||
</button>
|
||||
<button type="button" class="tbtn" id="btnResume" title="继续" disabled>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 5.5v13l10-6.5-10-6.5Zm12 0h2v13h-2v-13Z"/></svg>
|
||||
<span>继续</span>
|
||||
</button>
|
||||
<button type="button" class="tbtn tbtn-stop" id="btnStop" title="停止播放并恢复真实定位" disabled>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 6h12v12H6z"/></svg>
|
||||
<span>停止</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-top">
|
||||
<span class="play-state" id="playStateText">未播放</span>
|
||||
<span class="progress-num" id="liveProgress">0.0%</span>
|
||||
</div>
|
||||
<input type="range" id="progressRange" class="range range-progress" min="0" max="1000" step="1" value="0">
|
||||
<div class="progress-times">
|
||||
<span>已用 <b id="timeElapsed">00:00</b></span>
|
||||
<span>剩余 <b id="timeRemaining">00:00</b></span>
|
||||
<span>序号 <b id="liveIndex">0 / 0</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="live-stats">
|
||||
<div class="live"><span class="live-k">实时速度</span><span class="live-v" id="liveSpeed">--</span></div>
|
||||
<div class="live"><span class="live-k">当前坐标</span><span class="live-v live-v-mono" id="liveCoord">--</span></div>
|
||||
<div class="live"><span class="live-k">倍速</span><span class="live-v" id="liveScale">1.0x</span></div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 弹窗 / 提示 ===================== -->
|
||||
<div class="modal-mask hidden" id="modalMask">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<h3 id="modalTitle">提示</h3>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" id="modalCancel">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="modalOk">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-wrap" id="toastWrap"></div>
|
||||
|
||||
<input type="file" id="gpxFile" accept=".gpx,.xml,application/gpx+xml,text/xml" hidden>
|
||||
|
||||
<script src="js/coord.js"></script>
|
||||
<script src="js/ws.js"></script>
|
||||
<script src="js/map.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2385
File diff suppressed because it is too large.
Load diff
+235
@@ -0,0 +1,235 @@
|
||||
/*!
|
||||
* coord.js — 坐标系统转换与地理计算(纯 vanilla ES2020,无外部依赖)
|
||||
*
|
||||
* 约定(见 docs/PROTOCOL.md):
|
||||
* - 内部存储、发往后端的一切经纬度 **一律 WGS84**;
|
||||
* - 高德 / 腾讯瓦片为 GCJ-02(火星坐标),ArcGIS / OSM 为 WGS84;
|
||||
* - 显示用点 = WGS84 → 瓦片基准(GCJ-02 源时做一次 wgs84ToGcj02),
|
||||
* 取点(鼠标 → 坐标)= 瓦片基准 → WGS84(gcj02ToWgs84)。
|
||||
*
|
||||
* 导出:window.Coord(转换)、window.Geo(距离/格式化工具)
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var PI = Math.PI;
|
||||
var A = 6378245.0; // 克拉索夫斯基椭球长半轴
|
||||
var EE = 0.00669342162296594323; // 偏心率平方
|
||||
|
||||
/** 中国大陆粗略范围之外(含港澳台、境外)不做偏移 */
|
||||
function outOfChina(lng, lat) {
|
||||
if (!isFinite(lng) || !isFinite(lat)) return true;
|
||||
if (lng < 72.004 || lng > 137.8347) return true;
|
||||
if (lat < 0.8293 || lat > 55.8271) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function transformLat(x, y) {
|
||||
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
|
||||
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
|
||||
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
function transformLng(x, y) {
|
||||
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
|
||||
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
|
||||
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* WGS84 → GCJ-02
|
||||
* @returns {[number, number]} [lng, lat]
|
||||
*/
|
||||
function wgs84ToGcj02(lng, lat) {
|
||||
lng = +lng; lat = +lat;
|
||||
if (outOfChina(lng, lat)) return [lng, lat];
|
||||
var dLat = transformLat(lng - 105.0, lat - 35.0);
|
||||
var dLng = transformLng(lng - 105.0, lat - 35.0);
|
||||
var radLat = lat / 180.0 * PI;
|
||||
var magic = Math.sin(radLat);
|
||||
magic = 1 - EE * magic * magic;
|
||||
var sqrtMagic = Math.sqrt(magic);
|
||||
dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
|
||||
dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI);
|
||||
return [lng + dLng, lat + dLat];
|
||||
}
|
||||
|
||||
/**
|
||||
* GCJ-02 → WGS84(迭代逼近,精度 1e-9 度 ≈ 0.1mm,足够)
|
||||
* @returns {[number, number]} [lng, lat]
|
||||
*/
|
||||
function gcj02ToWgs84(lng, lat) {
|
||||
lng = +lng; lat = +lat;
|
||||
if (outOfChina(lng, lat)) return [lng, lat];
|
||||
var wLng = lng, wLat = lat;
|
||||
for (var i = 0; i < 12; i++) {
|
||||
var g = wgs84ToGcj02(wLng, wLat);
|
||||
var dLng = lng - g[0];
|
||||
var dLat = lat - g[1];
|
||||
wLng += dLng;
|
||||
wLat += dLat;
|
||||
if (Math.abs(dLng) < 1e-10 && Math.abs(dLat) < 1e-10) break;
|
||||
}
|
||||
return [wLng, wLat];
|
||||
}
|
||||
|
||||
/** 判断瓦片源使用的坐标基准 */
|
||||
function datumOfSource(key, src) {
|
||||
if (src) {
|
||||
var explicit = src.datum || src.coordsys || src.crs || src.coordinate_system;
|
||||
if (typeof explicit === 'string') {
|
||||
var e = explicit.toLowerCase();
|
||||
if (e.indexOf('gcj') >= 0 || e.indexOf('mars') >= 0 || e.indexOf('amap') >= 0) return 'gcj02';
|
||||
if (e.indexOf('wgs') >= 0 || e.indexOf('4326') >= 0 || e.indexOf('epsg') >= 0) return 'wgs84';
|
||||
}
|
||||
}
|
||||
var probe = ((key || '') + ' ' + ((src && (src.url || src.name)) || '')).toLowerCase();
|
||||
if (/amap|autonavi|gaode|tencent|gtimg|map\.qq|qqmap/.test(probe)) return 'gcj02';
|
||||
return 'wgs84';
|
||||
}
|
||||
|
||||
/** WGS84 → 显示基准(GCJ-02 源需要偏移,其余原样返回) */
|
||||
function toDisplay(lng, lat, datum) {
|
||||
if (datum === 'gcj02') return wgs84ToGcj02(lng, lat);
|
||||
return [+lng, +lat];
|
||||
}
|
||||
|
||||
/** 显示基准 → WGS84 */
|
||||
function fromDisplay(lng, lat, datum) {
|
||||
if (datum === 'gcj02') return gcj02ToWgs84(lng, lat);
|
||||
return [+lng, +lat];
|
||||
}
|
||||
|
||||
/** 保留 6 位小数(契约要求下发坐标 6 位小数) */
|
||||
function round6(v) { return Math.round((+v) * 1e6) / 1e6; }
|
||||
|
||||
global.Coord = {
|
||||
outOfChina: outOfChina,
|
||||
wgs84ToGcj02: wgs84ToGcj02,
|
||||
gcj02ToWgs84: gcj02ToWgs84,
|
||||
datumOfSource: datumOfSource,
|
||||
toDisplay: toDisplay,
|
||||
fromDisplay: fromDisplay,
|
||||
round6: round6,
|
||||
A: A,
|
||||
EE: EE
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 地理计算 / 格式化小工具 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
var EARTH_R = 6371008.8; // 平均地球半径(米)
|
||||
|
||||
function rad(d) { return d * PI / 180; }
|
||||
|
||||
/** 两点球面距离(米),入参 [lng,lat] 或 (lng,lat) */
|
||||
function haversine(a, b) {
|
||||
var lng1, lat1, lng2, lat2;
|
||||
if (Array.isArray(a)) { lng1 = a[0]; lat1 = a[1]; } else { lng1 = a; lat1 = arguments[1]; b = arguments[2]; lng2 = b[0]; lat2 = b[1]; }
|
||||
if (Array.isArray(b)) { lng2 = b[0]; lat2 = b[1]; }
|
||||
var dLat = rad(lat2 - lat1);
|
||||
var dLng = rad(lng2 - lng1);
|
||||
var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return 2 * EARTH_R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
/** 折线总长度(米) */
|
||||
function pathLength(path) {
|
||||
var d = 0;
|
||||
if (!path || path.length < 2) return 0;
|
||||
for (var i = 1; i < path.length; i++) d += haversine(path[i - 1], path[i]);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 距离格式化:<1000 → 米,否则 公里 */
|
||||
function fmtDistance(m) {
|
||||
if (!isFinite(m)) return '--';
|
||||
if (m < 1000) return Math.round(m) + ' 米';
|
||||
if (m < 10000) return (m / 1000).toFixed(2) + ' 公里';
|
||||
return (m / 1000).toFixed(1) + ' 公里';
|
||||
}
|
||||
|
||||
/** 秒 → 中文时长 */
|
||||
function fmtDuration(sec) {
|
||||
if (!isFinite(sec) || sec < 0) return '--';
|
||||
sec = Math.round(sec);
|
||||
var h = Math.floor(sec / 3600);
|
||||
var m = Math.floor((sec % 3600) / 60);
|
||||
var s = sec % 60;
|
||||
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
if (h > 0) return h + ':' + pad(m) + ':' + pad(s);
|
||||
return pad(m) + ':' + pad(s);
|
||||
}
|
||||
|
||||
/** 秒 → 「1 小时 2 分」式估算文案 */
|
||||
function fmtDurationCn(sec) {
|
||||
if (!isFinite(sec) || sec < 0) return '--';
|
||||
sec = Math.round(sec);
|
||||
if (sec < 60) return sec + ' 秒';
|
||||
var m = Math.floor(sec / 60);
|
||||
if (m < 60) return m + ' 分 ' + (sec % 60) + ' 秒';
|
||||
var h = Math.floor(m / 60);
|
||||
return h + ' 小时 ' + (m % 60) + ' 分';
|
||||
}
|
||||
|
||||
function fmtSpeed(ms) {
|
||||
if (!isFinite(ms)) return '--';
|
||||
return (ms * 3.6).toFixed(1) + ' km/h';
|
||||
}
|
||||
|
||||
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
function fmtLngLat(lng, lat, digits) {
|
||||
var d = digits == null ? 6 : digits;
|
||||
if (!isFinite(lng) || !isFinite(lat)) return '--';
|
||||
return (+lng).toFixed(d) + ', ' + (+lat).toFixed(d);
|
||||
}
|
||||
|
||||
/** 倍速滑块(0..1000)↔ 0.1x..50x 指数映射,低速段更细腻 */
|
||||
var SPEED_MIN = 0.1, SPEED_MAX = 50;
|
||||
function sliderToSpeed(t) {
|
||||
var k = SPEED_MAX / SPEED_MIN;
|
||||
return SPEED_MIN * Math.pow(k, clamp(+t, 0, 1));
|
||||
}
|
||||
function speedToSlider(v) {
|
||||
var k = SPEED_MAX / SPEED_MIN;
|
||||
return clamp(Math.log(clamp(+v, SPEED_MIN, SPEED_MAX) / SPEED_MIN) / Math.log(k), 0, 1);
|
||||
}
|
||||
function fmtSpeedScale(v) {
|
||||
var x = +v;
|
||||
if (x >= 10) return x.toFixed(0) + 'x';
|
||||
if (x >= 1) return x.toFixed(1) + 'x';
|
||||
return x.toFixed(2) + 'x';
|
||||
}
|
||||
|
||||
/** 时间戳 → HH:MM:SS */
|
||||
function fmtClock(ts) {
|
||||
var d = ts == null ? new Date() : new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) d = new Date();
|
||||
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
|
||||
global.Geo = {
|
||||
haversine: haversine,
|
||||
pathLength: pathLength,
|
||||
fmtDistance: fmtDistance,
|
||||
fmtDuration: fmtDuration,
|
||||
fmtDurationCn: fmtDurationCn,
|
||||
fmtSpeed: fmtSpeed,
|
||||
fmtLngLat: fmtLngLat,
|
||||
fmtClock: fmtClock,
|
||||
clamp: clamp,
|
||||
sliderToSpeed: sliderToSpeed,
|
||||
speedToSlider: speedToSlider,
|
||||
fmtSpeedScale: fmtSpeedScale,
|
||||
SPEED_MIN: SPEED_MIN,
|
||||
SPEED_MAX: SPEED_MAX
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+1186
File diff suppressed because it is too large.
Load diff
+339
@@ -0,0 +1,339 @@
|
||||
/*!
|
||||
* ws.js — WebSocket 客户端(协议见 docs/PROTOCOL.md §3)
|
||||
*
|
||||
* 能力:
|
||||
* - 连接 ws://127.0.0.1:8787/ws,连接后立即发 hello
|
||||
* - 自动重连:指数退避 400ms → 10s(带抖动),连接成功后重置
|
||||
* - 心跳:每 10s 发 ping;连续 3 次没有 pong 主动断开重连
|
||||
* - 命令请求-应答:request(cmd, fields, timeout) → Promise(ack ok:false 会 reject)
|
||||
* - 事件订阅:on(type, fn) / on('*', fn) / onState(fn)
|
||||
*
|
||||
* 导出:window.WSClient
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var CMD_TIMEOUT = 8000;
|
||||
var TICK_TIMEOUT = 6000;
|
||||
var HEARTBEAT_MS = 10000;
|
||||
var MAX_BACKOFF = 10000;
|
||||
|
||||
function nowMs() {
|
||||
return (global.performance && global.performance.now) ? global.performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function WSClient(opts) {
|
||||
opts = opts || {};
|
||||
this.url = opts.url || defaultUrl();
|
||||
// 部分服务端实现会在 101 响应里回带未请求的 Sec-WebSocket-Protocol,
|
||||
// 浏览器按 RFC 6455 会直接判定握手失败。这里在首次连接失败后自动带上 v1 再试一次。
|
||||
this.subprotocols = opts.subprotocols || null;
|
||||
this.everOpened = false;
|
||||
this.protocolFallback = false;
|
||||
this.handlers = Object.create(null); // type -> [fn]
|
||||
this.stateHandlers = [];
|
||||
this.state = 'closed'; // closed | connecting | open
|
||||
this.sock = null;
|
||||
this.seq = 0;
|
||||
this.pending = Object.create(null); // id -> {resolve, reject, timer, cmd}
|
||||
this.attempt = 0;
|
||||
this.closedByUser = false;
|
||||
this.reconnectTimer = null;
|
||||
this.heartbeatTimer = null;
|
||||
this.lastPongAt = 0;
|
||||
// 最近一次收到任何入站消息的时间(不只是 pong):用于判断连接是否真的静默
|
||||
this.lastInboundAt = 0;
|
||||
this.missedPings = 0;
|
||||
this.manualClose = false;
|
||||
}
|
||||
|
||||
function defaultUrl() {
|
||||
var loc = global.location;
|
||||
var host = (loc && loc.hostname) ? loc.hostname : '127.0.0.1';
|
||||
var port = (loc && loc.port) ? loc.port : '8787';
|
||||
var proto = (loc && loc.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||
return proto + '//' + host + ':' + port + '/ws';
|
||||
}
|
||||
|
||||
WSClient.prototype.on = function (type, fn) {
|
||||
(this.handlers[type] || (this.handlers[type] = [])).push(fn);
|
||||
return this;
|
||||
};
|
||||
|
||||
WSClient.prototype.off = function (type, fn) {
|
||||
var list = this.handlers[type];
|
||||
if (!list) return this;
|
||||
var i = list.indexOf(fn);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
return this;
|
||||
};
|
||||
|
||||
WSClient.prototype.onState = function (fn) {
|
||||
this.stateHandlers.push(fn);
|
||||
return this;
|
||||
};
|
||||
|
||||
WSClient.prototype._setState = function (s, detail) {
|
||||
if (this.state === s && !detail) return;
|
||||
this.state = s;
|
||||
for (var i = 0; i < this.stateHandlers.length; i++) {
|
||||
try { this.stateHandlers[i](s, detail); } catch (e) { /* 忽略订阅者异常 */ }
|
||||
}
|
||||
};
|
||||
|
||||
WSClient.prototype._emit = function (type, payload, raw) {
|
||||
var list = this.handlers[type];
|
||||
if (list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
try { list[i](payload, raw); } catch (e) {
|
||||
if (global.console) global.console.error('[ws] 事件处理异常 ' + type, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
var all = this.handlers['*'];
|
||||
if (all) {
|
||||
for (var j = 0; j < all.length; j++) {
|
||||
try { all[j](payload, raw, type); } catch (e2) { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
WSClient.prototype.connect = function () {
|
||||
var self = this;
|
||||
if (this.sock && (this.sock.readyState === 0 || this.sock.readyState === 1)) return this;
|
||||
this.closedByUser = false;
|
||||
this.manualClose = false;
|
||||
this._setState('connecting');
|
||||
var sock;
|
||||
try {
|
||||
sock = this.subprotocols ? new WebSocket(this.url, this.subprotocols) : new WebSocket(this.url);
|
||||
} catch (e) {
|
||||
this._scheduleReconnect('WebSocket 构造失败:' + e.message);
|
||||
return this;
|
||||
}
|
||||
this.sock = sock;
|
||||
|
||||
sock.onopen = function () {
|
||||
self.attempt = 0;
|
||||
self.everOpened = true;
|
||||
self.lastPongAt = nowMs();
|
||||
self.lastInboundAt = nowMs();
|
||||
self.missedPings = 0;
|
||||
self._setState('open');
|
||||
self._emit('open', { url: self.url });
|
||||
// hello 用请求-应答发送:服务端把 welcome 数据并回在 hello 的 ack 里
|
||||
// (契约里 hello → welcome;两种形态都兼容:ack 命中后归一化成 welcome 事件)
|
||||
self.request('hello', { client: 'web' }, 8000).then(function (msg) {
|
||||
self._emit('welcome', msg);
|
||||
}).catch(function (err) {
|
||||
self._emit('hello_failed', { error: err && err.message });
|
||||
});
|
||||
self._startHeartbeat();
|
||||
};
|
||||
|
||||
sock.onmessage = function (ev) {
|
||||
var msg;
|
||||
try {
|
||||
msg = JSON.parse(ev.data);
|
||||
} catch (e) {
|
||||
self._emit('error', { error: '服务端消息不是合法 JSON', raw: String(ev.data).slice(0, 200) });
|
||||
return;
|
||||
}
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
self._dispatch(msg);
|
||||
};
|
||||
|
||||
sock.onerror = function () {
|
||||
self._emit('sockerror', { url: self.url });
|
||||
};
|
||||
|
||||
sock.onclose = function (ev) {
|
||||
self._stopHeartbeat();
|
||||
self.sock = null;
|
||||
if (self.manualClose || self.closedByUser) {
|
||||
self._setState('closed', ev && ev.reason);
|
||||
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: true });
|
||||
return;
|
||||
}
|
||||
self._setState('closed', ev && ev.reason);
|
||||
self._emit('close', { code: ev && ev.code, reason: ev && ev.reason, manual: false });
|
||||
self._scheduleReconnect(ev && ev.reason ? ('连接关闭:' + ev.reason) : '连接已关闭');
|
||||
};
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
WSClient.prototype._dispatch = function (msg) {
|
||||
// 任何入站消息都证明服务端还活着。不能只认 pong:
|
||||
// 服务端执行 device.connect(iOS 17+ 建 RSD 隧道,最长约 1 分钟)时会持续推 log 帧,
|
||||
// 但由于该命令是同步阻塞的,ping 的 pong 可能迟迟回不来——只认 pong 会误判断线。
|
||||
this.lastInboundAt = nowMs();
|
||||
this.missedPings = 0;
|
||||
if (msg.type === 'pong') {
|
||||
this.lastPongAt = nowMs();
|
||||
}
|
||||
// 请求-应答:只要带 id 且命中 pending,就作为该请求的应答
|
||||
if (msg.id && this.pending[msg.id]) {
|
||||
var p = this.pending[msg.id];
|
||||
delete this.pending[msg.id];
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.type === 'ack' && msg.ok === false) {
|
||||
var err = new Error(msg.error || '服务端拒绝了该命令');
|
||||
err.code = msg.code || 'ack_failed';
|
||||
err.cmd = msg.cmd || p.cmd;
|
||||
p.reject(err);
|
||||
} else {
|
||||
p.resolve(msg);
|
||||
}
|
||||
// 应答同时也是事件(例如 ack 之外带状态),继续广播
|
||||
}
|
||||
this._emit(msg.type || 'unknown', msg, msg);
|
||||
};
|
||||
|
||||
/** 发送原始对象(无应答等待) */
|
||||
WSClient.prototype.send = function (obj) {
|
||||
if (!this.sock || this.sock.readyState !== 1) return false;
|
||||
try {
|
||||
this.sock.send(JSON.stringify(obj));
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._emit('error', { error: '发送失败:' + e.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
WSClient.prototype.nextId = function (prefix) {
|
||||
this.seq += 1;
|
||||
return (prefix || 'c') + this.seq + '-' + Math.floor(Math.random() * 1e6).toString(36);
|
||||
};
|
||||
|
||||
/**
|
||||
* 命令请求-应答。
|
||||
* @param {string} cmd 命令 type,例如 'play.start'
|
||||
* @param {object} [fields] 附加字段
|
||||
* @param {number} [timeout] 毫秒,默认 8000
|
||||
* @returns {Promise<object>} 应答消息;ok:false 或超时 → reject(Error)
|
||||
*/
|
||||
WSClient.prototype.request = function (cmd, fields, timeout) {
|
||||
var self = this;
|
||||
var id = this.nextId();
|
||||
var payload = Object.assign({ type: cmd, id: id }, fields || {});
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!self.sock || self.sock.readyState !== 1) {
|
||||
var e = new Error('尚未连接到本地服务,请确认服务已启动');
|
||||
e.code = 'ws_closed';
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
var timer = setTimeout(function () {
|
||||
if (self.pending[id]) {
|
||||
delete self.pending[id];
|
||||
var te = new Error('命令超时(' + cmd + ')');
|
||||
te.code = 'timeout';
|
||||
te.cmd = cmd;
|
||||
reject(te);
|
||||
}
|
||||
}, timeout || CMD_TIMEOUT);
|
||||
self.pending[id] = { resolve: resolve, reject: reject, timer: timer, cmd: cmd };
|
||||
var ok = self.send(payload);
|
||||
if (!ok) {
|
||||
clearTimeout(timer);
|
||||
delete self.pending[id];
|
||||
var se = new Error('发送失败:连接不可用');
|
||||
se.code = 'ws_send_failed';
|
||||
reject(se);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
WSClient.prototype.isOpen = function () {
|
||||
return !!this.sock && this.sock.readyState === 1;
|
||||
};
|
||||
|
||||
WSClient.prototype._startHeartbeat = function () {
|
||||
var self = this;
|
||||
this._stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(function () {
|
||||
if (!self.isOpen()) return;
|
||||
var lastSeen = Math.max(self.lastInboundAt || 0, self.lastPongAt || 0);
|
||||
var idle = nowMs() - lastSeen;
|
||||
// 只要还在收到任何消息(log/status/location/ack)就说明连接健康,不累计丢包
|
||||
if (idle > HEARTBEAT_MS * 3) {
|
||||
self.missedPings += 1;
|
||||
} else {
|
||||
self.missedPings = 0;
|
||||
}
|
||||
if (self.missedPings >= 3) {
|
||||
self._emit('error', {
|
||||
error: '连接静默超过 ' + Math.round(HEARTBEAT_MS * 9 / 1000) + ' 秒,正在重建连接'
|
||||
});
|
||||
try { self.sock.close(); } catch (e) { /* 忽略 */ }
|
||||
return;
|
||||
}
|
||||
self.request('ping', {}, TICK_TIMEOUT).catch(function () { /* ping 超时由 missedPings 兜底 */ });
|
||||
}, HEARTBEAT_MS);
|
||||
};
|
||||
|
||||
WSClient.prototype._stopHeartbeat = function () {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
WSClient.prototype._scheduleReconnect = function (reason) {
|
||||
var self = this;
|
||||
if (this.closedByUser || this.reconnectTimer) return;
|
||||
if (!this.everOpened && !this.protocolFallback && !this.subprotocols) {
|
||||
this.protocolFallback = true;
|
||||
this.subprotocols = ['v1'];
|
||||
this.attempt = 0;
|
||||
this._emit('protocol_fallback', { reason: reason || '' });
|
||||
}
|
||||
var delay = Math.min(MAX_BACKOFF, 400 * Math.pow(2, this.attempt)) + Math.floor(Math.random() * 250);
|
||||
this.attempt += 1;
|
||||
this._emit('reconnecting', { delay: delay, attempt: this.attempt, reason: reason });
|
||||
this.reconnectTimer = setTimeout(function () {
|
||||
self.reconnectTimer = null;
|
||||
self.connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
WSClient.prototype.close = function () {
|
||||
this.closedByUser = true;
|
||||
this.manualClose = true;
|
||||
this._stopHeartbeat();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
var ids = Object.keys(this.pending);
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var p = this.pending[ids[i]];
|
||||
if (p && p.timer) clearTimeout(p.timer);
|
||||
if (p) p.reject(new Error('连接已关闭'));
|
||||
delete this.pending[ids[i]];
|
||||
}
|
||||
if (this.sock) {
|
||||
try { this.sock.close(); } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
this.sock = null;
|
||||
this._setState('closed', 'manual');
|
||||
};
|
||||
|
||||
/** 立即重连(用户点「重试」时用) */
|
||||
WSClient.prototype.reconnectNow = function () {
|
||||
this.attempt = 0;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.sock) {
|
||||
try { this.sock.close(); } catch (e) { /* 忽略 */ }
|
||||
this.sock = null;
|
||||
}
|
||||
this.connect();
|
||||
};
|
||||
|
||||
global.WSClient = WSClient;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+803
@@ -0,0 +1,803 @@
|
||||
/* ==========================================================================
|
||||
iOS 虚拟定位 · 轨迹工具 — 深色专业风样式
|
||||
设计基准 1280x820,最小可用宽度 1024;系统字体栈,无任何外部资源。
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--bg: #0a0d12;
|
||||
--bg-2: #0f141c;
|
||||
--panel: #131a24;
|
||||
--panel-2: #182130;
|
||||
--panel-3: #1d2734;
|
||||
--line: #24303f;
|
||||
--line-2: #2f3d4f;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #9aa8ba;
|
||||
--text-mute: #6c7c90;
|
||||
--accent: #2f81f7;
|
||||
--accent-hi: #4c94ff;
|
||||
--accent-dim: #1b4f9c;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--shadow: 0 6px 20px rgba(0, 0, 0, .45);
|
||||
--font: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "PingFang SC", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1024px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.5 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0; font-weight: 600; }
|
||||
button { font-family: inherit; }
|
||||
input, select { font-family: inherit; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #2b3a4d; border-radius: 6px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a4c63; background-clip: content-box; }
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
/* 三个直属子元素:topbar / workspace / playbar。
|
||||
行数必须与实际子元素数量一致,否则最后一行(播放条)会被多余的空行挤扁,
|
||||
导致底部的「播放」按钮落到可视区域之外,用户完全看不到。 */
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================ 顶部栏 ============================ */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 12px;
|
||||
background: linear-gradient(180deg, #141c27 0%, #101720 100%);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
|
||||
|
||||
.brand-logo {
|
||||
width: 34px; height: 34px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, var(--accent) 0%, #1a5fd0 100%);
|
||||
box-shadow: 0 2px 8px rgba(47, 129, 247, .35);
|
||||
}
|
||||
|
||||
.brand-text h1 { font-size: 14px; letter-spacing: .2px; }
|
||||
.brand-text p { margin: 1px 0 0; font-size: 11px; color: var(--text-mute); }
|
||||
|
||||
/* 设备卡片 */
|
||||
.device-card {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 6px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
min-height: 46px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-empty {
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.device-empty::before { content: "⚠"; font-size: 14px; }
|
||||
|
||||
.dev-name { display: flex; flex-direction: column; gap: 1px; flex: 1 1 auto; min-width: 120px; max-width: 260px; }
|
||||
.dev-name b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dev-name span { font-size: 11px; color: var(--text-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.dev-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(58px, auto));
|
||||
gap: 3px 16px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.dev-row { display: flex; flex-direction: column; gap: 0; min-width: 0; }
|
||||
.dev-k { font-size: 10px; color: var(--text-mute); letter-spacing: .3px; }
|
||||
.dev-v { font-size: 12px; color: var(--text); }
|
||||
.dev-v.ok { color: var(--ok); }
|
||||
.dev-v.warn { color: var(--warn); }
|
||||
.dev-v.err { color: var(--err); }
|
||||
|
||||
.dev-hint {
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
background: rgba(210, 153, 34, .1);
|
||||
border: 1px solid rgba(210, 153, 34, .3);
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.top-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill-ok { color: #0b1210; background: var(--ok); border-color: var(--ok); font-weight: 600; }
|
||||
.pill-err { color: #fff; background: #7d2019; border-color: #b0342a; font-weight: 600; }
|
||||
.pill-warn { color: #1a1405; background: var(--warn); border-color: var(--warn); font-weight: 600; }
|
||||
.pill-mute { color: var(--text-mute); }
|
||||
|
||||
/* ============================ 通用按钮 ============================ */
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background .12s, border-color .12s, transform .06s;
|
||||
}
|
||||
.btn:hover { background: var(--panel-3); border-color: #3d4f66; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .42; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary { background: var(--accent-dim); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent); }
|
||||
.btn-danger { background: #46201d; border-color: #7e3a33; color: #ffd9d5; }
|
||||
.btn-danger:hover { background: #5c2823; }
|
||||
.btn-small { padding: 3px 8px; font-size: 11px; }
|
||||
.btn-block { width: 100%; display: block; padding: 7px 11px; font-size: 13px; }
|
||||
|
||||
.btn-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 6px; }
|
||||
|
||||
/* ============================ 提示条 ============================ */
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 8px 12px 0;
|
||||
padding: 7px 12px;
|
||||
background: rgba(210, 153, 34, .12);
|
||||
border: 1px solid rgba(210, 153, 34, .38);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: #f3d493;
|
||||
}
|
||||
.banner.is-error { background: rgba(248, 81, 73, .12); border-color: rgba(248, 81, 73, .42); color: #ffb4ae; }
|
||||
.banner.is-info { background: rgba(47, 129, 247, .12); border-color: rgba(47, 129, 247, .4); color: #b9d6ff; }
|
||||
.banner-icon {
|
||||
width: 17px; height: 17px; flex: 0 0 auto;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 50%; background: currentColor; color: #12161c;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
.banner-text { flex: 1 1 auto; }
|
||||
|
||||
/* ============================ 工作区 ============================ */
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 312px minmax(0, 1fr) 300px;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
.workspace.log-collapsed { grid-template-columns: 312px minmax(0, 1fr) 36px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.workspace { grid-template-columns: 288px minmax(0, 1fr) 262px; }
|
||||
.workspace.log-collapsed { grid-template-columns: 288px minmax(0, 1fr) 36px; }
|
||||
}
|
||||
@media (max-width: 1080px) {
|
||||
.workspace { grid-template-columns: 272px minmax(0, 1fr) 236px; }
|
||||
.workspace.log-collapsed { grid-template-columns: 272px minmax(0, 1fr) 36px; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-left { min-width: 0; }
|
||||
.panel-right { min-width: 0; overflow: hidden; }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #1a2331 0%, #151d29 100%);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
.panel-head h2 { font-size: 13px; }
|
||||
.panel-sub { font-size: 11px; color: var(--text-mute); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
|
||||
|
||||
.panel-body { padding: 9px 12px 14px; overflow-y: auto; min-height: 0; scrollbar-gutter: stable; }
|
||||
|
||||
.block { margin-bottom: 11px; }
|
||||
.block:last-child { margin-bottom: 0; }
|
||||
.block-muted { opacity: .85; }
|
||||
|
||||
.block-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: .4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.block-label.inline { margin-bottom: 0; white-space: nowrap; }
|
||||
.row .input { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 6px; }
|
||||
.row-wrap { flex-wrap: wrap; }
|
||||
.gap-top { margin-top: 7px; }
|
||||
.row-between { justify-content: space-between; }
|
||||
.value-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent-hi);
|
||||
background: rgba(47, 129, 247, .12);
|
||||
border: 1px solid rgba(47, 129, 247, .3);
|
||||
border-radius: 5px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
/* 分段控件 */
|
||||
.seg {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px;
|
||||
}
|
||||
.seg-btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 5px 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg-btn:hover { background: var(--panel-3); color: var(--text); }
|
||||
.seg-btn.is-active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
/* 滑块 */
|
||||
.range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.range::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-dim), #2b3a4d);
|
||||
}
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px;
|
||||
margin-top: -5px;
|
||||
border-radius: 50%;
|
||||
background: #eaf2ff;
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.range:disabled::-webkit-slider-thumb { border-color: #566273; background: #98a5b5; }
|
||||
.range-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-mute);
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.check { display: flex; align-items: center; gap: 7px; margin-top: 9px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
||||
.check input { accent-color: var(--accent); width: 14px; height: 14px; }
|
||||
|
||||
/* 统计 */
|
||||
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
|
||||
.stat {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.stat-k { font-size: 9px; color: var(--text-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.stat-v { font-size: 10.5px; font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 左侧面板底部固定操作条(保证「加载 OSRM 路线」始终可见) */
|
||||
.actions-sticky {
|
||||
position: sticky;
|
||||
bottom: -1px;
|
||||
z-index: 3;
|
||||
margin: 4px -12px -14px;
|
||||
padding: 8px 12px 9px;
|
||||
background: linear-gradient(180deg, rgba(19, 26, 36, .82) 0%, var(--panel) 42%);
|
||||
border-top: 1px solid var(--line);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 输入 */
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
user-select: text;
|
||||
}
|
||||
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(47, 129, 247, .18); }
|
||||
.select { appearance: none; cursor: pointer; padding-right: 20px;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-mute) 50%), linear-gradient(135deg, var(--text-mute) 50%, transparent 50%);
|
||||
background-position: calc(100% - 12px) 12px, calc(100% - 8px) 12px;
|
||||
background-size: 4px 4px, 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.row .input.select { flex: 1 1 auto; }
|
||||
|
||||
/* 途经点列表 */
|
||||
.wp-list { display: flex; flex-direction: column; gap: 4px; max-height: 208px; overflow-y: auto; }
|
||||
.wp-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
padding: 5px 6px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wp-item:hover { border-color: var(--line-2); background: var(--panel-2); }
|
||||
.wp-item.is-selected { border-color: var(--warn); box-shadow: 0 0 0 1px rgba(210, 153, 34, .35) inset; }
|
||||
.wp-item.is-dragging { opacity: .45; }
|
||||
.wp-item.drag-over { border-color: var(--accent); background: rgba(47, 129, 247, .12); }
|
||||
|
||||
.wp-drag { color: var(--text-mute); cursor: grab; font-size: 12px; letter-spacing: -1px; margin-top: 1px; }
|
||||
.wp-idx {
|
||||
flex: 0 0 auto;
|
||||
width: 19px; height: 19px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
}
|
||||
.wp-item.is-start .wp-idx { background: var(--ok); }
|
||||
.wp-item.is-end .wp-idx { background: var(--err); }
|
||||
.wp-main { display: flex; flex-direction: column; gap: 3px; flex: 1 1 auto; min-width: 0; }
|
||||
.wp-coord { font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); white-space: nowrap; cursor: text; }
|
||||
.wp-actions { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.wp-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--panel-2);
|
||||
color: var(--text-dim);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
padding: 2px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.wp-btn.danger:hover { color: #ffd9d5; border-color: var(--err); background: #46201d; }
|
||||
|
||||
.wp-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
line-height: 1.7;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint-mini { font-size: 10px; color: var(--text-mute); margin-bottom: 6px; }
|
||||
|
||||
.keys { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 8px; font-size: 10.5px; color: var(--text-mute); }
|
||||
kbd {
|
||||
display: inline-block;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-2);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ============================ 地图区 ============================ */
|
||||
|
||||
.map-area { position: relative; min-width: 0; min-height: 0; display: flex; }
|
||||
|
||||
.map-host {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #0b0f14;
|
||||
}
|
||||
|
||||
.map-canvas { display: block; touch-action: none; cursor: grab; }
|
||||
.slippy-map.is-dragging .map-canvas { cursor: grabbing; }
|
||||
.slippy-map.is-crosshair .map-canvas { cursor: crosshair; }
|
||||
|
||||
/* 地图内控件 */
|
||||
.map-zoom { position: absolute; right: 10px; top: 10px; display: flex; flex-direction: column; gap: 6px; z-index: 6; }
|
||||
.map-btn {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgba(19, 26, 36, .92);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.map-btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.map-warn {
|
||||
position: absolute;
|
||||
right: 10px; top: 86px;
|
||||
z-index: 6;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: #ffb4ae;
|
||||
background: rgba(70, 32, 29, .92);
|
||||
border: 1px solid rgba(248, 81, 73, .45);
|
||||
border-radius: 7px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.map-warn-btn {
|
||||
appearance: none; border: 1px solid rgba(248, 81, 73, .6);
|
||||
background: transparent; color: #ffd9d5; border-radius: 5px;
|
||||
font-size: 10px; padding: 1px 6px; cursor: pointer;
|
||||
}
|
||||
.map-warn-btn:hover { background: rgba(248, 81, 73, .25); }
|
||||
|
||||
.map-readout {
|
||||
position: absolute;
|
||||
left: 10px; bottom: 36px;
|
||||
z-index: 6;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(11, 15, 20, .78);
|
||||
border: 1px solid var(--line);
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--text-dim);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.map-readout .mr-z { color: var(--text-mute); }
|
||||
|
||||
.map-scalebar {
|
||||
position: absolute;
|
||||
left: 10px; bottom: 14px;
|
||||
z-index: 6;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ms-bar {
|
||||
height: 7px;
|
||||
border: 1px solid var(--text-dim);
|
||||
border-top: 0;
|
||||
min-width: 24px;
|
||||
background: linear-gradient(90deg, rgba(230, 237, 243, .18), rgba(230, 237, 243, .02));
|
||||
}
|
||||
|
||||
.map-attrib {
|
||||
position: absolute;
|
||||
right: 8px; bottom: 8px;
|
||||
z-index: 6;
|
||||
font-size: 10px;
|
||||
color: var(--text-mute);
|
||||
background: rgba(11, 15, 20, .7);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 绘制工具条 */
|
||||
.map-toolbar {
|
||||
position: absolute;
|
||||
left: 10px; top: 10px;
|
||||
z-index: 7;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 9px;
|
||||
background: rgba(19, 26, 36, .94);
|
||||
border: 1px solid var(--line-2);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(6px);
|
||||
max-width: calc(100% - 24px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tool-btn {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-btn:hover { background: var(--panel-3); color: var(--text); }
|
||||
.tool-btn.is-active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||
.tool-sep { width: 1px; height: 16px; background: var(--line-2); margin: 0 3px; }
|
||||
.tool-hint { font-size: 10.5px; color: var(--text-mute); padding-left: 4px; white-space: nowrap; }
|
||||
|
||||
/* 地图状态胶囊 */
|
||||
.map-chips {
|
||||
position: absolute;
|
||||
left: 50%; bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 6;
|
||||
display: flex; gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: 58%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
font-size: 10.5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 100px;
|
||||
background: rgba(11, 15, 20, .8);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--mono);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.chip.is-playing { color: #0b1210; background: var(--ok); border-color: var(--ok); font-weight: 600; }
|
||||
.chip.is-paused { color: #1a1405; background: var(--warn); border-color: var(--warn); font-weight: 600; }
|
||||
.chip-mute { color: var(--text-mute); }
|
||||
|
||||
/* ============================ 日志面板 ============================ */
|
||||
|
||||
.log-head { align-items: center; }
|
||||
.log-title { font-size: 13px; }
|
||||
.log-actions { margin-left: auto; display: flex; gap: 5px; }
|
||||
|
||||
.log-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.65;
|
||||
user-select: text;
|
||||
min-height: 0;
|
||||
}
|
||||
.log-line { display: flex; gap: 6px; padding: 1px 0; border-bottom: 1px solid rgba(36, 48, 63, .45); }
|
||||
.log-msg, .log-line { overflow-wrap: anywhere; }
|
||||
.log-ts { color: var(--text-mute); flex: 0 0 auto; }
|
||||
.log-lvl { flex: 0 0 auto; width: 34px; text-align: center; border-radius: 4px; font-size: 9.5px; height: 15px; line-height: 15px; }
|
||||
.log-msg { flex: 1 1 auto; color: var(--text-dim); }
|
||||
.level-info .log-lvl { background: rgba(47, 129, 247, .18); color: #8ab6ff; }
|
||||
.level-warn .log-lvl { background: rgba(210, 153, 34, .18); color: #e3bb63; }
|
||||
.level-error .log-lvl { background: rgba(248, 81, 73, .2); color: #ff9c95; }
|
||||
.level-success .log-lvl { background: rgba(63, 185, 80, .18); color: #7ee294; }
|
||||
.level-error .log-msg { color: #ffb4ae; }
|
||||
.level-warn .log-msg { color: #f0d69a; }
|
||||
.level-success .log-msg { color: #9fe8b0; }
|
||||
|
||||
.log-collapsed-label { display: none; }
|
||||
.workspace.log-collapsed .log-body,
|
||||
.workspace.log-collapsed .log-head h2,
|
||||
.workspace.log-collapsed .panel-sub,
|
||||
.workspace.log-collapsed #btnFetchLogs,
|
||||
.workspace.log-collapsed #btnClearLog { display: none; }
|
||||
.workspace.log-collapsed .log-head {
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
padding: 6px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.workspace.log-collapsed .log-actions { margin: 0 auto; }
|
||||
.workspace.log-collapsed #btnToggleLog {
|
||||
padding: 2px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
background: var(--panel-2);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.workspace.log-collapsed .log-collapsed-label {
|
||||
display: block;
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-mute);
|
||||
margin: 10px auto;
|
||||
user-select: none;
|
||||
}
|
||||
.workspace.log-collapsed #logPanel { cursor: pointer; }
|
||||
.workspace.log-collapsed #logPanel:hover .log-collapsed-label { color: var(--text); }
|
||||
|
||||
/* ============================ 播放条 ============================ */
|
||||
|
||||
.playbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 14px;
|
||||
background: linear-gradient(180deg, #131b26 0%, #0f151e 100%);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.transport { display: flex; gap: 6px; }
|
||||
|
||||
.tbtn {
|
||||
appearance: none;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 11px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.tbtn:hover:not(:disabled) { background: var(--panel-3); border-color: #3d4f66; }
|
||||
.tbtn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.tbtn-play:not(:disabled) { background: var(--accent-dim); border-color: var(--accent); color: #fff; }
|
||||
.tbtn-play:not(:disabled):hover { background: var(--accent); }
|
||||
.tbtn-stop:not(:disabled):hover { background: #46201d; border-color: var(--err); color: #ffd9d5; }
|
||||
|
||||
.progress-wrap { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.progress-top { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
|
||||
.play-state { font-weight: 600; color: var(--text); }
|
||||
.play-state.is-playing { color: var(--ok); }
|
||||
.play-state.is-paused { color: var(--warn); }
|
||||
.progress-num { font-family: var(--mono); color: var(--accent-hi); }
|
||||
.range-progress { height: 18px; }
|
||||
.range-progress::-webkit-slider-runnable-track { height: 6px; background: #23303f; }
|
||||
.range-progress::-webkit-slider-thumb { width: 15px; height: 15px; margin-top: -5px; }
|
||||
.progress-times { display: flex; gap: 16px; font-size: 10.5px; color: var(--text-mute); }
|
||||
.progress-times b { color: var(--text-dim); font-family: var(--mono); font-weight: 500; }
|
||||
|
||||
.live-stats { display: flex; gap: 14px; }
|
||||
.live { display: flex; flex-direction: column; gap: 0; min-width: 84px; }
|
||||
.live-k { font-size: 10px; color: var(--text-mute); }
|
||||
.live-v { font-size: 12px; color: var(--text); font-family: var(--mono); }
|
||||
.live-v-mono { font-size: 11.5px; }
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.live { min-width: 70px; }
|
||||
.live-v { font-size: 11px; }
|
||||
.tbtn span { display: none; }
|
||||
.tbtn { padding: 7px 9px; }
|
||||
}
|
||||
|
||||
/* ============================ 弹窗 ============================ */
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(4, 7, 11, .62);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.modal {
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 40px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.modal h3 { font-size: 14px; margin-bottom: 10px; }
|
||||
.modal-body { font-size: 12.5px; color: var(--text-dim); line-height: 1.7; }
|
||||
.modal-body .field { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.modal-body .field label { width: 46px; color: var(--text-dim); font-size: 12px; }
|
||||
.modal-body .field .input { flex: 1 1 auto; font-family: var(--mono); }
|
||||
.modal-body .hint { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||
|
||||
/* ============================ Toast ============================ */
|
||||
|
||||
.toast-wrap {
|
||||
position: fixed;
|
||||
right: 16px; bottom: 96px;
|
||||
z-index: 200;
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
align-items: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
max-width: 380px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: rgba(24, 33, 48, .96);
|
||||
border: 1px solid var(--line-2);
|
||||
border-left: 3px solid var(--accent);
|
||||
box-shadow: var(--shadow);
|
||||
animation: toast-in .18s ease-out;
|
||||
}
|
||||
.toast.is-ok { border-left-color: var(--ok); }
|
||||
.toast.is-warn { border-left-color: var(--warn); }
|
||||
.toast.is-err { border-left-color: var(--err); }
|
||||
.toast.is-out { opacity: 0; transform: translateY(6px); transition: opacity .25s, transform .25s; }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* 紧凑高度下的自适应(例如 820px 高的窗口) */
|
||||
@media (max-height: 780px) {
|
||||
.wp-list { max-height: 180px; }
|
||||
.block { margin-bottom: 11px; }
|
||||
.map-readout { bottom: 28px; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"_comment": "瓦片源由服务端 /api/settings/tiles 提供;此文件仅作为前端离线兜底。coord 字段表示瓦片坐标系:gcj02 需要把 WGS84 转成 GCJ-02 后再请求。",
|
||||
"current": "amap",
|
||||
"sources": {
|
||||
"amap": {
|
||||
"name": "高德矢量",
|
||||
"max_zoom": 18,
|
||||
"attribution": "© 高德地图",
|
||||
"datum": "gcj02"
|
||||
},
|
||||
"amap_sat": {
|
||||
"name": "高德卫星",
|
||||
"max_zoom": 18,
|
||||
"attribution": "© 高德地图",
|
||||
"datum": "gcj02"
|
||||
},
|
||||
"tencent": {
|
||||
"name": "腾讯地图",
|
||||
"max_zoom": 18,
|
||||
"attribution": "© 腾讯地图",
|
||||
"datum": "gcj02"
|
||||
},
|
||||
"arcgis_sat": {
|
||||
"name": "全球卫星(Esri)",
|
||||
"max_zoom": 19,
|
||||
"attribution": "Esri, Maxar, Earthstar Geographics",
|
||||
"datum": "wgs84"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user