init
This commit is contained in:
commit
2892b38ff9
252
.cursorrules
Normal file
252
.cursorrules
Normal 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
22
.eslintrc.cjs
Normal 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
175
.gitignore
vendored
Normal 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
11
.prettierrc.json
Normal 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
307
README.md
Normal 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
24
env.d.ts
vendored
Normal 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
124
index.html
Normal 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
5706
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
144
src/App.vue
Normal 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
95
src/api/entities.ts
Normal 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
93
src/api/index.ts
Normal 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
167
src/api/missions.ts
Normal 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
202
src/api/websocket.ts
Normal 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 }
|
||||
611
src/components/charts/MilitaryChart.vue
Normal file
611
src/components/charts/MilitaryChart.vue
Normal 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>
|
||||
|
||||
338
src/components/common/MilitaryCard.vue
Normal file
338
src/components/common/MilitaryCard.vue
Normal 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>
|
||||
|
||||
118
src/components/entity/EntityConfigPanel.vue
Normal file
118
src/components/entity/EntityConfigPanel.vue
Normal 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>
|
||||
141
src/components/entity/WeaponStatsPanel.vue
Normal file
141
src/components/entity/WeaponStatsPanel.vue
Normal 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>
|
||||
10
src/components/entity/index.ts
Normal file
10
src/components/entity/index.ts
Normal 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
76
src/components/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
385
src/components/ui/ActionButton.vue
Normal file
385
src/components/ui/ActionButton.vue
Normal 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>
|
||||
|
||||
617
src/components/ui/DropdownProgressBar.vue
Normal file
617
src/components/ui/DropdownProgressBar.vue
Normal 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>
|
||||
|
||||
344
src/components/ui/SideIndicator.vue
Normal file
344
src/components/ui/SideIndicator.vue
Normal 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>
|
||||
|
||||
224
src/components/ui/StatusIndicator.vue
Normal file
224
src/components/ui/StatusIndicator.vue
Normal 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>
|
||||
|
||||
358
src/components/ui/ThreatLevel.vue
Normal file
358
src/components/ui/ThreatLevel.vue
Normal 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
35
src/main.ts
Normal 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
57
src/router/index.ts
Normal 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
267
src/stores/entities.ts
Normal 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
427
src/stores/missions.ts
Normal 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
128
src/styles/variables.scss
Normal 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
213
src/types/index.ts
Normal 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
17
src/views/Assessment.vue
Normal 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
448
src/views/ComponentDemo.vue
Normal 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
535
src/views/Dashboard.vue
Normal 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>
|
||||
|
||||
488
src/views/EntityManagement.vue
Normal file
488
src/views/EntityManagement.vue
Normal 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>
|
||||
17
src/views/MissionPlanning.vue
Normal file
17
src/views/MissionPlanning.vue
Normal 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>
|
||||
|
||||
17
src/views/SituationAwareness.vue
Normal file
17
src/views/SituationAwareness.vue
Normal 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
15
tsconfig.app.json
Normal 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
13
tsconfig.json
Normal 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
8
tsconfig.node.json
Normal 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
42
vite.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user