feat: 全新的Vue3管理后台(admin-spa)和路由重构

🎨 新增功能:
- 使用Vue3 + Vite构建的全新管理后台界面
- 支持Tab切换的API统计页面(统计查询/使用教程)
- 优雅的胶囊式Tab切换设计
- 同步了PR #106的会话窗口管理功能
- 完整的响应式设计和骨架屏加载状态

🔧 路由调整:
- 新版管理后台部署在 /admin-next/ 路径
- 将根路径 / 重定向到 /admin-next/api-stats
- 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由
- 将 /apiStats 页面路由重定向到新版,保留API端点

🗑️ 清理工作:
- 删除旧版 web/admin/ 静态文件
- 删除旧版 web/apiStats/ 静态文件
- 清理相关的文件服务代码

🐛 修复问题:
- 修复重定向循环问题
- 修复环境变量配置
- 修复路由404错误
- 优化构建配置

🚀 生成方式:使用 Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-29 12:40:51 +08:00
parent c98de2aca5
commit 414856f152
70 changed files with 18748 additions and 10314 deletions

View File

@@ -0,0 +1,134 @@
<template>
<div class="glass-strong rounded-3xl p-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-robot mr-2 text-purple-500"></i>
模型使用分布
</h2>
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
<el-radio-button label="daily">今日</el-radio-button>
<el-radio-button label="total">累计</el-radio-button>
</el-radio-group>
</div>
<div v-if="dashboardStore.dashboardModelStats.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30"></i>
<p>暂无模型使用数据</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 饼图 -->
<div class="relative" style="height: 300px;">
<canvas ref="chartCanvas"></canvas>
</div>
<!-- 数据列表 -->
<div class="space-y-3">
<div v-for="(stat, index) in sortedStats" :key="stat.model"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-4 h-4 rounded" :style="`background-color: ${getColor(index)}`"></div>
<span class="font-medium text-gray-700">{{ stat.model }}</span>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Chart } from 'chart.js/auto'
import { useDashboardStore } from '@/stores/dashboard'
import { useChartConfig } from '@/composables/useChartConfig'
import { formatNumber } from '@/utils/format'
const dashboardStore = useDashboardStore()
const chartCanvas = ref(null)
let chart = null
const modelPeriod = ref('daily')
const sortedStats = computed(() => {
return [...dashboardStore.dashboardModelStats].sort((a, b) => b.requests - a.requests)
})
const getColor = (index) => {
const { colorSchemes } = useChartConfig()
const colors = colorSchemes.primary
return colors[index % colors.length]
}
const createChart = () => {
if (!chartCanvas.value || !dashboardStore.dashboardModelStats.length) return
if (chart) {
chart.destroy()
}
const { colorSchemes } = useChartConfig()
const colors = colorSchemes.primary
chart = new Chart(chartCanvas.value, {
type: 'doughnut',
data: {
labels: sortedStats.value.map(stat => stat.model),
datasets: [{
data: sortedStats.value.map(stat => stat.requests),
backgroundColor: sortedStats.value.map((_, index) => colors[index % colors.length]),
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
const stat = sortedStats.value[context.dataIndex]
const percentage = ((stat.requests / dashboardStore.dashboardModelStats.reduce((sum, s) => sum + s.requests, 0)) * 100).toFixed(1)
return [
`${stat.model}: ${percentage}%`,
`请求: ${formatNumber(stat.requests)}`,
`Tokens: ${formatNumber(stat.totalTokens)}`
]
}
}
}
}
}
})
}
const handlePeriodChange = async () => {
await dashboardStore.loadModelStats(modelPeriod.value)
createChart()
}
watch(() => dashboardStore.dashboardModelStats, () => {
createChart()
}, { deep: true })
onMounted(() => {
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div class="glass-strong rounded-3xl p-6 mb-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-chart-area mr-2 text-blue-500"></i>
使用趋势
</h2>
<div class="flex items-center gap-3">
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
<el-radio-button label="day">按天</el-radio-button>
<el-radio-button label="hour">按小时</el-radio-button>
</el-radio-group>
<el-select v-model="trendPeriod" size="small" style="width: 120px" @change="handlePeriodChange">
<el-option :label="`最近${period.days}天`" :value="period.days" v-for="period in periodOptions" :key="period.days" />
</el-select>
</div>
</div>
<div class="relative" style="height: 300px;">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Chart } from 'chart.js/auto'
import { useDashboardStore } from '@/stores/dashboard'
import { useChartConfig } from '@/composables/useChartConfig'
const dashboardStore = useDashboardStore()
const chartCanvas = ref(null)
let chart = null
const trendPeriod = ref(7)
const granularity = ref('day')
const periodOptions = [
{ days: 1, label: '24小时' },
{ days: 7, label: '7天' },
{ days: 30, label: '30天' }
]
const createChart = () => {
if (!chartCanvas.value || !dashboardStore.trendData.length) return
if (chart) {
chart.destroy()
}
const { getGradient } = useChartConfig()
const ctx = chartCanvas.value.getContext('2d')
const labels = dashboardStore.trendData.map(item => {
if (granularity.value === 'hour') {
const date = new Date(item.date)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:00`
}
return item.date
})
chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: '请求次数',
data: dashboardStore.trendData.map(item => item.requests),
borderColor: '#667eea',
backgroundColor: getGradient(ctx, '#667eea', 0.1),
yAxisID: 'y',
tension: 0.4
},
{
label: 'Token使用量',
data: dashboardStore.trendData.map(item => item.tokens),
borderColor: '#f093fb',
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
yAxisID: 'y1',
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
display: true,
grid: {
display: false
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: '请求次数'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Token使用量'
},
grid: {
drawOnChartArea: false
}
}
}
}
})
}
const handlePeriodChange = async () => {
await dashboardStore.loadUsageTrend(trendPeriod.value, granularity.value)
createChart()
}
const handleGranularityChange = async () => {
// 根据粒度调整时间范围
if (granularity.value === 'hour' && trendPeriod.value > 7) {
trendPeriod.value = 1
}
await dashboardStore.loadUsageTrend(trendPeriod.value, granularity.value)
createChart()
}
watch(() => dashboardStore.trendData, () => {
createChart()
}, { deep: true })
onMounted(() => {
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>