This commit is contained in:
Xin Wang 2025-08-21 12:58:48 +08:00
commit 2892b38ff9
41 changed files with 13313 additions and 0 deletions

252
.cursorrules Normal file
View File

@ -0,0 +1,252 @@
# War-Web 红蓝攻防对抗系统 Cursor Rules
You are an expert full-stack developer working on the War-Web project, a 3D red-blue military confrontation simulation system. This project uses Vue 3 + TypeScript + Cesium.js for frontend and Python FastAPI for backend.
## 项目概述
- **项目名称**: War-Web (红蓝攻防对抗系统)
- **前端**: Vue 3 + TypeScript + Cesium.js + Element Plus + Pinia
- **后端**: Python FastAPI + PostgreSQL + Redis + WebSocket
- **核心功能**: 3D态势显示、实体管理、任务规划、态势感知、战况评估
## 代码规范与最佳实践
### 通用规范
- 使用英文注释和变量命名
- 采用语义化命名,清楚表达功能意图
- 每个文件必须有头部注释说明文件作用
- 重要函数必须添加完整的类型注解和文档字符串
- 实现完整的错误处理,避免静默失败
- 添加详细日志记录便于调试
### 前端开发规范 (Vue 3 + TypeScript + Cesium)
#### 文件结构约定
```
src/
├── api/ # API接口封装
├── cesium/ # Cesium核心类和管理器
├── components/ # Vue组件
│ ├── common/ # 通用组件
│ ├── cesium/ # Cesium相关组件
│ └── ui/ # UI组件
├── stores/ # Pinia状态管理
├── types/ # TypeScript类型定义
├── utils/ # 工具函数
└── views/ # 页面组件
```
#### Vue组件规范
- 使用Composition API + `<script setup>`
- 组件名使用PascalCase
- Props和Emits必须定义TypeScript类型
- 使用Element Plus组件库
- CSS使用scoped样式类名采用BEM命名
#### TypeScript类型定义
```typescript
// types/entities.ts - 实体相关类型
export interface Entity {
id: string
name: string
type: 'fighter' | 'drone' | 'missile'
side: 'red' | 'blue'
position: Position3D
status: EntityStatus
attributes: EntityAttributes
createdAt: string
}
export interface Position3D {
lng: number
lat: number
alt: number
}
export type EntityStatus = 'active' | 'damaged' | 'destroyed' | 'offline'
```
#### Cesium开发规范
- 为每个Cesium功能创建独立的Manager类
- 实体渲染使用EntityManager统一管理
- 航线规划使用FlightPathManager处理
- 地形和影像使用TerrainManager管理
- 实现资源清理和内存管理
```typescript
// cesium/EntityManager.ts
export class EntityManager {
private viewer: Cesium.Viewer
private entities: Map<string, Cesium.Entity> = new Map()
constructor(viewer: Cesium.Viewer) {
this.viewer = viewer
}
addEntity(entityData: Entity): Cesium.Entity {
// 实现实体添加逻辑
}
updateEntity(id: string, updates: Partial<Entity>): void {
// 实现实体更新逻辑
}
removeEntity(id: string): void {
// 实现实体移除逻辑,注意清理资源
}
}
```
#### Pinia状态管理规范
- 每个功能模块创建独立的store
- 使用TypeScript定义store类型
- Actions使用async/await处理异步操作
- 实现错误状态管理
```typescript
// stores/entities.ts
export const useEntitiesStore = defineStore('entities', () => {
const entities = ref<Entity[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const fetchEntities = async (): Promise<void> => {
loading.value = true
error.value = null
try {
const response = await entitiesApi.getAll()
entities.value = response.data
} catch (err) {
error.value = err instanceof Error ? err.message : '获取实体列表失败'
throw err
} finally {
loading.value = false
}
}
return {
entities: readonly(entities),
loading: readonly(loading),
error: readonly(error),
fetchEntities
}
})
```
### 后端开发规范 (Python FastAPI)
#### 项目结构
```
backend/
├── app/
│ ├── api/ # API路由
│ ├── core/ # 核心配置
│ ├── models/ # 数据模型
│ ├── schemas/ # Pydantic模式
│ ├── services/ # 业务逻辑
│ └── utils/ # 工具函数
├── alembic/ # 数据库迁移
└── tests/ # 测试文件
```
#### FastAPI开发规范
- 使用Pydantic进行数据验证
- 路由按功能模块分组
- 实现统一的错误处理
- 添加API文档和类型注解
```python
# schemas/entities.py
from pydantic import BaseModel, Field
from typing import Optional, Literal
from datetime import datetime
class Position3D(BaseModel):
lng: float = Field(..., ge=-180, le=180, description="经度")
lat: float = Field(..., ge=-90, le=90, description="纬度")
alt: float = Field(..., ge=0, description="高度(米)")
class EntityCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
type: Literal["fighter", "drone", "missile"]
side: Literal["red", "blue"]
position: Position3D
attributes: Optional[dict] = Field(default_factory=dict)
class EntityResponse(EntityCreate):
id: str
status: str
created_at: datetime
class Config:
from_attributes = True
```
#### WebSocket实时通信
- 使用连接管理器管理WebSocket连接
- 实现分房间的消息广播
- 添加连接状态监控和重连机制
```python
# websocket/manager.py
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, mission_id: str):
await websocket.accept()
if mission_id not in self.active_connections:
self.active_connections[mission_id] = []
self.active_connections[mission_id].append(websocket)
async def broadcast_to_mission(self, mission_id: str, message: dict):
if mission_id in self.active_connections:
for connection in self.active_connections[mission_id]:
try:
await connection.send_json(message)
except ConnectionClosedOK:
self.active_connections[mission_id].remove(connection)
```
## 开发指导原则
### 前端开发
1. **组件设计**: 遵循单一职责原则,保持组件简洁
2. **状态管理**: 合理使用Pinia避免过度设计
3. **性能优化**:
- Cesium实体使用LOD和可见性优化
- 虚拟滚动处理大量数据
- 懒加载和代码分割
4. **错误处理**: 实现全局错误捕获和用户友好的错误提示
5. **可访问性**: 提供键盘导航和屏幕阅读器支持
### 后端开发
1. **API设计**: 遵循RESTful规范使用合适的HTTP状态码
2. **数据验证**: 使用Pydantic进行严格的输入验证
3. **安全性**: 实现认证授权、输入过滤、SQL注入防护
4. **性能**: 使用异步编程、数据库连接池、缓存策略
5. **可监控性**: 添加详细日志、性能指标、健康检查
### 军事特定功能
1. **实体建模**: 准确建模军事实体的属性和行为
2. **坐标系统**: 使用标准军用坐标系统和地理参考
3. **实时性**: 确保态势数据的实时性和准确性
4. **兵推逻辑**: 实现真实的战术和策略逻辑
5. **数据安全**: 处理敏感军事数据的安全性
### 代码示例要求
当生成代码时,请:
- 包含完整的类型注解
- 添加详细的注释说明
- 实现错误处理逻辑
- 遵循项目的文件结构和命名规范
- 考虑性能和可维护性
- 为复杂逻辑提供单元测试示例
### 特定场景处理
- **大量实体渲染**: 使用Cesium的批量渲染和LOD技术
- **实时数据更新**: 优化WebSocket消息处理和状态同步
- **复杂航线规划**: 实现路径规划算法和碰撞检测
- **3D模型优化**: 使用合适的模型格式和压缩技术
- **跨平台兼容**: 确保在不同浏览器和设备上的兼容性
Remember: This is a military simulation system for training purposes only. Focus on creating realistic, performant, and maintainable code that serves educational and training objectives.

22
.eslintrc.cjs Normal file
View File

@ -0,0 +1,22 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}

175
.gitignore vendored Normal file
View File

@ -0,0 +1,175 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# TypeScript
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# Rollup.js default build output
dist/
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity

11
.prettierrc.json Normal file
View File

@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 80,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

307
README.md Normal file
View File

@ -0,0 +1,307 @@
# 红蓝攻防对抗系统 (War-Web)
基于 Cesium.js + Vue 3 的3D红蓝攻防态势显示系统前端项目
## 🎯 项目介绍
本项目是一个军事态势感知与对抗演练系统的前端部分,提供:
- 🌍 基于Cesium的3D地球态势显示
- ✈️ 红蓝双方实体管理(飞机、无人机、导弹等)
- 🗺️ 任务规划与航线设计
- 📡 实时态势感知与监控
- 📊 战况评估与分析
- 🔄 WebSocket实时数据同步
## 🛠️ 技术栈
- **前端框架**: Vue 3 + TypeScript
- **3D引擎**: Cesium.js 1.111+
- **UI组件**: Element Plus
- **状态管理**: Pinia
- **路由管理**: Vue Router 4
- **构建工具**: Vite 5
- **实时通信**: Socket.io-client
- **HTTP客户端**: Axios
## 📋 环境要求
- **Node.js**: >= 18.0.0
- **npm**: >= 9.0.0 或 **pnpm**: >= 8.0.0
- **现代浏览器**: Chrome 88+, Firefox 78+, Safari 14+
## 🚀 快速开始
### 1. 克隆项目
```bash
git clone <repository-url>
cd war-web
```
### 2. 安装依赖
```bash
# 使用 npm
npm install
# 或使用 pnpm (推荐)
pnpm install
```
### 3. 环境配置
创建环境变量文件:
```bash
# 开发环境
touch .env.development
```
编辑 `.env.development`:
```bash
# API服务地址
VITE_API_BASE_URL=http://localhost:8000
# WebSocket服务地址
VITE_WS_URL=ws://localhost:8000
# Cesium Ion访问令牌 (可选)
VITE_CESIUM_ION_ACCESS_TOKEN=
# 地图样式
VITE_MAP_STYLE=satellite
VITE_TERRAIN_PROVIDER=cesium_world_terrain
# 应用配置
VITE_APP_TITLE=红蓝攻防对抗系统
VITE_APP_VERSION=1.0.0
# 开发模式配置
VITE_DEBUG_MODE=true
VITE_MOCK_DATA=false
```
### 4. 启动开发服务器
```bash
npm run dev
```
服务启动后访问: http://localhost:3000
## 📁 项目结构
```
war-web/
├── public/ # 静态资源
│ ├── models/ # 3D模型文件 (.glb, .gltf)
│ └── icons/ # 图标文件
├── src/
│ ├── api/ # API接口封装
│ │ ├── index.ts # API基础配置
│ │ ├── entities.ts # 实体管理API
│ │ ├── missions.ts # 任务规划API
│ │ └── websocket.ts # WebSocket客户端
│ ├── assets/ # 资源文件
│ │ ├── icons/ # SVG图标
│ │ ├── images/ # 图片资源
│ │ └── models/ # 本地3D模型
│ ├── cesium/ # Cesium相关模块
│ │ ├── EntityManager.ts # 实体管理器
│ │ ├── FlightPathManager.ts # 航线管理器
│ │ ├── TerrainManager.ts # 地形管理器
│ │ └── utils.ts # Cesium工具函数
│ ├── components/ # Vue组件
│ │ ├── common/ # 通用组件
│ │ ├── cesium/ # Cesium相关组件
│ │ └── ui/ # UI组件
│ ├── stores/ # Pinia状态管理
│ │ ├── entities.ts # 实体状态
│ │ ├── missions.ts # 任务状态
│ │ └── situation.ts # 态势状态
│ ├── types/ # TypeScript类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ │ ├── Dashboard.vue # 主控面板
│ │ ├── EntityManagement.vue # 实体管理
│ │ ├── MissionPlanning.vue # 任务规划
│ │ ├── SituationAwareness.vue # 态势感知
│ │ └── Assessment.vue # 战况评估
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── package.json # 项目配置
├── vite.config.ts # Vite配置
├── tsconfig.json # TypeScript配置
└── README.md # 项目说明
```
## 🔧 开发说明
### 开发模式
```bash
# 启动开发服务器(热重载)
npm run dev
# 类型检查
npm run type-check
# 代码检查和修复
npm run lint
```
### 生产构建
```bash
# 构建生产版本
npm run build
# 预览生产构建
npm run preview
```
### 代码规范
项目使用 ESLint + Prettier 进行代码规范检查:
```bash
# 检查代码规范
npm run lint
# 自动修复代码格式
npm run lint --fix
```
## 🔌 API配置
### 后端API接口
确保后端服务在 `http://localhost:8000` 运行,主要接口:
```
GET /api/entities # 获取实体列表
POST /api/entities # 创建实体
PUT /api/entities/:id # 更新实体
DELETE /api/entities/:id # 删除实体
GET /api/missions # 获取任务列表
POST /api/missions # 创建任务
PUT /api/missions/:id # 更新任务
GET /api/situation/current # 获取当前态势
WebSocket /ws/{mission_id} # 实时态势推送
```
### WebSocket连接
系统使用WebSocket进行实时数据推送
```typescript
// 连接示例
const socket = io('ws://localhost:8000')
socket.emit('join_mission', { missionId: 'xxx' })
socket.on('entity_update', (data) => {
// 处理实体更新
})
```
## 🎨 主要功能模块
### 1. 3D态势显示
- Cesium地球场景初始化
- 实体3D模型加载和显示
- 相机控制和视角切换
- 地形数据加载
### 2. 实体管理
- 飞机、无人机、导弹等实体的CRUD
- 实体属性配置
- 实体分组和筛选
- 实体状态实时更新
### 3. 任务规划
- 航线规划和编辑
- 航点拖拽功能
- 任务模板管理
- 武器装备配置
### 4. 态势感知
- 实时态势监控
- 威胁检测和预警
- 雷达扫描效果
- 态势数据分析
### 5. 战况评估
- 战况回放功能
- 损失评估统计
- 战况报告生成
- 数据可视化图表
## 🔍 调试说明
### Vue Devtools
推荐安装 Vue Devtools 浏览器扩展进行调试
### Cesium调试
在浏览器控制台访问 `window.viewer` 获取Cesium viewer实例
### 网络请求调试
查看浏览器开发者工具的Network面板监控API请求
## 🚢 部署说明
### 开发环境部署
```bash
# 构建项目
npm run build
# 使用nginx或其他web服务器托管dist目录
```
### 生产环境部署
1. 修改生产环境变量 `.env.production`
2. 执行构建命令 `npm run build`
3. 将 `dist` 目录部署到Web服务器
4. 配置nginx反向代理到后端API
### Docker部署
```dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
```
## 🛟 常见问题
### Q: Cesium加载失败
A: 检查网络连接确保可以访问Cesium CDN资源
### Q: WebSocket连接失败
A: 确认后端WebSocket服务正常运行检查防火墙设置
### Q: 3D模型不显示
A: 确认模型文件路径正确,检查模型格式是否为.glb或.gltf
### Q: 页面性能问题
A: 启用Cesium的LOD功能减少同时渲染的实体数量
## 📞 技术支持
- 开发文档: `/docs`
- 问题反馈: GitHub Issues
- 技术交流: 项目技术群
## 📄 许可证
MIT License
---
**注意**: 本项目为军事演练系统,仅供学习和演练使用,请勿用于实际军事行动。

24
env.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
/// <reference types="vite/client" />
/// <reference types="cesium" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_WS_URL: string
readonly VITE_CESIUM_ION_ACCESS_TOKEN: string
readonly VITE_APP_TITLE: string
readonly VITE_APP_VERSION: string
readonly VITE_DEBUG_MODE: string
readonly VITE_MOCK_DATA: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

124
index.html Normal file
View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>红蓝攻防对抗系统</title>
<meta name="description" content="基于Cesium.js的3D军事态势感知与对抗演练系统" />
<meta name="keywords" content="军事演练,态势感知,Cesium,3D地球,红蓝对抗" />
<!-- Cesium CSS -->
<script src="https://cesium.com/downloads/cesiumjs/releases/1.111/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.111/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
#app {
height: 100%;
}
/* 加载动画 */
.loading-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
z-index: 9999;
}
.loading-logo {
width: 80px;
height: 80px;
margin-bottom: 20px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
animation: pulse 2s ease-in-out infinite;
}
.loading-text {
font-size: 18px;
margin-bottom: 20px;
opacity: 0.9;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 隐藏加载动画 */
.loading-container.hidden {
opacity: 0;
visibility: hidden;
transition: opacity 0.5s ease, visibility 0.5s ease;
}
</style>
</head>
<body>
<!-- 加载动画 -->
<div id="loading" class="loading-container">
<div class="loading-logo">🌍</div>
<div class="loading-text">红蓝攻防对抗系统</div>
<div class="loading-text" style="font-size: 14px; opacity: 0.7;">正在初始化3D态势显示系统...</div>
<div class="loading-spinner"></div>
</div>
<!-- Vue应用容器 -->
<div id="app"></div>
<script>
// 页面加载完成后隐藏加载动画
window.addEventListener('load', function() {
setTimeout(function() {
const loading = document.getElementById('loading');
if (loading) {
loading.classList.add('hidden');
setTimeout(function() {
loading.style.display = 'none';
}, 500);
}
}, 1500); // 延迟1.5秒显示加载效果
});
// 设置Cesium静态资源路径
if (typeof window.CESIUM_BASE_URL === 'undefined') {
window.CESIUM_BASE_URL = 'https://cesium.com/downloads/cesiumjs/releases/1.111/Build/Cesium/';
}
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5706
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "war-web",
"version": "1.0.0",
"description": "红蓝攻防对抗系统前端",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2",
"cesium": "^1.111.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.4",
"uuid": "^9.0.1",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^20.10.4",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.55.0",
"eslint-plugin-vue": "^9.19.2",
"prettier": "^3.1.1",
"sass-embedded": "^1.90.0",
"typescript": "~5.3.3",
"vite": "^7.1.3",
"vite-plugin-cesium": "^1.2.23",
"vue-tsc": "^3.0.6"
}
}

144
src/App.vue Normal file
View File

@ -0,0 +1,144 @@
<template>
<div id="app">
<el-config-provider :locale="zhCn">
<div class="app-container">
<!-- 顶部导航栏 -->
<el-header class="app-header">
<div class="header-left">
<div class="logo">🛡</div>
<h1 class="app-title">红蓝攻防对抗系统</h1>
</div>
<div class="header-right">
<el-button-group>
<el-button
v-for="route in navRoutes"
:key="route.name"
:type="$route.name === route.name ? 'primary' : 'default'"
@click="$router.push(route.path)"
>
{{ route.meta?.title }}
</el-button>
</el-button-group>
</div>
</el-header>
<!-- 主要内容区域 -->
<el-main class="app-main">
<router-view />
</el-main>
</div>
</el-config-provider>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const router = useRouter()
//
const navRoutes = ref([
{ name: 'Dashboard', path: '/', meta: { title: '主控面板' } },
{ name: 'EntityManagement', path: '/entities', meta: { title: '实体管理' } },
{ name: 'MissionPlanning', path: '/missions', meta: { title: '任务规划' } },
{ name: 'SituationAwareness', path: '/situation', meta: { title: '态势感知' } },
{ name: 'Assessment', path: '/assessment', meta: { title: '战况评估' } },
{ name: 'ComponentDemo', path: '/components', meta: { title: '组件展示' } }
])
</script>
<style scoped>
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: white;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
height: 40px;
width: 40px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.app-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: white;
}
.header-right {
display: flex;
align-items: center;
}
.app-main {
flex: 1;
padding: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: #f5f5f5;
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: rgba(30, 60, 114, 0.3) transparent;
}
.app-main::-webkit-scrollbar {
width: 8px;
}
.app-main::-webkit-scrollbar-track {
background: transparent;
}
.app-main::-webkit-scrollbar-thumb {
background: rgba(30, 60, 114, 0.3);
border-radius: 4px;
transition: background-color 0.3s ease;
}
.app-main::-webkit-scrollbar-thumb:hover {
background: rgba(30, 60, 114, 0.5);
}
/* 全局样式重置 */
* {
box-sizing: border-box;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* 保持主体不滚动,只在.app-main中滚动 */
}
</style>

95
src/api/entities.ts Normal file
View File

@ -0,0 +1,95 @@
import { apiRequest } from './index'
import type { Entity, ApiResponse, PaginatedResponse } from '@/types'
export interface EntityCreateRequest {
name: string
type: string
side: string
position: {
lng: number
lat: number
alt: number
}
attributes?: Record<string, any>
}
export interface EntityUpdateRequest {
name?: string
position?: {
lng: number
lat: number
alt: number
}
status?: string
attributes?: Record<string, any>
}
export interface EntityQueryParams {
page?: number
pageSize?: number
type?: string
side?: string
status?: string
search?: string
}
// 实体管理API
export const entitiesApi = {
// 获取实体列表
getEntities(params?: EntityQueryParams): Promise<ApiResponse<PaginatedResponse<Entity>>> {
return apiRequest.get('/api/entities', params)
},
// 根据ID获取实体详情
getEntityById(id: string): Promise<ApiResponse<Entity>> {
return apiRequest.get(`/api/entities/${id}`)
},
// 创建新实体
createEntity(data: EntityCreateRequest): Promise<ApiResponse<Entity>> {
return apiRequest.post('/api/entities', data)
},
// 更新实体
updateEntity(id: string, data: EntityUpdateRequest): Promise<ApiResponse<Entity>> {
return apiRequest.put(`/api/entities/${id}`, data)
},
// 删除实体
deleteEntity(id: string): Promise<ApiResponse<void>> {
return apiRequest.delete(`/api/entities/${id}`)
},
// 批量更新实体位置
updateEntityPositions(updates: Array<{ id: string; position: { lng: number; lat: number; alt: number } }>): Promise<ApiResponse<void>> {
return apiRequest.patch('/api/entities/positions', { updates })
},
// 获取实体类型列表
getEntityTypes(): Promise<ApiResponse<string[]>> {
return apiRequest.get('/api/entities/types')
},
// 获取指定边方的实体
getEntitiesBySide(side: string): Promise<ApiResponse<Entity[]>> {
return apiRequest.get(`/api/entities/side/${side}`)
},
// 搜索实体
searchEntities(query: string): Promise<ApiResponse<Entity[]>> {
return apiRequest.get('/api/entities/search', { q: query })
},
// 获取实体统计信息
getEntityStats(): Promise<ApiResponse<{
total: number
bySide: Record<string, number>
byType: Record<string, number>
byStatus: Record<string, number>
}>> {
return apiRequest.get('/api/entities/stats')
}
}
export default entitiesApi

93
src/api/index.ts Normal file
View File

@ -0,0 +1,93 @@
import axios, { type AxiosInstance, type AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types'
// 创建axios实例
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 可以在这里添加认证token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse) => {
return response.data
},
(error) => {
// 统一错误处理
console.error('API Error:', error)
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response
switch (status) {
case 401:
// 未授权清除token并跳转到登录页
localStorage.removeItem('token')
window.location.href = '/login'
break
case 403:
console.error('Access forbidden')
break
case 404:
console.error('Resource not found')
break
case 500:
console.error('Internal server error')
break
default:
console.error(`HTTP Error ${status}:`, data?.message || error.message)
}
} else if (error.request) {
// 网络错误
console.error('Network Error:', error.message)
} else {
// 其他错误
console.error('Error:', error.message)
}
return Promise.reject(error)
}
)
// 通用API请求方法
export const apiRequest = {
get<T = any>(url: string, params?: any): Promise<ApiResponse<T>> {
return api.get(url, { params })
},
post<T = any>(url: string, data?: any): Promise<ApiResponse<T>> {
return api.post(url, data)
},
put<T = any>(url: string, data?: any): Promise<ApiResponse<T>> {
return api.put(url, data)
},
delete<T = any>(url: string): Promise<ApiResponse<T>> {
return api.delete(url)
},
patch<T = any>(url: string, data?: any): Promise<ApiResponse<T>> {
return api.patch(url, data)
}
}
export default api

167
src/api/missions.ts Normal file
View File

@ -0,0 +1,167 @@
import { apiRequest } from './index'
import type { Mission, FlightPath, ApiResponse, PaginatedResponse } from '@/types'
export interface MissionCreateRequest {
name: string
side: string
description?: string
startTime: string
endTime?: string
config: {
objectives: string[]
constraints: string[]
rules: Record<string, any>
participants: string[]
}
}
export interface MissionUpdateRequest {
name?: string
description?: string
startTime?: string
endTime?: string
status?: string
config?: Partial<MissionCreateRequest['config']>
}
export interface FlightPathCreateRequest {
entityId: string
waypoints: Array<{
position: { lng: number; lat: number; alt: number }
altitude: number
speed?: number
heading?: number
arrivalTime?: string
actions?: Array<{
type: string
target?: string
duration?: number
parameters?: Record<string, any>
}>
}>
}
export interface MissionQueryParams {
page?: number
pageSize?: number
side?: string
status?: string
search?: string
startDate?: string
endDate?: string
}
// 任务管理API
export const missionsApi = {
// 获取任务列表
getMissions(params?: MissionQueryParams): Promise<ApiResponse<PaginatedResponse<Mission>>> {
return apiRequest.get('/api/missions', params)
},
// 根据ID获取任务详情
getMissionById(id: string): Promise<ApiResponse<Mission>> {
return apiRequest.get(`/api/missions/${id}`)
},
// 创建新任务
createMission(data: MissionCreateRequest): Promise<ApiResponse<Mission>> {
return apiRequest.post('/api/missions', data)
},
// 更新任务
updateMission(id: string, data: MissionUpdateRequest): Promise<ApiResponse<Mission>> {
return apiRequest.put(`/api/missions/${id}`, data)
},
// 删除任务
deleteMission(id: string): Promise<ApiResponse<void>> {
return apiRequest.delete(`/api/missions/${id}`)
},
// 开始任务
startMission(id: string): Promise<ApiResponse<Mission>> {
return apiRequest.post(`/api/missions/${id}/start`)
},
// 暂停任务
pauseMission(id: string): Promise<ApiResponse<Mission>> {
return apiRequest.post(`/api/missions/${id}/pause`)
},
// 结束任务
endMission(id: string): Promise<ApiResponse<Mission>> {
return apiRequest.post(`/api/missions/${id}/end`)
},
// 获取任务参与的实体
getMissionEntities(id: string): Promise<ApiResponse<string[]>> {
return apiRequest.get(`/api/missions/${id}/entities`)
},
// 添加实体到任务
addEntityToMission(id: string, entityId: string): Promise<ApiResponse<void>> {
return apiRequest.post(`/api/missions/${id}/entities`, { entityId })
},
// 从任务中移除实体
removeEntityFromMission(id: string, entityId: string): Promise<ApiResponse<void>> {
return apiRequest.delete(`/api/missions/${id}/entities/${entityId}`)
},
// 获取任务统计
getMissionStats(): Promise<ApiResponse<{
total: number
byStatus: Record<string, number>
bySide: Record<string, number>
active: number
}>> {
return apiRequest.get('/api/missions/stats')
}
}
// 航线规划API
export const flightPathsApi = {
// 获取实体的航线
getFlightPath(entityId: string): Promise<ApiResponse<FlightPath>> {
return apiRequest.get(`/api/flight-paths/entity/${entityId}`)
},
// 创建航线
createFlightPath(data: FlightPathCreateRequest): Promise<ApiResponse<FlightPath>> {
return apiRequest.post('/api/flight-paths', data)
},
// 更新航线
updateFlightPath(id: string, data: Partial<FlightPathCreateRequest>): Promise<ApiResponse<FlightPath>> {
return apiRequest.put(`/api/flight-paths/${id}`, data)
},
// 删除航线
deleteFlightPath(id: string): Promise<ApiResponse<void>> {
return apiRequest.delete(`/api/flight-paths/${id}`)
},
// 获取任务的所有航线
getMissionFlightPaths(missionId: string): Promise<ApiResponse<FlightPath[]>> {
return apiRequest.get(`/api/flight-paths/mission/${missionId}`)
},
// 验证航线
validateFlightPath(data: FlightPathCreateRequest): Promise<ApiResponse<{
valid: boolean
warnings: string[]
errors: string[]
}>> {
return apiRequest.post('/api/flight-paths/validate', data)
},
// 优化航线
optimizeFlightPath(id: string, options?: {
optimizeFor?: 'time' | 'fuel' | 'safety'
avoidThreats?: boolean
}): Promise<ApiResponse<FlightPath>> {
return apiRequest.post(`/api/flight-paths/${id}/optimize`, options)
}
}
export default { missionsApi, flightPathsApi }

202
src/api/websocket.ts Normal file
View File

@ -0,0 +1,202 @@
import { io, type Socket } from 'socket.io-client'
import type { WebSocketMessage, Entity, SituationData } from '@/types'
class WebSocketClient {
private socket: Socket | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectInterval = 3000
private eventHandlers: Map<string, Function[]> = new Map()
constructor() {
this.connect()
}
// 连接WebSocket
private connect() {
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8000'
this.socket = io(wsUrl, {
transports: ['websocket'],
timeout: 10000,
forceNew: true
})
this.setupEventHandlers()
}
// 设置事件处理器
private setupEventHandlers() {
if (!this.socket) return
// 连接成功
this.socket.on('connect', () => {
console.log('WebSocket连接成功')
this.reconnectAttempts = 0
this.emit('connected')
})
// 连接断开
this.socket.on('disconnect', (reason) => {
console.log('WebSocket连接断开:', reason)
this.emit('disconnected', reason)
// 自动重连
if (reason === 'io server disconnect') {
// 服务器主动断开,不重连
return
}
this.attemptReconnect()
})
// 连接错误
this.socket.on('connect_error', (error) => {
console.error('WebSocket连接错误:', error)
this.emit('error', error)
this.attemptReconnect()
})
// 实体更新
this.socket.on('entity_update', (data: Entity) => {
this.emit('entity_update', data)
})
// 实体创建
this.socket.on('entity_created', (data: Entity) => {
this.emit('entity_created', data)
})
// 实体删除
this.socket.on('entity_deleted', (data: { id: string }) => {
this.emit('entity_deleted', data)
})
// 态势更新
this.socket.on('situation_update', (data: SituationData) => {
this.emit('situation_update', data)
})
// 任务状态更新
this.socket.on('mission_status', (data: { id: string; status: string; timestamp: string }) => {
this.emit('mission_status', data)
})
// 威胁警报
this.socket.on('threat_alert', (data: any) => {
this.emit('threat_alert', data)
})
// 系统通知
this.socket.on('notification', (data: any) => {
this.emit('notification', data)
})
// 自定义消息
this.socket.on('message', (data: WebSocketMessage) => {
this.emit('message', data)
})
}
// 尝试重连
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket重连次数已达上限')
this.emit('max_reconnect_attempts_reached')
return
}
this.reconnectAttempts++
console.log(`正在尝试第 ${this.reconnectAttempts} 次重连...`)
setTimeout(() => {
if (this.socket) {
this.socket.connect()
}
}, this.reconnectInterval * this.reconnectAttempts)
}
// 加入任务房间
joinMission(missionId: string) {
if (this.socket) {
this.socket.emit('join_mission', { missionId })
console.log(`已加入任务房间: ${missionId}`)
}
}
// 离开任务房间
leaveMission(missionId: string) {
if (this.socket) {
this.socket.emit('leave_mission', { missionId })
console.log(`已离开任务房间: ${missionId}`)
}
}
// 发送消息
send(event: string, data: any) {
if (this.socket && this.socket.connected) {
this.socket.emit(event, data)
} else {
console.warn('WebSocket未连接无法发送消息')
}
}
// 订阅事件
on(event: string, handler: Function) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, [])
}
this.eventHandlers.get(event)?.push(handler)
}
// 取消订阅事件
off(event: string, handler: Function) {
const handlers = this.eventHandlers.get(event)
if (handlers) {
const index = handlers.indexOf(handler)
if (index > -1) {
handlers.splice(index, 1)
}
}
}
// 触发事件
private emit(event: string, data?: any) {
const handlers = this.eventHandlers.get(event)
if (handlers) {
handlers.forEach(handler => handler(data))
}
}
// 获取连接状态
get connected() {
return this.socket?.connected || false
}
// 获取Socket ID
get socketId() {
return this.socket?.id
}
// 断开连接
disconnect() {
if (this.socket) {
this.socket.disconnect()
this.socket = null
}
}
// 手动重连
reconnect() {
this.disconnect()
this.reconnectAttempts = 0
this.connect()
}
}
// 创建全局WebSocket实例
export const wsClient = new WebSocketClient()
// 导出类型和实例
export default WebSocketClient
export { WebSocketClient }

View File

@ -0,0 +1,611 @@
<template>
<div class="military-chart" :class="`military-chart--${type}`">
<div v-if="title" class="military-chart__header">
<h4 class="military-chart__title">{{ title }}</h4>
<div v-if="subtitle" class="military-chart__subtitle">{{ subtitle }}</div>
</div>
<div class="military-chart__container" ref="chartContainer">
<!-- 雷达图 -->
<div v-if="type === 'radar'" class="radar-chart">
<svg :width="size" :height="size" class="radar-svg">
<!-- 背景网格 -->
<g class="radar-grid">
<circle
v-for="(ring, index) in radarRings"
:key="index"
:cx="center"
:cy="center"
:r="ring.radius"
fill="none"
:stroke="gridColor"
stroke-width="1"
/>
<line
v-for="(angle, index) in radarAngles"
:key="index"
:x1="center"
:y1="center"
:x2="center + maxRadius * Math.cos(angle)"
:y2="center + maxRadius * Math.sin(angle)"
:stroke="gridColor"
stroke-width="1"
/>
</g>
<!-- 数据区域 -->
<polygon
:points="radarPoints"
:fill="fillColor"
:stroke="strokeColor"
stroke-width="2"
fill-opacity="0.3"
/>
<!-- 数据点 -->
<circle
v-for="(point, index) in radarDataPoints"
:key="index"
:cx="point.x"
:cy="point.y"
r="4"
:fill="strokeColor"
/>
<!-- 标签 -->
<text
v-for="(label, index) in radarLabels"
:key="index"
:x="label.x"
:y="label.y"
:fill="textColor"
text-anchor="middle"
font-size="12"
font-weight="500"
>
{{ label.text }}
</text>
</svg>
</div>
<!-- 条形图 -->
<div v-else-if="type === 'bar'" class="bar-chart">
<div class="bar-chart__bars">
<div
v-for="(item, index) in chartData"
:key="index"
class="bar-chart__item"
>
<div class="bar-chart__label">{{ item.label }}</div>
<div class="bar-chart__bar-container">
<div
class="bar-chart__bar"
:class="`bar-chart__bar--${item.side || 'neutral'}`"
:style="{ width: `${(item.value / maxValue) * 100}%` }"
>
<span class="bar-chart__value">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 环形图 -->
<div v-else-if="type === 'donut'" class="donut-chart">
<svg :width="size" :height="size" class="donut-svg">
<g :transform="`translate(${center}, ${center})`">
<path
v-for="(segment, index) in donutSegments"
:key="index"
:d="segment.path"
:fill="segment.color"
:stroke="backgroundColor"
stroke-width="2"
class="donut-segment"
@mouseover="onSegmentHover(segment, index)"
@mouseleave="onSegmentLeave"
>
<title>{{ segment.label }}: {{ segment.value }}</title>
</path>
</g>
<!-- 中心文本 -->
<text
:x="center"
:y="center - 10"
:fill="textColor"
text-anchor="middle"
font-size="16"
font-weight="bold"
>
{{ centerText }}
</text>
<text
:x="center"
:y="center + 8"
:fill="textColor"
text-anchor="middle"
font-size="12"
>
{{ centerSubtext }}
</text>
</svg>
</div>
<!-- 态势图 -->
<div v-else-if="type === 'situation'" class="situation-chart">
<div class="situation-chart__grid">
<div
v-for="(entity, index) in situationData"
:key="index"
class="situation-chart__entity"
:class="`situation-chart__entity--${entity.side}`"
:style="{
left: `${entity.x}%`,
top: `${entity.y}%`,
transform: `rotate(${entity.heading || 0}deg)`
}"
@click="onEntityClick(entity)"
>
<div class="situation-chart__entity-icon">
{{ getEntityIcon(entity.type) }}
</div>
<div class="situation-chart__entity-label">
{{ entity.name }}
</div>
</div>
</div>
</div>
</div>
<!-- 图例 -->
<div v-if="showLegend && legend.length" class="military-chart__legend">
<div
v-for="(item, index) in legend"
:key="index"
class="military-chart__legend-item"
>
<div
class="military-chart__legend-color"
:style="{ backgroundColor: item.color }"
></div>
<span class="military-chart__legend-label">{{ item.label }}</span>
<span v-if="item.value" class="military-chart__legend-value">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
interface ChartDataItem {
label: string
value: number
side?: 'red' | 'blue' | 'neutral'
color?: string
}
interface SituationEntity {
id: string
name: string
type: string
side: 'red' | 'blue' | 'neutral'
x: number
y: number
heading?: number
}
interface LegendItem {
label: string
color: string
value?: string | number
}
interface Props {
type: 'radar' | 'bar' | 'donut' | 'situation'
data: ChartDataItem[] | SituationEntity[]
title?: string
subtitle?: string
size?: number
showLegend?: boolean
centerText?: string
centerSubtext?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 300,
showLegend: true,
centerText: '总计',
centerSubtext: '实体'
})
const emit = defineEmits<{
entityClick: [entity: SituationEntity]
segmentHover: [segment: any, index: number]
segmentLeave: []
}>()
const chartContainer = ref<HTMLElement>()
//
const center = computed(() => props.size / 2)
const maxRadius = computed(() => props.size / 2 - 40)
const innerRadius = computed(() => maxRadius.value * 0.5)
const chartData = computed(() => props.data as ChartDataItem[])
const situationData = computed(() => props.data as SituationEntity[])
const maxValue = computed(() => {
if (props.type === 'bar') {
return Math.max(...chartData.value.map(item => item.value))
}
return 100
})
//
const radarRings = computed(() => {
return Array(5).fill(0).map((_, index) => ({
radius: maxRadius.value * (index + 1) / 5
}))
})
const radarAngles = computed(() => {
const count = chartData.value.length
return Array(count).fill(0).map((_, index) =>
(index * 2 * Math.PI / count) - Math.PI / 2
)
})
const radarPoints = computed(() => {
return chartData.value.map((item, index) => {
const angle = radarAngles.value[index]
const radius = (item.value / 100) * maxRadius.value
const x = center.value + radius * Math.cos(angle)
const y = center.value + radius * Math.sin(angle)
return `${x},${y}`
}).join(' ')
})
const radarDataPoints = computed(() => {
return chartData.value.map((item, index) => {
const angle = radarAngles.value[index]
const radius = (item.value / 100) * maxRadius.value
return {
x: center.value + radius * Math.cos(angle),
y: center.value + radius * Math.sin(angle)
}
})
})
const radarLabels = computed(() => {
return chartData.value.map((item, index) => {
const angle = radarAngles.value[index]
const labelRadius = maxRadius.value + 20
return {
x: center.value + labelRadius * Math.cos(angle),
y: center.value + labelRadius * Math.sin(angle),
text: item.label
}
})
})
//
const donutSegments = computed(() => {
const total = chartData.value.reduce((sum, item) => sum + item.value, 0)
let currentAngle = 0
return chartData.value.map(item => {
const startAngle = currentAngle
const endAngle = currentAngle + (item.value / total) * 2 * Math.PI
currentAngle = endAngle
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0
const x1 = maxRadius.value * Math.cos(startAngle)
const y1 = maxRadius.value * Math.sin(startAngle)
const x2 = maxRadius.value * Math.cos(endAngle)
const y2 = maxRadius.value * Math.sin(endAngle)
const x3 = innerRadius.value * Math.cos(endAngle)
const y3 = innerRadius.value * Math.sin(endAngle)
const x4 = innerRadius.value * Math.cos(startAngle)
const y4 = innerRadius.value * Math.sin(startAngle)
const path = [
`M ${x1} ${y1}`,
`A ${maxRadius.value} ${maxRadius.value} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${innerRadius.value} ${innerRadius.value} 0 ${largeArcFlag} 0 ${x4} ${y4}`,
'Z'
].join(' ')
return {
path,
color: item.color || getSideColor(item.side),
label: item.label,
value: item.value
}
})
})
//
const legend = computed(() => {
return chartData.value.map(item => ({
label: item.label,
color: item.color || getSideColor(item.side),
value: item.value
}))
})
//
const gridColor = '#e0e0e0'
const textColor = '#666'
const backgroundColor = '#fff'
const fillColor = 'rgba(64, 158, 255, 0.3)'
const strokeColor = '#409eff'
//
const getSideColor = (side?: string) => {
const colors = {
red: '#f56c6c',
blue: '#409eff',
neutral: '#909399'
}
return colors[side as keyof typeof colors] || '#909399'
}
const getEntityIcon = (type: string) => {
const icons = {
fighter: '✈️',
drone: '🚁',
missile: '🚀',
ship: '🚢',
tank: '🎖️',
radar: '📡'
}
return icons[type as keyof typeof icons] || '⚫'
}
const onEntityClick = (entity: SituationEntity) => {
emit('entityClick', entity)
}
const onSegmentHover = (segment: any, index: number) => {
emit('segmentHover', segment, index)
}
const onSegmentLeave = () => {
emit('segmentLeave')
}
</script>
<style lang="scss" scoped>
@use 'sass:color';
@use '@/styles/variables.scss' as *;
.military-chart {
background: $bg-secondary;
border-radius: $border-radius-large;
border: 1px solid $border-light;
overflow: hidden;
&--radar {
.radar-svg {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
}
}
.military-chart__header {
padding: $spacing-base $spacing-lg;
border-bottom: 1px solid $border-light;
background: rgba(245, 245, 245, 0.5);
}
.military-chart__title {
margin: 0 0 $spacing-xs 0;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.military-chart__subtitle {
font-size: $font-size-sm;
color: $text-secondary;
}
.military-chart__container {
padding: $spacing-lg;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
//
.bar-chart__bars {
width: 100%;
max-width: 400px;
}
.bar-chart__item {
margin-bottom: $spacing-base;
&:last-child {
margin-bottom: 0;
}
}
.bar-chart__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-primary;
margin-bottom: $spacing-xs;
}
.bar-chart__bar-container {
position: relative;
height: 24px;
background: $border-light;
border-radius: $border-radius-base;
overflow: hidden;
}
.bar-chart__bar {
height: 100%;
border-radius: $border-radius-base;
transition: width $transition-base ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: $spacing-sm;
&--red {
background: linear-gradient(90deg, $red-side-dark, $red-side);
}
&--blue {
background: linear-gradient(90deg, $blue-side-dark, $blue-side);
}
&--neutral {
background: linear-gradient(90deg, color.adjust($neutral-side, $lightness: -10%), $neutral-side);
}
}
.bar-chart__value {
color: white;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
//
.donut-segment {
cursor: pointer;
transition: opacity $transition-base ease;
&:hover {
opacity: 0.8;
}
}
//
.situation-chart {
width: 100%;
height: 300px;
position: relative;
}
.situation-chart__grid {
position: relative;
width: 100%;
height: 100%;
background:
linear-gradient(to right, $border-light 1px, transparent 1px),
linear-gradient(to bottom, $border-light 1px, transparent 1px);
background-size: 20px 20px;
border: 2px solid $border-base;
border-radius: $border-radius-base;
overflow: hidden;
}
.situation-chart__entity {
position: absolute;
cursor: pointer;
transition: all $transition-base ease;
&:hover {
transform: scale(1.2) !important;
z-index: 10;
}
&--red {
color: $red-side;
}
&--blue {
color: $blue-side;
}
&--neutral {
color: $neutral-side;
}
}
.situation-chart__entity-icon {
font-size: 20px;
text-align: center;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
}
.situation-chart__entity-label {
font-size: $font-size-xs;
text-align: center;
margin-top: 2px;
background: rgba(255, 255, 255, 0.9);
border-radius: $border-radius-small;
padding: 1px 4px;
white-space: nowrap;
}
//
.military-chart__legend {
padding: $spacing-base $spacing-lg;
border-top: 1px solid $border-light;
background: rgba(245, 245, 245, 0.3);
display: flex;
flex-wrap: wrap;
gap: $spacing-base;
}
.military-chart__legend-item {
display: flex;
align-items: center;
gap: $spacing-xs;
}
.military-chart__legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.military-chart__legend-label {
font-size: $font-size-sm;
color: $text-primary;
}
.military-chart__legend-value {
font-size: $font-size-sm;
color: $text-secondary;
font-weight: $font-weight-medium;
}
//
@media (max-width: $breakpoint-sm) {
.military-chart__container {
padding: $spacing-base;
}
.military-chart__header {
padding: $spacing-base;
}
.military-chart__legend {
padding: $spacing-base;
gap: $spacing-sm;
}
.situation-chart {
height: 250px;
}
.situation-chart__entity-icon {
font-size: 16px;
}
}
</style>

View File

@ -0,0 +1,338 @@
<template>
<div
class="military-card"
:class="[
`military-card--${variant}`,
`military-card--${side}`,
{
'military-card--hoverable': hoverable,
'military-card--selected': selected,
'military-card--disabled': disabled,
'military-card--loading': loading
}
]"
@click="handleClick"
>
<!-- 卡片头部 -->
<div v-if="title || $slots.header" class="military-card__header">
<div class="military-card__title">
<div class="military-card__icon" v-if="icon">
{{ icon }}
</div>
<span class="military-card__title-text">{{ title }}</span>
<div class="military-card__badge" v-if="badge">
<el-tag :type="badgeType" size="small">{{ badge }}</el-tag>
</div>
</div>
<div class="military-card__extra" v-if="$slots.extra">
<slot name="extra"></slot>
</div>
<slot name="header"></slot>
</div>
<!-- 卡片内容 -->
<div class="military-card__body">
<div v-if="loading" class="military-card__loading">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>{{ loadingText }}</span>
</div>
<slot v-else></slot>
</div>
<!-- 卡片底部 -->
<div v-if="$slots.footer" class="military-card__footer">
<slot name="footer"></slot>
</div>
<!-- 状态指示器 -->
<div v-if="status" class="military-card__status" :class="`status--${status}`">
<div class="status-dot"></div>
</div>
<!-- 选中指示器 -->
<div v-if="selected" class="military-card__selected-indicator">
<el-icon>
<Check />
</el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Loading, Check } from '@element-plus/icons-vue'
interface Props {
title?: string
icon?: string
badge?: string
badgeType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
variant?: 'default' | 'outlined' | 'shadow' | 'glass'
side?: 'red' | 'blue' | 'neutral'
status?: 'active' | 'inactive' | 'destroyed' | 'damaged' | 'unknown'
hoverable?: boolean
selected?: boolean
disabled?: boolean
loading?: boolean
loadingText?: string
clickable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
side: 'neutral',
badgeType: 'primary',
loadingText: '加载中...',
hoverable: false,
selected: false,
disabled: false,
loading: false,
clickable: true
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (event: MouseEvent) => {
if (!props.disabled && props.clickable) {
emit('click', event)
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.military-card {
position: relative;
background: $bg-secondary;
border-radius: $border-radius-large;
border: 1px solid $border-light;
transition: all $transition-base ease;
overflow: hidden;
&--hoverable {
cursor: pointer;
&:hover {
border-color: $primary-color;
box-shadow: $shadow-base;
transform: translateY(-2px);
}
}
&--selected {
border-color: $primary-color;
box-shadow: 0 0 0 2px rgba(30, 60, 114, 0.2);
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
transform: none;
box-shadow: none;
}
}
&--loading {
pointer-events: none;
}
//
&--outlined {
background: transparent;
border: 2px solid $border-base;
}
&--shadow {
box-shadow: $shadow-heavy;
border: none;
}
&--glass {
background: $bg-panel;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
//
&--red {
border-left: 4px solid $red-side;
.military-card__header {
background: linear-gradient(90deg, rgba(245, 108, 108, 0.1) 0%, transparent 100%);
}
}
&--blue {
border-left: 4px solid $blue-side;
.military-card__header {
background: linear-gradient(90deg, rgba(64, 158, 255, 0.1) 0%, transparent 100%);
}
}
&--neutral {
border-left: 4px solid $neutral-side;
}
}
.military-card__header {
padding: $spacing-base $spacing-lg;
border-bottom: 1px solid $border-light;
background: rgba(245, 245, 245, 0.5);
display: flex;
align-items: center;
justify-content: space-between;
}
.military-card__title {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
}
.military-card__icon {
font-size: $font-size-lg;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.military-card__title-text {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.military-card__badge {
margin-left: auto;
}
.military-card__extra {
margin-left: $spacing-base;
}
.military-card__body {
padding: $spacing-lg;
min-height: 60px;
position: relative;
}
.military-card__loading {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
color: $text-secondary;
font-size: $font-size-sm;
height: 60px;
}
.military-card__footer {
padding: $spacing-base $spacing-lg;
border-top: 1px solid $border-light;
background: rgba(245, 245, 245, 0.3);
}
.military-card__status {
position: absolute;
top: $spacing-base;
right: $spacing-base;
width: 12px;
height: 12px;
.status-dot {
width: 100%;
height: 100%;
border-radius: 50%;
animation: pulse 2s infinite;
}
&.status--active .status-dot {
background: $active-color;
box-shadow: 0 0 8px rgba(0, 208, 132, 0.4);
}
&.status--inactive .status-dot {
background: $inactive-color;
}
&.status--destroyed .status-dot {
background: $destroyed-color;
animation: none;
}
&.status--damaged .status-dot {
background: $damaged-color;
animation: blink 1s infinite;
}
&.status--unknown .status-dot {
background: $text-placeholder;
animation: none;
}
}
.military-card__selected-indicator {
position: absolute;
top: -1px;
right: -1px;
width: 24px;
height: 24px;
background: $primary-color;
border-radius: 0 $border-radius-large 0 $border-radius-large;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0.3;
}
}
//
@media (max-width: $breakpoint-sm) {
.military-card__header {
padding: $spacing-sm $spacing-base;
flex-direction: column;
align-items: flex-start;
gap: $spacing-sm;
}
.military-card__body {
padding: $spacing-base;
}
.military-card__footer {
padding: $spacing-sm $spacing-base;
}
}
</style>

View File

@ -0,0 +1,118 @@
<!-- EntityConfigPanel.vue - 实体配置面板组件 -->
<template>
<div class="entity-config-panel">
<div class="config-header">
<h3>实体配置</h3>
<el-button-group size="small">
<el-button @click="resetConfig">重置</el-button>
<el-button type="primary" @click="saveConfig">保存</el-button>
</el-button-group>
</div>
<div class="config-content">
<el-form :model="config" label-width="80px" size="small">
<el-form-item label="实体类型">
<el-select v-model="config.type" placeholder="选择实体类型">
<el-option label="战斗机" value="fighter" />
<el-option label="无人机" value="drone" />
<el-option label="导弹" value="missile" />
<el-option label="军舰" value="ship" />
<el-option label="坦克" value="tank" />
<el-option label="雷达" value="radar" />
</el-select>
</el-form-item>
<el-form-item label="所属阵营">
<el-radio-group v-model="config.side">
<el-radio-button label="red">红方</el-radio-button>
<el-radio-button label="blue">蓝方</el-radio-button>
<el-radio-button label="neutral">中立</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="移动速度">
<el-slider v-model="config.speed" :min="0" :max="1000" show-input />
</el-form-item>
<el-form-item label="武器系统">
<el-checkbox-group v-model="config.weapons">
<el-checkbox label="主炮">主炮</el-checkbox>
<el-checkbox label="导弹">导弹</el-checkbox>
<el-checkbox label="鱼雷">鱼雷</el-checkbox>
<el-checkbox label="防空">防空</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
//
const config = reactive({
type: 'fighter',
side: 'red',
speed: 300,
weapons: ['主炮', '导弹']
})
//
const emit = defineEmits<{
save: [config: typeof config]
reset: []
}>()
//
function resetConfig() {
config.type = 'fighter'
config.side = 'red'
config.speed = 300
config.weapons = ['主炮', '导弹']
emit('reset')
}
function saveConfig() {
emit('save', { ...config })
}
</script>
<style scoped lang="scss">
@use 'sass:color';
@import '@/styles/variables.scss';
.entity-config-panel {
padding: $spacing-base;
background: $bg-secondary;
border-radius: $border-radius-base;
border: 1px solid $border-light;
}
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-base;
padding-bottom: $spacing-sm;
border-bottom: 1px solid $border-light;
h3 {
margin: 0;
color: $text-primary;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
}
.config-content {
:deep(.el-form-item) {
margin-bottom: $spacing-sm;
}
:deep(.el-form-item__label) {
color: $text-regular;
font-weight: $font-weight-medium;
}
}
</style>

View File

@ -0,0 +1,141 @@
<!-- WeaponStatsPanel.vue - 武器统计面板组件 -->
<template>
<div class="weapon-stats-panel">
<div class="stats-header">
<h3>武器统计</h3>
<el-tag :type="statusType" size="small">{{ statusText }}</el-tag>
</div>
<div class="stats-content">
<div class="weapon-item" v-for="weapon in weapons" :key="weapon.id">
<div class="weapon-info">
<span class="weapon-name">{{ weapon.name }}</span>
<span class="weapon-count">{{ weapon.count }}</span>
</div>
<div class="weapon-bar">
<div
class="weapon-progress"
:style="{ width: `${weapon.readiness}%` }"
:class="`weapon-progress--${weapon.type}`"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Weapon {
id: string
name: string
count: number
readiness: number
type: 'primary' | 'secondary' | 'defense'
}
const props = defineProps<{
weapons?: Weapon[]
status?: 'ready' | 'loading' | 'disabled'
}>()
const weapons = computed(() => props.weapons || [
{ id: '1', name: '主炮', count: 2, readiness: 100, type: 'primary' as const },
{ id: '2', name: '导弹', count: 8, readiness: 75, type: 'secondary' as const },
{ id: '3', name: '防空炮', count: 4, readiness: 90, type: 'defense' as const }
])
const statusType = computed(() => {
switch (props.status) {
case 'ready': return 'success'
case 'loading': return 'warning'
case 'disabled': return 'danger'
default: return 'info'
}
})
const statusText = computed(() => {
switch (props.status) {
case 'ready': return '就绪'
case 'loading': return '装填中'
case 'disabled': return '停用'
default: return '未知'
}
})
</script>
<style scoped lang="scss">
@use 'sass:color';
@import '@/styles/variables.scss';
.weapon-stats-panel {
padding: $spacing-base;
background: $bg-secondary;
border-radius: $border-radius-base;
border: 1px solid $border-light;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-base;
h3 {
margin: 0;
color: $text-primary;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
}
.weapon-item {
margin-bottom: $spacing-sm;
&:last-child {
margin-bottom: 0;
}
}
.weapon-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-xs;
}
.weapon-name {
color: $text-primary;
font-weight: $font-weight-medium;
}
.weapon-count {
color: $text-secondary;
font-size: $font-size-sm;
}
.weapon-bar {
height: 6px;
background: $border-light;
border-radius: $border-radius-small;
overflow: hidden;
}
.weapon-progress {
height: 100%;
transition: width $transition-base;
&--primary {
background: linear-gradient(90deg, $danger-color, color.adjust($danger-color, $lightness: 10%));
}
&--secondary {
background: linear-gradient(90deg, $warning-color, color.adjust($warning-color, $lightness: 10%));
}
&--defense {
background: linear-gradient(90deg, $success-color, color.adjust($success-color, $lightness: 10%));
}
}
</style>

View File

@ -0,0 +1,10 @@
// 实体管理组件导出
// ==========================================
// 暂时导出空对象,待后续添加实体相关组件
// export { default as EntityCard } from './EntityCard.vue'
// export { default as EntityList } from './EntityList.vue'
// export { default as EntityForm } from './EntityForm.vue'
// 临时空导出,避免编译错误
export {}

76
src/components/index.ts Normal file
View File

@ -0,0 +1,76 @@
// 红蓝攻防对抗系统 - 组件库导出
// ==========================================
// 通用组件
import MilitaryCard from './common/MilitaryCard.vue'
// UI组件
import StatusIndicator from './ui/StatusIndicator.vue'
import ThreatLevel from './ui/ThreatLevel.vue'
import SideIndicator from './ui/SideIndicator.vue'
import ActionButton from './ui/ActionButton.vue'
import DropdownProgressBar from './ui/DropdownProgressBar.vue'
// 图表组件
import MilitaryChart from './charts/MilitaryChart.vue'
// 实体管理组件
export * from './entity'
// 导出组件
export {
MilitaryCard,
StatusIndicator,
ThreatLevel,
SideIndicator,
ActionButton,
DropdownProgressBar,
MilitaryChart
}
// Cesium组件 (待实现)
// export { default as CesiumViewer } from './cesium/CesiumViewer.vue'
// export { default as EntityRenderer } from './cesium/EntityRenderer.vue'
// export { default as FlightPathRenderer } from './cesium/FlightPathRenderer.vue'
// 组件类型定义
export interface ComponentLibrary {
// 通用组件
MilitaryCard: typeof MilitaryCard
// UI组件
StatusIndicator: typeof StatusIndicator
ThreatLevel: typeof ThreatLevel
SideIndicator: typeof SideIndicator
ActionButton: typeof ActionButton
DropdownProgressBar: typeof DropdownProgressBar
// 图表组件
MilitaryChart: typeof MilitaryChart
}
// 组件注册函数
import type { App } from 'vue'
export function registerComponents(app: App) {
// 注册通用组件
app.component('MilitaryCard', MilitaryCard)
// 注册UI组件
app.component('StatusIndicator', StatusIndicator)
app.component('ThreatLevel', ThreatLevel)
app.component('SideIndicator', SideIndicator)
app.component('ActionButton', ActionButton)
app.component('DropdownProgressBar', DropdownProgressBar)
// 注册图表组件
app.component('MilitaryChart', MilitaryChart)
}
// 默认导出
export default {
install(app: App) {
registerComponents(app)
}
}

View File

@ -0,0 +1,385 @@
<template>
<el-button
:type="computedType"
:size="size"
:loading="loading"
:disabled="disabled"
:plain="plain"
:round="round"
:circle="circle"
:icon="icon"
class="action-button"
:class="[
`action-button--${actionType}`,
`action-button--${variant}`,
{
'action-button--urgent': urgent,
'action-button--pulsing': pulsing,
'action-button--glowing': glowing
}
]"
@click="handleClick"
>
<template v-if="!circle">
<el-icon v-if="icon && !loading" class="action-button__icon">
<component :is="icon" />
</el-icon>
<span class="action-button__text">
<slot>{{ text }}</slot>
</span>
<el-badge
v-if="badge"
:value="badge"
:type="badgeType"
class="action-button__badge"
/>
</template>
<template v-else>
<el-icon v-if="icon">
<component :is="icon" />
</el-icon>
</template>
</el-button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
actionType?: 'attack' | 'defend' | 'move' | 'scan' | 'repair' | 'abort' | 'confirm' | 'emergency'
variant?: 'default' | 'military' | 'tactical' | 'stealth'
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default'
size?: 'large' | 'default' | 'small'
text?: string
icon?: any
loading?: boolean
disabled?: boolean
plain?: boolean
round?: boolean
circle?: boolean
urgent?: boolean
pulsing?: boolean
glowing?: boolean
badge?: string | number
badgeType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
}
const props = withDefaults(defineProps<Props>(), {
actionType: 'confirm',
variant: 'default',
size: 'default',
loading: false,
disabled: false,
plain: false,
round: false,
circle: false,
urgent: false,
pulsing: false,
glowing: false,
badgeType: 'danger'
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const computedType = computed(() => {
if (props.type) return props.type
//
const typeMap = {
attack: 'danger',
defend: 'primary',
move: 'info',
scan: 'warning',
repair: 'success',
abort: 'danger',
confirm: 'primary',
emergency: 'danger'
}
return typeMap[props.actionType] || 'default'
})
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style lang="scss" scoped>
@use 'sass:color';
@use '@/styles/variables.scss' as *;
.action-button {
position: relative;
font-weight: $font-weight-semibold;
transition: all $transition-base ease;
border-width: 2px;
//
&--military {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
border-style: solid;
&:hover {
transform: translateY(-1px);
box-shadow: $shadow-base;
}
}
&--tactical {
background: linear-gradient(135deg, rgba(0,0,0,0.1) 0%, transparent 50%, rgba(255,255,255,0.1) 100%);
border: 2px solid;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -2px;
left: -100%;
width: 100%;
height: calc(100% + 4px);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transition: left 0.6s ease;
}
&:hover::before {
left: 100%;
}
}
&--stealth {
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #00ff41;
backdrop-filter: blur(5px);
&:hover {
background: rgba(0, 0, 0, 0.8);
box-shadow: 0 0 20px rgba(0, 255, 65, 0.3);
}
}
}
//
.action-button--attack {
&.action-button--military {
background: linear-gradient(135deg, $red-side-dark 0%, $red-side 100%);
border-color: $red-side-light;
&:hover {
box-shadow: 0 0 20px rgba(245, 108, 108, 0.4);
}
}
}
.action-button--defend {
&.action-button--military {
background: linear-gradient(135deg, $blue-side-dark 0%, $blue-side 100%);
border-color: $blue-side-light;
&:hover {
box-shadow: 0 0 20px rgba(64, 158, 255, 0.4);
}
}
}
.action-button--emergency {
&.action-button--military {
background: linear-gradient(135deg, $alert-red 0%, $danger-color 100%);
border-color: color.adjust($alert-red, $lightness: 10%);
animation: emergency-pulse 1s ease-in-out infinite;
&:hover {
animation: none;
box-shadow: 0 0 25px rgba(255, 23, 68, 0.6);
}
}
}
.action-button--scan {
&.action-button--military {
background: linear-gradient(135deg, $radar-green 0%, $success-color 100%);
border-color: color.adjust($radar-green, $lightness: 10%);
&:hover {
box-shadow: 0 0 20px rgba(0, 255, 65, 0.4);
}
}
}
//
.action-button--urgent {
animation: urgent-flash 0.8s ease-in-out infinite;
}
.action-button--pulsing {
animation: pulse 2s ease-in-out infinite;
}
.action-button--glowing {
&::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border-radius: inherit;
background: inherit;
filter: blur(8px);
opacity: 0.7;
z-index: -1;
animation: glow 2s ease-in-out infinite alternate;
}
}
.action-button__icon {
margin-right: $spacing-xs;
font-size: 16px;
}
.action-button__text {
flex: 1;
}
.action-button__badge {
margin-left: $spacing-xs;
}
//
@keyframes emergency-pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.9;
}
}
@keyframes urgent-flash {
0%, 50%, 100% {
opacity: 1;
}
25%, 75% {
opacity: 0.7;
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
@keyframes glow {
0% {
opacity: 0.5;
}
100% {
opacity: 0.8;
}
}
//
@media (max-width: $breakpoint-sm) {
.action-button {
&--military {
letter-spacing: 0.2px;
}
.action-button__icon {
margin-right: $spacing-xs;
font-size: 14px;
}
}
}
//
.action-button.is-disabled {
&.action-button--military {
opacity: 0.5;
&:hover {
transform: none;
box-shadow: none;
}
}
&.action-button--urgent,
&.action-button--pulsing {
animation: none;
}
}
//
.action-button.is-loading {
&.action-button--urgent,
&.action-button--pulsing {
animation: none;
}
}
//
.action-button--military {
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0,0,0,0.3), transparent);
}
}
//
.action-button.is-circle {
&.action-button--military {
border-width: 3px;
&:hover {
transform: rotate(5deg) scale(1.1);
}
}
&.action-button--emergency {
animation: emergency-spin 2s linear infinite;
}
}
@keyframes emergency-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,617 @@
<template>
<div
class="dropdown-progress-bar"
:class="[
`dropdown-progress-bar--${status}`,
`dropdown-progress-bar--${side}`,
{
'dropdown-progress-bar--expanded': isExpanded,
'dropdown-progress-bar--pulsing': pulsing,
'dropdown-progress-bar--glow': showGlow,
'dropdown-progress-bar--animated': animated
}
]"
>
<!-- 主要进度条区域 -->
<div class="dropdown-progress-bar__main" @click="toggleExpanded">
<div class="dropdown-progress-bar__header">
<div class="dropdown-progress-bar__title">
<span class="dropdown-progress-bar__title-text">{{ title }}</span>
<el-tag
v-if="badge"
:type="badgeType"
size="small"
class="dropdown-progress-bar__badge"
>
{{ badge }}
</el-tag>
</div>
<div class="dropdown-progress-bar__controls">
<span class="dropdown-progress-bar__percentage">{{ progress }}%</span>
<el-icon
class="dropdown-progress-bar__expand-icon"
:class="{ 'expanded': isExpanded }"
>
<ArrowDown />
</el-icon>
</div>
</div>
<!-- 进度条 -->
<div class="dropdown-progress-bar__progress">
<div
class="dropdown-progress-bar__progress-track"
:class="`dropdown-progress-bar__progress-track--${status}`"
>
<div
class="dropdown-progress-bar__progress-fill"
:style="{ width: `${progress}%` }"
>
<div v-if="animated" class="dropdown-progress-bar__progress-shine"></div>
</div>
<!-- 里程碑标记 -->
<div
v-for="milestone in milestones"
:key="milestone.value"
class="dropdown-progress-bar__milestone"
:class="{
'dropdown-progress-bar__milestone--completed': milestone.value <= progress,
'dropdown-progress-bar__milestone--current': Math.abs(milestone.value - progress) < 5
}"
:style="{ left: `${milestone.value}%` }"
:title="milestone.label"
>
<div class="dropdown-progress-bar__milestone-dot"></div>
</div>
</div>
</div>
</div>
<!-- 展开的详细信息 -->
<div
v-if="isExpanded"
class="dropdown-progress-bar__details"
>
<div class="dropdown-progress-bar__info">
<div v-if="description" class="dropdown-progress-bar__description">
{{ description }}
</div>
<div class="dropdown-progress-bar__metadata">
<div v-if="startTime" class="dropdown-progress-bar__time-info">
<span class="dropdown-progress-bar__time-label">开始时间:</span>
<span class="dropdown-progress-bar__time-value">{{ formatTime(startTime) }}</span>
</div>
<div v-if="estimatedCompletion" class="dropdown-progress-bar__time-info">
<span class="dropdown-progress-bar__time-label">预计完成:</span>
<span class="dropdown-progress-bar__time-value">{{ formatTime(estimatedCompletion) }}</span>
</div>
<div v-if="remainingTime" class="dropdown-progress-bar__time-info">
<span class="dropdown-progress-bar__time-label">剩余时间:</span>
<span class="dropdown-progress-bar__time-value">{{ remainingTime }}</span>
</div>
</div>
<!-- 里程碑列表 -->
<div v-if="milestones && milestones.length" class="dropdown-progress-bar__milestones">
<h5 class="dropdown-progress-bar__milestones-title">执行节点</h5>
<div class="dropdown-progress-bar__milestones-list">
<div
v-for="milestone in milestones"
:key="milestone.value"
class="dropdown-progress-bar__milestone-item"
:class="{
'dropdown-progress-bar__milestone-item--completed': milestone.value <= progress,
'dropdown-progress-bar__milestone-item--current': Math.abs(milestone.value - progress) < 5
}"
>
<div class="dropdown-progress-bar__milestone-indicator">
<el-icon v-if="milestone.value <= progress">
<Check />
</el-icon>
<div v-else class="dropdown-progress-bar__milestone-pending"></div>
</div>
<span class="dropdown-progress-bar__milestone-label">{{ milestone.label }}</span>
<span class="dropdown-progress-bar__milestone-value">{{ milestone.value }}%</span>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="$slots.actions" class="dropdown-progress-bar__actions">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowDown, Check } from '@element-plus/icons-vue'
interface Milestone {
value: number
label: string
}
interface Props {
title: string
progress: number
status?: 'active' | 'warning' | 'error' | 'completed' | 'paused'
side?: 'red' | 'blue' | 'neutral'
badge?: string
badgeType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
description?: string
startTime?: Date
estimatedCompletion?: Date
milestones?: Milestone[]
animated?: boolean
pulsing?: boolean
showGlow?: boolean
expandable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
status: 'active',
side: 'neutral',
badgeType: 'primary',
animated: false,
pulsing: false,
showGlow: false,
expandable: true
})
const isExpanded = ref(false)
const remainingTime = computed(() => {
if (!props.estimatedCompletion) return null
const now = new Date()
const remaining = props.estimatedCompletion.getTime() - now.getTime()
if (remaining <= 0) return '已完成'
const hours = Math.floor(remaining / (1000 * 60 * 60))
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else {
return `${minutes}分钟`
}
})
const toggleExpanded = () => {
if (props.expandable) {
isExpanded.value = !isExpanded.value
}
}
const formatTime = (date: Date) => {
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style lang="scss" scoped>
@use 'sass:color';
@use '@/styles/variables.scss' as *;
.dropdown-progress-bar {
background: $bg-secondary;
border-radius: $border-radius-large;
border: 1px solid $border-light;
overflow: hidden;
transition: all $transition-base ease;
&--pulsing {
animation: pulse 2s ease-in-out infinite;
}
&--glow {
box-shadow: 0 0 20px rgba(64, 158, 255, 0.3);
}
//
&--active {
border-left: 4px solid $success-color;
}
&--warning {
border-left: 4px solid $warning-color;
}
&--error {
border-left: 4px solid $danger-color;
}
&--completed {
border-left: 4px solid $success-color;
background: rgba(103, 194, 58, 0.05);
}
&--paused {
border-left: 4px solid $info-color;
}
//
&--red {
&.dropdown-progress-bar--active {
border-left-color: $red-side;
}
}
&--blue {
&.dropdown-progress-bar--active {
border-left-color: $blue-side;
}
}
}
.dropdown-progress-bar__main {
padding: $spacing-base $spacing-lg;
cursor: pointer;
transition: background-color $transition-base ease;
&:hover {
background-color: rgba(245, 245, 245, 0.5);
}
}
.dropdown-progress-bar__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
}
.dropdown-progress-bar__title {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
}
.dropdown-progress-bar__title-text {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.dropdown-progress-bar__badge {
flex-shrink: 0;
}
.dropdown-progress-bar__controls {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.dropdown-progress-bar__percentage {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-primary;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.dropdown-progress-bar__expand-icon {
transition: transform $transition-base ease;
color: $text-secondary;
&.expanded {
transform: rotate(180deg);
}
}
//
.dropdown-progress-bar__progress {
margin-top: $spacing-sm;
}
.dropdown-progress-bar__progress-track {
position: relative;
height: 8px;
background: $border-light;
border-radius: $border-radius-base;
overflow: hidden;
&--active {
background: rgba(64, 158, 255, 0.1);
}
&--warning {
background: rgba(230, 162, 60, 0.1);
}
&--error {
background: rgba(245, 108, 108, 0.1);
}
&--completed {
background: rgba(103, 194, 58, 0.1);
}
}
.dropdown-progress-bar__progress-fill {
position: relative;
height: 100%;
border-radius: $border-radius-base;
transition: width $transition-slow ease;
overflow: hidden;
.dropdown-progress-bar--active & {
background: linear-gradient(90deg, $success-color, color.adjust($success-color, $lightness: 10%));
}
.dropdown-progress-bar--warning & {
background: linear-gradient(90deg, $warning-color, color.adjust($warning-color, $lightness: 10%));
}
.dropdown-progress-bar--error & {
background: linear-gradient(90deg, $danger-color, color.adjust($danger-color, $lightness: 10%));
}
.dropdown-progress-bar--completed & {
background: linear-gradient(90deg, $success-color, color.adjust($success-color, $lightness: 10%));
}
.dropdown-progress-bar--red.dropdown-progress-bar--active & {
background: linear-gradient(90deg, $red-side, color.adjust($red-side, $lightness: 10%));
}
.dropdown-progress-bar--blue.dropdown-progress-bar--active & {
background: linear-gradient(90deg, $blue-side, color.adjust($blue-side, $lightness: 10%));
}
}
.dropdown-progress-bar__progress-shine {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: shine 2s ease-in-out infinite;
}
//
.dropdown-progress-bar__milestone {
position: absolute;
top: -2px;
transform: translateX(-50%);
z-index: 2;
}
.dropdown-progress-bar__milestone-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: $border-base;
border: 2px solid white;
transition: all $transition-base ease;
.dropdown-progress-bar__milestone--completed & {
background: $success-color;
box-shadow: 0 0 8px rgba(103, 194, 58, 0.4);
}
.dropdown-progress-bar__milestone--current & {
background: $primary-color;
box-shadow: 0 0 12px rgba(64, 158, 255, 0.6);
animation: milestone-pulse 1.5s ease-in-out infinite;
}
}
//
.dropdown-progress-bar__details {
border-top: 1px solid $border-light;
background: rgba(245, 245, 245, 0.3);
animation: slideDown $transition-base ease;
}
.dropdown-progress-bar__info {
padding: $spacing-lg;
}
.dropdown-progress-bar__description {
font-size: $font-size-base;
color: $text-regular;
line-height: 1.5;
margin-bottom: $spacing-base;
}
.dropdown-progress-bar__metadata {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-base;
margin-bottom: $spacing-lg;
}
.dropdown-progress-bar__time-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-sm;
background: white;
border-radius: $border-radius-base;
border: 1px solid $border-light;
}
.dropdown-progress-bar__time-label {
font-size: $font-size-sm;
color: $text-secondary;
font-weight: $font-weight-medium;
}
.dropdown-progress-bar__time-value {
font-size: $font-size-sm;
color: $text-primary;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
//
.dropdown-progress-bar__milestones {
margin-top: $spacing-lg;
}
.dropdown-progress-bar__milestones-title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-primary;
margin: 0 0 $spacing-base 0;
}
.dropdown-progress-bar__milestones-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.dropdown-progress-bar__milestone-item {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-sm;
background: white;
border-radius: $border-radius-base;
border: 1px solid $border-light;
transition: all $transition-base ease;
&--completed {
background: rgba(103, 194, 58, 0.05);
border-color: rgba(103, 194, 58, 0.2);
}
&--current {
background: rgba(64, 158, 255, 0.05);
border-color: rgba(64, 158, 255, 0.3);
box-shadow: 0 0 8px rgba(64, 158, 255, 0.2);
}
}
.dropdown-progress-bar__milestone-indicator {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $border-light;
color: white;
font-size: 12px;
.dropdown-progress-bar__milestone-item--completed & {
background: $success-color;
}
.dropdown-progress-bar__milestone-item--current & {
background: $primary-color;
}
}
.dropdown-progress-bar__milestone-pending {
width: 8px;
height: 8px;
border-radius: 50%;
background: $text-placeholder;
}
.dropdown-progress-bar__milestone-label {
flex: 1;
font-size: $font-size-sm;
color: $text-primary;
}
.dropdown-progress-bar__milestone-value {
font-size: $font-size-xs;
color: $text-secondary;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
//
.dropdown-progress-bar__actions {
padding: $spacing-base $spacing-lg;
border-top: 1px solid $border-light;
background: rgba(255, 255, 255, 0.5);
display: flex;
gap: $spacing-sm;
justify-content: flex-end;
}
//
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.02);
opacity: 0.95;
}
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
@keyframes milestone-pulse {
0%, 100% {
transform: translateX(-50%) scale(1);
}
50% {
transform: translateX(-50%) scale(1.2);
}
}
@keyframes slideDown {
0% {
opacity: 0;
max-height: 0;
}
100% {
opacity: 1;
max-height: 500px;
}
}
//
@media (max-width: $breakpoint-sm) {
.dropdown-progress-bar__main {
padding: $spacing-base;
}
.dropdown-progress-bar__info {
padding: $spacing-base;
}
.dropdown-progress-bar__metadata {
grid-template-columns: 1fr;
}
.dropdown-progress-bar__header {
flex-direction: column;
align-items: flex-start;
gap: $spacing-sm;
}
.dropdown-progress-bar__controls {
align-self: flex-end;
}
}
</style>

View File

@ -0,0 +1,344 @@
<template>
<div
class="side-indicator"
:class="[
`side-indicator--${side}`,
`side-indicator--${variant}`,
`side-indicator--${size}`,
{
'side-indicator--with-text': showText,
'side-indicator--bordered': bordered
}
]"
>
<div class="side-indicator__marker">
<div class="side-indicator__symbol">
{{ getSymbol(side) }}
</div>
</div>
<span v-if="showText" class="side-indicator__text">
{{ text || getSideText(side) }}
</span>
<el-tag
v-if="showTag"
:type="getTagType(side)"
:size="tagSize"
:effect="tagEffect"
>
{{ tagText || getSideText(side) }}
</el-tag>
</div>
</template>
<script setup lang="ts">
interface Props {
side: 'red' | 'blue' | 'neutral'
variant?: 'default' | 'solid' | 'outline' | 'minimal'
size?: 'small' | 'medium' | 'large'
showText?: boolean
showTag?: boolean
text?: string
tagText?: string
tagSize?: 'small' | 'default' | 'large'
tagEffect?: 'light' | 'dark' | 'plain'
bordered?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'medium',
showText: false,
showTag: false,
tagSize: 'small',
tagEffect: 'light',
bordered: false
})
const getSymbol = (side: string) => {
const symbols = {
red: '◆', //
blue: '▲', //
neutral: '●' //
}
return symbols[side as keyof typeof symbols] || '?'
}
const getSideText = (side: string) => {
const texts = {
red: '红方',
blue: '蓝方',
neutral: '中立'
}
return texts[side as keyof typeof texts] || '未知'
}
const getTagType = (side: string) => {
const types = {
red: 'danger',
blue: 'primary',
neutral: 'info'
}
return types[side as keyof typeof types] || 'info'
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.side-indicator {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
&--small {
gap: $spacing-xs;
}
&--large {
gap: $spacing-base;
}
}
.side-indicator__marker {
display: flex;
align-items: center;
justify-content: center;
border-radius: $border-radius-base;
transition: all $transition-base ease;
//
.side-indicator--small & {
width: 20px;
height: 20px;
}
.side-indicator--medium & {
width: 28px;
height: 28px;
}
.side-indicator--large & {
width: 36px;
height: 36px;
}
//
.side-indicator--default & {
background: rgba(255, 255, 255, 0.9);
border: 2px solid;
}
.side-indicator--solid & {
border: none;
}
.side-indicator--outline & {
background: transparent;
border: 2px solid;
}
.side-indicator--minimal & {
background: transparent;
border: none;
}
.side-indicator--bordered & {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
}
}
.side-indicator__symbol {
font-weight: bold;
font-size: 14px;
.side-indicator--small & {
font-size: 12px;
}
.side-indicator--large & {
font-size: 18px;
}
}
//
.side-indicator--red {
.side-indicator__marker {
border-color: $red-side;
.side-indicator--default &,
.side-indicator--outline & {
color: $red-side;
}
.side-indicator--solid & {
background: $red-side;
color: white;
}
.side-indicator--minimal & {
color: $red-side;
}
}
.side-indicator__text {
color: $red-side;
font-weight: $font-weight-semibold;
}
}
//
.side-indicator--blue {
.side-indicator__marker {
border-color: $blue-side;
.side-indicator--default &,
.side-indicator--outline & {
color: $blue-side;
}
.side-indicator--solid & {
background: $blue-side;
color: white;
}
.side-indicator--minimal & {
color: $blue-side;
}
}
.side-indicator__text {
color: $blue-side;
font-weight: $font-weight-semibold;
}
}
//
.side-indicator--neutral {
.side-indicator__marker {
border-color: $neutral-side;
.side-indicator--default &,
.side-indicator--outline & {
color: $neutral-side;
}
.side-indicator--solid & {
background: $neutral-side;
color: white;
}
.side-indicator--minimal & {
color: $neutral-side;
}
}
.side-indicator__text {
color: $neutral-side;
font-weight: $font-weight-semibold;
}
}
.side-indicator__text {
font-size: $font-size-sm;
.side-indicator--small & {
font-size: $font-size-xs;
}
.side-indicator--large & {
font-size: $font-size-base;
}
}
//
.side-indicator__marker {
&:hover {
transform: scale(1.1);
.side-indicator--red & {
box-shadow: 0 0 12px rgba(245, 108, 108, 0.4);
}
.side-indicator--blue & {
box-shadow: 0 0 12px rgba(64, 158, 255, 0.4);
}
.side-indicator--neutral & {
box-shadow: 0 0 12px rgba(144, 147, 153, 0.4);
}
}
}
//
.side-indicator__marker {
animation: side-glow 3s ease-in-out infinite;
}
@keyframes side-glow {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
//
.side-indicator--red .side-indicator__marker {
position: relative;
&::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border-radius: inherit;
background: linear-gradient(45deg, $red-side, transparent, $red-side);
z-index: -1;
opacity: 0;
transition: opacity $transition-base ease;
}
&:hover::before {
opacity: 0.3;
animation: rotate 2s linear infinite;
}
}
.side-indicator--blue .side-indicator__marker {
position: relative;
&::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border-radius: inherit;
background: linear-gradient(45deg, $blue-side, transparent, $blue-side);
z-index: -1;
opacity: 0;
transition: opacity $transition-base ease;
}
&:hover::before {
opacity: 0.3;
animation: rotate 2s linear infinite;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<div
class="status-indicator"
:class="[
`status-indicator--${type}`,
`status-indicator--${size}`,
{
'status-indicator--with-text': showText,
'status-indicator--pulsing': pulsing
}
]"
:title="tooltip"
>
<div class="status-indicator__dot">
<div class="status-indicator__inner-dot"></div>
</div>
<span v-if="showText" class="status-indicator__text">
{{ text || getStatusText(type) }}
</span>
<el-icon v-if="icon" class="status-indicator__icon">
<component :is="icon" />
</el-icon>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
type: 'active' | 'inactive' | 'destroyed' | 'damaged' | 'unknown' | 'online' | 'offline' | 'warning' | 'critical'
size?: 'small' | 'medium' | 'large'
showText?: boolean
text?: string
icon?: any
pulsing?: boolean
tooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
showText: false,
pulsing: false
})
const getStatusText = (type: string) => {
const statusTexts: Record<string, string> = {
active: '活跃',
inactive: '非活跃',
destroyed: '已摧毁',
damaged: '已损坏',
unknown: '未知',
online: '在线',
offline: '离线',
warning: '警告',
critical: '严重'
}
return statusTexts[type] || type
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.status-indicator {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
&--with-text {
gap: $spacing-sm;
}
}
.status-indicator__dot {
position: relative;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
//
.status-indicator--small & {
width: 8px;
height: 8px;
}
.status-indicator--medium & {
width: 12px;
height: 12px;
}
.status-indicator--large & {
width: 16px;
height: 16px;
}
}
.status-indicator__inner-dot {
width: 100%;
height: 100%;
border-radius: 50%;
transition: all $transition-base ease;
//
.status-indicator--active & {
background: $active-color;
box-shadow: 0 0 8px rgba(0, 208, 132, 0.4);
}
.status-indicator--inactive & {
background: $inactive-color;
}
.status-indicator--destroyed & {
background: $destroyed-color;
box-shadow: 0 0 8px rgba(255, 71, 87, 0.4);
}
.status-indicator--damaged & {
background: $damaged-color;
box-shadow: 0 0 8px rgba(255, 165, 2, 0.4);
}
.status-indicator--unknown & {
background: $text-placeholder;
}
.status-indicator--online & {
background: $success-color;
box-shadow: 0 0 8px rgba(103, 194, 58, 0.4);
}
.status-indicator--offline & {
background: $danger-color;
}
.status-indicator--warning & {
background: $warning-color;
box-shadow: 0 0 8px rgba(230, 162, 60, 0.4);
}
.status-indicator--critical & {
background: $danger-color;
box-shadow: 0 0 12px rgba(245, 108, 108, 0.6);
}
}
.status-indicator__text {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-primary;
.status-indicator--small & {
font-size: $font-size-xs;
}
.status-indicator--large & {
font-size: $font-size-base;
}
}
.status-indicator__icon {
font-size: 14px;
.status-indicator--small & {
font-size: 12px;
}
.status-indicator--large & {
font-size: 16px;
}
}
//
.status-indicator--pulsing {
.status-indicator__inner-dot {
animation: pulse 2s ease-in-out infinite;
}
&.status-indicator--damaged .status-indicator__inner-dot {
animation: blink 1s ease-in-out infinite;
}
&.status-indicator--critical .status-indicator__inner-dot {
animation: critical-pulse 0.8s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0.3;
}
}
@keyframes critical-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 12px rgba(245, 108, 108, 0.6);
}
50% {
opacity: 0.8;
transform: scale(1.3);
box-shadow: 0 0 20px rgba(245, 108, 108, 0.8);
}
}
</style>

View File

@ -0,0 +1,358 @@
<template>
<div
class="threat-level"
:class="[
`threat-level--${level}`,
`threat-level--${variant}`,
{
'threat-level--animated': animated,
'threat-level--with-icon': showIcon
}
]"
>
<div class="threat-level__indicator">
<el-icon v-if="showIcon" class="threat-level__icon">
<component :is="getIcon(level)" />
</el-icon>
<div class="threat-level__bars">
<div
v-for="(bar, index) in threatBars"
:key="index"
class="threat-level__bar"
:class="{ 'threat-level__bar--active': index < getBarCount(level) }"
:style="{ animationDelay: `${index * 0.1}s` }"
></div>
</div>
</div>
<div v-if="showText" class="threat-level__text">
<span class="threat-level__label">{{ text || getLevelText(level) }}</span>
<span v-if="showValue && value !== undefined" class="threat-level__value">
{{ value }}{{ unit }}
</span>
</div>
<div v-if="showProgress" class="threat-level__progress">
<div
class="threat-level__progress-bar"
:style="{ width: `${getProgressPercentage()}%` }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
InfoFilled,
WarningFilled,
Remove,
CircleCloseFilled,
QuestionFilled
} from '@element-plus/icons-vue'
interface Props {
level: 'low' | 'medium' | 'high' | 'critical' | 'unknown'
variant?: 'default' | 'compact' | 'detailed'
showIcon?: boolean
showText?: boolean
showProgress?: boolean
showValue?: boolean
text?: string
value?: number
unit?: string
animated?: boolean
maxBars?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
showIcon: true,
showText: true,
showProgress: false,
showValue: false,
unit: '',
animated: true,
maxBars: 5
})
const threatBars = computed(() => Array(props.maxBars).fill(0))
const getIcon = (level: string) => {
const icons = {
low: InfoFilled,
medium: WarningFilled,
high: Remove,
critical: CircleCloseFilled,
unknown: QuestionFilled
}
return icons[level as keyof typeof icons] || QuestionFilled
}
const getLevelText = (level: string) => {
const texts = {
low: '低威胁',
medium: '中等威胁',
high: '高威胁',
critical: '严重威胁',
unknown: '未知威胁'
}
return texts[level as keyof typeof texts] || '未知'
}
const getBarCount = (level: string) => {
const counts = {
low: 1,
medium: 2,
high: 4,
critical: 5,
unknown: 0
}
return counts[level as keyof typeof counts] || 0
}
const getProgressPercentage = () => {
if (props.value === undefined) {
return getBarCount(props.level) * 20
}
return Math.min(100, Math.max(0, props.value))
}
</script>
<style lang="scss" scoped>
@use 'sass:color';
@use '@/styles/variables.scss' as *;
.threat-level {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-sm;
border-radius: $border-radius-base;
background: rgba(255, 255, 255, 0.8);
border: 1px solid $border-light;
&--compact {
padding: $spacing-xs;
gap: $spacing-xs;
}
&--detailed {
flex-direction: column;
align-items: flex-start;
padding: $spacing-base;
gap: $spacing-sm;
}
}
.threat-level__indicator {
display: flex;
align-items: center;
gap: $spacing-xs;
}
.threat-level__icon {
font-size: 16px;
.threat-level--low & {
color: $threat-low;
}
.threat-level--medium & {
color: $threat-medium;
}
.threat-level--high & {
color: $threat-high;
}
.threat-level--critical & {
color: $threat-critical;
}
.threat-level--unknown & {
color: $text-placeholder;
}
}
.threat-level__bars {
display: flex;
gap: 2px;
align-items: flex-end;
}
.threat-level__bar {
width: 3px;
height: 12px;
background: $border-base;
border-radius: 1px;
transition: all $transition-base ease;
&--active {
.threat-level--low & {
background: $threat-low;
height: 8px;
}
.threat-level--medium & {
background: $threat-medium;
height: 10px;
}
.threat-level--high & {
background: $threat-high;
height: 14px;
}
.threat-level--critical & {
background: $threat-critical;
height: 16px;
}
}
.threat-level--animated &--active {
animation: bar-grow 0.8s ease-out;
}
}
.threat-level__text {
display: flex;
flex-direction: column;
gap: 2px;
.threat-level--compact & {
flex-direction: row;
gap: $spacing-xs;
align-items: center;
}
}
.threat-level__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
.threat-level--low & {
color: $threat-low;
}
.threat-level--medium & {
color: $threat-medium;
}
.threat-level--high & {
color: $threat-high;
}
.threat-level--critical & {
color: $threat-critical;
}
.threat-level--unknown & {
color: $text-placeholder;
}
}
.threat-level__value {
font-size: $font-size-xs;
color: $text-secondary;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.threat-level__progress {
width: 60px;
height: 4px;
background: $border-light;
border-radius: 2px;
overflow: hidden;
.threat-level--detailed & {
width: 100%;
margin-top: $spacing-xs;
}
}
.threat-level__progress-bar {
height: 100%;
border-radius: 2px;
transition: all $transition-base ease;
.threat-level--low & {
background: linear-gradient(90deg, $threat-low, color.adjust($threat-low, $lightness: 10%));
}
.threat-level--medium & {
background: linear-gradient(90deg, $threat-medium, color.adjust($threat-medium, $lightness: 10%));
}
.threat-level--high & {
background: linear-gradient(90deg, $threat-high, color.adjust($threat-high, $lightness: 10%));
}
.threat-level--critical & {
background: linear-gradient(90deg, $threat-critical, color.adjust($threat-critical, $lightness: 10%));
animation: critical-glow 1s ease-in-out infinite alternate;
}
.threat-level--unknown & {
background: $text-placeholder;
}
}
//
@keyframes bar-grow {
0% {
height: 4px;
opacity: 0.5;
}
100% {
opacity: 1;
}
}
@keyframes critical-glow {
0% {
box-shadow: 0 0 5px rgba(211, 47, 47, 0.5);
}
100% {
box-shadow: 0 0 15px rgba(211, 47, 47, 0.8);
}
}
//
.threat-level--low {
border-color: rgba(76, 175, 80, 0.3);
background: rgba(76, 175, 80, 0.05);
}
.threat-level--medium {
border-color: rgba(255, 152, 0, 0.3);
background: rgba(255, 152, 0, 0.05);
}
.threat-level--high {
border-color: rgba(244, 67, 54, 0.3);
background: rgba(244, 67, 54, 0.05);
}
.threat-level--critical {
border-color: rgba(211, 47, 47, 0.5);
background: rgba(211, 47, 47, 0.1);
animation: critical-border 2s ease-in-out infinite;
}
.threat-level--unknown {
border-color: $border-base;
background: rgba(158, 158, 158, 0.05);
}
@keyframes critical-border {
0%, 100% {
border-color: rgba(211, 47, 47, 0.5);
}
50% {
border-color: rgba(211, 47, 47, 0.8);
}
}
</style>

35
src/main.ts Normal file
View File

@ -0,0 +1,35 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { registerComponents } from '@/components'
// 创建Vue应用实例
const app = createApp(App)
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
// 注册自定义组件
registerComponents(app)
// 挂载应用
app.mount('#app')
// 添加全局错误处理
app.config.errorHandler = (error, instance, info) => {
console.error('应用错误:', error)
console.error('错误信息:', info)
console.error('组件实例:', instance)
}

57
src/router/index.ts Normal file
View File

@ -0,0 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '主控面板' }
},
{
path: '/entities',
name: 'EntityManagement',
component: () => import('@/views/EntityManagement.vue'),
meta: { title: '实体管理' }
},
{
path: '/missions',
name: 'MissionPlanning',
component: () => import('@/views/MissionPlanning.vue'),
meta: { title: '任务规划' }
},
{
path: '/situation',
name: 'SituationAwareness',
component: () => import('@/views/SituationAwareness.vue'),
meta: { title: '态势感知' }
},
{
path: '/assessment',
name: 'Assessment',
component: () => import('@/views/Assessment.vue'),
meta: { title: '战况评估' }
},
{
path: '/components',
name: 'ComponentDemo',
component: () => import('@/views/ComponentDemo.vue'),
meta: { title: '组件展示' }
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 路由守卫
router.beforeEach((to, _from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - ${import.meta.env.VITE_APP_TITLE || '红蓝攻防对抗系统'}`
}
next()
})
export default router

267
src/stores/entities.ts Normal file
View File

@ -0,0 +1,267 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Entity, EntityType, Side, EntityStatus } from '@/types'
import { entitiesApi } from '@/api/entities'
import { wsClient } from '@/api/websocket'
export const useEntitiesStore = defineStore('entities', () => {
// 状态
const entities = ref<Map<string, Entity>>(new Map())
const loading = ref(false)
const error = ref<string | null>(null)
const selectedEntityId = ref<string | null>(null)
// 计算属性
const entitiesList = computed(() => Array.from(entities.value.values()))
const selectedEntity = computed(() =>
selectedEntityId.value ? entities.value.get(selectedEntityId.value) : null
)
const entitiesByType = computed(() => {
const result: Record<EntityType, Entity[]> = {
fighter: [],
drone: [],
missile: [],
ship: [],
tank: [],
radar: []
}
entitiesList.value.forEach(entity => {
if (result[entity.type as EntityType]) {
result[entity.type as EntityType].push(entity)
}
})
return result
})
const entitiesBySide = computed(() => {
const result: Record<Side, Entity[]> = {
red: [],
blue: [],
neutral: []
}
entitiesList.value.forEach(entity => {
result[entity.side as Side].push(entity)
})
return result
})
const activeEntities = computed(() =>
entitiesList.value.filter(entity => entity.status === 'active')
)
const entityStats = computed(() => ({
total: entitiesList.value.length,
active: entitiesList.value.filter(e => e.status === 'active').length,
damaged: entitiesList.value.filter(e => e.status === 'damaged').length,
destroyed: entitiesList.value.filter(e => e.status === 'destroyed').length,
bySide: {
red: entitiesBySide.value.red.length,
blue: entitiesBySide.value.blue.length,
neutral: entitiesBySide.value.neutral.length
},
byType: Object.fromEntries(
Object.entries(entitiesByType.value).map(([type, entities]) => [type, entities.length])
)
}))
// 方法
async function fetchEntities() {
loading.value = true
error.value = null
try {
const response = await entitiesApi.getEntities()
if (response.success && response.data) {
// 清空现有实体
entities.value.clear()
// 添加新实体
response.data.items.forEach(entity => {
entities.value.set(entity.id, entity)
})
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取实体列表失败'
console.error('Failed to fetch entities:', err)
} finally {
loading.value = false
}
}
async function createEntity(entityData: {
name: string
type: string
side: string
position: { lng: number; lat: number; alt: number }
attributes?: Record<string, any>
}) {
loading.value = true
error.value = null
try {
const response = await entitiesApi.createEntity(entityData)
if (response.success && response.data) {
entities.value.set(response.data.id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '创建实体失败'
console.error('Failed to create entity:', err)
throw err
} finally {
loading.value = false
}
}
async function updateEntity(id: string, updates: Partial<Entity>) {
loading.value = true
error.value = null
try {
const response = await entitiesApi.updateEntity(id, updates)
if (response.success && response.data) {
entities.value.set(id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '更新实体失败'
console.error('Failed to update entity:', err)
throw err
} finally {
loading.value = false
}
}
async function deleteEntity(id: string) {
loading.value = true
error.value = null
try {
const response = await entitiesApi.deleteEntity(id)
if (response.success) {
entities.value.delete(id)
if (selectedEntityId.value === id) {
selectedEntityId.value = null
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : '删除实体失败'
console.error('Failed to delete entity:', err)
throw err
} finally {
loading.value = false
}
}
function selectEntity(id: string | null) {
selectedEntityId.value = id
}
function addEntity(entity: Entity) {
entities.value.set(entity.id, entity)
}
function removeEntity(id: string) {
entities.value.delete(id)
if (selectedEntityId.value === id) {
selectedEntityId.value = null
}
}
function updateEntityPosition(id: string, position: { lng: number; lat: number; alt: number }) {
const entity = entities.value.get(id)
if (entity) {
entity.position = position
entity.updatedAt = new Date().toISOString()
entities.value.set(id, { ...entity })
}
}
function updateEntityStatus(id: string, status: EntityStatus) {
const entity = entities.value.get(id)
if (entity) {
entity.status = status
entity.updatedAt = new Date().toISOString()
entities.value.set(id, { ...entity })
}
}
function getEntityById(id: string) {
return entities.value.get(id)
}
function getEntitiesByType(type: EntityType) {
return entitiesList.value.filter(entity => entity.type === type)
}
function getEntitiesBySide(side: Side) {
return entitiesList.value.filter(entity => entity.side === side)
}
function clearEntities() {
entities.value.clear()
selectedEntityId.value = null
}
// WebSocket事件监听
function setupWebSocketListeners() {
wsClient.on('entity_update', (entity: Entity) => {
entities.value.set(entity.id, entity)
})
wsClient.on('entity_created', (entity: Entity) => {
entities.value.set(entity.id, entity)
})
wsClient.on('entity_deleted', (data: { id: string }) => {
entities.value.delete(data.id)
if (selectedEntityId.value === data.id) {
selectedEntityId.value = null
}
})
}
// 初始化
function initialize() {
setupWebSocketListeners()
fetchEntities()
}
return {
// 状态
entities,
loading,
error,
selectedEntityId,
// 计算属性
entitiesList,
selectedEntity,
entitiesByType,
entitiesBySide,
activeEntities,
entityStats,
// 方法
fetchEntities,
createEntity,
updateEntity,
deleteEntity,
selectEntity,
addEntity,
removeEntity,
updateEntityPosition,
updateEntityStatus,
getEntityById,
getEntitiesByType,
getEntitiesBySide,
clearEntities,
initialize
}
})

427
src/stores/missions.ts Normal file
View File

@ -0,0 +1,427 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Mission, MissionStatus, Side, FlightPath } from '@/types'
import { missionsApi, flightPathsApi } from '@/api/missions'
import { wsClient } from '@/api/websocket'
export const useMissionsStore = defineStore('missions', () => {
// 状态
const missions = ref<Map<string, Mission>>(new Map())
const flightPaths = ref<Map<string, FlightPath>>(new Map())
const currentMissionId = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// 计算属性
const missionsList = computed(() => Array.from(missions.value.values()))
const currentMission = computed(() =>
currentMissionId.value ? missions.value.get(currentMissionId.value) : null
)
const activeMissions = computed(() =>
missionsList.value.filter(mission => mission.status === 'executing')
)
const missionsByStatus = computed(() => {
const result: Record<MissionStatus, Mission[]> = {
planning: [],
ready: [],
executing: [],
completed: [],
failed: [],
cancelled: []
}
missionsList.value.forEach(mission => {
result[mission.status as MissionStatus].push(mission)
})
return result
})
const missionsBySide = computed(() => {
const result: Record<Side, Mission[]> = {
red: [],
blue: [],
neutral: []
}
missionsList.value.forEach(mission => {
result[mission.side as Side].push(mission)
})
return result
})
const missionStats = computed(() => ({
total: missionsList.value.length,
active: activeMissions.value.length,
planning: missionsByStatus.value.planning.length,
completed: missionsByStatus.value.completed.length,
failed: missionsByStatus.value.failed.length,
bySide: {
red: missionsBySide.value.red.length,
blue: missionsBySide.value.blue.length,
neutral: missionsBySide.value.neutral.length
}
}))
const currentMissionFlightPaths = computed(() => {
if (!currentMissionId.value) return []
return Array.from(flightPaths.value.values()).filter(path =>
currentMission.value?.config.participants.includes(path.entityId)
)
})
// 方法
async function fetchMissions() {
loading.value = true
error.value = null
try {
const response = await missionsApi.getMissions()
if (response.success && response.data) {
missions.value.clear()
response.data.items.forEach(mission => {
missions.value.set(mission.id, mission)
})
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取任务列表失败'
console.error('Failed to fetch missions:', err)
} finally {
loading.value = false
}
}
async function createMission(missionData: {
name: string
side: string
description?: string
startTime: string
endTime?: string
config: {
objectives: string[]
constraints: string[]
rules: Record<string, any>
participants: string[]
}
}) {
loading.value = true
error.value = null
try {
const response = await missionsApi.createMission(missionData)
if (response.success && response.data) {
missions.value.set(response.data.id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '创建任务失败'
console.error('Failed to create mission:', err)
throw err
} finally {
loading.value = false
}
}
async function updateMission(id: string, updates: Partial<Mission>) {
loading.value = true
error.value = null
try {
const response = await missionsApi.updateMission(id, updates)
if (response.success && response.data) {
missions.value.set(id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '更新任务失败'
console.error('Failed to update mission:', err)
throw err
} finally {
loading.value = false
}
}
async function deleteMission(id: string) {
loading.value = true
error.value = null
try {
const response = await missionsApi.deleteMission(id)
if (response.success) {
missions.value.delete(id)
if (currentMissionId.value === id) {
currentMissionId.value = null
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : '删除任务失败'
console.error('Failed to delete mission:', err)
throw err
} finally {
loading.value = false
}
}
async function startMission(id: string) {
loading.value = true
error.value = null
try {
const response = await missionsApi.startMission(id)
if (response.success && response.data) {
missions.value.set(id, response.data)
currentMissionId.value = id
// 加入WebSocket房间
wsClient.joinMission(id)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '启动任务失败'
console.error('Failed to start mission:', err)
throw err
} finally {
loading.value = false
}
}
async function pauseMission(id: string) {
loading.value = true
error.value = null
try {
const response = await missionsApi.pauseMission(id)
if (response.success && response.data) {
missions.value.set(id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '暂停任务失败'
console.error('Failed to pause mission:', err)
throw err
} finally {
loading.value = false
}
}
async function endMission(id: string) {
loading.value = true
error.value = null
try {
const response = await missionsApi.endMission(id)
if (response.success && response.data) {
missions.value.set(id, response.data)
// 离开WebSocket房间
wsClient.leaveMission(id)
if (currentMissionId.value === id) {
currentMissionId.value = null
}
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '结束任务失败'
console.error('Failed to end mission:', err)
throw err
} finally {
loading.value = false
}
}
async function addEntityToMission(missionId: string, entityId: string) {
try {
await missionsApi.addEntityToMission(missionId, entityId)
const mission = missions.value.get(missionId)
if (mission) {
mission.config.participants.push(entityId)
missions.value.set(missionId, { ...mission })
}
} catch (err) {
error.value = err instanceof Error ? err.message : '添加实体到任务失败'
console.error('Failed to add entity to mission:', err)
throw err
}
}
async function removeEntityFromMission(missionId: string, entityId: string) {
try {
await missionsApi.removeEntityFromMission(missionId, entityId)
const mission = missions.value.get(missionId)
if (mission) {
mission.config.participants = mission.config.participants.filter(id => id !== entityId)
missions.value.set(missionId, { ...mission })
}
} catch (err) {
error.value = err instanceof Error ? err.message : '从任务中移除实体失败'
console.error('Failed to remove entity from mission:', err)
throw err
}
}
// 航线管理
async function createFlightPath(entityId: string, waypoints: any[]) {
loading.value = true
error.value = null
try {
const response = await flightPathsApi.createFlightPath({ entityId, waypoints })
if (response.success && response.data) {
flightPaths.value.set(response.data.id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '创建航线失败'
console.error('Failed to create flight path:', err)
throw err
} finally {
loading.value = false
}
}
async function updateFlightPath(id: string, updates: Partial<FlightPath>) {
loading.value = true
error.value = null
try {
const response = await flightPathsApi.updateFlightPath(id, updates)
if (response.success && response.data) {
flightPaths.value.set(id, response.data)
return response.data
}
} catch (err) {
error.value = err instanceof Error ? err.message : '更新航线失败'
console.error('Failed to update flight path:', err)
throw err
} finally {
loading.value = false
}
}
async function deleteFlightPath(id: string) {
loading.value = true
error.value = null
try {
const response = await flightPathsApi.deleteFlightPath(id)
if (response.success) {
flightPaths.value.delete(id)
}
} catch (err) {
error.value = err instanceof Error ? err.message : '删除航线失败'
console.error('Failed to delete flight path:', err)
throw err
} finally {
loading.value = false
}
}
async function fetchMissionFlightPaths(missionId: string) {
try {
const response = await flightPathsApi.getMissionFlightPaths(missionId)
if (response.success && response.data) {
response.data.forEach(path => {
flightPaths.value.set(path.id, path)
})
}
} catch (err) {
console.error('Failed to fetch mission flight paths:', err)
}
}
function setCurrentMission(missionId: string | null) {
if (currentMissionId.value && currentMissionId.value !== missionId) {
wsClient.leaveMission(currentMissionId.value)
}
currentMissionId.value = missionId
if (missionId) {
wsClient.joinMission(missionId)
fetchMissionFlightPaths(missionId)
}
}
function getMissionById(id: string) {
return missions.value.get(id)
}
function getMissionsByStatus(status: MissionStatus) {
return missionsList.value.filter(mission => mission.status === status)
}
function getMissionsBySide(side: Side) {
return missionsList.value.filter(mission => mission.side === side)
}
function clearMissions() {
missions.value.clear()
flightPaths.value.clear()
currentMissionId.value = null
}
// WebSocket事件监听
function setupWebSocketListeners() {
wsClient.on('mission_status', (data: { id: string; status: string; timestamp: string }) => {
const mission = missions.value.get(data.id)
if (mission) {
mission.status = data.status as MissionStatus
mission.updatedAt = data.timestamp
missions.value.set(data.id, { ...mission })
}
})
}
// 初始化
function initialize() {
setupWebSocketListeners()
fetchMissions()
}
return {
// 状态
missions,
flightPaths,
currentMissionId,
loading,
error,
// 计算属性
missionsList,
currentMission,
activeMissions,
missionsByStatus,
missionsBySide,
missionStats,
currentMissionFlightPaths,
// 方法
fetchMissions,
createMission,
updateMission,
deleteMission,
startMission,
pauseMission,
endMission,
addEntityToMission,
removeEntityFromMission,
createFlightPath,
updateFlightPath,
deleteFlightPath,
fetchMissionFlightPaths,
setCurrentMission,
getMissionById,
getMissionsByStatus,
getMissionsBySide,
clearMissions,
initialize
}
})

128
src/styles/variables.scss Normal file
View File

@ -0,0 +1,128 @@
// 红蓝攻防对抗系统 - 设计系统变量
// ===========================================
// 主题色彩
$primary-color: #1e3c72;
$primary-light: #2a5298;
$primary-dark: #1a2a4a;
// 阵营色彩
$red-side: #f56c6c;
$red-side-light: #f78989;
$red-side-dark: #d03050;
$blue-side: #409eff;
$blue-side-light: #66b1ff;
$blue-side-dark: #337ecc;
$neutral-side: #909399;
// 状态色彩
$success-color: #67c23a;
$warning-color: #e6a23c;
$danger-color: #f56c6c;
$info-color: #909399;
// 功能色彩
$active-color: #00d084;
$inactive-color: #a8abb2;
$destroyed-color: #ff4757;
$damaged-color: #ffa502;
// 背景色彩
$bg-primary: #f5f5f5;
$bg-secondary: #ffffff;
$bg-dark: #1a1a1a;
$bg-panel: rgba(255, 255, 255, 0.95);
$bg-overlay: rgba(0, 0, 0, 0.6);
// 文字色彩
$text-primary: #303133;
$text-regular: #606266;
$text-secondary: #909399;
$text-placeholder: #c0c4cc;
$text-white: #ffffff;
// 边框色彩
$border-light: #ebeef5;
$border-base: #dcdfe6;
$border-dark: #d4d7de;
$border-darker: #cdd0d6;
// 阴影
$shadow-light: 0 2px 4px rgba(0, 0, 0, 0.06);
$shadow-base: 0 2px 8px rgba(0, 0, 0, 0.1);
$shadow-heavy: 0 4px 16px rgba(0, 0, 0, 0.15);
$shadow-panel: 0 8px 32px rgba(0, 0, 0, 0.2);
// 圆角
$border-radius-small: 4px;
$border-radius-base: 6px;
$border-radius-large: 8px;
$border-radius-round: 20px;
// 间距
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-base: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$spacing-xxl: 48px;
// 字体大小
$font-size-xs: 12px;
$font-size-sm: 13px;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-xxl: 20px;
$font-size-title: 24px;
$font-size-header: 28px;
// 字体权重
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// Z-index层级
$z-index-dropdown: 1000;
$z-index-modal: 2000;
$z-index-message: 3000;
$z-index-tooltip: 4000;
$z-index-loading: 9000;
// 过渡动画
$transition-fast: 0.2s;
$transition-base: 0.3s;
$transition-slow: 0.5s;
// 断点
$breakpoint-xs: 480px;
$breakpoint-sm: 768px;
$breakpoint-md: 992px;
$breakpoint-lg: 1200px;
$breakpoint-xl: 1920px;
// 军事主题特殊色彩
$radar-green: #00ff41;
$sonar-blue: #00bfff;
$alert-red: #ff1744;
$target-orange: #ff9800;
$stealth-purple: #9c27b0;
// 威胁等级色彩
$threat-low: #4caf50;
$threat-medium: #ff9800;
$threat-high: #f44336;
$threat-critical: #d32f2f;
// 任务状态色彩
$mission-planning: #2196f3;
$mission-ready: #ff9800;
$mission-executing: #4caf50;
$mission-completed: #607d8b;
$mission-failed: #f44336;
$mission-cancelled: #9e9e9e;

213
src/types/index.ts Normal file
View File

@ -0,0 +1,213 @@
// 基础类型定义
export interface Position {
lng: number
lat: number
alt: number
}
export interface Entity {
id: string
name: string
type: EntityType
side: Side
position: Position
status: EntityStatus
attributes: Record<string, any>
createdAt: string
updatedAt?: string
}
export type EntityType = 'fighter' | 'drone' | 'missile' | 'ship' | 'tank' | 'radar'
export type Side = 'red' | 'blue' | 'neutral'
export type EntityStatus = 'active' | 'damaged' | 'destroyed' | 'inactive'
export interface Mission {
id: string
name: string
side: Side
status: MissionStatus
startTime: string
endTime?: string
description?: string
config: MissionConfig
createdAt: string
updatedAt?: string
}
export type MissionStatus = 'planning' | 'ready' | 'executing' | 'completed' | 'failed' | 'cancelled'
export interface MissionConfig {
objectives: string[]
constraints: string[]
rules: Record<string, any>
participants: string[] // entity IDs
}
export interface FlightPath {
id: string
entityId: string
waypoints: Waypoint[]
createdAt: string
updatedAt?: string
}
export interface Waypoint {
id: string
position: Position
altitude: number
speed?: number
heading?: number
arrivalTime?: string
actions?: WaypointAction[]
}
export interface WaypointAction {
type: 'attack' | 'scan' | 'hover' | 'land' | 'takeoff'
target?: string
duration?: number
parameters?: Record<string, any>
}
export interface SituationData {
timestamp: string
entities: Entity[]
threats: Threat[]
events: SituationEvent[]
}
export interface Threat {
id: string
type: ThreatType
source: string
target: string
level: ThreatLevel
position: Position
radius: number
detectedAt: string
}
export type ThreatType = 'missile' | 'radar' | 'aircraft' | 'sam' | 'unknown'
export type ThreatLevel = 'low' | 'medium' | 'high' | 'critical'
export interface SituationEvent {
id: string
type: EventType
timestamp: string
description: string
data: Record<string, any>
severity: EventSeverity
}
export type EventType = 'entity_created' | 'entity_updated' | 'entity_destroyed' |
'mission_started' | 'mission_completed' | 'threat_detected' |
'target_engaged' | 'communication_lost'
export type EventSeverity = 'info' | 'warning' | 'error' | 'critical'
export interface Assessment {
id: string
missionId: string
timestamp: string
summary: AssessmentSummary
details: AssessmentDetails
recommendations: string[]
}
export interface AssessmentSummary {
redSide: SideAssessment
blueSide: SideAssessment
overallStatus: 'red_advantage' | 'blue_advantage' | 'balanced' | 'inconclusive'
}
export interface SideAssessment {
entitiesTotal: number
entitiesActive: number
entitiesDamaged: number
entitiesDestroyed: number
objectivesCompleted: number
objectivesTotal: number
effectiveness: number // 0-100
}
export interface AssessmentDetails {
casualties: CasualtyReport[]
objectives: ObjectiveStatus[]
timeline: TimelineEvent[]
statistics: Record<string, number>
}
export interface CasualtyReport {
entityId: string
entityName: string
entityType: EntityType
side: Side
status: EntityStatus
timestamp: string
cause?: string
}
export interface ObjectiveStatus {
id: string
description: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
progress: number // 0-100
assignedTo: string[] // entity IDs
}
export interface TimelineEvent {
timestamp: string
event: string
participants: string[]
impact: 'positive' | 'negative' | 'neutral'
}
// API响应类型
export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
error?: string
timestamp: string
}
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// WebSocket消息类型
export interface WebSocketMessage {
type: string
data: any
timestamp: string
missionId?: string
}
// 用户界面状态类型
export interface UIState {
selectedEntity?: string
selectedMission?: string
currentView: string
sidebarCollapsed: boolean
filters: Record<string, any>
}
// Cesium相关类型
export interface CesiumConfig {
terrainProvider?: string
imageryProvider?: string
enableLighting?: boolean
enableFog?: boolean
showGroundAtmosphere?: boolean
}
export interface EntityDisplayOptions {
showLabel?: boolean
showTrail?: boolean
trailLength?: number
modelScale?: number
labelScale?: number
}

17
src/views/Assessment.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<div class="assessment">
<h2>战况评估</h2>
<p>战况评估功能正在开发中...</p>
</div>
</template>
<script setup lang="ts">
//
</script>
<style scoped>
.assessment {
padding: 20px;
}
</style>

448
src/views/ComponentDemo.vue Normal file
View File

@ -0,0 +1,448 @@
<template>
<div class="component-demo">
<div class="demo-header">
<h2>🎨 组件库展示</h2>
<p>红蓝攻防对抗系统前端组件库演示</p>
</div>
<!-- MilitaryCard 演示 -->
<div class="demo-section">
<h3>军事卡片组件 (MilitaryCard)</h3>
<div class="demo-grid">
<MilitaryCard
title="红方战斗机"
icon="✈️"
side="red"
status="active"
badge="5架"
badge-type="danger"
hoverable
@click="onCardClick('红方战斗机')"
>
<p>当前有5架战斗机处于活跃状态正在执行空中巡逻任务</p>
<template #footer>
<div style="display: flex; gap: 8px;">
<ActionButton action-type="scan" size="small" text="扫描" />
<ActionButton action-type="attack" size="small" text="攻击" />
</div>
</template>
</MilitaryCard>
<MilitaryCard
title="蓝方无人机"
icon="🚁"
side="blue"
status="damaged"
variant="glass"
hoverable
>
<p>无人机群正在进行侦察任务部分设备受损</p>
<template #footer>
<ActionButton action-type="repair" size="small" text="维修" />
</template>
</MilitaryCard>
<MilitaryCard
title="中立雷达站"
icon="📡"
side="neutral"
status="inactive"
loading
loading-text="正在连接..."
/>
</div>
</div>
<!-- StatusIndicator 演示 -->
<div class="demo-section">
<h3>状态指示器 (StatusIndicator)</h3>
<div class="demo-status-grid">
<div class="status-item">
<StatusIndicator type="active" show-text pulsing />
</div>
<div class="status-item">
<StatusIndicator type="damaged" show-text size="large" pulsing />
</div>
<div class="status-item">
<StatusIndicator type="destroyed" show-text />
</div>
<div class="status-item">
<StatusIndicator type="online" show-text size="small" />
</div>
<div class="status-item">
<StatusIndicator type="critical" show-text pulsing />
</div>
</div>
</div>
<!-- ThreatLevel 演示 -->
<div class="demo-section">
<h3>威胁等级 (ThreatLevel)</h3>
<div class="demo-threat-grid">
<ThreatLevel level="low" show-progress animated />
<ThreatLevel level="medium" variant="compact" />
<ThreatLevel level="high" show-value :value="75" unit="%" />
<ThreatLevel level="critical" variant="detailed" animated />
</div>
</div>
<!-- SideIndicator 演示 -->
<div class="demo-section">
<h3>阵营指示器 (SideIndicator)</h3>
<div class="demo-side-grid">
<SideIndicator side="red" show-text variant="solid" />
<SideIndicator side="blue" show-text variant="outline" />
<SideIndicator side="neutral" show-tag variant="minimal" />
<SideIndicator side="red" variant="solid" size="large" />
</div>
</div>
<!-- ActionButton 演示 -->
<div class="demo-section">
<h3>军事行动按钮 (ActionButton)</h3>
<div class="demo-button-grid">
<ActionButton
action-type="attack"
variant="military"
text="发起攻击"
:icon="Star"
/>
<ActionButton
action-type="defend"
variant="tactical"
text="防御模式"
:icon="CircleCheck"
/>
<ActionButton
action-type="emergency"
variant="military"
text="紧急撤退"
urgent
:icon="Warning"
/>
<ActionButton
action-type="scan"
variant="stealth"
text="隐蔽扫描"
glowing
:icon="Search"
/>
</div>
</div>
<!-- DropdownProgressBar 演示 -->
<div class="demo-section">
<h3>下拉进度条 (DropdownProgressBar)</h3>
<div class="demo-progress-grid">
<DropdownProgressBar
title="任务执行进度"
:progress="75"
status="active"
side="red"
badge="进行中"
badge-type="primary"
description="红方空中打击任务正在执行中,目标区域已锁定。"
:start-time="new Date('2024-01-20 09:00:00')"
:estimated-completion="new Date('2024-01-20 15:30:00')"
:milestones="[
{ value: 25, label: '起飞完成' },
{ value: 50, label: '到达目标区域' },
{ value: 75, label: '开始攻击' },
{ value: 100, label: '任务完成' }
]"
animated
show-glow
>
<template #actions>
<ActionButton action-type="scan" size="small" text="监控" />
<ActionButton action-type="emergency" size="small" text="中断" />
</template>
</DropdownProgressBar>
<DropdownProgressBar
title="设备维护状态"
:progress="45"
status="warning"
side="blue"
badge="维护中"
badge-type="warning"
description="蓝方雷达系统正在进行定期维护预计还需2小时完成。"
:start-time="new Date('2024-01-20 14:00:00')"
:estimated-completion="new Date('2024-01-20 18:00:00')"
:milestones="[
{ value: 20, label: '系统诊断' },
{ value: 60, label: '硬件检修' },
{ value: 90, label: '系统测试' }
]"
pulsing
>
<template #actions>
<ActionButton action-type="repair" size="small" text="加速" />
</template>
</DropdownProgressBar>
<DropdownProgressBar
title="数据传输"
:progress="100"
status="completed"
side="neutral"
badge="已完成"
badge-type="success"
description="侦察数据传输已完成,所有文件已安全上传到指挥中心。"
:start-time="new Date('2024-01-20 10:30:00')"
:estimated-completion="new Date('2024-01-20 12:30:00')"
:milestones="[
{ value: 25, label: '连接建立' },
{ value: 50, label: '开始传输' },
{ value: 75, label: '验证数据' },
{ value: 100, label: '传输完成' }
]"
/>
<DropdownProgressBar
title="系统启动"
:progress="30"
status="error"
side="red"
badge="错误"
badge-type="danger"
description="导弹系统启动过程中遇到错误,需要技术人员介入处理。"
:start-time="new Date('2024-01-20 16:00:00')"
:milestones="[
{ value: 20, label: '自检完成' },
{ value: 40, label: '系统加载' },
{ value: 80, label: '就绪状态' }
]"
>
<template #actions>
<ActionButton action-type="emergency" size="small" text="重启" />
<ActionButton action-type="repair" size="small" text="修复" />
</template>
</DropdownProgressBar>
</div>
</div>
<!-- MilitaryChart 演示 -->
<div class="demo-section">
<h3>军事图表 (MilitaryChart)</h3>
<div class="demo-charts-grid">
<!-- 雷达图 -->
<MilitaryChart
type="radar"
title="战斗力评估"
:data="radarData"
:size="250"
/>
<!-- 条形图 -->
<MilitaryChart
type="bar"
title="实体统计"
:data="barData"
/>
<!-- 环形图 -->
<MilitaryChart
type="donut"
title="阵营分布"
:data="donutData"
:size="250"
center-text="总计"
center-subtext="152 个实体"
/>
<!-- 态势图 -->
<MilitaryChart
type="situation"
title="战场态势"
:data="situationData"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Star, CircleCheck, Warning, Search } from '@element-plus/icons-vue'
//
import {
MilitaryCard,
StatusIndicator,
ThreatLevel,
SideIndicator,
ActionButton,
DropdownProgressBar,
MilitaryChart
} from '@/components'
//
const radarData = ref([
{ label: '火力', value: 85, side: 'red' as const },
{ label: '防御', value: 70, side: 'blue' as const },
{ label: '机动', value: 90, side: 'red' as const },
{ label: '通信', value: 75, side: 'blue' as const },
{ label: '侦察', value: 80, side: 'neutral' as const }
])
const barData = ref([
{ label: '战斗机', value: 25, side: 'red' as const },
{ label: '无人机', value: 40, side: 'blue' as const },
{ label: '导弹', value: 15, side: 'red' as const },
{ label: '雷达', value: 8, side: 'neutral' as const }
])
const donutData = ref([
{ label: '红方', value: 45, side: 'red' as const },
{ label: '蓝方', value: 42, side: 'blue' as const },
{ label: '中立', value: 13, side: 'neutral' as const }
])
const situationData = ref([
{ id: '1', name: 'F-16', type: 'fighter', side: 'red' as const, x: 20, y: 30, heading: 45 },
{ id: '2', name: 'MQ-9', type: 'drone', side: 'blue' as const, x: 70, y: 20, heading: 120 },
{ id: '3', name: 'AGM-88', type: 'missile', side: 'red' as const, x: 50, y: 60, heading: 90 },
{ id: '4', name: 'Radar-1', type: 'radar', side: 'neutral' as const, x: 80, y: 80, heading: 0 }
])
//
const onCardClick = (title: string) => {
ElMessage.success(`点击了卡片: ${title}`)
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.component-demo {
padding: $spacing-xl;
max-width: 1200px;
margin: 0 auto;
min-height: 100%;
/* 确保内容足够长时能够滚动 */
box-sizing: border-box;
}
.demo-header {
text-align: center;
margin-bottom: $spacing-xxl;
h2 {
color: $text-primary;
margin-bottom: $spacing-sm;
}
p {
color: $text-secondary;
font-size: $font-size-lg;
}
}
.demo-section {
margin-bottom: $spacing-xxl;
h3 {
color: $text-primary;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-sm;
border-bottom: 2px solid $primary-color;
display: inline-block;
}
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: $spacing-lg;
margin-bottom: $spacing-lg;
}
.demo-status-grid {
display: flex;
flex-wrap: wrap;
gap: $spacing-lg;
align-items: center;
}
.status-item {
padding: $spacing-base;
border: 1px solid $border-light;
border-radius: $border-radius-base;
background: white;
}
.demo-threat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-lg;
}
.demo-side-grid {
display: flex;
flex-wrap: wrap;
gap: $spacing-lg;
align-items: center;
}
.demo-button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: $spacing-lg;
}
.demo-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: $spacing-lg;
}
.demo-progress-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: $spacing-lg;
}
//
@media (max-width: $breakpoint-md) {
.component-demo {
padding: $spacing-lg;
}
.demo-grid {
grid-template-columns: 1fr;
}
.demo-charts-grid {
grid-template-columns: 1fr;
}
.demo-progress-grid {
grid-template-columns: 1fr;
}
.demo-status-grid {
justify-content: center;
}
.demo-side-grid {
justify-content: center;
}
}
@media (max-width: $breakpoint-sm) {
.demo-header {
margin-bottom: $spacing-lg;
}
.demo-section {
margin-bottom: $spacing-lg;
}
.demo-button-grid {
grid-template-columns: 1fr;
}
}
</style>

535
src/views/Dashboard.vue Normal file
View File

@ -0,0 +1,535 @@
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stats-grid">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ entityStats.total }}</div>
<div class="stat-label">总实体数</div>
</div>
<el-icon class="stat-icon" color="#409EFF">
<Platform />
</el-icon>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ missionStats.active }}</div>
<div class="stat-label">活跃任务</div>
</div>
<el-icon class="stat-icon" color="#67C23A">
<Operation />
</el-icon>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ entityStats.bySide.red }}</div>
<div class="stat-label">红方实体</div>
</div>
<el-icon class="stat-icon" color="#F56C6C">
<Warning />
</el-icon>
</el-card>
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ entityStats.bySide.blue }}</div>
<div class="stat-label">蓝方实体</div>
</div>
<el-icon class="stat-icon" color="#409EFF">
<Star />
</el-icon>
</el-card>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 3D地球视图 -->
<el-card class="cesium-container" :body-style="{ padding: '0' }">
<div id="cesiumContainer" class="cesium-viewer"></div>
<!-- 控制面板 -->
<div class="control-panel">
<el-button-group>
<el-button type="primary" :icon="VideoPlay" @click="playSimulation">
开始演练
</el-button>
<el-button :icon="VideoPause" @click="pauseSimulation">
暂停
</el-button>
<el-button :icon="RefreshRight" @click="resetView">
重置视角
</el-button>
</el-button-group>
</div>
</el-card>
<!-- 侧边信息面板 -->
<div class="side-panel">
<!-- 实体列表 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<span>实体列表</span>
<el-button type="primary" size="small" @click="showEntityDialog = true">
添加实体
</el-button>
</div>
</template>
<div class="entity-list">
<div
v-for="entity in entitiesList"
:key="entity.id"
class="entity-item"
:class="{ 'selected': selectedEntity?.id === entity.id }"
@click="selectEntity(entity.id)"
>
<div class="entity-info">
<div class="entity-name">{{ entity.name }}</div>
<div class="entity-details">
<el-tag
:type="entity.side === 'red' ? 'danger' : 'primary'"
size="small"
>
{{ entity.side === 'red' ? '红方' : '蓝方' }}
</el-tag>
<el-tag size="small" style="margin-left: 4px;">
{{ getEntityTypeLabel(entity.type) }}
</el-tag>
</div>
</div>
<div class="entity-status">
<el-icon
:color="getStatusColor(entity.status)"
size="16"
>
<CircleCheckFilled v-if="entity.status === 'active'" />
<WarningFilled v-else-if="entity.status === 'damaged'" />
<CircleCloseFilled v-else />
</el-icon>
</div>
</div>
</div>
</el-card>
<!-- 任务列表 -->
<el-card class="info-card">
<template #header>
<div class="card-header">
<span>任务列表</span>
<el-button type="primary" size="small" @click="showMissionDialog = true">
创建任务
</el-button>
</div>
</template>
<div class="mission-list">
<div
v-for="mission in missionsList"
:key="mission.id"
class="mission-item"
:class="{ 'active': mission.status === 'executing' }"
>
<div class="mission-info">
<div class="mission-name">{{ mission.name }}</div>
<div class="mission-details">
<el-tag
:type="getMissionStatusType(mission.status)"
size="small"
>
{{ getMissionStatusLabel(mission.status) }}
</el-tag>
</div>
</div>
</div>
</div>
</el-card>
</div>
</div>
<!-- 添加实体对话框 -->
<el-dialog
v-model="showEntityDialog"
title="添加实体"
width="500px"
>
<el-form :model="entityForm" label-width="80px">
<el-form-item label="名称">
<el-input v-model="entityForm.name" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="entityForm.type" placeholder="选择类型">
<el-option label="战斗机" value="fighter" />
<el-option label="无人机" value="drone" />
<el-option label="导弹" value="missile" />
<el-option label="舰船" value="ship" />
<el-option label="坦克" value="tank" />
<el-option label="雷达" value="radar" />
</el-select>
</el-form-item>
<el-form-item label="阵营">
<el-select v-model="entityForm.side" placeholder="选择阵营">
<el-option label="红方" value="red" />
<el-option label="蓝方" value="blue" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEntityDialog = false">取消</el-button>
<el-button type="primary" @click="createEntity">确定</el-button>
</template>
</el-dialog>
<!-- 创建任务对话框 -->
<el-dialog
v-model="showMissionDialog"
title="创建任务"
width="600px"
>
<el-form :model="missionForm" label-width="80px">
<el-form-item label="任务名称">
<el-input v-model="missionForm.name" />
</el-form-item>
<el-form-item label="阵营">
<el-select v-model="missionForm.side" placeholder="选择阵营">
<el-option label="红方" value="red" />
<el-option label="蓝方" value="blue" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="missionForm.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showMissionDialog = false">取消</el-button>
<el-button type="primary" @click="createMission">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import {
Platform, Operation, Warning, Star, VideoPlay, VideoPause, RefreshRight,
CircleCheckFilled, WarningFilled, CircleCloseFilled
} from '@element-plus/icons-vue'
import { useEntitiesStore } from '@/stores/entities'
import { useMissionsStore } from '@/stores/missions'
import { ElMessage } from 'element-plus'
//
const entitiesStore = useEntitiesStore()
const missionsStore = useMissionsStore()
//
const showEntityDialog = ref(false)
const showMissionDialog = ref(false)
const entityForm = ref({
name: '',
type: '',
side: '',
position: { lng: 116.397428, lat: 39.90923, alt: 1000 }
})
const missionForm = ref({
name: '',
side: '',
description: '',
startTime: new Date().toISOString(),
config: {
objectives: [],
constraints: [],
rules: {},
participants: []
}
})
//
const entitiesList = computed(() => entitiesStore.entitiesList)
const selectedEntity = computed(() => entitiesStore.selectedEntity)
const missionsList = computed(() => missionsStore.missionsList)
const entityStats = computed(() => entitiesStore.entityStats)
const missionStats = computed(() => missionsStore.missionStats)
//
function selectEntity(id: string) {
entitiesStore.selectEntity(id)
}
function getEntityTypeLabel(type: string) {
const labels: Record<string, string> = {
fighter: '战斗机',
drone: '无人机',
missile: '导弹',
ship: '舰船',
tank: '坦克',
radar: '雷达'
}
return labels[type] || type
}
function getStatusColor(status: string) {
const colors: Record<string, string> = {
active: '#67C23A',
damaged: '#E6A23C',
destroyed: '#F56C6C',
inactive: '#909399'
}
return colors[status] || '#909399'
}
function getMissionStatusType(status: string) {
const types: Record<string, string> = {
planning: 'info',
ready: 'warning',
executing: 'success',
completed: 'success',
failed: 'danger',
cancelled: 'info'
}
return types[status] || 'info'
}
function getMissionStatusLabel(status: string) {
const labels: Record<string, string> = {
planning: '规划中',
ready: '准备就绪',
executing: '执行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消'
}
return labels[status] || status
}
async function createEntity() {
try {
await entitiesStore.createEntity(entityForm.value)
ElMessage.success('实体创建成功')
showEntityDialog.value = false
resetEntityForm()
} catch (error) {
ElMessage.error('实体创建失败')
}
}
async function createMission() {
try {
await missionsStore.createMission(missionForm.value)
ElMessage.success('任务创建成功')
showMissionDialog.value = false
resetMissionForm()
} catch (error) {
ElMessage.error('任务创建失败')
}
}
function resetEntityForm() {
entityForm.value = {
name: '',
type: '',
side: '',
position: { lng: 116.397428, lat: 39.90923, alt: 1000 }
}
}
function resetMissionForm() {
missionForm.value = {
name: '',
side: '',
description: '',
startTime: new Date().toISOString(),
config: {
objectives: [],
constraints: [],
rules: {},
participants: []
}
}
}
function playSimulation() {
ElMessage.info('演练开始')
}
function pauseSimulation() {
ElMessage.info('演练暂停')
}
function resetView() {
ElMessage.info('视角重置')
}
//
onMounted(() => {
//
entitiesStore.initialize()
missionsStore.initialize()
// TODO: Cesium
initCesiumViewer()
})
function initCesiumViewer() {
// Cesium
console.log('Cesium viewer will be initialized here')
}
</script>
<style scoped>
.dashboard {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 16px;
}
.stat-card {
border-radius: 8px;
}
.stat-card :deep(.el-card__body) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
line-height: 1;
}
.stat-label {
font-size: 14px;
color: #606266;
margin-top: 4px;
}
.stat-icon {
font-size: 40px;
opacity: 0.8;
}
.main-content {
flex: 1;
display: flex;
gap: 16px;
padding: 0 16px 16px;
min-height: 0;
}
.cesium-container {
flex: 1;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.cesium-viewer {
width: 100%;
height: 600px;
background: linear-gradient(45deg, #1e3c72, #2a5298);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
}
.cesium-viewer::before {
content: "🌍 Cesium 3D地球视图即将加载";
}
.control-panel {
position: absolute;
top: 16px;
left: 16px;
z-index: 100;
}
.side-panel {
width: 320px;
display: flex;
flex-direction: column;
gap: 16px;
}
.info-card {
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.entity-list, .mission-list {
max-height: 300px;
overflow-y: auto;
}
.entity-item, .mission-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
border: 1px solid #ebeef5;
}
.entity-item:hover, .mission-item:hover {
background-color: #f5f7fa;
}
.entity-item.selected {
background-color: #ecf5ff;
border-color: #409eff;
}
.mission-item.active {
background-color: #f0f9ff;
border-color: #67c23a;
}
.entity-info, .mission-info {
flex: 1;
}
.entity-name, .mission-name {
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.entity-details, .mission-details {
display: flex;
gap: 4px;
}
.entity-status {
margin-left: 8px;
}
</style>

View File

@ -0,0 +1,488 @@
<template>
<div class="entity-management">
<!-- 顶部面包屑导航 -->
<div class="nav-breadcrumb">
<el-breadcrumb separator=">">
<el-breadcrumb-item>
<el-button link @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</el-button>
</el-breadcrumb-item>
<el-breadcrumb-item>上级页面</el-breadcrumb-item>
<el-breadcrumb-item>上级页面</el-breadcrumb-item>
<el-breadcrumb-item class="current">当前模块</el-breadcrumb-item>
</el-breadcrumb>
<div class="nav-actions">
<el-button-group>
<el-button size="small">
<el-icon><Refresh /></el-icon>
战场环境
</el-button>
<el-button size="small" type="primary">
<el-icon><Setting /></el-icon>
任务规划
</el-button>
<el-button size="small">行动评估</el-button>
<el-button size="small">分析评估</el-button>
</el-button-group>
<el-button type="primary" size="small" class="next-step-btn">
下一步
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<div class="management-container">
<!-- 左侧方案面板 -->
<div class="left-panel">
<div class="panel-header">
<div class="header-tabs">
<el-badge :value="1" class="scheme-badge">
方案列表
</el-badge>
</div>
</div>
<!-- 搜索框 -->
<div class="search-container">
<el-input
v-model="searchKeyword"
placeholder="搜索方案"
size="small"
clearable
>
<template #suffix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 方案树 -->
<div class="scheme-tree">
<el-tree
:data="schemeTreeData"
:props="treeProps"
:expand-on-click-node="false"
:default-expanded-keys="['scheme1']"
node-key="id"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="tree-node" :class="{ 'active': selectedNode?.id === data.id }">
<el-icon v-if="data.type === 'scheme'" class="node-icon">
<Folder />
</el-icon>
<el-icon v-else-if="data.type === 'batch'" class="node-icon">
<Operation />
</el-icon>
<el-icon v-else class="node-icon aircraft-icon">
<Position />
</el-icon>
<span class="node-label">{{ data.label }}</span>
<el-tag
v-if="data.status"
:type="getStatusType(data.status)"
size="small"
class="node-status"
>
{{ data.status }}
</el-tag>
</div>
</template>
</el-tree>
</div>
</div>
<!-- 中央配置区域 -->
<div class="center-panel">
<EntityConfigPanel
v-model:activeTab="activeTab"
v-model:entityConfig="entityConfig"
v-model:selectedWeaponScheme="selectedWeaponScheme"
:weapon-schemes="weaponSchemes"
:fuel-percentage="fuelPercentage"
@weapon-scheme-select="selectWeaponScheme"
/>
</div>
<!-- 右侧信息面板 -->
<div class="right-panel">
<WeaponStatsPanel
:weapon-inventory="weaponInventory"
:total-weight="16"
:weight-change="19.6"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
ArrowLeft, ArrowRight, Search, Refresh, Setting, Folder, Operation, Position
} from '@element-plus/icons-vue'
import EntityConfigPanel from '@/components/entity/EntityConfigPanel.vue'
import WeaponStatsPanel from '@/components/entity/WeaponStatsPanel.vue'
//
const searchKeyword = ref('')
const activeTab = ref('weapon')
const selectedNode = ref<any>(null)
const selectedWeaponScheme = ref('scheme1')
const fuelPercentage = ref(75)
//
const entityConfig = ref({
name: 'J-16',
displayName: 'J-16',
armorType: 'red',
missionType: 'sweep',
maxAltitude: '10000',
minAltitude: '1000'
})
//
const schemeTreeData = ref([
{
id: 'scheme1',
label: '任务方案#1',
type: 'scheme',
children: [
{
id: 'batch1',
label: '第一批次',
type: 'batch',
children: [
{ id: 'aircraft1', label: '飞机', type: 'aircraft', status: null }
]
},
{
id: 'batch2',
label: '第二批次',
type: 'batch',
children: []
},
{
id: 'batch3',
label: '第三批次',
type: 'batch',
children: []
}
]
},
{
id: 'scheme2',
label: '任务方案#2',
type: 'scheme',
children: [
{
id: 'batch4',
label: '第一批次',
type: 'batch',
children: []
},
{
id: 'batch5',
label: '第二批次',
type: 'batch',
children: []
},
{
id: 'batch6',
label: '第三批次',
type: 'batch',
children: []
}
]
},
{
id: 'scheme3',
label: '任务方案#3',
type: 'scheme',
children: []
}
])
const treeProps = {
children: 'children',
label: 'label'
}
//
const weaponSchemes = ref([
{ id: 'scheme1', name: '武器挂载方案一', available: true },
{ id: 'scheme2', name: '武器挂载方案二', available: true },
{ id: 'scheme3', name: '武器挂载方案三', available: true },
{ id: 'scheme4', name: '武器挂载方案四', available: false }
])
//
const weaponInventory = ref([
{ id: '1', name: 'PL-8', weight: '1.6 t', icon: '🚀' },
{ id: '2', name: 'Aim-7雷管', weight: '1.6 t', icon: '🚀' },
{ id: '3', name: 'AIM-120闪电拉姆', weight: '1.6 t', icon: '🚀' },
{ id: '4', name: 'PL-12', weight: '1.6 t', icon: '🚀' },
{ id: '5', name: 'PL-15', weight: '1.6 t', icon: '🚀' },
{ id: '6', name: 'PL-10', weight: '1.6 t', icon: '🚀' }
])
//
const goBack = () => {
//
}
const handleNodeClick = (data: any) => {
selectedNode.value = data
if (data.type === 'aircraft') {
loadAircraftConfig(data.id)
}
}
const getStatusType = (status: string) => {
const typeMap: Record<string, string> = {
'可用': 'success',
'维护中': 'warning',
'故障': 'danger'
}
return typeMap[status] || 'info'
}
const selectWeaponScheme = (schemeId: string) => {
if (weaponSchemes.value.find(s => s.id === schemeId)?.available) {
selectedWeaponScheme.value = schemeId
loadWeaponScheme(schemeId)
}
}
const loadAircraftConfig = (aircraftId: string) => {
console.log('Loading aircraft config for:', aircraftId)
}
const loadWeaponScheme = (schemeId: string) => {
console.log('Loading weapon scheme:', schemeId)
}
//
onMounted(() => {
selectedNode.value = schemeTreeData.value[0].children[0].children[0]
})
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
.entity-management {
height: 100vh;
background: #0f1419;
color: #ffffff;
display: flex;
flex-direction: column;
}
//
.nav-breadcrumb {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: rgba(15, 20, 25, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.current {
color: #409eff;
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
.next-step-btn {
background: #409eff;
border: none;
}
}
}
//
.management-container {
flex: 1;
display: flex;
overflow: hidden;
}
//
.left-panel {
width: 240px;
background: rgba(15, 20, 25, 0.8);
border-right: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
.panel-header {
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.scheme-badge {
:deep(.el-badge__content) {
background: #409eff;
}
}
}
.search-container {
padding: 12px 16px;
:deep(.el-input) {
.el-input__wrapper {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.el-input__inner {
color: #ffffff;
&::placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
}
.scheme-tree {
flex: 1;
padding: 8px;
overflow-y: auto;
:deep(.el-tree) {
background: transparent;
color: #ffffff;
.el-tree-node__content {
background: transparent;
border-radius: 4px;
margin-bottom: 2px;
&:hover {
background: rgba(64, 158, 255, 0.1);
}
}
.el-tree-node__expand-icon {
color: rgba(255, 255, 255, 0.6);
}
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
&.active {
background: #409eff;
border-radius: 4px;
padding: 2px 8px;
margin: -2px -8px;
}
.node-icon {
color: rgba(255, 255, 255, 0.7);
&.aircraft-icon {
color: #409eff;
}
}
.node-label {
flex: 1;
font-size: 13px;
}
.node-status {
font-size: 11px;
}
}
}
}
//
.center-panel {
flex: 1;
background: rgba(15, 20, 25, 0.6);
}
//
.right-panel {
width: 200px;
background: rgba(15, 20, 25, 0.8);
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
//
:deep(.el-breadcrumb) {
.el-breadcrumb__item {
.el-breadcrumb__inner {
color: rgba(255, 255, 255, 0.7);
&:hover {
color: #409eff;
}
}
&:last-child .el-breadcrumb__inner {
color: #409eff;
}
}
.el-breadcrumb__separator {
color: rgba(255, 255, 255, 0.5);
}
}
:deep(.el-button-group) {
.el-button {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&.el-button--primary {
background: #409eff;
border-color: #409eff;
color: #ffffff;
}
}
}
//
@media (max-width: $breakpoint-lg) {
.left-panel {
width: 200px;
}
.right-panel {
width: 180px;
}
}
@media (max-width: $breakpoint-md) {
.management-container {
flex-direction: column;
}
.left-panel,
.right-panel {
width: 100%;
height: auto;
}
}
</style>

View File

@ -0,0 +1,17 @@
<template>
<div class="mission-planning">
<h2>任务规划</h2>
<p>任务规划功能正在开发中...</p>
</div>
</template>
<script setup lang="ts">
//
</script>
<style scoped>
.mission-planning {
padding: 20px;
}
</style>

View File

@ -0,0 +1,17 @@
<template>
<div class="situation-awareness">
<h2>态势感知</h2>
<p>态势感知功能正在开发中...</p>
</div>
</template>
<script setup lang="ts">
//
</script>
<style scoped>
.situation-awareness {
padding: 20px;
}
</style>

15
tsconfig.app.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"skipLibCheck": true,
"types": ["cesium"]
}
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"skipLibCheck": true,
"types": ["cesium"]
}
}

8
tsconfig.node.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

42
vite.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import cesium from 'vite-plugin-cesium'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
cesium({
rebuildCesium: true,
})
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
build: {
rollupOptions: {
external: [],
},
target: 'esnext',
sourcemap: true,
},
optimizeDeps: {
include: ['cesium'],
},
})