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

@@ -4,10 +4,10 @@ API endpoints for querying location records and device tracks.
"""
import math
from datetime import datetime
from datetime import datetime, timedelta
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 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(
"/latest/{device_id}",
response_model=APIResponse[LocationRecordResponse | None],