first
This commit is contained in:
+37
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"topic_20260807-122045_ad70a5c6e85a19b4": {
|
||||||
|
"stage": 3,
|
||||||
|
"userTurns": 3,
|
||||||
|
"basisHash": "67a0aadd52e2dfe4",
|
||||||
|
"updatedAt": 1786107445489
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"topic_20260807-122045_ad70a5c6e85a19b4": 1786105245321
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"topic_20260807-122045_ad70a5c6e85a19b4": "auto"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"topic_20260807-122045_ad70a5c6e85a19b4": "我想建立一个无人机资产管理库,采用B…"
|
||||||
|
}
|
||||||
@@ -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+):<https://www.python.org/downloads/>,安装时勾选 *Add python.exe to PATH*
|
||||||
|
2. 把本项目整个文件夹拷贝到目标电脑(含 `main.py`、`static/`、`start.bat`)
|
||||||
|
3. 双击 `start.bat` —— 首次运行会自动安装 fastapi、uvicorn 并启动服务
|
||||||
|
4. 浏览器自动打开 <http://127.0.0.1:8000> 即可使用
|
||||||
|
|
||||||
|
> 局域网共享:同一网段内其他电脑浏览器访问 `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}` | 状态改名/改色(改名同步历史)/ 删除(内置与已使用状态不可删) |
|
||||||
|
|
||||||
|
交互式接口文档:启动服务后访问 <http://127.0.0.1:8000/docs>
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
UAVManager/
|
||||||
|
├── main.py # FastAPI 后端(应用 + 数据库 + API)
|
||||||
|
├── start.bat # Windows 一键启动脚本
|
||||||
|
├── README.md
|
||||||
|
├── uav.db # SQLite 数据库(运行时自动创建)
|
||||||
|
└── static/ # 前端页面
|
||||||
|
├── index.html
|
||||||
|
├── style.css
|
||||||
|
└── app.js
|
||||||
|
```
|
||||||
+18
@@ -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.
|
||||||
@@ -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
|
||||||
+961
@@ -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 = `<option value="">全部状态</option>` +
|
||||||
|
statusTypes.map((s) => `<option value="${esc(s.name)}">${esc(s.name)}</option>`).join("") +
|
||||||
|
`<option value="__none__">无状态</option>`;
|
||||||
|
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 `<span class="badge empty">无状态</span>`;
|
||||||
|
return `<span class="badge" style="background:${esc(badgeColor(status))}">${esc(status)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>${esc(title)}</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">${bodyHTML}</div>
|
||||||
|
${footerHTML ? `<div class="modal-footer">${footerHTML}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
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 = `<option value="">全部机型</option>` +
|
||||||
|
models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("");
|
||||||
|
const selM = document.getElementById("matrix-model");
|
||||||
|
selM.innerHTML = models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("");
|
||||||
|
const selF = document.getElementById("flight-model");
|
||||||
|
selF.innerHTML = models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).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 = `<div class="empty-tip">暂无无人机,点击右上角「新增无人机」添加</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.innerHTML = `
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:34px"><input type="checkbox" id="check-all" title="全选/取消全选"></th>
|
||||||
|
<th>机型</th><th>编号</th><th>链路ID</th><th>飞控ID</th>
|
||||||
|
<th>飞控版本</th><th>导航版本</th>
|
||||||
|
<th>当前状态</th><th>备注</th><th>操作</th>
|
||||||
|
</tr></thead><tbody></tbody>`;
|
||||||
|
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 = `
|
||||||
|
<td><input type="checkbox" class="drone-check" value="${d.id}"></td>
|
||||||
|
<td>${esc(d.model)}</td>
|
||||||
|
<td><strong>${esc(d.serial_no)}</strong></td>
|
||||||
|
<td>${esc(d.link_id)}</td>
|
||||||
|
<td>${esc(d.fc_id)}</td>
|
||||||
|
<td>${esc(d.fc_version)}</td>
|
||||||
|
<td>${esc(d.nav_version)}</td>
|
||||||
|
<td>${badge(st)}${lastRemark ? `<div style="font-size:12px;color:#7a8699;margin-top:2px">${esc(lastRemark)}</div>` : ""}</td>
|
||||||
|
<td class="remark-cell">${esc(d.remark)}</td>
|
||||||
|
<td><div class="actions">
|
||||||
|
<button class="small" onclick="showHistory(${d.id})">历史</button>
|
||||||
|
<button class="small" onclick="openFlightModal(${d.id})">飞行</button>
|
||||||
|
<button class="small primary" onclick="openStatusModal(${d.id})">修改状态</button>
|
||||||
|
<button class="small" onclick="openEditModal(${d.id})">编辑</button>
|
||||||
|
<button class="small danger" onclick="confirmDelete(${d.id})">删除</button>
|
||||||
|
</div></td>`;
|
||||||
|
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(
|
||||||
|
"新增无人机",
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>机型 *</label><input id="f-model" type="text" placeholder="如 DJI Mavic 3"></div>
|
||||||
|
<div class="form-row"><label>编号 *</label><input id="f-serial" type="text" placeholder="唯一编号,如 UAV-001"></div>
|
||||||
|
<div class="form-row"><label>链路ID</label><input id="f-link" type="text"></div>
|
||||||
|
<div class="form-row"><label>飞控ID</label><input id="f-fc" type="text"></div>
|
||||||
|
<div class="form-row"><label>飞控版本号</label><input id="f-fcv" type="text"></div>
|
||||||
|
<div class="form-row"><label>导航版本号</label><input id="f-nav" type="text"></div>
|
||||||
|
<div class="form-row"><label>备注</label><textarea id="f-remark"></textarea></div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="submitCreate()">保存</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>机型</label><input id="f-model" type="text" value="${esc(d.model)}"></div>
|
||||||
|
<div class="form-row"><label>链路ID</label><input id="f-link" type="text" value="${esc(d.link_id)}"></div>
|
||||||
|
<div class="form-row"><label>飞控ID</label><input id="f-fc" type="text" value="${esc(d.fc_id)}"></div>
|
||||||
|
<div class="form-row"><label>飞控版本号</label><input id="f-fcv" type="text" value="${esc(d.fc_version)}"></div>
|
||||||
|
<div class="form-row"><label>导航版本号</label><input id="f-nav" type="text" value="${esc(d.nav_version)}"></div>
|
||||||
|
<div class="form-row"><label>备注</label><textarea id="f-remark">${esc(d.remark)}</textarea></div>
|
||||||
|
<div style="font-size:12px;color:#7a8699">编号为唯一标识,新增时设定,此处不可修改。</div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="submitEdit(${id})">保存</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
"删除确认",
|
||||||
|
`<p style="font-size:14px">确定删除编号 <strong>${esc(d.serial_no)}</strong> 的无人机吗?<br>
|
||||||
|
其全部状态变更记录将一并删除,此操作不可恢复。</p>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary danger" onclick="doDelete(${id})">删除</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>当前状态</label><div>${badge(cur)}</div></div>
|
||||||
|
<div class="form-row"><label>新状态 *</label>
|
||||||
|
<select id="s-status">
|
||||||
|
${statusTypes.map((s) => `<option value="${esc(s.name)}" ${s.name === cur ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label>变更日期时间(可改,用于补充历史记录)</label>
|
||||||
|
<input type="datetime-local" id="s-time" value="${nowLocal()}">
|
||||||
|
<div style="font-size:12px;color:#7a8699;margin-top:4px">默认当前时间;如需补充补录某天的状态,改成对应日期时间即可。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label>本次备注(可选)</label><textarea id="s-remark" placeholder="如:检修完成、入库等"></textarea></div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="submitStatus(${id})">提交</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `<option value="${esc(s.name)}">${esc(s.name)}</option>`).join("");
|
||||||
|
openModal(
|
||||||
|
`批量修改状态(${ids.length} 架)`,
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>新状态 *</label><select id="bs-status">${opts}</select></div>
|
||||||
|
<div class="form-row"><label>变更日期时间(可改,用于补充历史记录)</label>
|
||||||
|
<input type="datetime-local" id="bs-time" value="${nowLocal()}">
|
||||||
|
<div style="font-size:12px;color:#7a8699;margin-top:4px">默认当前时间;如需批量补录历史,改为对应日期时间即可。</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label>本次备注(可选,写入每一架的记录)</label>
|
||||||
|
<textarea id="bs-remark" placeholder="统一备注,如:批量入库"></textarea>
|
||||||
|
</div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="submitBatchStatus()">提交</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 架)`,
|
||||||
|
`
|
||||||
|
<div style="font-size:13px;color:#7a8699;margin-bottom:10px">
|
||||||
|
按日期统一修改这些飞机「该日期」的飞行记录;留空的字段不修改。
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label>飞行日期 *</label><input id="bef-date" type="date"></div>
|
||||||
|
<div class="form-row"><label>飞行时长(分钟,留空不改)</label><input id="bef-dur" type="number" min="0" placeholder="如:60"></div>
|
||||||
|
<div class="form-row"><label>起飞地点(留空不改)</label><input id="bef-takeoff" type="text" placeholder="如:场A"></div>
|
||||||
|
<div class="form-row"><label>降落地点(留空不改)</label><input id="bef-landing" type="text" placeholder="如:场B"></div>
|
||||||
|
<div class="form-row"><label>备注(留空不改)</label><input id="bef-remark" type="text" placeholder="如:批量调整"></div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="submitBatchEditFlight()">提交</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 架)`,
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>删除方式</label>
|
||||||
|
<select id="bd-mode" onchange="toggleBdDate()">
|
||||||
|
<option value="flights-all">删除这些飞机的全部飞行记录</option>
|
||||||
|
<option value="flights-date">删除这些飞机指定日期的飞行记录</option>
|
||||||
|
<option value="drones">删除整架无人机(含状态与飞行记录)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="bd-date-row" style="display:none">
|
||||||
|
<label>飞行日期 *</label><input id="bd-date" type="date">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:#dc2626;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:10px">
|
||||||
|
警告:删除操作不可恢复!整机删除会连同其状态历史与飞行记录一并删除。
|
||||||
|
</div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary danger" onclick="submitBatchDelete()">确认删除</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<div class="sm-row" style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid #eef1f5">
|
||||||
|
<input type="color" data-action="color" data-name="${esc(s.name)}" value="${esc(s.color)}"
|
||||||
|
style="width:34px;height:26px;border:none;padding:0;cursor:pointer" title="点击修改颜色">
|
||||||
|
<span class="badge" style="background:${esc(s.color)}">${esc(s.name)}</span>
|
||||||
|
${s.is_builtin
|
||||||
|
? '<span style="font-size:12px;color:#7a8699">内置</span>'
|
||||||
|
: `<button class="small" data-action="rename" data-name="${esc(s.name)}">改名</button>
|
||||||
|
<button class="small danger" data-action="delete" data-name="${esc(s.name)}">删除</button>`}
|
||||||
|
</div>`).join("");
|
||||||
|
openModal(
|
||||||
|
"状态管理",
|
||||||
|
`
|
||||||
|
<div style="margin-bottom:14px;font-size:13px;color:#7a8699">
|
||||||
|
状态用于「修改状态」下拉及透视表/历史展示;点击颜色块可修改颜色,内置状态不可删除,已被记录使用的状态不可删除。
|
||||||
|
</div>
|
||||||
|
<div class="sm-list" style="max-height:300px;overflow-y:auto">${rows || '<div class="empty-tip">暂无状态</div>'}</div>
|
||||||
|
<div style="margin-top:16px;border-top:1px solid #eef1f5;padding-top:14px">
|
||||||
|
<div class="form-row" style="margin-bottom:0">
|
||||||
|
<label>新增自定义状态</label>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center">
|
||||||
|
<input id="sm-name" type="text" placeholder="状态名称,如:巡检中" style="flex:1">
|
||||||
|
<input id="sm-color" type="color" value="#3b82f6" style="width:44px;height:32px;border:none;padding:0;cursor:pointer">
|
||||||
|
<button class="primary" onclick="createStatus()">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
`<button class="primary" onclick="closeModal()">关闭</button>`
|
||||||
|
);
|
||||||
|
// 颜色修改
|
||||||
|
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) => `
|
||||||
|
<div class="tl-item">
|
||||||
|
<span class="dot" style="background:${badgeColor(h.status)}"></span>
|
||||||
|
<div class="tl-time">${esc(h.changed_at)}</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<span class="badge" style="background:${esc(badgeColor(h.status))}">${esc(h.status)}</span>
|
||||||
|
${h.remark ? `<div class="tl-remark">${esc(h.remark)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
<button class="small" data-action="edit-status" data-id="${h.id}">编辑</button>
|
||||||
|
<button class="small danger" data-action="del-status" data-id="${h.id}">删除</button>
|
||||||
|
</div>`).join("")
|
||||||
|
: `<div class="empty-tip">暂无状态变更记录</div>`;
|
||||||
|
openModal(
|
||||||
|
`状态历史 — ${d.serial_no}(${esc(d.model)})`,
|
||||||
|
`<div class="timeline">${items}</div>`,
|
||||||
|
`<button class="primary" onclick="closeModal()">关闭</button>`
|
||||||
|
);
|
||||||
|
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) =>
|
||||||
|
`<option value="${esc(s.name)}" ${s.name === rec.status ? "selected" : ""}>${esc(s.name)}</option>`
|
||||||
|
).join("");
|
||||||
|
const d = dronesCache.find((x) => x.id === id);
|
||||||
|
openModal(
|
||||||
|
`编辑状态记录 — ${d ? d.serial_no : id}`,
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>状态 *</label><select id="es-status">${opts}</select></div>
|
||||||
|
<div class="form-row"><label>变更日期时间</label>
|
||||||
|
<input type="datetime-local" id="es-time" value="${toLocalInput(rec.changed_at)}">
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label>备注</label><input id="es-remark" type="text" value="${esc(rec.remark)}"></div>`,
|
||||||
|
`<button onclick="showHistory(${id})">返回</button><button class="primary" onclick="saveStatusRecord(${id}, ${rec.id})">保存</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;padding:6px 4px;cursor:pointer">
|
||||||
|
<input type="checkbox" class="ex-model" value="${esc(m)}"> <span>${esc(m)}</span>
|
||||||
|
</label>`).join("")
|
||||||
|
: `<div class="empty-tip">暂无机型,请先添加无人机</div>`;
|
||||||
|
openModal(
|
||||||
|
"导出 Excel",
|
||||||
|
`
|
||||||
|
<div style="margin-bottom:10px;font-size:13px;color:#7a8699">
|
||||||
|
<div style="margin-bottom:6px">
|
||||||
|
选择机型(不勾选则导出全部):
|
||||||
|
<a href="javascript:void(0)" onclick="toggleExModels(true)" style="margin-left:10px">全选</a>
|
||||||
|
<a href="javascript:void(0)" onclick="toggleExModels(false)" style="margin-left:8px">清空</a>
|
||||||
|
</div>
|
||||||
|
<div style="max-height:200px;overflow-y:auto;border:1px solid #eef1f5;border-radius:8px;padding:6px 10px">
|
||||||
|
${items}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||||
|
<label style="font-size:13px;color:#7a8699">日期范围(可选):</label>
|
||||||
|
<input type="date" id="ex-start">
|
||||||
|
<span style="color:#7a8699">至</span>
|
||||||
|
<input type="date" id="ex-end">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;font-size:12px;color:#7a8699">
|
||||||
|
未选日期:导出「无人机台账 + 状态历史」;选择日期:导出「每日状态矩阵(行=日期、列=编号)+ 变更明细 + 台账」。
|
||||||
|
</div>`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="doExportExcel()">导出</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<div class="tl-item" style="border-left-color:#3b82f6">
|
||||||
|
<span class="dot" style="background:#3b82f6"></span>
|
||||||
|
<div class="tl-time">${esc(f.flight_date)}</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<strong>${f.duration_min} 分钟</strong>
|
||||||
|
<div class="tl-remark">${esc(f.takeoff)} → ${esc(f.landing)}${f.remark ? " | " + esc(f.remark) : ""}</div>
|
||||||
|
</div>
|
||||||
|
<button class="small" onclick="editFlight(${id}, ${f.id})">编辑</button>
|
||||||
|
<button class="small danger" data-action="del-flight" data-id="${f.id}">删除</button>
|
||||||
|
</div>`).join("")
|
||||||
|
: `<div class="empty-tip">暂无飞行记录</div>`;
|
||||||
|
openModal(
|
||||||
|
`飞行历史 — ${d.serial_no}(${esc(d.model)})`,
|
||||||
|
`
|
||||||
|
<div style="max-height:280px;overflow-y:auto">${items}</div>
|
||||||
|
<div style="margin-top:14px;border-top:1px solid #eef1f5;padding-top:14px">
|
||||||
|
<div style="font-size:13px;color:#7a8699;margin-bottom:8px">新增飞行记录</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
|
<input id="fl-date" type="date">
|
||||||
|
<input id="fl-dur" type="number" min="0" placeholder="时长(分钟)">
|
||||||
|
<input id="fl-takeoff" type="text" placeholder="起飞地点">
|
||||||
|
<input id="fl-landing" type="text" placeholder="降落地点">
|
||||||
|
</div>
|
||||||
|
<input id="fl-remark" type="text" placeholder="备注(可选)" style="margin-top:8px;width:100%">
|
||||||
|
</div>`,
|
||||||
|
`<button onclick="closeModal()">关闭</button><button class="primary" onclick="createFlight(${id})">添加飞行记录</button>`
|
||||||
|
);
|
||||||
|
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}`,
|
||||||
|
`
|
||||||
|
<div class="form-row"><label>飞行日期</label><input id="ef-date" type="date" value="${esc(f.flight_date)}"></div>
|
||||||
|
<div class="form-row"><label>飞行时长(分钟)</label><input id="ef-dur" type="number" min="0" value="${f.duration_min}"></div>
|
||||||
|
<div class="form-row"><label>起飞地点</label><input id="ef-takeoff" type="text" value="${esc(f.takeoff)}"></div>
|
||||||
|
<div class="form-row"><label>降落地点</label><input id="ef-landing" type="text" value="${esc(f.landing)}"></div>
|
||||||
|
<div class="form-row"><label>备注</label><input id="ef-remark" type="text" value="${esc(f.remark)}"></div>`,
|
||||||
|
`<button onclick="openFlightModal(${id})">返回</button><button class="primary" onclick="saveFlight(${id}, ${flightId})">保存</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;padding:5px 4px;cursor:pointer">
|
||||||
|
<input type="checkbox" class="bf-serial" value="${esc(d.serial_no)}">
|
||||||
|
<span>${esc(d.serial_no)}</span>
|
||||||
|
<span style="color:#7a8699;font-size:12px">(${esc(d.model)})</span>
|
||||||
|
</label>`).join("")
|
||||||
|
: `<div class="empty-tip">暂无无人机</div>`;
|
||||||
|
openModal(
|
||||||
|
"统一添加飞行历史",
|
||||||
|
`
|
||||||
|
<div style="margin-bottom:8px;font-size:13px;color:#7a8699">
|
||||||
|
勾选飞机编号(列表为当前机型筛选结果),填写飞行信息后统一添加,对应飞机自动同步:
|
||||||
|
<a href="javascript:void(0)" onclick="toggleBatch(true)" style="margin-left:10px">全选</a>
|
||||||
|
<a href="javascript:void(0)" onclick="toggleBatch(false)" style="margin-left:8px">清空</a>
|
||||||
|
</div>
|
||||||
|
<div style="max-height:180px;overflow-y:auto;border:1px solid #eef1f5;border-radius:8px;padding:6px 10px">${items}</div>
|
||||||
|
<div style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
|
<input id="bf-date" type="date">
|
||||||
|
<input id="bf-dur" type="number" min="0" placeholder="时长(分钟)">
|
||||||
|
<input id="bf-takeoff" type="text" placeholder="起飞地点">
|
||||||
|
<input id="bf-landing" type="text" placeholder="降落地点">
|
||||||
|
</div>
|
||||||
|
<input id="bf-remark" type="text" placeholder="备注(可选)" style="margin-top:8px;width:100%">`,
|
||||||
|
`<button onclick="closeModal()">取消</button><button class="primary" onclick="submitBatchFlight()">统一添加</button>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="empty-tip">请先在「无人机管理」中添加无人机,然后选择机型查看透视表</div>`;
|
||||||
|
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 = `<div class="empty-tip">加载中…</div>`;
|
||||||
|
const m = await api("/api/status-matrix" + q);
|
||||||
|
if (!m.drones.length) {
|
||||||
|
box.innerHTML = `<div class="empty-tip">该机型下暂无无人机</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const thead = `<tr><th class="corner">日期 \ 编号</th>` +
|
||||||
|
m.drones.map((d) => `<th>${esc(d.serial_no)}</th>`).join("") + `</tr>`;
|
||||||
|
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
|
||||||
|
? `<td><span class="cell" style="background:${badgeColor(st)}">${esc(st)}${rm ? `<span class="cell-remark">${esc(rm)}</span>` : ""}</span></td>`
|
||||||
|
: `<td><span class="cell empty-cell">—</span></td>`;
|
||||||
|
}).join("");
|
||||||
|
return `<tr><td>${esc(day)}</td>${cells}</tr>`;
|
||||||
|
}).join("");
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="margin-bottom:10px;font-size:13px;color:#7a8699">
|
||||||
|
机型:<strong>${esc(m.model)}</strong> | 日期范围:${esc(m.start)} ~ ${esc(m.end)}
|
||||||
|
(共 ${m.dates.length} 天,${m.drones.length} 架)
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap matrix-wrap">
|
||||||
|
<table id="matrix-table"><thead>${thead}</thead><tbody>${tbody}</tbody></table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 飞行透视表
|
||||||
|
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 = `<div class="empty-tip">请先在「无人机管理」中添加无人机,然后选择机型查看飞行透视表</div>`;
|
||||||
|
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 = `<div class="empty-tip">加载中…</div>`;
|
||||||
|
const m = await api("/api/flight-matrix" + q);
|
||||||
|
if (!m.drones.length) {
|
||||||
|
box.innerHTML = `<div class="empty-tip">该机型下暂无无人机</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const thead = `<tr><th class="corner">日期 \ 编号</th>` +
|
||||||
|
m.drones.map((d) => `<th>${esc(d.serial_no)}</th>`).join("") + `</tr>`;
|
||||||
|
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
|
||||||
|
? `<td><span class="cell" style="background:#3b82f6">${c} 次${rm ? `<span class="cell-remark">${esc(rm)}</span>` : ""}</span></td>`
|
||||||
|
: `<td><span class="cell empty-cell">—</span></td>`;
|
||||||
|
}).join("");
|
||||||
|
return `<tr><td>${esc(day)}</td>${cells}</tr>`;
|
||||||
|
}).join("");
|
||||||
|
// 总架次统计行(按筛选日期范围汇总)
|
||||||
|
const totals = m.drones.map((d) => d.counts.reduce((a, b) => a + b, 0));
|
||||||
|
const totalRow = `<tr style="border-top:2px solid #c3cbd6">
|
||||||
|
<td style="font-weight:700;background:#eef1f5">总架次</td>` +
|
||||||
|
totals.map((t) =>
|
||||||
|
t
|
||||||
|
? `<td style="background:#eef1f5"><span class="cell" style="background:#64748b">${t} 次</span></td>`
|
||||||
|
: `<td style="background:#eef1f5"><span class="cell empty-cell">—</span></td>`
|
||||||
|
).join("") + `</tr>`;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="margin-bottom:10px;font-size:13px;color:#7a8699">
|
||||||
|
机型:<strong>${esc(m.model)}</strong> | 日期范围:${esc(m.start)} ~ ${esc(m.end)}
|
||||||
|
(共 ${m.dates.length} 天,${m.drones.length} 架)
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap matrix-wrap">
|
||||||
|
<table id="matrix-table"><thead>${thead}</thead><tbody>${totalRow}${tbody}</tbody></table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 初始化
|
||||||
|
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);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>无人机资产管理系统</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>无人机资产管理系统</h1>
|
||||||
|
<nav>
|
||||||
|
<button id="tab-drones" class="tab active" data-tab="drones">无人机管理</button>
|
||||||
|
<button id="tab-matrix" class="tab" data-tab="matrix">状态透视表</button>
|
||||||
|
<button id="tab-flight" class="tab" data-tab="flight">飞行透视表</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="view-drones" class="view active">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label>机型:</label>
|
||||||
|
<select id="model-filter" style="min-width:140px"></select>
|
||||||
|
<label>编号:</label>
|
||||||
|
<input id="serial-filter" type="text" placeholder="模糊匹配" style="width:150px">
|
||||||
|
<label>飞控ID:</label>
|
||||||
|
<input id="fc-filter" type="text" placeholder="模糊匹配" style="width:110px">
|
||||||
|
<label>状态:</label>
|
||||||
|
<select id="status-filter" style="min-width:110px"></select>
|
||||||
|
<button id="apply-filter" class="primary">筛选</button>
|
||||||
|
<button id="reset-filter">重置</button>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button id="batch-status">批量修改状态</button>
|
||||||
|
<button id="batch-edit-flight">批量修改飞行</button>
|
||||||
|
<button id="batch-flight">批量添加飞行</button>
|
||||||
|
<button id="batch-delete" class="danger">批量删除</button>
|
||||||
|
<button id="status-manager">状态管理</button>
|
||||||
|
<button id="export-excel">导出 Excel</button>
|
||||||
|
<button id="add-drone" class="primary">+ 新增无人机</button>
|
||||||
|
</div>
|
||||||
|
<div id="drones-body" class="table-wrap"><div class="empty-tip">加载中…</div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="view-matrix" class="view">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label>机型:</label>
|
||||||
|
<select id="matrix-model" style="min-width:140px"></select>
|
||||||
|
<label>编号:</label>
|
||||||
|
<input id="matrix-serial" type="text" placeholder="模糊匹配" style="width:130px">
|
||||||
|
<label>飞控ID:</label>
|
||||||
|
<input id="matrix-fc" type="text" placeholder="模糊匹配" style="width:100px">
|
||||||
|
<label>状态:</label>
|
||||||
|
<select id="matrix-status" style="min-width:100px"></select>
|
||||||
|
<label>开始日期:</label>
|
||||||
|
<input type="date" id="matrix-start">
|
||||||
|
<label>结束日期:</label>
|
||||||
|
<input type="date" id="matrix-end">
|
||||||
|
<button id="matrix-query" class="primary">查询</button>
|
||||||
|
</div>
|
||||||
|
<div id="matrix-box"><div class="empty-tip">选择机型后点击「查询」查看每日状态透视表</div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="view-flight" class="view">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label>机型:</label>
|
||||||
|
<select id="flight-model" style="min-width:140px"></select>
|
||||||
|
<label>编号:</label>
|
||||||
|
<input id="flight-serial" type="text" placeholder="模糊匹配" style="width:130px">
|
||||||
|
<label>飞控ID:</label>
|
||||||
|
<input id="flight-fc" type="text" placeholder="模糊匹配" style="width:100px">
|
||||||
|
<label>开始日期:</label>
|
||||||
|
<input type="date" id="flight-start">
|
||||||
|
<label>结束日期:</label>
|
||||||
|
<input type="date" id="flight-end">
|
||||||
|
<button id="flight-query" class="primary">查询</button>
|
||||||
|
</div>
|
||||||
|
<div id="flight-box"><div class="empty-tip">选择机型后点击「查询」查看飞行历史透视表(单元格=当日飞行次数)</div></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="modal" class="modal-mask"></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user