feat: 围栏Tab布局重构、低精度过滤、蓝牙考勤去重、考勤删除API
- 围栏管理页面Tab移至顶部,设备绑定Tab隐藏地图全屏展示绑定矩阵 - 位置追踪新增"低精度"按钮,隐藏LBS/WiFi点(地图+折线+表格联动) - 移除LBS/WiFi精度半径圆圈,仅通过标记颜色区分定位类型 - 蓝牙打卡(0xB2)自动创建考勤记录,含去重和WebSocket广播 - 新增考勤批量删除和单条删除API - fence_checker补充json导入 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
559
CLAUDE.md
559
CLAUDE.md
@@ -1,559 +0,0 @@
|
|||||||
# Badge Admin - KKS P240/P241 蓝牙工牌管理系统
|
|
||||||
|
|
||||||
## 项目概览
|
|
||||||
|
|
||||||
KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio TCP 服务器。
|
|
||||||
支持设备管理、实时定位、告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
/home/gpsystem/
|
|
||||||
├── run.py # 启动脚本 (uvicorn)
|
|
||||||
├── .env.example # 环境变量模板 (复制为 .env 使用)
|
|
||||||
├── requirements.txt # Python 依赖
|
|
||||||
├── frpc.toml # FRP 客户端配置 (TCP隧道)
|
|
||||||
├── badge_admin.db # SQLite 数据库
|
|
||||||
├── server.log # 服务器日志
|
|
||||||
│
|
|
||||||
├── app/
|
|
||||||
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
|
|
||||||
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
|
|
||||||
│ ├── database.py # SQLAlchemy async 数据库连接
|
|
||||||
│ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin)
|
|
||||||
│ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取)
|
|
||||||
│ ├── websocket_manager.py # WebSocket 连接管理器 (topic订阅, 实时广播)
|
|
||||||
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog, ApiKey)
|
|
||||||
│ ├── schemas.py # Pydantic 请求/响应模型
|
|
||||||
│ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化
|
|
||||||
│ ├── geocoding.py # 高德地理编码服务 (基站/WiFi → 经纬度 + 经纬度 → 地址)
|
|
||||||
│ │
|
|
||||||
│ ├── protocol/
|
|
||||||
│ │ ├── constants.py # 协议常量 (协议号、告警类型snake_case、信号等级等)
|
|
||||||
│ │ ├── parser.py # 二进制协议解析器
|
|
||||||
│ │ ├── builder.py # 响应包构建器
|
|
||||||
│ │ └── crc.py # CRC-ITU (CRC-16/X-25, 多项式 0x8408)
|
|
||||||
│ │
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── device_service.py # 设备 CRUD
|
|
||||||
│ │ ├── command_service.py # 指令日志 CRUD
|
|
||||||
│ │ ├── location_service.py # 位置记录查询
|
|
||||||
│ │ ├── beacon_service.py # 蓝牙信标 CRUD
|
|
||||||
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
|
|
||||||
│ │
|
|
||||||
│ ├── routers/
|
|
||||||
│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete, /all-latest-locations)
|
|
||||||
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch)
|
|
||||||
│ │ ├── locations.py # /api/locations (含 /latest, /batch-latest, /track, /{id})
|
|
||||||
│ │ ├── alarms.py # /api/alarms (含 acknowledge, alarm_source过滤)
|
|
||||||
│ │ ├── attendance.py # /api/attendance (含 /stats, /{id})
|
|
||||||
│ │ ├── bluetooth.py # /api/bluetooth (含 beacon_mac过滤, /{id})
|
|
||||||
│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询)
|
|
||||||
│ │ ├── beacons.py # /api/beacons (信标管理 CRUD)
|
|
||||||
│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only)
|
|
||||||
│ │ └── ws.py # /ws (WebSocket实时推送, topic订阅)
|
|
||||||
│ │
|
|
||||||
│ └── static/
|
|
||||||
│ └── admin.html # 管理后台 SPA (暗色主题, 8个页面)
|
|
||||||
│
|
|
||||||
└── doc/
|
|
||||||
└── KKS_Protocol_P240_P241.md # 协议文档 (从PDF转换)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 架构设计
|
|
||||||
|
|
||||||
### 网络拓扑
|
|
||||||
|
|
||||||
```
|
|
||||||
[P241 工牌] --TCP--> [152.69.205.186:5001 (frps)]
|
|
||||||
|
|
|
||||||
FRP Tunnel
|
|
||||||
|
|
|
||||||
[Container:5000 (frpc)]
|
|
||||||
|
|
|
||||||
[TCP Server (asyncio)]
|
|
||||||
|
|
|
||||||
[FastAPI :8088] --CF Tunnel--> [Web 访问]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 端口说明
|
|
||||||
- **8088**: FastAPI HTTP API + 管理后台 UI
|
|
||||||
- **5000**: TCP 服务器 (KKS 二进制协议)
|
|
||||||
- **5001**: 远程服务器 FRP 映射端口 (152.69.205.186)
|
|
||||||
- **7000**: FRP 服务端管理端口
|
|
||||||
- **7500**: FRP Dashboard (admin/PassWord0325)
|
|
||||||
|
|
||||||
### 配置管理
|
|
||||||
- `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值
|
|
||||||
- `.env.example` 提供所有可配置项模板,复制为 `.env` 使用
|
|
||||||
- DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录)
|
|
||||||
- 高德 API 密钥集中在 `config.py`,`geocoding.py` 从 `settings` 导入
|
|
||||||
- 端口号有 pydantic 校验 (ge=1, le=65535)
|
|
||||||
|
|
||||||
## 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动服务
|
|
||||||
cd /home/gpsystem
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
|
|
||||||
|
|
||||||
# 启动 FRP 客户端 (TCP隧道)
|
|
||||||
nohup /tmp/frpc -c /home/gpsystem/frpc.toml > /tmp/frpc.log 2>&1 &
|
|
||||||
|
|
||||||
# 检查服务状态
|
|
||||||
curl http://localhost:8088/health
|
|
||||||
curl http://localhost:8088/api/devices
|
|
||||||
```
|
|
||||||
|
|
||||||
## 管理后台 UI
|
|
||||||
|
|
||||||
访问 `/admin` 路径,包含以下页面:
|
|
||||||
1. **仪表盘** - 设备统计、告警概览、在线状态
|
|
||||||
2. **设备管理** - 添加/编辑/删除设备
|
|
||||||
3. **位置追踪** - Leaflet 地图、轨迹回放、地址显示
|
|
||||||
4. **告警管理** - 告警列表、确认处理、位置/地址显示
|
|
||||||
5. **考勤记录** - 上下班打卡记录 (0xB0/0xB1 GPS+LBS+WiFi)
|
|
||||||
6. **蓝牙记录** - 蓝牙打卡(0xB2)/定位(0xB3)记录,含信标MAC/UUID/RSSI等
|
|
||||||
7. **信标管理** - 蓝牙信标注册、位置配置(MAC/UUID/楼层/区域/经纬度)
|
|
||||||
8. **指令管理** - 发送指令/留言/TTS语音
|
|
||||||
|
|
||||||
## 协议详情
|
|
||||||
|
|
||||||
KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
|
||||||
|
|
||||||
### 已支持协议号
|
|
||||||
| 协议号 | 名称 | 方向 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 0x01 | Login 登录 | 终端→服务器 |
|
|
||||||
| 0x13 | Heartbeat 心跳 | 双向 |
|
|
||||||
| 0x1F | Time Sync 时间同步 | 双向 |
|
|
||||||
| 0x22 | GPS 定位 | 终端→服务器 |
|
|
||||||
| 0x26 | Alarm ACK 告警确认 | 服务器→终端 |
|
|
||||||
| 0x28 | LBS 多基站 | 终端→服务器 |
|
|
||||||
| 0x2C | WiFi 定位 | 终端→服务器 |
|
|
||||||
| 0x36 | Heartbeat Extended 扩展心跳 | 双向 |
|
|
||||||
| 0x80 | Online Command 在线指令 | 服务器→终端 |
|
|
||||||
| 0x81 | Online Command Reply | 终端→服务器 |
|
|
||||||
| 0x82 | Message 留言 | 服务器→终端 |
|
|
||||||
| 0x94 | General Info (ICCID/IMSI等) | 双向 |
|
|
||||||
| 0xA0 | 4G GPS 定位 | 终端→服务器 |
|
|
||||||
| 0xA1 | 4G LBS 多基站 | 终端→服务器 |
|
|
||||||
| 0xA2 | 4G WiFi 定位 | 终端→服务器 |
|
|
||||||
| 0xA3-0xA5 | 4G 告警 (围栏/LBS) | 终端→服务器 |
|
|
||||||
| 0xA9 | WiFi 告警 | 终端→服务器 |
|
|
||||||
| 0xB0-0xB1 | 考勤打卡 | 双向 |
|
|
||||||
| 0xB2 | 蓝牙打卡 | 双向 |
|
|
||||||
| 0xB3 | 蓝牙定位 | 终端→服务器 |
|
|
||||||
|
|
||||||
### 数据包格式
|
|
||||||
```
|
|
||||||
[Start 0x7878/0x7979] [Length 1-2B] [Protocol 1B] [Content NB] [Serial 2B] [CRC 2B] [Stop 0x0D0A]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 当前设备
|
|
||||||
|
|
||||||
| 字段 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| IMEI | 868120334031363 |
|
|
||||||
| 型号 | P241 |
|
|
||||||
| 名称 | 测试 |
|
|
||||||
| DB ID | 1 |
|
|
||||||
| ICCID | 89860848102570005286 |
|
|
||||||
| IMSI | 460240388355286 |
|
|
||||||
| SIM卡 | 中国移动 IoT (MCC=460, MNC=0) |
|
|
||||||
| 位置 | 成都地区 (~30.605°N, 103.936°E) |
|
|
||||||
|
|
||||||
## 重要实现细节
|
|
||||||
|
|
||||||
### IMEI 解析
|
|
||||||
- BCD 编码: 8字节 = 16 hex digits, 首位为填充 0
|
|
||||||
- 使用 `raw_hex[1:]` 只移除首位填充0 (不用 lstrip 避免移除多个)
|
|
||||||
|
|
||||||
### 设备登录
|
|
||||||
- 登录时不覆盖用户设置的 device_type (避免被覆盖为 raw hex)
|
|
||||||
- 重连时先关闭旧连接再建立新连接
|
|
||||||
- 断开连接时检查 writer 是否匹配,避免误将新连接标记为 offline
|
|
||||||
|
|
||||||
### 告警处理
|
|
||||||
- 告警响应使用 **0x26 ACK** (非原始协议号回复)
|
|
||||||
- 告警类型常量使用 **snake_case** (如 `sos`, `vibration`, `enter_fence`)
|
|
||||||
- 4G 告警包解析:
|
|
||||||
- **0xA3/0xA4** (围栏告警): datetime(6) + gps(12) + lbs_length(1) + MCC/MNC + LAC(4) + CellID(8) + terminal_info(1) + voltage_level(**1**字节) + gsm_signal(1) + alarm_code(1) + language(1)
|
|
||||||
- **0xA5** (LBS告警): **无datetime, 无GPS** — 直接从 MCC 开始
|
|
||||||
- **0xA9** (WiFi告警): datetime(6) + MCC/MNC + cell_type(1) + cell_count(1) + 基站列表 + timing_advance(1) + WiFi列表 + alarm_code(1) + language(1)
|
|
||||||
- MCC 高位 `0x8000` 标志: 表示 MNC 为 2 字节 (非默认1字节)
|
|
||||||
- voltage_level 是 **1字节** (0x00-0x06 等级),不是2字节毫伏值
|
|
||||||
- LBS/WiFi 报警自动执行前向地理编码(基站→经纬度)+ 逆地理编码(经纬度→地址)
|
|
||||||
|
|
||||||
### 位置数据与地理编码
|
|
||||||
- GPS 定位 (0x22/0xA0): 直接包含经纬度坐标,精度最高 (~10m)
|
|
||||||
- LBS 基站定位 (0x28/0xA1): 包含 MCC/MNC/LAC/CellID,需要地理编码转换为经纬度
|
|
||||||
- WiFi 定位 (0x2C/0xA2): 包含基站数据 + WiFi AP MAC地址列表,需要地理编码
|
|
||||||
- **所有地理编码服务统一使用高德 (Amap)**
|
|
||||||
- **前向地理编码** (`geocode_location`): 基站/WiFi → 经纬度
|
|
||||||
- **高德智能硬件定位**: `apilocate.amap.com/position`,需企业认证 (认证中,个人账号返回 10012)
|
|
||||||
- WiFi+基站混合定位精度 ~30m,企业认证通过后自动生效
|
|
||||||
- **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址
|
|
||||||
- **高德**: `restapi.amap.com/v3/geocode/regeo`,需 WGS84→GCJ02 坐标转换 (服务端 Python 实现)
|
|
||||||
- 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key
|
|
||||||
- 内置 LRU 缓存 (maxsize=10000),避免重复请求相同基站/坐标
|
|
||||||
- **WGS84→GCJ02 服务端转换**: geocoding.py 内置 `wgs84_to_gcj02()` 函数 (与前端 JS 版一致)
|
|
||||||
- **高德数字签名**: 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数
|
|
||||||
|
|
||||||
### API 认证与限流
|
|
||||||
- **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头
|
|
||||||
- **多 API Key**: 支持 master key (环境变量) + 数据库管理的 API Key (SHA-256 hash)
|
|
||||||
- **权限级别**: `read` (只读) < `write` (读写) < `admin` (管理,含 key 管理)
|
|
||||||
- **权限控制**: 所有 POST/PUT/DELETE 端点需要 `write` 权限,`/api/keys` 需要 `admin` 权限
|
|
||||||
- **Key 管理**: `POST /api/keys` 创建 key (返回明文一次), `GET /api/keys` 列表, `PUT /api/keys/{id}` 更新, `DELETE /api/keys/{id}` 停用
|
|
||||||
- **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`)
|
|
||||||
- **真实 IP**: 从 `X-Forwarded-For` → `CF-Connecting-IP` → `request.client.host` 提取
|
|
||||||
- **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials`
|
|
||||||
|
|
||||||
### WebSocket 实时推送
|
|
||||||
- **端点**: `ws://host/ws?api_key=xxx&topics=location,alarm`
|
|
||||||
- **Topics**: location, alarm, device_status, attendance, bluetooth
|
|
||||||
- **认证**: query param api_key (支持 master key + DB key)
|
|
||||||
- **最大连接**: 100 个 WebSocket 连接
|
|
||||||
- **消息格式**: JSON `{"topic": "...", "data": {...}, "timestamp": "..."}`
|
|
||||||
- **广播点**: 位置存储、报警存储、设备上下线、考勤存储、蓝牙存储
|
|
||||||
- **Ping/Pong**: 客户端发送 "ping" 保活,服务器回复 "pong"
|
|
||||||
|
|
||||||
### 数据清理
|
|
||||||
- **自动清理**: 后台定时任务,每 `DATA_CLEANUP_INTERVAL_HOURS`(默认24) 小时执行
|
|
||||||
- **保留天数**: `DATA_RETENTION_DAYS`(默认90) 天,删除过期的 location/heartbeat/alarm/attendance/bluetooth 记录
|
|
||||||
- **手动清理**: `POST /api/system/cleanup` (admin only)
|
|
||||||
|
|
||||||
### 批量操作 API
|
|
||||||
- `POST /api/devices/batch` — 批量创建 (最多500),输入去重 + DB去重,结果按输入顺序
|
|
||||||
- `PUT /api/devices/batch` — 批量更新,单次 WHERE IN 查询 + 单次 flush
|
|
||||||
- `POST /api/devices/batch-delete` — 批量删除 (最多100),通过 body 传 device_ids
|
|
||||||
- `POST /api/locations/batch-latest` — 批量获取多设备最新位置 (最多100)
|
|
||||||
- `GET /api/devices/all-latest-locations` — 获取所有在线设备最新位置
|
|
||||||
- `POST /api/commands/batch` — 批量发送指令 (最多100),`model_validator` 互斥校验
|
|
||||||
- 所有批量操作使用 WHERE IN 批量查询,避免 N+1
|
|
||||||
|
|
||||||
### API 分页
|
|
||||||
- page_size 最大限制为 100 (schema 层校验)
|
|
||||||
- 前端设备选择器使用 page_size=100 (不能超过限制)
|
|
||||||
|
|
||||||
### CRC 算法
|
|
||||||
- CRC-ITU / CRC-16/X-25
|
|
||||||
- Reflected polynomial: 0x8408
|
|
||||||
- Initial value: 0xFFFF
|
|
||||||
- Final XOR: 0xFFFF
|
|
||||||
|
|
||||||
### TCP 指令下发
|
|
||||||
- 0x80 (Online Command): ASCII 指令 + 2字节语言字段 (`0x0001`=中文)
|
|
||||||
- 0x81 (Command Reply): 设备回复,格式 length(1) + server_flag(4) + content(n) + language(2)
|
|
||||||
- 0x82 (Message): **UTF-16 BE** 编码的文本消息 (非UTF-8)
|
|
||||||
- TTS: 通过 0x80 发送 `TTS,<文本>` 格式
|
|
||||||
- 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描
|
|
||||||
- 已验证可用指令: `BTON#`, `BTSCAN,1#`, `GPSON#`, `MODE,1/3#`, `PARAM#`, `CHECK#`, `VERSION#`, `TIMER#`, `WHERE#`, `STATUS#`, `RESET#`
|
|
||||||
- **架构**: `tcp_command_service.py` 作为抽象层解耦 routers↔tcp_server 的循环导入,通过 lazy import 访问 tcp_manager
|
|
||||||
- **重要**: TCP 层 (`send_command`/`send_message`) 只负责发送,不创建 CommandLog。CommandLog 由 API 层 (commands.py) 管理
|
|
||||||
- **重要**: 服务器启动时自动将所有设备标记为 offline,等待设备重新登录
|
|
||||||
|
|
||||||
### 蓝牙信标管理
|
|
||||||
- **BeaconConfig 表**: 注册蓝牙信标,配置 MAC/UUID/Major/Minor/楼层/区域/经纬度/地址
|
|
||||||
- **自动关联**: 0xB2 打卡和 0xB3 定位时,根据 beacon_mac 查询 beacon_configs 表
|
|
||||||
- **位置写入**: 将已注册信标的经纬度写入 BluetoothRecord 的 latitude/longitude
|
|
||||||
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
|
|
||||||
- **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描
|
|
||||||
- **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(btSCAN:1) — 设备确认设置成功
|
|
||||||
- **当前状态**: ✅ 0xB2 蓝牙打卡已验证可用 (2026-03-18),设备成功检测信标并上报数据
|
|
||||||
- **已验证信标**: MAC=`C3:00:00:34:43:5E`, UUID=FDA50693-A4E2-4FB1-AFCF-C6EB07647825, Major=10001, Minor=19641
|
|
||||||
- **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空
|
|
||||||
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列
|
|
||||||
|
|
||||||
### 0x94 General Info 子协议
|
|
||||||
- 子协议 0x0A: IMEI(8字节) + IMSI(8字节) + ICCID(10字节)
|
|
||||||
- 子协议 0x09: GPS 卫星状态
|
|
||||||
- 子协议 0x00: ICCID(10字节)
|
|
||||||
- 子协议 0x04: 设备配置上报 `ALM2=40;ALM4=E0;MODE=03;IMSI=...`
|
|
||||||
|
|
||||||
### 前端字段映射 (admin.html)
|
|
||||||
- 设备信号: `d.gsm_signal` (非 `d.signal_strength`)
|
|
||||||
- 指令响应: `c.response_content` (非 `c.response`)
|
|
||||||
- 响应时间: `c.response_at || c.sent_at` (非 `c.updated_at`)
|
|
||||||
- 位置地址: `l.address` (高德逆地理编码结果)
|
|
||||||
- 卫星数: `l.gps_satellites` (非 `l.accuracy`)
|
|
||||||
- 记录时间: `l.recorded_at` (非 `l.timestamp`)
|
|
||||||
- 报警来源: `a.alarm_source` (非 `a.source`)
|
|
||||||
- 报警信号: `a.gsm_signal` (非 `a.signal_strength`)
|
|
||||||
- 报警类型: snake_case (如 `vibration`, `power_cut`, `voice_alarm`)
|
|
||||||
|
|
||||||
## FRP 配置
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# frpc.toml (容器端)
|
|
||||||
serverAddr = "152.69.205.186"
|
|
||||||
serverPort = 7000
|
|
||||||
auth.method = "token"
|
|
||||||
auth.token = "PassWord0325"
|
|
||||||
|
|
||||||
[[proxies]]
|
|
||||||
name = "badge-tcp"
|
|
||||||
type = "tcp"
|
|
||||||
localIP = "127.0.0.1"
|
|
||||||
localPort = 5000
|
|
||||||
remotePort = 5001
|
|
||||||
```
|
|
||||||
|
|
||||||
## 外部服务器
|
|
||||||
|
|
||||||
- IP: 152.69.205.186
|
|
||||||
- OS: Ubuntu 20.04, Python 3.11, 24GB RAM
|
|
||||||
- FRP Server: frps v0.62.1 (1panel Docker, network_mode: host)
|
|
||||||
- Config: /opt/1panel/apps/frps/frps/data/frps.toml
|
|
||||||
|
|
||||||
## 高德地图 API
|
|
||||||
|
|
||||||
### Key
|
|
||||||
- **Web服务 Key**: `a9f4e04f5c8e739e5efb07175333f30b`
|
|
||||||
- **安全密钥**: `bfc4e002c49ab5f47df71e0aeaa086a5`
|
|
||||||
- **账号类型**: 个人认证开发者 (正在申请企业认证)
|
|
||||||
|
|
||||||
### 已接入服务
|
|
||||||
- **✅ 逆地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本,服务端 WGS84→GCJ02 坐标转换
|
|
||||||
- **✅ 智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (代码就绪,企业认证通过前返回 10012)
|
|
||||||
- **✅ 前端地图瓦片**: 高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换
|
|
||||||
- **数字签名**: `_amap_sign()` — 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数
|
|
||||||
|
|
||||||
### 配额 (个人认证开发者)
|
|
||||||
- 基础LBS服务: 5,000 次/日 (逆地理编码等)
|
|
||||||
- 在线定位: 50,000 次/日 (企业认证后 1,000,000 次/日)
|
|
||||||
|
|
||||||
## 已知限制
|
|
||||||
|
|
||||||
1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
|
|
||||||
2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发
|
|
||||||
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
|
|
||||||
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
|
|
||||||
5. **基站/WiFi定位需企业认证** - 高德智能硬件定位 API 已接入但个人账号返回 10012,企业认证通过后自动生效
|
|
||||||
|
|
||||||
## 已修复的问题 (Bug Fix 记录)
|
|
||||||
|
|
||||||
### 数据链路修复
|
|
||||||
1. **IMEI 解析** - 修复 BCD 解码逻辑,确保正确提取 15 位 IMEI
|
|
||||||
2. **设备类型覆盖** - 登录时不再覆盖用户设置的 device_type
|
|
||||||
3. **设备重连** - 新连接建立前先关闭旧连接,断开时检查 writer 身份
|
|
||||||
4. **告警类型提取** - 修正 alarm byte 位置,从 terminal_info+voltage+gsm_signal 之后读取
|
|
||||||
|
|
||||||
### 协议修复
|
|
||||||
5. **0x80 指令包** - 添加缺失的 2 字节语言字段 (`0x0001`)
|
|
||||||
6. **0x82 消息编码** - 从 UTF-8 改为 UTF-16 BE (协议要求)
|
|
||||||
7. **0x94 通用信息** - 完善子协议 0x0A (IMEI+IMSI+ICCID) 和 0x09 (GPS卫星状态) 解析
|
|
||||||
|
|
||||||
### 前端修复
|
|
||||||
8. **设备选择器为空** - `page_size=500` 超过 API 最大限制 100,返回 422 错误
|
|
||||||
9. **位置页面崩溃** - API 返回 `data: null` 时未做空值检查
|
|
||||||
10. **轨迹查询失败** - 缺少必填的 start_time/end_time 参数
|
|
||||||
11. **字段名不匹配** - 修复 signal_strength→gsm_signal, response→response_content, source→alarm_source 等
|
|
||||||
|
|
||||||
### 定位功能修复
|
|
||||||
12. **WiFi/LBS 无坐标** - 添加 wifi/wifi_4g 解析分支 (原代码缺失)
|
|
||||||
13. **地理编码集成** - LBS/WiFi 定位数据自动转换为经纬度坐标
|
|
||||||
14. **邻近基站和WiFi数据** - 存储到 LocationRecord 的 neighbor_cells 和 wifi_data 字段
|
|
||||||
|
|
||||||
### 告警功能修复
|
|
||||||
15. **告警响应协议** - 改为使用 0x26 ACK 响应 (原来错误使用地址回复)
|
|
||||||
16. **voltage_level 解析** - 从2字节改为1字节 (0x00-0x06等级)
|
|
||||||
17. **0xA5 LBS告警格式** - 移除错误的 datetime/lbs_length 前缀,直接从 MCC 开始
|
|
||||||
18. **0xA9 WiFi告警** - 独立解析器 (非GPS格式),支持 cell_type/cell_count/WiFi AP 列表
|
|
||||||
19. **MNC 2字节标志** - `_parse_mcc_mnc_from_content` 正确处理 MCC 高位 0x8000 标志
|
|
||||||
20. **告警类型常量** - 改为 snake_case,新增 voice_alarm(0x16), fake_base_station(0x17), cover_open(0x18), internal_low_battery(0x19)
|
|
||||||
21. **LBS/WiFi 报警定位** - 为无GPS的报警添加前向地理编码
|
|
||||||
|
|
||||||
### 逆地理编码
|
|
||||||
22. **逆地理编码集成** - 位置和报警记录自动获取中文地址 (高德逆地理编码)
|
|
||||||
23. **地址字段** - LocationRecord 新增 address 字段,前端位置表/报警表/地图弹窗显示地址
|
|
||||||
|
|
||||||
### 蓝牙与考勤功能
|
|
||||||
24. **0xB0/0xB1 考勤解析** - 完整解析 GPS+LBS+WiFi 考勤包,含基站邻区、WiFi AP、前向+逆地理编码
|
|
||||||
25. **0xB2 蓝牙打卡解析** - 解析 iBeacon 数据 (RSSI/MAC/UUID/Major/Minor/电池/打卡类型)
|
|
||||||
26. **0xB3 蓝牙定位解析** - 解析多信标数据,每信标 30 字节,支持电压(V)/百分比(%)电池单位
|
|
||||||
27. **考勤类型检测** - 从 terminal_info 字节 bits[5:2] 判断 clock_in(0001)/clock_out(0010)
|
|
||||||
28. **信标管理系统** - BeaconConfig CRUD,注册信标物理位置,TCP 自动关联信标经纬度
|
|
||||||
|
|
||||||
### 指令发送修复
|
|
||||||
29. **server_flag_str 未定义** - send_command 中记录日志时变量未定义导致 NameError
|
|
||||||
30. **TCP 层重复创建日志** - 简化 send_command/send_message 只负责发送,CommandLog 由 API 层管理
|
|
||||||
31. **IMEI lstrip 问题** - `lstrip("0")` 可能删除多个前导零,改为 `raw_hex[1:]`
|
|
||||||
32. **设备启动状态** - 服务器启动时重置所有设备为 offline,避免显示在线但实际未连接
|
|
||||||
33. **0x81 回复解析** - 去除末尾 2 字节语言字段,正确提取回复文本
|
|
||||||
|
|
||||||
### 协议全面审计修复 (2026-03-16)
|
|
||||||
34. **0x1F 时间同步格式** - 从6字节(YY MM DD HH MM SS)改为4字节Unix时间戳+2字节语言字段
|
|
||||||
35. **地址回复完整格式** - 重写 `_send_address_reply`,实现完整格式: server_flag(4)+ADDRESS/ALARMSMS(7-8)+&&+UTF16BE地址+&&+电话(21)+##
|
|
||||||
36. **报警地址回复** - 报警包在发送0x26 ACK后,额外发送0x17地址回复(含ALARMSMS标记)
|
|
||||||
37. **0x82 留言格式** - 添加 server_flag(4字节) + language(2字节) 字段
|
|
||||||
38. **0x22 GPS Cell ID** - 从2字节改为3字节解析 (2G基站CellID为3字节)
|
|
||||||
39. **登录包时区/语言** - 解析 content[10:12] 提取时区(bit15-04)和语言(bit00),存入Device
|
|
||||||
40. **0x94 不需回复** - 移除服务器响应,从 PROTOCOLS_REQUIRING_RESPONSE 中删除
|
|
||||||
41. **WiFi缓存+LRU淘汰** - 新增 LRUCache(OrderedDict, maxsize=10000),替换所有dict缓存,修复WiFi缓存未写入
|
|
||||||
42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项
|
|
||||||
43. **DATA_REPORT_MODES** - 修正所有模式名称匹配协议文档
|
|
||||||
|
|
||||||
### 架构优化 (2026-03-17)
|
|
||||||
44. **配置集中化** - API密钥从 geocoding.py 硬编码移至 config.py (pydantic-settings),支持 .env 覆盖
|
|
||||||
45. **数据库绝对路径** - DATABASE_URL 从相对路径改为基于 `__file__` 的绝对路径,避免 CWD 依赖
|
|
||||||
46. **tcp_command_service 抽象层** - 新建 services/tcp_command_service.py,通过 lazy import 解耦 routers↔tcp_server 循环依赖
|
|
||||||
47. **commands.py 去重** - send_command/send_message/send_tts 提取 `_send_to_device()` 通用函数
|
|
||||||
48. **协议常量扩展** - constants.py 新增 DEFAULT_DEVICE_TYPE, SERVER_FLAG_BYTES, ATTENDANCE_TYPES, COURSE_BIT_*, MCC_MNC2_FLAG, VOLTAGE_LEVELS
|
|
||||||
49. **前端侧边面板** - 位置追踪/信标管理页面添加左侧设备/信标列表面板,自动选中最近活跃设备
|
|
||||||
50. **前端面板解耦** - 提取 PANEL_IDS 配置 + _initPanelRender 通用函数,toggleSidePanel 仅在 locations 页调用 invalidateSize
|
|
||||||
|
|
||||||
### 连接修复 (2026-03-19)
|
|
||||||
51. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError,补充 import
|
|
||||||
52. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections
|
|
||||||
|
|
||||||
### 全面切换高德 API (2026-03-23)
|
|
||||||
53. **高德逆地理编码** - 接入 restapi.amap.com/v3/geocode/regeo,服务端 WGS84→GCJ02 坐标转换
|
|
||||||
54. **高德智能硬件定位** - 接入 apilocate.amap.com/position (基站+WiFi定位,企业认证通过前返回 10012)
|
|
||||||
55. **高德数字签名** - 实现 `_amap_sign()` 函数 (参数排序 + AMAP_SECRET + MD5)
|
|
||||||
56. **服务端坐标转换** - geocoding.py 内置 `wgs84_to_gcj02()` Python 实现
|
|
||||||
57. **高德地图瓦片** - 前端替换为高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换
|
|
||||||
58. **移除第三方地理编码** - 清理天地图/百度/Google/Unwired/Mylnikov,统一使用高德
|
|
||||||
|
|
||||||
### API 安全加固 & 批量管理 (2026-03-20)
|
|
||||||
59. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击
|
|
||||||
60. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min)
|
|
||||||
61. **限流器真实 IP** - `extensions.py` 从 `X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP
|
|
||||||
62. **全局异常处理** - 拦截未处理异常返回 500,不泄露堆栈;放行 HTTPException/ValidationError
|
|
||||||
63. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern
|
|
||||||
64. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数
|
|
||||||
65. **批量设备创建** - `POST /api/devices/batch` (最多500台),WHERE IN 单次查询去重,输入列表内 IMEI 去重
|
|
||||||
66. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush
|
|
||||||
67. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制
|
|
||||||
68. **批量指令发送** - `POST /api/commands/batch` (最多100台),`model_validator` 互斥校验 device_ids/imeis
|
|
||||||
69. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取
|
|
||||||
70. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突
|
|
||||||
71. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh,依赖 service 层
|
|
||||||
|
|
||||||
### 全面改进 (2026-03-22)
|
|
||||||
|
|
||||||
#### Protocol 修复
|
|
||||||
72. **parser.py 0x22 CellID** - 从2字节改为3字节解析
|
|
||||||
73. **parser.py 0xA5 LBS告警** - 移除错误的 datetime+lbs_length 前缀
|
|
||||||
74. **parser.py 0xA9 WiFi告警** - 完全重写为 datetime+MCC/MNC+cell_type+cell_count+基站+TA+WiFi+alarm_code
|
|
||||||
75. **parser.py voltage** - 0xA3/0xA5/0xA9 voltage 从2字节改为1字节
|
|
||||||
76. **parser.py 0xA4** - 新增多围栏告警解析器 (含 fence_id)
|
|
||||||
77. **parser.py 0xB2** - 完整 iBeacon 解析 (RSSI/MAC/UUID/Major/Minor/Battery)
|
|
||||||
78. **parser.py 0xB3** - beacon_count + 每信标30字节解析
|
|
||||||
79. **parser.py 0xB0/0xB1** - 补充 gps_positioned/terminal_info/voltage/gsm_signal 等中间字段
|
|
||||||
80. **parser.py 0x81** - 新增指令回复解析器
|
|
||||||
81. **builder.py 0x1F** - 时间同步添加2字节 language 参数
|
|
||||||
82. **builder.py 地址回复** - 重写 CN/EN 地址回复完整格式
|
|
||||||
83. **builder.py 0x80/0x82** - cmd_len 正确包含 language(2)
|
|
||||||
84. **constants.py** - 从 PROTOCOLS_REQUIRING_RESPONSE 移除 0x28
|
|
||||||
|
|
||||||
#### 批量 API + 索引
|
|
||||||
85. **batch-latest** - `POST /api/locations/batch-latest` 批量获取多设备最新位置
|
|
||||||
86. **all-latest-locations** - `GET /api/devices/all-latest-locations` 所有在线设备位置
|
|
||||||
87. **数据库索引** - 新增5个索引 (alarm_type, acknowledged, beacon_mac, location_type, attendance_type)
|
|
||||||
|
|
||||||
#### 多 API Key + 权限控制
|
|
||||||
88. **ApiKey 模型** - SHA-256 hash, permissions(read/write/admin), is_active
|
|
||||||
89. **多 Key 认证** - master key (env) + DB keys, last_used_at 追踪
|
|
||||||
90. **权限控制** - require_write/require_admin 依赖,写端点需 write 权限
|
|
||||||
91. **Key 管理 API** - `/api/keys` CRUD (admin only),创建时返回明文一次
|
|
||||||
92. **DeviceUpdate** - 移除 status 字段 (设备状态由系统管理)
|
|
||||||
|
|
||||||
#### WebSocket 实时推送
|
|
||||||
93. **WebSocketManager** - 连接管理器,topic 订阅,最大100连接
|
|
||||||
94. **WS 端点** - `/ws?api_key=xxx&topics=location,alarm` WebSocket 认证
|
|
||||||
95. **TCP 广播集成** - 7个广播点 (location, alarm, device_status x2, attendance, bluetooth x2)
|
|
||||||
|
|
||||||
#### 数据清理 + 补充
|
|
||||||
96. **自动数据清理** - 后台定时任务,DATA_RETENTION_DAYS=90, DATA_CLEANUP_INTERVAL_HOURS=24
|
|
||||||
97. **手动清理 API** - `POST /api/system/cleanup` (admin only)
|
|
||||||
98. **alarm_source 过滤** - alarms 列表新增 alarm_source 参数
|
|
||||||
99. **beacon_mac 过滤** - bluetooth 列表新增 beacon_mac 参数
|
|
||||||
100. **command_type 过滤** - commands 列表新增 command_type 参数
|
|
||||||
101. **sort_order 参数** - alarms/bluetooth 列表支持 asc/desc 排序
|
|
||||||
|
|
||||||
#### 协议层统一
|
|
||||||
102. **PacketBuilder 统一** - tcp_server.py 内嵌 PacketBuilder 改为委托 protocol/builder.py
|
|
||||||
|
|
||||||
### 审计修复 (2026-03-23)
|
|
||||||
103. **require_admin 导入** - main.py 缺少 require_admin 导入,设置 API_KEY 后 cleanup 端点崩溃
|
|
||||||
104. **0x28/0x2C CellID 3字节** - tcp_server.py 2G 基站 CellID 从2字节修正为3字节 (0x28 LBS, 0x2C WiFi, 邻区解析)
|
|
||||||
105. **parser.py CellID 3字节** - parse_lbs_station 默认 cell_id_size=2→3,新增3字节分支; _parse_lbs_address_req CellID 2→3字节
|
|
||||||
106. **alarm_source 字段长度** - models.py String(10)→String(20), schemas.py max_length=10→20 ("single_fence"=12字符)
|
|
||||||
107. **AlarmRecord wifi_data/fence_data** - 0xA9 WiFi报警存储 wifi_data; 0xA4 多围栏报警提取并存储 fence_id
|
|
||||||
108. **CommandLog.sent_at** - 指令发送成功后设置 sent_at 时间戳 (原来始终为 NULL)
|
|
||||||
109. **geocoding IMEI 参数化** - 移除硬编码 IMEI,新增 GEOCODING_DEFAULT_IMEI 配置项,geocode_location 接受 imei 参数,TCP 层传递设备 IMEI
|
|
||||||
110. **parser.py 循环长度检查** - _parse_lbs_multi 和 _parse_wifi 站点循环检查从 pos+5 改为 pos+6 (LAC=2+CellID=3+RSSI=1=6)
|
|
||||||
111. **batch sent_at** - batch_send_command 批量指令路径也设置 cmd_log.sent_at (与单条路径一致)
|
|
||||||
112. **GPS 经度符号反转** - course_status bit 11 含义为 0=东经/1=西经 (非 1=东经),`is_east` 改为 `is_west`,修复成都定位到北美洲的问题 (tcp_server.py + parser.py)
|
|
||||||
|
|
||||||
## 待完成功能
|
|
||||||
|
|
||||||
1. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
|
|
||||||
2. **前端 WebSocket 集成** - admin.html Dashboard 改用 WebSocket 替代 30s 轮询,报警页实时通知
|
|
||||||
3. **协议层深度统一** - tcp_server.py 辅助方法 (_parse_gps, _parse_datetime 等) 逐步迁移到 protocol/parser.py
|
|
||||||
|
|
||||||
## 调试技巧
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看实时日志
|
|
||||||
tail -f /home/gpsystem/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Amap" --line-buffered
|
|
||||||
|
|
||||||
# 检查数据库
|
|
||||||
python3 -c "
|
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect('/home/gpsystem/badge_admin.db')
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute('SELECT id, imei, device_type, status, battery_level FROM devices')
|
|
||||||
for row in cur.fetchall(): print(row)
|
|
||||||
cur.execute('SELECT id, location_type, latitude, longitude, address, recorded_at FROM location_records ORDER BY id DESC LIMIT 5')
|
|
||||||
for row in cur.fetchall(): print(row)
|
|
||||||
"
|
|
||||||
|
|
||||||
# 测试 API
|
|
||||||
curl -s http://localhost:8088/api/devices | python3 -m json.tool
|
|
||||||
curl -s http://localhost:8088/api/locations/latest/1 | python3 -m json.tool
|
|
||||||
curl -s http://localhost:8088/health
|
|
||||||
|
|
||||||
# 发送指令
|
|
||||||
curl -s -X POST http://localhost:8088/api/commands/send \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"device_id":1,"command_type":"online_cmd","command_content":"GPSON#"}' | python3 -m json.tool
|
|
||||||
|
|
||||||
# 开启蓝牙扫描
|
|
||||||
curl -s -X POST http://localhost:8088/api/commands/send \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"device_id":1,"command_type":"online_cmd","command_content":"BTON#"}' | python3 -m json.tool
|
|
||||||
|
|
||||||
# 管理信标
|
|
||||||
curl -s http://localhost:8088/api/beacons | python3 -m json.tool
|
|
||||||
curl -s -X POST http://localhost:8088/api/beacons \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"beacon_mac":"AA:BB:CC:DD:EE:FF","name":"前台","floor":"1F","latitude":30.27,"longitude":120.15}' | python3 -m json.tool
|
|
||||||
|
|
||||||
# 检查 FRP 连接
|
|
||||||
ps aux | grep frpc
|
|
||||||
# FRP Dashboard: http://152.69.205.186:7500 (admin/PassWord0325)
|
|
||||||
|
|
||||||
# 检查端口
|
|
||||||
python3 -c "
|
|
||||||
with open('/proc/net/tcp') as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts)>=2 and ':' in parts[1]:
|
|
||||||
port = int(parts[1].split(':')[1], 16)
|
|
||||||
if port in (8088, 5000): print(f'Port {port}: listening')
|
|
||||||
"
|
|
||||||
|
|
||||||
# 测试地理编码
|
|
||||||
python3 -c "
|
|
||||||
import asyncio
|
|
||||||
from app.geocoding import geocode_location, reverse_geocode
|
|
||||||
async def test():
|
|
||||||
lat, lon = await geocode_location(mcc=460, mnc=0, lac=32775, cell_id=205098688)
|
|
||||||
print(f'lat={lat}, lon={lon}')
|
|
||||||
if lat: print(await reverse_geocode(lat, lon))
|
|
||||||
asyncio.run(test())
|
|
||||||
"
|
|
||||||
```
|
|
||||||
@@ -192,6 +192,36 @@ async def device_attendance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/batch-delete",
|
||||||
|
response_model=APIResponse[dict],
|
||||||
|
summary="批量删除考勤记录 / Batch delete attendance records",
|
||||||
|
)
|
||||||
|
async def batch_delete_attendance(
|
||||||
|
body: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量删除考勤记录,通过 body 传递 attendance_ids 列表,最多 500 条。
|
||||||
|
Batch delete attendance records by IDs (max 500).
|
||||||
|
"""
|
||||||
|
attendance_ids = body.get("attendance_ids", [])
|
||||||
|
if not attendance_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="attendance_ids is required")
|
||||||
|
if len(attendance_ids) > 500:
|
||||||
|
raise HTTPException(status_code=400, detail="Max 500 records per request")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(AttendanceRecord).where(AttendanceRecord.id.in_(attendance_ids))
|
||||||
|
)
|
||||||
|
records = list(result.scalars().all())
|
||||||
|
for r in records:
|
||||||
|
await db.delete(r)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return APIResponse(data={"deleted": len(records)})
|
||||||
|
|
||||||
|
|
||||||
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{attendance_id}",
|
"/{attendance_id}",
|
||||||
@@ -207,3 +237,21 @@ async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db))
|
|||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
||||||
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{attendance_id}",
|
||||||
|
response_model=APIResponse[dict],
|
||||||
|
summary="删除单条考勤记录 / Delete attendance record",
|
||||||
|
)
|
||||||
|
async def delete_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""按ID删除单条考勤记录 / Delete a single attendance record by ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AttendanceRecord).where(AttendanceRecord.id == attendance_id)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
||||||
|
await db.delete(record)
|
||||||
|
await db.flush()
|
||||||
|
return APIResponse(data={"deleted": 1})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Checks whether a device's reported coordinates fall inside its bound fences.
|
|||||||
Creates automatic attendance records (clock_in/clock_out) on state transitions.
|
Creates automatic attendance records (clock_in/clock_out) on state transitions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|||||||
@@ -431,8 +431,8 @@
|
|||||||
<div class="page-main-content" style="position:relative">
|
<div class="page-main-content" style="position:relative">
|
||||||
<button class="panel-expand-btn" onclick="toggleSidePanel('locSidePanel')" title="展开设备面板"><i class="fas fa-chevron-right"></i></button>
|
<button class="panel-expand-btn" onclick="toggleSidePanel('locSidePanel')" title="展开设备面板"><i class="fas fa-chevron-right"></i></button>
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<select id="locDeviceSelect" style="width:200px">
|
<select id="locDeviceSelect" style="width:200px" onchange="onLocDeviceSelectChange(this.value)">
|
||||||
<option value="">选择设备...</option>
|
<option value="">全部设备</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="locTypeFilter" style="width:150px">
|
<select id="locTypeFilter" style="width:150px">
|
||||||
<option value="">全部类型</option>
|
<option value="">全部类型</option>
|
||||||
@@ -448,6 +448,7 @@
|
|||||||
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
|
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
|
||||||
<button class="btn btn-primary" onclick="playTrack()" style="background:#7c3aed"><i class="fas fa-play"></i> 路径回放</button>
|
<button class="btn btn-primary" onclick="playTrack()" style="background:#7c3aed"><i class="fas fa-play"></i> 路径回放</button>
|
||||||
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
|
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
|
||||||
|
<button id="btnHideLowPrecision" class="btn btn-secondary" onclick="toggleHideLowPrecision()" title="隐藏 LBS/WiFi 低精度定位点,仅显示 GPS"><i class="fas fa-eye"></i> 低精度</button>
|
||||||
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
|
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
|
||||||
<button class="btn" style="background:#dc2626;color:#fff" onclick="batchDeleteNoCoordLocations()"><i class="fas fa-broom"></i> 清除无坐标</button>
|
<button class="btn" style="background:#dc2626;color:#fff" onclick="batchDeleteNoCoordLocations()"><i class="fas fa-broom"></i> 清除无坐标</button>
|
||||||
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteLoc" onclick="batchDeleteSelectedLocations()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="locSelCount">0</span>)</button>
|
<button class="btn" style="background:#b91c1c;color:#fff" id="btnBatchDeleteLoc" onclick="batchDeleteSelectedLocations()" disabled><i class="fas fa-trash-alt"></i> 删除选中 (<span id="locSelCount">0</span>)</button>
|
||||||
@@ -648,10 +649,11 @@
|
|||||||
<th>电量/信号</th>
|
<th>电量/信号</th>
|
||||||
<th>基站</th>
|
<th>基站</th>
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="attendanceTableBody">
|
<tbody id="attendanceTableBody">
|
||||||
<tr><td colspan="7" 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>
|
||||||
@@ -825,6 +827,13 @@
|
|||||||
<!-- Right: Main Content -->
|
<!-- Right: Main Content -->
|
||||||
<div class="page-main-content" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
<div class="page-main-content" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||||
<button class="panel-expand-btn" onclick="toggleSidePanel('fenceSidePanel')" title="展开围栏面板"><i class="fas fa-chevron-right"></i></button>
|
<button class="panel-expand-btn" onclick="toggleSidePanel('fenceSidePanel')" title="展开围栏面板"><i class="fas fa-chevron-right"></i></button>
|
||||||
|
<!-- Top Tabs -->
|
||||||
|
<div style="display:flex;border-bottom:1px solid #374151;background:#1f2937;border-radius:8px 8px 0 0;margin-bottom:12px">
|
||||||
|
<button id="fenceTabList" class="fence-tab active" onclick="switchFenceTab('list')"><i class="fas fa-map-marked-alt"></i> 围栏管理</button>
|
||||||
|
<button id="fenceTabBindings" class="fence-tab" onclick="switchFenceTab('bindings')"><i class="fas fa-link"></i> 设备绑定</button>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Content: Fence List + Map -->
|
||||||
|
<div id="fenceTabContentList" style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||||
<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">
|
||||||
@@ -839,13 +848,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:300px;overflow-y:auto">
|
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="max-height:300px;overflow-y:auto">
|
||||||
<div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
<div id="fencesLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||||||
<!-- Tabs -->
|
|
||||||
<div style="display:flex;border-bottom:1px solid #374151;background:#1f2937">
|
|
||||||
<button id="fenceTabList" class="fence-tab active" onclick="switchFenceTab('list')"><i class="fas fa-list"></i> 围栏列表</button>
|
|
||||||
<button id="fenceTabBindings" class="fence-tab" onclick="switchFenceTab('bindings')"><i class="fas fa-link"></i> 设备绑定</button>
|
|
||||||
</div>
|
|
||||||
<!-- Tab: Fence List -->
|
|
||||||
<div id="fenceTabContentList" class="overflow-x-auto">
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -864,24 +866,21 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab: Device Bindings -->
|
|
||||||
<div id="fenceTabContentBindings" style="display:none;padding:12px">
|
|
||||||
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
|
|
||||||
<select id="fenceBindSelect" style="flex:1;max-width:240px" onchange="loadFenceBindingTab()">
|
|
||||||
<option value="">选择围栏...</option>
|
|
||||||
</select>
|
|
||||||
<select id="fenceBindDeviceAdd" style="flex:1;max-width:240px">
|
|
||||||
<option value="">选择设备添加绑定...</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-primary" style="white-space:nowrap" onclick="quickBindDevice()"><i class="fas fa-plus"></i> 绑定</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="fenceBindTableWrap" class="overflow-x-auto">
|
<!-- Tab Content: Device Bindings -->
|
||||||
<table>
|
<div id="fenceTabContentBindings" style="display:none;flex-direction:column;flex:1;overflow:hidden">
|
||||||
<thead><tr>
|
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||||
<th>设备名称</th><th>IMEI</th><th>围栏状态</th><th>最后检测</th><th>操作</th>
|
<button class="btn btn-primary" onclick="loadBindingMatrix()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||||
</tr></thead>
|
<span style="color:#9ca3af;font-size:12px"><i class="fas fa-info-circle"></i> 勾选表示绑定设备到围栏,取消勾选自动解绑</span>
|
||||||
<tbody id="fenceBindTableBody">
|
<div style="flex:1"></div>
|
||||||
<tr><td colspan="5" class="text-center text-gray-500 py-4">请选择围栏</td></tr>
|
<button id="fenceBindSaveBtn" class="btn btn-primary" onclick="saveBindingMatrix()"><i class="fas fa-save"></i> 保存更改</button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative" style="flex:1;overflow:auto">
|
||||||
|
<div id="fenceBindMatrixWrap" class="overflow-x-auto" style="height:100%;overflow-y:auto">
|
||||||
|
<table id="fenceBindMatrix" style="font-size:12px">
|
||||||
|
<thead id="fenceBindMatrixHead" style="position:sticky;top:0;background:#1f2937;z-index:1"></thead>
|
||||||
|
<tbody id="fenceBindMatrixBody">
|
||||||
|
<tr><td class="text-center text-gray-500 py-4">加载中...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1307,7 +1306,7 @@
|
|||||||
switch (page) {
|
switch (page) {
|
||||||
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
|
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
|
||||||
case 'devices': loadDevices(); break;
|
case 'devices': loadDevices(); break;
|
||||||
case 'locations': initLocationMap(); loadDeviceSelectors(); loadLocationRecords(1); 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': loadBluetooth(); loadDeviceSelectors(); break;
|
||||||
@@ -1454,7 +1453,16 @@
|
|||||||
if (select) select.value = deviceId;
|
if (select) select.value = deviceId;
|
||||||
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
|
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
|
||||||
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
// Don't auto-locate on panel selection, user clicks "最新位置" manually
|
// Reload location records filtered by selected device
|
||||||
|
loadLocationRecords(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocDeviceSelectChange(deviceId) {
|
||||||
|
selectedPanelDeviceId = deviceId;
|
||||||
|
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.dataset.deviceId == deviceId);
|
||||||
|
});
|
||||||
|
loadLocationRecords(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPanelBeacon(beaconId) {
|
function selectPanelBeacon(beaconId) {
|
||||||
@@ -1509,7 +1517,11 @@
|
|||||||
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
|
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
|
||||||
const sorted = sortDevicesByActivity(devices);
|
const sorted = sortDevicesByActivity(devices);
|
||||||
renderDevicePanel(sorted);
|
renderDevicePanel(sorted);
|
||||||
if (!selectedPanelDeviceId) autoSelectActiveDevice(sorted);
|
if (!selectedPanelDeviceId) {
|
||||||
|
autoSelectActiveDevice(sorted); // this calls selectPanelDevice → loadLocationRecords
|
||||||
|
}
|
||||||
|
// If no devices or already selected, ensure records are loaded
|
||||||
|
if (!sorted.length || selectedPanelDeviceId) loadLocationRecords(1);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load device selectors:', err);
|
console.error('Failed to load device selectors:', err);
|
||||||
@@ -2247,10 +2259,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearMapOverlays() {
|
function clearMapOverlays() {
|
||||||
mapMarkers.forEach(m => locationMap.remove(m));
|
mapMarkers.forEach(m => { if (!m._lpHidden) locationMap.remove(m); });
|
||||||
mapMarkers = [];
|
mapMarkers = [];
|
||||||
mapInfoWindows = [];
|
mapInfoWindows = [];
|
||||||
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
|
if (mapPolyline) { locationMap.remove(mapPolyline); mapPolyline = null; }
|
||||||
|
_trackPolyline = null;
|
||||||
|
_trackLocations = null;
|
||||||
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
|
if (trackPlayTimer) { cancelAnimationFrame(trackPlayTimer); trackPlayTimer = null; }
|
||||||
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
|
if (trackMovingMarker) { locationMap.remove(trackMovingMarker); trackMovingMarker = null; }
|
||||||
if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; }
|
if (_focusInfoWindow) { _focusInfoWindow.close(); _focusInfoWindow = null; }
|
||||||
@@ -2259,6 +2273,62 @@
|
|||||||
if (legend) legend.style.display = 'none';
|
if (legend) legend.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Hide low-precision toggle ----
|
||||||
|
let _hideLowPrecision = false;
|
||||||
|
function toggleHideLowPrecision() {
|
||||||
|
_hideLowPrecision = !_hideLowPrecision;
|
||||||
|
const btn = document.getElementById('btnHideLowPrecision');
|
||||||
|
if (_hideLowPrecision) {
|
||||||
|
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-apply to existing track markers
|
||||||
|
_applyLowPrecisionFilter();
|
||||||
|
}
|
||||||
|
function _isLowPrecision(locationType) {
|
||||||
|
const t = (locationType || '').toLowerCase();
|
||||||
|
return t.startsWith('lbs') || t.startsWith('wifi');
|
||||||
|
}
|
||||||
|
function _applyLowPrecisionFilter() {
|
||||||
|
// Toggle visibility of low-precision markers stored with _lpFlag
|
||||||
|
mapMarkers.forEach(m => {
|
||||||
|
if (m._lpFlag) {
|
||||||
|
if (_hideLowPrecision) { m.hide ? m.hide() : m.setMap(null); m._lpHidden = true; }
|
||||||
|
else { m.show ? m.show() : m.setMap(locationMap); m._lpHidden = false; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Also filter the track polyline if exists — rebuild path without LP points
|
||||||
|
if (_trackPolyline && _trackLocations) {
|
||||||
|
const path = [];
|
||||||
|
_trackLocations.forEach(loc => {
|
||||||
|
if (_hideLowPrecision && _isLowPrecision(loc.location_type)) return;
|
||||||
|
const lat = loc.latitude || loc.lat;
|
||||||
|
const lng = loc.longitude || loc.lng || loc.lon;
|
||||||
|
if (lat && lng) {
|
||||||
|
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||||
|
path.push([mLng, mLat]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_trackPolyline.setPath(path);
|
||||||
|
}
|
||||||
|
// Filter table rows
|
||||||
|
document.querySelectorAll('#locationsTableBody tr[data-loc-type]').forEach(tr => {
|
||||||
|
if (_hideLowPrecision && _isLowPrecision(tr.dataset.locType)) {
|
||||||
|
tr.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
tr.style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _trackPolyline = null;
|
||||||
|
let _trackLocations = null;
|
||||||
|
|
||||||
async function loadTrack() {
|
async function loadTrack() {
|
||||||
const deviceId = document.getElementById('locDeviceSelect').value;
|
const deviceId = document.getElementById('locDeviceSelect').value;
|
||||||
if (!deviceId) { showToast('请选择设备', 'error'); return; }
|
if (!deviceId) { showToast('请选择设备', 'error'); return; }
|
||||||
@@ -2322,25 +2392,10 @@
|
|||||||
fillOpacity: isLbs ? 0.6 : 0.9,
|
fillOpacity: isLbs ? 0.6 : 0.9,
|
||||||
zIndex: 120, cursor: 'pointer',
|
zIndex: 120, cursor: 'pointer',
|
||||||
});
|
});
|
||||||
marker.setMap(locationMap);
|
const isLP = (isLbs || isWifi) && !isFirst && !isLast;
|
||||||
// Add accuracy radius ring for LBS points (~1000m) and WiFi (~80m)
|
marker._lpFlag = isLP;
|
||||||
if (isLbs && !isFirst && !isLast) {
|
if (_hideLowPrecision && isLP) marker._lpHidden = true;
|
||||||
const ring = new AMap.Circle({
|
else marker.setMap(locationMap);
|
||||||
center: [mLng, mLat], radius: 1000,
|
|
||||||
strokeColor: '#f59e0b', strokeWeight: 1, strokeOpacity: 0.3, strokeStyle: 'dashed',
|
|
||||||
fillColor: '#f59e0b', fillOpacity: 0.05, bubble: true,
|
|
||||||
});
|
|
||||||
ring.setMap(locationMap);
|
|
||||||
mapMarkers.push(ring);
|
|
||||||
} else if (isWifi && !isFirst && !isLast) {
|
|
||||||
const ring = new AMap.Circle({
|
|
||||||
center: [mLng, mLat], radius: 80,
|
|
||||||
strokeColor: '#06b6d4', strokeWeight: 1, strokeOpacity: 0.3, strokeStyle: 'dashed',
|
|
||||||
fillColor: '#06b6d4', fillOpacity: 0.05, bubble: true,
|
|
||||||
});
|
|
||||||
ring.setMap(locationMap);
|
|
||||||
mapMarkers.push(ring);
|
|
||||||
}
|
|
||||||
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) });
|
||||||
@@ -2350,18 +2405,31 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (path.length > 1) {
|
// Store track data for LP filtering
|
||||||
mapPolyline = new AMap.Polyline({ path, strokeColor: '#3b82f6', strokeWeight: 3, strokeOpacity: 0.8, lineJoin: 'round' });
|
_trackLocations = locations;
|
||||||
|
|
||||||
|
// Build path excluding LP points if hidden
|
||||||
|
const filteredPath = _hideLowPrecision
|
||||||
|
? path.filter((_, i) => {
|
||||||
|
const lt = (locations[i]?.location_type || '').toLowerCase();
|
||||||
|
const isFirst = i === 0, isLast = i === locations.length - 1;
|
||||||
|
return isFirst || isLast || !(lt.startsWith('lbs') || lt.startsWith('wifi'));
|
||||||
|
})
|
||||||
|
: path;
|
||||||
|
|
||||||
|
if (filteredPath.length > 1) {
|
||||||
|
mapPolyline = new AMap.Polyline({ path: filteredPath, strokeColor: '#3b82f6', strokeWeight: 3, strokeOpacity: 0.8, lineJoin: 'round' });
|
||||||
mapPolyline.setMap(locationMap);
|
mapPolyline.setMap(locationMap);
|
||||||
|
_trackPolyline = mapPolyline;
|
||||||
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
|
locationMap.setFitView([mapPolyline], false, [50, 50, 50, 50]);
|
||||||
} else if (path.length === 1) {
|
} else if (filteredPath.length === 1) {
|
||||||
locationMap.setCenter(path[0]);
|
locationMap.setCenter(filteredPath[0]);
|
||||||
locationMap.setZoom(15);
|
locationMap.setZoom(15);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legend = document.getElementById('mapLegend');
|
const legend = document.getElementById('mapLegend');
|
||||||
if (legend) legend.style.display = 'block';
|
if (legend) legend.style.display = 'block';
|
||||||
showToast(`已加载 ${total} 个轨迹点`);
|
showToast(`已加载 ${total} 个轨迹点${_hideLowPrecision ? ' (已隐藏低精度)' : ''}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('加载轨迹失败: ' + err.message, 'error');
|
showToast('加载轨迹失败: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -2429,25 +2497,6 @@
|
|||||||
const [mLat, mLng] = toMapCoord(lat, lng);
|
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||||
const marker = new AMap.Marker({ position: [mLng, mLat] });
|
const marker = new AMap.Marker({ position: [mLng, mLat] });
|
||||||
marker.setMap(locationMap);
|
marker.setMap(locationMap);
|
||||||
// Add accuracy radius for LBS/WiFi latest position
|
|
||||||
const _lt = loc.location_type || '';
|
|
||||||
if (_lt.startsWith('lbs')) {
|
|
||||||
const ring = new AMap.Circle({
|
|
||||||
center: [mLng, mLat], radius: 1000,
|
|
||||||
strokeColor: '#f59e0b', strokeWeight: 1, strokeOpacity: 0.4, strokeStyle: 'dashed',
|
|
||||||
fillColor: '#f59e0b', fillOpacity: 0.06, bubble: true,
|
|
||||||
});
|
|
||||||
ring.setMap(locationMap);
|
|
||||||
mapMarkers.push(ring);
|
|
||||||
} else if (_lt.startsWith('wifi')) {
|
|
||||||
const ring = new AMap.Circle({
|
|
||||||
center: [mLng, mLat], radius: 80,
|
|
||||||
strokeColor: '#06b6d4', strokeWeight: 1, strokeOpacity: 0.4, strokeStyle: 'dashed',
|
|
||||||
fillColor: '#06b6d4', fillOpacity: 0.06, bubble: true,
|
|
||||||
});
|
|
||||||
ring.setMap(locationMap);
|
|
||||||
mapMarkers.push(ring);
|
|
||||||
}
|
|
||||||
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]);
|
||||||
@@ -2493,7 +2542,8 @@
|
|||||||
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;
|
||||||
return `<tr style="cursor:${hasCoord?'pointer':'default'}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
|
const lpHide = _hideLowPrecision && _isLowPrecision(l.location_type);
|
||||||
|
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(l.device_id || '-')}</td>
|
||||||
<td>${_locTypeLabel(l.location_type)}</td>
|
<td>${_locTypeLabel(l.location_type)}</td>
|
||||||
@@ -2648,7 +2698,7 @@
|
|||||||
const tbody = document.getElementById('attendanceTableBody');
|
const tbody = document.getElementById('attendanceTableBody');
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有考勤记录</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = items.map(a => {
|
tbody.innerHTML = items.map(a => {
|
||||||
const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-');
|
const posStr = a.address || (a.latitude != null ? `${Number(a.latitude).toFixed(6)}, ${Number(a.longitude).toFixed(6)}` : '-');
|
||||||
@@ -2666,18 +2716,31 @@
|
|||||||
<td class="text-xs">${battStr} ${sigStr}</td>
|
<td class="text-xs">${battStr} ${sigStr}</td>
|
||||||
<td class="text-xs font-mono">${lbsStr}</td>
|
<td class="text-xs font-mono">${lbsStr}</td>
|
||||||
<td class="text-xs text-gray-400">${formatTime(a.recorded_at)}</td>
|
<td class="text-xs text-gray-400">${formatTime(a.recorded_at)}</td>
|
||||||
|
<td><button class="btn btn-sm" style="color:#ef4444;padding:2px 8px;font-size:11px" onclick="deleteAttendance(${a.id})"><i class="fas fa-trash-alt"></i></button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance');
|
renderPagination('attendancePagination', data.total || 0, data.page || p, data.page_size || ps, 'loadAttendance');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('加载考勤记录失败: ' + err.message, 'error');
|
showToast('加载考勤记录失败: ' + err.message, 'error');
|
||||||
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="7" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
document.getElementById('attendanceTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||||
} finally {
|
} finally {
|
||||||
hideLoading('attendanceLoading');
|
hideLoading('attendanceLoading');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAttendance(id) {
|
||||||
|
if (!confirm('确认删除此考勤记录?')) return;
|
||||||
|
try {
|
||||||
|
await apiCall(`${API_BASE}/attendance/${id}`, {method: 'DELETE'});
|
||||||
|
showToast('删除成功', 'success');
|
||||||
|
loadAttendance();
|
||||||
|
loadAttendanceStats();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('删除失败: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== BLUETOOTH ====================
|
// ==================== BLUETOOTH ====================
|
||||||
async function loadBluetooth(page) {
|
async function loadBluetooth(page) {
|
||||||
if (page) pageState.bluetooth.page = page;
|
if (page) pageState.bluetooth.page = page;
|
||||||
@@ -3471,89 +3534,167 @@
|
|||||||
function switchFenceTab(tab) {
|
function switchFenceTab(tab) {
|
||||||
document.getElementById('fenceTabList').classList.toggle('active', tab === 'list');
|
document.getElementById('fenceTabList').classList.toggle('active', tab === 'list');
|
||||||
document.getElementById('fenceTabBindings').classList.toggle('active', tab === 'bindings');
|
document.getElementById('fenceTabBindings').classList.toggle('active', tab === 'bindings');
|
||||||
document.getElementById('fenceTabContentList').style.display = tab === 'list' ? '' : 'none';
|
document.getElementById('fenceTabContentList').style.display = tab === 'list' ? 'flex' : 'none';
|
||||||
document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? '' : 'none';
|
document.getElementById('fenceTabContentBindings').style.display = tab === 'bindings' ? 'flex' : 'none';
|
||||||
if (tab === 'bindings') initFenceBindingTab();
|
if (tab === 'bindings') loadBindingMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initFenceBindingTab() {
|
let _bindMatrixState = {}; // { "fenceId-deviceId": true/false }
|
||||||
const sel = document.getElementById('fenceBindSelect');
|
let _bindMatrixOriginal = {}; // original state for diff
|
||||||
if (sel.options.length <= 1) {
|
let _bindFences = [];
|
||||||
// Populate fence dropdown
|
let _bindDevices = [];
|
||||||
try {
|
|
||||||
const data = await apiCall(`${API_BASE}/fences?page=1&page_size=100`);
|
|
||||||
const items = data.items || [];
|
|
||||||
sel.innerHTML = '<option value="">选择围栏...</option>' + items.map(f =>
|
|
||||||
`<option value="${f.id}">${escapeHtml(f.name)} (${f.fence_type === 'circle' ? '圆形' : '多边形'})</option>`
|
|
||||||
).join('');
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
// Populate device add dropdown
|
|
||||||
const devSel = document.getElementById('fenceBindDeviceAdd');
|
|
||||||
if (devSel.options.length <= 1) {
|
|
||||||
try {
|
|
||||||
const devData = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
|
|
||||||
const allDevices = devData.items || [];
|
|
||||||
devSel.innerHTML = '<option value="">选择设备添加绑定...</option>' + allDevices.map(d =>
|
|
||||||
`<option value="${d.id}">${escapeHtml(d.name || d.imei)} (${d.imei})</option>`
|
|
||||||
).join('');
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFenceBindingTab() {
|
async function loadBindingMatrix() {
|
||||||
const fenceId = document.getElementById('fenceBindSelect').value;
|
const thead = document.getElementById('fenceBindMatrixHead');
|
||||||
const tbody = document.getElementById('fenceBindTableBody');
|
const tbody = document.getElementById('fenceBindMatrixBody');
|
||||||
if (!fenceId) {
|
try {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">请选择围栏</td></tr>';
|
const [fenceData, deviceData] = await Promise.all([
|
||||||
|
apiCall(`${API_BASE}/fences?page=1&page_size=100`),
|
||||||
|
apiCall(`${API_BASE}/devices?page=1&page_size=100`),
|
||||||
|
]);
|
||||||
|
_bindFences = fenceData.items || [];
|
||||||
|
_bindDevices = deviceData.items || [];
|
||||||
|
|
||||||
|
if (!_bindFences.length) {
|
||||||
|
thead.innerHTML = '';
|
||||||
|
tbody.innerHTML = '<tr><td class="text-center text-gray-500 py-4">暂无围栏,请先创建</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (!_bindDevices.length) {
|
||||||
const devices = await apiCall(`${API_BASE}/fences/${fenceId}/devices`);
|
thead.innerHTML = '';
|
||||||
if (devices.length === 0) {
|
tbody.innerHTML = '<tr><td class="text-center text-gray-500 py-4">暂无设备</td></tr>';
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">暂无绑定设备,请从上方添加</td></tr>';
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch bindings for all fences in parallel
|
||||||
|
const bindingsArr = await Promise.all(
|
||||||
|
_bindFences.map(f => apiCall(`${API_BASE}/fences/${f.id}/devices`).catch(() => []))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build state
|
||||||
|
_bindMatrixState = {};
|
||||||
|
_bindMatrixOriginal = {};
|
||||||
|
_bindFences.forEach((f, i) => {
|
||||||
|
const bound = bindingsArr[i] || [];
|
||||||
|
const boundIds = new Set(bound.map(b => b.device_id));
|
||||||
|
_bindDevices.forEach(d => {
|
||||||
|
const key = `${f.id}-${d.id}`;
|
||||||
|
const val = boundIds.has(d.id);
|
||||||
|
_bindMatrixState[key] = val;
|
||||||
|
_bindMatrixOriginal[key] = val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render header
|
||||||
|
const fenceTypeName = t => ({circle:'圆',polygon:'多边',rectangle:'矩'}[t] || t);
|
||||||
|
thead.innerHTML = `<tr>
|
||||||
|
<th style="position:sticky;left:0;background:#1f2937;z-index:2;min-width:120px">设备 \\ 围栏</th>
|
||||||
|
${_bindFences.map(f => `<th style="text-align:center;min-width:80px;font-size:11px;white-space:nowrap" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}<br><span style="color:#6b7280;font-weight:normal">${fenceTypeName(f.fence_type)}</span></th>`).join('')}
|
||||||
|
<th style="text-align:center;min-width:60px">全选</th>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// Render body
|
||||||
|
tbody.innerHTML = _bindDevices.map(d => {
|
||||||
|
const label = d.name || d.imei || d.id;
|
||||||
|
const statusDot = d.status === 'online' ? '🟢' : '⚪';
|
||||||
|
return `<tr>
|
||||||
|
<td style="position:sticky;left:0;background:#111827;z-index:1;white-space:nowrap;font-size:12px">${statusDot} ${escapeHtml(label)}</td>
|
||||||
|
${_bindFences.map(f => {
|
||||||
|
const key = `${f.id}-${d.id}`;
|
||||||
|
const checked = _bindMatrixState[key] ? 'checked' : '';
|
||||||
|
return `<td style="text-align:center"><input type="checkbox" ${checked} onchange="_bindMatrixState['${key}']=this.checked;updateBindSaveBtn()"></td>`;
|
||||||
|
}).join('')}
|
||||||
|
<td style="text-align:center"><input type="checkbox" onchange="toggleDeviceRow(${d.id},this.checked)"></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('') + `<tr style="border-top:1px solid #374151">
|
||||||
|
<td style="position:sticky;left:0;background:#111827;z-index:1;font-size:12px;color:#9ca3af">全选列</td>
|
||||||
|
${_bindFences.map(f => `<td style="text-align:center"><input type="checkbox" onchange="toggleFenceCol(${f.id},this.checked)"></td>`).join('')}
|
||||||
|
<td style="text-align:center"><input type="checkbox" onchange="toggleAllBindings(this.checked)"></td>
|
||||||
|
</tr>`;
|
||||||
|
updateBindSaveBtn();
|
||||||
|
} catch (err) {
|
||||||
|
thead.innerHTML = '';
|
||||||
|
tbody.innerHTML = `<tr><td class="text-center text-red-400 py-4">加载失败: ${escapeHtml(err.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDeviceRow(deviceId, checked) {
|
||||||
|
_bindFences.forEach(f => {
|
||||||
|
const key = `${f.id}-${deviceId}`;
|
||||||
|
_bindMatrixState[key] = checked;
|
||||||
|
});
|
||||||
|
refreshMatrixCheckboxes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFenceCol(fenceId, checked) {
|
||||||
|
_bindDevices.forEach(d => {
|
||||||
|
const key = `${fenceId}-${d.id}`;
|
||||||
|
_bindMatrixState[key] = checked;
|
||||||
|
});
|
||||||
|
refreshMatrixCheckboxes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllBindings(checked) {
|
||||||
|
_bindFences.forEach(f => _bindDevices.forEach(d => {
|
||||||
|
_bindMatrixState[`${f.id}-${d.id}`] = checked;
|
||||||
|
}));
|
||||||
|
refreshMatrixCheckboxes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMatrixCheckboxes() {
|
||||||
|
const tbody = document.getElementById('fenceBindMatrixBody');
|
||||||
|
const cbs = tbody.querySelectorAll('input[type="checkbox"]');
|
||||||
|
cbs.forEach(cb => {
|
||||||
|
const onchange = cb.getAttribute('onchange') || '';
|
||||||
|
const m = onchange.match(/_bindMatrixState\['(\d+-\d+)'\]/);
|
||||||
|
if (m) cb.checked = _bindMatrixState[m[1]] || false;
|
||||||
|
});
|
||||||
|
updateBindSaveBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBindSaveBtn() {
|
||||||
|
// Count changes
|
||||||
|
let changes = 0;
|
||||||
|
for (const key in _bindMatrixState) {
|
||||||
|
if (_bindMatrixState[key] !== _bindMatrixOriginal[key]) changes++;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('fenceBindSaveBtn');
|
||||||
|
if (btn) btn.innerHTML = changes > 0
|
||||||
|
? `<i class="fas fa-save"></i> 保存更改 (${changes})`
|
||||||
|
: `<i class="fas fa-save"></i> 保存更改`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBindingMatrix() {
|
||||||
|
// Compute diffs per fence: which devices to bind, which to unbind
|
||||||
|
const toBind = {}; // fenceId -> [deviceIds]
|
||||||
|
const toUnbind = {}; // fenceId -> [deviceIds]
|
||||||
|
for (const key in _bindMatrixState) {
|
||||||
|
if (_bindMatrixState[key] === _bindMatrixOriginal[key]) continue;
|
||||||
|
const [fenceId, deviceId] = key.split('-').map(Number);
|
||||||
|
if (_bindMatrixState[key]) {
|
||||||
|
(toBind[fenceId] = toBind[fenceId] || []).push(deviceId);
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = devices.map(d => `<tr>
|
(toUnbind[fenceId] = toUnbind[fenceId] || []).push(deviceId);
|
||||||
<td>${escapeHtml(d.device_name || '-')}</td>
|
|
||||||
<td class="font-mono text-xs">${escapeHtml(d.imei || '-')}</td>
|
|
||||||
<td><span class="badge ${d.is_inside ? 'badge-online' : 'badge-offline'}">${d.is_inside ? '围栏内' : '围栏外'}</span></td>
|
|
||||||
<td class="text-xs text-gray-400">${d.last_check_at ? formatTime(d.last_check_at) : '-'}</td>
|
|
||||||
<td><button class="btn btn-sm" style="color:#ef4444;font-size:11px" onclick="quickUnbindDevice(${fenceId},${d.device_id},'${escapeHtml(d.device_name||d.imei||"")}')"><i class="fas fa-unlink"></i> 解绑</button></td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-4">加载失败</td></tr>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const ops = [];
|
||||||
async function quickBindDevice() {
|
for (const fid in toBind) {
|
||||||
const fenceId = document.getElementById('fenceBindSelect').value;
|
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
|
||||||
const deviceId = document.getElementById('fenceBindDeviceAdd').value;
|
method: 'POST', body: JSON.stringify({ device_ids: toBind[fid] }),
|
||||||
if (!fenceId) { showToast('请先选择围栏', 'info'); return; }
|
}));
|
||||||
if (!deviceId) { showToast('请选择要绑定的设备', 'info'); return; }
|
}
|
||||||
|
for (const fid in toUnbind) {
|
||||||
|
ops.push(apiCall(`${API_BASE}/fences/${fid}/devices`, {
|
||||||
|
method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[fid] }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!ops.length) { showToast('没有更改', 'info'); return; }
|
||||||
try {
|
try {
|
||||||
await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
|
await Promise.all(ops);
|
||||||
method: 'POST',
|
showToast(`保存成功 (${ops.length} 项操作)`, 'success');
|
||||||
body: JSON.stringify({ device_ids: [parseInt(deviceId)] }),
|
loadBindingMatrix(); // reload to sync state
|
||||||
});
|
|
||||||
showToast('绑定成功');
|
|
||||||
loadFenceBindingTab();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('绑定失败: ' + err.message, 'error');
|
showToast('保存失败: ' + err.message, 'error');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function quickUnbindDevice(fenceId, deviceId, name) {
|
|
||||||
if (!confirm(`确定解绑设备 "${name}" ?`)) return;
|
|
||||||
try {
|
|
||||||
await apiCall(`${API_BASE}/fences/${fenceId}/devices`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: JSON.stringify({ device_ids: [deviceId] }),
|
|
||||||
});
|
|
||||||
showToast('已解绑');
|
|
||||||
loadFenceBindingTab();
|
|
||||||
} catch (err) {
|
|
||||||
showToast('解绑失败: ' + err.message, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2064,12 +2064,53 @@ class TCPManager:
|
|||||||
beacon_major, beacon_minor, rssi,
|
beacon_major, beacon_minor, rssi,
|
||||||
beacon_battery or 0,
|
beacon_battery or 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create AttendanceRecord for bluetooth punch
|
||||||
|
from app.services.fence_checker import _has_attendance_today
|
||||||
|
if not await _has_attendance_today(session, device_id, attendance_type):
|
||||||
|
device = await session.get(Device, device_id)
|
||||||
|
att_record = AttendanceRecord(
|
||||||
|
device_id=device_id,
|
||||||
|
imei=imei,
|
||||||
|
attendance_type=attendance_type,
|
||||||
|
attendance_source="bluetooth",
|
||||||
|
protocol_number=pkt["protocol"],
|
||||||
|
gps_positioned=False,
|
||||||
|
latitude=beacon_lat,
|
||||||
|
longitude=beacon_lon,
|
||||||
|
address=beacon_addr,
|
||||||
|
battery_level=device.battery_level if device else None,
|
||||||
|
gsm_signal=device.gsm_signal if device else None,
|
||||||
|
lbs_data={
|
||||||
|
"source": "bluetooth",
|
||||||
|
"beacon_mac": beacon_mac,
|
||||||
|
"beacon_name": beacon_cfg.name if beacon_cfg else None,
|
||||||
|
},
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
)
|
||||||
|
session.add(att_record)
|
||||||
|
logger.info(
|
||||||
|
"BT attendance created: IMEI=%s type=%s beacon=%s",
|
||||||
|
imei, attendance_type, beacon_mac,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"BT attendance dedup: IMEI=%s already has %s today",
|
||||||
|
imei, attendance_type,
|
||||||
|
)
|
||||||
|
|
||||||
# Broadcast bluetooth punch
|
# Broadcast bluetooth punch
|
||||||
ws_manager.broadcast_nonblocking("bluetooth", {
|
ws_manager.broadcast_nonblocking("bluetooth", {
|
||||||
"imei": imei, "record_type": "punch",
|
"imei": imei, "record_type": "punch",
|
||||||
"beacon_mac": beacon_mac, "attendance_type": attendance_type,
|
"beacon_mac": beacon_mac, "attendance_type": attendance_type,
|
||||||
"recorded_at": str(recorded_at),
|
"recorded_at": str(recorded_at),
|
||||||
})
|
})
|
||||||
|
ws_manager.broadcast_nonblocking("attendance", {
|
||||||
|
"imei": imei, "attendance_type": attendance_type,
|
||||||
|
"latitude": beacon_lat, "longitude": beacon_lon,
|
||||||
|
"address": beacon_addr, "recorded_at": str(recorded_at),
|
||||||
|
"source": "bluetooth",
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("DB error storing BT punch for IMEI=%s", imei)
|
logger.exception("DB error storing BT punch for IMEI=%s", imei)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user