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:
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
<div class="guide-body">
|
||||
<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">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">4</div><div class="step-text"><strong>定位类型</strong>:支持 GPS / LBS 基站 / WiFi 及其 4G 变体筛选</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-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" 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 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="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">●</span> GPS
|
||||
<span style="color:#06b6d4;margin-left:8px">●</span> WiFi <span style="color:#9ca3af">(~80m)</span>
|
||||
<span style="color:#f59e0b;margin-left:8px">○</span> LBS <span style="color:#9ca3af">(~1km)</span>
|
||||
<span style="color:#a855f7;margin-left:8px">●</span> 蓝牙
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -456,6 +467,7 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选"></th>
|
||||
<th>设备ID</th>
|
||||
<th>类型</th>
|
||||
<th>纬度</th>
|
||||
@@ -468,7 +480,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -612,6 +624,12 @@
|
||||
<option value="clock_in">签到</option>
|
||||
<option value="clock_out">签退</option>
|
||||
</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="attEndDate" style="width:160px">
|
||||
<button class="btn btn-primary" onclick="loadAttendance()"><i class="fas fa-search"></i> 查询</button>
|
||||
@@ -625,6 +643,7 @@
|
||||
<tr>
|
||||
<th>设备ID</th>
|
||||
<th>类型</th>
|
||||
<th>来源</th>
|
||||
<th>位置</th>
|
||||
<th>电量/信号</th>
|
||||
<th>基站</th>
|
||||
@@ -632,7 +651,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</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="fenceMap" style="height:100%;width:100%;border-radius:12px;"></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 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>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -839,6 +864,28 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 获取中...';
|
||||
|
||||
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 = `<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();
|
||||
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 = '<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 {
|
||||
tbody.innerHTML = items.map(l => {
|
||||
const q = _locQuality(l);
|
||||
const hasCoord = l.latitude != null && l.longitude != null;
|
||||
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>${_locTypeLabel(l.location_type)}</td>
|
||||
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
|
||||
@@ -2272,10 +2510,12 @@
|
||||
</tr>`;
|
||||
}).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 = '<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 {
|
||||
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 = '<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 {
|
||||
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':'<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>
|
||||
<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 style="color:${srcColor};font-size:12px">${srcLabel}</td>
|
||||
<td class="text-xs">${gpsIcon} ${escapeHtml(posStr)}</td>
|
||||
<td class="text-xs">${battStr} ${sigStr}</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');
|
||||
} catch (err) {
|
||||
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 {
|
||||
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 ----
|
||||
let _beaconPickerMap = null;
|
||||
let _beaconPickerMarker = null;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user