feat: 性能优化 + 设备总览轨迹展示 + 广播指令API
性能: SQLite WAL模式、aiohttp Session复用、TCP连接锁+空闲超时、 device_id缓存、WebSocket并发广播、API Key认证缓存、围栏N+1查询 批量化、逆地理编码并行化、新增5个DB索引、日志降级DEBUG 功能: 广播指令API(broadcast)、exclude_type低精度后端过滤、 前端设备总览Tab+多设备轨迹叠加+高亮联动+搜索+专属颜色 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -50,6 +50,9 @@ class Settings(BaseSettings):
|
|||||||
# Track query limit
|
# Track query limit
|
||||||
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
|
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 auto-attendance
|
||||||
FENCE_CHECK_ENABLED: bool = Field(default=True, description="Enable automatic fence attendance check on location report")
|
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")
|
FENCE_LBS_TOLERANCE_METERS: int = Field(default=200, description="Extra tolerance (meters) for LBS locations in fence check")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
@@ -9,6 +10,17 @@ engine = create_async_engine(
|
|||||||
connect_args={"check_same_thread": False},
|
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(
|
async_session = async_sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Shared FastAPI dependencies.
|
Shared FastAPI dependencies.
|
||||||
Supports master API key (env) and database-managed API keys with permission levels.
|
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 hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
import time
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Security
|
from fastapi import Depends, HTTPException, Security
|
||||||
from fastapi.security import APIKeyHeader
|
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 hierarchy: admin > write > read
|
||||||
_PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3}
|
_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:
|
def _hash_key(key: str) -> str:
|
||||||
"""SHA-256 hash of an API key."""
|
"""SHA-256 hash of an API key."""
|
||||||
@@ -32,7 +37,7 @@ async def verify_api_key(
|
|||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Verify API key. Returns key info dict or None (auth disabled).
|
"""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}.
|
Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}.
|
||||||
"""
|
"""
|
||||||
if settings.API_KEY is None:
|
if settings.API_KEY is None:
|
||||||
@@ -45,23 +50,34 @@ async def verify_api_key(
|
|||||||
if secrets.compare_digest(api_key, settings.API_KEY):
|
if secrets.compare_digest(api_key, settings.API_KEY):
|
||||||
return {"permissions": "admin", "key_id": None, "name": "master"}
|
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
|
# Check database keys
|
||||||
from app.models import ApiKey
|
from app.models import ApiKey
|
||||||
|
|
||||||
key_hash = _hash_key(api_key)
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712
|
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712
|
||||||
)
|
)
|
||||||
db_key = result.scalar_one_or_none()
|
db_key = result.scalar_one_or_none()
|
||||||
if db_key is None:
|
if db_key is None:
|
||||||
|
_AUTH_CACHE.pop(key_hash, None)
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
|
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
|
from app.config import now_cst
|
||||||
db_key.last_used_at = now_cst()
|
db_key.last_used_at = now_cst()
|
||||||
await db.flush()
|
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):
|
def require_permission(min_level: str):
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET
|
|||||||
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
_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)
|
# WGS-84 → GCJ-02 coordinate conversion (server-side)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -316,16 +341,14 @@ async def _geocode_amap_v5(
|
|||||||
|
|
||||||
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
|
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
|
||||||
|
|
||||||
logger.info("Amap v5 request body: %s", body)
|
logger.debug("Amap v5 request body: %s", body)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
session = await _get_http_session()
|
||||||
async with session.post(
|
async with session.post(url, data=body) as resp:
|
||||||
url, data=body, timeout=aiohttp.ClientTimeout(total=5)
|
|
||||||
) as resp:
|
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json(content_type=None)
|
data = await resp.json(content_type=None)
|
||||||
logger.info("Amap v5 response: %s", data)
|
logger.debug("Amap v5 response: %s", data)
|
||||||
if data.get("status") == "1":
|
if data.get("status") == "1":
|
||||||
position = data.get("position", {})
|
position = data.get("position", {})
|
||||||
location = position.get("location", "") if isinstance(position, dict) else ""
|
location = position.get("location", "") if isinstance(position, dict) else ""
|
||||||
@@ -400,16 +423,14 @@ async def _geocode_amap_legacy(
|
|||||||
|
|
||||||
url = "https://apilocate.amap.com/position"
|
url = "https://apilocate.amap.com/position"
|
||||||
|
|
||||||
logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
|
logger.debug("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
session = await _get_http_session()
|
||||||
async with session.get(
|
async with session.get(url, params=params) as resp:
|
||||||
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
|
|
||||||
) as resp:
|
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json(content_type=None)
|
data = await resp.json(content_type=None)
|
||||||
logger.info("Amap legacy response: %s", data)
|
logger.debug("Amap legacy response: %s", data)
|
||||||
if data.get("status") == "1" and data.get("result"):
|
if data.get("status") == "1" and data.get("result"):
|
||||||
result = data["result"]
|
result = data["result"]
|
||||||
location = result.get("location", "")
|
location = result.get("location", "")
|
||||||
@@ -490,10 +511,8 @@ async def _reverse_geocode_amap(
|
|||||||
url = "https://restapi.amap.com/v3/geocode/regeo"
|
url = "https://restapi.amap.com/v3/geocode/regeo"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
session = await _get_http_session()
|
||||||
async with session.get(
|
async with session.get(url, params=params) as resp:
|
||||||
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
|
|
||||||
) as resp:
|
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.json(content_type=None)
|
data = await resp.json(content_type=None)
|
||||||
if data.get("status") == "1":
|
if data.get("status") == "1":
|
||||||
|
|||||||
14
app/main.py
14
app/main.py
@@ -89,9 +89,14 @@ async def lifespan(app: FastAPI):
|
|||||||
for stmt in [
|
for stmt in [
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)",
|
"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_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_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_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_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))
|
await conn.execute(sa_text(stmt))
|
||||||
logger.info("Database indexes verified/created")
|
logger.info("Database indexes verified/created")
|
||||||
@@ -107,6 +112,9 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Shutting down TCP server...")
|
logger.info("Shutting down TCP server...")
|
||||||
await tcp_manager.stop()
|
await tcp_manager.stop()
|
||||||
tcp_task.cancel()
|
tcp_task.cancel()
|
||||||
|
# Close shared HTTP session
|
||||||
|
from app.geocoding import close_http_session
|
||||||
|
await close_http_session()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="KKS Badge Management System / KKS工牌管理系统",
|
title="KKS Badge Management System / KKS工牌管理系统",
|
||||||
@@ -186,12 +194,14 @@ app.include_router(geocoding.router, dependencies=[*_api_deps])
|
|||||||
_STATIC_DIR = Path(__file__).parent / "static"
|
_STATIC_DIR = Path(__file__).parent / "static"
|
||||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="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"])
|
@app.get("/admin", response_class=HTMLResponse, tags=["Admin"])
|
||||||
async def admin_page():
|
async def admin_page():
|
||||||
"""管理后台页面 / Admin Dashboard"""
|
"""管理后台页面 / Admin Dashboard"""
|
||||||
html_path = _STATIC_DIR / "admin.html"
|
return HTMLResponse(content=_admin_html_cache)
|
||||||
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["Root"])
|
@app.get("/", tags=["Root"])
|
||||||
|
|||||||
@@ -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(
|
@router.get(
|
||||||
"/{command_id}",
|
"/{command_id}",
|
||||||
response_model=APIResponse[CommandResponse],
|
response_model=APIResponse[CommandResponse],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
|
|||||||
async def list_locations(
|
async def list_locations(
|
||||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||||
location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"),
|
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)"),
|
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
||||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
||||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||||
@@ -45,6 +46,7 @@ async def list_locations(
|
|||||||
db,
|
db,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
location_type=location_type,
|
location_type=location_type,
|
||||||
|
exclude_type=exclude_type,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
page=page,
|
page=page,
|
||||||
|
|||||||
@@ -234,6 +234,19 @@ async def check_device_fences(
|
|||||||
"cell_id": cell_id,
|
"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)
|
tolerance = _get_tolerance_for_location_type(location_type)
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
now = now_cst()
|
now = now_cst()
|
||||||
@@ -242,14 +255,7 @@ async def check_device_fences(
|
|||||||
for fence in fences:
|
for fence in fences:
|
||||||
currently_inside = is_inside_fence(latitude, longitude, fence, tolerance)
|
currently_inside = is_inside_fence(latitude, longitude, fence, tolerance)
|
||||||
|
|
||||||
# 2. Get or create state record
|
state = states_map.get(fence.id)
|
||||||
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()
|
|
||||||
|
|
||||||
was_inside = bool(state and state.is_inside)
|
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)
|
_update_state(state, currently_inside, now, latitude, longitude)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Daily dedup: only one clock_in per device per day
|
# Daily dedup: only one clock_in per device per day (pre-fetched)
|
||||||
if await _has_attendance_today(session, device_id, "clock_in"):
|
if _today_clock_in:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
|
"Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
|
||||||
device_id, fence.id, fence.name,
|
device_id, fence.id, fence.name,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async def get_locations(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
device_id: int | None = None,
|
device_id: int | None = None,
|
||||||
location_type: str | None = None,
|
location_type: str | None = None,
|
||||||
|
exclude_type: str | None = None,
|
||||||
start_time: datetime | None = None,
|
start_time: datetime | None = None,
|
||||||
end_time: datetime | None = None,
|
end_time: datetime | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -56,6 +57,14 @@ async def get_locations(
|
|||||||
query = query.where(LocationRecord.location_type == location_type)
|
query = query.where(LocationRecord.location_type == location_type)
|
||||||
count_query = count_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:
|
if start_time:
|
||||||
query = query.where(LocationRecord.recorded_at >= start_time)
|
query = query.where(LocationRecord.recorded_at >= start_time)
|
||||||
count_query = count_query.where(LocationRecord.recorded_at >= start_time)
|
count_query = count_query.where(LocationRecord.recorded_at >= start_time)
|
||||||
|
|||||||
@@ -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; }
|
.panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; }
|
||||||
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
|
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
|
||||||
@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; } }
|
@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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -309,6 +320,12 @@
|
|||||||
<option value="offline">离线</option>
|
<option value="offline">离线</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-secondary" onclick="loadDevices()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
<button class="btn btn-secondary" onclick="loadDevices()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||||
|
<span style="width:1px;height:24px;background:#374151;margin:0 4px"></span>
|
||||||
|
<button class="btn btn-secondary" onclick="_broadcastCmd('WHERE#','全部定位')"><i class="fas fa-crosshairs"></i> 全部定位</button>
|
||||||
|
<button class="btn btn-secondary" onclick="_broadcastCmd('GPSON#','全部开GPS')"><i class="fas fa-satellite-dish"></i> 全部开GPS</button>
|
||||||
|
<button class="btn btn-secondary" onclick="_broadcastCmd('MODE,1#','全部定时模式')"><i class="fas fa-clock"></i> 全部定时</button>
|
||||||
|
<button class="btn btn-secondary" onclick="_broadcastCmd('MODE,3#','全部智能模式')"><i class="fas fa-brain"></i> 全部智能</button>
|
||||||
|
<button class="btn btn-secondary" onclick="_showBroadcastModal()"><i class="fas fa-broadcast-tower"></i> 自定义广播</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="showAddDeviceModal()"><i class="fas fa-plus"></i> 添加设备</button>
|
<button class="btn btn-primary" onclick="showAddDeviceModal()"><i class="fas fa-plus"></i> 添加设备</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,18 +335,20 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>IMEI</th>
|
<th class="dev-sort" data-sort="imei" style="cursor:pointer;user-select:none">IMEI <span class="sort-arrow"></span></th>
|
||||||
<th>名称</th>
|
<th class="dev-sort" data-sort="name" style="cursor:pointer;user-select:none">名称 <span class="sort-arrow"></span></th>
|
||||||
<th>类型</th>
|
<th class="dev-sort" data-sort="device_type" style="cursor:pointer;user-select:none">类型 <span class="sort-arrow"></span></th>
|
||||||
<th>状态</th>
|
<th class="dev-sort" data-sort="status" style="cursor:pointer;user-select:none">状态 <span class="sort-arrow"></span></th>
|
||||||
<th>电量</th>
|
<th>定位模式</th>
|
||||||
<th>信号</th>
|
<th class="dev-sort" data-sort="battery_level" style="cursor:pointer;user-select:none">电量 <span class="sort-arrow"></span></th>
|
||||||
<th>最后登录</th>
|
<th class="dev-sort" data-sort="gsm_signal" style="cursor:pointer;user-select:none">信号 <span class="sort-arrow"></span></th>
|
||||||
<th>最后心跳</th>
|
<th class="dev-sort" data-sort="last_login" style="cursor:pointer;user-select:none">最后登录 <span class="sort-arrow"></span></th>
|
||||||
|
<th class="dev-sort" data-sort="last_heartbeat" style="cursor:pointer;user-select:none">最后心跳 <span class="sort-arrow"></span></th>
|
||||||
|
<th style="text-align:center">快捷操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="devicesTableBody">
|
<tbody id="devicesTableBody">
|
||||||
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
<tr><td colspan="10" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,6 +358,14 @@
|
|||||||
|
|
||||||
<!-- ==================== LOCATIONS PAGE ==================== -->
|
<!-- ==================== LOCATIONS PAGE ==================== -->
|
||||||
<div id="page-locations" class="page">
|
<div id="page-locations" class="page">
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:12px;border-bottom:2px solid #374151">
|
||||||
|
<button class="fence-tab active" id="locTabTrack" onclick="_switchLocTab('track')"><i class="fas fa-route mr-1"></i>轨迹追踪</button>
|
||||||
|
<button class="fence-tab" id="locTabOverview" onclick="_switchLocTab('overview')"><i class="fas fa-users mr-1"></i>全部设备总览</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track tab content -->
|
||||||
|
<div id="locTabTrackContent">
|
||||||
<div class="page-guide" onclick="this.classList.toggle('collapsed')">
|
<div class="page-guide" onclick="this.classList.toggle('collapsed')">
|
||||||
<div class="guide-header">
|
<div class="guide-header">
|
||||||
<div class="guide-icon"><i class="fas fa-lightbulb text-white text-sm"></i></div>
|
<div class="guide-icon"><i class="fas fa-lightbulb text-white text-sm"></i></div>
|
||||||
@@ -412,7 +439,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选当前页"></th>
|
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选当前页"></th>
|
||||||
<th>设备ID</th>
|
<th>IMEI</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>纬度</th>
|
<th>纬度</th>
|
||||||
<th>经度</th>
|
<th>经度</th>
|
||||||
@@ -432,6 +459,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /locTabTrackContent -->
|
||||||
|
|
||||||
|
<!-- Overview tab content -->
|
||||||
|
<div id="locTabOverviewContent" style="display:none">
|
||||||
|
<div style="display:flex;gap:12px;height:calc(100vh - 200px);min-height:500px">
|
||||||
|
<!-- Left: device checklist -->
|
||||||
|
<div style="width:260px;min-width:260px;background:#1f2937;border:1px solid #374151;border-radius:10px;display:flex;flex-direction:column;overflow:hidden">
|
||||||
|
<div style="padding:10px 12px;border-bottom:1px solid #374151;font-size:13px;font-weight:600;color:#d1d5db;display:flex;align-items:center;gap:6px">
|
||||||
|
<i class="fas fa-users text-blue-400"></i> 设备选择
|
||||||
|
<span id="ovSelectedCount" style="margin-left:auto;font-size:11px;color:#6b7280"></span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px 8px;border-bottom:1px solid #374151;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<div style="position:relative">
|
||||||
|
<i class="fas fa-search" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);color:#6b7280;font-size:11px"></i>
|
||||||
|
<input type="text" id="ovDeviceSearch" placeholder="搜索名称/IMEI..." oninput="_ovFilterDevices()" style="width:100%;padding:4px 8px 4px 26px;font-size:11px;background:#111827;border:1px solid #374151;border-radius:5px;color:#d1d5db">
|
||||||
|
</div>
|
||||||
|
<label style="font-size:11px;color:#9ca3af;cursor:pointer;display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="checkbox" id="ovSelectAll" checked onchange="_ovToggleAll(this.checked)"> 全选/取消
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="ovDeviceList" style="flex:1;overflow-y:auto;padding:4px 8px"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Right: map + toolbar -->
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button class="btn btn-primary" onclick="loadAllDevicePositions()"><i class="fas fa-sync-alt"></i> 刷新位置</button>
|
||||||
|
<button class="btn btn-success" onclick="_ovRequestAllPositions()"><i class="fas fa-satellite-dish"></i> 获取实时位置</button>
|
||||||
|
<span style="width:1px;height:24px;background:#374151"></span>
|
||||||
|
<button class="btn btn-secondary" onclick="_ovShowTrack()" id="ovBtnTrack"><i class="fas fa-route"></i> 显示轨迹</button>
|
||||||
|
<button class="btn btn-secondary" onclick="_ovClearTrack()" id="ovBtnClearTrack" style="display:none"><i class="fas fa-times"></i> 清除轨迹</button>
|
||||||
|
<button id="ovBtnHideLP" class="btn btn-secondary" onclick="_ovToggleLP()"><i class="fas fa-eye"></i> 低精度</button>
|
||||||
|
<span id="overviewDeviceCount" class="text-sm text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden" style="flex:1;position:relative">
|
||||||
|
<div id="overviewMap" style="height:100%;width:100%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== ALARMS PAGE ==================== -->
|
<!-- ==================== ALARMS PAGE ==================== -->
|
||||||
@@ -505,7 +571,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:32px"><input type="checkbox" id="alarmSelectAll" onchange="toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked)" title="全选当前页"></th>
|
<th style="width:32px"><input type="checkbox" id="alarmSelectAll" onchange="toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked)" title="全选当前页"></th>
|
||||||
<th>设备ID</th>
|
<th>IMEI</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>来源</th>
|
<th>来源</th>
|
||||||
<th>位置</th>
|
<th>位置</th>
|
||||||
@@ -589,7 +655,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选当前页"></th>
|
<th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选当前页"></th>
|
||||||
<th>设备ID</th>
|
<th>IMEI</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>来源</th>
|
<th>来源</th>
|
||||||
<th>位置</th>
|
<th>位置</th>
|
||||||
@@ -657,7 +723,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选当前页"></th>
|
<th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选当前页"></th>
|
||||||
<th>设备ID</th>
|
<th>IMEI</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>信标MAC</th>
|
<th>信标MAC</th>
|
||||||
<th>UUID / Major / Minor</th>
|
<th>UUID / Major / Minor</th>
|
||||||
@@ -888,7 +954,6 @@
|
|||||||
<th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选当前页"></th>
|
<th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选当前页"></th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>设备ID</th>
|
|
||||||
<th>IMEI</th>
|
<th>IMEI</th>
|
||||||
<th>详情</th>
|
<th>详情</th>
|
||||||
<th>坐标</th>
|
<th>坐标</th>
|
||||||
@@ -897,7 +962,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="datalogTableBody">
|
<tbody id="datalogTableBody">
|
||||||
<tr><td colspan="9" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
|
<tr><td colspan="8" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1432,12 +1497,20 @@
|
|||||||
|
|
||||||
// ==================== DEVICE SELECTOR HELPER ====================
|
// ==================== DEVICE SELECTOR HELPER ====================
|
||||||
let cachedDevices = null;
|
let cachedDevices = null;
|
||||||
|
let _devIdToImei = {}; // {device_id: imei} global mapping
|
||||||
|
|
||||||
|
function _imei(deviceId) {
|
||||||
|
return _devIdToImei[deviceId] || deviceId || '-';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDeviceSelectors() {
|
async function loadDeviceSelectors() {
|
||||||
try {
|
try {
|
||||||
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
|
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
|
||||||
const devices = data.items || [];
|
const devices = data.items || [];
|
||||||
cachedDevices = devices;
|
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'];
|
const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter'];
|
||||||
selectors.forEach(id => {
|
selectors.forEach(id => {
|
||||||
const sel = document.getElementById(id);
|
const sel = document.getElementById(id);
|
||||||
@@ -1567,7 +1640,7 @@
|
|||||||
<i class="fas fa-exclamation-circle ${alarmTypeClass(a.alarm_type)}"></i>
|
<i class="fas fa-exclamation-circle ${alarmTypeClass(a.alarm_type)}"></i>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-medium ${alarmTypeClass(a.alarm_type)}">${alarmTypeName(a.alarm_type)}</span>
|
<span class="text-sm font-medium ${alarmTypeClass(a.alarm_type)}">${alarmTypeName(a.alarm_type)}</span>
|
||||||
<span class="text-xs text-gray-500 ml-2">设备: ${escapeHtml(a.device_id || '-')}</span>
|
<span class="text-xs text-gray-500 ml-2">IMEI: ${escapeHtml(_imei(a.device_id))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -1650,6 +1723,75 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== DEVICES ====================
|
// ==================== DEVICES ====================
|
||||||
|
let _devItems = [];
|
||||||
|
let _devLocModes = {};
|
||||||
|
const _devSort = { field: 'name', dir: 'asc' };
|
||||||
|
|
||||||
|
function _renderDeviceRows() {
|
||||||
|
const tbody = document.getElementById('devicesTableBody');
|
||||||
|
if (!_devItems.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
|
||||||
|
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 `
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono text-sm" style="cursor:pointer;color:#60a5fa" onclick="showDeviceDetail('${did}')">${escapeHtml(d.imei)}</td>
|
||||||
|
<td style="cursor:pointer" onclick="showDeviceDetail('${did}')">${escapeHtml(d.name || '-')}</td>
|
||||||
|
<td>${escapeHtml(d.device_type || '-')}</td>
|
||||||
|
<td>${statusBadge(d.status)}</td>
|
||||||
|
<td class="text-xs">${_locTypeBadge(_devLocModes[d.id] || null)}</td>
|
||||||
|
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
|
||||||
|
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
|
||||||
|
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
|
||||||
|
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
|
||||||
|
<td style="text-align:center;white-space:nowrap" onclick="event.stopPropagation()">
|
||||||
|
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','WHERE#',this)"><i class="fas fa-crosshairs"></i> 定位</button>
|
||||||
|
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','GPSON#',this)"><i class="fas fa-satellite-dish"></i> GPS</button>
|
||||||
|
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','MODE,1#',this)"><i class="fas fa-clock"></i> 定时</button>
|
||||||
|
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','STATUS#',this)"><i class="fas fa-info-circle"></i> 状态</button>
|
||||||
|
<button class="dev-qcmd dev-qcmd-danger" ${dis} onclick="if(confirm('确定重启该设备?'))_devQuickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).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) {
|
async function loadDevices(page) {
|
||||||
if (page) pageState.devices.page = page;
|
if (page) pageState.devices.page = page;
|
||||||
const p = pageState.devices.page;
|
const p = pageState.devices.page;
|
||||||
@@ -1664,29 +1806,18 @@
|
|||||||
showLoading('devicesLoading');
|
showLoading('devicesLoading');
|
||||||
try {
|
try {
|
||||||
const data = await apiCall(url);
|
const data = await apiCall(url);
|
||||||
const items = data.items || [];
|
_devItems = data.items || [];
|
||||||
const tbody = document.getElementById('devicesTableBody');
|
_devLocModes = {};
|
||||||
|
_renderDeviceRows();
|
||||||
if (items.length === 0) {
|
_updateSortArrows();
|
||||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
|
|
||||||
} else {
|
|
||||||
tbody.innerHTML = items.map(d => `
|
|
||||||
<tr style="cursor:pointer" onclick="showDeviceDetail('${d.id || d.device_id || ''}')">
|
|
||||||
<td class="font-mono text-sm">${escapeHtml(d.imei)}</td>
|
|
||||||
<td>${escapeHtml(d.name || '-')}</td>
|
|
||||||
<td>${escapeHtml(d.device_type || '-')}</td>
|
|
||||||
<td>${statusBadge(d.status)}</td>
|
|
||||||
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
|
|
||||||
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
|
|
||||||
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
|
|
||||||
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
renderPagination('devicesPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDevices');
|
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) {
|
} catch (err) {
|
||||||
showToast('加载设备列表失败: ' + err.message, 'error');
|
showToast('加载设备列表失败: ' + err.message, 'error');
|
||||||
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||||
} finally {
|
} finally {
|
||||||
hideLoading('devicesLoading');
|
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: '蓝牙' };
|
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 || '-';
|
return map[t] || t || '-';
|
||||||
}
|
}
|
||||||
|
function _locTypeBadge(t) {
|
||||||
|
if (!t) return '<span class="text-gray-600">-</span>';
|
||||||
|
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 `<span style="color:${color};font-weight:600">${label}</span>`;
|
||||||
|
}
|
||||||
|
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) {
|
function _locModeBadges(locType) {
|
||||||
const modes = [
|
const modes = [
|
||||||
{ label: 'GPS', match: ['gps','gps_4g'] },
|
{ label: 'GPS', match: ['gps','gps_4g'] },
|
||||||
@@ -1872,6 +2022,8 @@
|
|||||||
const content = _buildInfoContent('位置记录', loc, lat, lng);
|
const content = _buildInfoContent('位置记录', loc, lat, lng);
|
||||||
_focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
|
_focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
|
||||||
_focusInfoWindow.open(locationMap, [mLng, mLat]);
|
_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]));
|
_focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
|
||||||
|
|
||||||
locationMap.setCenter([mLng, mLat]);
|
locationMap.setCenter([mLng, mLat]);
|
||||||
@@ -1977,6 +2129,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Quick command sender for device detail panel ---
|
// --- 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(`
|
||||||
|
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-yellow-400"></i>广播指令 — 发送给所有在线设备</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>指令内容</label>
|
||||||
|
<input type="text" id="broadcastCmdInput" placeholder="如: GPSON#, MODE,1#, TIMER,60#" maxlength="500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">将发送给所有在线设备,离线设备自动跳过</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">常用指令快捷选择:</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='GPSON#'" title="开启GPS定位模块,持续5分钟">GPSON# 开启GPS</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='GPSOFF#'" title="关闭GPS定位模块,省电">GPSOFF# 关闭GPS</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='MODE,1#'" title="定时上报位置,按TIMER间隔">MODE,1# 定时定位</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='MODE,3#'" title="智能模式:运动时高频上报,静止时低频">MODE,3# 智能模式</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='WHERE#'" title="立即获取一次设备当前位置">WHERE# 立即定位</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='STATUS#'" title="查询电量、信号、GPS状态等">STATUS# 设备状态</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='TIMER,60#'" title="每60秒上报一次位置">TIMER,60# 间隔1分钟</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='TIMER,300#'" title="每300秒上报一次位置">TIMER,300# 间隔5分钟</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='BTON#'" title="开启蓝牙模块">BTON# 开蓝牙</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='BTSCAN,1#'" title="开启BLE信标扫描">BTSCAN,1# 开BLE扫描</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='PARAM#'" title="查询设备当前所有参数配置">PARAM# 参数查询</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='VERSION#'" title="查询设备固件版本号">VERSION# 固件版本</button>
|
||||||
|
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='RESET#'" style="color:#f87171" title="远程重启设备,慎用">RESET# 重启设备</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-5">
|
||||||
|
<button class="btn btn-primary flex-1" onclick="_doBroadcast()"><i class="fas fa-paper-plane"></i> 发送广播</button>
|
||||||
|
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function _quickCmd(deviceId, cmd, btnEl) {
|
||||||
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
|
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
|
||||||
try {
|
try {
|
||||||
@@ -2260,6 +2492,381 @@
|
|||||||
return wgs84ToGcj02(lat, lng);
|
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 = '<div style="padding:12px;color:#6b7280;font-size:12px;text-align:center">无设备</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = cachedDevices.map(d => {
|
||||||
|
const checked = _ovSelectedDevices.has(d.id) ? 'checked' : '';
|
||||||
|
const statusDot = d.status === 'online' ? '🟢' : '⚫';
|
||||||
|
return `<div style="display:flex;align-items:center;gap:4px;padding:5px 4px;color:#d1d5db;border-bottom:1px solid #374151;overflow:hidden" class="ov-dev-item" data-did="${d.id}">
|
||||||
|
<input type="checkbox" ${checked} onchange="event.stopPropagation();_ovToggleDevice(${d.id},this.checked)" style="flex:0 0 14px;width:14px;height:14px;cursor:pointer">
|
||||||
|
<span style="flex:0 0 16px;font-size:12px">${statusDot}</span>
|
||||||
|
<span style="flex:1;min-width:0;overflow:hidden;cursor:pointer" onclick="_ovLocateDevice(${d.id})">
|
||||||
|
<span style="display:block;font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.name || '-')}</span>
|
||||||
|
<span style="display:block;font-size:10px;color:#6b7280;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.imei)}</span>
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}).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: `<span style="background:${color};color:#fff;padding:2px 8px;border-radius:8px;font-size:11px;white-space:nowrap;border:2px solid ${borderColor}">${escapeHtml(labelText)}</span>`, direction: 'top', offset: new AMap.Pixel(0, -5) },
|
||||||
|
});
|
||||||
|
// Don't add to map yet — visibility controlled by _ovSyncMarkerVisibility
|
||||||
|
|
||||||
|
const statusText = isOnline ? '<span style="color:#22c55e">● 在线</span>' : '<span style="color:#6b7280">● 离线</span>';
|
||||||
|
const title = `${escapeHtml(devName || devImei)} ${statusText}<br><span style="font-size:11px;color:#9ca3af;font-family:monospace">${escapeHtml(devImei)}</span>`;
|
||||||
|
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 = `<i class="fas fa-route"></i> 轨迹 ${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 = '<i class="fas fa-route"></i> 显示轨迹';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ovToggleLP() {
|
||||||
|
_ovHideLP = !_ovHideLP;
|
||||||
|
const btn = document.getElementById('ovBtnHideLP');
|
||||||
|
if (_ovHideLP) {
|
||||||
|
btn.style.background = '#b91c1c'; btn.style.color = '#fff';
|
||||||
|
btn.innerHTML = '<i class="fas fa-eye-slash"></i> 低精度';
|
||||||
|
} else {
|
||||||
|
btn.style.background = ''; btn.style.color = '';
|
||||||
|
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
|
||||||
|
}
|
||||||
|
// Re-render track if active
|
||||||
|
if (_ovTrackMarkers.length) _ovShowTrack();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== LOCATIONS ====================
|
// ==================== LOCATIONS ====================
|
||||||
function initLocationMap() {
|
function initLocationMap() {
|
||||||
if (locationMap) return;
|
if (locationMap) return;
|
||||||
@@ -2308,12 +2915,14 @@
|
|||||||
btn.style.color = '';
|
btn.style.color = '';
|
||||||
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
|
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
|
||||||
}
|
}
|
||||||
// Re-apply to existing track markers
|
// Re-apply to existing track markers + polyline
|
||||||
_applyLowPrecisionFilter();
|
_applyLowPrecisionFilter();
|
||||||
|
// Reload table with correct pagination
|
||||||
|
loadLocationRecords(1);
|
||||||
}
|
}
|
||||||
function _isLowPrecision(locationType) {
|
function _isLowPrecision(locationType) {
|
||||||
const t = (locationType || '').toLowerCase();
|
const t = (locationType || '').toLowerCase();
|
||||||
return t.startsWith('lbs') || t.startsWith('wifi');
|
return t.startsWith('lbs');
|
||||||
}
|
}
|
||||||
function _applyLowPrecisionFilter() {
|
function _applyLowPrecisionFilter() {
|
||||||
// Toggle visibility of low-precision markers stored with _lpFlag
|
// Toggle visibility of low-precision markers stored with _lpFlag
|
||||||
@@ -2413,14 +3022,18 @@
|
|||||||
fillOpacity: isLbs ? 0.6 : 0.9,
|
fillOpacity: isLbs ? 0.6 : 0.9,
|
||||||
zIndex: 120, cursor: 'pointer',
|
zIndex: 120, cursor: 'pointer',
|
||||||
});
|
});
|
||||||
const isLP = (isLbs || isWifi) && !isFirst && !isLast;
|
const isLP = isLbs && !isFirst && !isLast;
|
||||||
marker._lpFlag = isLP;
|
marker._lpFlag = isLP;
|
||||||
if (_hideLowPrecision && isLP) marker._lpHidden = true;
|
if (_hideLowPrecision && isLP) marker._lpHidden = true;
|
||||||
else marker.setMap(locationMap);
|
else marker.setMap(locationMap);
|
||||||
const label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `第 ${i+1}/${total} 点`;
|
const label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `第 ${i+1}/${total} 点`;
|
||||||
const content = _buildInfoContent(label, loc, lat, lng);
|
const content = _buildInfoContent(label, loc, lat, lng);
|
||||||
const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
|
const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
|
||||||
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);
|
mapMarkers.push(marker);
|
||||||
mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id });
|
mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id });
|
||||||
}
|
}
|
||||||
@@ -2433,8 +3046,7 @@
|
|||||||
const filteredPath = _hideLowPrecision
|
const filteredPath = _hideLowPrecision
|
||||||
? path.filter((_, i) => {
|
? path.filter((_, i) => {
|
||||||
const lt = (locations[i]?.location_type || '').toLowerCase();
|
const lt = (locations[i]?.location_type || '').toLowerCase();
|
||||||
const isFirst = i === 0, isLast = i === locations.length - 1;
|
return !lt.startsWith('lbs');
|
||||||
return isFirst || isLast || !(lt.startsWith('lbs') || lt.startsWith('wifi'));
|
|
||||||
})
|
})
|
||||||
: path;
|
: path;
|
||||||
|
|
||||||
@@ -2521,7 +3133,11 @@
|
|||||||
const infoContent = _buildInfoContent('实时定位', loc, lat, lng);
|
const infoContent = _buildInfoContent('实时定位', loc, lat, lng);
|
||||||
const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
|
const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
|
||||||
infoWindow.open(locationMap, [mLng, mLat]);
|
infoWindow.open(locationMap, [mLng, mLat]);
|
||||||
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
|
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);
|
mapMarkers.push(marker);
|
||||||
locationMap.setCenter([mLng, mLat]);
|
locationMap.setCenter([mLng, mLat]);
|
||||||
locationMap.setZoom(15);
|
locationMap.setZoom(15);
|
||||||
@@ -2547,6 +3163,7 @@
|
|||||||
let url = `${API_BASE}/locations?page=${p}&page_size=${ps}`;
|
let url = `${API_BASE}/locations?page=${p}&page_size=${ps}`;
|
||||||
if (deviceId) url += `&device_id=${deviceId}`;
|
if (deviceId) url += `&device_id=${deviceId}`;
|
||||||
if (locType) url += `&location_type=${locType}`;
|
if (locType) url += `&location_type=${locType}`;
|
||||||
|
if (_hideLowPrecision && !locType) url += `&exclude_type=lbs`;
|
||||||
if (startTime) url += `&start_time=${startTime}T00:00:00`;
|
if (startTime) url += `&start_time=${startTime}T00:00:00`;
|
||||||
if (endTime) url += `&end_time=${endTime}T23:59:59`;
|
if (endTime) url += `&end_time=${endTime}T23:59:59`;
|
||||||
|
|
||||||
@@ -2563,10 +3180,9 @@
|
|||||||
tbody.innerHTML = items.map(l => {
|
tbody.innerHTML = items.map(l => {
|
||||||
const q = _locQuality(l);
|
const q = _locQuality(l);
|
||||||
const hasCoord = l.latitude != null && l.longitude != null;
|
const hasCoord = l.latitude != null && l.longitude != null;
|
||||||
const lpHide = _hideLowPrecision && _isLowPrecision(l.location_type);
|
return `<tr data-loc-type="${l.location_type || ''}" style="cursor:${hasCoord?'pointer':'default'}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
|
||||||
return `<tr data-loc-type="${l.location_type || ''}" style="cursor:${hasCoord?'pointer':'default'}${lpHide ? ';display:none' : ''}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
|
|
||||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="loc-sel-cb" value="${l.id}" onchange="updateLocSelCount()"></td>
|
<td onclick="event.stopPropagation()"><input type="checkbox" class="loc-sel-cb" value="${l.id}" onchange="updateLocSelCount()"></td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td>
|
<td class="font-mono text-xs">${escapeHtml(_imei(l.device_id))}</td>
|
||||||
<td>${_locTypeLabel(l.location_type)}</td>
|
<td>${_locTypeLabel(l.location_type)}</td>
|
||||||
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
|
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
|
||||||
<td>${l.longitude != null ? Number(l.longitude).toFixed(6) : '-'}</td>
|
<td>${l.longitude != null ? Number(l.longitude).toFixed(6) : '-'}</td>
|
||||||
@@ -2634,7 +3250,7 @@
|
|||||||
tbody.innerHTML = items.map(a => `
|
tbody.innerHTML = items.map(a => `
|
||||||
<tr>
|
<tr>
|
||||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="alarm-sel-cb" value="${a.id}" onchange="updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm')"></td>
|
<td onclick="event.stopPropagation()"><input type="checkbox" class="alarm-sel-cb" value="${a.id}" onchange="updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm')"></td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
|
<td class="font-mono text-xs">${escapeHtml(_imei(a.device_id))}</td>
|
||||||
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td>
|
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td>
|
||||||
<td>${({'single_fence':'<span style="color:#a855f7"><i class="fas fa-draw-polygon mr-1"></i>单围栏</span>','multi_fence':'<span style="color:#c084fc"><i class="fas fa-layer-group mr-1"></i>多围栏</span>','lbs':'<span style="color:#f97316"><i class="fas fa-broadcast-tower mr-1"></i>基站</span>','wifi':'<span style="color:#f59e0b"><i class="fas fa-wifi mr-1"></i>WiFi</span>'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')}</td>
|
<td>${({'single_fence':'<span style="color:#a855f7"><i class="fas fa-draw-polygon mr-1"></i>单围栏</span>','multi_fence':'<span style="color:#c084fc"><i class="fas fa-layer-group mr-1"></i>多围栏</span>','lbs':'<span style="color:#f97316"><i class="fas fa-broadcast-tower mr-1"></i>基站</span>','wifi':'<span style="color:#f59e0b"><i class="fas fa-wifi mr-1"></i>WiFi</span>'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')}</td>
|
||||||
<td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td>
|
<td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td>
|
||||||
@@ -2743,7 +3359,7 @@
|
|||||||
const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af';
|
const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="att-sel-cb" value="${a.id}" onchange="updateSelCount('att-sel-cb','attSelCount','btnBatchDeleteAtt')"></td>
|
<td onclick="event.stopPropagation()"><input type="checkbox" class="att-sel-cb" value="${a.id}" onchange="updateSelCount('att-sel-cb','attSelCount','btnBatchDeleteAtt')"></td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
|
<td class="font-mono text-xs">${escapeHtml(_imei(a.device_id))}</td>
|
||||||
<td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td>
|
<td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td>
|
||||||
<td style="color:${srcColor};font-size:12px">${srcLabel}</td>
|
<td style="color:${srcColor};font-size:12px">${srcLabel}</td>
|
||||||
<td class="text-xs">${locMethod} ${escapeHtml(posStr)}</td>
|
<td class="text-xs">${locMethod} ${escapeHtml(posStr)}</td>
|
||||||
@@ -2813,7 +3429,7 @@
|
|||||||
const attStr = b.attendance_type ? `<span class="${b.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'}">${attendanceTypeName(b.attendance_type)}</span>` : '-';
|
const attStr = b.attendance_type ? `<span class="${b.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'}">${attendanceTypeName(b.attendance_type)}</span>` : '-';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="bt-sel-cb" value="${b.id}" onchange="updateSelCount('bt-sel-cb','btSelCount','btnBatchDeleteBt')"></td>
|
<td onclick="event.stopPropagation()"><input type="checkbox" class="bt-sel-cb" value="${b.id}" onchange="updateSelCount('bt-sel-cb','btSelCount','btnBatchDeleteBt')"></td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(b.device_id || '-')}</td>
|
<td class="font-mono text-xs">${escapeHtml(_imei(b.device_id))}</td>
|
||||||
<td>${typeIcon} <span class="font-semibold">${typeName}</span></td>
|
<td>${typeIcon} <span class="font-semibold">${typeName}</span></td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(mac)}</td>
|
<td class="font-mono text-xs">${escapeHtml(mac)}</td>
|
||||||
<td class="text-xs">${uuidLine}</td>
|
<td class="text-xs">${uuidLine}</td>
|
||||||
@@ -2970,8 +3586,7 @@
|
|||||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="log-sel-cb" value="${r.id}" onchange="updateSelCount('log-sel-cb','logSelCount','btnBatchDeleteLog')"></td>
|
<td onclick="event.stopPropagation()"><input type="checkbox" class="log-sel-cb" value="${r.id}" onchange="updateSelCount('log-sel-cb','logSelCount','btnBatchDeleteLog')"></td>
|
||||||
<td class="font-mono text-xs">${r.id}</td>
|
<td class="font-mono text-xs">${r.id}</td>
|
||||||
<td>${typeBadge}</td>
|
<td>${typeBadge}</td>
|
||||||
<td>${r.device_id || '-'}</td>
|
<td class="font-mono text-xs">${escapeHtml(r.imei || _imei(r.device_id))}</td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(r.imei || (logDeviceMap[r.device_id] || {}).imei || '-')}</td>
|
|
||||||
<td class="text-xs" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(detail)}">${escapeHtml(detail)}</td>
|
<td class="text-xs" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(detail)}">${escapeHtml(detail)}</td>
|
||||||
<td class="font-mono text-xs">${coord}</td>
|
<td class="font-mono text-xs">${coord}</td>
|
||||||
<td class="text-xs">${escapeHtml(addr)}</td>
|
<td class="text-xs">${escapeHtml(addr)}</td>
|
||||||
@@ -4089,7 +4704,7 @@
|
|||||||
tbody.innerHTML = items.map(c => `
|
tbody.innerHTML = items.map(c => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-mono text-xs">${escapeHtml(c.id || '-')}</td>
|
<td class="font-mono text-xs">${escapeHtml(c.id || '-')}</td>
|
||||||
<td class="font-mono text-xs">${escapeHtml(c.device_id || c.imei || '-')}</td>
|
<td class="font-mono text-xs">${escapeHtml(_imei(c.device_id))}</td>
|
||||||
<td>${escapeHtml(c.command_type || '-')}</td>
|
<td>${escapeHtml(c.command_type || '-')}</td>
|
||||||
<td class="text-xs" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.command_content || '')}">${escapeHtml(truncate(c.command_content || '-', 40))}</td>
|
<td class="text-xs" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.command_content || '')}">${escapeHtml(truncate(c.command_content || '-', 40))}</td>
|
||||||
<td>${commandStatusBadge(c.status)}</td>
|
<td>${commandStatusBadge(c.status)}</td>
|
||||||
|
|||||||
@@ -235,10 +235,11 @@ class PacketBuilder:
|
|||||||
class ConnectionInfo:
|
class ConnectionInfo:
|
||||||
"""Metadata about a single device TCP connection."""
|
"""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:
|
def __init__(self, addr: Tuple[str, int]) -> None:
|
||||||
self.imei: Optional[str] = None
|
self.imei: Optional[str] = None
|
||||||
|
self.device_id: Optional[int] = None
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
self.last_activity = self.connected_at
|
self.last_activity = self.connected_at
|
||||||
@@ -254,12 +255,16 @@ class ConnectionInfo:
|
|||||||
# Helper: look up device_id from IMEI
|
# Helper: look up device_id from IMEI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _get_device_id(session, imei: str) -> Optional[int]:
|
async def _get_device_id(session, imei: str, conn_info: Optional["ConnectionInfo"] = None) -> Optional[int]:
|
||||||
"""Query the Device table and return the integer id for the given IMEI."""
|
"""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(
|
result = await session.execute(
|
||||||
select(Device.id).where(Device.imei == imei)
|
select(Device.id).where(Device.imei == imei)
|
||||||
)
|
)
|
||||||
row = result.scalar_one_or_none()
|
row = result.scalar_one_or_none()
|
||||||
|
if row is not None and conn_info is not None:
|
||||||
|
conn_info.device_id = row
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +278,7 @@ class TCPManager:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# {imei: (reader, writer, connection_info)}
|
# {imei: (reader, writer, connection_info)}
|
||||||
self.connections: Dict[str, Tuple[asyncio.StreamReader, asyncio.StreamWriter, ConnectionInfo]] = {}
|
self.connections: Dict[str, Tuple[asyncio.StreamReader, asyncio.StreamWriter, ConnectionInfo]] = {}
|
||||||
|
self._conn_lock = asyncio.Lock()
|
||||||
self._server: Optional[asyncio.AbstractServer] = None
|
self._server: Optional[asyncio.AbstractServer] = None
|
||||||
|
|
||||||
# Protocol number -> handler coroutine mapping
|
# Protocol number -> handler coroutine mapping
|
||||||
@@ -316,11 +322,22 @@ class TCPManager:
|
|||||||
conn_info = ConnectionInfo(addr)
|
conn_info = ConnectionInfo(addr)
|
||||||
logger.info("New TCP connection from %s:%d", addr[0], addr[1])
|
logger.info("New TCP connection from %s:%d", addr[0], addr[1])
|
||||||
|
|
||||||
recv_buffer = b""
|
recv_buffer = bytearray()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
idle_timeout = settings.TCP_IDLE_TIMEOUT or None
|
||||||
while True:
|
while True:
|
||||||
|
try:
|
||||||
|
if idle_timeout:
|
||||||
|
data = await asyncio.wait_for(reader.read(4096), timeout=idle_timeout)
|
||||||
|
else:
|
||||||
data = await reader.read(4096)
|
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:
|
if not data:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Connection closed by remote %s:%d (IMEI=%s)",
|
"Connection closed by remote %s:%d (IMEI=%s)",
|
||||||
@@ -371,7 +388,7 @@ class TCPManager:
|
|||||||
"Receive buffer overflow for IMEI=%s, discarding",
|
"Receive buffer overflow for IMEI=%s, discarding",
|
||||||
conn_info.imei,
|
conn_info.imei,
|
||||||
)
|
)
|
||||||
recv_buffer = b""
|
recv_buffer = bytearray()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -402,6 +419,7 @@ class TCPManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Remove connection from tracking and update the device status."""
|
"""Remove connection from tracking and update the device status."""
|
||||||
imei = conn_info.imei
|
imei = conn_info.imei
|
||||||
|
async with self._conn_lock:
|
||||||
if imei and imei in self.connections:
|
if imei and imei in self.connections:
|
||||||
# Only remove if this is still the active connection (not replaced by reconnect)
|
# Only remove if this is still the active connection (not replaced by reconnect)
|
||||||
_, stored_writer, _ = self.connections[imei]
|
_, stored_writer, _ = self.connections[imei]
|
||||||
@@ -599,6 +617,7 @@ class TCPManager:
|
|||||||
conn_info.imei = imei
|
conn_info.imei = imei
|
||||||
|
|
||||||
# Close existing connection if device reconnects
|
# Close existing connection if device reconnects
|
||||||
|
async with self._conn_lock:
|
||||||
old_conn = self.connections.get(imei)
|
old_conn = self.connections.get(imei)
|
||||||
if old_conn is not None:
|
if old_conn is not None:
|
||||||
_, old_writer, old_info = old_conn
|
_, old_writer, old_info = old_conn
|
||||||
@@ -607,7 +626,6 @@ class TCPManager:
|
|||||||
old_writer.close()
|
old_writer.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.connections[imei] = (reader, writer, conn_info)
|
self.connections[imei] = (reader, writer, conn_info)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Device login: IMEI=%s from %s:%d", imei, conn_info.addr[0], conn_info.addr[1]
|
"Device login: IMEI=%s from %s:%d", imei, conn_info.addr[0], conn_info.addr[1]
|
||||||
@@ -739,7 +757,7 @@ class TCPManager:
|
|||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is None:
|
||||||
logger.warning("Heartbeat for unknown IMEI=%s", imei)
|
logger.warning("Heartbeat for unknown IMEI=%s", imei)
|
||||||
return
|
return
|
||||||
@@ -1046,22 +1064,29 @@ class TCPManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Geocoding failed for %s IMEI=%s", location_type, imei)
|
logger.exception("Geocoding failed for %s IMEI=%s", location_type, imei)
|
||||||
|
|
||||||
# --- Reverse geocoding: coordinates -> address ---
|
# --- Reverse geocoding (run concurrently with DB store below) ---
|
||||||
address: Optional[str] = None
|
address_task = None
|
||||||
if latitude is not None and longitude is not None:
|
if latitude is not None and longitude is not None:
|
||||||
try:
|
address_task = asyncio.ensure_future(reverse_geocode(latitude, longitude))
|
||||||
address = await reverse_geocode(latitude, longitude)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is None:
|
||||||
logger.warning("Location for unknown IMEI=%s", imei)
|
logger.warning("Location for unknown IMEI=%s", imei)
|
||||||
|
if address_task:
|
||||||
|
address_task.cancel()
|
||||||
return
|
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(
|
record = LocationRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
imei=conn_info.imei,
|
imei=conn_info.imei,
|
||||||
@@ -1086,40 +1111,34 @@ class TCPManager:
|
|||||||
recorded_at=recorded_at,
|
recorded_at=recorded_at,
|
||||||
)
|
)
|
||||||
session.add(record)
|
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", {
|
ws_manager.broadcast_nonblocking("location", {
|
||||||
"imei": imei, "device_id": device_id, "location_type": location_type,
|
"imei": imei, "device_id": device_id, "location_type": location_type,
|
||||||
"latitude": latitude, "longitude": longitude, "speed": speed,
|
"latitude": latitude, "longitude": longitude, "speed": speed,
|
||||||
"address": address, "recorded_at": str(recorded_at),
|
"address": address, "recorded_at": str(recorded_at),
|
||||||
})
|
})
|
||||||
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:
|
for evt in fence_events:
|
||||||
ws_manager.broadcast_nonblocking("fence_attendance", evt)
|
ws_manager.broadcast_nonblocking("fence_attendance", evt)
|
||||||
ws_manager.broadcast_nonblocking("attendance", evt)
|
ws_manager.broadcast_nonblocking("attendance", evt)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Fence check failed for IMEI=%s", imei)
|
logger.exception(
|
||||||
|
"DB error storing %s location for IMEI=%s", location_type, imei
|
||||||
|
)
|
||||||
|
|
||||||
return address
|
return address
|
||||||
|
|
||||||
@@ -1564,7 +1583,7 @@ class TCPManager:
|
|||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is None:
|
||||||
logger.warning("Alarm for unknown IMEI=%s", imei)
|
logger.warning("Alarm for unknown IMEI=%s", imei)
|
||||||
return
|
return
|
||||||
@@ -1865,7 +1884,7 @@ class TCPManager:
|
|||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is None:
|
||||||
logger.warning("Attendance for unknown IMEI=%s", imei)
|
logger.warning("Attendance for unknown IMEI=%s", imei)
|
||||||
return attendance_type, reserved_bytes, datetime_bytes
|
return attendance_type, reserved_bytes, datetime_bytes
|
||||||
@@ -2001,7 +2020,7 @@ class TCPManager:
|
|||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is not None:
|
||||||
# Look up beacon location from beacon_configs
|
# Look up beacon location from beacon_configs
|
||||||
beacon_lat = None
|
beacon_lat = None
|
||||||
@@ -2206,7 +2225,7 @@ class TCPManager:
|
|||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is None:
|
||||||
logger.warning("BT location for unknown IMEI=%s", imei)
|
logger.warning("BT location for unknown IMEI=%s", imei)
|
||||||
return
|
return
|
||||||
@@ -2390,7 +2409,7 @@ class TCPManager:
|
|||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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:
|
if device_id is None:
|
||||||
logger.warning("Command reply for unknown IMEI=%s", imei)
|
logger.warning("Command reply for unknown IMEI=%s", imei)
|
||||||
return
|
return
|
||||||
@@ -2439,19 +2458,16 @@ class TCPManager:
|
|||||||
bool
|
bool
|
||||||
``True`` if the command was successfully written to the socket.
|
``True`` if the command was successfully written to the socket.
|
||||||
"""
|
"""
|
||||||
|
async with self._conn_lock:
|
||||||
conn = self.connections.get(imei)
|
conn = self.connections.get(imei)
|
||||||
if conn is None:
|
if conn is None:
|
||||||
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
|
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
_reader, writer, conn_info = conn
|
_reader, writer, conn_info = conn
|
||||||
|
|
||||||
# Check if the writer is still alive
|
|
||||||
if writer.is_closing():
|
if writer.is_closing():
|
||||||
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
|
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
|
||||||
del self.connections[imei]
|
del self.connections[imei]
|
||||||
return False
|
return False
|
||||||
|
|
||||||
serial = conn_info.next_serial()
|
serial = conn_info.next_serial()
|
||||||
|
|
||||||
# Build 0x80 online-command packet
|
# Build 0x80 online-command packet
|
||||||
@@ -2497,18 +2513,16 @@ class TCPManager:
|
|||||||
bool
|
bool
|
||||||
``True`` if the message was successfully written to the socket.
|
``True`` if the message was successfully written to the socket.
|
||||||
"""
|
"""
|
||||||
|
async with self._conn_lock:
|
||||||
conn = self.connections.get(imei)
|
conn = self.connections.get(imei)
|
||||||
if conn is None:
|
if conn is None:
|
||||||
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
|
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
_reader, writer, conn_info = conn
|
_reader, writer, conn_info = conn
|
||||||
|
|
||||||
if writer.is_closing():
|
if writer.is_closing():
|
||||||
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
|
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
|
||||||
del self.connections[imei]
|
del self.connections[imei]
|
||||||
return False
|
return False
|
||||||
|
|
||||||
serial = conn_info.next_serial()
|
serial = conn_info.next_serial()
|
||||||
|
|
||||||
msg_bytes = message.encode("utf-16-be")
|
msg_bytes = message.encode("utf-16-be")
|
||||||
|
|||||||
@@ -62,16 +62,21 @@ class WebSocketManager:
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
disconnected = []
|
# Send to all subscribers concurrently with timeout
|
||||||
# Snapshot dict to avoid RuntimeError from concurrent modification
|
subscribers = [(ws, topics) for ws, topics in list(self.active_connections.items()) if topic in topics]
|
||||||
for ws, topics in list(self.active_connections.items()):
|
if not subscribers:
|
||||||
if topic in topics:
|
return
|
||||||
try:
|
|
||||||
await ws.send_text(message)
|
|
||||||
except Exception:
|
|
||||||
disconnected.append(ws)
|
|
||||||
|
|
||||||
for ws in disconnected:
|
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)
|
self.active_connections.pop(ws, None)
|
||||||
|
|
||||||
def broadcast_nonblocking(self, topic: str, data: dict):
|
def broadcast_nonblocking(self, topic: str, data: dict):
|
||||||
|
|||||||
Reference in New Issue
Block a user