feat: 13个统计/聚合API + 前端同步 + 待完成功能文档

API新增:
- GET /api/system/overview 系统总览(在线率/今日统计/表大小)
- GET /api/locations/stats 位置统计(类型分布/小时趋势)
- GET /api/locations/track-summary/{id} 轨迹摘要(距离/时长/速度)
- POST /api/alarms/batch-acknowledge 批量确认告警
- GET /api/attendance/report 考勤日报表(每设备每天汇总)
- GET /api/bluetooth/stats 蓝牙统计(类型/TOP信标/RSSI分布)
- GET /api/heartbeats/stats 心跳统计(活跃设备/电量/间隔分析)
- GET /api/fences/stats 围栏统计(绑定/进出状态/今日事件)
- GET /api/fences/{id}/events 围栏进出事件历史
- GET /api/commands/stats 指令统计(成功率/类型/趋势)

API增强:
- devices/stats: 新增by_type/battery_distribution/signal_distribution
- alarms/stats: 新增today/by_source/daily_trend/top_devices
- attendance/stats: 新增today/by_source/daily_trend/by_device

前端同步:
- 仪表盘: 今日告警/考勤/定位卡片 + 在线率
- 告警页: 批量确认按钮 + 今日计数
- 考勤页: 今日计数
- 轨迹: 加载后显示距离/时长/速度摘要
- 蓝牙/围栏/指令页: 统计面板

文档: CLAUDE.md待完成功能按优先级重新规划

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-31 10:11:33 +00:00
parent b25eafc483
commit 8157f9cb52
10 changed files with 1044 additions and 51 deletions

View File

@@ -239,6 +239,93 @@ async def health():
} }
@app.get("/api/system/overview", tags=["System / 系统管理"])
async def system_overview():
"""
系统总览:设备/在线率/今日告警/今日考勤/各表记录数/运行状态。
System overview: devices, online rate, today stats, table sizes, uptime.
"""
from sqlalchemy import func, select, text
from app.models import (
Device, LocationRecord, AlarmRecord, AttendanceRecord,
BluetoothRecord, HeartbeatRecord, CommandLog, BeaconConfig, FenceConfig,
)
from app.config import now_cst
from app.websocket_manager import ws_manager
now = now_cst()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
async with async_session() as db:
# Device stats
total_devices = (await db.execute(select(func.count(Device.id)))).scalar() or 0
online_devices = (await db.execute(
select(func.count(Device.id)).where(Device.status == "online")
)).scalar() or 0
# Today counts
today_alarms = (await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.recorded_at >= today_start)
)).scalar() or 0
today_unack = (await db.execute(
select(func.count(AlarmRecord.id)).where(
AlarmRecord.recorded_at >= today_start,
AlarmRecord.acknowledged == False, # noqa: E712
)
)).scalar() or 0
today_attendance = (await db.execute(
select(func.count(AttendanceRecord.id)).where(AttendanceRecord.recorded_at >= today_start)
)).scalar() or 0
today_locations = (await db.execute(
select(func.count(LocationRecord.id)).where(LocationRecord.recorded_at >= today_start)
)).scalar() or 0
# Table sizes
table_sizes = {}
for name, model in [
("devices", Device), ("locations", LocationRecord),
("alarms", AlarmRecord), ("attendance", AttendanceRecord),
("bluetooth", BluetoothRecord), ("heartbeats", HeartbeatRecord),
("commands", CommandLog), ("beacons", BeaconConfig), ("fences", FenceConfig),
]:
cnt = (await db.execute(select(func.count(model.id)))).scalar() or 0
table_sizes[name] = cnt
# DB file size
db_size_mb = None
try:
import os
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
if os.path.exists(db_path):
db_size_mb = round(os.path.getsize(db_path) / 1024 / 1024, 2)
except Exception:
pass
return {
"code": 0,
"data": {
"devices": {
"total": total_devices,
"online": online_devices,
"offline": total_devices - online_devices,
"online_rate": round(online_devices / total_devices * 100, 1) if total_devices else 0,
},
"today": {
"alarms": today_alarms,
"unacknowledged_alarms": today_unack,
"attendance": today_attendance,
"locations": today_locations,
},
"table_sizes": table_sizes,
"connections": {
"tcp_devices": len(tcp_manager.connections),
"websocket_clients": ws_manager.connection_count,
},
"database_size_mb": db_size_mb,
},
}
@app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else []) @app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else [])
async def manual_cleanup(): async def manual_cleanup():
"""手动触发数据清理 / Manually trigger data cleanup (admin only).""" """手动触发数据清理 / Manually trigger data cleanup (admin only)."""

View File

@@ -4,11 +4,12 @@ API endpoints for alarm record queries, acknowledgement, and statistics.
""" """
import math import math
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from pydantic import BaseModel, Field
from sqlalchemy import func, select, case, extract
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write from app.dependencies import require_write
@@ -96,13 +97,19 @@ async def list_alarms(
@router.get( @router.get(
"/stats", "/stats",
response_model=APIResponse[dict], response_model=APIResponse[dict],
summary="获取报警统计 / Get alarm statistics", summary="获取报警统计(增强版)/ Get enhanced alarm statistics",
) )
async def alarm_stats(db: AsyncSession = Depends(get_db)): async def alarm_stats(
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
db: AsyncSession = Depends(get_db),
):
""" """
获取报警统计:总数、未确认数、按类型分组统计 增强版报警统计:总数、未确认数、按类型分组、按天趋势、平均响应时间、TOP10设备
Get alarm statistics: total, unacknowledged count, and breakdown by type. Enhanced alarm stats: totals, by type, daily trend, avg response time, top 10 devices.
""" """
from app.config import now_cst
now = now_cst()
# Total alarms # Total alarms
total_result = await db.execute(select(func.count(AlarmRecord.id))) total_result = await db.execute(select(func.count(AlarmRecord.id)))
total = total_result.scalar() or 0 total = total_result.scalar() or 0
@@ -121,16 +128,88 @@ async def alarm_stats(db: AsyncSession = Depends(get_db)):
) )
by_type = {row[0]: row[1] for row in type_result.all()} by_type = {row[0]: row[1] for row in type_result.all()}
# By source
source_result = await db.execute(
select(AlarmRecord.alarm_source, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_source)
)
by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
# Daily trend (last N days)
cutoff = now - timedelta(days=days)
trend_result = await db.execute(
select(
func.date(AlarmRecord.recorded_at).label("day"),
func.count(AlarmRecord.id),
)
.where(AlarmRecord.recorded_at >= cutoff)
.group_by("day")
.order_by("day")
)
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
# Today count
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.recorded_at >= today_start)
)
today_count = today_result.scalar() or 0
# Top 10 devices by alarm count
top_result = await db.execute(
select(AlarmRecord.device_id, AlarmRecord.imei, func.count(AlarmRecord.id).label("cnt"))
.group_by(AlarmRecord.device_id, AlarmRecord.imei)
.order_by(func.count(AlarmRecord.id).desc())
.limit(10)
)
top_devices = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in top_result.all()]
return APIResponse( return APIResponse(
data={ data={
"total": total, "total": total,
"unacknowledged": unacknowledged, "unacknowledged": unacknowledged,
"acknowledged": total - unacknowledged, "acknowledged": total - unacknowledged,
"today": today_count,
"by_type": by_type, "by_type": by_type,
"by_source": by_source,
"daily_trend": daily_trend,
"top_devices": top_devices,
} }
) )
class BatchAcknowledgeRequest(BaseModel):
alarm_ids: list[int] = Field(..., min_length=1, max_length=500, description="告警ID列表")
acknowledged: bool = Field(default=True, description="确认状态")
@router.post(
"/batch-acknowledge",
response_model=APIResponse[dict],
summary="批量确认告警 / Batch acknowledge alarms",
dependencies=[Depends(require_write)],
)
async def batch_acknowledge_alarms(
body: BatchAcknowledgeRequest,
db: AsyncSession = Depends(get_db),
):
"""
批量确认或取消确认告警记录最多500条。
Batch acknowledge (or un-acknowledge) alarm records (max 500).
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids))
)
records = list(result.scalars().all())
for r in records:
r.acknowledged = body.acknowledged
await db.flush()
return APIResponse(
message=f"{'确认' if body.acknowledged else '取消确认'} {len(records)} 条告警",
data={"updated": len(records), "requested": len(body.alarm_ids)},
)
@router.post( @router.post(
"/batch-delete", "/batch-delete",
response_model=APIResponse[dict], response_model=APIResponse[dict],

View File

@@ -4,10 +4,10 @@ API endpoints for attendance record queries and statistics.
""" """
import math import math
from datetime import datetime from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, select, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@@ -86,18 +86,22 @@ async def list_attendance(
@router.get( @router.get(
"/stats", "/stats",
response_model=APIResponse[dict], response_model=APIResponse[dict],
summary="获取考勤统计 / Get attendance statistics", summary="获取考勤统计(增强版)/ Get enhanced attendance statistics",
) )
async def attendance_stats( async def attendance_stats(
device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"), device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"), end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。 增强版考勤统计:总数、按类型/来源/设备分组、按天趋势、今日统计。
Get attendance statistics: total records, breakdown by type and by device. Enhanced: total, by type/source/device, daily trend, today count.
""" """
from app.config import now_cst
now = now_cst()
base_filter = [] base_filter = []
if device_id is not None: if device_id is not None:
base_filter.append(AttendanceRecord.device_id == device_id) base_filter.append(AttendanceRecord.device_id == device_id)
@@ -106,38 +110,146 @@ async def attendance_stats(
if end_time: if end_time:
base_filter.append(AttendanceRecord.recorded_at <= end_time) base_filter.append(AttendanceRecord.recorded_at <= end_time)
# Total count def _where(q):
total_q = select(func.count(AttendanceRecord.id)).where(*base_filter) if base_filter else select(func.count(AttendanceRecord.id)) return q.where(*base_filter) if base_filter else q
total_result = await db.execute(total_q)
total = total_result.scalar() or 0 # Total
total = (await db.execute(_where(select(func.count(AttendanceRecord.id))))).scalar() or 0
# By type # By type
type_q = select( type_result = await db.execute(_where(
AttendanceRecord.attendance_type, func.count(AttendanceRecord.id) select(AttendanceRecord.attendance_type, func.count(AttendanceRecord.id))
).group_by(AttendanceRecord.attendance_type) .group_by(AttendanceRecord.attendance_type)
if base_filter: ))
type_q = type_q.where(*base_filter)
type_result = await db.execute(type_q)
by_type = {row[0]: row[1] for row in type_result.all()} by_type = {row[0]: row[1] for row in type_result.all()}
# By device (top 20) # By source
device_q = select( source_result = await db.execute(_where(
AttendanceRecord.device_id, func.count(AttendanceRecord.id) select(AttendanceRecord.attendance_source, func.count(AttendanceRecord.id))
).group_by(AttendanceRecord.device_id).order_by( .group_by(AttendanceRecord.attendance_source)
func.count(AttendanceRecord.id).desc() ))
).limit(20) by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
if base_filter:
device_q = device_q.where(*base_filter)
device_result = await db.execute(device_q)
by_device = {str(row[0]): row[1] for row in device_result.all()}
return APIResponse( # By device (top 20)
data={ device_result = await db.execute(_where(
"total": total, select(AttendanceRecord.device_id, AttendanceRecord.imei, func.count(AttendanceRecord.id))
"by_type": by_type, .group_by(AttendanceRecord.device_id, AttendanceRecord.imei)
"by_device": by_device, .order_by(func.count(AttendanceRecord.id).desc())
} .limit(20)
))
by_device = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in device_result.all()]
# Daily trend (last N days)
cutoff = now - timedelta(days=days)
trend_result = await db.execute(
select(
func.date(AttendanceRecord.recorded_at).label("day"),
func.count(AttendanceRecord.id),
)
.where(AttendanceRecord.recorded_at >= cutoff)
.group_by("day").order_by("day")
) )
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
# Today
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_count = (await db.execute(
select(func.count(AttendanceRecord.id)).where(AttendanceRecord.recorded_at >= today_start)
)).scalar() or 0
return APIResponse(data={
"total": total,
"today": today_count,
"by_type": by_type,
"by_source": by_source,
"by_device": by_device,
"daily_trend": daily_trend,
})
@router.get(
"/report",
response_model=APIResponse[dict],
summary="考勤报表 / Attendance report",
)
async def attendance_report(
device_id: int | None = Query(default=None, description="设备ID (可选,不传则所有设备)"),
start_date: str = Query(..., description="开始日期 YYYY-MM-DD"),
end_date: str = Query(..., description="结束日期 YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
):
"""
考勤报表:按设备+日期聚合,返回每个设备每天的签到次数、首次签到时间、末次签到时间。
Attendance report: per device per day aggregation.
"""
from datetime import datetime as dt
try:
s_date = dt.strptime(start_date, "%Y-%m-%d")
e_date = dt.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
except ValueError:
raise HTTPException(status_code=400, detail="日期格式需为 YYYY-MM-DD")
if s_date > e_date:
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
filters = [
AttendanceRecord.recorded_at >= s_date,
AttendanceRecord.recorded_at <= e_date,
]
if device_id is not None:
filters.append(AttendanceRecord.device_id == device_id)
result = await db.execute(
select(
AttendanceRecord.device_id,
AttendanceRecord.imei,
func.date(AttendanceRecord.recorded_at).label("day"),
func.count(AttendanceRecord.id).label("punch_count"),
func.min(AttendanceRecord.recorded_at).label("first_punch"),
func.max(AttendanceRecord.recorded_at).label("last_punch"),
func.group_concat(AttendanceRecord.attendance_source).label("sources"),
)
.where(*filters)
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei, "day")
.order_by(AttendanceRecord.device_id, "day")
)
rows = result.all()
report = []
for row in rows:
report.append({
"device_id": row[0],
"imei": row[1],
"date": str(row[2]),
"punch_count": row[3],
"first_punch": str(row[4]) if row[4] else None,
"last_punch": str(row[5]) if row[5] else None,
"sources": list(set(row[6].split(","))) if row[6] else [],
})
# Summary: total days in range, devices with records, attendance rate
total_days = (e_date - s_date).days + 1
unique_devices = len({r["device_id"] for r in report})
device_days = len(report)
from app.models import Device
if device_id is not None:
total_device_count = 1
else:
total_device_count = (await db.execute(select(func.count(Device.id)))).scalar() or 1
expected_device_days = total_days * total_device_count
attendance_rate = round(device_days / expected_device_days * 100, 1) if expected_device_days else 0
return APIResponse(data={
"start_date": start_date,
"end_date": end_date,
"total_days": total_days,
"total_devices": unique_devices,
"attendance_rate": attendance_rate,
"records": report,
})
@router.get( @router.get(

View File

@@ -86,6 +86,75 @@ async def list_bluetooth_records(
) )
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="蓝牙数据统计 / Bluetooth statistics",
)
async def bluetooth_stats(
start_time: datetime | None = Query(default=None, description="开始时间"),
end_time: datetime | None = Query(default=None, description="结束时间"),
db: AsyncSession = Depends(get_db),
):
"""
蓝牙数据统计总记录数、按类型分布、按信标MAC分组TOP20、RSSI分布。
Bluetooth stats: total, by type, top beacons, RSSI distribution.
"""
from sqlalchemy import case
filters = []
if start_time:
filters.append(BluetoothRecord.recorded_at >= start_time)
if end_time:
filters.append(BluetoothRecord.recorded_at <= end_time)
def _where(q):
return q.where(*filters) if filters else q
total = (await db.execute(_where(select(func.count(BluetoothRecord.id))))).scalar() or 0
# By record_type
type_result = await db.execute(_where(
select(BluetoothRecord.record_type, func.count(BluetoothRecord.id))
.group_by(BluetoothRecord.record_type)
))
by_type = {row[0]: row[1] for row in type_result.all()}
# Top 20 beacons by record count
beacon_result = await db.execute(_where(
select(BluetoothRecord.beacon_mac, func.count(BluetoothRecord.id).label("cnt"))
.where(BluetoothRecord.beacon_mac.is_not(None))
.group_by(BluetoothRecord.beacon_mac)
.order_by(func.count(BluetoothRecord.id).desc())
.limit(20)
))
top_beacons = [{"beacon_mac": row[0], "count": row[1]} for row in beacon_result.all()]
# RSSI distribution
rssi_result = await db.execute(_where(
select(
func.sum(case(((BluetoothRecord.rssi.is_not(None)) & (BluetoothRecord.rssi >= -50), 1), else_=0)).label("strong"),
func.sum(case(((BluetoothRecord.rssi < -50) & (BluetoothRecord.rssi >= -70), 1), else_=0)).label("medium"),
func.sum(case(((BluetoothRecord.rssi < -70) & (BluetoothRecord.rssi.is_not(None)), 1), else_=0)).label("weak"),
func.sum(case((BluetoothRecord.rssi.is_(None), 1), else_=0)).label("unknown"),
)
))
rrow = rssi_result.one()
rssi_distribution = {
"strong_above_-50": int(rrow.strong or 0),
"medium_-50_-70": int(rrow.medium or 0),
"weak_below_-70": int(rrow.weak or 0),
"unknown": int(rrow.unknown or 0),
}
return APIResponse(data={
"total": total,
"by_type": by_type,
"top_beacons": top_beacons,
"rssi_distribution": rssi_distribution,
})
@router.get( @router.get(
"/device/{device_id}", "/device/{device_id}",
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]], response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],

View File

@@ -147,6 +147,74 @@ async def _send_to_device(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="指令统计 / Command statistics",
)
async def command_stats(
days: int = Query(default=7, ge=1, le=90, description="趋势天数"),
db: AsyncSession = Depends(get_db),
):
"""
指令统计:总数、按状态分布、按类型分布、成功率、按天趋势。
Command stats: total, by status, by type, success rate, daily trend.
"""
from sqlalchemy import func, select
from datetime import timedelta
from app.models import CommandLog
total = (await db.execute(select(func.count(CommandLog.id)))).scalar() or 0
# By status
status_result = await db.execute(
select(CommandLog.status, func.count(CommandLog.id))
.group_by(CommandLog.status)
)
by_status = {row[0]: row[1] for row in status_result.all()}
# By type
type_result = await db.execute(
select(CommandLog.command_type, func.count(CommandLog.id))
.group_by(CommandLog.command_type)
)
by_type = {row[0]: row[1] for row in type_result.all()}
# Success rate
sent = by_status.get("sent", 0) + by_status.get("success", 0)
failed = by_status.get("failed", 0)
total_attempted = sent + failed
success_rate = round(sent / total_attempted * 100, 1) if total_attempted else 0
# Daily trend
now = now_cst()
cutoff = now - timedelta(days=days)
trend_result = await db.execute(
select(
func.date(CommandLog.created_at).label("day"),
func.count(CommandLog.id),
)
.where(CommandLog.created_at >= cutoff)
.group_by("day").order_by("day")
)
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
# Today
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_count = (await db.execute(
select(func.count(CommandLog.id)).where(CommandLog.created_at >= today_start)
)).scalar() or 0
return APIResponse(data={
"total": total,
"today": today_count,
"by_status": by_status,
"by_type": by_type,
"success_rate": success_rate,
"daily_trend": daily_trend,
})
@router.get( @router.get(
"", "",
response_model=APIResponse[PaginatedList[CommandResponse]], response_model=APIResponse[PaginatedList[CommandResponse]],

View File

@@ -1,12 +1,15 @@
"""Fences Router - geofence management API endpoints.""" """Fences Router - geofence management API endpoints."""
import math import math
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write from app.dependencies import require_write
from app.database import get_db from app.database import get_db
from app.models import AttendanceRecord, DeviceFenceBinding, DeviceFenceState, FenceConfig
from app.schemas import ( from app.schemas import (
APIResponse, APIResponse,
DeviceFenceBindRequest, DeviceFenceBindRequest,
@@ -39,6 +42,73 @@ async def list_fences(
) )
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="围栏统计 / Fence statistics",
)
async def fence_stats(db: AsyncSession = Depends(get_db)):
"""
围栏统计:总数、活跃数、绑定设备总数、各围栏当前在内设备数、今日进出事件数。
Fence stats: totals, bindings, devices currently inside, today's events.
"""
from app.config import now_cst
now = now_cst()
total = (await db.execute(select(func.count(FenceConfig.id)))).scalar() or 0
active = (await db.execute(
select(func.count(FenceConfig.id)).where(FenceConfig.is_active == True) # noqa: E712
)).scalar() or 0
total_bindings = (await db.execute(select(func.count(DeviceFenceBinding.id)))).scalar() or 0
# Devices currently inside fences
inside_result = await db.execute(
select(
DeviceFenceState.fence_id,
func.count(DeviceFenceState.id),
)
.where(DeviceFenceState.is_inside == True) # noqa: E712
.group_by(DeviceFenceState.fence_id)
)
devices_inside = {row[0]: row[1] for row in inside_result.all()}
total_inside = sum(devices_inside.values())
# Today's fence attendance events
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_events = (await db.execute(
select(func.count(AttendanceRecord.id))
.where(
AttendanceRecord.attendance_source == "fence",
AttendanceRecord.recorded_at >= today_start,
)
)).scalar() or 0
# Per-fence summary
fence_result = await db.execute(
select(FenceConfig.id, FenceConfig.name, FenceConfig.fence_type, FenceConfig.is_active)
)
fence_summary = []
for row in fence_result.all():
fid = row[0]
fence_summary.append({
"fence_id": fid,
"name": row[1],
"type": row[2],
"is_active": bool(row[3]),
"devices_inside": devices_inside.get(fid, 0),
})
return APIResponse(data={
"total": total,
"active": active,
"inactive": total - active,
"total_bindings": total_bindings,
"total_devices_inside": total_inside,
"today_events": today_events,
"fences": fence_summary,
})
@router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]]) @router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]])
async def get_all_active(db: AsyncSession = Depends(get_db)): async def get_all_active(db: AsyncSession = Depends(get_db)):
fences = await fence_service.get_all_active_fences(db) fences = await fence_service.get_all_active_fences(db)
@@ -74,6 +144,73 @@ async def delete_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
return APIResponse(message="Fence deleted") return APIResponse(message="Fence deleted")
@router.get(
"/{fence_id}/events",
response_model=APIResponse[PaginatedList[dict]],
summary="围栏进出事件历史 / Fence events history",
)
async def fence_events(
fence_id: int,
start_time: datetime | None = Query(default=None, description="开始时间"),
end_time: datetime | None = Query(default=None, description="结束时间"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""
获取围栏的进出事件历史(来源为 fence 的考勤记录)。
Get fence entry/exit events (attendance records with source=fence).
"""
fence = await fence_service.get_fence(db, fence_id)
if fence is None:
raise HTTPException(status_code=404, detail="Fence not found")
from sqlalchemy.dialects.sqlite import JSON as _JSON
filters = [
AttendanceRecord.attendance_source == "fence",
]
if start_time:
filters.append(AttendanceRecord.recorded_at >= start_time)
if end_time:
filters.append(AttendanceRecord.recorded_at <= end_time)
# Filter by fence_id in lbs_data JSON (fence_auto records store fence_id there)
# Since SQLite JSON support is limited, we use LIKE for the fence_id match
filters.append(AttendanceRecord.lbs_data.like(f'%"fence_id": {fence_id}%'))
count_q = select(func.count(AttendanceRecord.id)).where(*filters)
total = (await db.execute(count_q)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(AttendanceRecord)
.where(*filters)
.order_by(AttendanceRecord.recorded_at.desc())
.offset(offset).limit(page_size)
)
records = result.scalars().all()
items = []
for r in records:
items.append({
"id": r.id,
"device_id": r.device_id,
"imei": r.imei,
"event_type": r.attendance_type,
"latitude": r.latitude,
"longitude": r.longitude,
"address": r.address,
"recorded_at": str(r.recorded_at),
})
return APIResponse(data=PaginatedList(
items=items,
total=total, page=page, page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Device-Fence Binding endpoints # Device-Fence Binding endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -73,6 +73,99 @@ async def list_heartbeats(
) )
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="心跳统计 / Heartbeat statistics",
)
async def heartbeat_stats(
hours: int = Query(default=24, ge=1, le=168, description="统计时间范围(小时)"),
db: AsyncSession = Depends(get_db),
):
"""
心跳数据统计:总记录数、活跃设备数、平均电量、按设备心跳间隔分析。
Heartbeat stats: total, active devices, avg battery, interval analysis.
"""
from app.config import now_cst
from app.models import Device
from datetime import timedelta
now = now_cst()
cutoff = now - timedelta(hours=hours)
# Total in range
total = (await db.execute(
select(func.count(HeartbeatRecord.id)).where(HeartbeatRecord.created_at >= cutoff)
)).scalar() or 0
# Unique devices with heartbeats in range
active_devices = (await db.execute(
select(func.count(func.distinct(HeartbeatRecord.device_id)))
.where(HeartbeatRecord.created_at >= cutoff)
)).scalar() or 0
# Total registered devices
total_devices = (await db.execute(select(func.count(Device.id)))).scalar() or 0
# Avg battery and signal in range
avg_result = await db.execute(
select(
func.avg(HeartbeatRecord.battery_level),
func.avg(HeartbeatRecord.gsm_signal),
).where(HeartbeatRecord.created_at >= cutoff)
)
avg_row = avg_result.one()
avg_battery = round(float(avg_row[0]), 1) if avg_row[0] else None
avg_signal = round(float(avg_row[1]), 1) if avg_row[1] else None
# Per-device heartbeat count in range (for interval estimation)
# Devices with < expected heartbeats may be anomalous
per_device_result = await db.execute(
select(
HeartbeatRecord.device_id,
HeartbeatRecord.imei,
func.count(HeartbeatRecord.id).label("hb_count"),
func.min(HeartbeatRecord.created_at).label("first_hb"),
func.max(HeartbeatRecord.created_at).label("last_hb"),
)
.where(HeartbeatRecord.created_at >= cutoff)
.group_by(HeartbeatRecord.device_id, HeartbeatRecord.imei)
.order_by(func.count(HeartbeatRecord.id).desc())
)
device_intervals = []
anomalous_devices = []
for row in per_device_result.all():
hb_count = row[2]
first_hb = row[3]
last_hb = row[4]
if hb_count > 1 and first_hb and last_hb:
span_min = (last_hb - first_hb).total_seconds() / 60
avg_interval_min = round(span_min / (hb_count - 1), 1) if hb_count > 1 else 0
else:
avg_interval_min = 0
entry = {
"device_id": row[0], "imei": row[1],
"heartbeat_count": hb_count,
"avg_interval_minutes": avg_interval_min,
}
device_intervals.append(entry)
# Flag devices with very few heartbeats (expected ~12/h * hours)
if hb_count < max(1, hours * 2):
anomalous_devices.append(entry)
return APIResponse(data={
"period_hours": hours,
"total_heartbeats": total,
"active_devices": active_devices,
"total_devices": total_devices,
"inactive_devices": total_devices - active_devices,
"avg_battery_level": avg_battery,
"avg_gsm_signal": avg_signal,
"device_intervals": device_intervals[:20],
"anomalous_devices": anomalous_devices[:10],
})
@router.post( @router.post(
"/batch-delete", "/batch-delete",
response_model=APIResponse[dict], response_model=APIResponse[dict],

View File

@@ -4,10 +4,10 @@ API endpoints for querying location records and device tracks.
""" """
import math import math
from datetime import datetime from datetime import datetime, timedelta
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select, delete from sqlalchemy import func, select, delete, case, extract
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write from app.dependencies import require_write
@@ -63,6 +63,154 @@ async def list_locations(
) )
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="位置数据统计 / Location statistics",
)
async def location_stats(
device_id: int | None = Query(default=None, description="设备ID (可选)"),
start_time: datetime | None = Query(default=None, description="开始时间"),
end_time: datetime | None = Query(default=None, description="结束时间"),
db: AsyncSession = Depends(get_db),
):
"""
位置数据统计:总记录数、按定位类型分布、有坐标率、按小时分布(24h)。
Location statistics: total, by type, coordinate rate, hourly distribution.
"""
filters = []
if device_id is not None:
filters.append(LocationRecord.device_id == device_id)
if start_time:
filters.append(LocationRecord.recorded_at >= start_time)
if end_time:
filters.append(LocationRecord.recorded_at <= end_time)
where = filters if filters else []
# Total
q = select(func.count(LocationRecord.id))
if where:
q = q.where(*where)
total = (await db.execute(q)).scalar() or 0
# With coordinates
q2 = select(func.count(LocationRecord.id)).where(
LocationRecord.latitude.is_not(None), LocationRecord.longitude.is_not(None)
)
if where:
q2 = q2.where(*where)
with_coords = (await db.execute(q2)).scalar() or 0
# By type
q3 = select(LocationRecord.location_type, func.count(LocationRecord.id)).group_by(LocationRecord.location_type)
if where:
q3 = q3.where(*where)
type_result = await db.execute(q3)
by_type = {row[0]: row[1] for row in type_result.all()}
# Hourly distribution (hour 0-23)
q4 = select(
extract("hour", LocationRecord.recorded_at).label("hour"),
func.count(LocationRecord.id),
).group_by("hour").order_by("hour")
if where:
q4 = q4.where(*where)
hour_result = await db.execute(q4)
hourly = {int(row[0]): row[1] for row in hour_result.all()}
return APIResponse(data={
"total": total,
"with_coordinates": with_coords,
"without_coordinates": total - with_coords,
"coordinate_rate": round(with_coords / total * 100, 1) if total else 0,
"by_type": by_type,
"hourly_distribution": hourly,
})
@router.get(
"/track-summary/{device_id}",
response_model=APIResponse[dict],
summary="轨迹摘要 / Track summary",
)
async def track_summary(
device_id: int,
start_time: datetime = Query(..., description="开始时间"),
end_time: datetime = Query(..., description="结束时间"),
db: AsyncSession = Depends(get_db),
):
"""
轨迹统计摘要:总距离(km)、运动时长、最高速度、平均速度、轨迹点数。
Track summary: total distance, duration, max/avg speed, point count.
"""
import math as _math
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if start_time >= end_time:
raise HTTPException(status_code=400, detail="start_time must be before end_time")
records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=50000)
if not records:
return APIResponse(data={
"device_id": device_id,
"point_count": 0,
"total_distance_km": 0,
"duration_minutes": 0,
"max_speed_kmh": 0,
"avg_speed_kmh": 0,
"by_type": {},
})
# Haversine distance calculation
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0 # km
dlat = _math.radians(lat2 - lat1)
dlon = _math.radians(lon2 - lon1)
a = _math.sin(dlat / 2) ** 2 + _math.cos(_math.radians(lat1)) * _math.cos(_math.radians(lat2)) * _math.sin(dlon / 2) ** 2
return R * 2 * _math.atan2(_math.sqrt(a), _math.sqrt(1 - a))
total_distance = 0.0
max_speed = 0.0
speeds = []
type_counts: dict[str, int] = {}
prev = None
for r in records:
t = r.location_type or "unknown"
type_counts[t] = type_counts.get(t, 0) + 1
if r.speed is not None and r.speed > max_speed:
max_speed = r.speed
if prev is not None and r.latitude is not None and r.longitude is not None and prev.latitude is not None and prev.longitude is not None:
d = _haversine(prev.latitude, prev.longitude, r.latitude, r.longitude)
total_distance += d
if r.latitude is not None and r.longitude is not None:
prev = r
first_time = records[0].recorded_at
last_time = records[-1].recorded_at
duration_min = (last_time - first_time).total_seconds() / 60 if last_time > first_time else 0
avg_speed = (total_distance / (duration_min / 60)) if duration_min > 0 else 0
return APIResponse(data={
"device_id": device_id,
"point_count": len(records),
"total_distance_km": round(total_distance, 2),
"duration_minutes": round(duration_min, 1),
"max_speed_kmh": round(max_speed, 1),
"avg_speed_kmh": round(avg_speed, 1),
"start_time": str(first_time),
"end_time": str(last_time),
"by_type": type_counts,
})
@router.get( @router.get(
"/latest/{device_id}", "/latest/{device_id}",
response_model=APIResponse[LocationRecordResponse | None], response_model=APIResponse[LocationRecordResponse | None],

View File

@@ -289,12 +289,10 @@ async def batch_delete_devices(
async def get_device_stats(db: AsyncSession) -> dict: async def get_device_stats(db: AsyncSession) -> dict:
""" """
获取设备统计信息 / Get device statistics. 获取设备统计信息(增强版)/ Get enhanced device statistics.
Returns Returns dict with total/online/offline counts, online_rate,
------- by_type breakdown, battery/signal distribution.
dict
{"total": int, "online": int, "offline": int}
""" """
total_result = await db.execute(select(func.count(Device.id))) total_result = await db.execute(select(func.count(Device.id)))
total = total_result.scalar() or 0 total = total_result.scalar() or 0
@@ -303,11 +301,57 @@ async def get_device_stats(db: AsyncSession) -> dict:
select(func.count(Device.id)).where(Device.status == "online") select(func.count(Device.id)).where(Device.status == "online")
) )
online = online_result.scalar() or 0 online = online_result.scalar() or 0
offline = total - online offline = total - online
# By device_type
type_result = await db.execute(
select(Device.device_type, func.count(Device.id))
.group_by(Device.device_type)
.order_by(func.count(Device.id).desc())
)
by_type = {row[0] or "unknown": row[1] for row in type_result.all()}
# Battery distribution: 0-20, 20-50, 50-100, unknown
from sqlalchemy import case
battery_result = await db.execute(
select(
func.sum(case((Device.battery_level < 20, 1), else_=0)).label("low"),
func.sum(case(((Device.battery_level >= 20) & (Device.battery_level < 50), 1), else_=0)).label("medium"),
func.sum(case((Device.battery_level >= 50, 1), else_=0)).label("high"),
func.sum(case((Device.battery_level.is_(None), 1), else_=0)).label("unknown"),
)
)
brow = battery_result.one()
battery_distribution = {
"low_0_20": int(brow.low or 0),
"medium_20_50": int(brow.medium or 0),
"high_50_100": int(brow.high or 0),
"unknown": int(brow.unknown or 0),
}
# Signal distribution: 0=none, 1-10=weak, 11-20=medium, 21-31=strong
signal_result = await db.execute(
select(
func.sum(case(((Device.gsm_signal.is_not(None)) & (Device.gsm_signal <= 10), 1), else_=0)).label("weak"),
func.sum(case(((Device.gsm_signal > 10) & (Device.gsm_signal <= 20), 1), else_=0)).label("medium"),
func.sum(case((Device.gsm_signal > 20, 1), else_=0)).label("strong"),
func.sum(case((Device.gsm_signal.is_(None), 1), else_=0)).label("unknown"),
)
)
srow = signal_result.one()
signal_distribution = {
"weak_0_10": int(srow.weak or 0),
"medium_11_20": int(srow.medium or 0),
"strong_21_31": int(srow.strong or 0),
"unknown": int(srow.unknown or 0),
}
return { return {
"total": total, "total": total,
"online": online, "online": online,
"offline": offline, "offline": offline,
"online_rate": round(online / total * 100, 1) if total else 0,
"by_type": by_type,
"battery_distribution": battery_distribution,
"signal_distribution": signal_distribution,
} }

View File

@@ -270,6 +270,24 @@
<span class="text-xs text-gray-500" id="dashConnectedHint"></span> <span class="text-xs text-gray-500" id="dashConnectedHint"></span>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-bell text-red-400 mr-1"></i>今日告警</p>
<p class="text-2xl font-bold text-red-400" id="dashTodayAlarms">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-user-check text-cyan-400 mr-1"></i>今日考勤</p>
<p class="text-2xl font-bold text-cyan-400" id="dashTodayAttendance">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-map-pin text-blue-400 mr-1"></i>今日定位</p>
<p class="text-2xl font-bold text-blue-400" id="dashTodayLocations">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-percentage text-green-400 mr-1"></i>在线率</p>
<p class="text-2xl font-bold text-green-400" id="dashOnlineRate">-</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="stat-card"> <div class="stat-card">
@@ -522,6 +540,7 @@
<div class="stat-card"> <div class="stat-card">
<p class="text-gray-400 text-sm">告警总数</p> <p class="text-gray-400 text-sm">告警总数</p>
<p class="text-2xl font-bold mt-1" id="alarmStatTotal">-</p> <p class="text-2xl font-bold mt-1" id="alarmStatTotal">-</p>
<p class="text-xs text-gray-500 mt-1" id="alarmStatToday"></p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<p class="text-gray-400 text-sm">未确认</p> <p class="text-gray-400 text-sm">未确认</p>
@@ -563,6 +582,7 @@
<button class="btn btn-primary" onclick="loadAlarms()"><i class="fas fa-search"></i> 查询</button> <button class="btn btn-primary" onclick="loadAlarms()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadAlarms()"><i class="fas fa-sync-alt"></i> 刷新</button> <button class="btn btn-secondary" onclick="loadAlarms()"><i class="fas fa-sync-alt"></i> 刷新</button>
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteAlarm" onclick="batchDeleteSelectedAlarms()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="alarmSelCount">0</span>)</button> <button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteAlarm" onclick="batchDeleteSelectedAlarms()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="alarmSelCount">0</span>)</button>
<button class="btn btn-success" id="btnBatchAckAlarm" onclick="batchAcknowledgeAlarms()" disabled><i class="fas fa-check-double"></i> 批量确认 (<span id="alarmAckCount">0</span>)</button>
</div> </div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative"> <div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="alarmsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div> <div id="alarmsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
@@ -570,7 +590,7 @@
<table> <table>
<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);updateSelCount('alarm-sel-cb','alarmAckCount','btnBatchAckAlarm')" title="全选当前页"></th>
<th>IMEI</th> <th>IMEI</th>
<th>类型</th> <th>类型</th>
<th>来源</th> <th>来源</th>
@@ -613,6 +633,7 @@
<div class="stat-card"> <div class="stat-card">
<p class="text-gray-400 text-sm">总记录数</p> <p class="text-gray-400 text-sm">总记录数</p>
<p class="text-2xl font-bold mt-1" id="attStatTotal">-</p> <p class="text-2xl font-bold mt-1" id="attStatTotal">-</p>
<p class="text-xs text-gray-500 mt-1" id="attStatToday"></p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<p class="text-gray-400 text-sm">签到次数</p> <p class="text-gray-400 text-sm">签到次数</p>
@@ -701,6 +722,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fab fa-bluetooth-b text-blue-400 mr-1"></i>总记录</p>
<p class="text-2xl font-bold" id="btStatTotal">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-fingerprint text-purple-400 mr-1"></i>打卡</p>
<p class="text-2xl font-bold text-purple-400" id="btStatPunch">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-map-marker-alt text-cyan-400 mr-1"></i>定位</p>
<p class="text-2xl font-bold text-cyan-400" id="btStatLocation">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-broadcast-tower text-green-400 mr-1"></i>信标数</p>
<p class="text-2xl font-bold text-green-400" id="btStatBeacons">-</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<select id="btDeviceFilter" style="width:180px"> <select id="btDeviceFilter" style="width:180px">
<option value="">全部设备</option> <option value="">全部设备</option>
@@ -849,6 +888,24 @@
</div> </div>
<!-- Tab Content: Fence List + Map --> <!-- Tab Content: Fence List + Map -->
<div id="fenceTabContentList" style="display:flex;flex-direction:column;flex:1;overflow:hidden"> <div id="fenceTabContentList" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
<div class="grid grid-cols-4 gap-3 mb-3" id="fenceStatsRow">
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">总围栏</p>
<p class="text-lg font-bold" id="fenceStatTotal">-</p>
</div>
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">已启用</p>
<p class="text-lg font-bold text-green-400" id="fenceStatActive">-</p>
</div>
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">绑定设备</p>
<p class="text-lg font-bold text-cyan-400" id="fenceStatBindings">-</p>
</div>
<div style="background:#1f2937;border:1px solid #374151;border-radius:8px;padding:10px 14px;text-align:center">
<p class="text-gray-500" style="font-size:11px">今日事件</p>
<p class="text-lg font-bold text-yellow-400" id="fenceStatEvents">-</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-3"> <div class="flex flex-wrap items-center gap-3 mb-3">
<button class="btn btn-primary" onclick="loadFences()"><i class="fas fa-sync-alt"></i> 刷新</button> <button class="btn btn-primary" onclick="loadFences()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1;display:flex;gap:6px;max-width:400px"> <div style="flex:1;display:flex;gap:6px;max-width:400px">
@@ -1030,6 +1087,25 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4" id="cmdStatsRow">
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-terminal text-blue-400 mr-1"></i>指令总数</p>
<p class="text-2xl font-bold" id="cmdStatTotal">-</p>
<p class="text-xs text-gray-500 mt-1" id="cmdStatToday"></p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-check-circle text-green-400 mr-1"></i>成功率</p>
<p class="text-2xl font-bold text-green-400" id="cmdStatRate">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-paper-plane text-cyan-400 mr-1"></i>已发送</p>
<p class="text-2xl font-bold text-cyan-400" id="cmdStatSent">-</p>
</div>
<div class="stat-card" style="padding:16px;">
<p class="text-gray-400 text-xs mb-1"><i class="fas fa-times-circle text-red-400 mr-1"></i>失败</p>
<p class="text-2xl font-bold text-red-400" id="cmdStatFailed">-</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<h3 class="text-lg font-semibold"><i class="fas fa-history mr-2 text-gray-400"></i>指令历史</h3> <h3 class="text-lg font-semibold"><i class="fas fa-history mr-2 text-gray-400"></i>指令历史</h3>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -1314,11 +1390,11 @@
case 'locations': initLocationMap(); loadDeviceSelectors(); break; case 'locations': initLocationMap(); loadDeviceSelectors(); break;
case 'alarms': loadAlarmStats(); loadAlarms(); loadDeviceSelectors(); break; case 'alarms': loadAlarmStats(); loadAlarms(); loadDeviceSelectors(); break;
case 'attendance': loadAttendanceStats(); loadAttendance(); loadDeviceSelectors(); break; case 'attendance': loadAttendanceStats(); loadAttendance(); loadDeviceSelectors(); break;
case 'bluetooth': loadBluetooth(); loadDeviceSelectors(); break; case 'bluetooth': loadBluetoothStats(); loadBluetooth(); loadDeviceSelectors(); break;
case 'beacons': loadBeacons(); break; case 'beacons': loadBeacons(); break;
case 'fences': initFenceMap(); loadFences(); break; case 'fences': initFenceMap(); loadFenceStats(); loadFences(); break;
case 'datalog': loadDataLogStats(); loadDataLog(); loadDeviceSelectors(); break; case 'datalog': loadDataLogStats(); loadDataLog(); loadDeviceSelectors(); break;
case 'commands': loadCommands(); loadDeviceSelectors(); break; case 'commands': loadCommandStats(); loadCommands(); loadDeviceSelectors(); break;
} }
} }
@@ -1589,10 +1665,11 @@
// ==================== DASHBOARD ==================== // ==================== DASHBOARD ====================
async function loadDashboard() { async function loadDashboard() {
try { try {
const [deviceStats, alarmStats, health] = await Promise.allSettled([ const [deviceStats, alarmStats, health, overview] = await Promise.allSettled([
apiCall(`${API_BASE}/devices/stats`), apiCall(`${API_BASE}/devices/stats`),
apiCall(`${API_BASE}/alarms/stats`), apiCall(`${API_BASE}/alarms/stats`),
apiCall('/health'), apiCall('/health'),
apiCall(`${API_BASE}/system/overview`),
]); ]);
if (deviceStats.status === 'fulfilled') { if (deviceStats.status === 'fulfilled') {
@@ -1622,6 +1699,15 @@
document.getElementById('dashSystemStatus').textContent = '无法连接'; document.getElementById('dashSystemStatus').textContent = '无法连接';
} }
if (overview.status === 'fulfilled') {
const ov = overview.value;
animateCounter('dashTodayAlarms', ov.today?.alarms || 0);
animateCounter('dashTodayAttendance', ov.today?.attendance || 0);
animateCounter('dashTodayLocations', ov.today?.locations || 0);
const rate = ov.devices?.online_rate;
document.getElementById('dashOnlineRate').textContent = rate != null ? rate + '%' : '-';
}
// Update last-refreshed timestamp // Update last-refreshed timestamp
const upd = document.getElementById('dashLastUpdated'); const upd = document.getElementById('dashLastUpdated');
if (upd) upd.textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); if (upd) upd.textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
@@ -3063,6 +3149,17 @@
const legend = document.getElementById('mapLegend'); const legend = document.getElementById('mapLegend');
if (legend) legend.style.display = 'block'; if (legend) legend.style.display = 'block';
showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`); showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`);
// Load track summary
try {
const summary = await apiCall(`${API_BASE}/locations/track-summary/${deviceId}?start_time=${startTime}T00:00:00&end_time=${endTime}T23:59:59`);
if (summary && summary.point_count > 0) {
const dist = summary.total_distance_km != null ? summary.total_distance_km.toFixed(2) + 'km' : '-';
const dur = summary.duration_minutes != null ? Math.round(summary.duration_minutes) + '分钟' : '-';
const spd = summary.avg_speed_kmh != null ? summary.avg_speed_kmh.toFixed(1) + 'km/h' : '-';
showToast(`轨迹摘要: ${summary.point_count}点, ${dist}, ${dur}, 均速${spd}`, 'info');
}
} catch(e) { console.warn('Track summary failed:', e); }
} catch (err) { } catch (err) {
showToast('加载轨迹失败: ' + err.message, 'error'); showToast('加载轨迹失败: ' + err.message, 'error');
} }
@@ -3215,6 +3312,8 @@
document.getElementById('alarmStatTotal').textContent = stats.total || 0; document.getElementById('alarmStatTotal').textContent = stats.total || 0;
document.getElementById('alarmStatUnack').textContent = stats.unacknowledged || 0; document.getElementById('alarmStatUnack').textContent = stats.unacknowledged || 0;
document.getElementById('alarmStatAck').textContent = stats.acknowledged || 0; document.getElementById('alarmStatAck').textContent = stats.acknowledged || 0;
const alarmTodayEl = document.getElementById('alarmStatToday');
if (alarmTodayEl) alarmTodayEl.textContent = `今日 ${stats.today || 0}`;
renderAlarmDoughnut('alarmTypeChart', stats.by_type, true); renderAlarmDoughnut('alarmTypeChart', stats.by_type, true);
} catch (err) { } catch (err) {
console.error('Failed to load alarm stats:', err); console.error('Failed to load alarm stats:', err);
@@ -3249,7 +3348,7 @@
} else { } else {
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');updateSelCount('alarm-sel-cb','alarmAckCount','btnBatchAckAlarm')"></td>
<td class="font-mono text-xs">${escapeHtml(_imei(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>
@@ -3301,6 +3400,24 @@
} }
} }
async function batchAcknowledgeAlarms() {
const cbs = document.querySelectorAll('.alarm-sel-cb:checked');
if (!cbs.length) return;
const ids = [...cbs].map(c => parseInt(c.value));
if (!confirm(`确认批量确认 ${ids.length} 条告警?`)) return;
try {
await apiCall(`${API_BASE}/alarms/batch-acknowledge`, {
method: 'POST',
body: JSON.stringify({ alarm_ids: ids, acknowledged: true }),
});
showToast(`已批量确认 ${ids.length} 条告警`, 'success');
loadAlarms();
loadAlarmStats();
} catch (err) {
showToast('批量确认失败: ' + err.message, 'error');
}
}
// ==================== ATTENDANCE ==================== // ==================== ATTENDANCE ====================
async function loadAttendanceStats() { async function loadAttendanceStats() {
try { try {
@@ -3318,6 +3435,8 @@
document.getElementById('attStatCheckOut').textContent = stats.by_type?.clock_out || stats.check_out || 0; document.getElementById('attStatCheckOut').textContent = stats.by_type?.clock_out || stats.check_out || 0;
const other = stats.total - (stats.by_type?.clock_in || 0) - (stats.by_type?.clock_out || 0); const other = stats.total - (stats.by_type?.clock_in || 0) - (stats.by_type?.clock_out || 0);
document.getElementById('attStatOther').textContent = other > 0 ? other : 0; document.getElementById('attStatOther').textContent = other > 0 ? other : 0;
const attTodayEl = document.getElementById('attStatToday');
if (attTodayEl) attTodayEl.textContent = `今日 ${stats.today || 0}`;
} catch (err) { } catch (err) {
console.error('Failed to load attendance stats:', err); console.error('Failed to load attendance stats:', err);
} }
@@ -3393,6 +3512,18 @@
} }
// ==================== BLUETOOTH ==================== // ==================== BLUETOOTH ====================
async function loadBluetoothStats() {
try {
const stats = await apiCall(`${API_BASE}/bluetooth/stats`);
document.getElementById('btStatTotal').textContent = stats.total || 0;
document.getElementById('btStatPunch').textContent = stats.by_type?.punch || 0;
document.getElementById('btStatLocation').textContent = stats.by_type?.location || 0;
document.getElementById('btStatBeacons').textContent = stats.top_beacons?.length || 0;
} catch (err) {
console.error('Failed to load bluetooth stats:', err);
}
}
async function loadBluetooth(page) { async function loadBluetooth(page) {
if (page) pageState.bluetooth.page = page; if (page) pageState.bluetooth.page = page;
const p = pageState.bluetooth.page; const p = pageState.bluetooth.page;
@@ -3772,6 +3903,18 @@
} }
} }
async function loadFenceStats() {
try {
const stats = await apiCall(`${API_BASE}/fences/stats`);
document.getElementById('fenceStatTotal').textContent = stats.total || 0;
document.getElementById('fenceStatActive').textContent = stats.active || 0;
document.getElementById('fenceStatBindings').textContent = stats.total_bindings || 0;
document.getElementById('fenceStatEvents').textContent = stats.today_events || 0;
} catch (err) {
console.error('Failed to load fence stats:', err);
}
}
async function loadFences() { async function loadFences() {
showLoading('fencesLoading'); showLoading('fencesLoading');
try { try {
@@ -4681,6 +4824,19 @@
} }
} }
async function loadCommandStats() {
try {
const stats = await apiCall(`${API_BASE}/commands/stats`);
document.getElementById('cmdStatTotal').textContent = stats.total || 0;
document.getElementById('cmdStatToday').textContent = `今日 ${stats.today || 0}`;
document.getElementById('cmdStatRate').textContent = (stats.success_rate || 0) + '%';
document.getElementById('cmdStatSent').textContent = (stats.by_status?.sent || 0) + (stats.by_status?.success || 0);
document.getElementById('cmdStatFailed').textContent = stats.by_status?.failed || 0;
} catch (err) {
console.error('Failed to load command stats:', err);
}
}
async function loadCommands(page) { async function loadCommands(page) {
if (page) pageState.commands.page = page; if (page) pageState.commands.page = page;
const p = pageState.commands.page; const p = pageState.commands.page;