Trae Assistant
初始化并升级:错误处理、上传支持、中文优化
92e639a
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云成本精算师 | Cloud Cost Optimizer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#3B82F6',
secondary: '#10B981',
dark: '#0F172A',
card: '#1E293B'
}
}
}
}
</script>
<style>
[v-cloak] { display: none; }
body { background-color: #0F172A; color: #E2E8F0; }
.chart-container { height: 350px; width: 100%; }
/* Custom scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1E293B; }
::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #64748B; }
</style>
</head>
<body class="antialiased min-h-screen">
<div id="app" v-cloak class="p-6 max-w-[1600px] mx-auto">
<!-- Header -->
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-teal-400">
云成本精算师
</h1>
<p class="text-slate-400 mt-1 text-sm">FinOps 智能分析与优化平台</p>
</div>
<div class="flex gap-4">
<button @click="refreshData" :disabled="loading"
class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-blue-600 rounded-lg transition-colors disabled:opacity-50">
<span v-if="loading" class="animate-spin"></span>
<span v-else></span>
<span>生成新模拟数据</span>
</button>
<label class="flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg cursor-pointer">
<input id="fileInput" type="file" class="hidden" @change="onFileSelected">
<span>选择账单文件</span>
</label>
<button @click="uploadFile" :disabled="!selectedFile || loading"
class="flex items-center gap-2 px-4 py-2 bg-secondary hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50">
<span>上传账单</span>
</button>
<span class="text-slate-400 text-sm self-center" v-if="uploadStatus">[[ uploadStatus ]]</span>
</div>
</header>
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg">
<div class="text-slate-400 text-sm mb-2">总支出 (近90天)</div>
<div class="text-3xl font-bold text-white">$[[ summary.total_cost ]]</div>
<div class="mt-2 text-xs text-slate-500">模拟 AWS 账单数据</div>
</div>
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg">
<div class="text-slate-400 text-sm mb-2">本月支出 (MTD)</div>
<div class="text-3xl font-bold text-white">$[[ summary.current_month_cost ]]</div>
<div class="mt-2 text-xs flex items-center" :class="summary.mom_change > 0 ? 'text-red-400' : 'text-green-400'">
<span v-if="summary.mom_change > 0"></span>
<span v-else></span>
[[ Math.abs(summary.mom_change) ]]% 环比上月
</div>
</div>
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg">
<div class="text-slate-400 text-sm mb-2">下月预测</div>
<div class="text-3xl font-bold text-white">$[[ summary.forecast ]]</div>
<div class="mt-2 text-xs text-slate-500">基于线性趋势预测</div>
</div>
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg">
<div class="text-slate-400 text-sm mb-2">异常检测</div>
<div class="text-3xl font-bold" :class="anomalies.length > 0 ? 'text-red-500' : 'text-green-500'">
[[ anomalies.length ]]
</div>
<div class="mt-2 text-xs text-slate-500">过去 90 天内的突增</div>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Main Trend Chart -->
<div class="lg:col-span-2 bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg">
<h3 class="text-lg font-semibold mb-4 text-white">日成本趋势</h3>
<div id="trendChart" class="chart-container"></div>
</div>
<!-- Breakdown Chart -->
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg">
<h3 class="text-lg font-semibold mb-4 text-white">服务成本占比</h3>
<div id="pieChart" class="chart-container"></div>
</div>
</div>
<!-- Lower Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Anomalies List -->
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg flex flex-col h-full">
<h3 class="text-lg font-semibold mb-4 text-white flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-red-500"></span>
异常成本预警
</h3>
<div class="overflow-y-auto flex-1 max-h-[400px]">
<table class="w-full text-left text-sm">
<thead class="bg-slate-800/50 text-slate-400 sticky top-0">
<tr>
<th class="p-3">日期</th>
<th class="p-3">服务</th>
<th class="p-3 text-right">花费</th>
<th class="p-3 text-right">阈值</th>
<th class="p-3 text-center">级别</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="item in anomalies" :key="item.date + item.service" class="hover:bg-slate-700/20">
<td class="p-3 text-slate-300">[[ item.date ]]</td>
<td class="p-3 text-blue-400">[[ item.service ]]</td>
<td class="p-3 text-right font-mono text-white">$[[ item.cost ]]</td>
<td class="p-3 text-right font-mono text-slate-500">$[[ item.threshold ]]</td>
<td class="p-3 text-center">
<span class="px-2 py-1 rounded text-xs font-bold"
:class="item.severity === 'Critical' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400'">
[[ item.severity ]]
</span>
</td>
</tr>
<tr v-if="anomalies.length === 0">
<td colspan="5" class="p-8 text-center text-slate-500">无异常记录</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Recommendations -->
<div class="bg-card p-6 rounded-xl border border-slate-700/50 shadow-lg flex flex-col h-full">
<h3 class="text-lg font-semibold mb-4 text-white flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
优化建议
</h3>
<div class="space-y-4 overflow-y-auto flex-1 max-h-[400px]">
<div v-for="rec in recommendations" :key="rec.id"
class="p-4 rounded-lg bg-slate-800/50 border border-slate-700 hover:border-primary/50 transition-all">
<div class="flex justify-between items-start mb-2">
<h4 class="font-semibold text-white">[[ rec.title ]]</h4>
<span class="text-green-400 font-bold text-sm">节省 $[[ rec.potential_savings ]]/月</span>
</div>
<p class="text-slate-400 text-sm mb-3">[[ rec.description ]]</p>
<div class="flex gap-2 text-xs">
<span class="px-2 py-1 rounded bg-slate-700 text-slate-300">[[ rec.category ]]</span>
<span class="px-2 py-1 rounded bg-slate-700 text-slate-300">难度: [[ rec.effort ]]</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted } = Vue;
createApp({
delimiters: ['[[', ']]'],
setup() {
const loading = ref(false);
const summary = ref({ total_cost: 0, current_month_cost: 0, mom_change: 0, forecast: 0 });
const anomalies = ref([]);
const recommendations = ref([]);
const selectedFile = ref(null);
const uploadStatus = ref('');
let trendChart = null;
let pieChart = null;
const initCharts = () => {
trendChart = echarts.init(document.getElementById('trendChart'));
pieChart = echarts.init(document.getElementById('pieChart'));
window.addEventListener('resize', () => {
trendChart.resize();
pieChart.resize();
});
};
const fetchSummary = async () => {
const res = await axios.get('/api/summary');
summary.value = res.data;
};
const fetchTrend = async () => {
const res = await axios.get('/api/trend');
const option = {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: res.data.series.map(s => s.name), textStyle: { color: '#94a3b8' }, bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true },
xAxis: {
type: 'category',
data: res.data.dates,
axisLine: { lineStyle: { color: '#475569' } },
axisLabel: { color: '#94a3b8' }
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', formatter: '${value}' }
},
series: res.data.series
};
trendChart.setOption(option);
};
const fetchBreakdown = async () => {
const res = await axios.get('/api/breakdown');
const option = {
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: ${c} ({d}%)' },
legend: { orient: 'vertical', left: 'left', textStyle: { color: '#94a3b8' } },
series: [
{
name: 'Cost',
type: 'pie',
radius: ['50%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 10, borderColor: '#1E293B', borderWidth: 2 },
label: { show: false },
labelLine: { show: false },
data: res.data.service_breakdown
}
]
};
pieChart.setOption(option);
};
const fetchAnomalies = async () => {
const res = await axios.get('/api/anomalies');
anomalies.value = res.data;
};
const fetchRecommendations = async () => {
const res = await axios.get('/api/recommendations');
recommendations.value = res.data;
};
const loadAllData = async () => {
loading.value = true;
try {
await Promise.all([
fetchSummary(),
fetchTrend(),
fetchBreakdown(),
fetchAnomalies(),
fetchRecommendations()
]);
} catch (e) {
console.error("Error loading data", e);
} finally {
loading.value = false;
}
};
const refreshData = async () => {
loading.value = true;
try {
await axios.post('/api/refresh');
await loadAllData();
} catch (e) {
console.error("Error refreshing data", e);
} finally {
loading.value = false;
}
};
const onFileSelected = (e) => {
selectedFile.value = e.target.files[0] || null;
uploadStatus.value = selectedFile.value ? `已选择:${selectedFile.value.name}` : '';
};
const uploadFile = async () => {
if (!selectedFile.value) return;
loading.value = true;
uploadStatus.value = '上传中...';
try {
const formData = new FormData();
formData.append('file', selectedFile.value);
const res = await axios.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
maxContentLength: Infinity,
maxBodyLength: Infinity
});
uploadStatus.value = res.data.status === 'success'
? `上传成功,行数:${res.data.rows || 0}`
: `上传失败:${res.data.message || '未知错误'}`;
await loadAllData();
} catch (e) {
uploadStatus.value = `上传失败:${e?.response?.data?.message || e.message}`;
} finally {
loading.value = false;
}
};
onMounted(() => {
initCharts();
loadAllData();
});
return {
loading,
summary,
anomalies,
recommendations,
refreshData,
selectedFile,
uploadStatus,
onFileSelected,
uploadFile
};
}
}).mount('#app');
</script>
</body>
</html>