Files
UAVManager/static/app.js
T

961 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 无人机资产管理系统 — 前端逻辑(原生 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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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()">&times;</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>当前状态</th><th>操作</th>
<th>飞控ID</th><th>链路ID</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>${badge(st)}${lastRemark ? `<div style="font-size:12px;color:#7a8699;margin-top:2px">${esc(lastRemark)}</div>` : ""}</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>
<td>${esc(d.fc_id)}</td>
<td>${esc(d.link_id)}</td>
<td>${esc(d.fc_version)}</td>
<td>${esc(d.nav_version)}</td>
<td class="remark-cell">${esc(d.remark)}</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);