// ==UserScript== // @name 广建实习定位助手 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 实习系统定位注入,支持地图选点和坐标缓存 // @author atvkh // @match *://jw.gdcvi.edu.cn/sx/ZhuJMB010306/ByJwb/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // ==/UserScript== (function() { 'use strict'; // ========== 默认坐标(GCJ02)========== const DEFAULT_LAT = 23.1291; const DEFAULT_LNG = 113.2644; // ========== 存储 ========== const Store = { get: (k, d) => { try { return typeof GM_getValue === 'function' ? GM_getValue(k, d) : (parseFloat(localStorage.getItem(k)) || d); } catch(e) { return d; } }, set: (k, v) => { try { if (typeof GM_setValue === 'function') GM_setValue(k, v); else localStorage.setItem(k, v); } catch(e) {} }, getJSON: (k, d) => { try { let v = typeof GM_getValue === 'function' ? GM_getValue(k, null) : localStorage.getItem(k); if (!v) return d; return typeof v === 'string' ? JSON.parse(v) : v; } catch(e) { return d; } }, setJSON: (k, v) => { try { let s = JSON.stringify(v); if (typeof GM_setValue === 'function') GM_setValue(k, s); else localStorage.setItem(k, s); } catch(e) {} } }; let F_LAT = Store.get('sx_fake_lat', DEFAULT_LAT); let F_LNG = Store.get('sx_fake_lng', DEFAULT_LNG); // ========== iframe 坐标同步 ========== if (window === window.top) { setInterval(() => { document.querySelectorAll('iframe').forEach(f => { try { if (f.contentWindow) f.contentWindow.postMessage({ type: 'SYNC_COORD', lat: F_LAT, lng: F_LNG }, '*'); } catch(err){} }); }, 1000); } // ========== 主世界注入脚本 ========== const mainWorldScript = function(initLat, initLng) { if (window.__SX_HOOK_ACTIVE__) return; window.__SX_HOOK_ACTIVE__ = true; let lat = parseFloat(initLat); let lng = parseFloat(initLng); window.addEventListener('message', (e) => { if (e.data && e.data.type === 'SYNC_COORD') { lat = parseFloat(e.data.lat); lng = parseFloat(e.data.lng); } }); // ----- 坐标转换 GCJ02 <-> WGS84 ----- const PI = 3.1415926535897932384626; const a = 6378245.0; const ee = 0.00669342162296594323; const transformLat = (x, y) => { let 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 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0; return ret; }; const transformLng = (x, y) => { let 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; }; const gcj02towgs84 = (lng, lat) => { let dlat = transformLat(lng - 105.0, lat - 35.0); let dlng = transformLng(lng - 105.0, lat - 35.0); let radlat = lat / 180.0 * PI; let magic = Math.sin(radlat); magic = 1 - ee * magic * magic; let 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 * 2 - (lng + dlng), lat * 2 - (lat + dlat)]; }; const getFakeData = () => { const jitter = () => (Math.random() - 0.5) * 0.00004; return { lat: lat + jitter(), lng: lng + jitter(), accuracy: 10 + Math.random() * 5 }; }; // ----- Function.toString 伪装 ----- const origToString = Function.prototype.toString; Object.defineProperty(Function.prototype, 'toString', { value: new Proxy(origToString, { apply(target, thisArg, args) { if (thisArg && thisArg.__hook_name) return 'function ' + thisArg.__hook_name + '() { [native code] }'; return Reflect.apply(target, thisArg, args); } }), configurable: true, writable: true }); // ----- 拦截 iframe src 防止绕过 ----- try { const desc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'); if (desc) { const newSet = new Proxy(desc.set, { apply(target, thisArg, args) { if (args[0] && typeof args[0] === 'string' && args[0].includes('geolocation')) args[0] = 'about:blank'; return Reflect.apply(target, thisArg, args); } }); newSet.__hook_name = 'set src'; Object.defineProperty(HTMLIFrameElement.prototype, 'src', { set: newSet, get: desc.get, configurable: desc.configurable, enumerable: desc.enumerable }); } } catch(e) {} // ----- Hook W3C Geolocation ----- const hookW3C = (origMethod, name) => { if (typeof origMethod !== 'function') return origMethod; const proxy = new Proxy(origMethod, { apply(target, thisArg, args) { const suc = args[0]; if (typeof suc === 'function') { const fd = getFakeData(); const [wgsLng, wgsLat] = gcj02towgs84(fd.lng, fd.lat); setTimeout(() => suc({ coords: { latitude: wgsLat, longitude: wgsLng, accuracy: fd.accuracy, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() }), 50); } return name === 'watchPosition' ? Math.floor(Math.random() * 10000) : undefined; } }); proxy.__hook_name = name; return proxy; }; if (window.Geolocation && Geolocation.prototype) { try { Object.defineProperty(Geolocation.prototype, 'getCurrentPosition', { value: hookW3C(Geolocation.prototype.getCurrentPosition, 'getCurrentPosition'), configurable: false, writable: false }); Object.defineProperty(Geolocation.prototype, 'watchPosition', { value: hookW3C(Geolocation.prototype.watchPosition, 'watchPosition'), configurable: false, writable: false }); } catch(e) {} } if (navigator.geolocation) { try { const geoProxy = new Proxy(navigator.geolocation, { get(target, prop) { if (prop === 'getCurrentPosition') return hookW3C(target[prop], 'getCurrentPosition'); if (prop === 'watchPosition') return hookW3C(target[prop], 'watchPosition'); return typeof target[prop] === 'function' ? target[prop].bind(target) : target[prop]; } }); Object.defineProperty(navigator, 'geolocation', { value: geoProxy, configurable: false, writable: false, enumerable: true }); } catch(e) {} } // ----- Hook 腾讯地图 Geolocation ----- const proxyCache = new WeakSet(); const hookTencentGeo = (OrigGeo) => { if (proxyCache.has(OrigGeo)) return OrigGeo; const HookedGeo = new Proxy(OrigGeo, { construct(target, args) { const instance = Reflect.construct(target, args); if (proxyCache.has(instance)) return instance; const InstanceProxy = new Proxy(instance, { get(target2, prop) { if (prop === 'getLocation' || prop === 'getIpLocation') { const fakeMethod = function(suc) { if (suc) { const fd = getFakeData(); setTimeout(() => suc({ module: 'geolocation', type: 'h5', lat: fd.lat, lng: fd.lng, accuracy: fd.accuracy, nation: '中国', province: '广东省', city: '广州市', adcode: '440100', __fake__: true }), 50); } }; fakeMethod.__hook_name = prop; return fakeMethod; } const val = Reflect.get(target2, prop); return typeof val === 'function' ? val.bind(target2) : val; } }); proxyCache.add(InstanceProxy); return InstanceProxy; } }); proxyCache.add(HookedGeo); return HookedGeo; }; let _qq = window.qq; Object.defineProperty(window, 'qq', { get() { if (_qq && !proxyCache.has(_qq)) { _qq = new Proxy(_qq, { get(target, prop) { if (prop === 'maps') { const maps = Reflect.get(target, prop); if (maps && !proxyCache.has(maps)) { const mapsProxy = new Proxy(maps, { get(t2, p2) { return p2 === 'Geolocation' ? hookTencentGeo(Reflect.get(t2, p2)) : Reflect.get(t2, p2); } }); proxyCache.add(mapsProxy); return mapsProxy; } } return Reflect.get(target, prop); } }); proxyCache.add(_qq); } return _qq; }, set(v) { _qq = v; }, configurable: true }); // ----- Hook 高德地图 AMap.Geolocation ----- const hookAMapGeo = (OrigGeo) => { if (!OrigGeo || proxyCache.has(OrigGeo)) return OrigGeo; const HookedGeo = new Proxy(OrigGeo, { construct(target, args) { const instance = Reflect.construct(target, args); if (proxyCache.has(instance)) return instance; const InstanceProxy = new Proxy(instance, { get(target2, prop) { if (prop === 'getCurrentPosition' || prop === 'getCityInfo') { const fakeMethod = function(suc) { const fd = getFakeData(); const result = { position: [fd.lng, fd.lat], coords: { latitude: fd.lat, longitude: fd.lng, accuracy: fd.accuracy }, accuracy: fd.accuracy, isHighAccuracy: true, status: 1, info: 'SUCCESS', __fake__: true }; if (prop === 'getCityInfo') Object.assign(result, { city: '广州市', province: '广东省', adcode: '440100' }); if (typeof suc === 'function') { setTimeout(() => { suc('complete', result); }, 50); } return result; }; fakeMethod.__hook_name = prop; return fakeMethod; } const val = Reflect.get(target2, prop); return typeof val === 'function' ? val.bind(target2) : val; } }); proxyCache.add(InstanceProxy); return InstanceProxy; } }); proxyCache.add(HookedGeo); return HookedGeo; }; let _amap = window.AMap; Object.defineProperty(window, 'AMap', { get() { if (_amap && !_amap.__amap_hooked__) { const proxy = new Proxy(_amap, { get(target, prop) { if (prop === 'Geolocation') { const geo = Reflect.get(target, prop); return geo ? hookAMapGeo(geo) : geo; } if (prop === '__amap_hooked__') return true; return Reflect.get(target, prop); } }); _amap = proxy; } return _amap; }, set(v) { _amap = v; }, configurable: true }); // ----- Hook 百度地图 BMap.Geolocation ----- const hookBMapGeo = (OrigGeo) => { if (!OrigGeo || proxyCache.has(OrigGeo)) return OrigGeo; const HookedGeo = new Proxy(OrigGeo, { construct(target, args) { const instance = Reflect.construct(target, args); if (proxyCache.has(instance)) return instance; const InstanceProxy = new Proxy(instance, { get(target2, prop) { if (prop === 'getCurrentPosition') { const fakeMethod = function(opts) { const fd = getFakeData(); const result = { latitude: fd.lat, longitude: fd.lng, accuracy: fd.accuracy, point: { lat: fd.lat, lng: fd.lng }, __fake__: true }; if (opts && typeof opts.success === 'function') { setTimeout(() => opts.success({ point: { lat: fd.lat, lng: fd.lng }, accuracy: fd.accuracy }), 50); } return BMap.GeolocationStatusSuccess || 0; }; fakeMethod.__hook_name = prop; return fakeMethod; } const val = Reflect.get(target2, prop); return typeof val === 'function' ? val.bind(target2) : val; } }); proxyCache.add(InstanceProxy); return InstanceProxy; } }); proxyCache.add(HookedGeo); return HookedGeo; }; let _bmap = window.BMap; Object.defineProperty(window, 'BMap', { get() { if (_bmap && !_bmap.__bmap_hooked__) { const proxy = new Proxy(_bmap, { get(target, prop) { if (prop === 'Geolocation') { const geo = Reflect.get(target, prop); return geo ? hookBMapGeo(geo) : geo; } if (prop === '__bmap_hooked__') return true; return Reflect.get(target, prop); } }); _bmap = proxy; } return _bmap; }, set(v) { _bmap = v; }, configurable: true }); // ----- Hook 微信 JSSDK wx.getLocation ----- let _wx = window.wx; if (_wx && !_wx.__wx_hooked__) { const origGetLocation = _wx.getLocation; if (typeof origGetLocation === 'function') { _wx.getLocation = function(opts) { const fd = getFakeData(); const result = { latitude: fd.lat, longitude: fd.lng, accuracy: fd.accuracy, errMsg: 'getLocation:ok', __fake__: true }; if (opts && typeof opts.success === 'function') { setTimeout(() => opts.success(result), 50); } }; _wx.getLocation.__hook_name = 'getLocation'; } _wx.__wx_hooked__ = true; } Object.defineProperty(window, 'wx', { get() { return _wx; }, set(v) { _wx = v; if (_wx && !_wx.__wx_hooked__ && typeof _wx.getLocation === 'function') { const orig = _wx.getLocation; _wx.getLocation = function(opts) { const fd = getFakeData(); const result = { latitude: fd.lat, longitude: fd.lng, accuracy: fd.accuracy, errMsg: 'getLocation:ok', __fake__: true }; if (opts && typeof opts.success === 'function') setTimeout(() => opts.success(result), 50); }; _wx.getLocation.__hook_name = 'getLocation'; _wx.__wx_hooked__ = true; } }, configurable: true }); }; // ========== 注入到主世界 ========== const injectToMainWorld = () => { const script = document.createElement('script'); script.textContent = '(' + mainWorldScript.toString() + `)(${F_LAT}, ${F_LNG});`; const target = document.head || document.documentElement; if (target) { target.appendChild(script); script.remove(); } }; if (document.head || document.documentElement) injectToMainWorld(); else { const obs = new MutationObserver(() => { if (document.head || document.documentElement) { obs.disconnect(); injectToMainWorld(); } }); obs.observe(document, { childList: true, subtree: true }); } // ========== UI ========== let shadowRoot = null; const showToast = (msg, isError = false) => { if (!shadowRoot) return; const color = isError ? 'rgba(255, 59, 48, 0.9)' : 'rgba(52, 199, 89, 0.9)'; const toast = document.createElement('div'); toast.style.cssText = `position:absolute;top:60px;left:50%;transform:translateX(-50%);background:rgba(255,255,255,0.95);border:1px solid ${color};color:${color};padding:14px 24px;border-radius:30px;font-size:15px;font-weight:900;box-shadow:0 8px 24px rgba(0,0,0,0.15);display:flex;align-items:center;gap:10px;white-space:nowrap;z-index:9999;transition:all 0.4s cubic-bezier(0.32, 0.72, 0, 1);opacity:0;backdrop-filter:blur(10px);`; toast.innerHTML = (isError ? '操作失败' : '操作成功') + `${msg}`; shadowRoot.appendChild(toast); setTimeout(() => { toast.style.opacity = '1'; }, 10); setTimeout(() => { toast.style.opacity = '0'; toast.style.top = '-50px'; }, 3000); setTimeout(() => { if (toast.parentNode) toast.remove(); }, 3500); }; // ========== Leaflet 动态加载 ========== let leafletLoaded = false; let leafletCallbacks = []; const loadLeaflet = (callback) => { if (leafletLoaded) { callback(); return; } leafletCallbacks.push(callback); if (leafletCallbacks.length > 1) return; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(link); const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js'; script.onload = () => { leafletLoaded = true; leafletCallbacks.forEach(cb => cb()); leafletCallbacks = []; }; document.head.appendChild(script); }; // ========== 坐标转换(搜索结果 WGS84→GCJ02)========== const _PI = 3.1415926535897932384626; const _a = 6378245.0; const _ee = 0.00669342162296594323; const _transformLat = (x, y) => { let 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 * Math.sin(y * _PI / 30.0)) * 2.0 / 3.0; return ret; }; const _transformLng = (x, y) => { let 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; }; const wgs84togcj02 = (lng, lat) => { let dlat = _transformLat(lng - 105.0, lat - 35.0); let dlng = _transformLng(lng - 105.0, lat - 35.0); let radlat = lat / 180.0 * _PI; let magic = Math.sin(radlat); magic = 1 - _ee * magic * magic; let 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]; }; // ========== 地图选点(高德瓦片,GCJ02坐标系,所见即所得)========== let mapInstance = null; let mapMarker = null; let selectedCoord = null; const openMapPicker = () => { loadLeaflet(() => { if (document.getElementById('sx-map-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'sx-map-overlay'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;background:#fff;display:flex;flex-direction:column;'; overlay.innerHTML = `
`; document.body.appendChild(overlay); // 初始化地图(高德瓦片,GCJ02坐标系,无需转换) setTimeout(() => { mapInstance = L.map('sx-map-container', { zoomControl: true, attributionControl: false }).setView([F_LAT, F_LNG], 16); // 高德地图瓦片(GCJ02) L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', { maxZoom: 18, subdomains: ['1', '2', '3', '4'] }).addTo(mapInstance); mapMarker = L.marker([F_LAT, F_LNG], { draggable: true }).addTo(mapInstance); selectedCoord = { lat: F_LAT, lng: F_LNG }; updateCoordDisplay(); // 点击地图选点 mapInstance.on('click', (e) => { mapMarker.setLatLng(e.latlng); selectedCoord = { lat: e.latlng.lat, lng: e.latlng.lng }; updateCoordDisplay(); }); // 拖拽标记 mapMarker.on('dragend', (e) => { const pos = e.target.getLatLng(); selectedCoord = { lat: pos.lat, lng: pos.lng }; updateCoordDisplay(); }); }, 100); const updateCoordDisplay = () => { if (!selectedCoord) return; document.getElementById('sx-map-coord').textContent = `${selectedCoord.lat.toFixed(6)}, ${selectedCoord.lng.toFixed(6)}`; }; // 搜索(Nominatim 免费 API,返回 WGS84 坐标,需转 GCJ02) const doSearch = () => { const q = document.getElementById('sx-map-search').value.trim(); if (!q) return; const resultsEl = document.getElementById('sx-map-search-results'); resultsEl.style.display = 'block'; resultsEl.innerHTML = '
搜索中...
'; fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=5&accept-language=zh-CN&countrycodes=cn`) .then(r => r.json()) .then(data => { if (!data || data.length === 0) { resultsEl.innerHTML = '
未找到结果
'; return; } resultsEl.innerHTML = data.map((item, idx) => `
${item.display_name.split(',')[0]}
${item.display_name}
`).join(''); resultsEl.querySelectorAll('.sx-search-item').forEach(el => { el.addEventListener('mouseenter', () => el.style.background = '#f5f5f5'); el.addEventListener('mouseleave', () => el.style.background = ''); el.addEventListener('click', () => { const item = data[parseInt(el.dataset.idx)]; // Nominatim 返回 WGS84,转 GCJ02 后放到高德瓦片上 const [gcjLng, gcjLat] = wgs84togcj02(parseFloat(item.lon), parseFloat(item.lat)); mapInstance.setView([gcjLat, gcjLng], 16); mapMarker.setLatLng([gcjLat, gcjLng]); selectedCoord = { lat: gcjLat, lng: gcjLng }; updateCoordDisplay(); resultsEl.style.display = 'none'; }); }); }) .catch(() => { resultsEl.innerHTML = '
搜索失败,请重试
'; }); }; document.getElementById('sx-map-search-btn').addEventListener('click', doSearch); document.getElementById('sx-map-search').addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); }); // 关闭 document.getElementById('sx-map-close').addEventListener('click', closeMapPicker); // 确认选点(高德瓦片已是GCJ02,直接使用) document.getElementById('sx-map-confirm').addEventListener('click', () => { if (!selectedCoord) { showToast('请先在地图上选点', true); return; } F_LAT = selectedCoord.lat; F_LNG = selectedCoord.lng; Store.set('sx_fake_lat', F_LAT); Store.set('sx_fake_lng', F_LNG); closeMapPicker(); syncAndReload(F_LAT, F_LNG); }); }); }; const closeMapPicker = () => { const overlay = document.getElementById('sx-map-overlay'); if (overlay) overlay.remove(); if (mapInstance) { mapInstance.remove(); mapInstance = null; } selectedCoord = null; }; // ========== 同步坐标并刷新 ========== const syncAndReload = (lat, lng) => { Store.set('sx_fake_lat', lat); Store.set('sx_fake_lng', lng); window.postMessage({ type: 'SYNC_COORD', lat, lng }, '*'); document.querySelectorAll('iframe').forEach(f => { try { if (f.contentWindow) f.contentWindow.postMessage({ type: 'SYNC_COORD', lat, lng }, '*'); } catch(err){} }); if (shadowRoot) { const panel = shadowRoot.getElementById('panel'); if (panel) panel.classList.remove('show'); const overlay = shadowRoot.getElementById('overlay'); if (overlay) overlay.classList.remove('active'); } showToast('坐标已应用,刷新页面中...'); setTimeout(() => location.reload(), 800); }; // ========== 创建 UI ========== const createUI = () => { if (document.getElementById('sx-host')) return; const root = document.documentElement || document.body; if (!root) return; const host = document.createElement('div'); host.id = 'sx-host'; host.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2147483647;'; root.appendChild(host); shadowRoot = host.attachShadow({ mode: 'closed' }); const style = document.createElement('style'); style.textContent = ` * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif; } #fab { position: absolute; top: 100px; right: 20px; width: 52px; height: 52px; background: rgba(255, 255, 255, 0.85); border-radius: 50%; box-shadow: 0 4px 16px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,1); pointer-events: auto; display: flex; justify-content: center; align-items: center; cursor: pointer; user-select: none; backdrop-filter: blur(12px) saturate(180%); transition: transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1); } #fab:active { transform: scale(0.88); } #overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.4); opacity: 0; pointer-events: none; transition: opacity 0.35s cubic-bezier(0.25, 0.8, 0.25, 1); backdrop-filter: blur(4px); } #overlay.active { opacity: 1; pointer-events: auto; } #panel { position: absolute; display: none; pointer-events: auto; background: rgba(245, 245, 247, 0.95); padding: 20px 18px 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.18); backdrop-filter: blur(25px) saturate(200%); border: 1px solid rgba(255,255,255,0.7); max-height: 85vh; overflow-y: auto; transition: opacity 0.25s ease, transform 0.25s ease; } #panel.show { display: block; } .panel-header { position: relative; margin-bottom: 16px; display: flex; align-items: center; justify-content: center; cursor: move; } .panel-title { font-weight: 700; font-size: 15px; color: #1d1d1f; } #btn-close { position: absolute; right: 0; width: 26px; height: 26px; background: rgba(0,0,0,0.06); border-radius: 50%; display: flex; justify-content: center; align-items: center; font-size: 14px; cursor: pointer; } .input-group { display: flex; gap: 10px; margin-bottom: 14px; } .input-box { flex: 1; display: flex; flex-direction: column; gap: 6px; } .input-box label { font-size: 12px; color: #86868b; font-weight: 600; } .input-box input { width: 100%; padding: 10px 10px; border: 1.5px solid rgba(0,0,0,0.04); border-radius: 10px; font-size: 14px; outline: none; background: #ffffff; color: #1d1d1f; font-weight: 600; font-family: monospace; transition: all 0.25s ease; } .input-box input:focus { border-color: #34c759; box-shadow: 0 0 0 3px rgba(52, 199, 89, 0.15); } .btn-map { width: 100%; padding: 12px; border: none; border-radius: 10px; background: #e8f0fe; color: #007aff; font-weight: 600; font-size: 14px; cursor: pointer; margin-bottom: 14px; transition: transform 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; } .btn-map:active { transform: scale(0.97); } .btn-group { display: flex; gap: 10px; margin-bottom: 16px; } .btn-group button { flex: 1; padding: 12px; border: none; border-radius: 12px; font-weight: 600; font-size: 14px; cursor: pointer; transition: transform 0.2s; } .btn-group button:active { transform: scale(0.96); } #btn-save { background: linear-gradient(135deg, #32d74b, #28a745); color: #ffffff; box-shadow: 0 4px 12px rgba(40,167,69,0.25); } #btn-reset { background: #e8f0fe; color: #007aff; } .section-label { font-size: 12px; color: #86868b; font-weight: 600; margin-bottom: 8px; } .saved-list { margin-top: 6px; } .saved-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #fff; border-radius: 10px; margin-bottom: 6px; border: 1px solid #e8e8e8; } .saved-item-info { flex: 1; } .saved-item-name { font-size: 13px; font-weight: 600; color: #1d1d1f; } .saved-item-coord { font-size: 10px; color: #999; font-family: monospace; margin-top: 2px; } .saved-item-actions { display: flex; gap: 6px; } .saved-item-btn { padding: 5px 10px; border: none; border-radius: 6px; font-size: 11px; cursor: pointer; font-weight: 500; } .saved-item-btn.apply { background: #34c759; color: #fff; } .saved-item-btn.del { background: #ff4d4f; color: #fff; } .btn-save-location { width: 100%; padding: 10px; border: 1.5px dashed #c7c7cc; border-radius: 10px; background: transparent; color: #86868b; font-weight: 500; font-size: 13px; cursor: pointer; margin-bottom: 10px; transition: all 0.2s; } .btn-save-location:hover { border-color: #34c759; color: #34c759; } /* 手机端:底部弹出 */ @media (max-width: 599px) { #panel { bottom: 0; left: 0; width: 100%; border-top-left-radius: 24px; border-top-right-radius: 24px; border-bottom: none; } #panel.show { display: block; } } /* 桌面端:浮动卡片 */ @media (min-width: 600px) { #panel { width: 340px; border-radius: 20px; top: 80px; right: 24px; } #overlay { display: none !important; } } `; shadowRoot.appendChild(style); const overlay = document.createElement('div'); overlay.id = 'overlay'; shadowRoot.appendChild(overlay); const fab = document.createElement('div'); fab.id = 'fab'; fab.innerHTML = ``; shadowRoot.appendChild(fab); const panel = document.createElement('div'); panel.id = 'panel'; panel.innerHTML = `
实习定位控制台
已保存位置
`; shadowRoot.appendChild(panel); // 渲染已保存位置 const renderSavedList = () => { const saved = Store.getJSON('sx_saved_locations', []); const listEl = shadowRoot.getElementById('saved-list'); if (saved.length === 0) { listEl.innerHTML = '
暂无保存的位置
'; return; } listEl.innerHTML = ''; saved.forEach((loc, idx) => { const item = document.createElement('div'); item.className = 'saved-item'; item.innerHTML = `
${loc.name}
${loc.lat.toFixed(6)}, ${loc.lng.toFixed(6)}
`; listEl.appendChild(item); }); listEl.querySelectorAll('.saved-item-btn.apply').forEach(btn => { btn.addEventListener('click', () => { const saved = Store.getJSON('sx_saved_locations', []); const loc = saved[parseInt(btn.dataset.idx)]; if (loc) { shadowRoot.getElementById('lat-input').value = loc.lat; shadowRoot.getElementById('lng-input').value = loc.lng; syncAndReload(loc.lat, loc.lng); } }); }); listEl.querySelectorAll('.saved-item-btn.del').forEach(btn => { btn.addEventListener('click', () => { const saved = Store.getJSON('sx_saved_locations', []); saved.splice(parseInt(btn.dataset.idx), 1); Store.setJSON('sx_saved_locations', saved); renderSavedList(); showToast('已删除'); }); }); }; renderSavedList(); // 面板开关 const isDesktop = window.innerWidth >= 600; const togglePanel = (show) => { panel.classList.toggle('show', show); if (!isDesktop) overlay.classList.toggle('active', show); }; overlay.addEventListener('click', () => togglePanel(false)); shadowRoot.getElementById('btn-close').addEventListener('click', () => togglePanel(false)); // 悬浮球拖拽 let isDragging = false, isMoved = false, startX, startY, startLeft, startTop; const startDrag = (e) => { if (e.type.includes('touch') && e.touches.length > 1) return; isDragging = true; isMoved = false; startX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; startY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY; startLeft = fab.offsetLeft; startTop = fab.offsetTop; fab.style.transition = 'none'; }; const doDrag = (e) => { if (!isDragging) return; if (e.type.includes('touch') && e.touches.length > 1) { isDragging = false; return; } const cx = e.type.includes('touch') ? e.touches[0].clientX : e.clientX; const cy = e.type.includes('touch') ? e.touches[0].clientY : e.clientY; if (Math.abs(cx - startX) > 3 || Math.abs(cy - startY) > 3) { isMoved = true; e.preventDefault(); } if (isMoved) { fab.style.left = `${Math.max(0, Math.min(startLeft + cx - startX, window.innerWidth - fab.offsetWidth))}px`; fab.style.top = `${Math.max(0, Math.min(startTop + cy - startY, window.innerHeight - fab.offsetHeight))}px`; } }; const stopDrag = () => { if (!isDragging) return; isDragging = false; fab.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1)'; if (!isMoved) togglePanel(!panel.classList.contains('show')); }; fab.addEventListener('mousedown', startDrag); fab.addEventListener('touchstart', startDrag, { passive: false }); window.addEventListener('mousemove', doDrag, { passive: false }); window.addEventListener('touchmove', doDrag, { passive: false }); window.addEventListener('mouseup', stopDrag); window.addEventListener('touchend', stopDrag); // 桌面端面板拖拽(拖标题栏移动) if (isDesktop) { let pDragging = false, pMoved = false, pStartX, pStartY, pStartLeft, pStartTop; const header = shadowRoot.getElementById('panel-header'); header.addEventListener('mousedown', (e) => { if (e.target.id === 'btn-close') return; pDragging = true; pMoved = false; pStartX = e.clientX; pStartY = e.clientY; const rect = panel.getBoundingClientRect(); pStartLeft = rect.left; pStartTop = rect.top; panel.style.transition = 'none'; }); window.addEventListener('mousemove', (e) => { if (!pDragging) return; const dx = e.clientX - pStartX; const dy = e.clientY - pStartY; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) pMoved = true; if (pMoved) { panel.style.left = `${Math.max(0, Math.min(pStartLeft + dx, window.innerWidth - panel.offsetWidth))}px`; panel.style.top = `${Math.max(0, Math.min(pStartTop + dy, window.innerHeight - panel.offsetHeight))}px`; panel.style.right = 'auto'; } }); window.addEventListener('mouseup', () => { if (pDragging) { pDragging = false; panel.style.transition = ''; } }); } // 手机端面板下滑关闭 if (!isDesktop) { let sheetStartY = 0, sheetIsDragging = false; panel.addEventListener('touchstart', (e) => { if (['INPUT', 'BUTTON'].includes(e.target.tagName)) return; sheetStartY = e.touches[0].clientY; sheetIsDragging = true; panel.style.transition = 'none'; }, { passive: true }); panel.addEventListener('touchmove', (e) => { if (!sheetIsDragging) return; const dy = e.touches[0].clientY - sheetStartY; if (dy > 0) panel.style.transform = `translateY(${dy}px)`; }, { passive: true }); panel.addEventListener('touchend', (e) => { if (!sheetIsDragging) return; sheetIsDragging = false; panel.style.transition = ''; panel.style.transform = ''; if (e.changedTouches[0].clientY - sheetStartY > 60) togglePanel(false); }); } // 按钮事件 shadowRoot.getElementById('btn-map').addEventListener('click', () => { togglePanel(false); setTimeout(openMapPicker, 300); }); shadowRoot.getElementById('btn-save').addEventListener('click', () => { const lat = parseFloat(shadowRoot.getElementById('lat-input').value); const lng = parseFloat(shadowRoot.getElementById('lng-input').value); if (!isNaN(lat) && !isNaN(lng)) syncAndReload(lat, lng); else showToast('坐标格式错误', true); }); shadowRoot.getElementById('btn-reset').addEventListener('click', () => { shadowRoot.getElementById('lat-input').value = DEFAULT_LAT; shadowRoot.getElementById('lng-input').value = DEFAULT_LNG; syncAndReload(DEFAULT_LAT, DEFAULT_LNG); }); shadowRoot.getElementById('btn-save-loc').addEventListener('click', () => { const lat = parseFloat(shadowRoot.getElementById('lat-input').value); const lng = parseFloat(shadowRoot.getElementById('lng-input').value); if (isNaN(lat) || isNaN(lng)) { showToast('坐标无效', true); return; } const name = prompt('请输入位置名称:', `位置${Date.now() % 10000}`); if (!name) return; const saved = Store.getJSON('sx_saved_locations', []); saved.push({ name, lat, lng }); Store.setJSON('sx_saved_locations', saved); renderSavedList(); showToast('位置已保存'); }); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', createUI); else createUI(); })();