diff --git a/app/config.py b/app/config.py index e596cf3..bd6006e 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,9 @@ class Settings(BaseSettings): # Track query limit TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint") + # TCP connection + TCP_IDLE_TIMEOUT: int = Field(default=600, description="Idle timeout in seconds for TCP connections (0=disabled)") + # Fence auto-attendance FENCE_CHECK_ENABLED: bool = Field(default=True, description="Enable automatic fence attendance check on location report") FENCE_LBS_TOLERANCE_METERS: int = Field(default=200, description="Extra tolerance (meters) for LBS locations in fence check") diff --git a/app/database.py b/app/database.py index 0441772..8923aaa 100644 --- a/app/database.py +++ b/app/database.py @@ -1,3 +1,4 @@ +from sqlalchemy import event from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -9,6 +10,17 @@ engine = create_async_engine( connect_args={"check_same_thread": False}, ) +# Enable WAL mode for concurrent read/write performance +@event.listens_for(engine.sync_engine, "connect") +def _set_sqlite_pragma(dbapi_conn, connection_record): + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA cache_size=-64000") # 64MB cache + cursor.execute("PRAGMA busy_timeout=5000") # 5s wait on lock + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + async_session = async_sessionmaker( bind=engine, class_=AsyncSession, diff --git a/app/dependencies.py b/app/dependencies.py index dc2a998..858a348 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,11 +1,12 @@ """ Shared FastAPI dependencies. Supports master API key (env) and database-managed API keys with permission levels. +Includes in-memory cache to avoid DB lookup on every request. """ import hashlib import secrets -from datetime import datetime, timezone +import time from fastapi import Depends, HTTPException, Security from fastapi.security import APIKeyHeader @@ -20,6 +21,10 @@ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) # Permission hierarchy: admin > write > read _PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3} +# In-memory auth cache: {key_hash: (result_dict, expire_timestamp)} +_AUTH_CACHE: dict[str, tuple[dict, float]] = {} +_AUTH_CACHE_TTL = 60 # seconds + def _hash_key(key: str) -> str: """SHA-256 hash of an API key.""" @@ -32,7 +37,7 @@ async def verify_api_key( ) -> dict | None: """Verify API key. Returns key info dict or None (auth disabled). - Checks master key first, then database keys. + Checks master key first, then in-memory cache, then database keys. Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}. """ if settings.API_KEY is None: @@ -45,23 +50,34 @@ async def verify_api_key( if secrets.compare_digest(api_key, settings.API_KEY): return {"permissions": "admin", "key_id": None, "name": "master"} + # Check in-memory cache first + key_hash = _hash_key(api_key) + now = time.monotonic() + cached = _AUTH_CACHE.get(key_hash) + if cached is not None: + result, expires = cached + if now < expires: + return result + # Check database keys from app.models import ApiKey - key_hash = _hash_key(api_key) result = await db.execute( select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712 ) db_key = result.scalar_one_or_none() if db_key is None: + _AUTH_CACHE.pop(key_hash, None) raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key") - # Update last_used_at + # Update last_used_at (deferred — only on cache miss, not every request) from app.config import now_cst db_key.last_used_at = now_cst() await db.flush() - return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} + key_info = {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} + _AUTH_CACHE[key_hash] = (key_info, now + _AUTH_CACHE_TTL) + return key_info def require_permission(min_level: str): diff --git a/app/geocoding.py b/app/geocoding.py index bb5b23d..e37848e 100644 --- a/app/geocoding.py +++ b/app/geocoding.py @@ -27,6 +27,31 @@ AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET _CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE +# --------------------------------------------------------------------------- +# Shared aiohttp session (reused across all geocoding calls) +# --------------------------------------------------------------------------- + +_http_session: Optional[aiohttp.ClientSession] = None + + +async def _get_http_session() -> aiohttp.ClientSession: + """Get or create the shared aiohttp session (lazy init).""" + global _http_session + if _http_session is None or _http_session.closed: + _http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=5), + ) + return _http_session + + +async def close_http_session() -> None: + """Close the shared session (call on app shutdown).""" + global _http_session + if _http_session and not _http_session.closed: + await _http_session.close() + _http_session = None + + # --------------------------------------------------------------------------- # WGS-84 → GCJ-02 coordinate conversion (server-side) # --------------------------------------------------------------------------- @@ -316,38 +341,36 @@ 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) + logger.debug("Amap v5 request body: %s", body) try: - async with aiohttp.ClientSession() as session: - async with session.post( - url, data=body, timeout=aiohttp.ClientTimeout(total=5) - ) 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 "" - if location and "," in location: - lon_str, lat_str = location.split(",") - gcj_lat = float(lat_str) - gcj_lon = float(lon_str) - lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon) - radius = position.get("radius", "?") if isinstance(position, dict) else "?" - logger.info( - "Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s", - gcj_lat, gcj_lon, lat, lon, radius, - ) - return (lat, lon) - else: - infocode = data.get("infocode", "") - logger.warning( - "Amap v5 geocode error: %s (code=%s) body=%s", - data.get("info", ""), infocode, body, + session = await _get_http_session() + async with session.post(url, data=body) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + logger.debug("Amap v5 response: %s", data) + if data.get("status") == "1": + position = data.get("position", {}) + location = position.get("location", "") if isinstance(position, dict) else "" + if location and "," in location: + lon_str, lat_str = location.split(",") + gcj_lat = float(lat_str) + gcj_lon = float(lon_str) + lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon) + radius = position.get("radius", "?") if isinstance(position, dict) else "?" + logger.info( + "Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s", + gcj_lat, gcj_lon, lat, lon, radius, ) + return (lat, lon) else: - logger.warning("Amap v5 geocode HTTP %d", resp.status) + infocode = data.get("infocode", "") + logger.warning( + "Amap v5 geocode error: %s (code=%s) body=%s", + data.get("info", ""), infocode, body, + ) + else: + logger.warning("Amap v5 geocode HTTP %d", resp.status) except Exception as e: logger.warning("Amap v5 geocode error: %s", e) @@ -400,37 +423,35 @@ 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'}) + logger.debug("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( - url, params=params, timeout=aiohttp.ClientTimeout(total=5) - ) 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", "") - if location and "," in location: - lon_str, lat_str = location.split(",") - gcj_lat = float(lat_str) - gcj_lon = float(lon_str) - lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon) - logger.info( - "Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)", - gcj_lat, gcj_lon, lat, lon, - ) - return (lat, lon) - else: - infocode = data.get("infocode", "") - if infocode == "10012": - logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)") - else: - logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode) + session = await _get_http_session() + async with session.get(url, params=params) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + logger.debug("Amap legacy response: %s", data) + if data.get("status") == "1" and data.get("result"): + result = data["result"] + location = result.get("location", "") + if location and "," in location: + lon_str, lat_str = location.split(",") + gcj_lat = float(lat_str) + gcj_lon = float(lon_str) + lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon) + logger.info( + "Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)", + gcj_lat, gcj_lon, lat, lon, + ) + return (lat, lon) else: - logger.warning("Amap legacy geocode HTTP %d", resp.status) + infocode = data.get("infocode", "") + if infocode == "10012": + logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)") + else: + logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode) + else: + logger.warning("Amap legacy geocode HTTP %d", resp.status) except Exception as e: logger.warning("Amap legacy geocode error: %s", e) @@ -490,28 +511,26 @@ async def _reverse_geocode_amap( url = "https://restapi.amap.com/v3/geocode/regeo" try: - async with aiohttp.ClientSession() as session: - async with session.get( - url, params=params, timeout=aiohttp.ClientTimeout(total=5) - ) as resp: - if resp.status == 200: - data = await resp.json(content_type=None) - if data.get("status") == "1": - regeocode = data.get("regeocode", {}) - formatted = regeocode.get("formatted_address", "") - if formatted and formatted != "[]": - logger.info( - "Amap reverse geocode: %.6f,%.6f -> %s", - lat, lon, formatted, - ) - return formatted - else: - logger.warning( - "Amap reverse geocode error: info=%s, infocode=%s", - data.get("info", ""), data.get("infocode", ""), + session = await _get_http_session() + async with session.get(url, params=params) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + if data.get("status") == "1": + regeocode = data.get("regeocode", {}) + formatted = regeocode.get("formatted_address", "") + if formatted and formatted != "[]": + logger.info( + "Amap reverse geocode: %.6f,%.6f -> %s", + lat, lon, formatted, ) + return formatted else: - logger.warning("Amap reverse geocode HTTP %d", resp.status) + logger.warning( + "Amap reverse geocode error: info=%s, infocode=%s", + data.get("info", ""), data.get("infocode", ""), + ) + else: + logger.warning("Amap reverse geocode HTTP %d", resp.status) except Exception as e: logger.warning("Amap reverse geocode error: %s", e) diff --git a/app/main.py b/app/main.py index ce5747b..0a8b34b 100644 --- a/app/main.py +++ b/app/main.py @@ -89,9 +89,14 @@ async def lifespan(app: FastAPI): for stmt in [ "CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)", "CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)", + "CREATE INDEX IF NOT EXISTS ix_alarm_source ON alarm_records(alarm_source)", "CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)", "CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)", "CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)", + "CREATE INDEX IF NOT EXISTS ix_att_source ON attendance_records(attendance_source)", + "CREATE INDEX IF NOT EXISTS ix_att_device_type_time ON attendance_records(device_id, attendance_type, recorded_at)", + "CREATE INDEX IF NOT EXISTS ix_device_status ON devices(status)", + "CREATE INDEX IF NOT EXISTS ix_fence_active ON fence_configs(is_active)", ]: await conn.execute(sa_text(stmt)) logger.info("Database indexes verified/created") @@ -107,6 +112,9 @@ async def lifespan(app: FastAPI): logger.info("Shutting down TCP server...") await tcp_manager.stop() tcp_task.cancel() + # Close shared HTTP session + from app.geocoding import close_http_session + await close_http_session() app = FastAPI( title="KKS Badge Management System / KKS工牌管理系统", @@ -186,12 +194,14 @@ app.include_router(geocoding.router, dependencies=[*_api_deps]) _STATIC_DIR = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") +# Cache admin.html in memory at startup (avoid disk read per request) +_admin_html_cache: str = (_STATIC_DIR / "admin.html").read_text(encoding="utf-8") + @app.get("/admin", response_class=HTMLResponse, tags=["Admin"]) async def admin_page(): """管理后台页面 / Admin Dashboard""" - html_path = _STATIC_DIR / "admin.html" - return HTMLResponse(content=html_path.read_text(encoding="utf-8")) + return HTMLResponse(content=_admin_html_cache) @app.get("/", tags=["Root"]) diff --git a/app/routers/commands.py b/app/routers/commands.py index 73bde34..3567d53 100644 --- a/app/routers/commands.py +++ b/app/routers/commands.py @@ -317,6 +317,81 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As ) +class BroadcastCommandRequest(BaseModel): + """Request body for broadcasting a command to all devices.""" + command_type: str = Field(default="online_cmd", max_length=30, description="指令类型") + command_content: str = Field(..., max_length=500, description="指令内容") + + +@router.post( + "/broadcast", + response_model=APIResponse[BatchCommandResponse], + status_code=201, + summary="广播指令给所有设备 / Broadcast command to all devices", + dependencies=[Depends(require_write)], +) +@limiter.limit(settings.RATE_LIMIT_WRITE) +async def broadcast_command(request: Request, body: BroadcastCommandRequest, db: AsyncSession = Depends(get_db)): + """ + 向所有设备广播同一指令。尝试通过 TCP 发送给每台设备,TCP 未连接的自动跳过。 + Broadcast the same command to all devices. Attempts TCP send for each, skips those without active TCP connection. + """ + from sqlalchemy import select + from app.models import Device + + result = await db.execute(select(Device)) + devices = list(result.scalars().all()) + + if not devices: + return APIResponse( + message="No devices / 没有设备", + data=BatchCommandResponse(total=0, sent=0, failed=0, results=[]), + ) + + results = [] + for device in devices: + if not tcp_command_service.is_device_online(device.imei): + results.append(BatchCommandResult( + device_id=device.id, imei=device.imei, + success=False, error="TCP not connected", + )) + continue + + try: + cmd_log = await command_service.create_command( + db, + device_id=device.id, + command_type=body.command_type, + command_content=body.command_content, + ) + await tcp_command_service.send_command( + device.imei, body.command_type, body.command_content + ) + cmd_log.status = "sent" + cmd_log.sent_at = now_cst() + await db.flush() + await db.refresh(cmd_log) + results.append(BatchCommandResult( + device_id=device.id, imei=device.imei, + success=True, command_id=cmd_log.id, + )) + except Exception as e: + logging.getLogger(__name__).error("Broadcast cmd failed for %s: %s", device.imei, e) + results.append(BatchCommandResult( + device_id=device.id, imei=device.imei, + success=False, error="Send failed", + )) + + sent = sum(1 for r in results if r.success) + failed = len(results) - sent + return APIResponse( + message=f"Broadcast: {sent} sent, {failed} skipped (total: {len(devices)})", + data=BatchCommandResponse( + total=len(results), sent=sent, failed=failed, results=results, + ), + ) + + @router.get( "/{command_id}", response_model=APIResponse[CommandResponse], diff --git a/app/routers/locations.py b/app/routers/locations.py index bed6e26..99c9c4b 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -31,6 +31,7 @@ router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"]) async def list_locations( device_id: int | None = Query(default=None, description="设备ID / Device ID"), location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"), + exclude_type: str | None = Query(default=None, description="排除定位类型前缀 / Exclude location type prefix (e.g. lbs)"), 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"), @@ -45,6 +46,7 @@ async def list_locations( db, device_id=device_id, location_type=location_type, + exclude_type=exclude_type, start_time=start_time, end_time=end_time, page=page, diff --git a/app/services/fence_checker.py b/app/services/fence_checker.py index b41ce01..d9f8b58 100644 --- a/app/services/fence_checker.py +++ b/app/services/fence_checker.py @@ -234,6 +234,19 @@ async def check_device_fences( "cell_id": cell_id, } + # 2. Batch-load all fence states in one query (avoid N+1) + fence_ids = [f.id for f in fences] + states_result = await session.execute( + select(DeviceFenceState).where( + DeviceFenceState.device_id == device_id, + DeviceFenceState.fence_id.in_(fence_ids), + ) + ) + states_map: dict[int, DeviceFenceState] = {s.fence_id: s for s in states_result.scalars().all()} + + # Pre-check today's attendance dedup once (not per-fence) + _today_clock_in = await _has_attendance_today(session, device_id, "clock_in") + tolerance = _get_tolerance_for_location_type(location_type) events: list[dict] = [] now = now_cst() @@ -242,14 +255,7 @@ async def check_device_fences( for fence in fences: currently_inside = is_inside_fence(latitude, longitude, fence, tolerance) - # 2. Get or create state record - state_result = await session.execute( - select(DeviceFenceState).where( - DeviceFenceState.device_id == device_id, - DeviceFenceState.fence_id == fence.id, - ) - ) - state = state_result.scalar_one_or_none() + state = states_map.get(fence.id) was_inside = bool(state and state.is_inside) @@ -266,8 +272,8 @@ async def check_device_fences( _update_state(state, currently_inside, now, latitude, longitude) continue - # Daily dedup: only one clock_in per device per day - if await _has_attendance_today(session, device_id, "clock_in"): + # Daily dedup: only one clock_in per device per day (pre-fetched) + if _today_clock_in: logger.info( "Fence skip clock_in: device=%d fence=%d(%s) already clocked in today", device_id, fence.id, fence.name, diff --git a/app/services/location_service.py b/app/services/location_service.py index 92f5eeb..b0a8049 100644 --- a/app/services/location_service.py +++ b/app/services/location_service.py @@ -15,6 +15,7 @@ async def get_locations( db: AsyncSession, device_id: int | None = None, location_type: str | None = None, + exclude_type: str | None = None, start_time: datetime | None = None, end_time: datetime | None = None, page: int = 1, @@ -56,6 +57,14 @@ async def get_locations( query = query.where(LocationRecord.location_type == location_type) count_query = count_query.where(LocationRecord.location_type == location_type) + if exclude_type: + # Map prefix to actual values for index-friendly IN query + _EXCLUDE_MAP = {"lbs": ["lbs", "lbs_4g"], "wifi": ["wifi", "wifi_4g"], "gps": ["gps", "gps_4g"]} + exclude_values = _EXCLUDE_MAP.get(exclude_type, [exclude_type]) + clause = LocationRecord.location_type.notin_(exclude_values) + query = query.where(clause) + count_query = count_query.where(clause) + if start_time: query = query.where(LocationRecord.recorded_at >= start_time) count_query = count_query.where(LocationRecord.recorded_at >= start_time) diff --git a/app/static/admin.html b/app/static/admin.html index 2820194..2055626 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -139,6 +139,17 @@ .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; } @media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } } + .dev-sort:hover { background: #374151; } + .sort-arrow { font-size: 10px; color: #6b7280; margin-left: 2px; } + .sort-arrow.asc::after { content: '▲'; color: #60a5fa; } + .sort-arrow.desc::after { content: '▼'; color: #60a5fa; } + .dev-qcmd { height:24px;border:1px solid #374151;border-radius:5px;background:#1f2937;color:#9ca3af;font-size:11px;cursor:pointer;padding:0 7px;margin:0 1px;transition:all 0.15s;white-space:nowrap; } + .dev-qcmd i { margin-right:2px; } + .dev-qcmd:hover:not(:disabled) { background:#2563eb;color:#fff;border-color:#2563eb; } + .dev-qcmd.sent { background:#065f46;color:#34d399;border-color:#065f46; } + .dev-qcmd-danger { color:#f87171; } + .ov-dev-item:hover { background: #374151; } + .dev-qcmd-danger:hover:not(:disabled) { background:#991b1b;color:#fff;border-color:#991b1b; } @@ -309,6 +320,12 @@ + + + + + + @@ -318,18 +335,20 @@ - - - - - - - - + + + + + + + + + + - +
IMEI名称类型状态电量信号最后登录最后心跳IMEI 名称 类型 状态 定位模式电量 信号 最后登录 最后心跳 快捷操作
加载中...
加载中...
@@ -339,6 +358,14 @@
+ +
+ + +
+ + +
@@ -412,7 +439,7 @@ - 设备ID + IMEI 类型 纬度 经度 @@ -432,6 +459,45 @@
+
+ + + @@ -505,7 +571,7 @@ - 设备ID + IMEI 类型 来源 位置 @@ -589,7 +655,7 @@ - 设备ID + IMEI 类型 来源 位置 @@ -657,7 +723,7 @@ - 设备ID + IMEI 类型 信标MAC UUID / Major / Minor @@ -888,7 +954,6 @@ ID 类型 - 设备ID IMEI 详情 坐标 @@ -897,7 +962,7 @@ - 选择筛选条件后点击查询 + 选择筛选条件后点击查询 @@ -1432,12 +1497,20 @@ // ==================== DEVICE SELECTOR HELPER ==================== let cachedDevices = null; + let _devIdToImei = {}; // {device_id: imei} global mapping + + function _imei(deviceId) { + return _devIdToImei[deviceId] || deviceId || '-'; + } async function loadDeviceSelectors() { try { const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`); const devices = data.items || []; cachedDevices = devices; + // Build global device_id -> imei mapping + _devIdToImei = {}; + devices.forEach(d => { _devIdToImei[d.id || d.device_id] = d.imei; }); const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter']; selectors.forEach(id => { const sel = document.getElementById(id); @@ -1567,7 +1640,7 @@
${alarmTypeName(a.alarm_type)} - 设备: ${escapeHtml(a.device_id || '-')} + IMEI: ${escapeHtml(_imei(a.device_id))}
@@ -1650,6 +1723,75 @@ } // ==================== DEVICES ==================== + let _devItems = []; + let _devLocModes = {}; + const _devSort = { field: 'name', dir: 'asc' }; + + function _renderDeviceRows() { + const tbody = document.getElementById('devicesTableBody'); + if (!_devItems.length) { + tbody.innerHTML = '没有找到设备'; + return; + } + const sorted = [..._devItems].sort((a, b) => { + const f = _devSort.field; + let va = a[f], vb = b[f]; + if (va == null) va = ''; + if (vb == null) vb = ''; + if (typeof va === 'number' && typeof vb === 'number') return _devSort.dir === 'asc' ? va - vb : vb - va; + va = String(va); vb = String(vb); + const cmp = va.localeCompare(vb, 'zh-CN', { numeric: true }); + return _devSort.dir === 'asc' ? cmp : -cmp; + }); + tbody.innerHTML = sorted.map(d => { + const did = d.id || d.device_id || ''; + const on = d.status === 'online'; + const dis = on ? '' : 'disabled style="opacity:0.35;cursor:not-allowed"'; + return ` + + ${escapeHtml(d.imei)} + ${escapeHtml(d.name || '-')} + ${escapeHtml(d.device_type || '-')} + ${statusBadge(d.status)} + ${_locTypeBadge(_devLocModes[d.id] || null)} + ${d.battery_level !== undefined && d.battery_level !== null ? `${d.battery_level}%` : '-'} + ${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'} + ${formatTime(d.last_login)} + ${formatTime(d.last_heartbeat)} + + + + + + + + `; + }).join(''); + } + + function _updateSortArrows() { + document.querySelectorAll('.dev-sort .sort-arrow').forEach(el => { + el.className = 'sort-arrow'; + if (el.parentElement.dataset.sort === _devSort.field) { + el.classList.add(_devSort.dir); + } + }); + } + + document.addEventListener('click', e => { + const th = e.target.closest('.dev-sort'); + if (!th) return; + const field = th.dataset.sort; + if (_devSort.field === field) { + _devSort.dir = _devSort.dir === 'asc' ? 'desc' : 'asc'; + } else { + _devSort.field = field; + _devSort.dir = 'asc'; + } + _updateSortArrows(); + _renderDeviceRows(); + }); + async function loadDevices(page) { if (page) pageState.devices.page = page; const p = pageState.devices.page; @@ -1664,29 +1806,18 @@ showLoading('devicesLoading'); try { const data = await apiCall(url); - const items = data.items || []; - const tbody = document.getElementById('devicesTableBody'); - - if (items.length === 0) { - tbody.innerHTML = '没有找到设备'; - } else { - tbody.innerHTML = items.map(d => ` - - ${escapeHtml(d.imei)} - ${escapeHtml(d.name || '-')} - ${escapeHtml(d.device_type || '-')} - ${statusBadge(d.status)} - ${d.battery_level !== undefined && d.battery_level !== null ? `${d.battery_level}%` : '-'} - ${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'} - ${formatTime(d.last_login)} - ${formatTime(d.last_heartbeat)} - - `).join(''); - } + _devItems = data.items || []; + _devLocModes = {}; + _renderDeviceRows(); + _updateSortArrows(); renderPagination('devicesPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDevices'); + // Fetch latest location types asynchronously + if (_devItems.length) { + _fillDeviceLocModes(_devItems.map(d => d.id)); + } } catch (err) { showToast('加载设备列表失败: ' + err.message, 'error'); - document.getElementById('devicesTableBody').innerHTML = '加载失败'; + document.getElementById('devicesTableBody').innerHTML = '加载失败'; } finally { hideLoading('devicesLoading'); } @@ -1736,6 +1867,25 @@ const map = { gps: 'GPS', gps_4g: 'GPS 4G', lbs: 'LBS 基站', lbs_4g: 'LBS 4G', wifi: 'WiFi', wifi_4g: 'WiFi 4G', bluetooth: '蓝牙' }; return map[t] || t || '-'; } + function _locTypeBadge(t) { + if (!t) return '-'; + const colors = { gps: '#3b82f6', gps_4g: '#3b82f6', wifi: '#06b6d4', wifi_4g: '#06b6d4', lbs: '#f59e0b', lbs_4g: '#f59e0b', bluetooth: '#a855f7' }; + const color = colors[t] || '#6b7280'; + const label = _locTypeLabel(t); + return `${label}`; + } + async function _fillDeviceLocModes(deviceIds) { + if (!deviceIds.length) return; + try { + const data = await apiCall(`${API_BASE}/locations/batch-latest`, { + method: 'POST', + body: JSON.stringify({ device_ids: deviceIds }) + }); + const locs = Array.isArray(data) ? data : []; + locs.forEach(l => { if (l) _devLocModes[l.device_id] = l.location_type; }); + _renderDeviceRows(); + } catch (e) { /* silent - non-critical */ } + } function _locModeBadges(locType) { const modes = [ { label: 'GPS', match: ['gps','gps_4g'] }, @@ -1872,6 +2022,8 @@ const content = _buildInfoContent('位置记录', loc, lat, lng); _focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) }); _focusInfoWindow.open(locationMap, [mLng, mLat]); + _focusMarker.on('mouseover', () => _focusInfoWindow.open(locationMap, [mLng, mLat])); + _focusMarker.on('mouseout', () => _focusInfoWindow.close()); _focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat])); locationMap.setCenter([mLng, mLat]); @@ -1977,6 +2129,86 @@ } // --- Quick command sender for device detail panel --- + async function _devQuickCmd(deviceId, cmd, btnEl) { + if (btnEl) { btnEl.disabled = true; btnEl.classList.add('sent'); } + try { + const res = await apiCall(`${API_BASE}/commands/send`, { + method: 'POST', + body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }), + }); + showToast(`${cmd} → 已发送`); + // Poll for response + const cmdId = res && res.id; + if (cmdId) { + for (let i = 0; i < 6; i++) { + await new Promise(r => setTimeout(r, 1500)); + try { + const c = await apiCall(`${API_BASE}/commands/${cmdId}`); + if (c.response_content) { showToast(`${cmd} → ${c.response_content}`); break; } + } catch (_) {} + } + } + } catch (err) { + showToast(`${cmd} 发送失败: ${err.message}`, 'error'); + } finally { + if (btnEl) { btnEl.disabled = false; btnEl.classList.remove('sent'); } + } + } + + async function _broadcastCmd(cmd, label) { + if (!confirm(`确定向所有设备发送 ${cmd} ?`)) return; + try { + const data = await apiCall(`${API_BASE}/commands/broadcast`, { + method: 'POST', + body: JSON.stringify({ command_type: 'online_cmd', command_content: cmd }), + }); + showToast(`${label}: ${data.sent} 台已发送, ${data.failed} 台未连接`); + } catch (err) { + showToast(`${label} 失败: ${err.message}`, 'error'); + } + } + + function _showBroadcastModal() { + showModal(` +

广播指令 — 发送给所有在线设备

+
+ + +

将发送给所有在线设备,离线设备自动跳过

+
+
+
常用指令快捷选择:
+
+ + + + + + + + + + + + + +
+
+
+ + +
+ `); + } + + async function _doBroadcast() { + const cmd = document.getElementById('broadcastCmdInput').value.trim(); + if (!cmd) { showToast('请输入指令内容', 'error'); return; } + if (!confirm('确定向所有设备发送 ' + cmd + ' ?')) return; + closeModal(); + await _broadcastCmd(cmd, '广播 ' + cmd); + } + async function _quickCmd(deviceId, cmd, btnEl) { if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; } try { @@ -2260,6 +2492,381 @@ return wgs84ToGcj02(lat, lng); } + // ==================== LOCATION TAB SWITCH ==================== + let _ovInited = false; + async function _switchLocTab(tab) { + document.getElementById('locTabTrack').classList.toggle('active', tab === 'track'); + document.getElementById('locTabOverview').classList.toggle('active', tab === 'overview'); + document.getElementById('locTabTrackContent').style.display = tab === 'track' ? '' : 'none'; + document.getElementById('locTabOverviewContent').style.display = tab === 'overview' ? '' : 'none'; + if (tab === 'overview') { + if (!_overviewMap) _initOverviewMap(); + if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors(); + if (!_ovInited) { + _ovInited = true; + cachedDevices.forEach(d => _ovSelectedDevices.add(d.id)); + } + _ovBuildDeviceList(); + loadAllDevicePositions(); + } + } + + // ==================== OVERVIEW MAP ==================== + let _overviewMap = null; + let _ovMarkerMap = {}; // {device_id: marker} + let _ovSelectedDevices = new Set(); // selected device IDs + + function _initOverviewMap() { + if (_overviewMap) return; + setTimeout(() => { + const [mLat, mLng] = toMapCoord(30.605, 103.936); + _overviewMap = new AMap.Map('overviewMap', { + viewMode: '3D', pitch: 45, rotation: -15, rotateEnable: true, + zoom: 14, center: [mLng, mLat], + mapStyle: 'amap://styles/normal', + }); + }, 100); + } + + function _clearOverviewMarkers() { + Object.values(_ovMarkerMap).forEach(m => m.setMap(null)); + _ovMarkerMap = {}; + } + + let _ovPinnedIW = null; // pinned (click) info window + + function _ovLocateDevice(did) { + // Highlight in list + document.querySelectorAll('.ov-dev-item').forEach(el => { + el.style.background = Number(el.dataset.did) === did ? '#1e3a5f' : ''; + }); + // Move map to device marker (show even if unchecked) + const marker = _ovMarkerMap[did]; + if (marker) { + if (!marker.getMap()) marker.setMap(_overviewMap); + const pos = marker.getPosition(); + _overviewMap.setCenter(pos); + _overviewMap.setZoom(16); + if (_ovPinnedIW) _ovPinnedIW.close(); + marker.emit('click', { lnglat: pos }); + } else { + showToast('该设备暂无位置数据', 'info'); + } + // Highlight this device's track if exists + _ovHighlightByDevice(did); + } + + function _ovSyncMarkerVisibility() { + let visibleCount = 0; + const visible = []; + for (const [did, marker] of Object.entries(_ovMarkerMap)) { + if (_ovSelectedDevices.has(Number(did))) { + marker.setMap(_overviewMap); + visible.push(marker); + visibleCount++; + } else { + marker.setMap(null); + } + } + document.getElementById('overviewDeviceCount').textContent = + `已选 ${_ovSelectedDevices.size} 台,${visibleCount} 台有位置`; + } + + function _ovBuildDeviceList() { + const list = document.getElementById('ovDeviceList'); + if (!cachedDevices || !cachedDevices.length) { + list.innerHTML = '
无设备
'; + return; + } + list.innerHTML = cachedDevices.map(d => { + const checked = _ovSelectedDevices.has(d.id) ? 'checked' : ''; + const statusDot = d.status === 'online' ? '🟢' : '⚫'; + return `
+ + ${statusDot} + + ${escapeHtml(d.name || '-')} + ${escapeHtml(d.imei)} + +
`; + }).join(''); + _ovUpdateCount(); + document.getElementById('ovSelectAll').checked = _ovSelectedDevices.size === cachedDevices.length; + } + + function _ovToggleDevice(id, checked) { + if (checked) _ovSelectedDevices.add(id); else _ovSelectedDevices.delete(id); + _ovUpdateCount(); + document.getElementById('ovSelectAll').checked = _ovSelectedDevices.size === (cachedDevices || []).length; + _ovSyncMarkerVisibility(); + } + + function _ovFilterDevices() { + const q = (document.getElementById('ovDeviceSearch').value || '').toLowerCase(); + document.querySelectorAll('.ov-dev-item').forEach(el => { + const text = el.textContent.toLowerCase(); + el.style.display = !q || text.includes(q) ? '' : 'none'; + }); + } + + function _ovToggleAll(checked) { + (cachedDevices || []).forEach(d => { + if (checked) _ovSelectedDevices.add(d.id); else _ovSelectedDevices.delete(d.id); + }); + _ovBuildDeviceList(); + _ovSyncMarkerVisibility(); + } + + function _ovUpdateCount() { + const el = document.getElementById('ovSelectedCount'); + if (el) el.textContent = `${_ovSelectedDevices.size}/${(cachedDevices||[]).length}`; + } + + async function loadAllDevicePositions() { + if (!_overviewMap) { _initOverviewMap(); await new Promise(r => setTimeout(r, 200)); } + if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors(); + _clearOverviewMarkers(); + + const allIds = cachedDevices.map(d => d.id); + if (!allIds.length) return; + + try { + const data = await apiCall(`${API_BASE}/locations/batch-latest`, { + method: 'POST', + body: JSON.stringify({ device_ids: allIds }), + }); + const locs = Array.isArray(data) ? data : []; + const _devColors = ['#3b82f6','#ef4444','#22c55e','#f59e0b','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1','#14b8a6','#e879f9','#facc15','#fb923c']; + const _devColorMap = {}; + allIds.forEach((id, idx) => { _devColorMap[id] = _devColors[idx % _devColors.length]; }); + let plotted = 0; + locs.forEach((loc, i) => { + if (!loc) return; + const lat = loc.latitude, lng = loc.longitude; + if (!lat || !lng) return; + const did = allIds[i]; + const [mLat, mLng] = toMapCoord(lat, lng); + const dev = cachedDevices.find(d => d.id === did); + const devName = dev ? (dev.name || '') : ''; + const devImei = dev ? dev.imei : _imei(did); + const isOnline = dev && dev.status === 'online'; + const color = _devColorMap[did] || '#3b82f6'; + const borderColor = isOnline ? '#22c55e' : '#6b7280'; + const labelText = devName ? `${devName} (${devImei.slice(-4)})` : devImei; + + const marker = new AMap.Marker({ + position: [mLng, mLat], + label: { content: `${escapeHtml(labelText)}`, direction: 'top', offset: new AMap.Pixel(0, -5) }, + }); + // Don't add to map yet — visibility controlled by _ovSyncMarkerVisibility + + const statusText = isOnline ? '● 在线' : '● 离线'; + const title = `${escapeHtml(devName || devImei)} ${statusText}
${escapeHtml(devImei)}`; + const content = _buildInfoContent(title, loc, lat, lng); + // Hover: lightweight preview + const hoverIW = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) }); + let _pinned = false; + marker.on('mouseover', () => { if (!_pinned) hoverIW.open(_overviewMap, [mLng, mLat]); }); + marker.on('mouseout', () => { if (!_pinned) hoverIW.close(); }); + // Click: pin the info window + highlight track + const _clickDid = did; + marker.on('click', () => { + if (_ovPinnedIW) _ovPinnedIW.close(); + _pinned = true; + const pinnedIW = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) }); + pinnedIW.open(_overviewMap, [mLng, mLat]); + pinnedIW.on('close', () => { _pinned = false; }); + _ovPinnedIW = pinnedIW; + _ovHighlightByDevice(_clickDid); + }); + _ovMarkerMap[did] = marker; + plotted++; + }); + + // Show only selected devices + _ovSyncMarkerVisibility(); + + // Fit view to visible markers + const visible = Object.entries(_ovMarkerMap) + .filter(([did]) => _ovSelectedDevices.has(Number(did))) + .map(([, m]) => m); + if (visible.length > 1) { + _overviewMap.setFitView(visible, false, [80,80,80,80]); + } else if (visible.length === 1) { + _overviewMap.setCenter(visible[0].getPosition()); + _overviewMap.setZoom(15); + } + showToast(`已加载 ${plotted} 台设备位置`); + } catch (err) { + showToast('加载设备位置失败: ' + err.message, 'error'); + } + } + + async function _ovRequestAllPositions() { + if (!confirm('向所有设备发送定位指令?')) return; + try { + const data = await apiCall(`${API_BASE}/commands/broadcast`, { + method: 'POST', + body: JSON.stringify({ command_type: 'online_cmd', command_content: 'WHERE#' }), + }); + showToast(`已发送定位指令: ${data.sent} 台已发送, ${data.failed} 台未连接,等待回传后点"刷新位置"`); + } catch (err) { + showToast('发送失败: ' + err.message, 'error'); + } + } + + // ==================== OVERVIEW TRACK + LP FILTER ==================== + let _ovTrackMarkers = []; + let _ovHideLP = false; + let _ovHighlightedPL = null; + let _ovDidToPL = {}; // {device_id: polyline} for highlight lookup + + function _ovHighlightByDevice(did) { + const pl = _ovDidToPL[did]; + if (pl) _ovHighlightTrack(pl); + } + + function _ovHighlightTrack(pl) { + // Reset previous highlight + _ovTrackMarkers.forEach(m => { + if (m._ovColor) { + m.setOptions({ strokeWeight: 3, strokeOpacity: 0.6, zIndex: 50 }); + } + // Dim track points of other devices + if (m.setRadius && m._ovOwnerPL && m._ovOwnerPL !== pl) { + m.setMap(null); m._ovDimmed = true; + } + }); + + if (_ovHighlightedPL === pl) { + // Toggle off: restore all + _ovHighlightedPL = null; + _ovTrackMarkers.forEach(m => { + if (m._ovColor) m.setOptions({ strokeOpacity: 0.6 }); + if (m._ovDimmed) { m.setMap(_overviewMap); m._ovDimmed = false; } + }); + showToast('已取消高亮'); + return; + } + + // Highlight clicked polyline + pl.setOptions({ strokeWeight: 6, strokeOpacity: 1, zIndex: 200 }); + _ovHighlightedPL = pl; + // Show only this device's track points + _ovTrackMarkers.forEach(m => { + if (m._ovDimmed) { m.setMap(_overviewMap); m._ovDimmed = false; } + }); + showToast(`已高亮: ${pl._ovDevName}`); + } + + async function _ovShowTrack() { + const ids = [..._ovSelectedDevices]; + if (!ids.length) { showToast('请勾选至少一台设备', 'error'); return; } + + const today = new Date().toISOString().split('T')[0]; + _ovClearTrack(); + let totalPoints = 0; + + // Distinct track colors per device + const trackColors = ['#3b82f6','#ef4444','#22c55e','#f59e0b','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1','#14b8a6']; + + for (let idx = 0; idx < ids.length; idx++) { + const did = ids[idx]; + const dev = cachedDevices.find(d => d.id === did); + const devName = dev ? (dev.name || dev.imei) : did; + const color = trackColors[idx % trackColors.length]; + + try { + const data = await apiCall(`${API_BASE}/locations/track/${did}?start_time=${today}T00:00:00&end_time=${today}T23:59:59`); + const locs = Array.isArray(data) ? data : (data.items || []); + if (!locs.length) continue; + + const path = []; + const deviceMarkers = []; + locs.forEach((loc, i) => { + const lat = loc.latitude, lng = loc.longitude; + if (!lat || !lng) return; + const lt = (loc.location_type || '').toLowerCase(); + if (_ovHideLP && lt.startsWith('lbs')) return; + + const [mLat, mLng] = toMapCoord(lat, lng); + path.push([mLng, mLat]); + + const isFirst = i === 0, isLast = i === locs.length - 1; + const marker = new AMap.CircleMarker({ + center: [mLng, mLat], + radius: isFirst || isLast ? 10 : 5, + fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : color, + strokeColor: '#fff', strokeWeight: 1, + fillOpacity: 0.9, zIndex: 130, cursor: 'pointer', + }); + marker.setMap(_overviewMap); + + const label = `${devName} ${isFirst ? '起点' : isLast ? '终点' : `第${i+1}/${locs.length}点`}`; + const content = _buildInfoContent(label, loc, lat, lng); + const iw = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) }); + let pinned = false; + marker.on('mouseover', () => { if (!pinned) iw.open(_overviewMap, [mLng, mLat]); }); + marker.on('mouseout', () => { if (!pinned) iw.close(); }); + marker.on('click', () => { pinned = !pinned; iw.open(_overviewMap, [mLng, mLat]); }); + iw.on('close', () => { pinned = false; }); + deviceMarkers.push(marker); + _ovTrackMarkers.push(marker); + }); + + if (path.length > 1) { + const pl = new AMap.Polyline({ path, strokeColor: color, strokeWeight: 3, strokeOpacity: 0.6, lineJoin: 'round', cursor: 'pointer', zIndex: 50 }); + pl.setMap(_overviewMap); + pl._ovColor = color; + pl._ovDevName = devName; + pl._ovDid = did; + _ovDidToPL[did] = pl; + pl.on('click', () => _ovHighlightTrack(pl)); + pl.on('mouseover', () => { if (pl !== _ovHighlightedPL) pl.setOptions({ strokeWeight: 5 }); }); + pl.on('mouseout', () => { if (pl !== _ovHighlightedPL) pl.setOptions({ strokeWeight: 3 }); }); + _ovTrackMarkers.push(pl); + // Tag markers with their polyline for highlight grouping + deviceMarkers.forEach(m => { m._ovOwnerPL = pl; }); + } + totalPoints += locs.length; + } catch (err) { + console.error(`Track load failed for ${devName}:`, err); + } + } + + if (totalPoints === 0) { showToast('今天没有轨迹数据', 'info'); return; } + + if (_ovTrackMarkers.length > 1) { + _overviewMap.setFitView(_ovTrackMarkers.filter(m => m.getPosition), false, [80,80,80,80]); + } + document.getElementById('ovBtnClearTrack').style.display = ''; + document.getElementById('ovBtnTrack').innerHTML = ` 轨迹 ${ids.length}台 (${totalPoints}点)`; + showToast(`已加载 ${ids.length} 台设备共 ${totalPoints} 个轨迹点`); + } + + function _ovClearTrack() { + _ovTrackMarkers.forEach(m => m.setMap(null)); + _ovTrackMarkers = []; + _ovHighlightedPL = null; + _ovDidToPL = {}; + document.getElementById('ovBtnClearTrack').style.display = 'none'; + document.getElementById('ovBtnTrack').innerHTML = ' 显示轨迹'; + } + + function _ovToggleLP() { + _ovHideLP = !_ovHideLP; + const btn = document.getElementById('ovBtnHideLP'); + if (_ovHideLP) { + btn.style.background = '#b91c1c'; btn.style.color = '#fff'; + btn.innerHTML = ' 低精度'; + } else { + btn.style.background = ''; btn.style.color = ''; + btn.innerHTML = ' 低精度'; + } + // Re-render track if active + if (_ovTrackMarkers.length) _ovShowTrack(); + } + // ==================== LOCATIONS ==================== function initLocationMap() { if (locationMap) return; @@ -2308,12 +2915,14 @@ btn.style.color = ''; btn.innerHTML = ' 低精度'; } - // Re-apply to existing track markers + // Re-apply to existing track markers + polyline _applyLowPrecisionFilter(); + // Reload table with correct pagination + loadLocationRecords(1); } function _isLowPrecision(locationType) { const t = (locationType || '').toLowerCase(); - return t.startsWith('lbs') || t.startsWith('wifi'); + return t.startsWith('lbs'); } function _applyLowPrecisionFilter() { // Toggle visibility of low-precision markers stored with _lpFlag @@ -2413,14 +3022,18 @@ fillOpacity: isLbs ? 0.6 : 0.9, zIndex: 120, cursor: 'pointer', }); - const isLP = (isLbs || isWifi) && !isFirst && !isLast; + const isLP = isLbs && !isFirst && !isLast; marker._lpFlag = isLP; if (_hideLowPrecision && isLP) marker._lpHidden = true; else marker.setMap(locationMap); 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) }); - marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat])); + let _trackPinned = false; + marker.on('mouseover', () => { if (!_trackPinned) infoWindow.open(locationMap, [mLng, mLat]); }); + marker.on('mouseout', () => { if (!_trackPinned) infoWindow.close(); }); + marker.on('click', () => { _trackPinned = !_trackPinned; infoWindow.open(locationMap, [mLng, mLat]); }); + infoWindow.on('close', () => { _trackPinned = false; }); mapMarkers.push(marker); mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id }); } @@ -2433,8 +3046,7 @@ const filteredPath = _hideLowPrecision ? path.filter((_, i) => { const lt = (locations[i]?.location_type || '').toLowerCase(); - const isFirst = i === 0, isLast = i === locations.length - 1; - return isFirst || isLast || !(lt.startsWith('lbs') || lt.startsWith('wifi')); + return !lt.startsWith('lbs'); }) : path; @@ -2521,7 +3133,11 @@ 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])); + let _latestPinned = true; // pinned by default for latest position + marker.on('mouseover', () => { if (!_latestPinned) infoWindow.open(locationMap, [mLng, mLat]); }); + marker.on('mouseout', () => { if (!_latestPinned) infoWindow.close(); }); + marker.on('click', () => { _latestPinned = !_latestPinned; infoWindow.open(locationMap, [mLng, mLat]); }); + infoWindow.on('close', () => { _latestPinned = false; }); mapMarkers.push(marker); locationMap.setCenter([mLng, mLat]); locationMap.setZoom(15); @@ -2547,6 +3163,7 @@ let url = `${API_BASE}/locations?page=${p}&page_size=${ps}`; if (deviceId) url += `&device_id=${deviceId}`; if (locType) url += `&location_type=${locType}`; + if (_hideLowPrecision && !locType) url += `&exclude_type=lbs`; if (startTime) url += `&start_time=${startTime}T00:00:00`; if (endTime) url += `&end_time=${endTime}T23:59:59`; @@ -2563,10 +3180,9 @@ tbody.innerHTML = items.map(l => { const q = _locQuality(l); const hasCoord = l.latitude != null && l.longitude != null; - const lpHide = _hideLowPrecision && _isLowPrecision(l.location_type); - return ` + return ` - ${escapeHtml(l.device_id || '-')} + ${escapeHtml(_imei(l.device_id))} ${_locTypeLabel(l.location_type)} ${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'} ${l.longitude != null ? Number(l.longitude).toFixed(6) : '-'} @@ -2634,7 +3250,7 @@ tbody.innerHTML = items.map(a => ` - ${escapeHtml(a.device_id || '-')} + ${escapeHtml(_imei(a.device_id))} ${alarmTypeName(a.alarm_type)} ${({'single_fence':'单围栏','multi_fence':'多围栏','lbs':'基站','wifi':'WiFi'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')} ${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')} @@ -2743,7 +3359,7 @@ const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af'; return ` - ${escapeHtml(a.device_id || '-')} + ${escapeHtml(_imei(a.device_id))} ${attendanceTypeName(a.attendance_type)} ${srcLabel} ${locMethod} ${escapeHtml(posStr)} @@ -2813,7 +3429,7 @@ const attStr = b.attendance_type ? `${attendanceTypeName(b.attendance_type)}` : '-'; return ` - ${escapeHtml(b.device_id || '-')} + ${escapeHtml(_imei(b.device_id))} ${typeIcon} ${typeName} ${escapeHtml(mac)} ${uuidLine} @@ -2970,8 +3586,7 @@ ${r.id} ${typeBadge} - ${r.device_id || '-'} - ${escapeHtml(r.imei || (logDeviceMap[r.device_id] || {}).imei || '-')} + ${escapeHtml(r.imei || _imei(r.device_id))} ${escapeHtml(detail)} ${coord} ${escapeHtml(addr)} @@ -4089,7 +4704,7 @@ tbody.innerHTML = items.map(c => ` ${escapeHtml(c.id || '-')} - ${escapeHtml(c.device_id || c.imei || '-')} + ${escapeHtml(_imei(c.device_id))} ${escapeHtml(c.command_type || '-')} ${escapeHtml(truncate(c.command_content || '-', 40))} ${commandStatusBadge(c.status)} diff --git a/app/tcp_server.py b/app/tcp_server.py index cee6a02..eedb59f 100644 --- a/app/tcp_server.py +++ b/app/tcp_server.py @@ -235,10 +235,11 @@ class PacketBuilder: class ConnectionInfo: """Metadata about a single device TCP connection.""" - __slots__ = ("imei", "addr", "connected_at", "last_activity", "serial_counter") + __slots__ = ("imei", "device_id", "addr", "connected_at", "last_activity", "serial_counter") def __init__(self, addr: Tuple[str, int]) -> None: self.imei: Optional[str] = None + self.device_id: Optional[int] = None self.addr = addr self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None) self.last_activity = self.connected_at @@ -254,12 +255,16 @@ class ConnectionInfo: # Helper: look up device_id from IMEI # --------------------------------------------------------------------------- -async def _get_device_id(session, imei: str) -> Optional[int]: - """Query the Device table and return the integer id for the given IMEI.""" +async def _get_device_id(session, imei: str, conn_info: Optional["ConnectionInfo"] = None) -> Optional[int]: + """Return the device id for the given IMEI, using ConnectionInfo cache if available.""" + if conn_info is not None and conn_info.device_id is not None: + return conn_info.device_id result = await session.execute( select(Device.id).where(Device.imei == imei) ) row = result.scalar_one_or_none() + if row is not None and conn_info is not None: + conn_info.device_id = row return row @@ -273,6 +278,7 @@ class TCPManager: def __init__(self) -> None: # {imei: (reader, writer, connection_info)} self.connections: Dict[str, Tuple[asyncio.StreamReader, asyncio.StreamWriter, ConnectionInfo]] = {} + self._conn_lock = asyncio.Lock() self._server: Optional[asyncio.AbstractServer] = None # Protocol number -> handler coroutine mapping @@ -316,11 +322,22 @@ class TCPManager: conn_info = ConnectionInfo(addr) logger.info("New TCP connection from %s:%d", addr[0], addr[1]) - recv_buffer = b"" + recv_buffer = bytearray() try: + idle_timeout = settings.TCP_IDLE_TIMEOUT or None while True: - data = await reader.read(4096) + try: + if idle_timeout: + data = await asyncio.wait_for(reader.read(4096), timeout=idle_timeout) + else: + data = await reader.read(4096) + except asyncio.TimeoutError: + logger.info( + "Idle timeout (%ds) for %s:%d (IMEI=%s), closing", + idle_timeout, addr[0], addr[1], conn_info.imei, + ) + break if not data: logger.info( "Connection closed by remote %s:%d (IMEI=%s)", @@ -371,7 +388,7 @@ class TCPManager: "Receive buffer overflow for IMEI=%s, discarding", conn_info.imei, ) - recv_buffer = b"" + recv_buffer = bytearray() except asyncio.CancelledError: logger.info( @@ -402,14 +419,15 @@ class TCPManager: ) -> None: """Remove connection from tracking and update the device status.""" imei = conn_info.imei - if imei and imei in self.connections: - # Only remove if this is still the active connection (not replaced by reconnect) - _, stored_writer, _ = self.connections[imei] - if stored_writer is writer: - del self.connections[imei] - logger.info("Device IMEI=%s removed from active connections", imei) - else: - logger.info("Device IMEI=%s has reconnected, keeping new connection", imei) + async with self._conn_lock: + if imei and imei in self.connections: + # Only remove if this is still the active connection (not replaced by reconnect) + _, stored_writer, _ = self.connections[imei] + if stored_writer is writer: + del self.connections[imei] + logger.info("Device IMEI=%s removed from active connections", imei) + else: + logger.info("Device IMEI=%s has reconnected, keeping new connection", imei) # Don't mark offline since device reconnected try: writer.close() @@ -599,16 +617,16 @@ class TCPManager: conn_info.imei = imei # Close existing connection if device reconnects - old_conn = self.connections.get(imei) - if old_conn is not None: - _, old_writer, old_info = old_conn - logger.info("Closing stale connection for IMEI=%s (old %s:%d)", imei, old_info.addr[0], old_info.addr[1]) - try: - old_writer.close() - except Exception: - pass - - self.connections[imei] = (reader, writer, conn_info) + async with self._conn_lock: + old_conn = self.connections.get(imei) + if old_conn is not None: + _, old_writer, old_info = old_conn + logger.info("Closing stale connection for IMEI=%s (old %s:%d)", imei, old_info.addr[0], old_info.addr[1]) + try: + old_writer.close() + except Exception: + pass + self.connections[imei] = (reader, writer, conn_info) logger.info( "Device login: IMEI=%s from %s:%d", imei, conn_info.addr[0], conn_info.addr[1] ) @@ -739,7 +757,7 @@ class TCPManager: try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is None: logger.warning("Heartbeat for unknown IMEI=%s", imei) return @@ -1046,22 +1064,29 @@ class TCPManager: except Exception: logger.exception("Geocoding failed for %s IMEI=%s", location_type, imei) - # --- Reverse geocoding: coordinates -> address --- - address: Optional[str] = None + # --- Reverse geocoding (run concurrently with DB store below) --- + address_task = None if latitude is not None and longitude is not None: - try: - address = await reverse_geocode(latitude, longitude) - except Exception: - logger.exception("Reverse geocoding failed for IMEI=%s", imei) + address_task = asyncio.ensure_future(reverse_geocode(latitude, longitude)) try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is None: logger.warning("Location for unknown IMEI=%s", imei) + if address_task: + address_task.cancel() return + # Await reverse geocode result if running + address: Optional[str] = None + if address_task: + try: + address = await address_task + except Exception: + logger.exception("Reverse geocoding failed for IMEI=%s", imei) + record = LocationRecord( device_id=device_id, imei=conn_info.imei, @@ -1086,41 +1111,35 @@ class TCPManager: recorded_at=recorded_at, ) session.add(record) - # Broadcast to WebSocket subscribers + + # --- Fence auto-attendance check (same session) --- + fence_events = [] + if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None: + try: + from app.services.fence_checker import check_device_fences + fence_events = await check_device_fences( + session, device_id, imei, + latitude, longitude, location_type, + address, recorded_at, + mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id, + ) + except Exception: + logger.exception("Fence check failed for IMEI=%s", imei) + + # Broadcast to WebSocket subscribers (after commit) ws_manager.broadcast_nonblocking("location", { "imei": imei, "device_id": device_id, "location_type": location_type, "latitude": latitude, "longitude": longitude, "speed": speed, "address": address, "recorded_at": str(recorded_at), }) + for evt in fence_events: + ws_manager.broadcast_nonblocking("fence_attendance", evt) + ws_manager.broadcast_nonblocking("attendance", evt) except Exception: logger.exception( "DB error storing %s location for IMEI=%s", location_type, imei ) - # --- Fence auto-attendance check --- - if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None: - try: - from app.services.fence_checker import check_device_fences - - async with async_session() as fence_session: - async with fence_session.begin(): - device_id_for_fence = device_id - if device_id_for_fence is None: - # Resolve device_id if not available from above - device_id_for_fence = await _get_device_id(fence_session, imei) - if device_id_for_fence is not None: - fence_events = await check_device_fences( - 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) - ws_manager.broadcast_nonblocking("attendance", evt) - except Exception: - logger.exception("Fence check failed for IMEI=%s", imei) - return address @staticmethod @@ -1564,7 +1583,7 @@ class TCPManager: try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is None: logger.warning("Alarm for unknown IMEI=%s", imei) return @@ -1865,7 +1884,7 @@ class TCPManager: try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is None: logger.warning("Attendance for unknown IMEI=%s", imei) return attendance_type, reserved_bytes, datetime_bytes @@ -2001,7 +2020,7 @@ class TCPManager: try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is not None: # Look up beacon location from beacon_configs beacon_lat = None @@ -2206,7 +2225,7 @@ class TCPManager: try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is None: logger.warning("BT location for unknown IMEI=%s", imei) return @@ -2390,7 +2409,7 @@ class TCPManager: try: async with async_session() as session: async with session.begin(): - device_id = await _get_device_id(session, imei) + device_id = await _get_device_id(session, imei, conn_info) if device_id is None: logger.warning("Command reply for unknown IMEI=%s", imei) return @@ -2439,20 +2458,17 @@ class TCPManager: bool ``True`` if the command was successfully written to the socket. """ - conn = self.connections.get(imei) - if conn is None: - logger.warning("Cannot send command to IMEI=%s: not connected", imei) - return False - - _reader, writer, conn_info = conn - - # Check if the writer is still alive - if writer.is_closing(): - logger.warning("IMEI=%s writer is closing, removing stale connection", imei) - del self.connections[imei] - return False - - serial = conn_info.next_serial() + async with self._conn_lock: + conn = self.connections.get(imei) + if conn is None: + logger.warning("Cannot send command to IMEI=%s: not connected", imei) + return False + _reader, writer, conn_info = conn + if writer.is_closing(): + logger.warning("IMEI=%s writer is closing, removing stale connection", imei) + del self.connections[imei] + return False + serial = conn_info.next_serial() # Build 0x80 online-command packet # Payload: length(1) + server_flag(4) + content_bytes + language(2) @@ -2497,19 +2513,17 @@ class TCPManager: bool ``True`` if the message was successfully written to the socket. """ - conn = self.connections.get(imei) - if conn is None: - logger.warning("Cannot send message to IMEI=%s: not connected", imei) - return False - - _reader, writer, conn_info = conn - - if writer.is_closing(): - logger.warning("IMEI=%s writer is closing, removing stale connection", imei) - del self.connections[imei] - return False - - serial = conn_info.next_serial() + async with self._conn_lock: + conn = self.connections.get(imei) + if conn is None: + logger.warning("Cannot send message to IMEI=%s: not connected", imei) + return False + _reader, writer, conn_info = conn + if writer.is_closing(): + logger.warning("IMEI=%s writer is closing, removing stale connection", imei) + del self.connections[imei] + return False + serial = conn_info.next_serial() msg_bytes = message.encode("utf-16-be") server_flag = b"\x00\x00\x00\x00" diff --git a/app/websocket_manager.py b/app/websocket_manager.py index 95ba191..01dd05c 100644 --- a/app/websocket_manager.py +++ b/app/websocket_manager.py @@ -62,17 +62,22 @@ class WebSocketManager: ensure_ascii=False, ) - disconnected = [] - # Snapshot dict to avoid RuntimeError from concurrent modification - for ws, topics in list(self.active_connections.items()): - if topic in topics: - try: - await ws.send_text(message) - except Exception: - disconnected.append(ws) + # Send to all subscribers concurrently with timeout + subscribers = [(ws, topics) for ws, topics in list(self.active_connections.items()) if topic in topics] + if not subscribers: + return - for ws in disconnected: - self.active_connections.pop(ws, None) + async def _safe_send(ws): + try: + await asyncio.wait_for(ws.send_text(message), timeout=3.0) + return None + except Exception: + return ws + + results = await asyncio.gather(*[_safe_send(ws) for ws, _ in subscribers]) + for ws in results: + if ws is not None: + self.active_connections.pop(ws, None) def broadcast_nonblocking(self, topic: str, data: dict): """Fire-and-forget broadcast (used from TCP handler context)."""