+
+## 目录结构
+
+```
+UAVManager/
+├── main.py # FastAPI 后端(应用 + 数据库 + API)
+├── start.bat # Windows 一键启动脚本
+├── README.md
+├── uav.db # SQLite 数据库(运行时自动创建)
+└── static/ # 前端页面
+ ├── index.html
+ ├── style.css
+ └── app.js
+```
diff --git a/cmd_out.txt b/cmd_out.txt
new file mode 100644
index 0000000..3b7f66f
--- /dev/null
+++ b/cmd_out.txt
@@ -0,0 +1,18 @@
+============================================
+ ÎÞÈ˻ú×ʲú¹ÜÀíϵͳ - һ¼üÆô¶¯
+============================================
+
+[1/2] ¼ì²éÒÀÀµ ...
+[2/2] Æô¶¯·þÎñ£º http://127.0.0.1:8000
+´íÎó: ²»֧³ÖÊäÈëÖØÐ¶¨Ïò£¬Á¢¼´Í˳ö´˽ø³̡£
+
+·þÎñÒÑÆô¶¯£¬ä¯ÀÀÆ÷½«×Զ¯´ò¿ªҳÃ档
+¾ÖÓòÍøÄÚÆäËûµçÄԿɷÃÎʣº http://±¾»úIP:8000
+¹رձ¾´°¿ڼ´ֹͣ·þÎñ¡£
+
+INFO: Started server process [26368]
+INFO: Waiting for application startup.
+INFO: Application startup complete.
+ERROR: [Errno 10048] error while attempting to bind on address ('0.0.0.0', 8000): ͨ³£ÿ¸öÌÓ×ֵØַ(ЭÒé/ÍøÂçµØַ/¶˿Ú)ֻÔÊÐíʹÓÃһ´Ρ£
+INFO: Waiting for application shutdown.
+INFO: Application shutdown complete.
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..a706104
--- /dev/null
+++ b/main.py
@@ -0,0 +1,1144 @@
+# -*- 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")
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..6b79354
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,40 @@
+@echo off
+cd /d "%~dp0"
+title ÎÞÈ˻ú×ʲú¹ÜÀíϵͳ
+
+echo ============================================
+echo ÎÞÈ˻ú×ʲú¹ÜÀíϵͳ - һ¼üÆô¶¯
+echo ============================================
+echo.
+
+where python >nul 2>nul
+if errorlevel 1 (
+ echo [´íÎó] δ¼ì² Python£¬ÇëÏȰ²װ Python 3.9 ¼°ÒÔÉϰ汾¡£
+ echo °²װʱÇ빴ѡ "Add python.exe to PATH"¡£
+ pause
+ exit /b 1
+)
+
+echo [1/2] ¼ì²éÒÀÀµ ...
+python -c "import fastapi, uvicorn, openpyxl" >nul 2>nul
+if errorlevel 1 (
+ echo Ê״ÎÔËÐУ¬ÕýÔڰ²װ fastapi¡¢uvicorn ºÍ openpyxl ...
+ python -m pip install fastapi uvicorn openpyxl
+ if errorlevel 1 (
+ echo [´íÎó] ÒÀÀµ°²װʧ°ܣ¬Çë¼ì²éÍøÂçºóÖØÊԡ£
+ pause
+ exit /b 1
+ )
+)
+
+echo [2/2] Æô¶¯·þÎñ£º http://127.0.0.1:8000
+start "UAV-Server" /b python -m uvicorn main:app --host 0.0.0.0 --port 8000
+timeout /t 2 /nobreak >nul
+start "" http://127.0.0.1:8000
+
+echo.
+echo ·þÎñÒÑÆô¶¯£¬ä¯ÀÀÆ÷½«×Զ¯´ò¿ªҳÃ档
+echo ¾ÖÓòÍøÄÚÆäËûµçÄԿɷÃÎʣº http://±¾»úIP:8000
+echo ¹رձ¾´°¿ڼ´ֹͣ·þÎñ¡£
+echo.
+pause >nul
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..e74882f
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,961 @@
+/* 无人机资产管理系统 — 前端逻辑(原生 JS,无依赖) */
+"use strict";
+
+const API = "";
+
+// 动态状态定义(由 /api/status-types 加载)
+let statusTypes = []; // [{name, color, is_builtin}]
+let statusMap = {}; // name -> color
+
+async function loadStatusTypes() {
+ statusTypes = await api("/api/status-types");
+ statusMap = {};
+ for (const s of statusTypes) statusMap[s.name] = s.color;
+ fillStatusFilter();
+}
+
+function fillStatusFilter() {
+ const opts = `` +
+ statusTypes.map((s) => ``).join("") +
+ ``;
+ const sel = document.getElementById("status-filter");
+ if (sel) sel.innerHTML = opts;
+ const selM = document.getElementById("matrix-status");
+ if (selM) selM.innerHTML = opts;
+}
+
+function badgeColor(status) {
+ return statusMap[status] || "#9e9e9e";
+}
+
+// ---------------------------------------------------------------- 工具
+async function api(path, options = {}) {
+ const resp = await fetch(API + path, {
+ headers: { "Content-Type": "application/json; charset=utf-8" },
+ ...options,
+ });
+ if (resp.status === 204) return null;
+ const data = await resp.json().catch(() => null);
+ if (!resp.ok) {
+ const msg = (data && data.detail) || `请求失败 (${resp.status})`;
+ throw new Error(msg);
+ }
+ return data;
+}
+
+function esc(s) {
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
+ "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
+ }[c]));
+}
+
+function badge(status) {
+ if (!status) return `无状态`;
+ return `${esc(status)}`;
+}
+
+function toast(msg, isError = false) {
+ let el = document.querySelector(".toast");
+ if (!el) {
+ el = document.createElement("div");
+ el.className = "toast";
+ document.body.appendChild(el);
+ }
+ el.textContent = msg;
+ el.classList.toggle("error", isError);
+ el.classList.add("show");
+ clearTimeout(el._t);
+ el._t = setTimeout(() => el.classList.remove("show"), 2600);
+}
+
+// 弹窗管理:一个容器反复复用
+function openModal(title, bodyHTML, footerHTML = "") {
+ const mask = document.getElementById("modal");
+ mask.innerHTML = `
+
+
+
${bodyHTML}
+ ${footerHTML ? `` : ""}
+
`;
+ mask.classList.add("open");
+}
+function closeModal() {
+ document.getElementById("modal").classList.remove("open");
+}
+document.addEventListener("click", (e) => {
+ if (e.target.id === "modal") closeModal();
+});
+
+// ---------------------------------------------------------------- Tab
+function switchTab(name) {
+ document.querySelectorAll(".tab").forEach((b) =>
+ b.classList.toggle("active", b.dataset.tab === name)
+ );
+ document.querySelectorAll("main.view").forEach((v) =>
+ v.classList.toggle("active", v.id === "view-" + name)
+ );
+ if (name === "matrix") renderMatrix();
+ if (name === "flight") renderFlightMatrix();
+}
+
+// ---------------------------------------------------------------- 无人机列表
+let currentModelFilter = "";
+
+function buildDronesQuery() {
+ const p = [];
+ const m = document.getElementById("model-filter").value;
+ const s = document.getElementById("serial-filter").value.trim();
+ const f = document.getElementById("fc-filter").value.trim();
+ const st = document.getElementById("status-filter").value;
+ if (m) p.push("model=" + encodeURIComponent(m));
+ if (s) p.push("serial_no=" + encodeURIComponent(s));
+ if (f) p.push("fc_id=" + encodeURIComponent(f));
+ if (st) p.push("status=" + encodeURIComponent(st));
+ return p.length ? "?" + p.join("&") : "";
+}
+
+function resetDronesFilter() {
+ document.getElementById("model-filter").value = "";
+ document.getElementById("serial-filter").value = "";
+ document.getElementById("fc-filter").value = "";
+ document.getElementById("status-filter").value = "";
+ currentModelFilter = "";
+ loadDrones();
+}
+let dronesCache = [];
+
+let globalModels = [];
+
+async function loadModels() {
+ const models = await api("/api/drones/models");
+ globalModels = models;
+ const sel = document.getElementById("model-filter");
+ sel.innerHTML = `` +
+ models.map((m) => ``).join("");
+ const selM = document.getElementById("matrix-model");
+ selM.innerHTML = models.map((m) => ``).join("");
+ const selF = document.getElementById("flight-model");
+ selF.innerHTML = models.map((m) => ``).join("");
+}
+
+async function loadDrones() {
+ currentModelFilter = document.getElementById("model-filter").value;
+ dronesCache = await api("/api/drones" + buildDronesQuery());
+ renderDrones();
+}
+
+function renderDrones() {
+ const wrap = document.getElementById("drones-body");
+ if (!dronesCache.length) {
+ wrap.innerHTML = `暂无无人机,点击右上角「新增无人机」添加
`;
+ return;
+ }
+ wrap.innerHTML = "";
+ const table = document.createElement("table");
+ table.innerHTML = `
+
+ |
+ 机型 | 编号 | 链路ID | 飞控ID |
+ 飞控版本 | 导航版本 |
+ 当前状态 | 备注 | 操作 |
+
`;
+ const tbodyEl = table.querySelector("tbody");
+ for (const d of dronesCache) {
+ const tr = document.createElement("tr");
+ const st = d.current_status ? d.current_status.status : "";
+ const lastRemark = d.current_status ? d.current_status.remark : "";
+ tr.innerHTML = `
+ |
+ ${esc(d.model)} |
+ ${esc(d.serial_no)} |
+ ${esc(d.link_id)} |
+ ${esc(d.fc_id)} |
+ ${esc(d.fc_version)} |
+ ${esc(d.nav_version)} |
+ ${badge(st)}${lastRemark ? ` ${esc(lastRemark)} ` : ""} |
+
+
+
+
+
+
+
+ | `;
+ tbodyEl.appendChild(tr);
+ }
+ wrap.innerHTML = "";
+ wrap.appendChild(table);
+ const ca = document.getElementById("check-all");
+ if (ca) {
+ ca.onchange = () => {
+ document.querySelectorAll(".drone-check").forEach((c) => (c.checked = ca.checked));
+ };
+ }
+}
+
+// ---------------------------------------------------------------- 新增 / 编辑
+function openCreateModal() {
+ openModal(
+ "新增无人机",
+ `
+
+
+
+
+
+
+ `,
+ ``
+ );
+}
+
+async function submitCreate() {
+ const body = {
+ model: document.getElementById("f-model").value,
+ serial_no: document.getElementById("f-serial").value,
+ link_id: document.getElementById("f-link").value,
+ fc_id: document.getElementById("f-fc").value,
+ fc_version: document.getElementById("f-fcv").value,
+ nav_version: document.getElementById("f-nav").value,
+ remark: document.getElementById("f-remark").value,
+ };
+ if (!body.model.trim() || !body.serial_no.trim()) return toast("机型与编号不能为空", true);
+ try {
+ await api("/api/drones", { method: "POST", body: JSON.stringify(body) });
+ closeModal();
+ toast("新增成功");
+ await Promise.all([loadModels(), loadDrones()]);
+ } catch (e) { toast(e.message, true); }
+}
+
+function openEditModal(id) {
+ const d = dronesCache.find((x) => x.id === id);
+ if (!d) return;
+ openModal(
+ `编辑无人机 ${d.serial_no}`,
+ `
+
+
+
+
+
+
+ 编号为唯一标识,新增时设定,此处不可修改。
`,
+ ``
+ );
+}
+
+async function submitEdit(id) {
+ const body = {
+ model: document.getElementById("f-model").value,
+ link_id: document.getElementById("f-link").value,
+ fc_id: document.getElementById("f-fc").value,
+ fc_version: document.getElementById("f-fcv").value,
+ nav_version: document.getElementById("f-nav").value,
+ remark: document.getElementById("f-remark").value,
+ };
+ try {
+ await api(`/api/drones/${id}`, { method: "PUT", body: JSON.stringify(body) });
+ closeModal();
+ toast("已保存");
+ await Promise.all([loadModels(), loadDrones()]);
+ } catch (e) { toast(e.message, true); }
+}
+
+function confirmDelete(id) {
+ const d = dronesCache.find((x) => x.id === id);
+ if (!d) return;
+ openModal(
+ "删除确认",
+ `确定删除编号 ${esc(d.serial_no)} 的无人机吗?
+ 其全部状态变更记录将一并删除,此操作不可恢复。
`,
+ ``
+ );
+}
+
+async function doDelete(id) {
+ try {
+ await api(`/api/drones/${id}`, { method: "DELETE" });
+ closeModal();
+ toast("已删除");
+ await Promise.all([loadModels(), loadDrones()]);
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- 修改状态
+function openStatusModal(id) {
+ const d = dronesCache.find((x) => x.id === id);
+ if (!d) return;
+ const cur = d.current_status ? d.current_status.status : "";
+ openModal(
+ `修改状态 — ${d.serial_no}`,
+ `
+
+
+
+
+
+ `,
+ ``
+ );
+}
+
+async function submitStatus(id) {
+ const body = {
+ status: document.getElementById("s-status").value,
+ remark: document.getElementById("s-remark").value,
+ };
+ const t = document.getElementById("s-time").value;
+ if (t) body.changed_at = t.replace("T", " ") + ":00";
+ try {
+ await api(`/api/drones/${id}/status`, { method: "POST", body: JSON.stringify(body) });
+ closeModal();
+ toast("状态已更新");
+ await loadDrones();
+ } catch (e) { toast(e.message, true); }
+}
+
+function nowLocal() {
+ const d = new Date();
+ const p = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+// ---------------------------------------------------------------- 批量修改状态
+function getSelectedDroneIds() {
+ return Array.from(document.querySelectorAll(".drone-check:checked")).map((c) => parseInt(c.value, 10));
+}
+
+function openBatchStatusModal() {
+ const ids = getSelectedDroneIds();
+ if (!ids.length) return toast("请先勾选需要修改状态的无人机", true);
+ const opts = statusTypes.map((s) => ``).join("");
+ openModal(
+ `批量修改状态(${ids.length} 架)`,
+ `
+
+
+
+
+
`,
+ ``
+ );
+}
+
+async function submitBatchStatus() {
+ const ids = getSelectedDroneIds();
+ const body = {
+ ids,
+ status: document.getElementById("bs-status").value,
+ remark: document.getElementById("bs-remark").value,
+ };
+ const t = document.getElementById("bs-time").value;
+ if (t) body.changed_at = t.replace("T", " ") + ":00";
+ try {
+ const r = await api("/api/drones/status/batch", { method: "POST", body: JSON.stringify(body) });
+ closeModal();
+ toast(`已更新 ${r.updated.length} 架` + (r.missing.length ? `,${r.missing.length} 架未找到` : ""));
+ await loadDrones();
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- 批量修改飞行
+function openBatchEditFlightModal() {
+ const ids = getSelectedDroneIds();
+ if (!ids.length) return toast("请先勾选需要修改飞行记录的无人机", true);
+ openModal(
+ `批量修改飞行(${ids.length} 架)`,
+ `
+
+ 按日期统一修改这些飞机「该日期」的飞行记录;留空的字段不修改。
+
+
+
+
+
+ `,
+ ``
+ );
+}
+
+async function submitBatchEditFlight() {
+ const ids = getSelectedDroneIds();
+ const date = document.getElementById("bef-date").value;
+ if (!date) return toast("请选择飞行日期", true);
+ const dur = document.getElementById("bef-dur").value;
+ const takeoff = document.getElementById("bef-takeoff").value;
+ const landing = document.getElementById("bef-landing").value;
+ const remark = document.getElementById("bef-remark").value;
+ const body = { ids, flight_date: date };
+ if (dur !== "") body.duration_min = parseInt(dur, 10);
+ if (takeoff !== "") body.takeoff = takeoff;
+ if (landing !== "") body.landing = landing;
+ if (remark !== "") body.remark = remark;
+ if (!("duration_min" in body) && !("takeoff" in body) && !("landing" in body) && !("remark" in body)) {
+ return toast("请至少填写一个要修改的字段", true);
+ }
+ try {
+ const r = await api("/api/flights/batch-update", { method: "POST", body: JSON.stringify(body) });
+ closeModal();
+ toast(`已修改 ${r.updated_records} 条飞行记录`);
+ await loadDrones();
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- 批量删除
+function openBatchDeleteModal() {
+ const ids = getSelectedDroneIds();
+ if (!ids.length) return toast("请先勾选需要操作的无人机", true);
+ openModal(
+ `批量删除(${ids.length} 架)`,
+ `
+
+
+
+
+
+
+
+ 警告:删除操作不可恢复!整机删除会连同其状态历史与飞行记录一并删除。
+
`,
+ ``
+ );
+}
+
+function toggleBdDate() {
+ const mode = document.getElementById("bd-mode").value;
+ document.getElementById("bd-date-row").style.display = mode === "flights-date" ? "block" : "none";
+}
+
+async function submitBatchDelete() {
+ const ids = getSelectedDroneIds();
+ const mode = document.getElementById("bd-mode").value;
+ if (!confirm("确认执行批量删除?此操作不可恢复!")) return;
+ try {
+ if (mode === "drones") {
+ const r = await api("/api/drones/batch-delete", { method: "POST", body: JSON.stringify({ ids }) });
+ closeModal();
+ toast(`已删除 ${r.deleted.length} 架` + (r.not_found.length ? `,${r.not_found.length} 架未找到` : ""));
+ await Promise.all([loadModels(), loadDrones(), loadStatusTypes()]);
+ } else {
+ const body = { ids, mode: mode === "flights-date" ? "date" : "all" };
+ if (mode === "flights-date") {
+ const date = document.getElementById("bd-date").value;
+ if (!date) return toast("请选择飞行日期", true);
+ body.flight_date = date;
+ }
+ const r = await api("/api/flights/batch-delete", { method: "POST", body: JSON.stringify(body) });
+ closeModal();
+ toast(`已删除 ${r.deleted_records} 条飞行记录`);
+ await loadDrones();
+ }
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- 状态管理
+async function openStatusManager() {
+ await loadStatusTypes();
+ const rows = statusTypes.map((s) => `
+
+
+ ${esc(s.name)}
+ ${s.is_builtin
+ ? '内置'
+ : `
+ `}
+
`).join("");
+ openModal(
+ "状态管理",
+ `
+
+ 状态用于「修改状态」下拉及透视表/历史展示;点击颜色块可修改颜色,内置状态不可删除,已被记录使用的状态不可删除。
+
+
+ `,
+ ``
+ );
+ // 颜色修改
+ document.querySelectorAll("#modal input[data-action=color]").forEach((inp) => {
+ inp.addEventListener("change", async () => {
+ try {
+ await api(`/api/status-types/${encodeURIComponent(inp.dataset.name)}`, {
+ method: "PUT", body: JSON.stringify({ color: inp.value }),
+ });
+ toast("颜色已更新");
+ await loadStatusTypes(); await loadDrones(); openStatusManager();
+ } catch (e) { toast(e.message, true); }
+ });
+ });
+ // 改名 / 删除
+ document.querySelectorAll("#modal [data-action=rename], #modal [data-action=delete]").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ const name = btn.dataset.name;
+ try {
+ if (btn.dataset.action === "rename") {
+ const newName = prompt(`将状态「${name}」改名为:`, name);
+ if (newName === null) return;
+ if (!newName.trim()) return toast("名称不能为空", true);
+ await api(`/api/status-types/${encodeURIComponent(name)}`, {
+ method: "PUT", body: JSON.stringify({ name: newName.trim() }),
+ });
+ toast("已改名");
+ } else {
+ if (!confirm(`确定删除状态「${name}」吗?`)) return;
+ await api(`/api/status-types/${encodeURIComponent(name)}`, { method: "DELETE" });
+ toast("已删除");
+ }
+ await loadStatusTypes(); await loadDrones(); openStatusManager();
+ } catch (e) { toast(e.message, true); }
+ });
+ });
+}
+
+async function createStatus() {
+ const name = document.getElementById("sm-name").value;
+ const color = document.getElementById("sm-color").value;
+ if (!name.trim()) return toast("请输入状态名称", true);
+ try {
+ await api("/api/status-types", { method: "POST", body: JSON.stringify({ name, color }) });
+ toast("状态已添加");
+ await loadStatusTypes(); await loadDrones(); openStatusManager();
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- 历史
+async function showHistory(id) {
+ const data = await api(`/api/drones/${id}/history`);
+ const d = data.drone;
+ const items = data.history.length
+ ? data.history.map((h) => `
+
+
+
${esc(h.changed_at)}
+
+ ${esc(h.status)}
+ ${h.remark ? `` : ""}
+
+
+
+
`).join("")
+ : `暂无状态变更记录
`;
+ openModal(
+ `状态历史 — ${d.serial_no}(${esc(d.model)})`,
+ `${items}
`,
+ ``
+ );
+ document.querySelectorAll("#modal [data-action=del-status]").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ if (!confirm("确定删除这条状态记录吗?删除后当前状态取剩余最新一条。")) return;
+ try {
+ await api(`/api/drones/${id}/history/${btn.dataset.id}`, { method: "DELETE" });
+ toast("已删除");
+ await loadDrones();
+ showHistory(id);
+ } catch (e) { toast(e.message, true); }
+ });
+ });
+ document.querySelectorAll("#modal [data-action=edit-status]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const rec = data.history.find((x) => x.id === parseInt(btn.dataset.id, 10));
+ if (rec) openEditStatusModal(id, rec);
+ });
+ });
+}
+
+function toLocalInput(iso) {
+ return iso ? iso.slice(0, 16) : "";
+}
+
+function openEditStatusModal(id, rec) {
+ const opts = statusTypes.map((s) =>
+ ``
+ ).join("");
+ const d = dronesCache.find((x) => x.id === id);
+ openModal(
+ `编辑状态记录 — ${d ? d.serial_no : id}`,
+ `
+
+
+
+
+ `,
+ ``
+ );
+}
+
+async function saveStatusRecord(id, recordId) {
+ const body = {
+ status: document.getElementById("es-status").value,
+ remark: document.getElementById("es-remark").value,
+ };
+ const t = document.getElementById("es-time").value;
+ if (t) body.changed_at = t.replace("T", " ") + ":00";
+ try {
+ await api(`/api/drones/${id}/history/${recordId}`, { method: "PUT", body: JSON.stringify(body) });
+ toast("已保存");
+ await loadDrones();
+ showHistory(id);
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- Excel 导出
+function openExportModal() {
+ const items = globalModels.length
+ ? globalModels.map((m) => `
+ `).join("")
+ : `暂无机型,请先添加无人机
`;
+ openModal(
+ "导出 Excel",
+ `
+
+
+ 选择机型(不勾选则导出全部):
+
全选
+
清空
+
+
+ ${items}
+
+
+
+
+
+ 至
+
+
+
+ 未选日期:导出「无人机台账 + 状态历史」;选择日期:导出「每日状态矩阵(行=日期、列=编号)+ 变更明细 + 台账」。
+
`,
+ ``
+ );
+}
+
+function toggleExModels(checked) {
+ document.querySelectorAll(".ex-model").forEach((c) => (c.checked = checked));
+}
+
+function doExportExcel() {
+ const selected = Array.from(document.querySelectorAll(".ex-model:checked")).map((c) => c.value);
+ const start = document.getElementById("ex-start").value;
+ const end = document.getElementById("ex-end").value;
+ if ((start && !end) || (end && !start)) return toast("开始与结束日期需同时选择", true);
+ if (start && end && start > end) return toast("开始日期不能晚于结束日期", true);
+ const parts = [];
+ for (const m of selected) parts.push("models=" + encodeURIComponent(m));
+ if (start && end) { parts.push("start=" + start); parts.push("end=" + end); }
+ window.location.href = API + "/api/export-excel" + (parts.length ? "?" + parts.join("&") : "");
+ closeModal();
+}
+
+// ---------------------------------------------------------------- 飞行历史
+async function openFlightModal(id) {
+ const data = await api(`/api/drones/${id}/flights`);
+ const d = data.drone;
+ const items = data.flights.length
+ ? data.flights.map((f) => `
+
+
+
${esc(f.flight_date)}
+
+ ${f.duration_min} 分钟
+
+
+
+
+
`).join("")
+ : `暂无飞行记录
`;
+ openModal(
+ `飞行历史 — ${d.serial_no}(${esc(d.model)})`,
+ `
+ ${items}
+ `,
+ ``
+ );
+ document.querySelectorAll("#modal [data-action=del-flight]").forEach((btn) => {
+ btn.addEventListener("click", async () => {
+ if (!confirm("确定删除这条飞行记录吗?")) return;
+ try {
+ await api(`/api/drones/${id}/flights/${btn.dataset.id}`, { method: "DELETE" });
+ toast("已删除");
+ openFlightModal(id);
+ } catch (e) { toast(e.message, true); }
+ });
+ });
+}
+
+async function createFlight(id) {
+ const body = {
+ flight_date: document.getElementById("fl-date").value,
+ duration_min: parseInt(document.getElementById("fl-dur").value || "0", 10),
+ takeoff: document.getElementById("fl-takeoff").value,
+ landing: document.getElementById("fl-landing").value,
+ remark: document.getElementById("fl-remark").value,
+ };
+ if (!body.flight_date) return toast("请选择飞行日期", true);
+ try {
+ await api(`/api/drones/${id}/flights`, { method: "POST", body: JSON.stringify(body) });
+ toast("已添加");
+ openFlightModal(id);
+ } catch (e) { toast(e.message, true); }
+}
+
+async function editFlight(id, flightId) {
+ const data = await api(`/api/drones/${id}/flights`);
+ const f = data.flights.find((x) => x.id === flightId);
+ if (!f) return;
+ openModal(
+ `编辑飞行记录 — ${data.drone.serial_no}`,
+ `
+
+
+
+
+ `,
+ ``
+ );
+}
+
+async function saveFlight(id, flightId) {
+ const body = {
+ flight_date: document.getElementById("ef-date").value,
+ duration_min: parseInt(document.getElementById("ef-dur").value || "0", 10),
+ takeoff: document.getElementById("ef-takeoff").value,
+ landing: document.getElementById("ef-landing").value,
+ remark: document.getElementById("ef-remark").value,
+ };
+ if (!body.flight_date) return toast("请选择飞行日期", true);
+ try {
+ await api(`/api/drones/${id}/flights/${flightId}`, { method: "PUT", body: JSON.stringify(body) });
+ toast("已保存");
+ openFlightModal(id);
+ } catch (e) { toast(e.message, true); }
+}
+
+function openBatchFlightModal() {
+ const items = dronesCache.length
+ ? dronesCache.map((d) => `
+ `).join("")
+ : `暂无无人机
`;
+ openModal(
+ "统一添加飞行历史",
+ `
+
+ 勾选飞机编号(列表为当前机型筛选结果),填写飞行信息后统一添加,对应飞机自动同步:
+
全选
+
清空
+
+ ${items}
+
+
+
+
+
+
+ `,
+ ``
+ );
+}
+
+function toggleBatch(checked) {
+ document.querySelectorAll(".bf-serial").forEach((c) => (c.checked = checked));
+}
+
+async function submitBatchFlight() {
+ const serials = Array.from(document.querySelectorAll(".bf-serial:checked")).map((c) => c.value);
+ const body = {
+ serial_nos: serials,
+ flight_date: document.getElementById("bf-date").value,
+ duration_min: parseInt(document.getElementById("bf-dur").value || "0", 10),
+ takeoff: document.getElementById("bf-takeoff").value,
+ landing: document.getElementById("bf-landing").value,
+ remark: document.getElementById("bf-remark").value,
+ };
+ if (!serials.length) return toast("请至少勾选一架飞机", true);
+ if (!body.flight_date) return toast("请选择飞行日期", true);
+ try {
+ const r = await api("/api/flights/batch", { method: "POST", body: JSON.stringify(body) });
+ closeModal();
+ toast(`已为 ${r.added.length} 架添加飞行记录` + (r.missing.length ? `,${r.missing.length} 个编号未找到` : ""));
+ } catch (e) { toast(e.message, true); }
+}
+
+// ---------------------------------------------------------------- 透视表
+async function renderMatrix() {
+ const model = document.getElementById("matrix-model").value;
+ const start = document.getElementById("matrix-start").value;
+ const end = document.getElementById("matrix-end").value;
+ const box = document.getElementById("matrix-box");
+ if (!model) {
+ box.innerHTML = `请先在「无人机管理」中添加无人机,然后选择机型查看透视表
`;
+ return;
+ }
+ let q = `?model=${encodeURIComponent(model)}`;
+ const ms = document.getElementById("matrix-serial").value.trim();
+ const mf = document.getElementById("matrix-fc").value.trim();
+ const mst = document.getElementById("matrix-status").value;
+ if (ms) q += `&serial_no=${encodeURIComponent(ms)}`;
+ if (mf) q += `&fc_id=${encodeURIComponent(mf)}`;
+ if (mst) q += `&status=${encodeURIComponent(mst)}`;
+ if (start) q += `&start=${start}`;
+ if (end) q += `&end=${end}`;
+ box.innerHTML = `加载中…
`;
+ const m = await api("/api/status-matrix" + q);
+ if (!m.drones.length) {
+ box.innerHTML = `该机型下暂无无人机
`;
+ return;
+ }
+ const thead = `| 日期 \ 编号 | ` +
+ m.drones.map((d) => `${esc(d.serial_no)} | `).join("") + `
`;
+ const tbody = m.dates.map((day, i) => {
+ const cells = m.drones.map((d) => {
+ const st = d.statuses[i];
+ const rm = d.remarks[i];
+ return st
+ ? `${esc(st)}${rm ? `` : ""} | `
+ : `— | `;
+ }).join("");
+ return `| ${esc(day)} | ${cells}
`;
+ }).join("");
+ box.innerHTML = `
+
+ 机型:${esc(m.model)} | 日期范围:${esc(m.start)} ~ ${esc(m.end)}
+ (共 ${m.dates.length} 天,${m.drones.length} 架)
+
+ `;
+}
+
+// ---------------------------------------------------------------- 飞行透视表
+async function renderFlightMatrix() {
+ const model = document.getElementById("flight-model").value;
+ const start = document.getElementById("flight-start").value;
+ const end = document.getElementById("flight-end").value;
+ const box = document.getElementById("flight-box");
+ if (!model) {
+ box.innerHTML = `请先在「无人机管理」中添加无人机,然后选择机型查看飞行透视表
`;
+ return;
+ }
+ let q = `?model=${encodeURIComponent(model)}`;
+ const fs2 = document.getElementById("flight-serial").value.trim();
+ const ff = document.getElementById("flight-fc").value.trim();
+ if (fs2) q += `&serial_no=${encodeURIComponent(fs2)}`;
+ if (ff) q += `&fc_id=${encodeURIComponent(ff)}`;
+ if (start) q += `&start=${start}`;
+ if (end) q += `&end=${end}`;
+ box.innerHTML = `加载中…
`;
+ const m = await api("/api/flight-matrix" + q);
+ if (!m.drones.length) {
+ box.innerHTML = `该机型下暂无无人机
`;
+ return;
+ }
+ const thead = `| 日期 \ 编号 | ` +
+ m.drones.map((d) => `${esc(d.serial_no)} | `).join("") + `
`;
+ const tbody = m.dates.map((day, i) => {
+ const cells = m.drones.map((d) => {
+ const c = d.counts[i];
+ const rm = d.remarks[i];
+ return c
+ ? `${c} 次${rm ? `` : ""} | `
+ : `— | `;
+ }).join("");
+ return `| ${esc(day)} | ${cells}
`;
+ }).join("");
+ // 总架次统计行(按筛选日期范围汇总)
+ const totals = m.drones.map((d) => d.counts.reduce((a, b) => a + b, 0));
+ const totalRow = `
+ | 总架次 | ` +
+ totals.map((t) =>
+ t
+ ? `${t} 次 | `
+ : `— | `
+ ).join("") + `
`;
+ box.innerHTML = `
+
+ 机型:${esc(m.model)} | 日期范围:${esc(m.start)} ~ ${esc(m.end)}
+ (共 ${m.dates.length} 天,${m.drones.length} 架)
+
+
+
${thead}${totalRow}${tbody}
+
`;
+}
+
+// ---------------------------------------------------------------- 初始化
+function initMatrixDefaults() {
+ const now = new Date();
+ document.getElementById("matrix-end").value = now.toISOString().slice(0, 10);
+ const start = new Date(now);
+ start.setDate(start.getDate() - 29);
+ document.getElementById("matrix-start").value = start.toISOString().slice(0, 10);
+}
+
+async function init() {
+ document.getElementById("tab-drones").onclick = () => switchTab("drones");
+ document.getElementById("tab-matrix").onclick = () => switchTab("matrix");
+ document.getElementById("tab-flight").onclick = () => switchTab("flight");
+ document.getElementById("add-drone").onclick = openCreateModal;
+ document.getElementById("status-manager").onclick = openStatusManager;
+ document.getElementById("export-excel").onclick = openExportModal;
+ document.getElementById("batch-flight").onclick = openBatchFlightModal;
+ document.getElementById("batch-status").onclick = openBatchStatusModal;
+ document.getElementById("batch-edit-flight").onclick = openBatchEditFlightModal;
+ document.getElementById("batch-delete").onclick = openBatchDeleteModal;
+ document.getElementById("model-filter").onchange = () => loadDrones();
+ document.getElementById("apply-filter").onclick = loadDrones;
+ document.getElementById("reset-filter").onclick = resetDronesFilter;
+ document.getElementById("matrix-query").onclick = renderMatrix;
+ document.getElementById("flight-query").onclick = renderFlightMatrix;
+ initMatrixDefaults();
+ document.getElementById("flight-end").value = new Date().toISOString().slice(0, 10);
+ const fs = new Date(); fs.setDate(fs.getDate() - 29);
+ document.getElementById("flight-start").value = fs.toISOString().slice(0, 10);
+ try {
+ await Promise.all([loadModels(), loadDrones(), loadStatusTypes()]);
+ const hashTab = location.hash.replace("#", "");
+ if (["matrix", "drones", "flight"].includes(hashTab)) switchTab(hashTab);
+ } catch (e) {
+ toast("加载数据失败:" + e.message, true);
+ }
+}
+
+document.addEventListener("DOMContentLoaded", init);
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..cee6504
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,82 @@
+
+
+
+
+
+ 无人机资产管理系统
+
+
+
+
+ 无人机资产管理系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择机型后点击「查询」查看飞行历史透视表(单元格=当日飞行次数)
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..ab1866c
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,196 @@
+/* 无人机资产管理系统样式 */
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #f4f6f9;
+ --card: #ffffff;
+ --border: #e2e6ec;
+ --text: #26303c;
+ --muted: #7a8699;
+ --primary: #2563eb;
+ --primary-dark: #1d4ed8;
+ --danger: #dc2626;
+}
+
+body {
+ font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ min-height: 100vh;
+}
+
+header {
+ background: var(--card);
+ border-bottom: 1px solid var(--border);
+ padding: 14px 24px;
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ flex-wrap: wrap;
+}
+
+header h1 { font-size: 20px; font-weight: 600; }
+
+nav { display: flex; gap: 8px; }
+
+.tab {
+ padding: 8px 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--card);
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--muted);
+}
+.tab.active {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: #fff;
+}
+.tab:hover:not(.active) { color: var(--primary); }
+
+main.view { display: none; padding: 20px 24px; max-width: 1400px; margin: 0 auto; }
+main.view.active { display: block; }
+
+/* 工具条 */
+.toolbar {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+.toolbar label { font-size: 13px; color: var(--muted); }
+
+input[type="text"], input[type="date"], select, textarea {
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 14px;
+ background: var(--card);
+ color: var(--text);
+}
+input:focus, select:focus, textarea:focus { outline: 2px solid #93c5fd; border-color: var(--primary); }
+textarea { resize: vertical; min-height: 64px; }
+
+button {
+ padding: 8px 14px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--card);
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--text);
+ transition: all .15s;
+}
+button:hover { border-color: var(--primary); color: var(--primary); }
+button.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
+button.primary:hover { background: var(--primary-dark); color: #fff; }
+button.danger:hover { border-color: var(--danger); color: var(--danger); }
+button.small { padding: 4px 10px; font-size: 13px; }
+
+/* 表格 */
+.table-wrap { overflow-x: auto; background: var(--card); border: 1px solid var(--border); border-radius: 10px; }
+table { border-collapse: collapse; width: 100%; font-size: 14px; }
+th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
+th { background: #fafbfc; color: var(--muted); font-weight: 600; font-size: 13px; }
+tbody tr:hover { background: #f8fafc; }
+tbody tr:last-child td { border-bottom: none; }
+td.remark-cell { white-space: normal; min-width: 160px; max-width: 320px; }
+td .actions { display: flex; gap: 6px; flex-wrap: wrap; }
+
+/* 状态徽标 */
+.badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ color: #fff;
+ font-weight: 600;
+}
+.badge.empty { background: #e5e9f0; color: var(--muted); }
+.badge-待组装 { background: #9e9e9e; }
+.badge-待调试 { background: #ff9800; }
+.badge-待飞 { background: #4caf50; }
+.badge-损坏 { background: #f44336; }
+
+/* 弹窗 */
+.modal-mask {
+ position: fixed; inset: 0;
+ background: rgba(15, 23, 42, .45);
+ display: none;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 48px 16px;
+ z-index: 50;
+ overflow-y: auto;
+}
+.modal-mask.open { display: flex; }
+.modal {
+ background: var(--card);
+ border-radius: 12px;
+ width: 100%;
+ max-width: 520px;
+ box-shadow: 0 20px 50px rgba(0,0,0,.25);
+}
+.modal-header {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
+}
+.modal-header h2 { font-size: 16px; }
+.modal-close { border: none; background: none; font-size: 20px; color: var(--muted); cursor: pointer; }
+.modal-body { padding: 18px 20px; }
+.modal-footer {
+ padding: 12px 20px; border-top: 1px solid var(--border);
+ display: flex; justify-content: flex-end; gap: 10px;
+}
+
+.form-row { margin-bottom: 14px; }
+.form-row label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 5px; }
+.form-row input, .form-row select, .form-row textarea { width: 100%; }
+
+/* 历史时间线 */
+.timeline { max-height: 420px; overflow-y: auto; padding-right: 6px; }
+.tl-item {
+ display: flex; gap: 12px; padding: 10px 0;
+ border-left: 3px solid var(--border); margin-left: 6px; padding-left: 16px;
+ position: relative;
+}
+.tl-item .dot {
+ position: absolute; left: -8px; top: 16px;
+ width: 13px; height: 13px; border-radius: 50%;
+ border: 2px solid #fff; box-shadow: 0 0 0 2px var(--border);
+}
+.tl-item .tl-time { font-size: 12px; color: var(--muted); min-width: 150px; }
+.tl-item .tl-status { font-weight: 600; }
+.tl-item .tl-remark { font-size: 13px; color: var(--muted); margin-top: 2px; }
+
+/* 透视表 */
+.matrix-wrap { overflow: auto; max-height: 70vh; }
+#matrix-table th { position: sticky; top: 0; background: #fafbfc; z-index: 2; }
+#matrix-table th:first-child, #matrix-table td:first-child { position: sticky; left: 0; background: #fafbfc; z-index: 1; }
+#matrix-table td:first-child { background: #f4f6f9; color: var(--muted); font-size: 12px; }
+#matrix-table th.corner { z-index: 3; }
+.cell {
+ display: inline-block; min-width: 64px; text-align: center;
+ padding: 3px 8px; border-radius: 6px; font-size: 12px; color: #fff; font-weight: 600;
+ line-height: 1.35;
+}
+.cell-remark {
+ display: block; font-weight: 400; font-size: 11px;
+ opacity: .88; margin-top: 2px; max-width: 180px;
+ white-space: normal; word-break: break-all;
+}
+.cell.empty-cell { background: #f1f4f8; color: #c3cbd6; }
+
+/* 提示 */
+.toast {
+ position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
+ background: #111827; color: #fff; padding: 10px 20px;
+ border-radius: 8px; font-size: 14px; z-index: 100;
+ opacity: 0; transition: opacity .25s; pointer-events: none;
+}
+.toast.show { opacity: 1; }
+.toast.error { background: var(--danger); }
+
+.empty-tip { color: var(--muted); text-align: center; padding: 40px 0; font-size: 14px; }
diff --git a/stop.bat b/stop.bat
new file mode 100644
index 0000000..63e9db3
--- /dev/null
+++ b/stop.bat
@@ -0,0 +1,15 @@
+@echo off
+title ÎÞÈ˻ú×ʲú¹ÜÀíϵͳ - ֹͣ·þÎñ
+
+echo ============================================
+echo ÎÞÈ˻ú×ʲú¹ÜÀíϵͳ - ֹͣ·þÎñ
+echo ============================================
+echo.
+
+powershell -NoProfile -ExecutionPolicy Bypass -Command "$c = Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue; if ($c) { $c.OwningProcess | Sort-Object -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue; Write-Host (' ÒÑֹͣ·þÎñ½ø³Ì PID ' + $_) } } else { Write-Host ' δ·¢ÏÖÔËÐÐÖеķþÎ˿Ú 8000 δ±»ռÓã©' }"
+
+echo.
+echo ·þÎñÒÑֹͣ£¨ÈçÐèÔٴÎÆô¶¯£¬˫»÷ start.bat£©¡£
+echo.
+pause
+