提交电脑端初期设计喝所有ui设计供参考

wangjiaqi23
Surponess 2 days ago
parent 2b2ee3a8c9
commit 8e51aeb0ff

@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(curl -sL \"https://unpkg.com/roslib@2.1.0/dist/\")",
"Bash(curl -sL \"https://unpkg.com/roslib@1.3.0/build/roslib.min.js\" -o \"d:/29578/Documents/Study/computer/S_E/软件体系结构与设计/软件前端/js/roslib.min.js\" -w \"%{http_code} %{size_download}\")"
]
}
}

@ -1,383 +0,0 @@
/* ===== 全局样式 ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f0f2f5;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
color: #333;
}
/* ===== 顶部状态栏 ===== */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 0 16px;
background: #1a1a2e;
color: #e0e0e0;
flex-shrink: 0;
z-index: 1000;
}
.top-bar-left {
display: flex;
align-items: center;
gap: 16px;
}
.logo {
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.status-item {
font-size: 13px;
color: #aaa;
}
.status-item strong {
color: #e0e0e0;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 8px;
}
.top-bar-right input {
width: 220px;
padding: 5px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a3e;
color: #e0e0e0;
font-size: 13px;
}
/* ===== 连接模式切换 ===== */
.conn-mode-group {
display: flex;
border-radius: 4px;
overflow: hidden;
border: 1px solid #444;
}
.mode-btn {
padding: 4px 12px;
background: #2a2a3e;
color: #aaa;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s;
}
.mode-btn:hover { color: #ddd; }
.mode-btn.active {
background: #007aff;
color: #fff;
}
.conn-mode-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
background: #2a2a3e;
color: #888;
white-space: nowrap;
}
/* ===== 标签 ===== */
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.tag-success { background: #f6ffed; color: #52c41a; }
.tag-danger { background: #fff1f0; color: #ff4d4f; }
.tag-warning { background: #fffbe6; color: #faad14; }
.tag-info { background: #e6f7ff; color: #007aff; }
/* ===== 按钮 ===== */
.btn {
padding: 7px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.2s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn:not(:disabled):hover { opacity: 0.85; }
.btn-primary { background: #007aff; color: #fff; }
.btn-success { background: #52c41a; color: #fff; }
.btn-warning { background: #faad14; color: #fff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.btn-secondary { background: #e0e0e0; color: #333; }
.btn.wide { width: 100%; }
.btn-link {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 12px;
}
.btn-link:hover { color: #007aff; }
.btn-group {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.btn-group .btn { flex: 1; }
/* ===== 主内容区 ===== */
.main-content {
display: flex;
flex: 1;
min-height: 0;
}
/* ===== 地图 ===== */
.map-container {
flex: 1;
min-width: 0;
z-index: 1;
}
/* ===== 右侧控制面板 ===== */
.control-panel {
width: 300px;
flex-shrink: 0;
background: #fff;
border-left: 1px solid #e0e0e0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel-section {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.panel-header {
font-size: 14px;
font-weight: 700;
color: #333;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header span:last-child {
font-weight: 400;
font-size: 12px;
color: #999;
}
/* ===== 航点列表 ===== */
.waypoint-list {
max-height: 220px;
overflow-y: auto;
margin-bottom: 8px;
}
.empty-hint {
text-align: center;
color: #bbb;
font-size: 13px;
padding: 20px 0;
}
.wp-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-radius: 6px;
margin-bottom: 4px;
background: #f8f9fa;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.wp-item:hover { background: #e6f7ff; }
.wp-item.active { background: #e6f7ff; border: 1px solid #91d5ff; }
.wp-index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #007aff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
margin-right: 8px;
flex-shrink: 0;
}
.wp-info {
flex: 1;
min-width: 0;
}
.wp-coord {
font-size: 11px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-alt {
font-size: 11px;
color: #999;
}
.wp-delete {
width: 20px;
height: 20px;
border: none;
background: none;
color: #ccc;
cursor: pointer;
font-size: 16px;
line-height: 1;
flex-shrink: 0;
}
.wp-delete:hover { color: #ff4d4f; }
.wp-params {
display: flex;
gap: 10px;
font-size: 12px;
color: #666;
}
.wp-params input {
width: 60px;
padding: 3px 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
text-align: center;
}
/* ===== 数据面板 ===== */
.data-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.data-item {
background: #f8f9fa;
border-radius: 6px;
padding: 8px;
text-align: center;
}
.data-label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 2px;
}
.data-value {
font-size: 13px;
font-weight: 600;
color: #333;
}
/* ===== 底部日志栏 ===== */
.log-bar {
height: 120px;
flex-shrink: 0;
background: #1e1e1e;
display: flex;
flex-direction: column;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px;
font-size: 12px;
color: #888;
background: #2a2a2a;
border-bottom: 1px solid #333;
}
.log-content {
flex: 1;
overflow-y: auto;
padding: 4px 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-entry {
white-space: nowrap;
}
.log-entry .time { color: #666; margin-right: 8px; }
.log-entry.success { color: #52c41a; }
.log-entry.error { color: #ff4d4f; }
.log-entry.warning { color: #faad14; }
.log-entry.info { color: #aaa; }
/* ===== Leaflet 自定义 ===== */
.drone-icon {
width: 24px;
height: 24px;
position: relative;
}
.drone-icon-inner {
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 20px solid #007aff;
position: absolute;
top: 2px;
left: 2px;
transform-origin: center 60%;
}

@ -1,118 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P600 无人机定点巡航控制系统</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<!-- 顶部状态栏 -->
<header class="top-bar">
<div class="top-bar-left">
<span class="logo">P600 定点巡航控制系统</span>
<span id="conn-status" class="tag tag-danger">未连接</span>
<span class="status-item">模式: <strong id="flight-mode">--</strong></span>
<span class="status-item">电量: <strong id="battery">--%</strong></span>
<span class="status-item">GPS: <strong id="gps-status">--</strong></span>
<span class="status-item">速度: <strong id="speed">-- m/s</strong></span>
<span class="status-item">高度: <strong id="altitude">-- m</strong></span>
</div>
<div class="top-bar-right">
<div class="conn-mode-group">
<button id="btn-mode-sim" class="mode-btn active" title="Prometheus SITL 仿真 (rosbridge WebSocket)">仿真模式</button>
<button id="btn-mode-real" class="mode-btn" title="P600 实机 WiFi 数传 (rosbridge WebSocket)">实机模式</button>
</div>
<input type="text" id="drone-url" value="ws://localhost:9090" placeholder="rosbridge 地址" />
<button id="btn-connect" class="btn btn-primary">连接无人机</button>
<span id="conn-mode-tag" class="conn-mode-tag">rosbridge</span>
</div>
</header>
<!-- 主内容区 -->
<div class="main-content">
<!-- 地图区域 -->
<div id="map" class="map-container"></div>
<!-- 右侧控制面板 -->
<aside class="control-panel">
<!-- 航点列表 -->
<div class="panel-section">
<div class="panel-header">
<span>航点列表</span>
<span id="wp-count">0 个航点</span>
</div>
<div id="waypoint-list" class="waypoint-list">
<div class="empty-hint">在地图上点击以添加航点</div>
</div>
<div class="wp-params">
<label>默认高度: <input type="number" id="default-alt" value="15" min="1" max="100" /></label>
<label>默认悬停: <input type="number" id="default-hold" value="0" min="0" max="60" /></label>
</div>
</div>
<!-- 任务操作 -->
<div class="panel-section">
<div class="panel-header">任务操作</div>
<div class="btn-group">
<button id="btn-upload" class="btn btn-primary wide" disabled>上传任务</button>
</div>
<div class="btn-group">
<button id="btn-start" class="btn btn-success wide" disabled>开始任务</button>
</div>
<div class="btn-group">
<button id="btn-pause" class="btn btn-warning" disabled>暂停</button>
<button id="btn-resume" class="btn btn-success" disabled>继续</button>
</div>
<div class="btn-group">
<button id="btn-rth" class="btn btn-danger" disabled>返航</button>
<button id="btn-land" class="btn btn-danger" disabled>降落</button>
</div>
<div class="btn-group">
<button id="btn-clear" class="btn btn-secondary wide">清除所有航点</button>
</div>
</div>
<!-- 实时数据 -->
<div class="panel-section">
<div class="panel-header">实时数据</div>
<div class="data-grid">
<div class="data-item">
<span class="data-label">纬度</span>
<span class="data-value" id="data-lat">--</span>
</div>
<div class="data-item">
<span class="data-label">经度</span>
<span class="data-value" id="data-lng">--</span>
</div>
<div class="data-item">
<span class="data-label">航向</span>
<span class="data-value" id="data-heading">--</span>
</div>
<div class="data-item">
<span class="data-label">当前航点</span>
<span class="data-value" id="data-wp">--</span>
</div>
</div>
</div>
</aside>
</div>
<!-- 底部日志栏 -->
<footer class="log-bar">
<div class="log-header">
<span>操作日志</span>
<button id="btn-clear-log" class="btn-link">清空</button>
</div>
<div id="log-content" class="log-content"></div>
</footer>
<!-- JS 依赖 -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/roslib.min.js"></script>
<script src="/js/map.js"></script>
<script src="/js/websocket.js"></script>
<script src="/js/ui.js"></script>
</body>
</html>

@ -1,335 +0,0 @@
/**
* UI 交互模块
* 负责: 按钮事件绑定状态管理航点列表渲染日志输出
*/
const UIModule = (() => {
// 应用状态
let droneConnected = false;
let missionUploaded = false;
let missionRunning = false;
let missionPaused = false;
let connectionMode = 'sim'; // 'sim' 仿真 | 'real' 实机
// 连接预设 (rosbridge WebSocket 地址)
const PRESETS = {
sim: { url: 'ws://localhost:9090', label: '仿真 rosbridge', desc: 'Prometheus SITL 仿真 (本地 rosbridge 端口 9090)' },
real: { url: 'ws://192.168.1.31:9090', label: '实机 rosbridge', desc: 'P600 实机 WiFi 数传 (rosbridge 192.168.1.31:9090)' }
};
function init() {
// 初始化地图
MapModule.init();
// 初始化 WebSocket
WsModule.init();
// 绑定按钮事件
document.getElementById('btn-connect').addEventListener('click', onConnect);
document.getElementById('btn-upload').addEventListener('click', onUpload);
document.getElementById('btn-start').addEventListener('click', onStart);
document.getElementById('btn-pause').addEventListener('click', onPause);
document.getElementById('btn-resume').addEventListener('click', onResume);
document.getElementById('btn-rth').addEventListener('click', onReturnHome);
document.getElementById('btn-land').addEventListener('click', onLand);
document.getElementById('btn-clear').addEventListener('click', onClear);
document.getElementById('btn-clear-log').addEventListener('click', onClearLog);
// 绑定连接模式切换
document.getElementById('btn-mode-sim').addEventListener('click', () => switchMode('sim'));
document.getElementById('btn-mode-real').addEventListener('click', () => switchMode('real'));
// 连接地址输入框变化时更新标签
document.getElementById('drone-url').addEventListener('input', onUrlInput);
updateButtons();
addLog('info', '系统就绪,当前模式: 仿真 (rosbridge)');
addLog('info', '提示: 浏览器通过 roslibjs 直接连接 rosbridge_server无需 MAVSDK 后端');
}
// ---------- 连接模式切换 ----------
function switchMode(mode) {
if (droneConnected) {
addLog('warning', '请先断开当前连接再切换模式');
return;
}
connectionMode = mode;
const preset = PRESETS[mode];
// 更新按钮状态
document.getElementById('btn-mode-sim').classList.toggle('active', mode === 'sim');
document.getElementById('btn-mode-real').classList.toggle('active', mode === 'real');
// 自动填入预设地址
document.getElementById('drone-url').value = preset.url;
document.getElementById('conn-mode-tag').textContent = preset.label;
addLog('info', `已切换到${preset.label}模式: ${preset.desc}`);
if (mode === 'sim') {
addLog('info', '仿真模式说明: 先启动 Prometheus SITL (roslaunch prometheus_gazebo sitl.launch),再启动 rosbridge (roslaunch rosbridge_server rosbridge_websocket.launch)');
} else {
addLog('info', '实机模式说明: 请确保电脑已连接 P600 的 WiFi 数传rosbridge 需在机载电脑上运行');
}
}
function onUrlInput() {
const url = document.getElementById('drone-url').value.trim();
const tag = document.getElementById('conn-mode-tag');
if (url.startsWith('ws://')) {
tag.textContent = 'rosbridge';
} else if (url.startsWith('wss://')) {
tag.textContent = 'rosbridge (SSL)';
} else {
tag.textContent = '未知';
}
}
// ---------- 按钮事件处理 ----------
function onConnect() {
const url = document.getElementById('drone-url').value.trim();
if (!url) {
addLog('error', '请输入 rosbridge 连接地址');
return;
}
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
addLog('error', '地址格式错误,应以 ws:// 开头 (例如 ws://localhost:9090)');
return;
}
WsModule.connectDrone(url);
}
function onUpload() {
const waypoints = MapModule.getWaypoints();
if (waypoints.length === 0) {
addLog('error', '请先在地图上添加航点');
return;
}
WsModule.uploadMission(waypoints);
}
function onStart() {
WsModule.startMission();
}
function onPause() {
WsModule.pauseMission();
}
function onResume() {
WsModule.resumeMission();
}
function onReturnHome() {
WsModule.returnHome();
}
function onLand() {
WsModule.land();
}
function onClear() {
MapModule.clearWaypoints();
missionUploaded = false;
updateButtons();
addLog('info', '已清除所有航点');
}
function onClearLog() {
document.getElementById('log-content').innerHTML = '';
}
// ---------- 状态回调(由 WsModule 触发) ----------
function onTelemetry(data) {
if (data.type === 'telemetry') {
// 更新顶栏数据
document.getElementById('battery').textContent = data.battery.toFixed(0) + '%';
document.getElementById('speed').textContent = data.speed.toFixed(1) + ' m/s';
document.getElementById('altitude').textContent = data.alt.toFixed(1) + ' m';
document.getElementById('gps-status').textContent = data.gps_status;
document.getElementById('flight-mode').textContent = data.flight_mode;
// 更新右侧数据面板
document.getElementById('data-lat').textContent = data.lat.toFixed(6);
document.getElementById('data-lng').textContent = data.lng.toFixed(6);
document.getElementById('data-heading').textContent = data.heading.toFixed(0) + '°';
document.getElementById('data-wp').textContent = data.current_wp;
// 更新无人机位置 — 只要坐标有效就更新
if (droneConnected && (data.lat !== 0 || data.alt > 0.1)) {
MapModule.updateDronePosition(data.lat, data.lng, data.heading);
}
}
if (data.type === 'mission_progress') {
document.getElementById('data-wp').textContent =
data.current + ' / ' + data.total;
}
}
function onStatus(data) {
addLog(data.type, data.message);
// 根据状态消息更新应用状态
const msg = data.message;
if (msg.includes('连接成功') || msg.includes('已连接')) {
droneConnected = true;
document.getElementById('conn-status').className = 'tag tag-success';
document.getElementById('conn-status').textContent = '已连接';
document.getElementById('btn-connect').textContent = '已连接';
document.getElementById('btn-connect').disabled = true;
updateButtons();
}
if (msg.includes('上传成功')) {
missionUploaded = true;
updateButtons();
}
if (msg.includes('任务已开始')) {
missionRunning = true;
missionPaused = false;
updateButtons();
}
if (msg.includes('已暂停')) {
missionRunning = false;
missionPaused = true;
updateButtons();
}
if (msg.includes('已继续')) {
missionRunning = true;
missionPaused = false;
updateButtons();
}
if (msg.includes('返航') || msg.includes('降落') || msg.includes('失败')) {
if (msg.includes('返航') || msg.includes('降落')) {
missionRunning = false;
missionPaused = false;
updateButtons();
}
}
if (msg.includes('任务完成')) {
missionRunning = false;
missionPaused = false;
missionUploaded = false;
updateButtons();
}
}
function onDroneDisconnected() {
droneConnected = false;
missionUploaded = false;
missionRunning = false;
missionPaused = false;
document.getElementById('conn-status').className = 'tag tag-danger';
document.getElementById('conn-status').textContent = '未连接';
document.getElementById('btn-connect').textContent = '连接无人机';
document.getElementById('btn-connect').disabled = false;
updateButtons();
addLog('error', '无人机连接断开');
}
// ---------- 按钮状态管理 ----------
function updateButtons() {
const wpCount = MapModule.getWaypointCount();
setBtn('btn-upload', droneConnected && wpCount > 0 && !missionRunning && !missionPaused);
setBtn('btn-start', droneConnected && missionUploaded && !missionRunning && !missionPaused);
setBtn('btn-pause', droneConnected && missionRunning);
setBtn('btn-resume', droneConnected && missionPaused);
setBtn('btn-rth', droneConnected);
setBtn('btn-land', droneConnected);
}
function setBtn(id, enabled) {
document.getElementById(id).disabled = !enabled;
}
// ---------- 航点列表渲染 ----------
function updateWaypointList() {
const container = document.getElementById('waypoint-list');
const waypoints = MapModule.getWaypoints();
document.getElementById('wp-count').textContent = waypoints.length + ' 个航点';
if (waypoints.length === 0) {
container.innerHTML = '<div class="empty-hint">在地图上点击以添加航点</div>';
return;
}
container.innerHTML = waypoints.map((wp, i) => `
<div class="wp-item" data-index="${i}">
<span class="wp-index">${i + 1}</span>
<div class="wp-info">
<div class="wp-coord">${wp.lat}, ${wp.lng}</div>
<div class="wp-alt">${wp.alt}m | 悬停 ${wp.hold_time}s</div>
</div>
<button class="wp-delete" data-index="${i}" title="删除">&times;</button>
</div>
`).join('');
// 绑定点击定位事件
container.querySelectorAll('.wp-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.classList.contains('wp-delete')) return;
const idx = parseInt(item.dataset.index);
const wps = MapModule.getWaypoints();
if (wps[idx]) {
MapModule.panTo(wps[idx].lat, wps[idx].lng);
}
});
});
// 绑定删除事件
container.querySelectorAll('.wp-delete').forEach(btn => {
btn.addEventListener('click', () => {
MapModule.removeWaypoint(parseInt(btn.dataset.index));
});
});
}
// ---------- 日志输出 ----------
function addLog(type, message) {
const container = document.getElementById('log-content');
const now = new Date();
const time = now.toTimeString().split(' ')[0];
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
entry.innerHTML = `<span class="time">[${time}]</span>${message}`;
container.appendChild(entry);
// 自动滚动到底部
container.scrollTop = container.scrollHeight;
// 限制日志条数
while (container.children.length > 200) {
container.removeChild(container.firstChild);
}
}
return {
init,
onTelemetry,
onStatus,
onDroneDisconnected,
updateWaypointList,
updateButtons
};
})();
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
UIModule.init();
});

@ -1,39 +0,0 @@
"""
Flask 静态文件服务器 - 托管前端页面
浏览器通过 roslibjs 直接连接 rosbridge_server无需 Python 后端处理无人机逻辑
"""
import os
from flask import Flask, send_from_directory
# 项目根目录server 的上级目录)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
app = Flask(__name__, static_folder=BASE_DIR)
@app.route("/")
def index():
return send_from_directory(BASE_DIR, "index.html")
@app.route("/css/<path:filename>")
def css_files(filename):
return send_from_directory(os.path.join(BASE_DIR, "css"), filename)
@app.route("/js/<path:filename>")
def js_files(filename):
return send_from_directory(os.path.join(BASE_DIR, "js"), filename)
if __name__ == "__main__":
print("=" * 50)
print(" P600 无人机定点巡航控制系统")
print(" 打开浏览器访问: http://localhost:5000")
print()
print(" 架构: 浏览器 ──roslibjs──► rosbridge_server ──ROS──► PX4")
print(" rosbridge 地址默认: ws://localhost:9090 (仿真)")
print(" ws://192.168.1.31:9090 (实机)")
print("=" * 50)
app.run(host="0.0.0.0", port=5000, debug=False)

@ -1,7 +0,0 @@
# 此文件已弃用 - 新架构通过 roslibjs + rosbridge_server 直接在浏览器端控制无人机
# 无需 Python 后端处理无人机逻辑
#
# 新架构:
# 浏览器 (roslibjs) ──WebSocket──► rosbridge_server ──ROS话题──► Prometheus/MAVROS ──► PX4
#
# 如需参考旧版 MAVSDK 实现,请查看 git 历史

@ -1,310 +0,0 @@
# P600 无人机定点巡航控制系统 - 使用说明
## 系统简介
本系统是一个基于 Web 的 P600 无人机定点巡航控制前端,支持在地图上选择航点并让无人机按指定路径飞行。前端通过 **roslibjs** 直接连接机载 **rosbridge_server**,利用 ROS 话题和服务控制无人机,兼容**仿真模式**Prometheus SITL和**实机模式**P600 WiFi 数传)。
## 系统架构
```
Windows 浏览器 ──WebSocket──► rosbridge_server ──ROS话题/服务──► PX4 飞控
(地图+控制面板) (机载/仿真 ROS) (Prometheus/MAVROS)
```
- 前端通过 Leaflet 地图进行航点规划,通过 **roslibjs** 与 rosbridge_server 实时通信
- rosbridge_server 将 WebSocket 消息转换为 ROS 话题发布/订阅和服务调用
- 控制指令通过 `/prometheus/control_command` 话题发送到 Prometheus 控制节点
- 无人机状态通过 `/prometheus/drone_state` 话题接收
- 解锁/模式切换通过 MAVROS 服务(`/mavros/cmd/arming`、`/mavros/set_mode`)实现
- QGC 可同时连接同一无人机做监控,互不干扰
## 项目文件结构
```
软件前端/
├── index.html # 主页面
├── css/
│ └── style.css # 样式文件
├── js/
│ ├── map.js # 地图模块Leaflet 航点管理)
│ ├── websocket.js # 通信模块roslibjs + rosbridge
│ └── ui.js # 交互逻辑(按钮状态、日志)
└── server/
├── app.py # Flask 静态文件服务器(仅托管页面)
└── requirements.txt # Python 依赖(仅 flask
```
---
## 一、环境准备
### 1.1 安装 Python 依赖(仅用于启动静态文件服务器)
```bash
cd server
pip install -r requirements.txt
```
依赖内容:
| 包名 | 版本 | 作用 |
|------|------|------|
| flask | 3.0.0 | Web 框架,托管前端页面 |
### 1.2 确认 Python 版本
需要 **Python 3.8+**
```bash
python --version
```
### 1.3 ROS 端依赖(在无人机机载电脑或仿真环境中)
需要在 ROS 环境中安装并运行以下组件:
| 组件 | 作用 |
|------|------|
| rosbridge_suite | WebSocket-ROS 桥接,允许浏览器通过 WebSocket 与 ROS 通信 |
| prometheus_msgs | Prometheus 自定义消息类型ControlCommand、DroneState |
| MAVROS | ROS-MAVLink 桥接,提供服务调用(解锁、模式切换等) |
安装 rosbridge_suite
```bash
sudo apt install ros-<ros>-rosbridge-suite
# 例如 Ubuntu 20.04 + ROS Noetic:
sudo apt install ros-noetic-rosbridge-suite
```
---
## 二、仿真模式使用
> 适用场景:在电脑上调试,不需要真机,使用 Prometheus SITL 仿真
### 2.1 启动仿真环境(在 Ubuntu 虚拟机中)
```bash
# 1. 启动 Prometheus SITL 仿真
cd ~/Prometheus/...
roslaunch prometheus_gazebo sitl.launch
# 2. 在新终端中启动 rosbridge_server
roslaunch rosbridge_server rosbridge_websocket.launch
```
等待 Gazebo 窗口出现、PX4 终端显示 `pxh>` 即表示仿真就绪。rosbridge 默认监听端口 **9090**
> **提示**:如果后端运行在 Windows 上,仿真在虚拟机中,需要确保虚拟机网络为**桥接模式**,并能从 Windows 访问虚拟机 IP。
### 2.2 启动前端页面服务器
```bash
cd server
python app.py
```
浏览器访问:`http://localhost:5000`
### 2.3 前端操作
1. 页面顶栏点击 **「仿真模式」**,地址栏自动填入 `ws://localhost:9090`
- 如果仿真在虚拟机中,地址改为 `ws://<虚拟机IP>:9090`
2. 点击 **「连接无人机」**,等待连接成功提示
3. 在左侧地图上**点击添加航点**(可拖拽调整位置)
4. 在右侧面板设置每个航点的**高度**和**悬停时间**
5. 点击 **「上传任务」** → **「开始任务」**
6. 系统将自动:切换 OFFBOARD 模式 → 解锁电机 → 起飞 → 按航点顺序飞行
7. 可随时 **「暂停」** / **「继续」** / **「返航」** / **「降落」**
---
## 三、实机模式使用
> 适用场景:连接真实的 P600 无人机进行飞行
### 3.1 前置准备
1. P600 无人机**已上电**
2. 电脑连接 P600 的 **WiFi 数传**WiFi 名称和密码见无人机底部标签)
3. 确认能 ping 通机载电脑 IP默认 `192.168.1.31`
4. 确认机载电脑上 **rosbridge_server 已启动**
```bash
# SSH 登录机载电脑后执行
roslaunch rosbridge_server rosbridge_websocket.launch
```
如果 rosbridge 已集成在 Prometheus 启动脚本中则无需手动启动。
### 3.2 启动前端页面服务器
```bash
cd server
python app.py
```
浏览器访问:`http://localhost:5000`
### 3.3 前端操作
1. 页面顶栏点击 **「实机模式」**,地址栏自动填入 `ws://192.168.1.31:9090`
2. 点击 **「连接无人机」**,等待连接成功
3. 在地图上规划航点 → 上传任务 → 开始任务(操作流程与仿真相同)
> **注意**实机飞行时QGC 可同时连接MAVLink 协议支持多客户端),用于辅助监控无人机状态。
---
## 四、界面说明
### 4.1 顶部状态栏
| 区域 | 内容 |
|------|------|
| 左侧 | 系统名称、连接状态标签、飞行模式、电量、GPS 状态、速度、高度 |
| 右侧 | 仿真/实机模式切换、rosbridge 地址输入框、连接按钮 |
### 4.2 左侧地图区域
- **左键点击**:添加航点(蓝色编号标记)
- **蓝色虚线**:航点之间的飞行路径
- **蓝色三角图标**:无人机实时位置(随航向旋转)
- **红色实线**:无人机已飞过的航迹
- **拖拽标记**:移动航点位置
### 4.3 右侧控制面板
| 模块 | 功能 |
|------|------|
| 航点列表 | 显示所有航点的经纬度、高度;点击可定位;可单独删除 |
| 默认参数 | 设置新建航点的默认高度(米)和悬停时间(秒) |
| 任务操作 | 上传任务、开始任务、暂停/继续、返航、降落、清除航点 |
| 实时数据 | 纬度、经度、航向、当前航点进度 |
### 4.4 底部日志栏
- 实时显示操作记录和状态消息
- 颜色区分:绿色=成功,红色=错误,黄色=警告,灰色=信息
---
## 五、工作原理
### 5.1 通信流程
```
浏览器 (roslibjs) ──WebSocket──► rosbridge_server (端口 9090)
┌────────────────┼────────────────┐
▼ ▼ ▼
/prometheus/ /prometheus/ /mavros/
control_command drone_state cmd/arming
(发布控制指令) (订阅无人机状态) (服务调用)
```
### 5.2 航点导航机制
本系统采用**前端控制的逐航点导航**模式:
1. 用户在地图上添加航点(经纬度 + 高度)
2. 点击「上传任务」时,前端将经纬度转换为 ENU 坐标(以东经北天为坐标轴)
3. 点击「开始任务」后,系统按以下顺序执行:
- 先发布控制指令OFFBOARD 模式需要先有指令流)
- 切换 OFFBOARD 模式MAVROS 服务调用)
- 解锁电机MAVROS 服务调用)
- 发送起飞指令Prometheus TAKEOFF 命令)
- 逐个航点发送 MOVE 指令,以 10Hz 频率持续发布
- 检测到达航点(距离 < 2
4. 所有航点完成后自动悬停
### 5.3 坐标转换
- 地图使用 WGS84 经纬度坐标
- ROS/Prometheus 使用 ENU东-北-天)局部坐标系
- 系统在首次获取 GPS 定位时自动设置坐标原点
- 所有航点在「上传任务」时从经纬度转换为相对于原点的 ENU 坐标
---
## 六、按钮状态说明
| 状态 | 上传任务 | 开始任务 | 暂停 | 继续 | 返航 | 降落 |
|------|----------|----------|------|------|------|------|
| 未连接 | 禁用 | 禁用 | 禁用 | 禁用 | 禁用 | 禁用 |
| 已连接(有航点) | 启用 | 禁用 | 禁用 | 禁用 | 启用 | 启用 |
| 任务已上传 | 禁用 | 启用 | 禁用 | 禁用 | 启用 | 启用 |
| 任务执行中 | 禁用 | 禁用 | 启用 | 禁用 | 启用 | 启用 |
| 任务已暂停 | 禁用 | 禁用 | 禁用 | 启用 | 启用 | 启用 |
---
## 七、完整操作流程
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 启动 ROS │────►│ 连接前端 │────►│ 地图添加航点│────►│ 上传任务 │
│ SITL/实机 │ │ 选择模式 │ │ 点击地图 │ │ 转换坐标 │
└──────────┘ └──────────┘ └──────────┘ └────┬─────┘
┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ 任务完成 │◄────│ 飞行监控 │◄────│ 开始任务 │◄─────────┘
│ 或返航降落 │ │ 实时遥测 │ │ 解锁→起飞 │
└──────────┘ └──────────┘ └──────────┘
```
---
## 八、连接参数参考
| 参数 | 仿真模式 | 实机模式 |
|------|----------|----------|
| 协议 | WebSocket | WebSocket |
| rosbridge 地址 | `ws://localhost:9090` | `ws://192.168.1.31:9090` |
| 连接对象 | 本地/虚拟机 rosbridge | P600 机载电脑 rosbridge |
| 网络要求 | 本地或虚拟机网络 | P600 WiFi 数传 |
| ROS 话题(控制) | `/prometheus/control_command` | `/prometheus/control_command` |
| ROS 话题(状态) | `/prometheus/drone_state` | `/prometheus/drone_state` |
| ROS 话题GPS | `/mavros/global_position/raw/fix` | `/mavros/global_position/raw/fix` |
| ROS 服务(解锁) | `/mavros/cmd/arming` | `/mavros/cmd/arming` |
| ROS 服务(模式) | `/mavros/set_mode` | `/mavros/set_mode` |
---
## 九、常见问题
### 连接超时怎么办?
**仿真模式:**
- 确认 Prometheus SITL 已启动(`roslaunch prometheus_gazebo sitl.launch`
- 确认 rosbridge_server 已启动(`roslaunch rosbridge_server rosbridge_websocket.launch`
- 如果仿真在虚拟机中,确认 Windows 能 ping 通虚拟机 IP
- 确认虚拟机网络为桥接模式
**实机模式:**
- 确认电脑已连接 P600 的 WiFi 数传
- 在终端执行 `ping 192.168.1.31` 确认网络通畅
- 确认无人机已上电
- 确认机载电脑上 rosbridge_server 正在运行
- 关闭 VPN/防火墙等可能干扰网络的软件
### 航点任务执行失败?
- 确认无人机 GPS 已定位(状态栏显示 "3D Fix"
- 航点高度建议设置在 10 米以上
- 确认无人机已通过安全检查(传感器校准、解锁检查等)
- 检查日志中的 ROS 话题和服务调用是否成功
### Prometheus 话题收不到数据?
- 确认 Prometheus 控制节点正在运行
- 在 ROS 端检查话题是否存在:`rostopic list | grep prometheus`
- 检查话题数据:`rostopic echo /prometheus/drone_state`
- 确认 `prometheus_msgs` 包已正确编译和 source
### 如何同时使用 QGC 监控?
MAVLink 协议支持多客户端同时连接。QGC 通过 MAVLink 连接UDP 14550本系统通过 rosbridge 连接WebSocket 9090两者互不干扰监控的是同一架无人机。
### 默认航点参数可以改吗?
可以。右侧面板的「默认高度」和「默认悬停时间」会影响后续新建的航点。已创建的航点参数不受影响。

@ -0,0 +1,406 @@
/* ===== 全局 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f0f2f5;
height: 100vh;
display: flex;
overflow: hidden;
color: #333;
}
/* ===== 侧边栏 ===== */
.sidebar {
width: 200px;
flex-shrink: 0;
background: #1a1a2e;
display: flex;
flex-direction: column;
height: 100vh;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
border-bottom: 1px solid #2a2a3e;
}
.logo-icon {
font-size: 22px;
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 122, 255, 0.2);
border-radius: 8px;
}
.logo-text { font-size: 16px; font-weight: 700; color: #fff; letter-spacing: 1px; }
.nav-menu { list-style: none; padding: 12px 8px; flex: 1; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px; cursor: pointer;
font-size: 14px; color: #8888aa; margin-bottom: 4px; transition: all 0.2s;
}
.nav-item:hover { background: rgba(0, 122, 255, 0.1); color: #c0c0d0; }
.nav-item.active { background: rgba(0, 122, 255, 0.2); color: #58a6ff; }
.nav-icon { font-size: 16px; width: 20px; text-align: center; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #2a2a3e; }
.conn-indicator { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #8888aa; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #484f58; display: inline-block; }
.conn-indicator.connected .dot { background: #3fb950; box-shadow: 0 0 6px rgba(63, 185, 80, 0.5); }
.conn-indicator.connected { color: #3fb950; }
/* ===== 主区域 ===== */
.main-area { flex: 1; display: flex; flex-direction: column; min-width: 0; height: 100vh; }
/* ===== 顶栏 ===== */
.top-bar {
display: flex; justify-content: space-between; align-items: center;
height: 48px; padding: 0 20px; background: #1a1a2e; flex-shrink: 0; z-index: 100;
}
.top-bar-left { display: flex; align-items: center; gap: 16px; }
.top-bar-right { display: flex; align-items: center; gap: 8px; }
.page-title { font-size: 16px; font-weight: 600; color: #e6edf3; }
.status-item { font-size: 13px; color: #aaa; }
.status-item strong { color: #e0e0e0; }
.status-chip {
padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600;
background: #2a2a3e; color: #8888aa;
}
/* ===== 标签 ===== */
.tag { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; }
.tag-success { background: #f6ffed; color: #52c41a; }
.tag-danger { background: #fff1f0; color: #ff4d4f; }
.tag-warning { background: #fffbe6; color: #faad14; }
.tag-info { background: #e6f7ff; color: #007aff; }
/* ===== 页面容器 ===== */
.page-content { display: none; flex-direction: column; flex: 1; min-height: 0; }
.page-content.active { display: flex; }
.page-body { flex: 1; overflow-y: auto; padding: 20px; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer;
font-size: 13px; font-weight: 600; transition: opacity 0.2s;
}
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn:not(:disabled):hover { opacity: 0.85; }
.btn-primary { background: #007aff; color: #fff; }
.btn-success { background: #52c41a; color: #fff; }
.btn-warning { background: #faad14; color: #fff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.btn-secondary { background: #e0e0e0; color: #333; }
.btn.wide { width: 100%; }
.btn-link { background: none; border: none; color: #999; cursor: pointer; font-size: 12px; }
.btn-link:hover { color: #007aff; }
/* ===== 统计卡片 ===== */
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
.stat-card {
background: #fff; border-radius: 10px; padding: 16px 20px;
border-left: 3px solid transparent; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.stat-card.stat-pending { border-left-color: #faad14; }
.stat-card.stat-active { border-left-color: #007aff; }
.stat-card.stat-done { border-left-color: #52c41a; }
.stat-card.stat-drone { border-left-color: #58a6ff; }
.stat-value { font-size: 28px; font-weight: 700; margin-bottom: 4px; }
.stat-card.stat-pending .stat-value { color: #faad14; }
.stat-card.stat-active .stat-value { color: #007aff; }
.stat-card.stat-done .stat-value { color: #52c41a; }
.stat-label { font-size: 13px; color: #999; }
.dashboard-body { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* ===== 卡片 ===== */
.card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card-header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border-bottom: 1px solid #f0f0f0;
}
.card-title { font-size: 14px; font-weight: 600; color: #333; }
.card-badge { padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; background: rgba(250, 173, 20, 0.15); color: #faad14; }
.card-badge.active { background: rgba(0, 122, 255, 0.15); color: #007aff; }
/* ===== 任务列表 ===== */
.list-card { max-height: 400px; display: flex; flex-direction: column; }
.task-list { flex: 1; overflow-y: auto; padding: 8px; }
.task-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 8px; cursor: pointer;
margin-bottom: 4px; transition: background 0.15s;
}
.task-item:hover { background: #f0f7ff; }
.task-priority { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; flex-shrink: 0; }
.priority-urgent { background: rgba(255, 77, 79, 0.1); color: #ff4d4f; }
.priority-high { background: rgba(250, 173, 20, 0.1); color: #faad14; }
.priority-normal { background: rgba(0, 122, 255, 0.1); color: #007aff; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.task-desc { font-size: 11px; color: #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-arrow { color: #ccc; font-size: 14px; flex-shrink: 0; }
.empty-hint { text-align: center; color: #bbb; font-size: 13px; padding: 20px 0; }
/* ===== 连接设置 ===== */
.conn-card { flex-shrink: 0; }
.conn-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.conn-url-row {
display: flex;
gap: 6px;
}
.conn-url-row input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 12px;
outline: none;
font-family: 'Consolas', 'Monaco', monospace;
}
.conn-url-row input:focus { border-color: #007aff; }
/* ===== 士兵标记 (地图) ===== */
.soldier-marker {
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
}
.soldier-marker-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #52c41a;
border: 2px solid #fff;
box-shadow: 0 0 6px rgba(82, 196, 26, 0.5);
}
.soldier-marker-name {
font-size: 11px;
font-weight: 600;
color: #fff;
background: rgba(82, 196, 26, 0.85);
padding: 1px 6px;
border-radius: 3px;
margin-top: 2px;
white-space: nowrap;
}
/* ===== 危险区域标记 (地图) ===== */
.danger-zone-marker { pointer-events: auto; }
.danger-zone-icon {
font-size: 20px;
color: #ff4d4f;
text-shadow: 0 0 4px rgba(255, 77, 79, 0.6);
text-align: center;
line-height: 24px;
}
/* ===== 士兵列表 ===== */
.soldier-card { flex-shrink: 0; }
.soldier-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
transition: background 0.15s;
}
.soldier-item:hover { background: #f0fff0; }
.soldier-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #52c41a;
flex-shrink: 0;
}
.soldier-info { flex: 1; min-width: 0; }
.soldier-name { font-size: 13px; font-weight: 600; color: #333; }
.soldier-coord { font-size: 11px; color: #999; }
/* ===== 危险区域列表 ===== */
.danger-card { flex-shrink: 0; }
.card-badge.danger { background: rgba(255, 77, 79, 0.15); color: #ff4d4f; }
.danger-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
margin-bottom: 2px;
background: #fff1f0;
}
.danger-icon {
font-size: 16px;
color: #ff4d4f;
flex-shrink: 0;
}
.danger-info { flex: 1; min-width: 0; }
.danger-desc { font-size: 13px; font-weight: 600; color: #ff4d4f; }
.danger-coord { font-size: 11px; color: #999; }
/* ===== 滚动列表容器 ===== */
.list-scroll-content {
max-height: 150px;
overflow-y: auto;
padding: 8px;
}
/* ===== 监控页 ===== */
.monitor-body { flex: 1; display: flex; min-height: 0; }
.map-container { flex: 1; min-width: 0; z-index: 1; }
.monitor-panel {
width: 320px; flex-shrink: 0; background: #fff; border-left: 1px solid #e8e8e8;
display: flex; flex-direction: column; overflow-y: auto;
}
.drone-info-card { flex-shrink: 0; }
.drone-card-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px; border-bottom: 1px solid #f0f0f0;
}
.drone-name { font-size: 16px; font-weight: 700; color: #333; }
.drone-meta {
padding: 10px 16px; font-size: 12px; color: #999;
display: flex; gap: 20px; border-bottom: 1px solid #f0f0f0;
}
.drone-meta strong { color: #333; }
.monitor-data { padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.data-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.data-item { background: #f8f9fa; border-radius: 6px; padding: 8px; text-align: center; }
.data-label { display: block; font-size: 11px; color: #999; margin-bottom: 2px; }
.data-value { font-size: 13px; font-weight: 600; color: #333; }
.control-card { flex-shrink: 0; }
.control-btns { padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.log-card { flex: 1; display: flex; flex-direction: column; min-height: 150px; }
.log-content {
flex: 1; overflow-y: auto; padding: 8px 12px;
font-family: 'Consolas', 'Monaco', monospace; font-size: 11px; line-height: 1.6;
}
.log-entry { white-space: nowrap; }
.log-entry .time { color: #bbb; margin-right: 8px; }
.log-entry.success { color: #52c41a; }
.log-entry.error { color: #ff4d4f; }
.log-entry.warning { color: #faad14; }
.log-entry.info { color: #999; }
/* ===== 弹窗 ===== */
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); display: flex; align-items: center;
justify-content: center; z-index: 9999;
}
.modal {
background: #fff; border-radius: 12px; width: 480px; max-height: 85vh;
display: flex; flex-direction: column; box-shadow: 0 8px 30px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 20px; border-bottom: 1px solid #f0f0f0;
}
.modal-title { font-size: 15px; font-weight: 700; color: #333; }
.modal-close { background: none; border: none; color: #999; font-size: 20px; cursor: pointer; }
.modal-close:hover { color: #ff4d4f; }
.modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 10px;
padding: 12px 20px; border-top: 1px solid #f0f0f0;
}
.detail-row { display: flex; margin-bottom: 10px; font-size: 13px; }
.detail-label { width: 80px; color: #999; flex-shrink: 0; }
.detail-value { color: #333; flex: 1; }
.detail-section { margin-top: 14px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
.detail-section-title { font-size: 13px; font-weight: 600; color: #007aff; margin-bottom: 10px; }
/* 坐标输入 */
.coord-group { margin-bottom: 12px; }
.coord-label { font-size: 12px; font-weight: 600; color: #333; margin-bottom: 6px; }
.coord-inputs { display: flex; gap: 10px; }
.coord-inputs label { font-size: 12px; color: #666; display: flex; align-items: center; gap: 4px; }
.coord-inputs input {
width: 110px; padding: 5px 8px; border: 1px solid #ddd; border-radius: 4px;
font-size: 12px; text-align: center; outline: none;
}
.coord-inputs input:focus { border-color: #007aff; }
.coord-hint { font-size: 11px; color: #bbb; margin-top: 4px; }
.dispatch-option {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: 8px; margin-bottom: 6px;
cursor: pointer; border: 1px solid #e8e8e8; transition: all 0.2s;
}
.dispatch-option:hover { border-color: #007aff; }
.dispatch-option.selected { border-color: #007aff; background: #f0f7ff; }
.dispatch-option input[type="radio"] { accent-color: #007aff; }
.dispatch-option label { font-size: 13px; color: #333; cursor: pointer; }
/* ===== Leaflet ===== */
.drone-icon { width: 24px; height: 24px; position: relative; }
.drone-icon-inner {
width: 0; height: 0;
border-left: 10px solid transparent; border-right: 10px solid transparent;
border-bottom: 20px solid #007aff;
position: absolute; top: 2px; left: 2px; transform-origin: center 60%;
}
/* ===== 滚动条 ===== */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
/* ===== 适配 ===== */
@media (max-width: 1100px) {
.stat-cards { grid-template-columns: repeat(2, 1fr); }
.dashboard-body { grid-template-columns: 1fr; }
.monitor-panel { width: 280px; }
}

@ -0,0 +1,188 @@
<!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="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<!-- ===== 左侧导航栏 ===== -->
<nav class="sidebar">
<div class="sidebar-logo">
<div class="logo-icon">&#9992;</div>
<div class="logo-text">智途投送</div>
</div>
<ul class="nav-menu">
<li class="nav-item active" data-page="dashboard">
<span class="nav-icon">&#9776;</span>
<span class="nav-label">任务调度</span>
</li>
<li class="nav-item" data-page="monitor">
<span class="nav-icon">&#128225;</span>
<span class="nav-label">无人机监控</span>
</li>
</ul>
<div class="sidebar-footer">
<div class="conn-indicator" id="conn-dot">
<span class="dot"></span>
<span id="conn-text">未连接</span>
</div>
</div>
</nav>
<!-- ===== 主内容区 ===== -->
<div class="main-area">
<!-- ===== 页面A: 任务调度 ===== -->
<div class="page-content active" id="page-dashboard">
<header class="top-bar">
<div class="top-bar-left">
<h1 class="page-title">任务调度</h1>
</div>
<div class="top-bar-right">
<span class="status-chip" id="chip-time"></span>
</div>
</header>
<div class="page-body">
<div class="stat-cards">
<div class="stat-card stat-pending">
<div class="stat-value" id="stat-pending">3</div>
<div class="stat-label">待处理需求</div>
</div>
<div class="stat-card stat-active">
<div class="stat-value" id="stat-active">2</div>
<div class="stat-label">飞行中</div>
</div>
<div class="stat-card stat-done">
<div class="stat-value" id="stat-done">128</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-card stat-drone">
<div class="stat-value" id="stat-drone">3</div>
<div class="stat-label">无人机可用</div>
</div>
</div>
<div class="dashboard-body">
<div class="card list-card">
<div class="card-header">
<span class="card-title">待处理需求</span>
<span class="card-badge" id="pending-count">3</span>
</div>
<div class="task-list" id="pending-list"></div>
</div>
<div class="card list-card">
<div class="card-header">
<span class="card-title">飞行中任务</span>
<span class="card-badge active" id="active-count">2</span>
</div>
<div class="task-list" id="active-list"></div>
</div>
</div>
</div>
</div>
<!-- ===== 页面B: 无人机监控 ===== -->
<div class="page-content" id="page-monitor">
<header class="top-bar">
<div class="top-bar-left">
<h1 class="page-title">无人机监控</h1>
<span id="monitor-flight-tag" class="tag tag-info">空闲</span>
</div>
<div class="top-bar-right">
<span class="status-item">电量: <strong id="mon-battery">--%</strong></span>
<span class="status-item">速度: <strong id="mon-speed">-- m/s</strong></span>
<span class="status-item">高度: <strong id="mon-alt">-- m</strong></span>
<span class="status-item">GPS: <strong id="mon-gps">--</strong></span>
<span class="status-chip" id="chip-time-mon"></span>
</div>
</header>
<div class="monitor-body">
<div id="map" class="map-container"></div>
<div class="monitor-panel">
<div class="card conn-card">
<div class="card-header"><span class="card-title">连接设置</span></div>
<div class="conn-body">
<div class="conn-url-row">
<input type="text" id="drone-url" value="ws://192.168.1.14:9090" placeholder="rosbridge WebSocket 地址" />
<button id="btn-connect" class="btn btn-primary">连接</button>
</div>
</div>
</div>
<div class="card drone-info-card">
<div class="drone-card-header">
<div class="drone-name" id="mon-drone-name">无人机-01</div>
<span class="tag tag-success" id="monitor-status">空闲</span>
</div>
<div class="drone-meta">
<span>任务: <strong id="monitor-task">--</strong></span>
<span>目标士兵: <strong id="monitor-soldier">--</strong></span>
</div>
<div class="data-grid monitor-data">
<div class="data-item"><span class="data-label">纬度</span><span class="data-value" id="mon-lat">--</span></div>
<div class="data-item"><span class="data-label">经度</span><span class="data-value" id="mon-lng">--</span></div>
<div class="data-item"><span class="data-label">航向</span><span class="data-value" id="mon-heading">--</span></div>
<div class="data-item"><span class="data-label">距目标</span><span class="data-value" id="mon-dist">--</span></div>
<div class="data-item"><span class="data-label">信号</span><span class="data-value" id="mon-signal">--%</span></div>
<div class="data-item"><span class="data-label">预计到达</span><span class="data-value" id="mon-eta">--</span></div>
</div>
</div>
<div class="card control-card">
<div class="card-header"><span class="card-title">控制面板</span></div>
<div class="control-btns">
<button class="btn btn-warning wide" id="btn-pause" disabled>暂停任务</button>
<button class="btn btn-success wide" id="btn-resume" disabled>继续任务</button>
<button class="btn btn-danger wide" id="btn-rth" disabled>紧急返航</button>
<button class="btn btn-danger wide" id="btn-mark-destroyed" disabled>标记无人机被摧毁</button>
</div>
</div>
<div class="card soldier-card">
<div class="card-header">
<span class="card-title">在线士兵</span>
<span class="card-badge" id="soldier-count">0</span>
</div>
<div id="soldier-list" class="list-scroll-content"></div>
</div>
<div class="card danger-card">
<div class="card-header">
<span class="card-title">危险区域</span>
<span class="card-badge danger" id="danger-count">0</span>
</div>
<div id="danger-zone-list" class="list-scroll-content"></div>
</div>
<div class="card log-card">
<div class="card-header">
<span class="card-title">操作日志</span>
<button class="btn-link" id="btn-clear-log">清除</button>
</div>
<div id="log-content" class="log-content"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ===== 调度弹窗 ===== -->
<div class="modal-overlay" id="modal-task" style="display:none">
<div class="modal">
<div class="modal-header">
<span class="modal-title">调度任务</span>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modal-reject">拒绝</button>
<button class="btn btn-primary" id="modal-dispatch">确认调度</button>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/js/roslib.min.js"></script>
<script src="/js/map.js"></script>
<script src="/js/websocket.js"></script>
<script src="/js/ui.js"></script>
</body>
</html>

@ -11,6 +11,10 @@ const MapModule = (() => {
let droneMarker = null; // 无人机位置标记
let trailLine = null; // 飞行轨迹
let trailCoords = []; // 轨迹坐标历史
let soldierMarkers = {}; // { soldierId: L.marker }
let soldierLayerGroup = null;
let dangerZoneItems = []; // [ { circle, marker } ]
let dangerLayerGroup = null;
const DEFAULT_LAT = 30.0;
const DEFAULT_LNG = 120.0;
@ -59,6 +63,17 @@ const MapModule = (() => {
icon: droneIcon,
visible: false
}).addTo(map);
// 士兵位置图层
soldierLayerGroup = L.layerGroup().addTo(map);
// 危险区域图层
dangerLayerGroup = L.layerGroup().addTo(map);
// 图层切换控件
L.control.layers(null, {
"士兵位置": soldierLayerGroup,
"危险区域": dangerLayerGroup
}, { collapsed: false }).addTo(map);
}
function onMapClick(e) {
@ -185,6 +200,67 @@ const MapModule = (() => {
}
}
// ---- 士兵标记 ----
function updateSoldierMarker(id, name, lat, lng) {
if (soldierMarkers[id]) {
soldierMarkers[id].setLatLng([lat, lng]);
return;
}
const icon = L.divIcon({
className: 'soldier-marker',
html: '<div class="soldier-marker-dot"></div><div class="soldier-marker-name">' + name + '</div>',
iconSize: [40, 40],
iconAnchor: [20, 20]
});
const marker = L.marker([lat, lng], { icon: icon })
.bindPopup('<b>' + name + '</b><br>(' + lat.toFixed(4) + ', ' + lng.toFixed(4) + ')')
.addTo(soldierLayerGroup);
soldierMarkers[id] = marker;
}
function removeSoldierMarker(id) {
if (soldierMarkers[id]) {
soldierLayerGroup.removeLayer(soldierMarkers[id]);
delete soldierMarkers[id];
}
}
function clearAllSoldiers() {
soldierLayerGroup.clearLayers();
soldierMarkers = {};
}
// ---- 危险区域 ----
function addDangerZone(lat, lng, radius, description) {
const circle = L.circle([lat, lng], {
radius: radius,
color: '#ff4d4f',
fillColor: '#ff4d4f',
fillOpacity: 0.15,
weight: 2,
dashArray: '5, 5'
}).addTo(dangerLayerGroup);
const icon = L.divIcon({
className: 'danger-zone-marker',
html: '<div class="danger-zone-icon">&#9888;</div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const marker = L.marker([lat, lng], { icon: icon })
.bindPopup('<b style="color:#ff4d4f">' + (description || '危险区域') + '</b><br>(' + lat.toFixed(4) + ', ' + lng.toFixed(4) + ')<br>半径: ' + radius + 'm')
.addTo(dangerLayerGroup);
dangerZoneItems.push({ circle, marker });
}
function clearAllDangerZones() {
dangerLayerGroup.clearLayers();
dangerZoneItems = [];
}
return {
init,
addWaypoint,
@ -196,6 +272,11 @@ const MapModule = (() => {
getWaypoints,
getWaypointCount,
updateWaypointAlt,
updateSoldierMarker,
removeSoldierMarker,
clearAllSoldiers,
addDangerZone,
clearAllDangerZones,
DEFAULT_LAT,
DEFAULT_LNG
};

@ -0,0 +1,134 @@
/**
* 热成像威胁检测模块
* 负责: 接收热成像检测结果在地图上标注威胁更新威胁列表UI
*/
const ThermalModule = (() => {
let threats = [];
let pollTimer = null;
const POLL_INTERVAL = 3000; // ms
function init() {
// 自动轮询模式 (如果有后端API)
// startPolling('http://localhost:5000/api/threats');
}
/**
* 开始轮询后端威胁数据
* @param {string} apiUrl - 后端热成像API地址
*/
function startPolling(apiUrl) {
stopPolling();
pollTimer = setInterval(() => fetchThreats(apiUrl), POLL_INTERVAL);
fetchThreats(apiUrl);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
/**
* 从后端获取最新威胁数据
*/
async function fetchThreats(apiUrl) {
try {
const resp = await fetch(apiUrl);
if (!resp.ok) return;
const data = await resp.json();
if (data.threats && Array.isArray(data.threats)) {
processThreats(data.threats);
}
} catch (e) {
// 静默忽略网络错误
}
}
/**
* 直接传入威胁数据 (用于手动推送或模拟)
* @param {Array} newThreats - 威胁数组 [{lat, lng, level, description, time}]
*/
function processThreats(newThreats) {
// 清除旧威胁标记
MapModule.clearThreats();
threats = newThreats;
// 在地图上添加标记
newThreats.forEach(t => {
MapModule.addThreatMarker(t.lat, t.lng, t.level, t.description);
});
// 更新威胁列表UI
renderThreatList();
}
/**
* 添加单个威胁
*/
function addThreat(threat) {
threats.push(threat);
MapModule.addThreatMarker(threat.lat, threat.lng, threat.level, threat.description);
renderThreatList();
if (threat.level === 'high') {
UIModule.addLog('error', `热成像告警: ${threat.description} (${threat.lat.toFixed(4)}, ${threat.lng.toFixed(4)})`);
} else {
UIModule.addLog('warning', `威胁检测: ${threat.description}`);
}
}
function renderThreatList() {
const container = document.getElementById('threat-list');
if (!container) return;
if (threats.length === 0) {
container.innerHTML = '<div class="empty-hint">暂无威胁检测</div>';
return;
}
container.innerHTML = threats.map(t => `
<div class="threat-item level-${t.level}">
<span class="threat-dot ${t.level}"></span>
<span class="threat-text">${t.description}</span>
<span class="threat-time">${t.time || '--'}</span>
</div>
`).join('');
}
function getThreats() {
return threats;
}
function clearThreats() {
threats = [];
MapModule.clearThreats();
renderThreatList();
}
/**
* 模拟威胁数据 (用于演示)
*/
function simulateThreats() {
const baseLat = 30.0;
const baseLng = 120.0;
const demo = [
{ lat: baseLat + 0.0008, lng: baseLng - 0.0005, level: 'high', description: '热源: 人员活动', time: '14:32' },
{ lat: baseLat - 0.0012, lng: baseLng + 0.001, level: 'medium', description: '热源: 车辆引擎', time: '14:28' },
{ lat: baseLat + 0.0003, lng: baseLng + 0.0018, level: 'low', description: '热源: 动物', time: '14:25' }
];
processThreats(demo);
UIModule.addLog('info', '已加载模拟威胁数据 (3个热源)');
}
return {
init,
startPolling,
stopPolling,
processThreats,
addThreat,
getThreats,
clearThreats,
simulateThreats
};
})();

@ -0,0 +1,567 @@
/**
* UI 交互模块 智途投送电脑端
* 实机直连模式: 浏览器 roslibjs rosbridge MAVROS MAVLink PX4
* 功能: 任务调度无人机监控士兵位置追踪危险区域标记
*/
const UIModule = (() => {
// ---- 状态 ----
let droneConnected = false;
let missionRunning = false;
let missionPaused = false;
let mapInited = false;
let currentPage = 'dashboard';
// ---- 调度数据 ----
const BASE_LOCATION = { lat: 30.0000, lng: 120.0000, name: '补给基地' };
let pendingTasks = [
{ id: 'REQ-001', soldier: '张三', unit: '1排2班', type: '弹药补给', items: '5.56mm弹匣 × 20, 手雷 × 4', urgency: '紧急', lat: 30.0050, lng: 120.0030 },
{ id: 'REQ-002', soldier: '李四', unit: '2排1班', type: '医疗物资', items: '急救包 × 3, 止血带 × 5', urgency: '高', lat: 30.0080, lng: 119.9970 },
{ id: 'REQ-003', soldier: '王五', unit: '3排3班', type: '食品饮水', items: '单兵口粮 × 10, 饮用水 × 20L', urgency: '一般', lat: 29.9970, lng: 120.0050 }
];
let activeTasks = [
{ id: 'TSK-001', soldier: '赵六', type: '弹药补给', drone: '无人机-02', endLat: 30.0030, endLng: 120.0060 }
];
let completedCount = 128;
let selectedTask = null;
// ---- 士兵/危险区域 ----
let knownSoldiers = {};
let dangerZones = [];
let droneLastPos = { lat: 0, lng: 0 };
// =============== 初始化 ===============
function init() {
// 导航
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => switchPage(item.dataset.page));
});
// 弹窗
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-reject').addEventListener('click', onReject);
document.getElementById('modal-dispatch').addEventListener('click', onDispatch);
document.getElementById('modal-task').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
// 监控页控制
document.getElementById('btn-pause').addEventListener('click', () => {
if (WsModule.isConnected()) WsModule.pauseMission();
});
document.getElementById('btn-resume').addEventListener('click', () => {
if (WsModule.isConnected()) WsModule.resumeMission();
});
document.getElementById('btn-rth').addEventListener('click', () => {
if (WsModule.isConnected()) {
WsModule.returnHome();
addLog('warning', '返航指令已发送...');
}
updateMonitorControls();
updateDashboardStats();
});
document.getElementById('btn-mark-destroyed').addEventListener('click', markDroneDestroyed);
document.getElementById('btn-clear-log').addEventListener('click', () => {
document.getElementById('log-content').innerHTML = '';
});
// 连接按钮
document.getElementById('btn-connect').addEventListener('click', onConnect);
// 初始化 WsModule
WsModule.init();
// 时钟
setInterval(updateClock, 1000);
updateClock();
// 渲染
renderPendingList();
renderActiveList();
updateDashboardStats();
// 士兵位置轮询 (5秒)
setInterval(fetchSoldiers, 5000);
fetchSoldiers();
// 加载已有危险区域
fetchDangerZones();
addLog('info', '系统就绪 — 实机连接模式');
addLog('info', '请确保电脑已连接 P600 的 WiFi 数传rosbridge 需在机载电脑上运行');
}
// =============== 连接管理 ===============
function onConnect() {
const url = document.getElementById('drone-url').value.trim();
if (!url) {
addLog('error', '请输入 rosbridge 连接地址');
return;
}
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
addLog('error', '地址格式错误,应以 ws:// 开头 (例如 ws://192.168.1.14:9090)');
return;
}
addLog('info', '正在连接 ' + url + ' ...');
WsModule.connectDrone(url);
}
// =============== 页面路由 ===============
function switchPage(page) {
currentPage = page;
document.querySelectorAll('.nav-item').forEach(i => i.classList.toggle('active', i.dataset.page === page));
document.querySelectorAll('.page-content').forEach(s => s.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
if (page === 'monitor' && !mapInited) {
setTimeout(() => {
MapModule.init();
mapInited = true;
addLog('info', '地图已加载');
}, 50);
}
}
// =============== 调度台渲染 ===============
function renderPendingList() {
const c = document.getElementById('pending-list');
document.getElementById('pending-count').textContent = pendingTasks.length;
if (pendingTasks.length === 0) { c.innerHTML = '<div class="empty-hint">暂无待处理需求</div>'; return; }
c.innerHTML = pendingTasks.map(t => {
const uc = t.urgency === '紧急' ? 'priority-urgent' : t.urgency === '高' ? 'priority-high' : 'priority-normal';
return '<div class="task-item" data-id="' + t.id + '">' +
'<span class="task-priority ' + uc + '">' + t.urgency + '</span>' +
'<div class="task-info">' +
'<div class="task-name">' + t.type + ' — ' + t.soldier + ' (' + t.unit + ')</div>' +
'<div class="task-desc">' + t.items + '</div>' +
'</div>' +
'<span class="task-arrow">></span>' +
'</div>';
}).join('');
c.querySelectorAll('.task-item').forEach(item => {
item.addEventListener('click', () => {
const t = pendingTasks.find(x => x.id === item.dataset.id);
if (t) openModal(t);
});
});
}
function renderActiveList() {
const c = document.getElementById('active-list');
document.getElementById('active-count').textContent = activeTasks.length;
if (activeTasks.length === 0) { c.innerHTML = '<div class="empty-hint">暂无飞行中任务</div>'; return; }
c.innerHTML = activeTasks.map(t =>
'<div class="task-item">' +
'<span class="task-priority priority-high">飞行中</span>' +
'<div class="task-info">' +
'<div class="task-name">' + t.type + ' → ' + t.soldier + ' | ' + t.drone + '</div>' +
'<div class="task-desc">目标: (' + t.endLat.toFixed(4) + ', ' + t.endLng.toFixed(4) + ')</div>' +
'</div>' +
'<span class="task-arrow">></span>' +
'</div>'
).join('');
c.querySelectorAll('.task-item').forEach(item => {
item.addEventListener('click', () => switchPage('monitor'));
});
}
function updateDashboardStats() {
document.getElementById('stat-pending').textContent = pendingTasks.length;
document.getElementById('stat-active').textContent = activeTasks.length;
document.getElementById('stat-done').textContent = completedCount;
}
// =============== 弹窗 ===============
function openModal(task) {
selectedTask = task;
const uc = task.urgency === '紧急' ? 'priority-urgent' : task.urgency === '高' ? 'priority-high' : 'priority-normal';
document.getElementById('modal-body').innerHTML =
'<div class="detail-row"><span class="detail-label">任务编号</span><span class="detail-value">' + task.id + '</span></div>' +
'<div class="detail-row"><span class="detail-label">请求士兵</span><span class="detail-value">' + task.soldier + ' (' + task.unit + ')</span></div>' +
'<div class="detail-row"><span class="detail-label">物资类型</span><span class="detail-value">' + task.type + '</span></div>' +
'<div class="detail-row"><span class="detail-label">物资清单</span><span class="detail-value">' + task.items + '</span></div>' +
'<div class="detail-row"><span class="detail-label">紧急程度</span><span class="detail-value"><span class="task-priority ' + uc + '">' + task.urgency + '</span></span></div>' +
'<div class="detail-section">' +
'<div class="detail-section-title">航线设置</div>' +
'<div class="coord-group">' +
'<div class="coord-label">起点(起飞位置)</div>' +
'<div class="coord-inputs">' +
'<label>纬度 <input type="number" step="0.0001" id="start-lat" value="' + BASE_LOCATION.lat.toFixed(4) + '" /></label>' +
'<label>经度 <input type="number" step="0.0001" id="start-lng" value="' + BASE_LOCATION.lng.toFixed(4) + '" /></label>' +
'</div>' +
'<div class="coord-hint">' + BASE_LOCATION.name + '</div>' +
'</div>' +
'<div class="coord-group">' +
'<div class="coord-label">终点(投送位置)</div>' +
'<div class="coord-inputs">' +
'<label>纬度 <input type="number" step="0.0001" id="end-lat" value="' + task.lat.toFixed(4) + '" /></label>' +
'<label>经度 <input type="number" step="0.0001" id="end-lng" value="' + task.lng.toFixed(4) + '" /></label>' +
'</div>' +
'<div class="coord-hint">' + task.soldier + ' 的位置</div>' +
'</div>' +
'</div>' +
'<div class="detail-section">' +
'<div class="detail-section-title">分配无人机</div>' +
'<div class="dispatch-option selected" onclick="this.parentElement.querySelectorAll(\'.dispatch-option\').forEach(o=>o.classList.remove(\'selected\'));this.classList.add(\'selected\')">' +
'<input type="radio" name="drone" value="01" checked /><label>无人机-01 (可用)</label>' +
'</div>' +
'</div>';
document.getElementById('modal-task').style.display = 'flex';
}
function closeModal() {
document.getElementById('modal-task').style.display = 'none';
selectedTask = null;
}
function onReject() {
if (!selectedTask) return;
addLog('info', '已拒绝需求: ' + selectedTask.id);
pendingTasks = pendingTasks.filter(t => t.id !== selectedTask.id);
renderPendingList();
updateDashboardStats();
closeModal();
}
function onDispatch() {
if (!selectedTask) return;
const startLat = parseFloat(document.getElementById('start-lat').value) || BASE_LOCATION.lat;
const startLng = parseFloat(document.getElementById('start-lng').value) || BASE_LOCATION.lng;
const endLat = parseFloat(document.getElementById('end-lat').value) || selectedTask.lat;
const endLng = parseFloat(document.getElementById('end-lng').value) || selectedTask.lng;
if (!mapInited) {
switchPage('monitor');
setTimeout(() => startTask(selectedTask, startLat, startLng, endLat, endLng), 200);
} else {
startTask(selectedTask, startLat, startLng, endLat, endLng);
}
activeTasks.push({
id: 'TSK-' + String(activeTasks.length + 1).padStart(3, '0'),
soldier: selectedTask.soldier,
type: selectedTask.type,
drone: '无人机-01',
endLat, endLng
});
pendingTasks = pendingTasks.filter(t => t.id !== selectedTask.id);
addLog('success', '已调度: ' + selectedTask.type + ' → ' + selectedTask.soldier);
addLog('info', '航线: (' + startLat.toFixed(4) + ',' + startLng.toFixed(4) + ') → (' + endLat.toFixed(4) + ',' + endLng.toFixed(4) + ')');
renderPendingList();
renderActiveList();
updateDashboardStats();
closeModal();
}
// =============== 任务启动 ===============
function startTask(task, startLat, startLng, endLat, endLng) {
MapModule.clearWaypoints();
MapModule.addWaypoint(startLat, startLng);
MapModule.addWaypoint(endLat, endLng);
MapModule.updateDronePosition(startLat, startLng, 0);
MapModule.centerOnDrone(startLat, startLng);
document.getElementById('monitor-task').textContent = task.type;
document.getElementById('monitor-soldier').textContent = task.soldier;
document.getElementById('monitor-status').textContent = '飞行中';
document.getElementById('monitor-status').className = 'tag tag-success';
document.getElementById('monitor-flight-tag').textContent = '飞行中';
document.getElementById('monitor-flight-tag').className = 'tag tag-success';
missionRunning = true;
missionPaused = false;
if (WsModule.isConnected()) {
WsModule.uploadMission([
{ lat: startLat, lng: startLng, alt: 15, hold_time: 0 },
{ lat: endLat, lng: endLng, alt: 15, hold_time: 0 }
]);
addLog('success', '航线上传至飞控系统,准备起飞...');
setTimeout(() => {
WsModule.startMission();
addLog('success', '任务已启动');
}, 1000);
document.getElementById('conn-dot').classList.add('connected');
document.getElementById('conn-text').textContent = '飞行中';
} else {
addLog('warning', '无人机未连接,请先连接后再调度');
document.getElementById('monitor-status').textContent = '等待连接';
document.getElementById('monitor-status').className = 'tag tag-warning';
}
updateMonitorControls();
}
function updateMonitorControls() {
const connected = WsModule.isConnected();
document.getElementById('btn-pause').disabled = !connected || !missionRunning;
document.getElementById('btn-resume').disabled = !connected || !missionPaused;
document.getElementById('btn-rth').disabled = !connected;
document.getElementById('btn-mark-destroyed').disabled = !droneConnected;
}
// =============== 士兵位置追踪 ===============
async function fetchSoldiers() {
try {
const resp = await fetch('/api/soldiers');
if (!resp.ok) return;
const data = await resp.json();
if (data.soldiers && Array.isArray(data.soldiers)) {
const currentIds = new Set(data.soldiers.map(s => s.id));
for (const id of Object.keys(knownSoldiers)) {
if (!currentIds.has(id)) {
if (mapInited) MapModule.removeSoldierMarker(id);
delete knownSoldiers[id];
}
}
data.soldiers.forEach(s => {
if (mapInited) MapModule.updateSoldierMarker(s.id, s.name, s.lat, s.lng);
knownSoldiers[s.id] = s;
});
renderSoldierList(data.soldiers);
}
} catch (e) { /* server not running */ }
}
function renderSoldierList(soldiers) {
const container = document.getElementById('soldier-list');
if (!container) return;
const countEl = document.getElementById('soldier-count');
if (countEl) countEl.textContent = soldiers.length;
if (soldiers.length === 0) {
container.innerHTML = '<div class="empty-hint">暂无在线士兵</div>';
return;
}
container.innerHTML = soldiers.map(s =>
'<div class="soldier-item" data-id="' + s.id + '">' +
'<span class="soldier-dot"></span>' +
'<div class="soldier-info">' +
'<div class="soldier-name">' + s.name + '</div>' +
'<div class="soldier-coord">(' + s.lat.toFixed(4) + ', ' + s.lng.toFixed(4) + ')</div>' +
'</div>' +
'</div>'
).join('');
container.querySelectorAll('.soldier-item').forEach(el => {
el.addEventListener('click', () => {
const s = soldiers.find(x => x.id === el.dataset.id);
if (s && mapInited) MapModule.panTo(s.lat, s.lng);
});
});
}
// =============== 危险区域管理 ===============
async function fetchDangerZones() {
try {
const resp = await fetch('/api/danger-zones');
if (!resp.ok) return;
const data = await resp.json();
if (data.danger_zones && Array.isArray(data.danger_zones)) {
dangerZones = data.danger_zones;
if (mapInited) {
dangerZones.forEach(dz => {
MapModule.addDangerZone(dz.lat, dz.lng, dz.radius || 50, dz.description);
});
}
renderDangerZoneList();
}
} catch (e) { /* server not running */ }
}
async function markDroneDestroyed() {
if (droneLastPos.lat === 0 && droneLastPos.lng === 0) {
addLog('error', '无法标记: 无人机位置未知');
return;
}
const lat = droneLastPos.lat;
const lng = droneLastPos.lng;
const radius = 50;
const description = '无人机被摧毁';
try {
const resp = await fetch('/api/danger-zones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lng, radius, description })
});
if (resp.ok) {
const data = await resp.json();
addLog('error', '已标记危险区域: (' + lat.toFixed(4) + ', ' + lng.toFixed(4) + ') 半径' + radius + 'm');
if (mapInited) MapModule.addDangerZone(lat, lng, radius, description);
dangerZones.push({ lat, lng, radius, description, id: data.id });
renderDangerZoneList();
missionRunning = false;
missionPaused = false;
document.getElementById('monitor-status').textContent = '已摧毁';
document.getElementById('monitor-status').className = 'tag tag-danger';
document.getElementById('monitor-flight-tag').textContent = '已摧毁';
document.getElementById('monitor-flight-tag').className = 'tag tag-danger';
updateMonitorControls();
}
} catch (e) {
addLog('error', '标记危险区域失败: 网络错误');
}
}
function renderDangerZoneList() {
const container = document.getElementById('danger-zone-list');
if (!container) return;
const countEl = document.getElementById('danger-count');
if (countEl) countEl.textContent = dangerZones.length;
if (dangerZones.length === 0) {
container.innerHTML = '<div class="empty-hint">暂无危险区域</div>';
return;
}
container.innerHTML = dangerZones.map(dz =>
'<div class="danger-item">' +
'<span class="danger-icon">&#9888;</span>' +
'<div class="danger-info">' +
'<div class="danger-desc">' + (dz.description || '危险区域') + '</div>' +
'<div class="danger-coord">(' + dz.lat.toFixed(4) + ', ' + dz.lng.toFixed(4) + ') R' + (dz.radius || 50) + 'm</div>' +
'</div>' +
'</div>'
).join('');
}
// =============== WsModule 回调 ===============
function onTelemetry(data) {
if (data.type === 'telemetry') {
document.getElementById('mon-lat').textContent = data.lat.toFixed(6);
document.getElementById('mon-lng').textContent = data.lng.toFixed(6);
document.getElementById('mon-heading').textContent = data.heading.toFixed(0) + '°';
document.getElementById('mon-battery').textContent = data.battery.toFixed(0) + '%';
document.getElementById('mon-speed').textContent = data.speed.toFixed(1) + ' m/s';
document.getElementById('mon-alt').textContent = data.alt.toFixed(1) + ' m';
document.getElementById('mon-gps').textContent = data.gps_status;
if (mapInited && (data.lat !== 0 || data.alt > 0.1)) {
MapModule.updateDronePosition(data.lat, data.lng, data.heading);
}
// 追踪无人机最后位置
if (data.lat && data.lat !== 0) {
droneLastPos.lat = data.lat;
droneLastPos.lng = data.lng;
}
}
if (data.type === 'mission_progress') {
document.getElementById('mon-dist').textContent =
'航点 ' + data.current + '/' + data.total;
}
}
function onStatus(data) {
addLog(data.type, data.message);
const msg = data.message;
if (msg.includes('无人机已连接') || msg.includes('飞控已就绪')) {
droneConnected = true;
document.getElementById('conn-dot').classList.add('connected');
document.getElementById('conn-text').textContent = '已连接';
document.getElementById('btn-connect').textContent = '已连接';
document.getElementById('btn-connect').disabled = true;
document.getElementById('monitor-status').textContent = '待命';
document.getElementById('monitor-status').className = 'tag tag-success';
updateMonitorControls();
}
if (msg.includes('任务完成') || msg.includes('所有航点已到达')) {
missionRunning = false;
missionPaused = false;
document.getElementById('monitor-status').textContent = '已到达';
document.getElementById('monitor-status').className = 'tag tag-info';
document.getElementById('monitor-flight-tag').textContent = '已到达';
document.getElementById('monitor-flight-tag').className = 'tag tag-info';
completedCount++;
updateDashboardStats();
updateMonitorControls();
}
if (msg.includes('起飞完成') || msg.includes('巡航')) {
missionRunning = true;
missionPaused = false;
updateMonitorControls();
}
if (msg.includes('降落')) {
missionRunning = false;
missionPaused = false;
document.getElementById('monitor-status').textContent = '降落中';
document.getElementById('monitor-status').className = 'tag tag-warning';
document.getElementById('monitor-flight-tag').textContent = '降落中';
document.getElementById('monitor-flight-tag').className = 'tag tag-warning';
updateMonitorControls();
}
}
function onDroneDisconnected() {
droneConnected = false;
missionRunning = false;
missionPaused = false;
document.getElementById('conn-dot').classList.remove('connected');
document.getElementById('conn-text').textContent = '未连接';
document.getElementById('btn-connect').textContent = '连接';
document.getElementById('btn-connect').disabled = false;
document.getElementById('monitor-status').textContent = '离线';
document.getElementById('monitor-status').className = 'tag tag-danger';
updateMonitorControls();
addLog('error', '无人机连接断开');
}
// =============== 日志 ===============
function addLog(type, message) {
const c = document.getElementById('log-content');
if (!c) return;
const time = new Date().toTimeString().split(' ')[0];
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
entry.innerHTML = '<span class="time">[' + time + ']</span>' + message;
c.appendChild(entry);
c.scrollTop = c.scrollHeight;
while (c.children.length > 200) c.removeChild(c.firstChild);
}
function updateClock() {
const now = new Date().toTimeString().split(' ')[0];
const el1 = document.getElementById('chip-time');
const el2 = document.getElementById('chip-time-mon');
if (el1) el1.textContent = now;
if (el2) el2.textContent = now;
}
// 航点列表保留接口map.js 回调用)
function updateWaypointList() {}
function updateButtons() {}
return {
init,
onTelemetry,
onStatus,
onDroneDisconnected,
updateWaypointList,
updateButtons,
addLog
};
})();
document.addEventListener('DOMContentLoaded', () => {
UIModule.init();
});

@ -33,6 +33,7 @@ const WsModule = (() => {
let localPosSub = null;
let gpsSub = null;
let batterySub = null;
let velocitySub = null;
let setpointPub = null;
// Telemetry state
@ -141,15 +142,14 @@ const WsModule = (() => {
trySubscribesWithFallback();
}
// Set a default origin for coordinate conversion (no GPS needed)
// Set a default origin for coordinate conversion (fallback before GPS)
function setDefaultOrigin() {
if (originSet) return;
// Use map default center as origin - simulation doesn't need real GPS
origin.lat = MapModule.DEFAULT_LAT || 30.0;
origin.lng = MapModule.DEFAULT_LNG || 120.0;
originSet = true;
originFromGps = false;
UIModule.onStatus({ type: 'info', message: `仿真模式: 使用默认坐标原点 (${origin.lat}, ${origin.lng})无需GPS` });
UIModule.onStatus({ type: 'info', message: '使用默认坐标原点 (' + origin.lat + ', ' + origin.lng + '),等待 GPS 定位...' });
}
function trySubscribesWithFallback() {
@ -247,7 +247,7 @@ const WsModule = (() => {
origin.lng = msg.longitude;
originSet = true;
originFromGps = true;
UIModule.onStatus({ type: 'info', message: `GPS 坐标已更新: ${origin.lat.toFixed(6)}, ${origin.lng.toFixed(6)}` });
UIModule.onStatus({ type: 'success', message: 'GPS 已定位! 坐标原点: ' + origin.lat.toFixed(6) + ', ' + origin.lng.toFixed(6) });
MapModule.centerOnDrone(msg.latitude, msg.longitude);
}
}
@ -264,6 +264,20 @@ const WsModule = (() => {
telemetry.battery = (msg.percentage != null) ? msg.percentage * 100 : 100;
});
// Velocity (for speed calculation)
velocitySub = new ROSLIB.Topic({
ros: ros,
name: topicName('/mavros/local_position/velocity'),
messageType: 'geometry_msgs/TwistStamped'
});
velocitySub.subscribe((msg) => {
if (msg.twist && msg.twist.linear) {
const v = msg.twist.linear;
telemetry.speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}
});
// Setpoint publisher — PoseStamped is the standard MAVROS position control interface
setpointPub = new ROSLIB.Topic({
ros: ros,
@ -403,7 +417,11 @@ const WsModule = (() => {
convertWaypoints();
}
if (navWaypoints.length === 0) {
UIModule.onStatus({ type: 'error', message: '航点转换失败,请重新上传任务' });
if (!originSet) {
UIModule.onStatus({ type: 'error', message: '无法开始任务: 实机模式需要等待 GPS 定位后才能转换航点坐标' });
} else {
UIModule.onStatus({ type: 'error', message: '航点转换失败,请重新上传任务' });
}
return;
}

@ -0,0 +1,96 @@
"""
Flask 服务器 - 智途投送电脑端
提供静态文件服务 + REST API士兵位置危险区域
浏览器通过 roslibjs 直接连接 rosbridge_server无需 Python 后端处理无人机逻辑
"""
import os
from datetime import datetime
from flask import Flask, send_from_directory, request, jsonify
# 项目根目录server 的上级目录)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
app = Flask(__name__, static_folder=BASE_DIR)
# ---- 内存数据存储 ----
soldiers = {} # { soldier_id: { id, name, lat, lng, updated_at } }
danger_zones = [] # [ { id, lat, lng, radius, description, created_at } ]
_danger_id_counter = 0
# ===== 静态文件路由 =====
@app.route("/")
def index():
return send_from_directory(BASE_DIR, "index.html")
@app.route("/css/<path:filename>")
def css_files(filename):
return send_from_directory(os.path.join(BASE_DIR, "css"), filename)
@app.route("/js/<path:filename>")
def js_files(filename):
return send_from_directory(os.path.join(BASE_DIR, "js"), filename)
# ===== REST API: 士兵位置 =====
@app.route("/api/soldier/location", methods=["POST"])
def update_soldier_location():
data = request.get_json(force=True)
sid = data.get("id")
if not sid:
return jsonify({"ok": False, "error": "missing id"}), 400
soldiers[sid] = {
"id": sid,
"name": data.get("name", sid),
"lat": float(data.get("lat", 0)),
"lng": float(data.get("lng", 0)),
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
return jsonify({"ok": True})
@app.route("/api/soldiers", methods=["GET"])
def get_soldiers():
return jsonify({"soldiers": list(soldiers.values())})
# ===== REST API: 危险区域 =====
@app.route("/api/danger-zones", methods=["GET"])
def get_danger_zones():
return jsonify({"danger_zones": danger_zones})
@app.route("/api/danger-zones", methods=["POST"])
def add_danger_zone():
global _danger_id_counter
data = request.get_json(force=True)
_danger_id_counter += 1
zone = {
"id": _danger_id_counter,
"lat": float(data.get("lat", 0)),
"lng": float(data.get("lng", 0)),
"radius": float(data.get("radius", 50)),
"description": data.get("description", "危险区域"),
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
danger_zones.append(zone)
return jsonify({"ok": True, "id": zone["id"]})
if __name__ == "__main__":
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
print("=" * 50)
print(" 智途投送 - 物流保障系统")
print(" http://localhost:5000")
print(" 实机 rosbridge: ws://192.168.1.14:9090")
print("=" * 50)
app.run(host="0.0.0.0", port=5000, debug=False)

@ -0,0 +1,458 @@
# 智途投送 - 物流保障系统 使用说明
## 一、系统简介
智途投送是一套面向军事后勤保障场景的无人机物资投送管理系统(电脑端),支持:
- **任务调度** — 接收前方部队物资需求,分配无人机执行投送任务
- **无人机监控** — 实时监控无人机飞行状态、位置、电量等信息
- **士兵位置追踪** — 接收并显示前方士兵的实时位置
- **危险区域标记** — 标记无人机被摧毁等危险区域,供后续任务规避
系统通过 **rosbridge + MAVROS** 直接连接实机飞控PX4实现航线规划、自动起飞、巡航投送、返航降落等全流程控制。
---
## 二、环境要求
| 项目 | 要求 |
|------|------|
| 操作系统 | Windows 10/11 |
| Python | 3.8+ |
| 浏览器 | Chrome / Edge推荐 Chromium 内核) |
| 网络 | 电脑需连接无人机的 WiFi 数传网络 |
| 无人机 | PX4 飞控 + 机载电脑运行 ROS + MAVROS + rosbridge_server |
---
## 三、启动系统
### 3.1 启动 Flask 服务器
打开终端,进入项目目录:
```bash
cd 电脑端/server
python app.py
```
启动成功后会显示:
```
==================================================
智途投送 - 物流保障系统
http://localhost:5000
实机 rosbridge: ws://192.168.1.14:9090
==================================================
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
```
### 3.2 打开浏览器
在浏览器中访问 **http://localhost:5000** 即可进入系统。
### 3.3 安装 Python 依赖(如未安装)
```bash
pip install flask
```
---
## 四、功能模块详解
### 4.1 任务调度(首页)
#### 页面概览
进入系统后默认显示「任务调度」页面,包含:
- **统计卡片** — 顶部展示待处理需求数、飞行中任务数、已完成数、可用无人机数
- **待处理需求列表** — 左侧卡片,显示所有待调度物资需求
- **飞行中任务列表** — 右侧卡片,显示正在执行的任务
#### 调度任务操作
1. 在「待处理需求」列表中 **点击** 一条需求
2. 弹出调度窗口,显示任务详情:
- 任务编号、请求士兵、物资类型、物资清单、紧急程度
- **起点坐标**(默认为补给基地,可手动修改)
- **终点坐标**(默认为士兵位置,可手动修改)
- 无人机分配
3. 点击 **「确认调度」** 开始执行任务,或点击 **「拒绝」** 取消该需求
4. 调度成功后系统自动跳转到「无人机监控」页面
#### 紧急程度说明
| 标签 | 颜色 | 含义 |
|------|------|------|
| 紧急 | 红色 | 最高优先级,需立即处理 |
| 高 | 黄色 | 高优先级 |
| 一般 | 蓝色 | 普通优先级 |
---
### 4.2 无人机监控
点击左侧导航栏的 **「无人机监控」** 进入监控页面。
#### 4.2.1 连接无人机
1. 在右侧面板「连接设置」卡片中,确认 rosbridge 地址:
- 默认地址:`ws://192.168.1.14:9090`
- 根据实际机载电脑 IP 修改(需连接无人机 WiFi 数传网络)
2. 点击 **「连接」** 按钮
3. 连接成功后:
- 按钮变为 **「已连接」** 并置灰
- 左侧导航底部连接指示灯变绿
- 操作日志显示「无人机已连接」
- 状态栏显示电量、速度、高度、GPS 等实时数据
> **注意**:连接前请确保:
> - 电脑已连接无人机的 WiFi 数传网络
> - 机载电脑上 rosbridge_server 已启动
> - MAVROS 节点已正常运行
#### 4.2.2 地图操作
- **缩放** — 鼠标滚轮或地图右下角 +/- 按钮
- **平移** — 鼠标拖拽
- **添加航点** — 点击地图任意位置(可在调度前手动规划航线)
- **图层切换** — 地图右上角可切换显示「士兵位置」和「危险区域」图层
地图上的元素:
- **蓝色三角** — 无人机当前位置及航向
- **蓝色虚线** — 计划航线
- **红色实线** — 飞行轨迹(实时记录)
- **绿色圆点** — 士兵位置(带名字标签)
- **红色虚线圆圈** — 危险区域
#### 4.2.3 无人机信息面板
连接成功后,「无人机信息」卡片显示:
| 数据项 | 说明 |
|--------|------|
| 纬度/经度 | 当前 GPS 坐标 |
| 航向 | 飞行方向0-360° |
| 距目标 | 当前航点进度(如 航点 1/2 |
| 信号 | GPS 信号状态 |
| 预计到达 | 当前航段进度 |
#### 4.2.4 控制面板
| 按钮 | 功能 | 启用条件 |
|------|------|----------|
| 暂停任务 | 无人机悬停等待 | 已连接 + 任务执行中 |
| 继续任务 | 恢复巡航 | 已连接 + 任务暂停中 |
| 紧急返航 | 返回起飞点并降落 | 已连接 |
| 标记无人机被摧毁 | 在当前位置标记危险区域 | 无人机已连接 |
#### 4.2.5 操作日志
底部「操作日志」卡片记录所有系统事件,包括连接状态、任务进度、错误信息等。日志按颜色区分:
| 颜色 | 类型 |
|------|------|
| 绿色 | 成功消息 |
| 红色 | 错误消息 |
| 黄色 | 警告消息 |
| 灰色 | 一般信息 |
点击 **「清除」** 按钮可清空日志。
---
### 4.3 士兵位置追踪
系统自动每 5 秒轮询后端 API获取前方士兵的最新位置并显示在
- **地图** — 绿色圆点 + 士兵姓名标签
- **右侧面板「在线士兵」列表** — 显示士兵姓名和坐标
点击列表中的士兵,地图会自动平移到该士兵位置。
> **数据来源**:士兵位置由 APP 端通过 REST API 上报(见下方 API 说明)。如需测试,可通过 curl 命令模拟上报。
---
### 4.4 危险区域标记
#### 标记无人机被摧毁
1. 在无人机已连接且获取到 GPS 位置后
2. 点击控制面板的 **「标记无人机被摧毁」** 按钮
3. 系统自动在无人机最后位置标记半径 50m 的红色危险区域
4. 无人机状态变为「已摧毁」
#### 查看危险区域
- **地图** — 红色虚线圆圈 + 警告图标
- **右侧面板「危险区域」列表** — 显示描述和坐标
点击地图上的危险区域圆圈可查看详细信息。
---
## 五、REST API 接口说明
系统提供以下 REST API供 APP 端(移动设备)或外部系统调用。
### 5.1 士兵位置上报
**请求**
```
POST /api/soldier/location
Content-Type: application/json
{
"id": "soldier_001",
"name": "张三",
"lat": 30.001,
"lng": 120.001
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | string | 是 | 士兵唯一标识 |
| name | string | 否 | 士兵姓名(默认使用 id |
| lat | float | 否 | 纬度 |
| lng | float | 否 | 经度 |
**响应**
```json
{"ok": true}
```
### 5.2 获取所有士兵位置
```
GET /api/soldiers
```
**响应**
```json
{
"soldiers": [
{
"id": "soldier_001",
"name": "张三",
"lat": 30.001,
"lng": 120.001,
"updated_at": "2026-05-11 14:30:00"
}
]
}
```
### 5.3 添加危险区域
**请求**
```
POST /api/danger-zones
Content-Type: application/json
{
"lat": 30.005,
"lng": 120.003,
"radius": 50,
"description": "无人机被摧毁"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| lat | float | 否 | 纬度 |
| lng | float | 否 | 经度 |
| radius | float | 否 | 半径(米),默认 50 |
| description | string | 否 | 描述,默认「危险区域」 |
**响应**
```json
{"ok": true, "id": 1}
```
### 5.4 获取所有危险区域
```
GET /api/danger-zones
```
**响应**
```json
{
"danger_zones": [
{
"id": 1,
"lat": 30.005,
"lng": 120.003,
"radius": 50,
"description": "无人机被摧毁",
"created_at": "2026-05-11 14:35:00"
}
]
}
```
---
## 六、测试 API使用 curl
在系统运行状态下,可通过以下命令测试 API 功能:
### 模拟士兵位置上报
```bash
curl -X POST http://localhost:5000/api/soldier/location -H "Content-Type: application/json" -d "{\"id\":\"s1\",\"name\":\"test\",\"lat\":30.001,\"lng\":120.001}"
```
### 查询士兵列表
```bash
curl http://localhost:5000/api/soldiers
```
### 模拟添加危险区域
```bash
curl -X POST http://localhost:5000/api/danger-zones -H "Content-Type: application/json" -d "{\"lat\":30.005,\"lng\":120.003,\"radius\":50,\"description\":\"crash\"}"
```
### 查询危险区域
```bash
curl http://localhost:5000/api/danger-zones
```
---
## 七、无人机连接配置
### 7.1 系统架构
```
浏览器 (roslibjs) ──WebSocket──► rosbridge_server ──ROS话题──► MAVROS ──MAVLink──► PX4 飞控
```
### 7.2 机载电脑端准备
在无人机机载电脑(如树莓派/NVIDIA Jetson上运行
```bash
# 1. 启动 MAVROS连接飞控
roslaunch mavros px4.launch fcu_url:=/dev/ttyUSB0:921600
# 2. 启动 rosbridge_server提供 WebSocket 接口)
roslaunch rosbridge_server rosbridge_websocket.launch
# 默认端口为 9090即 ws://机载电脑IP:9090
```
### 7.3 网络配置
1. 机载电脑和地面电脑需在同一局域网(通常通过无人机 WiFi 数传连接)
2. 机载电脑 IP 示例:`192.168.1.14`
3. 在浏览器「连接设置」中输入 `ws://192.168.1.14:9090`
### 7.4 MAVROS 话题说明
| 话题 | 方向 | 说明 |
|------|------|------|
| /uav1/mavros/state | 订阅 | 飞控状态(模式、解锁) |
| /uav1/mavros/global_position/raw/fix | 订阅 | GPS 全球坐标 |
| /uav1/mavros/local_position/pose | 订阅 | 本地位置ENU坐标+航向) |
| /uav1/mavros/battery | 订阅 | 电池电量 |
| /uav1/mavros/setpoint_position/local | 发布 | 位置控制设定值 |
| /uav1/mavros/set_mode | 服务 | 飞行模式切换 |
| /uav1/mavros/cmd/arming | 服务 | 解锁/上锁电机 |
> 系统会自动检测 MAVROS 命名空间(`/uav1` 或无命名空间),如检测不到 `/uav1` 会自动尝试无命名空间连接。
---
## 八、任务执行流程
```
1. 前方士兵通过 APP 提交物资需求
2. 电脑端「任务调度」页面收到需求
3. 操作员点击需求 → 设置航线 → 确认调度
4. 系统上传航线至飞控 → 切换 OFFBOARD 模式 → 解锁电机 → 自动起飞
5. 无人机沿航线巡航至投送位置
6. 到达目标后任务完成 → 可操作返航或继续下一任务
```
### 飞行控制时序
1. **上传航线** — 将起点/终点坐标转换为 ENU 坐标系发送给飞控
2. **发送位置设定值** — 以 10Hz 频率持续发送目标位置OFFBOARD 模式要求 ≥2Hz
3. **切换 OFFBOARD 模式** — 最多重试 3 次
4. **解锁电机** — 最多重试 3 次
5. **起飞检测** — 检测高度是否达到目标高度,超时 15 秒后强制开始巡航
6. **巡航导航** — 逐航点飞行,每航点超时 30 秒
7. **任务完成** — 所有航点到达后悬停,可手动操作返航
---
## 九、常见问题
### Q: 连接无人机失败?
- 检查电脑是否已连接无人机的 WiFi 数传网络
- 确认机载电脑上 rosbridge_server 和 MAVROS 已启动
- 尝试在浏览器中访问 `http://机载电脑IP:9090`,应返回 rosbridge 页面
- 检查防火墙是否阻止了 WebSocket 连接
### Q: 连接成功但无遥测数据?
- 确认 MAVROS 话题命名空间正确(系统默认 `/uav1`,会自动尝试无命名空间)
- 检查飞控是否已正常启动(查看操作日志中是否显示飞控模式)
- 确认 GPS 已定位(未定位时坐标显示为默认值)
### Q: 任务调度后无人机不动?
- 确认无人机已连接且 GPS 已定位
- 检查飞控是否通过安全检查传感器校准、GPS 定位等)
- 确认 OFFBOARD 模式切换成功(查看操作日志)
- 检查电机是否解锁成功
### Q: 士兵列表为空?
- 士兵位置由 APP 端通过 API 上报,需 APP 端运行并上报数据
- 可通过 curl 命令手动测试(见第六节)
- 前端每 5 秒自动轮询,上报后稍等即可看到
### Q: 端口 5000 被占用?
- 关闭已运行的 Flask 进程:`taskkill /F /IM python.exe`
- 或修改 `app.py` 最后一行的端口号
---
## 十、项目文件结构
```
电脑端/
├── index.html # 主页面(导航、任务调度、无人机监控)
├── css/
│ └── style.css # 全局样式
├── js/
│ ├── ui.js # UI 交互逻辑(调度、士兵追踪、危险区域)
│ ├── map.js # Leaflet 地图模块(航点、标记、航线)
│ ├── websocket.js # MAVROS 通信模块roslibjs 话题/服务)
│ └── roslib.min.js # roslibjs 库WebSocket 连接 ROS
└── server/
└── app.py # Flask 服务器(静态文件 + REST API
```
Loading…
Cancel
Save