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).
"""
# 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", "")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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],

View File

@@ -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

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.
"""
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,

View File

@@ -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">&#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 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;

View File

@@ -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,