From 891344bfa0b6d009e313987ac2e1a0bffbf94ef7 Mon Sep 17 00:00:00 2001 From: default Date: Mon, 30 Mar 2026 04:26:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=8D=E7=BD=AE=E8=BF=BD=E8=B8=AA?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E3=80=81=E8=80=83=E5=8B=A4=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E3=80=81=E5=9B=B4=E6=A0=8F=E8=80=83=E5=8B=A4=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 地图轨迹点按定位类型区分颜色 (GPS蓝/WiFi青/LBS橙/蓝牙紫) - LBS/WiFi定位点显示精度圈 (虚线圆, LBS~1km/WiFi~80m) - 地图图例显示各定位类型颜色和精度范围 - 精度圈添加 bubble:true 防止遮挡轨迹点点击 - 点击列表记录直接在地图显示Marker+弹窗 (无需先加载轨迹) - 修复3D地图setZoomAndCenter坐标偏移, 改用setCenter+setZoom - 最新位置轮询超时从15s延长至30s (适配LBS慢响应) - 考勤每日去重: 同设备同类型每天只记录一条 (fence/device/bluetooth通用) - 围栏自动考勤补充设备电量/信号/基站信息 (从Device表和位置包获取) - 考勤来源字段 attendance_source 区分 device/bluetooth/fence via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- app/geocoding.py | 79 ++++--- app/models.py | 4 + app/routers/attendance.py | 9 +- app/routers/locations.py | 64 +++++- app/schemas.py | 3 +- app/services/fence_checker.py | 126 ++++++++--- app/static/admin.html | 399 +++++++++++++++++++++++++++++++--- app/tcp_server.py | 14 ++ 8 files changed, 598 insertions(+), 100 deletions(-) diff --git a/app/geocoding.py b/app/geocoding.py index f9b687a..bb5b23d 100644 --- a/app/geocoding.py +++ b/app/geocoding.py @@ -145,32 +145,35 @@ async def geocode_location( location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE). """ - # Check cache first + # Build cache key (include neighbor cells hash for accuracy) + nb_hash = tuple(sorted((nc.get("lac", 0), nc.get("cell_id", 0)) for nc in neighbor_cells)) if neighbor_cells else () + if wifi_list: wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list))) cached = _wifi_cache.get_cached(wifi_cache_key) if cached is not None: return cached elif mcc is not None and lac is not None and cell_id is not None: - cache_key = (mcc, mnc or 0, lac, cell_id) + cache_key = (mcc, mnc or 0, lac, cell_id, nb_hash) cached = _cell_cache.get_cached(cache_key) if cached is not None: return cached - api_key = AMAP_KEY - if not api_key: - return (None, None) + # Map location_type to v5 network parameter + # Valid: GSM, GPRS, EDGE, HSUPA, HSDPA, WCDMA, NR (LTE is NOT valid!) + _NETWORK_MAP = {"lbs_4g": "WCDMA", "wifi_4g": "WCDMA", "gps_4g": "WCDMA"} + network = _NETWORK_MAP.get(location_type or "", "GSM") - # Determine network type from location_type - is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g") + result: tuple[Optional[float], Optional[float]] = (None, None) - # Try v5 API first (POST restapi.amap.com/v5/position/IoT) - result = await _geocode_amap_v5( - mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, - imei=imei, api_key=api_key, is_4g=is_4g, - ) + # Try v5 API first (requires bts with cage field + network param) + if AMAP_KEY: + result = await _geocode_amap_v5( + mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, + imei=imei, api_key=AMAP_KEY, network=network, + ) - # Fallback to legacy API if v5 fails and hardware key is available + # Fallback to legacy API if v5 fails if result[0] is None and AMAP_HARDWARE_KEY: result = await _geocode_amap_legacy( mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, @@ -181,20 +184,25 @@ async def geocode_location( if wifi_list: _wifi_cache.put(wifi_cache_key, result) elif mcc is not None and lac is not None and cell_id is not None: - _cell_cache.put((mcc, mnc or 0, lac, cell_id), result) + _cell_cache.put(cache_key, result) return result -def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str: - """Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage""" +def _build_bts( + mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int], + *, include_cage: bool = False, +) -> str: + """Build bts parameter. v5 API uses cage field, legacy does not.""" if mcc is not None and lac is not None and cell_id is not None: - return f"{mcc},{mnc or 0},{lac},{cell_id},-65,0" + base = f"{mcc},{mnc or 0},{lac},{cell_id},-65" + return f"{base},0" if include_cage else base return "" def _build_nearbts( - neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int] + neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int], + *, include_cage: bool = False, ) -> list[str]: """Build nearbts (neighbor cell) parts.""" parts = [] @@ -203,7 +211,8 @@ def _build_nearbts( nc_lac = nc.get("lac", 0) nc_cid = nc.get("cell_id", 0) nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80 - parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal},0") + base = f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}" + parts.append(f"{base},0" if include_cage else base) return parts @@ -259,22 +268,20 @@ def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]: async def _geocode_amap_v5( mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int], wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]], - *, imei: Optional[str] = None, api_key: str, is_4g: bool = False, + *, imei: Optional[str] = None, api_key: str, network: str = "GSM", ) -> tuple[Optional[float], Optional[float]]: """ Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT). - Key differences from legacy: - - POST method, key in URL params, data in body - - accesstype: 0=未知, 1=移动网络, 2=WiFi - - WiFi requires mmac (connected WiFi) + macs (nearby, 2-30) - - network: GSM(default)/LTE/WCDMA/NR — critical for 4G accuracy - - diu replaces imei - - No digital signature needed - - show_fields can return address directly + Key requirements: + - POST method, key in URL params, data in form body + - bts MUST have 6 fields: mcc,mnc,lac,cellid,signal,cage + - network MUST be valid: GSM/GPRS/EDGE/HSUPA/HSDPA/WCDMA/NR (LTE is NOT valid!) + - For 4G LTE, use WCDMA as network value + - accesstype: 1=移动网络, 2=WiFi (requires mmac + 2+ macs) """ - bts = _build_bts(mcc, mnc, lac, cell_id) - nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc) + bts = _build_bts(mcc, mnc, lac, cell_id, include_cage=True) + nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc, include_cage=True) wifi_parts = _build_wifi_parts(wifi_list) if not bts and not wifi_parts: @@ -288,7 +295,7 @@ async def _geocode_amap_v5( body: dict[str, str] = { "accesstype": accesstype, "cdma": "0", - "network": "LTE" if is_4g else "GSM", + "network": network, "diu": imei or _settings.GEOCODING_DEFAULT_IMEI, "show_fields": "formatted_address", } @@ -309,6 +316,8 @@ async def _geocode_amap_v5( url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}" + logger.info("Amap v5 request body: %s", body) + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -316,6 +325,7 @@ async def _geocode_amap_v5( ) as resp: if resp.status == 200: data = await resp.json(content_type=None) + logger.info("Amap v5 response: %s", data) if data.get("status") == "1": position = data.get("position", {}) location = position.get("location", "") if isinstance(position, dict) else "" @@ -333,8 +343,8 @@ async def _geocode_amap_v5( else: infocode = data.get("infocode", "") logger.warning( - "Amap v5 geocode error: %s (code=%s)", - data.get("info", ""), infocode, + "Amap v5 geocode error: %s (code=%s) body=%s", + data.get("info", ""), infocode, body, ) else: logger.warning("Amap v5 geocode HTTP %d", resp.status) @@ -390,6 +400,8 @@ async def _geocode_amap_legacy( url = "https://apilocate.amap.com/position" + logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'}) + try: async with aiohttp.ClientSession() as session: async with session.get( @@ -397,6 +409,7 @@ async def _geocode_amap_legacy( ) as resp: if resp.status == 200: data = await resp.json(content_type=None) + logger.info("Amap legacy response: %s", data) if data.get("status") == "1" and data.get("result"): result = data["result"] location = result.get("location", "") diff --git a/app/models.py b/app/models.py index 6191da2..e01f3e8 100644 --- a/app/models.py +++ b/app/models.py @@ -199,6 +199,10 @@ class AttendanceRecord(Base): attendance_type: Mapped[str] = mapped_column( String(20), nullable=False ) # clock_in, clock_out + attendance_source: Mapped[str] = mapped_column( + String(20), nullable=False, default="device", + server_default="device", + ) # device (0xB0/0xB1), bluetooth (0xB2), fence (auto) protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) latitude: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/app/routers/attendance.py b/app/routers/attendance.py index f259369..a99864d 100644 --- a/app/routers/attendance.py +++ b/app/routers/attendance.py @@ -30,6 +30,7 @@ router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"]) async def list_attendance( device_id: int | None = Query(default=None, description="设备ID / Device ID"), attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"), + attendance_source: str | None = Query(default=None, description="考勤来源 / Source (device/bluetooth/fence)"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"), end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"), page: int = Query(default=1, ge=1, description="页码 / Page number"), @@ -37,8 +38,8 @@ async def list_attendance( db: AsyncSession = Depends(get_db), ): """ - 获取考勤记录列表,支持按设备、考勤类型、时间范围过滤。 - List attendance records with filters for device, type, and time range. + 获取考勤记录列表,支持按设备、考勤类型、来源、时间范围过滤。 + List attendance records with filters for device, type, source, and time range. """ query = select(AttendanceRecord) count_query = select(func.count(AttendanceRecord.id)) @@ -51,6 +52,10 @@ async def list_attendance( query = query.where(AttendanceRecord.attendance_type == attendance_type) count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type) + if attendance_source: + query = query.where(AttendanceRecord.attendance_source == attendance_source) + count_query = count_query.where(AttendanceRecord.attendance_source == attendance_source) + if start_time: query = query.where(AttendanceRecord.recorded_at >= start_time) count_query = count_query.where(AttendanceRecord.recorded_at >= start_time) diff --git a/app/routers/locations.py b/app/routers/locations.py index ebb48fc..bed6e26 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -7,7 +7,7 @@ import math from datetime import datetime from fastapi import APIRouter, Body, Depends, HTTPException, Query -from sqlalchemy import select, delete +from sqlalchemy import func, select, delete from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import require_write @@ -140,6 +140,68 @@ async def device_track( ) +@router.post( + "/batch-delete", + response_model=APIResponse[dict], + summary="批量删除位置记录 / Batch delete location records", + dependencies=[Depends(require_write)], +) +async def batch_delete_locations( + location_ids: list[int] = Body(..., min_length=1, max_length=500, embed=True), + db: AsyncSession = Depends(get_db), +): + """批量删除位置记录(最多500条)。""" + result = await db.execute( + delete(LocationRecord).where(LocationRecord.id.in_(location_ids)) + ) + await db.flush() + return APIResponse( + message=f"已删除 {result.rowcount} 条位置记录", + data={"deleted": result.rowcount, "requested": len(location_ids)}, + ) + + +@router.post( + "/delete-no-coords", + response_model=APIResponse[dict], + summary="删除无坐标位置记录 / Delete location records without coordinates", + dependencies=[Depends(require_write)], +) +async def delete_no_coord_locations( + device_id: int | None = Body(default=None, description="设备ID (可选,不传则所有设备)"), + start_time: str | None = Body(default=None, description="开始时间 ISO 8601"), + end_time: str | None = Body(default=None, description="结束时间 ISO 8601"), + db: AsyncSession = Depends(get_db), +): + """删除经纬度为空的位置记录,可按设备和时间范围过滤。""" + from datetime import datetime as dt + + conditions = [ + (LocationRecord.latitude.is_(None)) | (LocationRecord.longitude.is_(None)) + ] + if device_id is not None: + conditions.append(LocationRecord.device_id == device_id) + if start_time: + conditions.append(LocationRecord.recorded_at >= dt.fromisoformat(start_time)) + if end_time: + conditions.append(LocationRecord.recorded_at <= dt.fromisoformat(end_time)) + + # Count first + count_result = await db.execute( + select(func.count(LocationRecord.id)).where(*conditions) + ) + count = count_result.scalar() or 0 + + if count > 0: + await db.execute(delete(LocationRecord).where(*conditions)) + await db.flush() + + return APIResponse( + message=f"已删除 {count} 条无坐标记录", + data={"deleted": count}, + ) + + @router.get( "/{location_id}", response_model=APIResponse[LocationRecordResponse], diff --git a/app/schemas.py b/app/schemas.py index 2c87170..7062b49 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -229,6 +229,7 @@ class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]]) class AttendanceRecordBase(BaseModel): device_id: int attendance_type: str = Field(..., max_length=20) + attendance_source: str = Field(default="device", max_length=20) # device, bluetooth, fence protocol_number: int gps_positioned: bool = False latitude: float | None = Field(None, ge=-90, le=90) @@ -243,7 +244,7 @@ class AttendanceRecordBase(BaseModel): lac: int | None = None cell_id: int | None = None wifi_data: list[dict[str, Any]] | None = None - lbs_data: list[dict[str, Any]] | None = None + lbs_data: list[dict[str, Any]] | dict[str, Any] | None = None address: str | None = None recorded_at: datetime diff --git a/app/services/fence_checker.py b/app/services/fence_checker.py index aaec1c2..9109c4b 100644 --- a/app/services/fence_checker.py +++ b/app/services/fence_checker.py @@ -4,18 +4,18 @@ Checks whether a device's reported coordinates fall inside its bound fences. Creates automatic attendance records (clock_in/clock_out) on state transitions. """ -import json import logging import math -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Optional -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import now_cst, settings from app.models import ( AttendanceRecord, + Device, DeviceFenceBinding, DeviceFenceState, FenceConfig, @@ -159,6 +159,31 @@ def _get_tolerance_for_location_type(location_type: str) -> float: return 0.0 +# --------------------------------------------------------------------------- +# Daily dedup helper +# --------------------------------------------------------------------------- + + +async def _has_attendance_today( + session: AsyncSession, + device_id: int, + attendance_type: str, +) -> bool: + """Check if device already has an attendance record of given type today (CST).""" + cst_now = datetime.now(timezone(timedelta(hours=8))) + day_start = cst_now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + day_end = day_start + timedelta(days=1) + result = await session.execute( + select(func.count()).select_from(AttendanceRecord).where( + AttendanceRecord.device_id == device_id, + AttendanceRecord.attendance_type == attendance_type, + AttendanceRecord.recorded_at >= day_start, + AttendanceRecord.recorded_at < day_end, + ) + ) + return (result.scalar() or 0) > 0 + + # --------------------------------------------------------------------------- # Main fence check entry point # --------------------------------------------------------------------------- @@ -173,6 +198,11 @@ async def check_device_fences( location_type: str, address: Optional[str], recorded_at: datetime, + *, + mcc: Optional[int] = None, + mnc: Optional[int] = None, + lac: Optional[int] = None, + cell_id: Optional[int] = None, ) -> list[dict]: """Check all bound active fences for a device. Returns attendance events. @@ -192,6 +222,17 @@ async def check_device_fences( if not fences: return [] + # Query device for latest battery/signal info (from heartbeats) + device = await session.get(Device, device_id) + device_info = { + "battery_level": device.battery_level if device else None, + "gsm_signal": device.gsm_signal if device else None, + "mcc": mcc, + "mnc": mnc, + "lac": lac, + "cell_id": cell_id, + } + tolerance = _get_tolerance_for_location_type(location_type) events: list[dict] = [] now = now_cst() @@ -224,21 +265,28 @@ async def check_device_fences( _update_state(state, currently_inside, now, latitude, longitude) continue - attendance = _create_attendance( - device_id, imei, "clock_in", latitude, longitude, - address, recorded_at, fence, - ) - session.add(attendance) + # Daily dedup: only one clock_in per device per day + if await _has_attendance_today(session, device_id, "clock_in"): + logger.info( + "Fence skip clock_in: device=%d fence=%d(%s) already clocked in today", + device_id, fence.id, fence.name, + ) + else: + attendance = _create_attendance( + device_id, imei, "clock_in", latitude, longitude, + address, recorded_at, fence, device_info, + ) + session.add(attendance) - event = _build_event( - device_id, imei, fence, "clock_in", - latitude, longitude, address, recorded_at, - ) - events.append(event) - logger.info( - "Fence auto clock_in: device=%d fence=%d(%s)", - device_id, fence.id, fence.name, - ) + event = _build_event( + device_id, imei, fence, "clock_in", + latitude, longitude, address, recorded_at, + ) + events.append(event) + logger.info( + "Fence auto clock_in: device=%d fence=%d(%s)", + device_id, fence.id, fence.name, + ) elif not currently_inside and was_inside: # EXIT: inside -> outside = clock_out @@ -252,21 +300,28 @@ async def check_device_fences( _update_state(state, currently_inside, now, latitude, longitude) continue - attendance = _create_attendance( - device_id, imei, "clock_out", latitude, longitude, - address, recorded_at, fence, - ) - session.add(attendance) + # Daily dedup: only one clock_out per device per day + if await _has_attendance_today(session, device_id, "clock_out"): + logger.info( + "Fence skip clock_out: device=%d fence=%d(%s) already clocked out today", + device_id, fence.id, fence.name, + ) + else: + attendance = _create_attendance( + device_id, imei, "clock_out", latitude, longitude, + address, recorded_at, fence, device_info, + ) + session.add(attendance) - event = _build_event( - device_id, imei, fence, "clock_out", - latitude, longitude, address, recorded_at, - ) - events.append(event) - logger.info( - "Fence auto clock_out: device=%d fence=%d(%s)", - device_id, fence.id, fence.name, - ) + event = _build_event( + device_id, imei, fence, "clock_out", + latitude, longitude, address, recorded_at, + ) + events.append(event) + logger.info( + "Fence auto clock_out: device=%d fence=%d(%s)", + device_id, fence.id, fence.name, + ) # 4. Update state if state is None: @@ -315,18 +370,27 @@ def _create_attendance( address: Optional[str], recorded_at: datetime, fence: FenceConfig, + device_info: Optional[dict] = None, ) -> AttendanceRecord: """Create an auto-generated fence attendance record.""" + info = device_info or {} return AttendanceRecord( device_id=device_id, imei=imei, attendance_type=attendance_type, + attendance_source="fence", protocol_number=0, # synthetic, not from device protocol gps_positioned=True, latitude=latitude, longitude=longitude, address=address, recorded_at=recorded_at, + battery_level=info.get("battery_level"), + gsm_signal=info.get("gsm_signal"), + mcc=info.get("mcc"), + mnc=info.get("mnc"), + lac=info.get("lac"), + cell_id=info.get("cell_id"), lbs_data={ "source": "fence_auto", "fence_id": fence.id, diff --git a/app/static/admin.html b/app/static/admin.html index 20c5022..cc0f2e5 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -132,6 +132,9 @@ .panel-item:hover .panel-item-actions { display: flex; } .panel-action-btn { width: 24px; height: 24px; border-radius: 4px; border: none; background: #4b5563; color: #e5e7eb; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .panel-action-btn:hover { background: #2563eb; } + .fence-tab { padding: 8px 16px; border: none; background: transparent; color: #9ca3af; cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; transition: all 0.2s; } + .fence-tab:hover { color: #e5e7eb; background: #374151; } + .fence-tab.active { color: #60a5fa; border-bottom-color: #3b82f6; background: rgba(59,130,246,0.1); } .panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; } .panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; } .side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; } @@ -402,7 +405,7 @@
1
选择设备:从下拉框选择要查看的工牌设备
-
2
最新位置:点击绿色按钮获取设备最新定位并标注地图
+
2
最新位置:发送WHERE#指令获取设备实时定位(需设备在线)
3
轨迹回放:设定日期范围后点击"显示轨迹"查看移动路径
4
定位类型:支持 GPS / LBS 基站 / WiFi 及其 4G 变体筛选
@@ -446,9 +449,17 @@ + +
-
+
+
@@ -456,6 +467,7 @@ + @@ -468,7 +480,7 @@ - +
设备ID 类型 纬度
请选择设备并查询
请选择设备并查询
@@ -612,6 +624,12 @@ + @@ -625,6 +643,7 @@ 设备ID 类型 + 来源 位置 电量/信号 基站 @@ -632,7 +651,7 @@ - 加载中... + 加载中...
@@ -818,9 +837,15 @@
-
+
-
+ +
+ + +
+ +
@@ -839,6 +864,28 @@
+ +
@@ -1033,6 +1080,7 @@ let mapMarkers = []; let mapPolyline = null; let mapInfoWindows = []; // store {infoWindow, position} for each track point + let _locTableItems = []; // cached location records from table for on-the-fly marker creation let trackPlayTimer = null; let trackMovingMarker = null; let dashAlarmChart = null; @@ -1713,7 +1761,8 @@ trackMovingMarker.setMap(locationMap); showToast('路径回放中...', 'info'); - locationMap.setZoomAndCenter(16, positions[0]); + locationMap.setCenter(positions[0]); + locationMap.setZoom(16); mapInfoWindows[0].infoWindow.open(locationMap, positions[0]); let segIdx = 0; // current segment (from segIdx to segIdx+1) @@ -1761,14 +1810,51 @@ } // --- Focus a location on map by record id (called from table row) --- + let _focusInfoWindow = null; // single info window for table-click focus + let _focusMarker = null; // single marker for table-click focus function focusMapPoint(locId) { - if (!locationMap) { showToast('请先加载轨迹或最新位置', 'error'); return; } - const item = mapInfoWindows.find(iw => iw.locId === locId); - if (!item) { showToast('该记录未在地图上显示,请先加载轨迹', 'info'); return; } - locationMap.setZoomAndCenter(17, item.position); - mapInfoWindows.forEach(iw => iw.infoWindow.close()); - item.infoWindow.open(locationMap, item.position); - // Scroll map into view + if (!locationMap) initLocationMap(); + // Wait for map init + if (!locationMap) { showToast('地图初始化中,请稍后重试', 'info'); return; } + + // First try existing track markers + const existing = mapInfoWindows.find(iw => iw.locId === locId); + if (existing) { + locationMap.setCenter(existing.position); + locationMap.setZoom(17); + mapInfoWindows.forEach(iw => iw.infoWindow.close()); + if (_focusInfoWindow) _focusInfoWindow.close(); + if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; } + existing.infoWindow.open(locationMap, existing.position); + document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + // Fallback: create marker on-the-fly from cached table data + const loc = _locTableItems.find(l => l.id === locId); + if (!loc) { showToast('记录数据不可用', 'info'); return; } + const lat = loc.latitude || loc.lat; + const lng = loc.longitude || loc.lng || loc.lon; + if (!lat || !lng) { showToast('该记录无坐标', 'info'); return; } + + const [mLat, mLng] = toMapCoord(lat, lng); + + // Remove previous focus marker + if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; } + if (_focusInfoWindow) _focusInfoWindow.close(); + + // Create marker + _focusMarker = new AMap.Marker({ position: [mLng, mLat] }); + _focusMarker.setMap(locationMap); + + // Create and open info window + const content = _buildInfoContent('位置记录', loc, lat, lng); + _focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) }); + _focusInfoWindow.open(locationMap, [mLng, mLat]); + _focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat])); + + locationMap.setCenter([mLng, mLat]); + locationMap.setZoom(17); document.getElementById('locationMap').scrollIntoView({ behavior: 'smooth', block: 'center' }); } @@ -1804,6 +1890,59 @@ } } + function toggleAllLocCheckboxes(checked) { + document.querySelectorAll('.loc-sel-cb').forEach(cb => { cb.checked = checked; }); + updateLocSelCount(); + } + + function updateLocSelCount() { + const count = document.querySelectorAll('.loc-sel-cb:checked').length; + document.getElementById('locSelCount').textContent = count; + document.getElementById('btnBatchDeleteLoc').disabled = count === 0; + } + + async function batchDeleteSelectedLocations() { + const ids = Array.from(document.querySelectorAll('.loc-sel-cb:checked')).map(cb => parseInt(cb.value)); + if (ids.length === 0) { showToast('请先勾选要删除的记录', 'info'); return; } + if (!confirm(`确定批量删除选中的 ${ids.length} 条位置记录?`)) return; + try { + const result = await apiCall(`${API_BASE}/locations/batch-delete`, { + method: 'POST', + body: JSON.stringify({ location_ids: ids }), + }); + showToast(`已删除 ${result.deleted} 条记录`); + loadLocationRecords(); + } catch (err) { + showToast('批量删除失败: ' + err.message, 'error'); + } + } + + async function batchDeleteNoCoordLocations() { + const deviceId = document.getElementById('locDeviceSelect').value || null; + const startTime = document.getElementById('locStartDate').value || null; + const endTime = document.getElementById('locEndDate').value || null; + const filterDesc = [ + deviceId ? `设备ID=${deviceId}` : '所有设备', + startTime ? `从${startTime}` : '', + endTime ? `到${endTime}` : '', + ].filter(Boolean).join(', '); + if (!confirm(`确定删除无坐标(经纬度为空)的位置记录?\n范围: ${filterDesc}\n\n此操作不可撤销!`)) return; + try { + const body = {}; + if (deviceId) body.device_id = parseInt(deviceId); + if (startTime) body.start_time = startTime + 'T00:00:00'; + if (endTime) body.end_time = endTime + 'T23:59:59'; + const result = await apiCall(`${API_BASE}/locations/delete-no-coords`, { + method: 'POST', + body: JSON.stringify(body), + }); + showToast(`已清除 ${result.deleted} 条无坐标记录`); + loadLocationRecords(); + } catch (err) { + showToast('清除失败: ' + err.message, 'error'); + } + } + // --- Quick command sender for device detail panel --- async function _quickCmd(deviceId, cmd, btnEl) { if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; } @@ -2114,6 +2253,10 @@ if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; } if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; } if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; } + if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; } + if (_focusMarker) { locationMap.remove(_focusMarker); _focusMarker = null; } + const legend = document.getElementById('mapLegend'); + if (legend) legend.style.display = 'none'; } async function loadTrack() { @@ -2158,13 +2301,46 @@ path.push([mLng, mLat]); const isFirst = i === 0; const isLast = i === total - 1; + const lt = loc.location_type || ''; + const isLbs = lt.startsWith('lbs'); + const isWifi = lt.startsWith('wifi'); + const isBt = lt === 'bluetooth'; + // Color by location type: GPS=blue, WiFi=cyan, LBS=orange, BT=purple + let dotColor = '#3b82f6'; + if (isFirst) dotColor = '#22c55e'; + else if (isLast) dotColor = '#ef4444'; + else if (isLbs) dotColor = '#f59e0b'; + else if (isWifi) dotColor = '#06b6d4'; + else if (isBt) dotColor = '#a855f7'; const marker = new AMap.CircleMarker({ center: [mLng, mLat], - radius: isFirst || isLast ? 12 : 7, - fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6', - strokeColor: '#fff', strokeWeight: 1, fillOpacity: 0.9, + radius: isFirst || isLast ? 12 : 8, + fillColor: dotColor, + strokeColor: isLbs ? '#f59e0b' : '#fff', + strokeWeight: isLbs ? 2 : 1, + strokeOpacity: isLbs ? 0.6 : 1, + fillOpacity: isLbs ? 0.6 : 0.9, + zIndex: 120, cursor: 'pointer', }); marker.setMap(locationMap); + // Add accuracy radius ring for LBS points (~1000m) and WiFi (~80m) + if (isLbs && !isFirst && !isLast) { + const ring = new AMap.Circle({ + center: [mLng, mLat], radius: 1000, + strokeColor: '#f59e0b', strokeWeight: 1, strokeOpacity: 0.3, strokeStyle: 'dashed', + fillColor: '#f59e0b', fillOpacity: 0.05, bubble: true, + }); + ring.setMap(locationMap); + mapMarkers.push(ring); + } else if (isWifi && !isFirst && !isLast) { + const ring = new AMap.Circle({ + center: [mLng, mLat], radius: 80, + strokeColor: '#06b6d4', strokeWeight: 1, strokeOpacity: 0.3, strokeStyle: 'dashed', + fillColor: '#06b6d4', fillOpacity: 0.05, bubble: true, + }); + ring.setMap(locationMap); + mapMarkers.push(ring); + } const label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `第 ${i+1}/${total} 点`; const content = _buildInfoContent(label, loc, lat, lng); const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) }); @@ -2179,9 +2355,12 @@ mapPolyline.setMap(locationMap); locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]); } else if (path.length === 1) { - locationMap.setZoomAndCenter(15, path[0]); + locationMap.setCenter(path[0]); + locationMap.setZoom(15); } + const legend = document.getElementById('mapLegend'); + if (legend) legend.style.display = 'block'; showToast(`已加载 ${total} 个轨迹点`); } catch (err) { showToast('加载轨迹失败: ' + err.message, 'error'); @@ -2192,7 +2371,15 @@ const deviceId = document.getElementById('locDeviceSelect').value; if (!deviceId) { showToast('请选择设备', 'error'); return; } + const btn = document.querySelector('.btn-success'); + const origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' 获取中...'; + try { + // Record timestamp before sending command + const sentAt = new Date().toISOString(); + // Send WHERE# to request fresh position from device try { await apiCall(`${API_BASE}/commands/send`, { @@ -2200,32 +2387,81 @@ body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: 'WHERE#' }), }); showToast('已发送定位指令,等待设备回传...', 'info'); - await new Promise(r => setTimeout(r, 3000)); - } catch (_) { /* device may be offline, still show DB data */ } + } catch (e) { + showToast('设备可能离线: ' + e.message, 'error'); + btn.disabled = false; + btn.innerHTML = origHtml; + return; + } + + // Poll for new location (up to 30s, every 3s) + let loc = null; + const maxPolls = 10; + const pollInterval = 3000; + for (let i = 0; i < maxPolls; i++) { + await new Promise(r => setTimeout(r, pollInterval)); + try { + const result = await apiCall(`${API_BASE}/locations/latest/${deviceId}`); + if (result && (result.latitude != null || result.longitude != null)) { + const recTime = new Date(result.recorded_at || result.created_at); + if (recTime >= new Date(sentAt) - 5000) { + loc = result; + break; + } + } + } catch (_) {} + const elapsed = (i + 1) * pollInterval / 1000; + btn.innerHTML = ` 等待回传 ${elapsed}s...`; + } + + if (!loc) { + showToast('设备未在30秒内回传位置,LBS模式下设备响应较慢属正常现象', 'error'); + return; + } - const loc = await apiCall(`${API_BASE}/locations/latest/${deviceId}`); - if (!loc) { showToast('暂无位置数据', 'info'); return; } if (!locationMap) initLocationMap(); clearMapOverlays(); const lat = loc.latitude || loc.lat; const lng = loc.longitude || loc.lng || loc.lon; - if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; } + if (!lat || !lng) { showToast('设备回传了数据但无有效坐标', 'info'); return; } const [mLat, mLng] = toMapCoord(lat, lng); const marker = new AMap.Marker({ position: [mLng, mLat] }); marker.setMap(locationMap); - const infoContent = _buildInfoContent('最新位置', loc, lat, lng); + // Add accuracy radius for LBS/WiFi latest position + const _lt = loc.location_type || ''; + if (_lt.startsWith('lbs')) { + const ring = new AMap.Circle({ + center: [mLng, mLat], radius: 1000, + strokeColor: '#f59e0b', strokeWeight: 1, strokeOpacity: 0.4, strokeStyle: 'dashed', + fillColor: '#f59e0b', fillOpacity: 0.06, bubble: true, + }); + ring.setMap(locationMap); + mapMarkers.push(ring); + } else if (_lt.startsWith('wifi')) { + const ring = new AMap.Circle({ + center: [mLng, mLat], radius: 80, + strokeColor: '#06b6d4', strokeWeight: 1, strokeOpacity: 0.4, strokeStyle: 'dashed', + fillColor: '#06b6d4', fillOpacity: 0.06, bubble: true, + }); + ring.setMap(locationMap); + mapMarkers.push(ring); + } + const infoContent = _buildInfoContent('实时定位', loc, lat, lng); const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) }); infoWindow.open(locationMap, [mLng, mLat]); marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat])); mapMarkers.push(marker); - locationMap.setZoomAndCenter(15, [mLng, mLat]); - showToast('已显示最新位置'); - // Auto-load records table + locationMap.setCenter([mLng, mLat]); + locationMap.setZoom(15); + showToast('已获取设备实时位置'); loadLocationRecords(1); } catch (err) { - showToast('获取最新位置失败: ' + err.message, 'error'); + showToast('获取位置失败: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; } } @@ -2248,15 +2484,17 @@ try { const data = await apiCall(url); const items = data.items || []; + _locTableItems = items; // cache for focusMapPoint const tbody = document.getElementById('locationsTableBody'); if (items.length === 0) { - tbody.innerHTML = '没有位置记录'; + tbody.innerHTML = '没有位置记录'; } else { tbody.innerHTML = items.map(l => { const q = _locQuality(l); const hasCoord = l.latitude != null && l.longitude != null; return ` + ${escapeHtml(l.device_id || '-')} ${_locTypeLabel(l.location_type)} ${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'} @@ -2272,10 +2510,12 @@ `; }).join(''); } + document.getElementById('locSelectAll').checked = false; + updateLocSelCount(); renderPagination('locationsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadLocationRecords'); } catch (err) { showToast('加载位置记录失败: ' + err.message, 'error'); - document.getElementById('locationsTableBody').innerHTML = '加载失败'; + document.getElementById('locationsTableBody').innerHTML = '加载失败'; } finally { hideLoading('locationsLoading'); } @@ -2390,12 +2630,14 @@ const ps = pageState.attendance.pageSize; const deviceId = document.getElementById('attDeviceFilter').value; const attType = document.getElementById('attTypeFilter').value; + const attSource = document.getElementById('attSourceFilter').value; const startTime = document.getElementById('attStartDate').value; const endTime = document.getElementById('attEndDate').value; let url = `${API_BASE}/attendance?page=${p}&page_size=${ps}`; if (deviceId) url += `&device_id=${deviceId}`; if (attType) url += `&attendance_type=${attType}`; + if (attSource) url += `&attendance_source=${attSource}`; if (startTime) url += `&start_time=${startTime}T00:00:00`; if (endTime) url += `&end_time=${endTime}T23:59:59`; @@ -2406,7 +2648,7 @@ const tbody = document.getElementById('attendanceTableBody'); if (items.length === 0) { - tbody.innerHTML = '没有考勤记录'; + tbody.innerHTML = '没有考勤记录'; } else { tbody.innerHTML = items.map(a => { const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-'); @@ -2414,9 +2656,12 @@ const battStr = a.battery_level != null ? `${a.battery_level}%` : '-'; const sigStr = a.gsm_signal != null ? `GSM:${a.gsm_signal}` : ''; const lbsStr = a.mcc != null ? `${a.mcc}/${a.mnc || 0}/${a.lac || 0}/${a.cell_id || 0}` : '-'; + const srcLabel = {'device':' 设备','bluetooth':' 蓝牙','fence':' 围栏'}[a.attendance_source] || a.attendance_source || '设备'; + const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af'; return ` ${escapeHtml(a.device_id || '-')} ${attendanceTypeName(a.attendance_type)} + ${srcLabel} ${gpsIcon} ${escapeHtml(posStr)} ${battStr} ${sigStr} ${lbsStr} @@ -2427,7 +2672,7 @@ renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance'); } catch (err) { showToast('加载考勤记录失败: ' + err.message, 'error'); - document.getElementById('attendanceTableBody').innerHTML = '加载失败'; + document.getElementById('attendanceTableBody').innerHTML = '加载失败'; } finally { hideLoading('attendanceLoading'); } @@ -3222,6 +3467,96 @@ } } + // ---- Fence Tab switching & binding tab ---- + function switchFenceTab(tab) { + document.getElementById('fenceTabList').classList.toggle('active', tab === 'list'); + document.getElementById('fenceTabBindings').classList.toggle('active', tab === 'bindings'); + document.getElementById('fenceTabContentList').style.display = tab === 'list' ? '' : 'none'; + document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? '' : 'none'; + if (tab === 'bindings') initFenceBindingTab(); + } + + async function initFenceBindingTab() { + const sel = document.getElementById('fenceBindSelect'); + if (sel.options.length <= 1) { + // Populate fence dropdown + try { + const data = await apiCall(`${API_BASE}/fences?page=1&page_size=100`); + const items = data.items || []; + sel.innerHTML = '' + items.map(f => + `` + ).join(''); + } catch (_) {} + } + // Populate device add dropdown + const devSel = document.getElementById('fenceBindDeviceAdd'); + if (devSel.options.length <= 1) { + try { + const devData = await apiCall(`${API_BASE}/devices?page=1&page_size=100`); + const allDevices = devData.items || []; + devSel.innerHTML = '' + allDevices.map(d => + `` + ).join(''); + } catch (_) {} + } + } + + async function loadFenceBindingTab() { + const fenceId = document.getElementById('fenceBindSelect').value; + const tbody = document.getElementById('fenceBindTableBody'); + if (!fenceId) { + tbody.innerHTML = '请选择围栏'; + return; + } + try { + const devices = await apiCall(`${API_BASE}/fences/${fenceId}/devices`); + if (devices.length === 0) { + tbody.innerHTML = '暂无绑定设备,请从上方添加'; + } else { + tbody.innerHTML = devices.map(d => ` + ${escapeHtml(d.device_name || '-')} + ${escapeHtml(d.imei || '-')} + ${d.is_inside ? '围栏内' : '围栏外'} + ${d.last_check_at ? formatTime(d.last_check_at) : '-'} + + `).join(''); + } + } catch (err) { + tbody.innerHTML = '加载失败'; + } + } + + async function quickBindDevice() { + const fenceId = document.getElementById('fenceBindSelect').value; + const deviceId = document.getElementById('fenceBindDeviceAdd').value; + if (!fenceId) { showToast('请先选择围栏', 'info'); return; } + if (!deviceId) { showToast('请选择要绑定的设备', 'info'); return; } + try { + await apiCall(`${API_BASE}/fences/${fenceId}/devices`, { + method: 'POST', + body: JSON.stringify({ device_ids: [parseInt(deviceId)] }), + }); + showToast('绑定成功'); + loadFenceBindingTab(); + } catch (err) { + showToast('绑定失败: ' + err.message, 'error'); + } + } + + async function quickUnbindDevice(fenceId, deviceId, name) { + if (!confirm(`确定解绑设备 "${name}" ?`)) return; + try { + await apiCall(`${API_BASE}/fences/${fenceId}/devices`, { + method: 'DELETE', + body: JSON.stringify({ device_ids: [deviceId] }), + }); + showToast('已解绑'); + loadFenceBindingTab(); + } catch (err) { + showToast('解绑失败: ' + err.message, 'error'); + } + } + // ---- Beacon map picker ---- let _beaconPickerMap = null; let _beaconPickerMarker = null; diff --git a/app/tcp_server.py b/app/tcp_server.py index bb29e55..41a0a05 100644 --- a/app/tcp_server.py +++ b/app/tcp_server.py @@ -1113,6 +1113,7 @@ class TCPManager: fence_session, device_id_for_fence, imei, latitude, longitude, location_type, address, recorded_at, + mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id, ) for evt in fence_events: ws_manager.broadcast_nonblocking("fence_attendance", evt) @@ -1873,10 +1874,23 @@ class TCPManager: logger.warning("Attendance for unknown IMEI=%s", imei) return attendance_type, reserved_bytes, datetime_bytes + # Determine attendance source from protocol + _att_source = "bluetooth" if proto == 0xB2 else "device" + + # Daily dedup: one clock_in / clock_out per device per day + from app.services.fence_checker import _has_attendance_today + if await _has_attendance_today(session, device_id, attendance_type): + logger.info( + "Attendance dedup: IMEI=%s already has %s today, skip", + imei, attendance_type, + ) + return attendance_type, reserved_bytes, datetime_bytes + record = AttendanceRecord( device_id=device_id, imei=conn_info.imei, attendance_type=attendance_type, + attendance_source=_att_source, protocol_number=proto, gps_positioned=gps_positioned, latitude=latitude,