Spaces:
Sleeping
Sleeping
| <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> | |