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:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+334
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+235
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+339
View File
@@ -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
View File
@@ -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; }
}
+30
View File
@@ -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"
}
}
}