This commit is contained in:
wangqifan 2025-03-26 12:48:43 +08:00
parent 99e2d2ea2e
commit 96204bed06
34 changed files with 17672 additions and 1 deletions

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
# AWS 凭证
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_DEFAULT_REGION=ap-northeast-1
# 数据库配置
DATABASE_URL=postgresql://postgres:postgres@db:5432/calc
REDIS_URL=redis://redis:6379/0
# 应用配置
ENVIRONMENT=production
CORS_ORIGINS=http://localhost,http://localhost:80
# 域名配置
DOMAIN=localhost

4
.gitignore vendored
View File

@ -14,4 +14,6 @@ node_modules/
.env.production.local .env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
backend/__pycache__/*
backend/venv/*

23
backend/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM python:3.9-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件
COPY requirements.txt .
# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]

387
backend/main.py Normal file
View File

@ -0,0 +1,387 @@
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional, Dict
import boto3
import json
from datetime import datetime, timedelta
import os
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
app = FastAPI()
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
pricing_ebs = {
"us-east-1": 0.08,
"us-east-2": 0.08,
"us-west-1": 0.096,
"us-west-2": 0.08,
"af-south-1": 0.1047,
"ap-east-1": 0.1056,
"ap-south-1": 0.0912,
"ap-northeast-3": 0.096,
"ap-northeast-2": 0.0912,
"ap-southeast-1": 0.096,
"ap-southeast-2": 0.096,
"ap-northeast-1": 0.096,
"ca-central-1": 0.088,
"eu-central-1": 0.0952,
"eu-west-1": 0.088,
"eu-west-2": 0.0928,
"eu-west-3": 0.0928,
"eu-north-1": 0.0836,
"me-central-1": 0.0968,
"sa-east-1": 0.152,
}
# 数据模型
class PriceRequest(BaseModel):
instance_type: str
region: str
operating_system: str
purchase_option: str
duration: Optional[int] = 1
class PriceComparison(BaseModel):
configurations: List[PriceRequest]
class InstanceSearchRequest(BaseModel):
cpu_cores: Optional[int] = None
memory_gb: Optional[float] = None
disk_gb: Optional[int] = None
region: Optional[str] = None
operating_system: Optional[str] = "Linux"
# EC2实例信息
instance_info = {
# T2 系列 - 入门级通用型
"t2.nano": {"cpu": 1, "memory": 0.5, "description": "入门级通用型实例,适合轻量级工作负载"},
"t2.micro": {"cpu": 1, "memory": 1, "description": "具成本效益的入门级实例"},
"t2.small": {"cpu": 1, "memory": 2, "description": "低成本通用实例"},
"t2.medium": {"cpu": 2, "memory": 4, "description": "中等负载通用实例"},
"t2.large": {"cpu": 2, "memory": 8, "description": "大型通用实例"},
"t2.xlarge": {"cpu": 4, "memory": 16, "description": "超大型通用实例"},
"t2.2xlarge": {"cpu": 8, "memory": 32, "description": "双倍超大型通用实例"},
# T3 系列 - 新一代通用型
"t3.nano": {"cpu": 1, "memory": 0.5, "description": "新一代入门级通用型实例"},
"t3.micro": {"cpu": 1, "memory": 1, "description": "新一代低成本通用实例"},
"t3.small": {"cpu": 1, "memory": 2, "description": "新一代小型通用实例"},
"t3.medium": {"cpu": 2, "memory": 4, "description": "新一代中等负载通用实例"},
"t3.large": {"cpu": 2, "memory": 8, "description": "新一代大型通用实例"},
"t3.xlarge": {"cpu": 4, "memory": 16, "description": "新一代超大型通用实例"},
"t3.2xlarge": {"cpu": 8, "memory": 32, "description": "新一代双倍超大型通用实例"},
# T3a 系列 - 新一代通用型
"t3a.nano": {"cpu": 1, "memory": 0.5, "description": "新一代入门级通用型实例"},
"t3a.micro": {"cpu": 1, "memory": 1, "description": "新一代低成本通用实例"},
"t3a.small": {"cpu": 1, "memory": 2, "description": "新一代小型通用实例"},
"t3a.medium": {"cpu": 2, "memory": 4, "description": "新一代中等负载通用实例"},
"t3a.large": {"cpu": 2, "memory": 8, "description": "新一代大型通用实例"},
"t3a.xlarge": {"cpu": 4, "memory": 16, "description": "新一代超大型通用实例"},
"t3a.2xlarge": {"cpu": 8, "memory": 32, "description": "新一代双倍超大型通用实例"},
# C5 系列 - 计算优化型
"c5.large": {"cpu": 2, "memory": 4, "description": "计算优化实例"},
"c5.xlarge": {"cpu": 4, "memory": 8, "description": "高性能计算优化实例"},
"c5.2xlarge": {"cpu": 8, "memory": 16, "description": "大规模计算优化实例"},
"c5.4xlarge": {"cpu": 16, "memory": 32, "description": "超大规模计算优化实例"},
"c5.9xlarge": {"cpu": 36, "memory": 72, "description": "高性能计算优化实例"},
"c5.12xlarge": {"cpu": 48, "memory": 96, "description": "大规模计算优化实例"},
"c5.18xlarge": {"cpu": 72, "memory": 144, "description": "超大规模计算优化实例"},
"c5.24xlarge": {"cpu": 96, "memory": 192, "description": "最大规模计算优化实例"},
# c6a 系列 - 新一代计算优化型
"c6a.large": {"cpu": 2, "memory": 4, "description": "新一代计算优化实例"},
"c6a.xlarge": {"cpu": 4, "memory": 8, "description": "新一代高性能计算优化实例"},
"c6a.2xlarge": {"cpu": 8, "memory": 16, "description": "新一代大规模计算优化实例"},
"c6a.4xlarge": {"cpu": 16, "memory": 32, "description": "新一代超大规模计算优化实例"},
"c6a.8xlarge": {"cpu": 32, "memory": 64, "description": "新一代高性能计算优化实例"},
"c6a.12xlarge": {"cpu": 48, "memory": 96, "description": "新一代大规模计算优化实例"},
"c6a.16xlarge": {"cpu": 64, "memory": 128, "description": "新一代超大规模计算优化实例"},
"c6a.24xlarge": {"cpu": 96, "memory": 192, "description": "新一代最大规模计算优化实例"},
"c6a.32xlarge": {"cpu": 128, "memory": 256, "description": "新一代最大规模计算优化实例"},
# R5 系列 - 内存优化型
"r5.large": {"cpu": 2, "memory": 16, "description": "内存优化实例"},
"r5.xlarge": {"cpu": 4, "memory": 32, "description": "高性能内存优化实例"},
"r5.2xlarge": {"cpu": 8, "memory": 64, "description": "大规模内存优化实例"},
"r5.4xlarge": {"cpu": 16, "memory": 128, "description": "超大规模内存优化实例"},
"r5.8xlarge": {"cpu": 32, "memory": 256, "description": "高性能内存优化实例"},
"r5.12xlarge": {"cpu": 48, "memory": 384, "description": "大规模内存优化实例"},
"r5.16xlarge": {"cpu": 64, "memory": 512, "description": "超大规模内存优化实例"},
"r5.24xlarge": {"cpu": 96, "memory": 768, "description": "最大规模内存优化实例"},
# R6a 系列 - 新一代内存优化型
"r6a.large": {"cpu": 2, "memory": 16, "description": "新一代内存优化实例"},
"r6a.xlarge": {"cpu": 4, "memory": 32, "description": "新一代高性能内存优化实例"},
"r6a.2xlarge": {"cpu": 8, "memory": 64, "description": "新一代大规模内存优化实例"},
"r6a.4xlarge": {"cpu": 16, "memory": 128, "description": "新一代超大规模内存优化实例"},
"r6a.8xlarge": {"cpu": 32, "memory": 256, "description": "新一代高性能内存优化实例"},
"r6a.12xlarge": {"cpu": 48, "memory": 384, "description": "新一代大规模内存优化实例"},
"r6a.16xlarge": {"cpu": 64, "memory": 512, "description": "新一代超大规模内存优化实例"},
"r6a.24xlarge": {"cpu": 96, "memory": 768, "description": "新一代最大规模内存优化实例"},
"r6a.32xlarge": {"cpu": 128, "memory": 1024, "description": "新一代最大规模内存优化实例"},
# M5 系列 - 通用型
"m5.large": {"cpu": 2, "memory": 8, "description": "平衡型计算和内存实例"},
"m5.xlarge": {"cpu": 4, "memory": 16, "description": "高性能平衡型实例"},
"m5.2xlarge": {"cpu": 8, "memory": 32, "description": "大规模工作负载平衡型实例"},
"m5.4xlarge": {"cpu": 16, "memory": 64, "description": "超大规模工作负载平衡型实例"},
"m5.8xlarge": {"cpu": 32, "memory": 128, "description": "高性能工作负载平衡型实例"},
"m5.12xlarge": {"cpu": 48, "memory": 192, "description": "大规模工作负载平衡型实例"},
"m5.16xlarge": {"cpu": 64, "memory": 256, "description": "超大规模工作负载平衡型实例"},
"m5.24xlarge": {"cpu": 96, "memory": 384, "description": "最大规模工作负载平衡型实例"},
# M6a 系列 - 新一代通用型
"m6a.large": {"cpu": 2, "memory": 8, "description": "新一代平衡型计算和内存实例"},
"m6a.xlarge": {"cpu": 4, "memory": 16, "description": "新一代高性能平衡型实例"},
"m6a.2xlarge": {"cpu": 8, "memory": 32, "description": "新一代大规模工作负载平衡型实例"},
"m6a.4xlarge": {"cpu": 16, "memory": 64, "description": "新一代超大规模工作负载平衡型实例"},
"m6a.8xlarge": {"cpu": 32, "memory": 128, "description": "新一代高性能工作负载平衡型实例"},
"m6a.12xlarge": {"cpu": 48, "memory": 192, "description": "新一代大规模工作负载平衡型实例"},
"m6a.16xlarge": {"cpu": 64, "memory": 256, "description": "新一代超大规模工作负载平衡型实例"},
"m6a.24xlarge": {"cpu": 96, "memory": 384, "description": "新一代最大规模工作负载平衡型实例"},
"m6a.32xlarge": {"cpu": 128, "memory": 512, "description": "新一代最大规模工作负载平衡型实例"}
}
# 区域中文名称映射
region_names: Dict[str, str] = {
"us-east-1": "美国东部 (弗吉尼亚北部)",
"us-east-2": "美国东部 (俄亥俄)",
"us-west-1": "美国西部 (加利福尼亚北部)",
"us-west-2": "美国西部 (俄勒冈)",
"ap-south-1": "亚太地区 (孟买)",
"ap-east-1": "亚太地区 (香港)",
"ap-northeast-1": "亚太地区 (东京)",
"ap-northeast-2": "亚太地区 (首尔)",
"ap-southeast-1": "亚太地区 (新加坡)",
"ap-southeast-2": "亚太地区 (悉尼)",
"ca-central-1": "加拿大 (中部)",
"eu-central-1": "欧洲 (法兰克福)",
"eu-west-1": "欧洲 (爱尔兰)",
"eu-west-2": "欧洲 (伦敦)",
"eu-west-3": "欧洲 (巴黎)",
"sa-east-1": "南美洲 (圣保罗)",
"me-central-1": "中东 (阿联酋)",
"eu-north-1": "欧洲 (斯德哥尔摩)",
"eu-west-4": "欧洲 (比利时)",
"eu-south-1": "欧洲 (米兰)",
"eu-west-5": "欧洲 (阿姆斯特丹)",
"eu-west-6": "欧洲 (华沙)",
"eu-west-7": "欧洲 (伦敦)",
"eu-west-8": "欧洲 (米兰)",
"eu-west-9": "欧洲 (马德里)",
"eu-west-10": "欧洲 (巴黎)",
"eu-west-11": "欧洲 (阿姆斯特丹)",
"eu-west-12": "欧洲 (米兰)",
"eu-west-13": "欧洲 (米兰)",
}
# 获取EC2价格
@app.get("/api/regions")
async def get_regions():
# 从环境变量获取区域,如果未设置则使用默认区域
region = os.environ.get('AWS_DEFAULT_REGION', 'us-east-1')
ec2 = boto3.client('ec2', region_name=region)
try:
regions_response = ec2.describe_regions()
regions = [region['RegionName'] for region in regions_response['Regions']]
# 创建包含中文名称的结果
result = []
for region_code in regions:
region_info = {
"code": region_code,
"name": region_names.get(region_code, f"未知区域 ({region_code})")
}
result.append(region_info)
# 按照区域名称排序
result.sort(key=lambda x: x["name"])
return result
except Exception as e:
# 如果API调用失败返回常用区域列表
print(f"Error fetching regions: {str(e)}")
result = []
for region_code in [
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
"ap-south-1", "ap-northeast-1", "ap-northeast-2", "ap-southeast-1",
"ap-southeast-2", "ca-central-1", "eu-central-1", "eu-west-1",
"eu-west-2", "eu-west-3", "sa-east-1"
]:
region_info = {
"code": region_code,
"name": region_names.get(region_code, f"未知区域 ({region_code})")
}
result.append(region_info)
# 按照区域名称排序
result.sort(key=lambda x: x["name"])
return result
@app.get("/api/instance-types")
async def get_instance_types():
# 返回所有实例类型和它们的详细信息
return instance_info
@app.post("/api/search-instances")
async def search_instances(request: InstanceSearchRequest):
try:
matching_instances = []
print(f"request: {request}")
if request.cpu_cores is None:
raise HTTPException(status_code=500, detail=str("cpu_cores is required"))
if request.memory_gb is None:
raise HTTPException(status_code=500, detail=str("memory_gb is required"))
if request.disk_gb is None:
raise HTTPException(status_code=500, detail=str("disk_gb is required"))
if request.region is None:
raise HTTPException(status_code=500, detail=str("region is required"))
# 遍历所有实例类型
for instance_type, info in instance_info.items():
try:
# 检查是否满足CPU要求严格匹配
if request.cpu_cores and info['cpu'] != request.cpu_cores:
continue
# 检查是否满足内存要求(严格匹配)
if request.memory_gb and info['memory'] != request.memory_gb:
continue
print(f"instance_type: {instance_type}")
# 计算价格
price_info = await calculate_price(
instance_type=instance_type,
region=request.region,
disk_gb=request.disk_gb,
operating_system=request.operating_system
)
# 添加到匹配列表
matching_instances.append({
"instance_type": instance_type,
"description": info['description'],
"cpu": info['cpu'],
"memory": info['memory'],
"disk_gb": request.disk_gb,
"hourly_price": price_info['hourly_price'],
"monthly_price": price_info['monthly_price'],
"disk_monthly_price": price_info['disk_monthly_price'],
"total_monthly_price": price_info['total_monthly_price']
})
except Exception as e:
print(f"Error calculating price: {str(e)}")
# 按总价格排序
matching_instances.sort(key=lambda x: x['total_monthly_price'])
return matching_instances
except Exception as e:
print(f"Error searching instances: {str(e)}")
# raise HTTPException(status_code=500, detail=str(e))
async def calculate_price(instance_type: str, region: str, disk_gb: int, operating_system: str = "Linux"):
print(f"operating_system: {operating_system}")
try:
# 创建Pricing API客户端
pricing_client = boto3.client('pricing', region_name='us-east-1')
# 构建基础过滤器
filters = [
{'Type': 'TERM_MATCH', 'Field': 'instanceType', 'Value': instance_type},
{'Type': 'TERM_MATCH', 'Field': 'regionCode', 'Value': region},
{'Type': 'TERM_MATCH', 'Field': 'productFamily', 'Value': 'Compute Instance'},
{'Type': 'TERM_MATCH', 'Field': 'serviceCode', 'Value': 'AmazonEC2'},
{'Type': 'TERM_MATCH', 'Field': 'tenancy', 'Value': 'Shared'},
{'Type': 'TERM_MATCH', 'Field': 'operatingSystem', 'Value': operating_system},
{'Type': 'TERM_MATCH', 'Field': 'preInstalledSw', 'Value': 'NA'},
{'Type': 'TERM_MATCH', 'Field': 'termType', 'Value': 'OnDemand'},
{'Type': 'TERM_MATCH', 'Field': 'capacitystatus', 'Value': 'Used'},
{'Type': 'TERM_MATCH', 'Field': 'currentGeneration', 'Value': 'Yes'}
]
# 根据操作系统设置不同的许可证模型
# if operating_system == "Windows":
# filters.append({'Type': 'TERM_MATCH', 'Field': 'licenseModel', 'Value': 'Windows'})
# else:
# filters.append({'Type': 'TERM_MATCH', 'Field': 'licenseModel', 'Value': 'No License required'})
# 获取实例价格
response = pricing_client.get_products(
ServiceCode='AmazonEC2',
Filters=filters,
MaxResults=1
)
if not response['PriceList']:
raise Exception(f"未找到实例 {instance_type} 的价格信息")
price_list = json.loads(response['PriceList'][0])
terms = price_list['terms']['OnDemand']
price_dimensions = list(terms.values())[0]['priceDimensions']
price_per_hour = float(list(price_dimensions.values())[0]['pricePerUnit']['USD'])
print(f"price_per_hour: {price_per_hour}")
# 计算GP3存储价格
storage_price_per_gb = calculate_gp3_price(region)
disk_monthly_price = storage_price_per_gb * disk_gb
# 计算每月价格
monthly_price = price_per_hour * 730
total_monthly_price = monthly_price + disk_monthly_price
return {
"hourly_price": price_per_hour,
"monthly_price": monthly_price,
"disk_monthly_price": disk_monthly_price,
"total_monthly_price": total_monthly_price
}
except Exception as e:
print(f"Error calculating price: {str(e)}")
# raise HTTPException(status_code=500, detail=str(e))
# 添加计算 GP3 存储价格的函数
def calculate_gp3_price(region: str) -> float:
if region in pricing_ebs.keys():
price_dimensions = pricing_ebs[region]
else:
price_dimensions = 0.1
return price_dimensions
@app.post("/api/compare-prices")
async def compare_prices(comparison: PriceComparison):
try:
results = []
for config in comparison.configurations:
price = await calculate_price(
config.instance_type,
config.region,
config.disk_gb,
config.operating_system
)
results.append({
"configuration": config.dict(),
"price": price
})
return results
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

7
backend/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
fastapi==0.104.1
uvicorn==0.24.0
boto3==1.29.3
python-dotenv==1.0.0
pydantic==2.4.2
pandas==2.1.3
python-multipart==0.0.6

69
docker-compose.yml Normal file
View File

@ -0,0 +1,69 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/calc
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
nginx:
image: nginx:stable-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- backend
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
db:
image: postgres:13-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=calc
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:6-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres_data:
redis_data:

2
frontend/.env.production Normal file
View File

@ -0,0 +1,2 @@
# 生产环境API配置
VUE_APP_API_BASE_URL=http://api.your-domain.com

19
frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,19 @@
module.exports = {
root: true,
env: {
node: true
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended'
],
parserOptions: {
parser: '@babel/eslint-parser',
requireConfigFile: false
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/multi-word-component-names': 'off'
}
}

29
frontend/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# 构建阶段
FROM node:16-alpine as build-stage
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
# 安装依赖
RUN npm install
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 生产阶段
FROM nginx:stable-alpine as production-stage
# 复制构建产物
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 复制nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

5
frontend/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

BIN
frontend/dist.zip Normal file

Binary file not shown.

1
frontend/dist/css/app.98e934e6.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
frontend/dist/index.html vendored Normal file
View File

@ -0,0 +1 @@
<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue App</title><script defer="defer" src="/js/chunk-vendors.d55f6ef3.js"></script><script defer="defer" src="/js/app.13e0a1ea.js"></script><link href="/css/chunk-vendors.f7d64127.css" rel="stylesheet"><link href="/css/app.98e934e6.css" rel="stylesheet"></head><body><div id="app"></div></body></html>

2
frontend/dist/js/app.13e0a1ea.js vendored Normal file

File diff suppressed because one or more lines are too long

1
frontend/dist/js/app.13e0a1ea.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

46
frontend/nginx.conf Normal file
View File

@ -0,0 +1,46 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# gzip配置
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 路由配置
location / {
try_files $uri $uri/ /index.html;
}
# API代理配置
location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 错误页面配置
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

12726
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "aws-ec2-price-calculator",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.6.2",
"core-js": "^3.8.3",
"echarts": "^5.4.3",
"element-plus": "^2.4.2",
"file-saver": "^2.0.5",
"vue": "^3.2.13",
"vue-router": "^4.0.3",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3"
}
}

160
frontend/src/App.vue Normal file
View File

@ -0,0 +1,160 @@
<template>
<div id="app">
<el-container>
<el-header class="header">
<div class="header-content">
<h1><i class="el-icon-cloudy-and-sunny"></i> AWS EC2 价格计算器</h1>
<el-menu mode="horizontal" router class="menu" background-color="#3498db" text-color="#fff" active-text-color="#ffd04b">
<!-- <el-menu-item index="/"><i class="el-icon-s-finance"></i> 价格计算器</el-menu-item> -->
<el-menu-item index="/awsSearch"><i class="el-icon-search"></i>AWS报价</el-menu-item>
<el-menu-item index="/awsSearchDiscount"><i class="el-icon-search"></i>AWS折扣</el-menu-item>
<!-- <el-menu-item index="/compare"><i class="el-icon-data-analysis"></i> 价格对比</el-menu-item> -->
</el-menu>
</div>
</el-header>
<el-main class="main-content">
<div class="sun-rays"></div>
<router-view></router-view>
</el-main>
<el-footer class="footer">
<p>AWS EC2 价格计算器 &copy; 2023</p>
</el-footer>
</el-container>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
<style>
:root {
--primary-color: #3498db;
--secondary-color: #f39c12;
--accent-color: #2ecc71;
--background-color: #f8f9fa;
--card-color: #ffffff;
--text-color: #2c3e50;
}
#app {
font-family: 'Nunito', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--text-color);
background-color: var(--background-color);
min-height: 100vh;
}
.header {
background-color: var(--primary-color);
color: white;
padding: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 10;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
.header h1 {
margin: 0;
padding: 10px 0;
font-size: 24px;
display: flex;
align-items: center;
}
.header h1 i {
margin-right: 10px;
color: var(--secondary-color);
}
.menu {
border-bottom: none;
}
.main-content {
padding: 30px 20px;
background-color: var(--background-color);
position: relative;
overflow: hidden;
min-height: calc(100vh - 120px);
}
.sun-rays {
position: absolute;
top: -150px;
right: -150px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(255,208,75,0.2) 0%, rgba(255,208,75,0) 70%);
border-radius: 50%;
z-index: 0;
opacity: 0.8;
}
.footer {
background-color: var(--primary-color);
color: white;
text-align: center;
padding: 15px 0;
font-size: 14px;
}
/* 全局卡片样式 */
.el-card {
border-radius: 12px !important;
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.05) !important;
border: none !important;
overflow: hidden;
}
.el-card__header {
background-color: var(--primary-color);
color: white;
padding: 15px 20px;
}
/* 全局按钮样式 */
.el-button--primary {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.el-button--primary:hover {
background-color: #2980b9 !important;
border-color: #2980b9 !important;
}
.el-button--success {
background-color: var(--accent-color) !important;
border-color: var(--accent-color) !important;
}
/* 表单样式 */
.el-form-item__label {
font-weight: 600;
}
/* 设置表格样式 */
.el-table th {
background-color: #ecf0f1 !important;
}
@media (max-width: 768px) {
.header h1 {
font-size: 20px;
}
.main-content {
padding: 20px 10px;
}
}
</style>

108
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,108 @@
import axios from 'axios'
import config from '../config'
// 创建axios实例
const apiClient = axios.create({
baseURL: config.apiBaseUrl,
timeout: 10000, // 超时时间10秒
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
})
// 请求拦截器 - 可以在这里添加认证令牌等
apiClient.interceptors.request.use(
config => {
// 在发送请求前做些什么
return config
},
error => {
// 对请求错误做些什么
return Promise.reject(error)
}
)
// 响应拦截器 - 统一处理错误
apiClient.interceptors.response.use(
response => {
// 对响应数据做些什么
return response
},
error => {
// 对响应错误做些什么
console.error('API请求失败:', error)
return Promise.reject(error)
}
)
// API服务对象
const apiService = {
// 获取区域列表
getRegions: async () => {
try {
const response = await apiClient.get('/api/regions')
return response.data
} catch (error) {
console.error('获取区域列表失败:', error)
throw error
}
},
// 获取实例类型列表
getInstanceTypes: async () => {
try {
const response = await apiClient.get('/api/instance-types')
return response.data
} catch (error) {
console.error('获取实例类型列表失败:', error)
throw error
}
},
// 搜索实例
searchInstances: async (params) => {
try {
const response = await apiClient.post('/api/search-instances', params)
return response.data
} catch (error) {
console.error('搜索实例失败:', error)
throw error
}
},
// 计算价格
calculatePrice: async (params) => {
try {
const response = await apiClient.post('/api/calculate-price', params)
return response.data
} catch (error) {
console.error('计算价格失败:', error)
throw error
}
},
// 比较价格
comparePrices: async (params) => {
try {
const response = await apiClient.post('/api/compare-prices', params)
return response.data
} catch (error) {
console.error('比较价格失败:', error)
throw error
}
},
// 获取预算估算
getBudgetEstimate: async (params) => {
try {
const response = await apiClient.post('/api/budget', params)
return response.data
} catch (error) {
console.error('获取预算估算失败:', error)
throw error
}
}
}
export default apiService

12
frontend/src/config.js Normal file
View File

@ -0,0 +1,12 @@
// API 配置文件
const config = {
// 后端API基础URL - 从环境变量中读取,如果不存在则使用默认值
apiBaseUrl: process.env.VUE_APP_API_BASE_URL || 'http://localhost:8000',
// 其他全局配置
defaultRegion: 'us-east-1',
defaultOS: 'Linux',
defaultDiskSize: 30
}
export default config;

Binary file not shown.

15
frontend/src/main.js Normal file
View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import config from './config'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
// 将配置对象挂载到app的全局属性上
app.config.globalProperties.$config = config
app.mount('#app')

View File

@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import PriceCalculator from '../views/PriceCalculator.vue'
import PriceComparison from '../views/PriceComparison.vue'
import BudgetEstimator from '../views/BudgetEstimator.vue'
import InstanceSearch from '../views/InstanceSearch.vue'
import AwsSearchDiscount from '../views/AwsSearchDiscount.vue'
const routes = [
{
path: '/',
name: 'PriceCalculator',
component: PriceCalculator
},
{
path: '/compare',
name: 'PriceComparison',
component: PriceComparison
},
{
path: '/budget',
name: 'BudgetEstimator',
component: BudgetEstimator
},
{
path: '/awsSearch',
name: 'InstanceSearch',
component: InstanceSearch
},
{
path: '/awsSearchDiscount',
name: 'AwsSearchDiscount',
component: AwsSearchDiscount
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,674 @@
<template>
<div class="budget-estimator">
<el-card class="estimator-card">
<template #header>
<div class="card-header">
<span><i class="el-icon-money"></i> EC2 预算估算</span>
<div class="card-subtitle">规划您的云计算花费避免预算超支</div>
</div>
</template>
<div class="form-container">
<el-form :model="form" label-width="120px">
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="实例类型">
<el-select v-model="form.instance_type" placeholder="请选择实例类型" class="full-width">
<el-option
v-for="type in instanceTypes"
:key="type"
:label="type"
:value="type">
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24">
<el-form-item label="区域">
<el-select v-model="form.region" placeholder="请选择区域" class="full-width">
<el-option
v-for="region in regions"
:key="region.code"
:label="region.name"
:value="region.code">
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="操作系统">
<el-select v-model="form.operating_system" placeholder="请选择操作系统" class="full-width">
<el-option label="Linux" value="Linux">
<div class="option-with-icon">
<span class="option-icon">🐧</span>
<span>Linux</span>
</div>
</el-option>
<el-option label="Windows" value="Windows">
<div class="option-with-icon">
<span class="option-icon">🪟</span>
<span>Windows</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24">
<el-form-item label="购买选项">
<el-select v-model="form.purchase_option" placeholder="请选择购买选项" class="full-width">
<el-option label="按需实例" value="OnDemand">
<div class="option-with-icon">
<span class="option-icon"></span>
<span>按需实例</span>
</div>
</el-option>
<el-option label="预留实例" value="Reserved">
<div class="option-with-icon">
<span class="option-icon">📅</span>
<span>预留实例</span>
</div>
</el-option>
<el-option label="Spot实例" value="Spot">
<div class="option-with-icon">
<span class="option-icon">💸</span>
<span>Spot实例</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="center">
<i class="el-icon-time"></i> 使用时长设置
</el-divider>
<el-form-item>
<el-radio-group v-model="form.duration_type" class="duration-radio-group">
<el-radio-button label="preset">
<div class="radio-content">
<i class="el-icon-star-off"></i>
<span>预设时长</span>
</div>
</el-radio-button>
<el-radio-button label="custom">
<div class="radio-content">
<i class="el-icon-edit"></i>
<span>自定义</span>
</div>
</el-radio-button>
</el-radio-group>
</el-form-item>
<div v-if="form.duration_type === 'custom'" class="custom-duration">
<el-form-item label="月数">
<el-slider
v-model="form.duration"
:min="1"
:max="36"
:format-tooltip="formatDuration"
:marks="{1: '1个月', 12: '1年', 36: '3年'}"
class="duration-slider">
</el-slider>
</el-form-item>
</div>
<div v-else class="preset-duration">
<el-form-item label="预设时长">
<el-radio-group v-model="form.preset_duration" class="preset-radio-group">
<el-radio :label="1">
<div class="duration-option">
<span class="duration-icon">1</span>
<span>1个月</span>
</div>
</el-radio>
<el-radio :label="3">
<div class="duration-option">
<span class="duration-icon">3</span>
<span>3个月</span>
</div>
</el-radio>
<el-radio :label="6">
<div class="duration-option">
<span class="duration-icon">6</span>
<span>6个月</span>
</div>
</el-radio>
<el-radio :label="12">
<div class="duration-option">
<span class="duration-icon">12</span>
<span>1</span>
</div>
</el-radio>
<el-radio :label="36">
<div class="duration-option">
<span class="duration-icon">36</span>
<span>3</span>
</div>
</el-radio>
</el-radio-group>
</el-form-item>
</div>
<div class="form-actions">
<el-button type="primary" @click="calculateBudget" icon="el-icon-money" class="calculate-button">计算预算</el-button>
</div>
</el-form>
</div>
<div v-if="budgetResult" class="budget-result animated fadeIn">
<h3><i class="el-icon-wallet"></i> 预算估算结果</h3>
<el-row :gutter="20">
<el-col :md="6" :sm="12">
<div class="budget-card hourly">
<div class="budget-icon"></div>
<div class="budget-title">每小时价格</div>
<div class="budget-amount">${{ budgetResult.hourly_price.toFixed(4) }}</div>
</div>
</el-col>
<el-col :md="6" :sm="12">
<div class="budget-card monthly">
<div class="budget-icon">📅</div>
<div class="budget-title">每月价格</div>
<div class="budget-amount">${{ budgetResult.monthly_price.toFixed(2) }}</div>
</div>
</el-col>
<el-col :md="6" :sm="12">
<div class="budget-card yearly">
<div class="budget-icon">🗓</div>
<div class="budget-title">年度价格</div>
<div class="budget-amount">${{ (budgetResult.monthly_price * 12).toFixed(2) }}</div>
</div>
</el-col>
<el-col :md="6" :sm="12">
<div class="budget-card total">
<div class="budget-icon">💰</div>
<div class="budget-title">总价格</div>
<div class="budget-amount">${{ budgetResult.total_price.toFixed(2) }}</div>
<div class="budget-period">{{ getDurationText() }}</div>
</div>
</el-col>
</el-row>
<div class="chart-container">
<div ref="budgetChart" style="height: 400px;"></div>
</div>
<div class="budget-tips">
<h4><i class="el-icon-warning-outline"></i> 预算提示</h4>
<ul>
<li>以上价格仅包含EC2实例费用不包含存储数据传输等额外费用</li>
<li>实际账单可能因使用情况和AWS价格变动而有所不同</li>
<li v-if="form.purchase_option === 'Reserved'">预留实例需要预付费用但总体可节省高达72%的成本</li>
<li v-if="form.purchase_option === 'Spot'">Spot实例价格可能随市场波动但可节省高达90%的成本</li>
</ul>
</div>
</div>
</el-card>
</div>
</template>
<script>
import apiService from '../api'
import * as echarts from 'echarts'
export default {
name: 'BudgetEstimator',
data() {
return {
form: {
instance_type: '',
region: '',
operating_system: 'Linux',
purchase_option: 'OnDemand',
duration_type: 'preset',
duration: 1,
preset_duration: 1
},
instanceTypes: [],
regions: [],
budgetResult: null
}
},
async created() {
try {
// 使API
const [instanceTypes, regions] = await Promise.all([
apiService.getInstanceTypes(),
apiService.getRegions()
])
this.instanceTypes = instanceTypes
this.regions = regions
//
if (this.instanceTypes && this.instanceTypes.length > 0) {
this.form.instance_type = this.instanceTypes[0]
}
if (this.regions && this.regions.length > 0) {
this.form.region = this.regions[0].code
}
} catch (error) {
console.error('Error fetching data:', error)
this.$message.error('获取数据失败')
}
},
methods: {
async calculateBudget() {
try {
const duration = this.form.duration_type === 'custom' ? this.form.duration : this.form.preset_duration
// 使API
this.budgetResult = await apiService.calculatePrice({
...this.form,
duration
})
this.$nextTick(() => {
this.updateChart()
})
} catch (error) {
console.error('Error calculating budget:', error)
this.$message.error('计算预算失败')
}
},
updateChart() {
const chartDom = this.$refs.budgetChart
const myChart = echarts.init(chartDom)
const duration = this.form.duration_type === 'custom' ? this.form.duration : this.form.preset_duration
const monthlyData = Array.from({ length: duration }, (_, i) => ({
month: `${i + 1}个月`,
cost: this.budgetResult.monthly_price
}))
const option = {
title: {
text: '月度成本趋势',
left: 'center',
textStyle: {
color: '#2c3e50'
}
},
tooltip: {
trigger: 'axis',
formatter: '{b}: ${c}'
},
xAxis: {
type: 'category',
data: monthlyData.map(item => item.month),
axisLine: {
lineStyle: {
color: '#7f8c8d'
}
}
},
yAxis: {
type: 'value',
name: '成本 ($)',
nameTextStyle: {
color: '#7f8c8d'
},
axisLine: {
lineStyle: {
color: '#7f8c8d'
}
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
series: [
{
name: '月度成本',
type: 'bar',
data: monthlyData.map(item => item.cost),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#f39c12' },
{ offset: 1, color: '#e67e22' }
])
},
markLine: {
data: [
{
type: 'average',
name: '平均值',
lineStyle: {
color: '#e74c3c'
},
label: {
formatter: '平均月度成本: ${avg}',
color: '#e74c3c'
}
}
]
}
},
{
name: '累计成本',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 8,
data: monthlyData.map((_, index) =>
this.budgetResult.monthly_price * (index + 1)
),
itemStyle: {
color: '#3498db'
},
lineStyle: {
width: 3,
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#3498db' },
{ offset: 1, color: '#2980b9' }
])
}
}
]
}
myChart.setOption(option)
window.addEventListener('resize', () => {
myChart.resize()
})
},
formatDuration(val) {
if (val === 1) return '1个月'
if (val === 12) return '1年'
if (val === 36) return '3年'
return `${val}个月`
},
getDurationText() {
const duration = this.form.duration_type === 'custom' ? this.form.duration : this.form.preset_duration
if (duration === 1) return '1个月'
if (duration === 12) return '1年'
if (duration === 36) return '3年'
return `${duration}个月`
},
//
getRegionName(regionCode) {
const region = this.regions.find(r => r.code === regionCode)
return region ? region.name : regionCode
}
}
}
</script>
<style scoped>
.budget-estimator {
max-width: 1000px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.estimator-card {
margin-bottom: 20px;
transition: all 0.3s ease;
}
.estimator-card:hover {
transform: translateY(-5px);
}
.card-header {
display: flex;
flex-direction: column;
padding: 5px 0;
}
.card-header span {
font-size: 20px;
font-weight: 600;
}
.card-header i {
margin-right: 8px;
}
.card-subtitle {
margin-top: 5px;
font-size: 14px;
opacity: 0.8;
}
.form-container {
padding: 20px 10px;
}
.full-width {
width: 100%;
}
.option-with-icon {
display: flex;
align-items: center;
}
.option-icon {
margin-right: 8px;
font-size: 16px;
}
.duration-radio-group {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.radio-content {
display: flex;
align-items: center;
padding: 0 8px;
}
.radio-content i {
margin-right: 5px;
}
.preset-radio-group {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.duration-option {
display: flex;
align-items: center;
}
.duration-icon {
display: flex;
justify-content: center;
align-items: center;
width: 28px;
height: 28px;
background-color: #f39c12;
color: white;
border-radius: 50%;
margin-right: 8px;
font-weight: bold;
}
.el-divider {
margin: 30px 0;
}
.el-divider__text {
background-color: #f8f9fa;
color: #7f8c8d;
}
.duration-slider {
margin: 15px 0;
}
.form-actions {
display: flex;
justify-content: center;
margin-top: 30px;
}
.calculate-button {
min-width: 160px;
height: 44px;
font-size: 16px;
transition: all 0.3s ease;
}
.calculate-button:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.budget-result {
margin-top: 30px;
padding-top: 20px;
border-top: 1px dashed #e0e0e0;
}
.budget-result h3 {
margin-bottom: 20px;
color: #2c3e50;
font-size: 18px;
text-align: center;
}
.budget-result h3 i {
margin-right: 8px;
color: var(--secondary-color);
}
.budget-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
text-align: center;
transition: all 0.3s ease;
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.05);
height: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
}
.budget-card:hover {
transform: translateY(-5px);
}
.budget-card.hourly {
border-top: 5px solid #3498db;
}
.budget-card.monthly {
border-top: 5px solid #f39c12;
}
.budget-card.yearly {
border-top: 5px solid #9b59b6;
}
.budget-card.total {
border-top: 5px solid #2ecc71;
}
.budget-icon {
font-size: 28px;
margin-bottom: 10px;
}
.budget-title {
color: #7f8c8d;
font-size: 16px;
margin-bottom: 10px;
}
.budget-amount {
font-size: 24px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 5px;
}
.budget-period {
font-size: 14px;
color: #95a5a6;
}
.chart-container {
background-color: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.05);
margin: 30px 0;
}
.budget-tips {
background-color: #fef9e6;
border-radius: 8px;
padding: 15px 20px;
margin-top: 20px;
}
.budget-tips h4 {
color: #f39c12;
margin-top: 0;
margin-bottom: 10px;
font-size: 16px;
display: flex;
align-items: center;
}
.budget-tips h4 i {
margin-right: 8px;
}
.budget-tips ul {
margin: 0;
padding-left: 20px;
}
.budget-tips li {
margin-bottom: 8px;
color: #7f8c8d;
}
.animated {
animation-duration: 0.5s;
animation-fill-mode: both;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fadeIn {
animation-name: fadeIn;
}
@media (max-width: 768px) {
.preset-radio-group {
flex-direction: column;
gap: 10px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,394 @@
<template>
<div class="price-calculator">
<el-card class="calculator-card">
<template #header>
<div class="card-header">
<span><i class="el-icon-s-finance"></i> EC2 价格计算器</span>
<div class="card-subtitle">选择您的实例配置即时获取价格</div>
</div>
</template>
<div class="form-container">
<el-form :model="form" label-width="120px">
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="实例类型">
<el-select v-model="form.instance_type" placeholder="请选择实例类型" class="full-width">
<el-option
v-for="type in instanceTypes"
:key="type"
:label="type"
:value="type">
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24">
<el-form-item label="区域">
<el-select v-model="form.region" placeholder="请选择区域" class="full-width">
<el-option
v-for="region in regions"
:key="region"
:label="region"
:value="region">
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="操作系统">
<el-select v-model="form.operating_system" placeholder="请选择操作系统" class="full-width">
<el-option label="Linux" value="Linux">
<div class="option-with-icon">
<span class="option-icon">🐧</span>
<span>Linux</span>
</div>
</el-option>
<el-option label="Windows" value="Windows">
<div class="option-with-icon">
<span class="option-icon">🪟</span>
<span>Windows</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24">
<el-form-item label="购买选项">
<el-select v-model="form.purchase_option" placeholder="请选择购买选项" class="full-width">
<el-option label="按需实例" value="OnDemand">
<div class="option-with-icon">
<span class="option-icon"></span>
<span>按需实例</span>
</div>
</el-option>
<el-option label="预留实例" value="Reserved">
<div class="option-with-icon">
<span class="option-icon">📅</span>
<span>预留实例</span>
</div>
</el-option>
<el-option label="Spot实例" value="Spot">
<div class="option-with-icon">
<span class="option-icon">💸</span>
<span>Spot实例</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="使用时长(月)">
<el-slider
v-model="form.duration"
:min="1"
:max="36"
:format-tooltip="formatDuration"
:marks="{1: '1个月', 12: '1年', 36: '3年'}"
class="duration-slider">
</el-slider>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24" class="flexible-col">
<el-form-item>
<el-button type="primary" @click="calculatePrice" icon="el-icon-money" class="calculate-button">计算价格</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div v-if="priceResult" class="price-result animated fadeIn">
<h3><i class="el-icon-data-analysis"></i> 价格计算结果</h3>
<el-row :gutter="20">
<el-col :md="8" :sm="24">
<div class="price-card hourly">
<div class="price-icon"></div>
<div class="price-title">每小时价格</div>
<div class="price-amount">${{ priceResult.hourly_price.toFixed(4) }}</div>
</div>
</el-col>
<el-col :md="8" :sm="24">
<div class="price-card monthly">
<div class="price-icon">📅</div>
<div class="price-title">每月价格</div>
<div class="price-amount">${{ priceResult.monthly_price.toFixed(2) }}</div>
</div>
</el-col>
<el-col :md="8" :sm="24">
<div class="price-card total">
<div class="price-icon">💰</div>
<div class="price-title">总价格</div>
<div class="price-amount">${{ priceResult.total_price.toFixed(2) }}</div>
<div class="price-period">{{ form.duration }}个月</div>
</div>
</el-col>
</el-row>
<div class="saving-tip" v-if="form.purchase_option === 'Reserved'">
<i class="el-icon-info"></i> 与按需实例相比预留实例可为您节省高达72%的成本
</div>
<div class="saving-tip" v-else-if="form.purchase_option === 'Spot'">
<i class="el-icon-info"></i> 与按需实例相比Spot实例可为您节省高达90%的成本适合灵活的工作负载
</div>
</div>
</el-card>
</div>
</template>
<script>
import apiService from '../api'
export default {
name: 'PriceCalculator',
data() {
return {
form: {
instance_type: '',
region: '',
operating_system: 'Linux',
purchase_option: 'OnDemand',
duration: 1
},
instanceTypes: [],
regions: [],
priceResult: null
}
},
async created() {
try {
const [instanceTypes, regions] = await Promise.all([
apiService.getInstanceTypes(),
apiService.getRegions()
])
this.instanceTypes = instanceTypes
this.regions = regions.map(region => region.code)
} catch (error) {
console.error('Error fetching data:', error)
this.$message.error('获取数据失败')
}
},
methods: {
async calculatePrice() {
try {
this.priceResult = await apiService.calculatePrice(this.form)
} catch (error) {
console.error('Error calculating price:', error)
this.$message.error('计算价格失败')
}
},
formatDuration(val) {
if (val === 1) return '1个月'
if (val === 12) return '1年'
if (val === 36) return '3年'
return `${val}个月`
}
}
}
</script>
<style scoped>
.price-calculator {
max-width: 900px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.calculator-card {
margin-bottom: 20px;
transition: all 0.3s ease;
}
.calculator-card:hover {
transform: translateY(-5px);
}
.card-header {
display: flex;
flex-direction: column;
padding: 5px 0;
}
.card-header span {
font-size: 20px;
font-weight: 600;
}
.card-header i {
margin-right: 8px;
}
.card-subtitle {
margin-top: 5px;
font-size: 14px;
opacity: 0.8;
}
.form-container {
padding: 20px 10px;
}
.full-width {
width: 100%;
}
.option-with-icon {
display: flex;
align-items: center;
}
.option-icon {
margin-right: 8px;
font-size: 16px;
}
.duration-slider {
margin: 15px 0;
}
.calculate-button {
width: 100%;
height: 40px;
font-size: 16px;
transition: all 0.3s ease;
}
.calculate-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.price-result {
margin-top: 30px;
padding-top: 20px;
border-top: 1px dashed #e0e0e0;
}
.price-result h3 {
margin-bottom: 20px;
color: #2c3e50;
font-size: 18px;
text-align: center;
}
.price-result h3 i {
margin-right: 8px;
color: var(--secondary-color);
}
.price-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
text-align: center;
transition: all 0.3s ease;
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.05);
height: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
}
.price-card:hover {
transform: translateY(-5px);
}
.price-card.hourly {
border-top: 5px solid #3498db;
}
.price-card.monthly {
border-top: 5px solid #f39c12;
}
.price-card.total {
border-top: 5px solid #2ecc71;
}
.price-icon {
font-size: 28px;
margin-bottom: 10px;
}
.price-title {
color: #7f8c8d;
font-size: 16px;
margin-bottom: 10px;
}
.price-amount {
font-size: 28px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 5px;
}
.price-period {
font-size: 14px;
color: #95a5a6;
}
.saving-tip {
margin-top: 20px;
padding: 10px 15px;
background-color: #e5f9e7;
border-radius: 8px;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
}
.saving-tip i {
color: #2ecc71;
font-size: 18px;
margin-right: 10px;
}
.flexible-col {
display: flex;
align-items: center;
}
.animated {
animation-duration: 0.5s;
animation-fill-mode: both;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fadeIn {
animation-name: fadeIn;
}
@media (max-width: 768px) {
.price-calculator {
padding: 0 10px;
}
.calculate-button {
margin-top: 15px;
}
}
</style>

View File

@ -0,0 +1,390 @@
<template>
<div class="price-comparison">
<el-card class="comparison-card">
<template #header>
<div class="card-header">
<span><i class="el-icon-data-analysis"></i> EC2 价格对比</span>
<div class="card-subtitle">比较不同配置的价格做出最佳选择</div>
</div>
</template>
<div class="form-container">
<div class="configurations-container">
<div v-for="(config, index) in configurations" :key="index" class="configuration-item animated fadeIn">
<el-card class="config-card">
<template #header>
<div class="config-header">
<span><i class="el-icon-s-platform"></i> 配置 #{{ index + 1 }}</span>
<el-button type="danger" size="small" icon="el-icon-delete" circle @click="removeConfiguration(index)"></el-button>
</div>
</template>
<el-form :model="config" label-width="120px">
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="实例类型">
<el-select v-model="config.instance_type" placeholder="请选择实例类型" class="full-width">
<el-option
v-for="type in instanceTypes"
:key="type"
:label="type"
:value="type">
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24">
<el-form-item label="区域">
<el-select v-model="config.region" placeholder="请选择区域" class="full-width">
<el-option
v-for="region in regions"
:key="region.code"
:label="region.name"
:value="region.code">
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :md="12" :sm="24">
<el-form-item label="操作系统">
<el-select v-model="config.operating_system" placeholder="请选择操作系统" class="full-width">
<el-option label="Linux" value="Linux">
<div class="option-with-icon">
<span class="option-icon">🐧</span>
<span>Linux</span>
</div>
</el-option>
<el-option label="Windows" value="Windows">
<div class="option-with-icon">
<span class="option-icon">🪟</span>
<span>Windows</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :md="12" :sm="24">
<el-form-item label="购买选项">
<el-select v-model="config.purchase_option" placeholder="请选择购买选项" class="full-width">
<el-option label="按需实例" value="OnDemand">
<div class="option-with-icon">
<span class="option-icon"></span>
<span>按需实例</span>
</div>
</el-option>
<el-option label="预留实例" value="Reserved">
<div class="option-with-icon">
<span class="option-icon">📅</span>
<span>预留实例</span>
</div>
</el-option>
<el-option label="Spot实例" value="Spot">
<div class="option-with-icon">
<span class="option-icon">💸</span>
<span>Spot实例</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="使用时长(月)">
<el-slider
v-model="config.duration"
:min="1"
:max="36"
:format-tooltip="formatDuration"
:marks="{1: '1个月', 12: '1年', 36: '3年'}"
class="duration-slider">
</el-slider>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
<div class="comparison-actions">
<el-button type="primary" icon="el-icon-plus" @click="addConfiguration" class="action-button">添加配置</el-button>
<el-button type="success" icon="el-icon-refresh" @click="comparePrices" class="action-button">比较价格</el-button>
<el-button icon="el-icon-download" @click="exportResults" class="action-button" :disabled="!comparisonResults.length">导出结果</el-button>
</div>
<div v-if="comparisonResults.length" class="comparison-results animated fadeIn">
<h3><i class="el-icon-s-data"></i> 对比结果</h3>
<div class="chart-container">
<div ref="priceChart" style="height: 400px;"></div>
</div>
<el-table :data="comparisonResults" border style="width: 100%; margin-top: 20px;" :stripe="true" class="result-table">
<el-table-column label="配置" width="50">
<template #default="scope">
<div class="config-number">{{ scope.$index + 1 }}</div>
</template>
</el-table-column>
<el-table-column prop="configuration.instance_type" label="实例类型"></el-table-column>
<el-table-column prop="configuration.region" label="区域">
<template #default="scope">
{{ getRegionName(scope.row.configuration.region) }}
</template>
</el-table-column>
<el-table-column prop="configuration.operating_system" label="操作系统">
<template #default="scope">
<div class="option-with-icon">
<span class="option-icon">{{ scope.row.configuration.operating_system === 'Linux' ? '🐧' : '🪟' }}</span>
<span>{{ scope.row.configuration.operating_system }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="configuration.purchase_option" label="购买选项">
<template #default="scope">
<div class="option-with-icon">
<span class="option-icon">
{{ scope.row.configuration.purchase_option === 'OnDemand' ? '⏱️' :
scope.row.configuration.purchase_option === 'Reserved' ? '📅' : '💸' }}
</span>
<span>{{ getPurchaseOptionLabel(scope.row.configuration.purchase_option) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="price.hourly_price" label="每小时价格">
<template #default="scope">
<div class="price-value">
${{ scope.row.price.hourly_price.toFixed(4) }}
</div>
</template>
</el-table-column>
<el-table-column prop="price.monthly_price" label="每月价格">
<template #default="scope">
<div class="price-value">
${{ scope.row.price.monthly_price.toFixed(2) }}
</div>
</template>
</el-table-column>
<el-table-column prop="price.total_price" label="总价格">
<template #default="scope">
<div class="price-value highlight">
${{ scope.row.price.total_price.toFixed(2) }}
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
</div>
</template>
<script>
import apiService from '../api'
import * as echarts from 'echarts'
export default {
name: 'PriceComparison',
data() {
return {
configurations: [],
instanceTypes: [],
regions: [],
comparisonResults: []
}
},
async created() {
try {
const [instanceTypes, regions] = await Promise.all([
apiService.getInstanceTypes(),
apiService.getRegions()
])
this.instanceTypes = instanceTypes
this.regions = regions
this.addConfiguration()
} catch (error) {
console.error('Error fetching data:', error)
this.$message.error('获取数据失败')
}
},
methods: {
addConfiguration() {
this.configurations.push({
instance_type: '',
region: '',
operating_system: 'Linux',
purchase_option: 'OnDemand',
duration: 1
})
},
removeConfiguration(index) {
this.configurations.splice(index, 1)
if (this.configurations.length === 0) {
this.addConfiguration()
}
},
async comparePrices() {
try {
this.comparisonResults = await apiService.comparePrices({
configurations: this.configurations
})
this.$nextTick(() => {
this.updateChart()
})
} catch (error) {
console.error('Error comparing prices:', error)
this.$message.error('比较价格失败')
}
},
updateChart() {
const chartDom = this.$refs.priceChart
const myChart = echarts.init(chartDom)
const option = {
title: {
text: '价格对比图表',
left: 'center',
textStyle: {
color: '#2c3e50'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['每小时价格', '每月价格', '总价格'],
bottom: 10
},
xAxis: {
type: 'category',
data: this.comparisonResults.map((_, index) => `配置 ${index + 1}`),
axisLine: {
lineStyle: {
color: '#7f8c8d'
}
},
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value',
name: '价格 ($)',
nameTextStyle: {
color: '#7f8c8d'
},
axisLine: {
lineStyle: {
color: '#7f8c8d'
}
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
series: [
{
name: '每小时价格',
type: 'bar',
data: this.comparisonResults.map(result => result.price.hourly_price),
itemStyle: {
color: '#3498db'
}
},
{
name: '每月价格',
type: 'bar',
data: this.comparisonResults.map(result => result.price.monthly_price),
itemStyle: {
color: '#f39c12'
}
},
{
name: '总价格',
type: 'bar',
data: this.comparisonResults.map(result => result.price.total_price),
itemStyle: {
color: '#2ecc71'
},
label: {
show: true,
position: 'top',
formatter: '{c}$',
fontSize: 12
}
}
]
}
myChart.setOption(option)
window.addEventListener('resize', () => {
myChart.resize()
})
},
exportResults() {
if (!this.comparisonResults.length) {
this.$message.warning('没有可导出的结果')
return
}
const csv = this.generateCSV()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = 'price-comparison.csv'
link.click()
this.$message.success('导出成功')
},
generateCSV() {
const headers = ['实例类型', '区域', '操作系统', '购买选项', '每小时价格', '每月价格', '总价格']
const rows = this.comparisonResults.map(result => [
result.configuration.instance_type,
result.configuration.region,
result.configuration.operating_system,
this.getPurchaseOptionLabel(result.configuration.purchase_option),
result.price.hourly_price.toFixed(4),
result.price.monthly_price.toFixed(2),
result.price.total_price.toFixed(2)
])
return [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n')
},
formatDuration(val) {
if (val === 1) return '1个月'
if (val === 12) return '1年'
if (val === 36) return '3年'
return `${val}个月`
},
getPurchaseOptionLabel(option) {
switch(option) {
case 'OnDemand': return '按需实例'
case 'Reserved': return '预留实例'
case 'Spot': return 'Spot实例'
default: return option
}
},
getRegionName(regionCode) {
const region = this.regions.find(r => r.code === regionCode)
return region ? region.name : regionCode
}
}
}
</script>
<style scoped>
/* Add your styles here */
</style>

100
install.sh Normal file
View File

@ -0,0 +1,100 @@
#!/bin/bash
# 设置颜色输出
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
# 打印带颜色的信息
print_info() {
echo -e "${GREEN}[INFO] $1${NC}"
}
print_error() {
echo -e "${RED}[ERROR] $1${NC}"
}
# 检查必要的命令
check_commands() {
print_info "检查必要的命令..."
commands=("docker" "docker-compose" "node" "npm")
for cmd in "${commands[@]}"; do
if ! command -v $cmd &> /dev/null; then
print_error "$cmd 未安装"
exit 1
fi
done
}
# 创建必要的目录
create_directories() {
print_info "创建必要的目录..."
mkdir -p nginx/conf.d nginx/ssl frontend/dist
}
# 构建前端
build_frontend() {
print_info "构建前端..."
cd frontend
npm install
npm run build
cd ..
}
# 检查环境变量
check_env() {
print_info "检查环境变量..."
if [ ! -f .env ]; then
print_error "未找到 .env 文件"
exit 1
fi
}
# 检查SSL证书
check_ssl() {
print_info "检查SSL证书..."
if [ ! -f nginx/ssl/cert.pem ] || [ ! -f nginx/ssl/key.pem ]; then
print_info "未找到SSL证书将使用HTTP模式"
# 修改nginx配置移除SSL相关配置
sed -i '/ssl/d' nginx/conf.d/default.conf
sed -i 's/443/80/g' nginx/conf.d/default.conf
fi
}
# 启动服务
start_services() {
print_info "启动服务..."
docker-compose up -d
}
# 检查服务状态
check_services() {
print_info "检查服务状态..."
sleep 5
if docker-compose ps | grep -q "Up"; then
print_info "服务启动成功!"
print_info "前端访问地址: http://localhost"
print_info "后端API地址: http://localhost:8000"
else
print_error "服务启动失败,请检查日志"
docker-compose logs
fi
}
# 主函数
main() {
print_info "开始安装部署..."
check_commands
create_directories
build_frontend
check_env
check_ssl
start_services
check_services
print_info "安装部署完成!"
}
# 执行主函数
main

89
nginx/conf.d/default.conf Normal file
View File

@ -0,0 +1,89 @@
server {
listen 443 ssl http2;
server_name your_domain.com;
# SSL配置
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# 现代SSL配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS配置
add_header Strict-Transport-Security "max-age=63072000" always;
# 前端服务
location / {
proxy_pass http://frontend:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 后端API服务
location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP重定向到HTTPS
server {
listen 80;
server_name your_domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 80;
server_name localhost;
# 前端静态文件
root /usr/share/nginx/html;
index index.html;
# gzip压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 缓存设置
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 7d;
add_header Cache-Control "public, no-transform";
}
# API代理
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 处理前端路由
location / {
try_files $uri $uri/ /index.html;
}
}