Add WebSocket, multi API key, geocoding proxy, beacon map picker, and comprehensive bug fixes
- Multi API Key + permission system (read/write/admin) with SHA-256 hash - WebSocket real-time push (location, alarm, device_status, attendance, bluetooth) - Geocoding proxy endpoints for Amap POI search and reverse geocode - Beacon modal map-based location picker with search and click-to-select - GCJ-02 ↔ WGS-84 bidirectional coordinate conversion - Data cleanup scheduler (configurable retention days) - Fix GPS longitude sign inversion (course_status bit 11: 0=East, 1=West) - Fix 2G CellID 2→3 bytes across all protocols (0x28, 0x2C, parser.py) - Fix parser loop guards, alarm_source field length, CommandLog.sent_at - Fix geocoding IMEI parameterization, require_admin import - Improve API error messages for 422 validation errors - Remove beacon floor/area fields (consolidated into name) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -28,6 +28,9 @@
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; }
|
||||
.beacon-search-item:hover { background: #1e3a5f; }
|
||||
#addBeaconMapDiv .leaflet-pane, #editBeaconMapDiv .leaflet-pane { z-index: 0 !important; }
|
||||
#addBeaconMapDiv .leaflet-control, #editBeaconMapDiv .leaflet-control { z-index: 1 !important; }
|
||||
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; }
|
||||
.toast.success { background: #059669; }
|
||||
@@ -938,7 +941,9 @@
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.detail || `HTTP ${response.status}`);
|
||||
const detail = data.detail;
|
||||
const msg = data.message || (typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map(e => e.msg || JSON.stringify(e)).join('; ') : null) || `HTTP ${response.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (data.code !== undefined && data.code !== 0) {
|
||||
throw new Error(data.message || '请求失败');
|
||||
@@ -1082,6 +1087,7 @@
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (typeof destroyBeaconPickerMap === 'function') destroyBeaconPickerMap();
|
||||
document.getElementById('modalContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
@@ -1775,6 +1781,19 @@
|
||||
return [lat + dLat, lng + dLng];
|
||||
}
|
||||
|
||||
function gcj02ToWgs84(gcjLat, gcjLng) {
|
||||
if (_outOfChina(gcjLat, gcjLng)) return [gcjLat, gcjLng];
|
||||
let dLat = _transformLat(gcjLng - 105.0, gcjLat - 35.0);
|
||||
let dLng = _transformLng(gcjLng - 105.0, gcjLat - 35.0);
|
||||
const radLat = gcjLat / 180.0 * Math.PI;
|
||||
let magic = Math.sin(radLat);
|
||||
magic = 1 - _gcj_ee * magic * magic;
|
||||
const sqrtMagic = Math.sqrt(magic);
|
||||
dLat = (dLat * 180.0) / ((_gcj_a * (1 - _gcj_ee)) / (magic * sqrtMagic) * Math.PI);
|
||||
dLng = (dLng * 180.0) / (_gcj_a / sqrtMagic * Math.cos(radLat) * Math.PI);
|
||||
return [gcjLat - dLat, gcjLng - dLng];
|
||||
}
|
||||
|
||||
// Convert WGS-84 coords for current map provider
|
||||
function toMapCoord(lat, lng) {
|
||||
if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng);
|
||||
@@ -1910,6 +1929,8 @@
|
||||
mapMarkers.push(marker);
|
||||
locationMap.setView([mLat, mLng], 15);
|
||||
showToast('已显示最新位置');
|
||||
// Auto-load records table
|
||||
loadLocationRecords(1);
|
||||
} catch (err) {
|
||||
showToast('获取最新位置失败: ' + err.message, 'error');
|
||||
}
|
||||
@@ -2225,30 +2246,127 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Beacon map picker ----
|
||||
let _beaconPickerMap = null;
|
||||
let _beaconPickerMarker = null;
|
||||
let _beaconSearchTimeout = null;
|
||||
|
||||
function initBeaconPickerMap(mapDivId, latInputId, lonInputId, addrInputId, initLat, initLon) {
|
||||
setTimeout(() => {
|
||||
const defaultCenter = [30.605, 103.936];
|
||||
const hasInit = initLat && initLon;
|
||||
const wgsCenter = hasInit ? [initLat, initLon] : defaultCenter;
|
||||
const [mLat, mLng] = toMapCoord(wgsCenter[0], wgsCenter[1]);
|
||||
const zoom = hasInit ? 16 : 12;
|
||||
|
||||
_beaconPickerMap = L.map(mapDivId, {zoomControl: true}).setView([mLat, mLng], zoom);
|
||||
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', {
|
||||
subdomains: '1234', maxZoom: 18,
|
||||
attribution: '© 高德地图',
|
||||
}).addTo(_beaconPickerMap);
|
||||
|
||||
if (hasInit) {
|
||||
_beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap);
|
||||
}
|
||||
|
||||
_beaconPickerMap.on('click', async (e) => {
|
||||
const gcjLat = e.latlng.lat, gcjLng = e.latlng.lng;
|
||||
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
|
||||
document.getElementById(latInputId).value = wgsLat.toFixed(6);
|
||||
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
|
||||
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]);
|
||||
else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap);
|
||||
try {
|
||||
const res = await apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`);
|
||||
if (res.address) document.getElementById(addrInputId).value = res.address;
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
const syncMarker = () => {
|
||||
const lat = parseFloat(document.getElementById(latInputId).value);
|
||||
const lon = parseFloat(document.getElementById(lonInputId).value);
|
||||
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||||
const [mLat, mLng] = toMapCoord(lat, lon);
|
||||
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([mLat, mLng]);
|
||||
else _beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap);
|
||||
_beaconPickerMap.setView([mLat, mLng], 16);
|
||||
}
|
||||
};
|
||||
document.getElementById(latInputId).addEventListener('change', syncMarker);
|
||||
document.getElementById(lonInputId).addEventListener('change', syncMarker);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function searchBeaconLocation(query, resultsId, latInputId, lonInputId, addrInputId) {
|
||||
if (_beaconSearchTimeout) clearTimeout(_beaconSearchTimeout);
|
||||
const container = document.getElementById(resultsId);
|
||||
if (!query || query.length < 2) { container.innerHTML = ''; return; }
|
||||
_beaconSearchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`);
|
||||
if (!res.results || !res.results.length) {
|
||||
container.innerHTML = '<div style="color:#9ca3af;font-size:12px;padding:8px;">无搜索结果</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = res.results.map(r => {
|
||||
const [lng, lat] = r.location.split(',').map(Number);
|
||||
return `<div class="beacon-search-item" onclick="selectBeaconSearchResult(${lat},${lng},'${latInputId}','${lonInputId}','${addrInputId}','${resultsId}')" style="padding:6px 8px;cursor:pointer;border-bottom:1px solid #374151;font-size:13px;">
|
||||
<div style="color:#93c5fd;">${escapeHtml(r.name)}</div>
|
||||
<div style="color:#9ca3af;font-size:11px;">${escapeHtml(r.address || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (_) { container.innerHTML = ''; }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function selectBeaconSearchResult(gcjLat, gcjLng, latInputId, lonInputId, addrInputId, resultsId) {
|
||||
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
|
||||
document.getElementById(latInputId).value = wgsLat.toFixed(6);
|
||||
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
|
||||
document.getElementById(resultsId).innerHTML = '';
|
||||
if (_beaconPickerMap) {
|
||||
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]);
|
||||
else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap);
|
||||
_beaconPickerMap.setView([gcjLat, gcjLng], 16);
|
||||
}
|
||||
apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`)
|
||||
.then(res => { if (res.address) document.getElementById(addrInputId).value = res.address; })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function destroyBeaconPickerMap() {
|
||||
if (_beaconPickerMap) { _beaconPickerMap.remove(); _beaconPickerMap = null; }
|
||||
_beaconPickerMarker = null;
|
||||
}
|
||||
|
||||
function showAddBeaconModal() {
|
||||
showModal(`
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-blue-400"></i>添加蓝牙信标</h3>
|
||||
<div class="form-group"><label>MAC 地址 <span class="text-red-400">*</span></label><input type="text" id="addBeaconMac" placeholder="AA:BB:CC:DD:EE:FF" maxlength="20"><p class="text-xs text-gray-500 mt-1">信标的蓝牙 MAC 地址,冒号分隔大写十六进制</p></div>
|
||||
<div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="addBeaconName" placeholder="如: 前台大门 / A区3楼走廊"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div>
|
||||
<div class="form-group"><label>UUID</label><input type="text" id="addBeaconUuid" placeholder="iBeacon UUID (可选)" maxlength="36"></div>
|
||||
<div class="form-group"><label>UUID</label><input type="text" id="addBeaconUuid" placeholder="iBeacon UUID (可选)" maxlength="36"><p class="text-xs text-gray-500 mt-1">iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据</p></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-group"><label>Major</label><input type="number" id="addBeaconMajor" placeholder="0-65535" min="0" max="65535"></div>
|
||||
<div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"></div>
|
||||
<div class="form-group"><label>Major</label><input type="number" id="addBeaconMajor" placeholder="0-65535" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Major 值,区分信标组</p></div>
|
||||
<div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Minor 值,区分组内信标</p></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-group"><label>楼层</label><input type="text" id="addBeaconFloor" placeholder="如: 3F"></div>
|
||||
<div class="form-group"><label>区域</label><input type="text" id="addBeaconArea" placeholder="如: A区会议室"></div>
|
||||
<p style="font-size:11px;color:#f59e0b;margin:8px 0 4px;"><i class="fas fa-map-marker-alt mr-1"></i>位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标</p>
|
||||
<div class="form-group"><label><i class="fas fa-search mr-1"></i>搜索位置</label>
|
||||
<input type="text" id="addBeaconSearch" placeholder="输入地址或地点名称搜索..." oninput="searchBeaconLocation(this.value,'addBeaconSearchResults','addBeaconLat','addBeaconLon','addBeaconAddress')">
|
||||
<div id="addBeaconSearchResults" style="max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;"></div>
|
||||
</div>
|
||||
<div id="addBeaconMapDiv" style="height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;"></div>
|
||||
<p style="font-size:11px;color:#9ca3af;margin:-4px 0 12px;"><i class="fas fa-mouse-pointer mr-1"></i>点击地图选择位置,或手动输入坐标</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-group"><label>纬度</label><input type="number" id="addBeaconLat" step="0.000001" placeholder="如: 30.12345"></div>
|
||||
<div class="form-group"><label>经度</label><input type="number" id="addBeaconLon" step="0.000001" placeholder="如: 120.12345"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>详细地址</label><input type="text" id="addBeaconAddress" placeholder="可选"></div>
|
||||
<div class="form-group"><label>详细地址</label><input type="text" id="addBeaconAddress" placeholder="点击地图或搜索自动填充"><p class="text-xs text-gray-500 mt-1">点击地图或搜索位置后自动填充</p></div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button class="btn btn-primary flex-1" onclick="submitAddBeacon()"><i class="fas fa-check"></i> 确认添加</button>
|
||||
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
`);
|
||||
initBeaconPickerMap('addBeaconMapDiv', 'addBeaconLat', 'addBeaconLon', 'addBeaconAddress', null, null);
|
||||
}
|
||||
|
||||
async function submitAddBeacon() {
|
||||
@@ -2262,8 +2380,6 @@
|
||||
const uuid = document.getElementById('addBeaconUuid').value.trim();
|
||||
const major = document.getElementById('addBeaconMajor').value;
|
||||
const minor = document.getElementById('addBeaconMinor').value;
|
||||
const floor = document.getElementById('addBeaconFloor').value.trim();
|
||||
const area = document.getElementById('addBeaconArea').value.trim();
|
||||
const lat = document.getElementById('addBeaconLat').value;
|
||||
const lon = document.getElementById('addBeaconLon').value;
|
||||
const address = document.getElementById('addBeaconAddress').value.trim();
|
||||
@@ -2271,8 +2387,6 @@
|
||||
if (uuid) body.beacon_uuid = uuid;
|
||||
if (major !== '') body.beacon_major = parseInt(major);
|
||||
if (minor !== '') body.beacon_minor = parseInt(minor);
|
||||
if (floor) body.floor = floor;
|
||||
if (area) body.area = area;
|
||||
if (lat !== '') body.latitude = parseFloat(lat);
|
||||
if (lon !== '') body.longitude = parseFloat(lon);
|
||||
if (address) body.address = address;
|
||||
@@ -2292,33 +2406,39 @@
|
||||
const b = await apiCall(`${API_BASE}/beacons/${id}`);
|
||||
showModal(`
|
||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-edit mr-2 text-yellow-400"></i>编辑信标</h3>
|
||||
<div class="form-group"><label>MAC 地址</label><input type="text" value="${escapeHtml(b.beacon_mac)}" disabled style="opacity:0.5"><p class="text-xs text-gray-500 mt-1">MAC 地址不可修改</p></div>
|
||||
<div class="form-group"><label>名称</label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"></div>
|
||||
<div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"></div>
|
||||
<div class="form-group"><label>MAC 地址</label><input type="text" value="${escapeHtml(b.beacon_mac)}" disabled style="opacity:0.5"><p class="text-xs text-gray-500 mt-1">MAC 地址不可修改,设备通过此地址匹配信标</p></div>
|
||||
<div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div>
|
||||
<div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"><p class="text-xs text-gray-500 mt-1">iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据</p></div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-group"><label>Major</label><input type="number" id="editBeaconMajor" value="${b.beacon_major != null ? b.beacon_major : ''}" min="0" max="65535"></div>
|
||||
<div class="form-group"><label>Minor</label><input type="number" id="editBeaconMinor" value="${b.beacon_minor != null ? b.beacon_minor : ''}" min="0" max="65535"></div>
|
||||
<div class="form-group"><label>Major</label><input type="number" id="editBeaconMajor" value="${b.beacon_major != null ? b.beacon_major : ''}" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Major 值,区分信标组</p></div>
|
||||
<div class="form-group"><label>Minor</label><input type="number" id="editBeaconMinor" value="${b.beacon_minor != null ? b.beacon_minor : ''}" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Minor 值,区分组内信标</p></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-group"><label>楼层</label><input type="text" id="editBeaconFloor" value="${escapeHtml(b.floor || '')}"></div>
|
||||
<div class="form-group"><label>区域</label><input type="text" id="editBeaconArea" value="${escapeHtml(b.area || '')}"></div>
|
||||
<p style="font-size:11px;color:#f59e0b;margin:8px 0 4px;"><i class="fas fa-map-marker-alt mr-1"></i>位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标</p>
|
||||
<div class="form-group"><label><i class="fas fa-search mr-1"></i>搜索位置</label>
|
||||
<input type="text" id="editBeaconSearch" placeholder="输入地址或地点名称搜索..." oninput="searchBeaconLocation(this.value,'editBeaconSearchResults','editBeaconLat','editBeaconLon','editBeaconAddress')">
|
||||
<div id="editBeaconSearchResults" style="max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;"></div>
|
||||
</div>
|
||||
<div id="editBeaconMapDiv" style="height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;"></div>
|
||||
<p style="font-size:11px;color:#9ca3af;margin:-4px 0 12px;"><i class="fas fa-mouse-pointer mr-1"></i>点击地图选择位置,或手动输入坐标</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="form-group"><label>纬度</label><input type="number" id="editBeaconLat" step="0.000001" value="${b.latitude != null ? b.latitude : ''}"></div>
|
||||
<div class="form-group"><label>经度</label><input type="number" id="editBeaconLon" step="0.000001" value="${b.longitude != null ? b.longitude : ''}"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>详细地址</label><input type="text" id="editBeaconAddress" value="${escapeHtml(b.address || '')}"></div>
|
||||
<div class="form-group"><label>详细地址</label><input type="text" id="editBeaconAddress" value="${escapeHtml(b.address || '')}"><p class="text-xs text-gray-500 mt-1">点击地图或搜索位置后自动填充</p></div>
|
||||
<div class="form-group"><label>状态</label>
|
||||
<select id="editBeaconStatus">
|
||||
<option value="active" ${b.status === 'active' ? 'selected' : ''}>启用</option>
|
||||
<option value="inactive" ${b.status !== 'active' ? 'selected' : ''}>停用</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">停用后蓝牙记录将不再关联此信标位置</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button class="btn btn-primary flex-1" onclick="submitEditBeacon(${id})"><i class="fas fa-check"></i> 保存</button>
|
||||
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
`);
|
||||
initBeaconPickerMap('editBeaconMapDiv', 'editBeaconLat', 'editBeaconLon', 'editBeaconAddress',
|
||||
b.latitude || null, b.longitude || null);
|
||||
} catch (err) {
|
||||
showToast('加载信标信息失败: ' + err.message, 'error');
|
||||
}
|
||||
@@ -2330,8 +2450,6 @@
|
||||
const uuid = document.getElementById('editBeaconUuid').value.trim();
|
||||
const major = document.getElementById('editBeaconMajor').value;
|
||||
const minor = document.getElementById('editBeaconMinor').value;
|
||||
const floor = document.getElementById('editBeaconFloor').value.trim();
|
||||
const area = document.getElementById('editBeaconArea').value.trim();
|
||||
const lat = document.getElementById('editBeaconLat').value;
|
||||
const lon = document.getElementById('editBeaconLon').value;
|
||||
const address = document.getElementById('editBeaconAddress').value.trim();
|
||||
@@ -2341,8 +2459,6 @@
|
||||
body.beacon_uuid = uuid || null;
|
||||
if (major !== '') body.beacon_major = parseInt(major); else body.beacon_major = null;
|
||||
if (minor !== '') body.beacon_minor = parseInt(minor); else body.beacon_minor = null;
|
||||
body.floor = floor || null;
|
||||
body.area = area || null;
|
||||
if (lat !== '') body.latitude = parseFloat(lat); else body.latitude = null;
|
||||
if (lon !== '') body.longitude = parseFloat(lon); else body.longitude = null;
|
||||
body.address = address || null;
|
||||
|
||||
Reference in New Issue
Block a user