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:
2026-03-31 09:41:09 +00:00
parent b970b78136
commit b25eafc483
12 changed files with 1030 additions and 244 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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