feat: 位置追踪优化、考勤去重、围栏考勤补充设备信息

- 地图轨迹点按定位类型区分颜色 (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 <noreply@hapi.run>
This commit is contained in:
2026-03-30 04:26:29 +00:00
parent 1d06cc5415
commit 891344bfa0
8 changed files with 598 additions and 100 deletions

View File

@@ -145,32 +145,35 @@ async def geocode_location(
location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE). 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: if wifi_list:
wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list))) wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list)))
cached = _wifi_cache.get_cached(wifi_cache_key) cached = _wifi_cache.get_cached(wifi_cache_key)
if cached is not None: if cached is not None:
return cached return cached
elif mcc is not None and lac is not None and cell_id is not None: 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) cached = _cell_cache.get_cached(cache_key)
if cached is not None: if cached is not None:
return cached return cached
api_key = AMAP_KEY # Map location_type to v5 network parameter
if not api_key: # Valid: GSM, GPRS, EDGE, HSUPA, HSDPA, WCDMA, NR (LTE is NOT valid!)
return (None, None) _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 result: tuple[Optional[float], Optional[float]] = (None, None)
is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g")
# Try v5 API first (POST restapi.amap.com/v5/position/IoT) # Try v5 API first (requires bts with cage field + network param)
result = await _geocode_amap_v5( if AMAP_KEY:
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, result = await _geocode_amap_v5(
imei=imei, api_key=api_key, is_4g=is_4g, 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: if result[0] is None and AMAP_HARDWARE_KEY:
result = await _geocode_amap_legacy( result = await _geocode_amap_legacy(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
@@ -181,20 +184,25 @@ async def geocode_location(
if wifi_list: if wifi_list:
_wifi_cache.put(wifi_cache_key, result) _wifi_cache.put(wifi_cache_key, result)
elif mcc is not None and lac is not None and cell_id is not None: 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 return result
def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str: def _build_bts(
"""Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage""" 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: 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 "" return ""
def _build_nearbts( 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]: ) -> list[str]:
"""Build nearbts (neighbor cell) parts.""" """Build nearbts (neighbor cell) parts."""
parts = [] parts = []
@@ -203,7 +211,8 @@ def _build_nearbts(
nc_lac = nc.get("lac", 0) nc_lac = nc.get("lac", 0)
nc_cid = nc.get("cell_id", 0) nc_cid = nc.get("cell_id", 0)
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80 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 return parts
@@ -259,22 +268,20 @@ def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]:
async def _geocode_amap_v5( async def _geocode_amap_v5(
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int], mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]], 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]]: ) -> tuple[Optional[float], Optional[float]]:
""" """
Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT). Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT).
Key differences from legacy: Key requirements:
- POST method, key in URL params, data in body - POST method, key in URL params, data in form body
- accesstype: 0=未知, 1=移动网络, 2=WiFi - bts MUST have 6 fields: mcc,mnc,lac,cellid,signal,cage
- WiFi requires mmac (connected WiFi) + macs (nearby, 2-30) - network MUST be valid: GSM/GPRS/EDGE/HSUPA/HSDPA/WCDMA/NR (LTE is NOT valid!)
- network: GSM(default)/LTE/WCDMA/NR — critical for 4G accuracy - For 4G LTE, use WCDMA as network value
- diu replaces imei - accesstype: 1=移动网络, 2=WiFi (requires mmac + 2+ macs)
- No digital signature needed
- show_fields can return address directly
""" """
bts = _build_bts(mcc, mnc, lac, cell_id) bts = _build_bts(mcc, mnc, lac, cell_id, include_cage=True)
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc) nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc, include_cage=True)
wifi_parts = _build_wifi_parts(wifi_list) wifi_parts = _build_wifi_parts(wifi_list)
if not bts and not wifi_parts: if not bts and not wifi_parts:
@@ -288,7 +295,7 @@ async def _geocode_amap_v5(
body: dict[str, str] = { body: dict[str, str] = {
"accesstype": accesstype, "accesstype": accesstype,
"cdma": "0", "cdma": "0",
"network": "LTE" if is_4g else "GSM", "network": network,
"diu": imei or _settings.GEOCODING_DEFAULT_IMEI, "diu": imei or _settings.GEOCODING_DEFAULT_IMEI,
"show_fields": "formatted_address", "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}" url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
logger.info("Amap v5 request body: %s", body)
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
@@ -316,6 +325,7 @@ async def _geocode_amap_v5(
) as resp: ) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json(content_type=None) data = await resp.json(content_type=None)
logger.info("Amap v5 response: %s", data)
if data.get("status") == "1": if data.get("status") == "1":
position = data.get("position", {}) position = data.get("position", {})
location = position.get("location", "") if isinstance(position, dict) else "" location = position.get("location", "") if isinstance(position, dict) else ""
@@ -333,8 +343,8 @@ async def _geocode_amap_v5(
else: else:
infocode = data.get("infocode", "") infocode = data.get("infocode", "")
logger.warning( logger.warning(
"Amap v5 geocode error: %s (code=%s)", "Amap v5 geocode error: %s (code=%s) body=%s",
data.get("info", ""), infocode, data.get("info", ""), infocode, body,
) )
else: else:
logger.warning("Amap v5 geocode HTTP %d", resp.status) logger.warning("Amap v5 geocode HTTP %d", resp.status)
@@ -390,6 +400,8 @@ async def _geocode_amap_legacy(
url = "https://apilocate.amap.com/position" 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: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
@@ -397,6 +409,7 @@ async def _geocode_amap_legacy(
) as resp: ) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json(content_type=None) data = await resp.json(content_type=None)
logger.info("Amap legacy response: %s", data)
if data.get("status") == "1" and data.get("result"): if data.get("status") == "1" and data.get("result"):
result = data["result"] result = data["result"]
location = result.get("location", "") location = result.get("location", "")

View File

@@ -199,6 +199,10 @@ class AttendanceRecord(Base):
attendance_type: Mapped[str] = mapped_column( attendance_type: Mapped[str] = mapped_column(
String(20), nullable=False String(20), nullable=False
) # clock_in, clock_out ) # 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) protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True) latitude: Mapped[float | None] = mapped_column(Float, nullable=True)

View File

@@ -30,6 +30,7 @@ router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
async def list_attendance( async def list_attendance(
device_id: int | None = Query(default=None, description="设备ID / Device ID"), device_id: int | None = Query(default=None, description="设备ID / Device ID"),
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"), 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)"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End 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"), page: int = Query(default=1, ge=1, description="页码 / Page number"),
@@ -37,8 +38,8 @@ async def list_attendance(
db: AsyncSession = Depends(get_db), 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) query = select(AttendanceRecord)
count_query = select(func.count(AttendanceRecord.id)) count_query = select(func.count(AttendanceRecord.id))
@@ -51,6 +52,10 @@ async def list_attendance(
query = query.where(AttendanceRecord.attendance_type == attendance_type) query = query.where(AttendanceRecord.attendance_type == attendance_type)
count_query = count_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: if start_time:
query = query.where(AttendanceRecord.recorded_at >= start_time) query = query.where(AttendanceRecord.recorded_at >= start_time)
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time) count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)

View File

@@ -7,7 +7,7 @@ import math
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query 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 sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write 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( @router.get(
"/{location_id}", "/{location_id}",
response_model=APIResponse[LocationRecordResponse], response_model=APIResponse[LocationRecordResponse],

View File

@@ -229,6 +229,7 @@ class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]])
class AttendanceRecordBase(BaseModel): class AttendanceRecordBase(BaseModel):
device_id: int device_id: int
attendance_type: str = Field(..., max_length=20) attendance_type: str = Field(..., max_length=20)
attendance_source: str = Field(default="device", max_length=20) # device, bluetooth, fence
protocol_number: int protocol_number: int
gps_positioned: bool = False gps_positioned: bool = False
latitude: float | None = Field(None, ge=-90, le=90) latitude: float | None = Field(None, ge=-90, le=90)
@@ -243,7 +244,7 @@ class AttendanceRecordBase(BaseModel):
lac: int | None = None lac: int | None = None
cell_id: int | None = None cell_id: int | None = None
wifi_data: list[dict[str, Any]] | 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 address: str | None = None
recorded_at: datetime recorded_at: datetime

View File

@@ -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. Creates automatic attendance records (clock_in/clock_out) on state transitions.
""" """
import json
import logging import logging
import math import math
from datetime import datetime from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import now_cst, settings from app.config import now_cst, settings
from app.models import ( from app.models import (
AttendanceRecord, AttendanceRecord,
Device,
DeviceFenceBinding, DeviceFenceBinding,
DeviceFenceState, DeviceFenceState,
FenceConfig, FenceConfig,
@@ -159,6 +159,31 @@ def _get_tolerance_for_location_type(location_type: str) -> float:
return 0.0 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 # Main fence check entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -173,6 +198,11 @@ async def check_device_fences(
location_type: str, location_type: str,
address: Optional[str], address: Optional[str],
recorded_at: datetime, recorded_at: datetime,
*,
mcc: Optional[int] = None,
mnc: Optional[int] = None,
lac: Optional[int] = None,
cell_id: Optional[int] = None,
) -> list[dict]: ) -> list[dict]:
"""Check all bound active fences for a device. Returns attendance events. """Check all bound active fences for a device. Returns attendance events.
@@ -192,6 +222,17 @@ async def check_device_fences(
if not fences: if not fences:
return [] 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) tolerance = _get_tolerance_for_location_type(location_type)
events: list[dict] = [] events: list[dict] = []
now = now_cst() now = now_cst()
@@ -224,21 +265,28 @@ async def check_device_fences(
_update_state(state, currently_inside, now, latitude, longitude) _update_state(state, currently_inside, now, latitude, longitude)
continue continue
attendance = _create_attendance( # Daily dedup: only one clock_in per device per day
device_id, imei, "clock_in", latitude, longitude, if await _has_attendance_today(session, device_id, "clock_in"):
address, recorded_at, fence, logger.info(
) "Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
session.add(attendance) 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( event = _build_event(
device_id, imei, fence, "clock_in", device_id, imei, fence, "clock_in",
latitude, longitude, address, recorded_at, latitude, longitude, address, recorded_at,
) )
events.append(event) events.append(event)
logger.info( logger.info(
"Fence auto clock_in: device=%d fence=%d(%s)", "Fence auto clock_in: device=%d fence=%d(%s)",
device_id, fence.id, fence.name, device_id, fence.id, fence.name,
) )
elif not currently_inside and was_inside: elif not currently_inside and was_inside:
# EXIT: inside -> outside = clock_out # EXIT: inside -> outside = clock_out
@@ -252,21 +300,28 @@ async def check_device_fences(
_update_state(state, currently_inside, now, latitude, longitude) _update_state(state, currently_inside, now, latitude, longitude)
continue continue
attendance = _create_attendance( # Daily dedup: only one clock_out per device per day
device_id, imei, "clock_out", latitude, longitude, if await _has_attendance_today(session, device_id, "clock_out"):
address, recorded_at, fence, logger.info(
) "Fence skip clock_out: device=%d fence=%d(%s) already clocked out today",
session.add(attendance) 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( event = _build_event(
device_id, imei, fence, "clock_out", device_id, imei, fence, "clock_out",
latitude, longitude, address, recorded_at, latitude, longitude, address, recorded_at,
) )
events.append(event) events.append(event)
logger.info( logger.info(
"Fence auto clock_out: device=%d fence=%d(%s)", "Fence auto clock_out: device=%d fence=%d(%s)",
device_id, fence.id, fence.name, device_id, fence.id, fence.name,
) )
# 4. Update state # 4. Update state
if state is None: if state is None:
@@ -315,18 +370,27 @@ def _create_attendance(
address: Optional[str], address: Optional[str],
recorded_at: datetime, recorded_at: datetime,
fence: FenceConfig, fence: FenceConfig,
device_info: Optional[dict] = None,
) -> AttendanceRecord: ) -> AttendanceRecord:
"""Create an auto-generated fence attendance record.""" """Create an auto-generated fence attendance record."""
info = device_info or {}
return AttendanceRecord( return AttendanceRecord(
device_id=device_id, device_id=device_id,
imei=imei, imei=imei,
attendance_type=attendance_type, attendance_type=attendance_type,
attendance_source="fence",
protocol_number=0, # synthetic, not from device protocol protocol_number=0, # synthetic, not from device protocol
gps_positioned=True, gps_positioned=True,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
address=address, address=address,
recorded_at=recorded_at, 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={ lbs_data={
"source": "fence_auto", "source": "fence_auto",
"fence_id": fence.id, "fence_id": fence.id,

View File

@@ -132,6 +132,9 @@
.panel-item:hover .panel-item-actions { display: flex; } .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 { 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; } .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-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; } .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; } .side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
@@ -402,7 +405,7 @@
<div class="guide-body"> <div class="guide-body">
<div class="guide-steps"> <div class="guide-steps">
<div class="guide-step"><div class="step-num">1</div><div class="step-text"><strong>选择设备</strong>:从下拉框选择要查看的工牌设备</div></div> <div class="guide-step"><div class="step-num">1</div><div class="step-text"><strong>选择设备</strong>:从下拉框选择要查看的工牌设备</div></div>
<div class="guide-step"><div class="step-num">2</div><div class="step-text"><strong>最新位置</strong>点击绿色按钮获取设备最新定位并标注地图</div></div> <div class="guide-step"><div class="step-num">2</div><div class="step-text"><strong>最新位置</strong>发送WHERE#指令获取设备实时定位(需设备在线)</div></div>
<div class="guide-step"><div class="step-num">3</div><div class="step-text"><strong>轨迹回放</strong>:设定日期范围后点击"显示轨迹"查看移动路径</div></div> <div class="guide-step"><div class="step-num">3</div><div class="step-text"><strong>轨迹回放</strong>:设定日期范围后点击"显示轨迹"查看移动路径</div></div>
<div class="guide-step"><div class="step-num">4</div><div class="step-text"><strong>定位类型</strong>:支持 GPS / LBS 基站 / WiFi 及其 4G 变体筛选</div></div> <div class="guide-step"><div class="step-num">4</div><div class="step-text"><strong>定位类型</strong>:支持 GPS / LBS 基站 / WiFi 及其 4G 变体筛选</div></div>
</div> </div>
@@ -446,9 +449,17 @@
<button class="btn btn-primary" onclick="playTrack()" style="background:#7c3aed"><i class="fas fa-play"></i> 路径回放</button> <button class="btn btn-primary" onclick="playTrack()" style="background:#7c3aed"><i class="fas fa-play"></i> 路径回放</button>
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button> <button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button> <button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
<button class="btn" style="background:#dc2626;color:#fff" onclick="batchDeleteNoCoordLocations()"><i class="fas fa-broom"></i> 清除无坐标</button>
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteLoc" onclick="batchDeleteSelectedLocations()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="locSelCount">0</span>)</button>
</div> </div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;"> <div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px; position: relative;">
<div id="locationMap" style="height: 100%; width: 100%;"></div> <div id="locationMap" style="height: 100%; width: 100%;"></div>
<div id="mapLegend" style="display:none;position:absolute;bottom:10px;left:10px;background:rgba(30,30,40,0.85);border:1px solid #4b5563;border-radius:8px;padding:8px 12px;font-size:11px;color:#d1d5db;z-index:10;line-height:1.8">
<span style="color:#3b82f6">&#9679;</span> GPS
<span style="color:#06b6d4;margin-left:8px">&#9679;</span> WiFi <span style="color:#9ca3af">(~80m)</span>
<span style="color:#f59e0b;margin-left:8px">&#9675;</span> LBS <span style="color:#9ca3af">(~1km)</span>
<span style="color:#a855f7;margin-left:8px">&#9679;</span> 蓝牙
</div>
</div> </div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative"> <div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div> <div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
@@ -456,6 +467,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选"></th>
<th>设备ID</th> <th>设备ID</th>
<th>类型</th> <th>类型</th>
<th>纬度</th> <th>纬度</th>
@@ -468,7 +480,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="locationsTableBody"> <tbody id="locationsTableBody">
<tr><td colspan="9" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr> <tr><td colspan="10" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -612,6 +624,12 @@
<option value="clock_in">签到</option> <option value="clock_in">签到</option>
<option value="clock_out">签退</option> <option value="clock_out">签退</option>
</select> </select>
<select id="attSourceFilter" style="width:150px">
<option value="">全部来源</option>
<option value="device">设备打卡</option>
<option value="bluetooth">蓝牙打卡</option>
<option value="fence">围栏自动</option>
</select>
<input type="date" id="attStartDate" style="width:160px"> <input type="date" id="attStartDate" style="width:160px">
<input type="date" id="attEndDate" style="width:160px"> <input type="date" id="attEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadAttendance()"><i class="fas fa-search"></i> 查询</button> <button class="btn btn-primary" onclick="loadAttendance()"><i class="fas fa-search"></i> 查询</button>
@@ -625,6 +643,7 @@
<tr> <tr>
<th>设备ID</th> <th>设备ID</th>
<th>类型</th> <th>类型</th>
<th>来源</th>
<th>位置</th> <th>位置</th>
<th>电量/信号</th> <th>电量/信号</th>
<th>基站</th> <th>基站</th>
@@ -632,7 +651,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="attendanceTableBody"> <tbody id="attendanceTableBody">
<tr><td colspan="6" class="text-center text-gray-500 py-8">加载中...</td></tr> <tr><td colspan="7" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -818,9 +837,15 @@
<div id="fenceMapContainer" style="flex:1;min-height:400px;border-radius:12px;border:1px solid #374151;margin-bottom:12px;position:relative;"> <div id="fenceMapContainer" style="flex:1;min-height:400px;border-radius:12px;border:1px solid #374151;margin-bottom:12px;position:relative;">
<div id="fenceMap" style="height:100%;width:100%;border-radius:12px;"></div> <div id="fenceMap" style="height:100%;width:100%;border-radius:12px;"></div>
</div> </div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:250px;overflow-y:auto"> <div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:300px;overflow-y:auto">
<div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div> <div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto"> <!-- Tabs -->
<div style="display:flex;border-bottom:1px solid #374151;background:#1f2937">
<button id="fenceTabList" class="fence-tab active" onclick="switchFenceTab('list')"><i class="fas fa-list"></i> 围栏列表</button>
<button id="fenceTabBindings" class="fence-tab" onclick="switchFenceTab('bindings')"><i class="fas fa-link"></i> 设备绑定</button>
</div>
<!-- Tab: Fence List -->
<div id="fenceTabContentList" class="overflow-x-auto">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -839,6 +864,28 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Tab: Device Bindings -->
<div id="fenceTabContentBindings" style="display:none;padding:12px">
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
<select id="fenceBindSelect" style="flex:1;max-width:240px" onchange="loadFenceBindingTab()">
<option value="">选择围栏...</option>
</select>
<select id="fenceBindDeviceAdd" style="flex:1;max-width:240px">
<option value="">选择设备添加绑定...</option>
</select>
<button class="btn btn-primary" style="white-space:nowrap" onclick="quickBindDevice()"><i class="fas fa-plus"></i> 绑定</button>
</div>
<div id="fenceBindTableWrap" class="overflow-x-auto">
<table>
<thead><tr>
<th>设备名称</th><th>IMEI</th><th>围栏状态</th><th>最后检测</th><th>操作</th>
</tr></thead>
<tbody id="fenceBindTableBody">
<tr><td colspan="5" class="text-center text-gray-500 py-4">请选择围栏</td></tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1033,6 +1080,7 @@
let mapMarkers = []; let mapMarkers = [];
let mapPolyline = null; let mapPolyline = null;
let mapInfoWindows = []; // store {infoWindow, position} for each track point 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 trackPlayTimer = null;
let trackMovingMarker = null; let trackMovingMarker = null;
let dashAlarmChart = null; let dashAlarmChart = null;
@@ -1713,7 +1761,8 @@
trackMovingMarker.setMap(locationMap); trackMovingMarker.setMap(locationMap);
showToast('路径回放中...', 'info'); showToast('路径回放中...', 'info');
locationMap.setZoomAndCenter(16, positions[0]); locationMap.setCenter(positions[0]);
locationMap.setZoom(16);
mapInfoWindows[0].infoWindow.open(locationMap, positions[0]); mapInfoWindows[0].infoWindow.open(locationMap, positions[0]);
let segIdx = 0; // current segment (from segIdx to segIdx+1) 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) --- // --- 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) { function focusMapPoint(locId) {
if (!locationMap) { showToast('请先加载轨迹或最新位置', 'error'); return; } if (!locationMap) initLocationMap();
const item = mapInfoWindows.find(iw => iw.locId === locId); // Wait for map init
if (!item) { showToast('该记录未在地图上显示,请先加载轨迹', 'info'); return; } if (!locationMap) { showToast('地图初始化中,请稍后重试', 'info'); return; }
locationMap.setZoomAndCenter(17, item.position);
mapInfoWindows.forEach(iw => iw.infoWindow.close()); // First try existing track markers
item.infoWindow.open(locationMap, item.position); const existing = mapInfoWindows.find(iw => iw.locId === locId);
// Scroll map into view 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' }); 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 --- // --- Quick command sender for device detail panel ---
async function _quickCmd(deviceId, cmd, btnEl) { async function _quickCmd(deviceId, cmd, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; } if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
@@ -2114,6 +2253,10 @@
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; } if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; } if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = 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() { async function loadTrack() {
@@ -2158,13 +2301,46 @@
path.push([mLng, mLat]); path.push([mLng, mLat]);
const isFirst = i === 0; const isFirst = i === 0;
const isLast = i === total - 1; 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({ const marker = new AMap.CircleMarker({
center: [mLng, mLat], center: [mLng, mLat],
radius: isFirst || isLast ? 12 : 7, radius: isFirst || isLast ? 12 : 8,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6', fillColor: dotColor,
strokeColor: '#fff', strokeWeight: 1, fillOpacity: 0.9, 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); 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 label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `${i+1}/${total}`;
const content = _buildInfoContent(label, loc, lat, lng); const content = _buildInfoContent(label, loc, lat, lng);
const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) }); const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
@@ -2179,9 +2355,12 @@
mapPolyline.setMap(locationMap); mapPolyline.setMap(locationMap);
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]); locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
} else if (path.length === 1) { } 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} 个轨迹点`); showToast(`已加载 ${total} 个轨迹点`);
} catch (err) { } catch (err) {
showToast('加载轨迹失败: ' + err.message, 'error'); showToast('加载轨迹失败: ' + err.message, 'error');
@@ -2192,7 +2371,15 @@
const deviceId = document.getElementById('locDeviceSelect').value; const deviceId = document.getElementById('locDeviceSelect').value;
if (!deviceId) { showToast('请选择设备', 'error'); return; } if (!deviceId) { showToast('请选择设备', 'error'); return; }
const btn = document.querySelector('.btn-success');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 获取中...';
try { try {
// Record timestamp before sending command
const sentAt = new Date().toISOString();
// Send WHERE# to request fresh position from device // Send WHERE# to request fresh position from device
try { try {
await apiCall(`${API_BASE}/commands/send`, { await apiCall(`${API_BASE}/commands/send`, {
@@ -2200,32 +2387,81 @@
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: 'WHERE#' }), body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: 'WHERE#' }),
}); });
showToast('已发送定位指令,等待设备回传...', 'info'); showToast('已发送定位指令,等待设备回传...', 'info');
await new Promise(r => setTimeout(r, 3000)); } catch (e) {
} catch (_) { /* device may be offline, still show DB data */ } 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 = `<i class="fas fa-spinner fa-spin"></i> 等待回传 ${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(); if (!locationMap) initLocationMap();
clearMapOverlays(); clearMapOverlays();
const lat = loc.latitude || loc.lat; const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon; 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 [mLat, mLng] = toMapCoord(lat, lng);
const marker = new AMap.Marker({ position: [mLng, mLat] }); const marker = new AMap.Marker({ position: [mLng, mLat] });
marker.setMap(locationMap); 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) }); const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
infoWindow.open(locationMap, [mLng, mLat]); infoWindow.open(locationMap, [mLng, mLat]);
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat])); marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
mapMarkers.push(marker); mapMarkers.push(marker);
locationMap.setZoomAndCenter(15, [mLng, mLat]); locationMap.setCenter([mLng, mLat]);
showToast('已显示最新位置'); locationMap.setZoom(15);
// Auto-load records table showToast('已获取设备实时位置');
loadLocationRecords(1); loadLocationRecords(1);
} catch (err) { } catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error'); showToast('获取位置失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
} }
} }
@@ -2248,15 +2484,17 @@
try { try {
const data = await apiCall(url); const data = await apiCall(url);
const items = data.items || []; const items = data.items || [];
_locTableItems = items; // cache for focusMapPoint
const tbody = document.getElementById('locationsTableBody'); const tbody = document.getElementById('locationsTableBody');
if (items.length === 0) { if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-500 py-8">没有位置记录</td></tr>'; tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有位置记录</td></tr>';
} else { } else {
tbody.innerHTML = items.map(l => { tbody.innerHTML = items.map(l => {
const q = _locQuality(l); const q = _locQuality(l);
const hasCoord = l.latitude != null && l.longitude != null; const hasCoord = l.latitude != null && l.longitude != null;
return `<tr style="cursor:${hasCoord?'pointer':'default'}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}> return `<tr style="cursor:${hasCoord?'pointer':'default'}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
<td onclick="event.stopPropagation()"><input type="checkbox" class="loc-sel-cb" value="${l.id}" onchange="updateLocSelCount()"></td>
<td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td> <td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td>
<td>${_locTypeLabel(l.location_type)}</td> <td>${_locTypeLabel(l.location_type)}</td>
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td> <td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
@@ -2272,10 +2510,12 @@
</tr>`; </tr>`;
}).join(''); }).join('');
} }
document.getElementById('locSelectAll').checked = false;
updateLocSelCount();
renderPagination('locationsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadLocationRecords'); renderPagination('locationsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadLocationRecords');
} catch (err) { } catch (err) {
showToast('加载位置记录失败: ' + err.message, 'error'); showToast('加载位置记录失败: ' + err.message, 'error');
document.getElementById('locationsTableBody').innerHTML = '<tr><td colspan="9" class="text-center text-red-400 py-8">加载失败</td></tr>'; document.getElementById('locationsTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally { } finally {
hideLoading('locationsLoading'); hideLoading('locationsLoading');
} }
@@ -2390,12 +2630,14 @@
const ps = pageState.attendance.pageSize; const ps = pageState.attendance.pageSize;
const deviceId = document.getElementById('attDeviceFilter').value; const deviceId = document.getElementById('attDeviceFilter').value;
const attType = document.getElementById('attTypeFilter').value; const attType = document.getElementById('attTypeFilter').value;
const attSource = document.getElementById('attSourceFilter').value;
const startTime = document.getElementById('attStartDate').value; const startTime = document.getElementById('attStartDate').value;
const endTime = document.getElementById('attEndDate').value; const endTime = document.getElementById('attEndDate').value;
let url = `${API_BASE}/attendance?page=${p}&page_size=${ps}`; let url = `${API_BASE}/attendance?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`; if (deviceId) url += `&device_id=${deviceId}`;
if (attType) url += `&attendance_type=${attType}`; if (attType) url += `&attendance_type=${attType}`;
if (attSource) url += `&attendance_source=${attSource}`;
if (startTime) url += `&start_time=${startTime}T00:00:00`; if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`; if (endTime) url += `&end_time=${endTime}T23:59:59`;
@@ -2406,7 +2648,7 @@
const tbody = document.getElementById('attendanceTableBody'); const tbody = document.getElementById('attendanceTableBody');
if (items.length === 0) { if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
} else { } else {
tbody.innerHTML = items.map(a => { tbody.innerHTML = items.map(a => {
const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-'); 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 battStr = a.battery_level != null ? `${a.battery_level}%` : '-';
const sigStr = a.gsm_signal != null ? `GSM:${a.gsm_signal}` : ''; 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 lbsStr = a.mcc != null ? `${a.mcc}/${a.mnc || 0}/${a.lac || 0}/${a.cell_id || 0}` : '-';
const srcLabel = {'device':'<i class="fas fa-mobile-alt"></i> 设备','bluetooth':'<i class="fab fa-bluetooth-b"></i> 蓝牙','fence':'<i class="fas fa-draw-polygon"></i> 围栏'}[a.attendance_source] || a.attendance_source || '设备';
const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af';
return `<tr> return `<tr>
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td> <td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
<td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td> <td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td>
<td style="color:${srcColor};font-size:12px">${srcLabel}</td>
<td class="text-xs">${gpsIcon} ${escapeHtml(posStr)}</td> <td class="text-xs">${gpsIcon} ${escapeHtml(posStr)}</td>
<td class="text-xs">${battStr} ${sigStr}</td> <td class="text-xs">${battStr} ${sigStr}</td>
<td class="text-xs font-mono">${lbsStr}</td> <td class="text-xs font-mono">${lbsStr}</td>
@@ -2427,7 +2672,7 @@
renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance'); renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance');
} catch (err) { } catch (err) {
showToast('加载考勤记录失败: ' + err.message, 'error'); showToast('加载考勤记录失败: ' + err.message, 'error');
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="6" class="text-center text-red-400 py-8">加载失败</td></tr>'; document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="7" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally { } finally {
hideLoading('attendanceLoading'); 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 = '<option value="">选择围栏...</option>' + items.map(f =>
`<option value="${f.id}">${escapeHtml(f.name)} (${f.fence_type === 'circle' ? '圆形' : '多边形'})</option>`
).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 = '<option value="">选择设备添加绑定...</option>' + allDevices.map(d =>
`<option value="${d.id}">${escapeHtml(d.name || d.imei)} (${d.imei})</option>`
).join('');
} catch (_) {}
}
}
async function loadFenceBindingTab() {
const fenceId = document.getElementById('fenceBindSelect').value;
const tbody = document.getElementById('fenceBindTableBody');
if (!fenceId) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">请选择围栏</td></tr>';
return;
}
try {
const devices = await apiCall(`${API_BASE}/fences/${fenceId}/devices`);
if (devices.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">暂无绑定设备,请从上方添加</td></tr>';
} else {
tbody.innerHTML = devices.map(d => `<tr>
<td>${escapeHtml(d.device_name || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(d.imei || '-')}</td>
<td><span class="badge ${d.is_inside ? 'badge-online' : 'badge-offline'}">${d.is_inside ? '围栏内' : '围栏外'}</span></td>
<td class="text-xs text-gray-400">${d.last_check_at ? formatTime(d.last_check_at) : '-'}</td>
<td><button class="btn btn-sm" style="color:#ef4444;font-size:11px" onclick="quickUnbindDevice(${fenceId},${d.device_id},'${escapeHtml(d.device_name||d.imei||"")}')"><i class="fas fa-unlink"></i> 解绑</button></td>
</tr>`).join('');
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-4">加载失败</td></tr>';
}
}
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 ---- // ---- Beacon map picker ----
let _beaconPickerMap = null; let _beaconPickerMap = null;
let _beaconPickerMarker = null; let _beaconPickerMarker = null;

View File

@@ -1113,6 +1113,7 @@ class TCPManager:
fence_session, device_id_for_fence, imei, fence_session, device_id_for_fence, imei,
latitude, longitude, location_type, latitude, longitude, location_type,
address, recorded_at, address, recorded_at,
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
) )
for evt in fence_events: for evt in fence_events:
ws_manager.broadcast_nonblocking("fence_attendance", evt) ws_manager.broadcast_nonblocking("fence_attendance", evt)
@@ -1873,10 +1874,23 @@ class TCPManager:
logger.warning("Attendance for unknown IMEI=%s", imei) logger.warning("Attendance for unknown IMEI=%s", imei)
return attendance_type, reserved_bytes, datetime_bytes 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( record = AttendanceRecord(
device_id=device_id, device_id=device_id,
imei=conn_info.imei, imei=conn_info.imei,
attendance_type=attendance_type, attendance_type=attendance_type,
attendance_source=_att_source,
protocol_number=proto, protocol_number=proto,
gps_positioned=gps_positioned, gps_positioned=gps_positioned,
latitude=latitude, latitude=latitude,