Files
UAVManager/main.py
T
2026-08-07 23:04:54 +08:00

1145 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""无人机资产管理系统 — FastAPI 后端
BS 架构:FastAPI + SQLite(Python 内置 sqlite3),前端为 static/ 下的原生页面。
状态按"每次修改一条记录(含时间戳)"保存,当前状态取最新一条。
"""
import sqlite3
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
try:
import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
except ImportError: # openpyxl 未安装时仅禁用 Excel 导出,不影响系统运行
openpyxl = None
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "uav.db"
STATIC_DIR = BASE_DIR / "static"
EXCEL_PATH = BASE_DIR / "无人机台账.xlsx"
STATUSES = ["待组装", "待调试", "待飞", "损坏"] # 内置默认状态
NO_STATUS = "__none__" # 筛选"无状态"专用标记
STATUS_COLORS = ["#9e9e9e", "#ff9800", "#4caf50", "#f44336"] # 对应内置颜色
DEFAULT_STATUS_COLORS = [ # 新增自定义状态时的自动配色候选
"#9e9e9e", "#ff9800", "#4caf50", "#f44336",
"#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6",
"#f59e0b", "#64748b",
]
# ---------------------------------------------------------------- 数据库
def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db() -> None:
with get_conn() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS drones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model TEXT NOT NULL, -- 机型
serial_no TEXT NOT NULL UNIQUE, -- 编号
link_id TEXT NOT NULL DEFAULT '', -- 链路ID
fc_id TEXT NOT NULL DEFAULT '', -- 飞控ID
fc_version TEXT NOT NULL DEFAULT '', -- 飞控版本号
nav_version TEXT NOT NULL DEFAULT '', -- 导航版本号
remark TEXT NOT NULL DEFAULT '', -- 备注
created_at TEXT NOT NULL -- 创建时间(ISO)
);
CREATE TABLE IF NOT EXISTS drone_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drone_id INTEGER NOT NULL REFERENCES drones(id) ON DELETE CASCADE,
status TEXT NOT NULL, -- 主状态
remark TEXT NOT NULL DEFAULT '', -- 本次变更备注
changed_at TEXT NOT NULL -- 变更时间戳(ISO)
);
CREATE INDEX IF NOT EXISTS idx_status_drone ON drone_status(drone_id, changed_at);
CREATE TABLE IF NOT EXISTS status_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- 状态名称
color TEXT NOT NULL DEFAULT '#9e9e9e', -- 展示颜色
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序
is_builtin INTEGER NOT NULL DEFAULT 0 -- 是否内置(内置不可删)
);
CREATE TABLE IF NOT EXISTS flight_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drone_id INTEGER NOT NULL REFERENCES drones(id) ON DELETE CASCADE,
flight_date TEXT NOT NULL, -- 飞行日期 YYYY-MM-DD
duration_min INTEGER NOT NULL DEFAULT 0, -- 飞行时长(分钟)
takeoff TEXT NOT NULL DEFAULT '', -- 起飞地点
landing TEXT NOT NULL DEFAULT '', -- 降落地点
remark TEXT NOT NULL DEFAULT '', -- 备注
created_at TEXT NOT NULL -- 录入时间
);
CREATE INDEX IF NOT EXISTS idx_flight_drone ON flight_records(drone_id, flight_date);
"""
)
# 存量库迁移:为已存在的 drones 表补充版本号列
cols = {r[1] for r in conn.execute("PRAGMA table_info(drones)")}
if "fc_version" not in cols:
conn.execute("ALTER TABLE drones ADD COLUMN fc_version TEXT NOT NULL DEFAULT ''")
if "nav_version" not in cols:
conn.execute("ALTER TABLE drones ADD COLUMN nav_version TEXT NOT NULL DEFAULT ''")
# 预置内置状态(幂等)
for i, (s, c) in enumerate(zip(STATUSES, STATUS_COLORS), start=1):
conn.execute(
"INSERT OR IGNORE INTO status_defs (name, color, sort_order, is_builtin) "
"VALUES (?, ?, ?, 1)",
(s, c, i),
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
yield
app = FastAPI(title="无人机资产管理系统", lifespan=lifespan)
# ---------------------------------------------------------------- 模型
class DroneCreate(BaseModel):
model: str
serial_no: str
link_id: str = ""
fc_id: str = ""
fc_version: str = ""
nav_version: str = ""
remark: str = ""
class DroneUpdate(BaseModel):
model: Optional[str] = None
serial_no: Optional[str] = None
link_id: Optional[str] = None
fc_id: Optional[str] = None
fc_version: Optional[str] = None
nav_version: Optional[str] = None
remark: Optional[str] = None
class StatusChange(BaseModel):
status: str
remark: str = ""
changed_at: Optional[str] = None # 可选:指定变更时间(补充历史用);缺省为当前时间
class StatusBatchChange(BaseModel):
ids: List[int]
status: str
remark: str = ""
changed_at: Optional[str] = None
class StatusRecordUpdate(BaseModel):
status: Optional[str] = None
remark: Optional[str] = None
changed_at: Optional[str] = None
class StatusDefCreate(BaseModel):
name: str
color: Optional[str] = None
class StatusDefUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
class FlightCreate(BaseModel):
flight_date: str
duration_min: int = 0
takeoff: str = ""
landing: str = ""
remark: str = ""
class FlightUpdate(BaseModel):
flight_date: Optional[str] = None
duration_min: Optional[int] = None
takeoff: Optional[str] = None
landing: Optional[str] = None
remark: Optional[str] = None
class FlightBatchCreate(BaseModel):
serial_nos: List[str]
flight_date: str
duration_min: int = 0
takeoff: str = ""
landing: str = ""
remark: str = ""
class FlightBatchUpdate(BaseModel):
ids: List[int]
flight_date: str
duration_min: Optional[int] = None
takeoff: Optional[str] = None
landing: Optional[str] = None
remark: Optional[str] = None
class FlightBatchDelete(BaseModel):
ids: List[int]
mode: str = "all" # all=全部 | date=按日期
flight_date: Optional[str] = None
class DroneBatchDelete(BaseModel):
ids: List[int]
def _check_date(s: str) -> str:
"""校验 YYYY-MM-DD 并返回规范化日期。"""
try:
return date.fromisoformat(s).isoformat()
except ValueError:
raise HTTPException(400, "日期格式应为 YYYY-MM-DD")
def _flight_row(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"drone_id": row["drone_id"],
"flight_date": row["flight_date"],
"duration_min": row["duration_min"],
"takeoff": row["takeoff"],
"landing": row["landing"],
"remark": row["remark"],
"created_at": row["created_at"],
}
def _drone_row(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"model": row["model"],
"serial_no": row["serial_no"],
"link_id": row["link_id"],
"fc_id": row["fc_id"],
"fc_version": row["fc_version"],
"nav_version": row["nav_version"],
"remark": row["remark"],
"created_at": row["created_at"],
}
def _current_status(conn: sqlite3.Connection, drone_id: int) -> Optional[dict]:
row = conn.execute(
"SELECT status, remark, changed_at FROM drone_status "
"WHERE drone_id = ? ORDER BY changed_at DESC, id DESC LIMIT 1",
(drone_id,),
).fetchone()
return dict(row) if row else None
# ---------------------------------------------------------------- Excel 台账
def export_to_excel(
models: Optional[List[str]] = None,
start: Optional[str] = None,
end: Optional[str] = None,
) -> Optional[str]:
"""导出 Excel 台账(手动触发)。
- 不指定日期:导出「无人机台账」+「状态历史」两个 sheet。
- 指定日期范围(start/end,YYYY-MM-DD):导出「每日状态矩阵」
(行=日期、列=编号、状态按颜色着色)+「状态变更明细」(范围内) +
「无人机台账」(基础信息) 三个 sheet。
可按机型筛选(models 缺省导出全部)。失败返回 None。
"""
if openpyxl is None:
return None
try:
wb = openpyxl.Workbook()
where = ""
params: tuple = ()
if models:
where = f"WHERE d.model IN ({','.join('?' * len(models))})"
params = tuple(models)
with get_conn() as conn:
rows = conn.execute(
"SELECT d.*, s.status AS cur_status "
"FROM drones d "
"LEFT JOIN drone_status s ON s.id = ("
" SELECT id FROM drone_status WHERE drone_id = d.id "
" ORDER BY changed_at DESC, id DESC LIMIT 1) "
f"{where} "
"ORDER BY d.model, d.serial_no",
params,
).fetchall()
has_dates = bool(start and end)
if has_dates:
# ---- 日期范围解析 ----
d0, d1 = date.fromisoformat(start), date.fromisoformat(end)
if d0 > d1:
d0, d1 = d1, d0
dates = []
d = d0
while d <= d1:
dates.append(d.isoformat())
d += timedelta(days=1)
first_day = dates[0]
color_map = {
x["name"]: x["color"]
for x in conn.execute("SELECT name, color FROM status_defs").fetchall()
}
# 每架无人机逐日状态(无记录日期沿用前一状态)
drone_cells = []
for r in rows:
hist = conn.execute(
"SELECT status, changed_at FROM drone_status "
"WHERE drone_id = ? ORDER BY changed_at ASC, id ASC",
(r["id"],),
).fetchall()
last = ""
for h in hist:
if h["changed_at"][:10] < first_day:
last = h["status"]
else:
break
by_day = {}
for h in hist:
by_day[h["changed_at"][:10]] = h["status"]
cells = []
for ds in dates:
if ds in by_day:
last = by_day[ds]
cells.append(last)
drone_cells.append((r, cells))
# 范围内状态变更明细
statuses = conn.execute(
"SELECT d.serial_no, d.model, s.status, s.remark, s.changed_at "
"FROM drone_status s JOIN drones d ON d.id = s.drone_id "
f"{where} AND date(s.changed_at) BETWEEN ? AND ? "
"ORDER BY s.changed_at, s.id",
params + (dates[0], dates[-1]),
).fetchall()
# ---- Sheet1: 每日状态矩阵 ----
ws = wb.active
ws.title = "每日状态矩阵"
ws.append(["日期 \\ 编号"] + [dc[0]["serial_no"] for dc in drone_cells])
for i, ds in enumerate(dates):
ws.append([ds] + [dc[1][i] for dc in drone_cells])
for ri in range(2, len(dates) + 2):
for ci in range(2, len(drone_cells) + 2):
cell = ws.cell(ri, ci)
if cell.value:
col = color_map.get(cell.value, "#9e9e9e").lstrip("#")
cell.fill = PatternFill("solid", fgColor=col)
cell.font = Font(color="FFFFFF")
# ---- Sheet2: 状态变更明细 ----
ws2 = wb.create_sheet("状态变更明细")
ws2.append(["编号", "机型", "状态", "备注", "变更时间"])
for s in statuses:
ws2.append([s["serial_no"], s["model"], s["status"],
s["remark"], s["changed_at"]])
# ---- Sheet3: 无人机台账 ----
ws3 = wb.create_sheet("无人机台账")
ws3.append(["机型", "编号", "链路ID", "飞控ID", "飞控版本",
"导航版本", "当前状态", "备注", "创建时间"])
for r in rows:
ws3.append([
r["model"], r["serial_no"], r["link_id"], r["fc_id"],
r["fc_version"], r["nav_version"],
r["cur_status"] or "", r["remark"], r["created_at"],
])
sheets = [ws, ws2, ws3]
else:
# ---- 无日期:台账 + 状态历史 ----
statuses = conn.execute(
"SELECT d.serial_no, d.model, s.status, s.remark, s.changed_at "
"FROM drone_status s JOIN drones d ON d.id = s.drone_id "
f"{where} "
"ORDER BY s.changed_at, s.id",
params,
).fetchall()
ws = wb.active
ws.title = "无人机台账"
ws.append(["机型", "编号", "链路ID", "飞控ID", "飞控版本",
"导航版本", "当前状态", "备注", "创建时间"])
for r in rows:
ws.append([
r["model"], r["serial_no"], r["link_id"], r["fc_id"],
r["fc_version"], r["nav_version"],
r["cur_status"] or "", r["remark"], r["created_at"],
])
ws2 = wb.create_sheet("状态历史")
ws2.append(["编号", "机型", "状态", "备注", "变更时间"])
for s in statuses:
ws2.append([s["serial_no"], s["model"], s["status"],
s["remark"], s["changed_at"]])
sheets = [ws, ws2]
# ---- 样式 ----
for sheet in sheets:
for cell in sheet[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="2563EB")
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
for col_idx, col_cells in enumerate(sheet.columns, start=1):
width = max(len(str(c.value)) if c.value is not None else 0
for c in col_cells)
sheet.column_dimensions[get_column_letter(col_idx)].width = min(max(width + 4, 10), 60)
wb.save(EXCEL_PATH)
return str(EXCEL_PATH)
except Exception as exc: # 导出失败不阻断业务操作
print(f"[Excel 导出失败] {exc}", flush=True)
return None
# ---------------------------------------------------------------- 无人机 CRUD
@app.get("/api/drones")
def list_drones(
model: Optional[str] = Query(None),
serial_no: Optional[str] = Query(None),
fc_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
):
"""无人机列表,支持机型/编号/飞控ID/状态(当前状态)筛选。"""
with get_conn() as conn:
sql = "SELECT * FROM drones WHERE 1=1"
params: list = []
if model:
sql += " AND model = ?"
params.append(model)
if serial_no:
sql += " AND serial_no LIKE ?"
params.append(f"%{serial_no}%")
if fc_id:
sql += " AND fc_id LIKE ?"
params.append(f"%{fc_id}%")
sql += " ORDER BY id"
rows = conn.execute(sql, params).fetchall()
result = []
for r in rows:
cur = _current_status(conn, r["id"])
if status == NO_STATUS:
if cur:
continue
elif status:
if not cur or cur["status"] != status:
continue
item = _drone_row(r)
item["current_status"] = cur
result.append(item)
return result
@app.get("/api/drones/models")
def list_models():
"""所有机型(供筛选/透视表下拉框)。"""
with get_conn() as conn:
rows = conn.execute(
"SELECT DISTINCT model FROM drones ORDER BY model"
).fetchall()
return [r["model"] for r in rows]
@app.post("/api/drones", status_code=201)
def create_drone(body: DroneCreate):
model = body.model.strip()
serial_no = body.serial_no.strip()
if not model or not serial_no:
raise HTTPException(400, "机型与编号不能为空")
with get_conn() as conn:
exists = conn.execute(
"SELECT 1 FROM drones WHERE serial_no = ?", (serial_no,)
).fetchone()
if exists:
raise HTTPException(409, f"编号 {serial_no} 已存在")
cur = conn.execute(
"INSERT INTO drones (model, serial_no, link_id, fc_id, fc_version, nav_version, remark, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(model, serial_no, body.link_id.strip(), body.fc_id.strip(),
body.fc_version.strip(), body.nav_version.strip(),
body.remark.strip(), date.today().isoformat()),
)
return _drone_row(
conn.execute("SELECT * FROM drones WHERE id = ?", (cur.lastrowid,)).fetchone()
)
@app.put("/api/drones/{drone_id}")
def update_drone(drone_id: int, body: DroneUpdate):
with get_conn() as conn:
row = conn.execute("SELECT * FROM drones WHERE id = ?", (drone_id,)).fetchone()
if not row:
raise HTTPException(404, "无人机不存在")
new_serial = body.serial_no.strip() if body.serial_no is not None else row["serial_no"]
new_model = body.model.strip() if body.model is not None else row["model"]
new_link = body.link_id.strip() if body.link_id is not None else row["link_id"]
new_fc = body.fc_id.strip() if body.fc_id is not None else row["fc_id"]
new_fcv = body.fc_version.strip() if body.fc_version is not None else row["fc_version"]
new_nav = body.nav_version.strip() if body.nav_version is not None else row["nav_version"]
new_remark = body.remark.strip() if body.remark is not None else row["remark"]
if not new_serial:
raise HTTPException(400, "编号不能为空")
if new_serial != row["serial_no"]:
exists = conn.execute(
"SELECT 1 FROM drones WHERE serial_no = ? AND id != ?",
(new_serial, drone_id),
).fetchone()
if exists:
raise HTTPException(409, f"编号 {new_serial} 已存在")
conn.execute(
"UPDATE drones SET model = ?, serial_no = ?, link_id = ?, fc_id = ?, "
"fc_version = ?, nav_version = ?, remark = ? WHERE id = ?",
(new_model, new_serial, new_link, new_fc, new_fcv, new_nav, new_remark, drone_id),
)
return _drone_row(
conn.execute("SELECT * FROM drones WHERE id = ?", (drone_id,)).fetchone()
)
@app.delete("/api/drones/{drone_id}", status_code=204)
def delete_drone(drone_id: int):
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM drones WHERE id = ?", (drone_id,)).fetchone()
if not row:
raise HTTPException(404, "无人机不存在")
conn.execute("DELETE FROM drones WHERE id = ?", (drone_id,)) # 级联删除状态
return None
# ---------------------------------------------------------------- 状态
@app.post("/api/drones/{drone_id}/status", status_code=201)
def change_status(drone_id: int, body: StatusChange):
"""修改主状态:写入一条含时间戳的记录。"""
status = body.status.strip()
with get_conn() as conn:
def_row = conn.execute(
"SELECT 1 FROM status_defs WHERE name = ?", (status,)
).fetchone()
if not def_row:
raise HTTPException(400, f"无效状态:{status} 不在状态列表中")
row = conn.execute("SELECT 1 FROM drones WHERE id = ?", (drone_id,)).fetchone()
if not row:
raise HTTPException(404, "无人机不存在")
now = datetime.now().isoformat(timespec="seconds")
ts = now
if body.changed_at:
raw = body.changed_at.strip().replace(" ", "T")
try:
dt = datetime.fromisoformat(raw)
ts = dt.strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
raise HTTPException(
400, "changed_at 格式无效,应为 YYYY-MM-DD 或 YYYY-MM-DD HH:MM[:SS]"
)
conn.execute(
"INSERT INTO drone_status (drone_id, status, remark, changed_at) VALUES (?, ?, ?, ?)",
(drone_id, status, body.remark.strip(), ts),
)
return {"drone_id": drone_id, "status": status, "remark": body.remark.strip(), "changed_at": ts}
@app.post("/api/drones/status/batch", status_code=201)
def batch_change_status(body: StatusBatchChange):
"""批量修改状态:一次给多架无人机写入同一条状态记录(支持补录时间)。"""
status = body.status.strip()
ids = list(dict.fromkeys(body.ids))
if not ids:
raise HTTPException(400, "请至少选择一架无人机")
with get_conn() as conn:
def_row = conn.execute(
"SELECT 1 FROM status_defs WHERE name = ?", (status,)
).fetchone()
if not def_row:
raise HTTPException(400, f"无效状态:{status} 不在状态列表中")
if body.changed_at:
raw = body.changed_at.strip().replace(" ", "T")
try:
ts = datetime.fromisoformat(raw).strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
raise HTTPException(
400, "changed_at 格式无效,应为 YYYY-MM-DD 或 YYYY-MM-DD HH:MM[:SS]"
)
else:
ts = datetime.now().isoformat(timespec="seconds")
remark = body.remark.strip()
updated, missing = [], []
for did in ids:
row = conn.execute("SELECT 1 FROM drones WHERE id = ?", (did,)).fetchone()
if not row:
missing.append(did)
continue
conn.execute(
"INSERT INTO drone_status (drone_id, status, remark, changed_at) VALUES (?, ?, ?, ?)",
(did, status, remark, ts),
)
updated.append(did)
return {"updated": updated, "missing": missing}
@app.get("/api/drones/{drone_id}/history")
def status_history(drone_id: int):
"""单机完整状态变更记录(时间正序)。"""
with get_conn() as conn:
row = conn.execute("SELECT * FROM drones WHERE id = ?", (drone_id,)).fetchone()
if not row:
raise HTTPException(404, "无人机不存在")
rows = conn.execute(
"SELECT id, status, remark, changed_at FROM drone_status "
"WHERE drone_id = ? ORDER BY changed_at ASC, id ASC",
(drone_id,),
).fetchall()
drone = _drone_row(row)
drone["current_status"] = _current_status(conn, drone_id)
return {"drone": drone, "history": [dict(r) for r in rows]}
@app.delete("/api/drones/{drone_id}/history/{record_id}", status_code=204)
def delete_status_record(drone_id: int, record_id: int):
"""删除单条状态历史记录(删除后当前状态取剩余最新一条)。"""
with get_conn() as conn:
row = conn.execute(
"SELECT 1 FROM drone_status WHERE id = ? AND drone_id = ?",
(record_id, drone_id),
).fetchone()
if not row:
raise HTTPException(404, "状态记录不存在")
conn.execute("DELETE FROM drone_status WHERE id = ?", (record_id,))
return None
@app.put("/api/drones/{drone_id}/history/{record_id}")
def update_status_record(drone_id: int, record_id: int, body: StatusRecordUpdate):
"""修改单条状态历史记录(状态/备注/变更时间;None 字段不修改)。"""
with get_conn() as conn:
rec = conn.execute(
"SELECT * FROM drone_status WHERE id = ? AND drone_id = ?",
(record_id, drone_id),
).fetchone()
if not rec:
raise HTTPException(404, "状态记录不存在")
new_status = body.status.strip() if body.status is not None else rec["status"]
def_row = conn.execute(
"SELECT 1 FROM status_defs WHERE name = ?", (new_status,)
).fetchone()
if not def_row:
raise HTTPException(400, f"无效状态:{new_status} 不在状态列表中")
new_remark = body.remark if body.remark is not None else rec["remark"]
new_ts = rec["changed_at"]
if body.changed_at:
raw = body.changed_at.strip().replace(" ", "T")
try:
new_ts = datetime.fromisoformat(raw).strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
raise HTTPException(
400, "changed_at 格式无效,应为 YYYY-MM-DD 或 YYYY-MM-DD HH:MM[:SS]"
)
conn.execute(
"UPDATE drone_status SET status = ?, remark = ?, changed_at = ? WHERE id = ?",
(new_status, new_remark, new_ts, record_id),
)
return {
"id": record_id,
"status": new_status,
"remark": new_remark,
"changed_at": new_ts,
}
# ---------------------------------------------------------------- 透视表
@app.get("/api/status-matrix")
def status_matrix(
model: str = Query(...),
serial_no: Optional[str] = Query(None),
fc_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
):
"""按机型返回日期×编号的状态矩阵(支持编号/飞控ID/当前状态筛选列)。
dates 为行(日期),drones[].statuses 与 dates 对齐;
单元格 = 截至该日 23:59 的最新状态,无记录日期沿用前一状态,
从未有过状态则为空字符串。
"""
try:
end_d = date.fromisoformat(end) if end else date.today()
start_d = date.fromisoformat(start) if start else end_d - timedelta(days=29)
except ValueError:
raise HTTPException(400, "日期格式应为 YYYY-MM-DD")
if start_d > end_d:
start_d, end_d = end_d, start_d
dates = []
d = start_d
while d <= end_d:
dates.append(d.isoformat())
d += timedelta(days=1)
first_day = dates[0]
with get_conn() as conn:
sql = "SELECT * FROM drones WHERE model = ?"
sql_params: list = [model]
if serial_no:
sql += " AND serial_no LIKE ?"
sql_params.append(f"%{serial_no}%")
if fc_id:
sql += " AND fc_id LIKE ?"
sql_params.append(f"%{fc_id}%")
sql += " ORDER BY serial_no"
rows = conn.execute(sql, sql_params).fetchall()
drones = []
for r in rows:
cur = _current_status(conn, r["id"])
if status == NO_STATUS:
if cur:
continue
elif status:
if not cur or cur["status"] != status:
continue
history = conn.execute(
"SELECT status, remark, changed_at FROM drone_status "
"WHERE drone_id = ? ORDER BY changed_at ASC, id ASC",
(r["id"],),
).fetchall()
# 初始值 = 范围开始日之前的最新状态(含备注)
last_status, last_remark = "", ""
for h in history:
if h["changed_at"][:10] < first_day:
last_status, last_remark = h["status"], h["remark"]
else:
break
by_day = {}
by_day_remark = {}
for h in history:
by_day[h["changed_at"][:10]] = h["status"] # 升序覆盖,保留当日最后一条
by_day_remark[h["changed_at"][:10]] = h["remark"]
cells = []
remark_cells = []
for ds in dates:
if ds in by_day:
last_status = by_day[ds]
last_remark = by_day_remark[ds]
cells.append(last_status)
remark_cells.append(last_remark)
drones.append(
{
"id": r["id"],
"serial_no": r["serial_no"],
"model": r["model"],
"statuses": cells,
"remarks": remark_cells,
}
)
return {
"model": model,
"start": dates[0],
"end": dates[-1],
"dates": dates,
"drones": drones,
}
# ---------------------------------------------------------------- 飞行历史
@app.get("/api/drones/{drone_id}/flights")
def list_flights(drone_id: int):
"""单机飞行历史列表(按飞行日期倒序)。"""
with get_conn() as conn:
row = conn.execute("SELECT * FROM drones WHERE id = ?", (drone_id,)).fetchone()
if not row:
raise HTTPException(404, "无人机不存在")
rows = conn.execute(
"SELECT * FROM flight_records WHERE drone_id = ? "
"ORDER BY flight_date DESC, id DESC",
(drone_id,),
).fetchall()
drone = _drone_row(row)
return {"drone": drone, "flights": [_flight_row(r) for r in rows]}
@app.post("/api/drones/{drone_id}/flights", status_code=201)
def create_flight(drone_id: int, body: FlightCreate):
"""新增一条飞行记录。"""
flight_date = _check_date(body.flight_date)
if body.duration_min < 0:
raise HTTPException(400, "飞行时长不能为负数")
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM drones WHERE id = ?", (drone_id,)).fetchone()
if not row:
raise HTTPException(404, "无人机不存在")
now = datetime.now().isoformat(timespec="seconds")
cur = conn.execute(
"INSERT INTO flight_records (drone_id, flight_date, duration_min, takeoff, landing, remark, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(drone_id, flight_date, body.duration_min, body.takeoff.strip(),
body.landing.strip(), body.remark.strip(), now),
)
rec = conn.execute(
"SELECT * FROM flight_records WHERE id = ?", (cur.lastrowid,)
).fetchone()
return _flight_row(rec)
@app.put("/api/drones/{drone_id}/flights/{flight_id}")
def update_flight(drone_id: int, flight_id: int, body: FlightUpdate):
"""修改飞行记录。"""
with get_conn() as conn:
rec = conn.execute(
"SELECT * FROM flight_records WHERE id = ? AND drone_id = ?",
(flight_id, drone_id),
).fetchone()
if not rec:
raise HTTPException(404, "飞行记录不存在")
new_date = _check_date(body.flight_date) if body.flight_date is not None else rec["flight_date"]
new_dur = body.duration_min if body.duration_min is not None else rec["duration_min"]
if new_dur < 0:
raise HTTPException(400, "飞行时长不能为负数")
new_takeoff = body.takeoff.strip() if body.takeoff is not None else rec["takeoff"]
new_landing = body.landing.strip() if body.landing is not None else rec["landing"]
new_remark = body.remark.strip() if body.remark is not None else rec["remark"]
conn.execute(
"UPDATE flight_records SET flight_date = ?, duration_min = ?, takeoff = ?, "
"landing = ?, remark = ? WHERE id = ?",
(new_date, new_dur, new_takeoff, new_landing, new_remark, flight_id),
)
rec = conn.execute(
"SELECT * FROM flight_records WHERE id = ?", (flight_id,)
).fetchone()
return _flight_row(rec)
@app.delete("/api/drones/{drone_id}/flights/{flight_id}", status_code=204)
def delete_flight(drone_id: int, flight_id: int):
"""删除飞行记录。"""
with get_conn() as conn:
rec = conn.execute(
"SELECT 1 FROM flight_records WHERE id = ? AND drone_id = ?",
(flight_id, drone_id),
).fetchone()
if not rec:
raise HTTPException(404, "飞行记录不存在")
conn.execute("DELETE FROM flight_records WHERE id = ?", (flight_id,))
return None
@app.post("/api/flights/batch", status_code=201)
def batch_create_flights(body: FlightBatchCreate):
"""统一添加飞行历史:一次给多个飞机编号添加同一条飞行记录。"""
flight_date = _check_date(body.flight_date)
if body.duration_min < 0:
raise HTTPException(400, "飞行时长不能为负数")
serials = [s.strip() for s in body.serial_nos if s.strip()]
if not serials:
raise HTTPException(400, "请至少选择一个飞机编号")
now = datetime.now().isoformat(timespec="seconds")
added, missing = [], []
with get_conn() as conn:
for s in serials:
row = conn.execute(
"SELECT id FROM drones WHERE serial_no = ?", (s,)
).fetchone()
if not row:
missing.append(s)
continue
conn.execute(
"INSERT INTO flight_records (drone_id, flight_date, duration_min, takeoff, landing, remark, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(row["id"], flight_date, body.duration_min, body.takeoff.strip(),
body.landing.strip(), body.remark.strip(), now),
)
added.append(s)
return {"added": added, "missing": missing}
@app.get("/api/flight-matrix")
def flight_matrix(
model: str = Query(...),
serial_no: Optional[str] = Query(None),
fc_id: Optional[str] = Query(None),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
):
"""飞行历史透视表:行=日期、列=编号、单元格=当日飞行次数(支持编号/飞控ID筛选列)。"""
try:
end_d = date.fromisoformat(end) if end else date.today()
start_d = date.fromisoformat(start) if start else end_d - timedelta(days=29)
except ValueError:
raise HTTPException(400, "日期格式应为 YYYY-MM-DD")
if start_d > end_d:
start_d, end_d = end_d, start_d
dates = []
d = start_d
while d <= end_d:
dates.append(d.isoformat())
d += timedelta(days=1)
with get_conn() as conn:
sql = "SELECT id, serial_no FROM drones WHERE model = ?"
sql_params: list = [model]
if serial_no:
sql += " AND serial_no LIKE ?"
sql_params.append(f"%{serial_no}%")
if fc_id:
sql += " AND fc_id LIKE ?"
sql_params.append(f"%{fc_id}%")
sql += " ORDER BY serial_no"
drones = conn.execute(sql, sql_params).fetchall()
result = []
for r in drones:
counts = {
x["flight_date"]: x["c"]
for x in conn.execute(
"SELECT flight_date, COUNT(*) AS c FROM flight_records "
"WHERE drone_id = ? AND flight_date BETWEEN ? AND ? "
"GROUP BY flight_date",
(r["id"], dates[0], dates[-1]),
)
}
# 每日期备注(当日多条用;拼接)
remarks: dict = {}
for x in conn.execute(
"SELECT flight_date, remark FROM flight_records "
"WHERE drone_id = ? AND flight_date BETWEEN ? AND ? "
"ORDER BY flight_date, id",
(r["id"], dates[0], dates[-1]),
):
rm = (x["remark"] or "").strip()
if rm:
remarks.setdefault(x["flight_date"], []).append(rm)
remark_cells = []
for ds in dates:
lst = remarks.get(ds)
remark_cells.append(";".join(lst) if lst else "")
result.append({
"id": r["id"],
"serial_no": r["serial_no"],
"counts": [counts.get(ds, 0) for ds in dates],
"remarks": remark_cells,
})
return {"model": model, "start": dates[0], "end": dates[-1], "dates": dates, "drones": result}
@app.post("/api/flights/batch-update")
def batch_update_flights(body: FlightBatchUpdate):
"""按日期批量修改:对所选飞机「该日期」的所有飞行记录统一更新(None 字段不修改)。"""
ids = list(dict.fromkeys(body.ids))
if not ids:
raise HTTPException(400, "请至少选择一架无人机")
flight_date = _check_date(body.flight_date)
if body.duration_min is not None and body.duration_min < 0:
raise HTTPException(400, "飞行时长不能为负数")
sets, params = [], []
if body.duration_min is not None:
sets.append("duration_min = ?")
params.append(body.duration_min)
if body.takeoff is not None:
sets.append("takeoff = ?")
params.append(body.takeoff.strip())
if body.landing is not None:
sets.append("landing = ?")
params.append(body.landing.strip())
if body.remark is not None:
sets.append("remark = ?")
params.append(body.remark.strip())
if not sets:
raise HTTPException(400, "请至少填写一个要修改的字段")
ph = ",".join("?" * len(ids))
params.extend(ids)
params.append(flight_date)
with get_conn() as conn:
cur = conn.execute(
f"UPDATE flight_records SET {', '.join(sets)} "
f"WHERE drone_id IN ({ph}) AND flight_date = ?",
params,
)
return {"updated_records": cur.rowcount}
@app.post("/api/flights/batch-delete")
def batch_delete_flights(body: FlightBatchDelete):
"""批量删除飞行记录:mode=all 删除全部,mode=date 按日期删除。"""
ids = list(dict.fromkeys(body.ids))
if not ids:
raise HTTPException(400, "请至少选择一架无人机")
sql = f"DELETE FROM flight_records WHERE drone_id IN ({','.join('?' * len(ids))})"
params: list = ids.copy()
if body.mode == "date":
if not body.flight_date:
raise HTTPException(400, "按日期删除需提供 flight_date")
sql += " AND flight_date = ?"
params.append(_check_date(body.flight_date))
elif body.mode != "all":
raise HTTPException(400, "mode 应为 all 或 date")
with get_conn() as conn:
cur = conn.execute(sql, params)
return {"deleted_records": cur.rowcount}
@app.post("/api/drones/batch-delete")
def batch_delete_drones(body: DroneBatchDelete):
"""批量删除整架无人机(级联删除其状态与飞行记录)。"""
ids = list(dict.fromkeys(body.ids))
if not ids:
raise HTTPException(400, "请至少选择一架无人机")
deleted, not_found = [], []
with get_conn() as conn:
for did in ids:
row = conn.execute("SELECT 1 FROM drones WHERE id = ?", (did,)).fetchone()
if not row:
not_found.append(did)
continue
conn.execute("DELETE FROM drones WHERE id = ?", (did,))
deleted.append(did)
return {"deleted": deleted, "not_found": not_found}
# ---------------------------------------------------------------- 状态管理(自定义状态)
@app.get("/api/status-types")
def list_status_types():
"""全部可用状态(含颜色与是否内置),按排序返回。"""
with get_conn() as conn:
rows = conn.execute(
"SELECT name, color, is_builtin FROM status_defs ORDER BY sort_order, id"
).fetchall()
return [
{"name": r["name"], "color": r["color"], "is_builtin": bool(r["is_builtin"])}
for r in rows
]
@app.post("/api/status-types", status_code=201)
def create_status_type(body: StatusDefCreate):
"""新增自定义状态;不填颜色时自动从调色板分配。"""
name = body.name.strip()
if not name:
raise HTTPException(400, "状态名称不能为空")
with get_conn() as conn:
exists = conn.execute(
"SELECT 1 FROM status_defs WHERE name = ?", (name,)
).fetchone()
if exists:
raise HTTPException(409, f"状态 {name} 已存在")
color = (body.color or "").strip()
if not color:
used = {r[0] for r in conn.execute("SELECT color FROM status_defs")}
color = next((c for c in DEFAULT_STATUS_COLORS if c not in used), "#64748b")
conn.execute(
"INSERT INTO status_defs (name, color, sort_order, is_builtin) VALUES (?, ?, "
"(SELECT COALESCE(MAX(sort_order), 0) + 1 FROM status_defs), 0)",
(name, color),
)
return {"name": name, "color": color, "is_builtin": False}
@app.put("/api/status-types/{name}")
def update_status_type(name: str, body: StatusDefUpdate):
"""改名 / 改颜色;改名会同步更新历史记录中的旧状态名。"""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM status_defs WHERE name = ?", (name,)
).fetchone()
if not row:
raise HTTPException(404, f"状态 {name} 不存在")
new_name = body.name.strip() if body.name is not None else name
new_color = body.color.strip() if body.color is not None else row["color"]
if not new_name:
raise HTTPException(400, "状态名称不能为空")
if new_name != name:
dup = conn.execute(
"SELECT 1 FROM status_defs WHERE name = ? AND id != ?",
(new_name, row["id"]),
).fetchone()
if dup:
raise HTTPException(409, f"状态 {new_name} 已存在")
conn.execute(
"UPDATE status_defs SET name = ?, color = ? WHERE id = ?",
(new_name, new_color, row["id"]),
)
if new_name != name:
conn.execute(
"UPDATE drone_status SET status = ? WHERE status = ?",
(new_name, name),
)
return {"name": new_name, "color": new_color, "is_builtin": bool(row["is_builtin"])}
@app.delete("/api/status-types/{name}", status_code=204)
def delete_status_type(name: str):
"""删除自定义状态;内置状态与已被记录使用的状态不可删除。"""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM status_defs WHERE name = ?", (name,)
).fetchone()
if not row:
raise HTTPException(404, f"状态 {name} 不存在")
if row["is_builtin"]:
raise HTTPException(400, "内置状态不可删除")
used = conn.execute(
"SELECT COUNT(*) FROM drone_status WHERE status = ?", (name,)
).fetchone()[0]
if used:
raise HTTPException(400, f"状态 {name} 已被 {used} 条记录使用,无法删除")
conn.execute("DELETE FROM status_defs WHERE id = ?", (row["id"],))
return None
# ---------------------------------------------------------------- Excel 导出
@app.get("/api/export-excel")
def export_excel_api(
models: Optional[List[str]] = Query(None),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
):
"""手动导出:生成并下载 Excel。
- models:机型筛选(可重复传,不传=全部)
- start/end:日期范围(YYYY-MM-DD)。不传日期导出「台账+历史」;
传入日期导出「每日状态矩阵+变更明细+台账」。
"""
try:
if (start and not end) or (end and not start):
raise HTTPException(400, "start 与 end 需同时提供")
except HTTPException:
raise
path = export_to_excel(models=models, start=start, end=end)
if not path or not Path(path).exists():
raise HTTPException(500, "Excel 导出失败,请检查 openpyxl 是否已安装")
parts = []
if models:
parts.append("_".join(models))
if start and end:
parts.append(f"{start}-{end}")
fname = "无人机台账.xlsx"
if parts:
safe = "".join(c if c not in r'\/:*?"<>|' else "_" for c in "_".join(parts))
fname = f"无人机台账-{safe}.xlsx"
return FileResponse(
path,
filename=fname,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
# ---------------------------------------------------------------- 静态页面
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")