111
This commit is contained in:
parent
99e2d2ea2e
commit
96204bed06
15
.env.example
Normal file
15
.env.example
Normal 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
4
.gitignore
vendored
@ -14,4 +14,6 @@ node_modules/
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
yarn-error.log*
|
||||
backend/__pycache__/*
|
||||
backend/venv/*
|
||||
|
||||
23
backend/Dockerfile
Normal file
23
backend/Dockerfile
Normal 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
387
backend/main.py
Normal 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
7
backend/requirements.txt
Normal 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
69
docker-compose.yml
Normal 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
2
frontend/.env.production
Normal file
@ -0,0 +1,2 @@
|
||||
# 生产环境API配置
|
||||
VUE_APP_API_BASE_URL=http://api.your-domain.com
|
||||
19
frontend/.eslintrc.js
Normal file
19
frontend/.eslintrc.js
Normal 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
29
frontend/Dockerfile
Normal 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
5
frontend/babel.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
BIN
frontend/dist.zip
Normal file
BIN
frontend/dist.zip
Normal file
Binary file not shown.
1
frontend/dist/css/app.98e934e6.css
vendored
Normal file
1
frontend/dist/css/app.98e934e6.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/css/chunk-vendors.f7d64127.css
vendored
Normal file
1
frontend/dist/css/chunk-vendors.f7d64127.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/index.html
vendored
Normal file
1
frontend/dist/index.html
vendored
Normal 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
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
1
frontend/dist/js/app.13e0a1ea.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
76
frontend/dist/js/chunk-vendors.d55f6ef3.js
vendored
Normal file
76
frontend/dist/js/chunk-vendors.d55f6ef3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/js/chunk-vendors.d55f6ef3.js.map
vendored
Normal file
1
frontend/dist/js/chunk-vendors.d55f6ef3.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
46
frontend/nginx.conf
Normal file
46
frontend/nginx.conf
Normal 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
12726
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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
160
frontend/src/App.vue
Normal 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 价格计算器 © 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
108
frontend/src/api/index.js
Normal 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
12
frontend/src/config.js
Normal 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;
|
||||
BIN
frontend/src/demo/报价单-无优惠v1.xlsx
Normal file
BIN
frontend/src/demo/报价单-无优惠v1.xlsx
Normal file
Binary file not shown.
15
frontend/src/main.js
Normal file
15
frontend/src/main.js
Normal 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')
|
||||
40
frontend/src/router/index.js
Normal file
40
frontend/src/router/index.js
Normal 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
|
||||
1172
frontend/src/views/AwsSearchDiscount.vue
Normal file
1172
frontend/src/views/AwsSearchDiscount.vue
Normal file
File diff suppressed because it is too large
Load Diff
674
frontend/src/views/BudgetEstimator.vue
Normal file
674
frontend/src/views/BudgetEstimator.vue
Normal 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>
|
||||
1073
frontend/src/views/InstanceSearch.vue
Normal file
1073
frontend/src/views/InstanceSearch.vue
Normal file
File diff suppressed because it is too large
Load Diff
394
frontend/src/views/PriceCalculator.vue
Normal file
394
frontend/src/views/PriceCalculator.vue
Normal 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>
|
||||
390
frontend/src/views/PriceComparison.vue
Normal file
390
frontend/src/views/PriceComparison.vue
Normal 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
100
install.sh
Normal 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
89
nginx/conf.d/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user