commit 00af9f7fdb5cf4e5b9b833388571857a566b01eb Author: tianzhendong <1203886034@qq.com> Date: Fri Aug 7 23:04:54 2026 +0800 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df11c39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# ===== Python ===== +__pycache__/ +*.py[cod] +*$py.class +*.so + +# ===== 虚拟环境 ===== +venv/ +.venv/ +env/ +ENV/ + +# ===== 运行时数据(本地生成,勿提交)===== +# SQLite 数据库及 WAL 日志 +uav.db +uav.db-journal +uav.db-wal +uav.db-shm +# 手动导出的 Excel 台账 +无人机台账.xlsx + +# ===== 日志 / 缓存 / 测试产物 ===== +*.log +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# ===== 编辑器 / IDE ===== +.idea/ +.vscode/ +*.swp +*.swo + +# ===== 操作系统 ===== +.DS_Store +Thumbs.db diff --git a/.reasonix/desktop-topic-auto-title-meta.json b/.reasonix/desktop-topic-auto-title-meta.json new file mode 100644 index 0000000..8144bab --- /dev/null +++ b/.reasonix/desktop-topic-auto-title-meta.json @@ -0,0 +1,8 @@ +{ + "topic_20260807-122045_ad70a5c6e85a19b4": { + "stage": 3, + "userTurns": 3, + "basisHash": "67a0aadd52e2dfe4", + "updatedAt": 1786107445489 + } +} \ No newline at end of file diff --git a/.reasonix/desktop-topic-created-at.json b/.reasonix/desktop-topic-created-at.json new file mode 100644 index 0000000..c8b366f --- /dev/null +++ b/.reasonix/desktop-topic-created-at.json @@ -0,0 +1,3 @@ +{ + "topic_20260807-122045_ad70a5c6e85a19b4": 1786105245321 +} \ No newline at end of file diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json new file mode 100644 index 0000000..f1df253 --- /dev/null +++ b/.reasonix/desktop-topic-title-sources.json @@ -0,0 +1,3 @@ +{ + "topic_20260807-122045_ad70a5c6e85a19b4": "auto" +} \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json new file mode 100644 index 0000000..be8780c --- /dev/null +++ b/.reasonix/desktop-topic-titles.json @@ -0,0 +1,3 @@ +{ + "topic_20260807-122045_ad70a5c6e85a19b4": "我想建立一个无人机资产管理库,采用B…" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1776877 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# 无人机资产管理系统 + +BS 架构的无人机资产管理库:管理无人机基本信息(机型、编号、链路ID、飞控ID、备注), +记录每次主状态变更(待组装 / 待调试 / 待飞 / 损坏),可按机型查看"日期 × 飞机编号"的状态透视表, +也可查看每一架无人机的完整状态变更历史。 + +## 功能 + +- **无人机管理**:新增、编辑、删除无人机;机型筛选;编号唯一 +- **主状态管理**:修改状态时写入一条含时间戳的记录(每次修改均保留) +- **自定义状态**:内置"待组装/待调试/待飞/损坏",可新增自定义状态并设置/修改颜色、改名;内置与已被使用的状态不可删除(改名会同步更新历史记录) +- **状态历史**:每一架无人机的时间线,展示每条变更记录(时间戳 + 状态 + 备注),**单条记录可删除** +- **飞行历史**:每架无人机的飞行记录(日期、时长、起降地点、备注),支持增/删/改;支持**统一添加**——一次勾选多架飞机编号批量添加,对应飞机自动同步 +- **飞行透视表**:行=日期、列=编号,单元格=当日飞行次数,支持机型与日期范围筛选 +- **按机型透视表**:行 = 日期、列 = 飞机编号、单元格 = 当日状态;无变更日期自动沿用前一状态;支持自定义日期范围(默认近 30 天) +- **状态颜色区分**:待组装=灰、待调试=橙、待飞=绿、损坏=红 +- **手动导出 Excel**:点击「导出 Excel」按钮,下载 `无人机台账.xlsx`(含"无人机台账"与"状态历史"两个工作表),数据以点击时刻为准 + +## 技术栈 + +- 后端:Python + FastAPI + Uvicorn +- 数据库:SQLite(单文件 `uav.db`,无需安装数据库服务) +- 前端:原生 HTML / CSS / JavaScript(无构建工具、无第三方依赖) +- Excel 导出:openpyxl + +## 部署(其他电脑) + +1. **安装 Python**(3.9+):,安装时勾选 *Add python.exe to PATH* +2. 把本项目整个文件夹拷贝到目标电脑(含 `main.py`、`static/`、`start.bat`) +3. 双击 `start.bat` —— 首次运行会自动安装 fastapi、uvicorn 并启动服务 +4. 浏览器自动打开 即可使用 + +> 局域网共享:同一网段内其他电脑浏览器访问 `http://本机IP:8000`(服务已监听 0.0.0.0)。 +> 数据文件 `uav.db` 与程序同目录,备份/迁移时直接拷贝该文件即可。 + +## 手动启动 + +```bash +pip install fastapi uvicorn openpyxl +python -m uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +## REST API + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/drones?model=` | 无人机列表(含当前状态,可按机型筛选) | +| POST | `/api/drones` | 新增无人机(机型、编号必填,编号唯一) | +| PUT | `/api/drones/{id}` | 编辑无人机(机型/链路ID/飞控ID/备注) | +| DELETE | `/api/drones/{id}` | 删除无人机(级联删除其状态记录) | +| GET | `/api/drones/models` | 全部机型列表 | +| POST | `/api/drones/{id}/status` | 修改主状态,写入一条含时间戳的记录 | +| GET | `/api/drones/{id}/history` | 单机完整状态变更记录 | +| DELETE | `/api/drones/{id}/history/{record_id}` | 删除单条状态历史记录 | +| GET/POST | `/api/drones/{id}/flights` | 单机飞行历史列表 / 新增飞行记录 | +| PUT/DELETE | `/api/drones/{id}/flights/{flight_id}` | 修改 / 删除飞行记录 | +| POST | `/api/flights/batch` | 统一添加飞行历史(多个编号批量同步) | +| GET | `/api/flight-matrix?model=&start=&end=` | 飞行透视表(当日飞行次数) | +| GET | `/api/status-matrix?model=&start=&end=` | 按机型返回日期×编号状态矩阵 | +| GET | `/api/export-excel` | 手动导出 `无人机台账.xlsx`(台账 + 状态历史两个 sheet) | +| GET/POST | `/api/status-types` | 状态列表 / 新增自定义状态(name 必填,color 可选自动配色) | +| PUT/DELETE | `/api/status-types/{name}` | 状态改名/改色(改名同步历史)/ 删除(内置与已使用状态不可删) | + +交互式接口文档:启动服务后访问 + +## 目录结构 + +``` +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 = ` + `; + 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)}
` : ""} + ${esc(d.remark)} +
+ + + + + +
`; + 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}`, + ` +
${badge(cur)}
+
+ +
+
+ +
默认当前时间;如需补充补录某天的状态,改成对应日期时间即可。
+
+
`, + `` + ); +} + +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( + "状态管理", + ` +
+ 状态用于「修改状态」下拉及透视表/历史展示;点击颜色块可修改颜色,内置状态不可删除,已被记录使用的状态不可删除。 +
+
${rows || '
暂无状态
'}
+
+
+ +
+ + + +
+
+
`, + `` + ); + // 颜色修改 + 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 ? `
${esc(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} 分钟 +
${esc(f.takeoff)} → ${esc(f.landing)}${f.remark ? " | " + esc(f.remark) : ""}
+
+ + +
`).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 ? `${esc(rm)}` : ""}` + : `—`; + }).join(""); + return `${esc(day)}${cells}`; + }).join(""); + box.innerHTML = ` +
+ 机型:${esc(m.model)} | 日期范围:${esc(m.start)} ~ ${esc(m.end)} + (共 ${m.dates.length} 天,${m.drones.length} 架) +
+
+ ${thead}${tbody}
+
`; +} + +// ---------------------------------------------------------------- 飞行透视表 +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 ? `${esc(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 +