Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>智能机队指挥官 | Fleet Commander</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <style> | |
| [v-cloak] { display: none; } | |
| .scrollbar-hide::-webkit-scrollbar { display: none; } | |
| .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } | |
| .chat-msg { max-width: 80%; word-wrap: break-word; } | |
| </style> | |
| </head> | |
| <body class="bg-slate-100 text-slate-800"> | |
| <div id="app" v-cloak class="h-screen flex flex-col"> | |
| <!-- Header --> | |
| <header class="bg-white shadow-sm p-4 flex justify-between items-center z-20 border-b"> | |
| <div class="flex items-center gap-3"> | |
| <div class="bg-indigo-600 text-white p-2 rounded-lg"> | |
| <i class="fas fa-helicopter text-xl"></i> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-bold text-slate-800 leading-tight">智能机队指挥官</h1> | |
| <div class="text-xs text-slate-500">Fleet Commander Agent <span class="bg-green-100 text-green-700 px-1 rounded ml-1">v2.0</span></div> | |
| </div> | |
| </div> | |
| <div class="flex gap-4 text-sm text-slate-600 items-center"> | |
| <div class="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-50 rounded-full border"> | |
| <div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div> | |
| <span>系统在线</span> | |
| </div> | |
| <div class="hidden md:block">CPU: 12%</div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-1 flex overflow-hidden flex-col md:flex-row"> | |
| <!-- Left: Map Area --> | |
| <div class="flex-1 relative bg-slate-200 flex flex-col" id="map-container"> | |
| <div id="fleet-map" class="flex-1 w-full h-full min-h-[300px]"></div> | |
| <!-- Map Overlay Stats --> | |
| <div class="absolute top-4 left-4 bg-white/90 p-4 rounded-xl shadow-lg backdrop-blur-sm z-10 border border-white/50"> | |
| <div class="text-xs text-slate-500 mb-1 font-medium uppercase tracking-wider">活跃单位</div> | |
| <div class="text-3xl font-black text-indigo-600 flex items-baseline gap-1"> | |
| ${ stats.active } | |
| <span class="text-sm font-normal text-slate-400">/ ${ stats.total }</span> | |
| </div> | |
| </div> | |
| <!-- Alert Overlay (Bottom Left) --> | |
| <div class="absolute bottom-4 left-4 right-4 md:right-auto md:w-96 max-h-48 overflow-y-auto bg-black/70 text-white p-3 rounded-lg shadow-lg z-10 backdrop-blur-md text-xs font-mono"> | |
| <div v-for="(alert, i) in alerts.slice(0, 5)" :key="i" class="mb-1.5 flex gap-2"> | |
| <span class="opacity-50 text-cyan-400">[${ alert.time }]</span> | |
| <span :class="{'text-red-400': alert.level==='error', 'text-yellow-400': alert.level==='warning', 'text-green-400': alert.level==='success'}"> | |
| ${ alert.message } | |
| </span> | |
| </div> | |
| <div v-if="alerts.length === 0" class="opacity-50 italic">暂无警报数据...</div> | |
| </div> | |
| </div> | |
| <!-- Right: Sidebar Control Panel --> | |
| <aside class="w-full md:w-[400px] bg-white shadow-2xl z-20 flex flex-col border-l border-slate-200 h-[60%] md:h-auto"> | |
| <!-- Tabs --> | |
| <div class="flex border-b text-sm font-medium"> | |
| <button @click="currentTab = 'control'" :class="{'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50': currentTab === 'control'}" class="flex-1 py-3 hover:bg-slate-50 transition-colors"> | |
| <i class="fas fa-gamepad mr-1"></i> 控制 | |
| </button> | |
| <button @click="currentTab = 'fleet'" :class="{'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50': currentTab === 'fleet'}" class="flex-1 py-3 hover:bg-slate-50 transition-colors"> | |
| <i class="fas fa-robot mr-1"></i> 机队 | |
| </button> | |
| <button @click="currentTab = 'ai'" :class="{'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50': currentTab === 'ai'}" class="flex-1 py-3 hover:bg-slate-50 transition-colors"> | |
| <i class="fas fa-brain mr-1"></i> AI 助手 | |
| </button> | |
| </div> | |
| <!-- Content Area --> | |
| <div class="flex-1 overflow-hidden relative bg-slate-50"> | |
| <!-- Tab: Control --> | |
| <div v-if="currentTab === 'control'" class="h-full overflow-y-auto p-4 space-y-4"> | |
| <!-- Quick Stats --> | |
| <div class="grid grid-cols-3 gap-3"> | |
| <div class="bg-white p-3 rounded-lg shadow-sm border border-slate-100 text-center"> | |
| <div class="text-xs text-slate-500">总数</div> | |
| <div class="text-xl font-bold text-slate-700">${ stats.total }</div> | |
| </div> | |
| <div class="bg-white p-3 rounded-lg shadow-sm border border-slate-100 text-center"> | |
| <div class="text-xs text-green-600">充电中</div> | |
| <div class="text-xl font-bold text-green-600">${ stats.charging }</div> | |
| </div> | |
| <div class="bg-white p-3 rounded-lg shadow-sm border border-slate-100 text-center"> | |
| <div class="text-xs text-red-600">故障</div> | |
| <div class="text-xl font-bold text-red-600">${ stats.error }</div> | |
| </div> | |
| </div> | |
| <!-- Manual Task --> | |
| <div class="bg-white p-4 rounded-lg shadow-sm border border-slate-200"> | |
| <h3 class="text-sm font-bold mb-3 text-slate-700 flex items-center gap-2"> | |
| <i class="fas fa-tasks text-indigo-500"></i> 手动任务 | |
| </h3> | |
| <div class="flex gap-2"> | |
| <input v-model="newTask" @keyup.enter="assignTask" type="text" placeholder="输入任务指令 (如: 巡逻 A 区)" class="flex-1 border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"> | |
| <button @click="assignTask" class="bg-indigo-600 text-white px-4 py-2 rounded-md text-sm hover:bg-indigo-700 transition-colors"> | |
| <i class="fas fa-paper-plane"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- File Upload --> | |
| <div class="bg-white p-4 rounded-lg shadow-sm border border-slate-200"> | |
| <h3 class="text-sm font-bold mb-3 text-slate-700 flex items-center gap-2"> | |
| <i class="fas fa-file-upload text-indigo-500"></i> 任务文件上传 | |
| </h3> | |
| <div class="border-2 border-dashed border-slate-300 rounded-lg p-4 text-center hover:bg-slate-50 transition-colors cursor-pointer relative"> | |
| <input type="file" @change="handleFileUpload" class="absolute inset-0 opacity-0 cursor-pointer"> | |
| <div v-if="!uploading"> | |
| <i class="fas fa-cloud-upload-alt text-2xl text-slate-400 mb-2"></i> | |
| <p class="text-xs text-slate-500">点击或拖拽上传 JSON/TXT 任务文件</p> | |
| </div> | |
| <div v-else class="text-indigo-600"> | |
| <i class="fas fa-spinner fa-spin"></i> 上传解析中... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Tab: Fleet --> | |
| <div v-if="currentTab === 'fleet'" class="h-full overflow-y-auto p-4 space-y-2"> | |
| <div v-for="bot in robots" :key="bot.id" class="bg-white p-3 rounded-lg shadow-sm border border-slate-200 flex items-center justify-between hover:shadow-md transition-shadow"> | |
| <div> | |
| <div class="flex items-center gap-2 mb-1"> | |
| <span class="font-bold text-sm text-slate-700">${ bot.id }</span> | |
| <span class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-500 border">${ bot.type }</span> | |
| </div> | |
| <div class="flex items-center gap-3 text-xs text-slate-500"> | |
| <span :class="{'text-green-500': bot.battery > 20, 'text-red-500': bot.battery <= 20}"> | |
| <i class="fas fa-battery-three-quarters"></i> ${ Math.round(bot.battery) }% | |
| </span> | |
| <span class="flex items-center gap-1"> | |
| <div class="w-1.5 h-1.5 rounded-full" :class="getStatusColorBg(bot.status)"></div> | |
| ${ getStatusText(bot.status) } | |
| </span> | |
| </div> | |
| </div> | |
| <button v-if="bot.status === 'error'" @click="fixBot(bot.id)" class="bg-red-50 text-red-600 border border-red-200 text-xs px-3 py-1.5 rounded-md hover:bg-red-100 transition-colors"> | |
| <i class="fas fa-wrench mr-1"></i> 修复 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Tab: AI Assistant --> | |
| <div v-if="currentTab === 'ai'" class="h-full flex flex-col bg-white"> | |
| <div class="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50" ref="chatContainer"> | |
| <div v-for="(msg, i) in chatHistory" :key="i" class="flex" :class="msg.role === 'user' ? 'justify-end' : 'justify-start'"> | |
| <div class="chat-msg p-3 rounded-lg text-sm shadow-sm" | |
| :class="msg.role === 'user' ? 'bg-indigo-600 text-white rounded-br-none' : 'bg-white border border-slate-200 text-slate-700 rounded-bl-none'"> | |
| <div v-if="msg.role === 'assistant'" class="text-xs text-indigo-500 font-bold mb-1 flex items-center gap-1"> | |
| <i class="fas fa-robot"></i> 指挥官助手 | |
| </div> | |
| <div class="whitespace-pre-wrap">${ msg.content }</div> | |
| </div> | |
| </div> | |
| <div v-if="isThinking" class="flex justify-start"> | |
| <div class="chat-msg p-3 rounded-lg text-sm bg-white border border-slate-200 text-slate-500 rounded-bl-none"> | |
| <i class="fas fa-circle-notch fa-spin mr-1"></i> 思考中... | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-3 border-t bg-white"> | |
| <div class="flex gap-2"> | |
| <input v-model="chatInput" @keyup.enter="sendChat" type="text" placeholder="询问机队状态或寻求建议..." class="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-slate-50"> | |
| <button @click="sendChat" :disabled="isThinking" class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors disabled:opacity-50"> | |
| <i class="fas fa-paper-plane"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| </main> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted, nextTick, watch } = Vue; | |
| createApp({ | |
| delimiters: ['${', '}'], | |
| setup() { | |
| const robots = ref([]); | |
| const alerts = ref([]); | |
| const stats = ref({ total: 0, active: 0, charging: 0, error: 0 }); | |
| const newTask = ref(''); | |
| const currentTab = ref('control'); | |
| const chatInput = ref(''); | |
| const chatHistory = ref([ | |
| { role: 'assistant', content: '指挥官您好!我是您的智能机队助手。您可以询问我关于机队状态的问题,或者让我协助分析异常。' } | |
| ]); | |
| const isThinking = ref(false); | |
| const uploading = ref(false); | |
| const chatContainer = ref(null); | |
| let mapChart = null; | |
| // --- Map Logic --- | |
| const initMap = () => { | |
| const el = document.getElementById('fleet-map'); | |
| if (!el) return; | |
| mapChart = echarts.init(el); | |
| const option = { | |
| backgroundColor: 'transparent', | |
| tooltip: { | |
| trigger: 'item', | |
| formatter: (params) => { | |
| const bot = params.data.bot; | |
| return ` | |
| <div class="font-bold">${bot.id}</div> | |
| <div>状态: ${getStatusText(bot.status)}</div> | |
| <div>电量: ${Math.round(bot.battery)}%</div> | |
| <div>坐标: (${Math.round(bot.x)}, ${Math.round(bot.y)})</div> | |
| `; | |
| } | |
| }, | |
| grid: { top: 20, bottom: 20, left: 20, right: 20 }, | |
| xAxis: { min: 0, max: 100, show: false }, | |
| yAxis: { min: 0, max: 100, show: false }, | |
| series: [{ | |
| type: 'scatter', | |
| symbolSize: 30, | |
| data: [], | |
| itemStyle: { | |
| color: (params) => { | |
| const status = params.data.status; | |
| if (status === 'error') return '#ef4444'; | |
| if (status === 'charging') return '#22c55e'; | |
| if (status === 'active') return '#4f46e5'; | |
| return '#94a3b8'; | |
| }, | |
| shadowBlur: 10, | |
| shadowColor: 'rgba(0,0,0,0.2)' | |
| }, | |
| label: { | |
| show: true, | |
| formatter: '{b}', | |
| position: 'top', | |
| color: '#333', | |
| fontSize: 11, | |
| fontWeight: 'bold' | |
| } | |
| }] | |
| }; | |
| mapChart.setOption(option); | |
| window.addEventListener('resize', () => mapChart.resize()); | |
| }; | |
| const updateMap = () => { | |
| if (!mapChart) return; | |
| const data = robots.value.map(bot => ({ | |
| name: bot.id, | |
| value: [bot.x, bot.y], | |
| status: bot.status, | |
| bot: bot | |
| })); | |
| mapChart.setOption({ | |
| series: [{ data }] | |
| }); | |
| }; | |
| // --- API Logic --- | |
| const fetchTelemetry = async () => { | |
| try { | |
| const res = await fetch('/api/telemetry'); | |
| const data = await res.json(); | |
| robots.value = data.robots; | |
| alerts.value = data.alerts; | |
| stats.value = data.stats; | |
| updateMap(); | |
| } catch (e) { | |
| console.error("Telemetry error:", e); | |
| } | |
| }; | |
| const assignTask = async () => { | |
| if (!newTask.value) return; | |
| try { | |
| await fetch('/api/command', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ type: 'task', description: newTask.value }) | |
| }); | |
| newTask.value = ''; | |
| fetchTelemetry(); | |
| } catch (e) { | |
| alert("任务下发失败: " + e.message); | |
| } | |
| }; | |
| const fixBot = async (botId) => { | |
| try { | |
| await fetch('/api/command', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ type: 'fix', bot_id: botId }) | |
| }); | |
| fetchTelemetry(); | |
| } catch (e) { | |
| alert("修复指令失败: " + e.message); | |
| } | |
| }; | |
| const handleFileUpload = async (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| uploading.value = true; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| alert(data.message); | |
| } catch (e) { | |
| alert("上传失败: " + e.message); | |
| } finally { | |
| uploading.value = false; | |
| event.target.value = ''; // Reset input | |
| } | |
| }; | |
| const sendChat = async () => { | |
| if (!chatInput.value.trim() || isThinking.value) return; | |
| const msg = chatInput.value; | |
| chatHistory.value.push({ role: 'user', content: msg }); | |
| chatInput.value = ''; | |
| isThinking.value = true; | |
| scrollToBottom(); | |
| try { | |
| const res = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: msg }) | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| chatHistory.value.push({ role: 'assistant', content: data.reply }); | |
| } else { | |
| chatHistory.value.push({ role: 'assistant', content: "API 错误: " + data.reply }); | |
| } | |
| } catch (e) { | |
| chatHistory.value.push({ role: 'assistant', content: "网络错误,请稍后再试。" }); | |
| } finally { | |
| isThinking.value = false; | |
| scrollToBottom(); | |
| } | |
| }; | |
| const scrollToBottom = () => { | |
| nextTick(() => { | |
| if (chatContainer.value) { | |
| chatContainer.value.scrollTop = chatContainer.value.scrollHeight; | |
| } | |
| }); | |
| }; | |
| // --- Helpers --- | |
| const getStatusText = (status) => { | |
| const map = { 'idle': '待命', 'active': '任务中', 'charging': '充电中', 'error': '故障' }; | |
| return map[status] || status; | |
| }; | |
| const getStatusColorBg = (status) => { | |
| if (status === 'error') return 'bg-red-500'; | |
| if (status === 'charging') return 'bg-green-500'; | |
| if (status === 'active') return 'bg-indigo-500'; | |
| return 'bg-slate-400'; | |
| }; | |
| onMounted(() => { | |
| initMap(); | |
| fetchTelemetry(); | |
| setInterval(fetchTelemetry, 1000); // Poll every second | |
| }); | |
| return { | |
| robots, alerts, stats, newTask, currentTab, | |
| chatInput, chatHistory, isThinking, uploading, chatContainer, | |
| assignTask, fixBot, handleFileUpload, sendChat, | |
| getStatusText, getStatusColorBg | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |