feat: 新增告警、成本、配额、微信模块及缓存服务
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
- 新增告警模块 (alerts): 告警规则配置与触发 - 新增成本管理模块 (cost): 成本统计与分析 - 新增配额模块 (quota): 配额管理与限制 - 新增微信模块 (wechat): 微信相关功能接口 - 新增缓存服务 (cache): Redis 缓存封装 - 新增请求日志中间件 (request_logger) - 新增异常处理和链路追踪中间件 - 更新 dashboard 前端展示 - 更新 SDK stats_client 功能
This commit is contained in:
@@ -7,6 +7,53 @@ const api = axios.create({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
/**
|
||||
* 解析 API 错误响应
|
||||
*/
|
||||
function parseApiError(error) {
|
||||
const result = {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: '发生了未知错误',
|
||||
traceId: '',
|
||||
status: 500
|
||||
}
|
||||
|
||||
if (!error.response) {
|
||||
result.code = 'NETWORK_ERROR'
|
||||
result.message = '网络连接失败,请检查网络后重试'
|
||||
return result
|
||||
}
|
||||
|
||||
const { status, data, headers } = error.response
|
||||
result.status = status
|
||||
result.traceId = headers['x-trace-id'] || headers['X-Trace-ID'] || ''
|
||||
|
||||
if (data && data.error) {
|
||||
result.code = data.error.code || result.code
|
||||
result.message = data.error.message || result.message
|
||||
result.traceId = data.error.trace_id || result.traceId
|
||||
} else if (data && data.detail) {
|
||||
result.message = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到错误页面
|
||||
*/
|
||||
function navigateToErrorPage(errorInfo) {
|
||||
router.push({
|
||||
name: 'Error',
|
||||
query: {
|
||||
code: errorInfo.code,
|
||||
message: errorInfo.message,
|
||||
trace_id: errorInfo.traceId,
|
||||
status: String(errorInfo.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
@@ -19,10 +66,15 @@ api.interceptors.request.use(
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
// 响应拦截器(集成 TraceID 追踪)
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const errorInfo = parseApiError(error)
|
||||
const traceLog = errorInfo.traceId ? ` (trace: ${errorInfo.traceId})` : ''
|
||||
|
||||
console.error(`[API Error] ${errorInfo.code}: ${errorInfo.message}${traceLog}`)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
@@ -30,9 +82,14 @@ api.interceptors.response.use(
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (error.response?.status === 403) {
|
||||
ElMessage.error('没有权限执行此操作')
|
||||
} else if (['INTERNAL_ERROR', 'SERVICE_UNAVAILABLE', 'GATEWAY_ERROR'].includes(errorInfo.code)) {
|
||||
// 严重错误跳转到错误页面
|
||||
navigateToErrorPage(errorInfo)
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.detail || error.message || '请求失败')
|
||||
// 普通错误显示消息
|
||||
ElMessage.error(errorInfo.message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,12 @@ const routes = [
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录', public: true }
|
||||
},
|
||||
{
|
||||
path: '/error',
|
||||
name: 'Error',
|
||||
component: () => import('@/views/error/index.vue'),
|
||||
meta: { title: '出错了', public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/Layout.vue'),
|
||||
|
||||
@@ -7,11 +7,15 @@ const stats = ref({
|
||||
totalTenants: 0,
|
||||
activeTenants: 0,
|
||||
todayCalls: 0,
|
||||
todayTokens: 0
|
||||
todayTokens: 0,
|
||||
weekCalls: 0,
|
||||
weekTokens: 0
|
||||
})
|
||||
|
||||
const recentLogs = ref([])
|
||||
const trendData = ref([])
|
||||
const chartRef = ref(null)
|
||||
const chartLoading = ref(false)
|
||||
let chartInstance = null
|
||||
|
||||
async function fetchStats() {
|
||||
@@ -25,6 +29,8 @@ async function fetchStats() {
|
||||
if (statsRes.data) {
|
||||
stats.value.todayCalls = statsRes.data.today_calls || 0
|
||||
stats.value.todayTokens = statsRes.data.today_tokens || 0
|
||||
stats.value.weekCalls = statsRes.data.week_calls || 0
|
||||
stats.value.weekTokens = statsRes.data.week_tokens || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取统计失败:', e)
|
||||
@@ -40,10 +46,38 @@ async function fetchRecentLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrendData() {
|
||||
chartLoading.value = true
|
||||
try {
|
||||
const res = await api.get('/api/stats/trend', { params: { days: 7 } })
|
||||
trendData.value = res.data.trend || []
|
||||
updateChart()
|
||||
} catch (e) {
|
||||
console.error('获取趋势数据失败:', e)
|
||||
// 如果API失败,使用空数据
|
||||
trendData.value = []
|
||||
updateChart()
|
||||
} finally {
|
||||
chartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chartInstance) return
|
||||
|
||||
// 从API数据提取日期和调用次数
|
||||
const dates = trendData.value.map(item => {
|
||||
// 格式化日期为 MM-DD
|
||||
const date = new Date(item.date)
|
||||
return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
|
||||
})
|
||||
const calls = trendData.value.map(item => item.calls || 0)
|
||||
const tokens = trendData.value.map(item => item.tokens || 0)
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
@@ -51,22 +85,44 @@ function initChart() {
|
||||
textStyle: { fontSize: 14, fontWeight: 500 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
trigger: 'axis',
|
||||
formatter: function(params) {
|
||||
let result = params[0].axisValue + '<br/>'
|
||||
params.forEach(param => {
|
||||
result += `${param.marker} ${param.seriesName}: ${param.value.toLocaleString()}<br/>`
|
||||
})
|
||||
return result
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['调用次数', 'Token 消耗'],
|
||||
top: 0,
|
||||
right: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: 50,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
data: dates.length > 0 ? dates : ['暂无数据']
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '调用次数',
|
||||
position: 'left'
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Token',
|
||||
position: 'right'
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '调用次数',
|
||||
@@ -80,7 +136,16 @@ function initChart() {
|
||||
},
|
||||
lineStyle: { color: '#409eff' },
|
||||
itemStyle: { color: '#409eff' },
|
||||
data: [120, 132, 101, 134, 90, 230, 210]
|
||||
data: calls.length > 0 ? calls : [0]
|
||||
},
|
||||
{
|
||||
name: 'Token 消耗',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
lineStyle: { color: '#67c23a' },
|
||||
itemStyle: { color: '#67c23a' },
|
||||
data: tokens.length > 0 ? tokens : [0]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -96,6 +161,7 @@ onMounted(() => {
|
||||
fetchStats()
|
||||
fetchRecentLogs()
|
||||
initChart()
|
||||
fetchTrendData()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
@@ -113,22 +179,22 @@ onUnmounted(() => {
|
||||
<div class="stat-title">租户总数</div>
|
||||
<div class="stat-value">{{ stats.totalTenants }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">活跃租户</div>
|
||||
<div class="stat-value">{{ stats.activeTenants || '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">今日 AI 调用</div>
|
||||
<div class="stat-value">{{ stats.todayCalls }}</div>
|
||||
<div class="stat-value">{{ stats.todayCalls.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">今日 Token 消耗</div>
|
||||
<div class="stat-value">{{ stats.todayTokens.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">本周 AI 调用</div>
|
||||
<div class="stat-value">{{ stats.weekCalls.toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="chart-section">
|
||||
<div class="chart-section" v-loading="chartLoading">
|
||||
<div class="chart-container" ref="chartRef"></div>
|
||||
</div>
|
||||
|
||||
|
||||
179
frontend/src/views/error/index.vue
Normal file
179
frontend/src/views/error/index.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 统一错误页面
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const errorCode = computed(() => route.query.code || 'UNKNOWN_ERROR')
|
||||
const errorMessage = computed(() => route.query.message || '发生了未知错误')
|
||||
const traceId = computed(() => route.query.trace_id || '')
|
||||
const statusCode = computed(() => route.query.status || '500')
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const errorConfig = computed(() => {
|
||||
const configs = {
|
||||
'UNAUTHORIZED': { icon: 'Lock', title: '访问受限', color: '#e67e22' },
|
||||
'FORBIDDEN': { icon: 'CircleClose', title: '权限不足', color: '#e74c3c' },
|
||||
'NOT_FOUND': { icon: 'Search', title: '页面未找到', color: '#3498db' },
|
||||
'VALIDATION_ERROR': { icon: 'Warning', title: '请求参数错误', color: '#f39c12' },
|
||||
'INTERNAL_ERROR': { icon: 'Close', title: '服务器错误', color: '#e74c3c' },
|
||||
'SERVICE_UNAVAILABLE': { icon: 'Tools', title: '服务暂时不可用', color: '#9b59b6' },
|
||||
'NETWORK_ERROR': { icon: 'Connection', title: '网络连接失败', color: '#95a5a6' },
|
||||
'UNKNOWN_ERROR': { icon: 'QuestionFilled', title: '未知错误', color: '#7f8c8d' }
|
||||
}
|
||||
return configs[errorCode.value] || configs['UNKNOWN_ERROR']
|
||||
})
|
||||
|
||||
const copyTraceId = async () => {
|
||||
if (!traceId.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(traceId.value)
|
||||
copied.value = true
|
||||
ElMessage.success('追踪码已复制')
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/dashboard')
|
||||
const retry = () => router.back()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-container">
|
||||
<div class="error-icon-wrapper" :style="{ background: errorConfig.color + '20', color: errorConfig.color }">
|
||||
<el-icon :size="48">
|
||||
<component :is="errorConfig.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<h1 class="error-title">{{ errorConfig.title }}</h1>
|
||||
<div class="status-code">HTTP {{ statusCode }}</div>
|
||||
<p class="error-message">{{ errorMessage }}</p>
|
||||
|
||||
<div class="trace-section" v-if="traceId">
|
||||
<div class="trace-label">问题追踪码</div>
|
||||
<div class="trace-id-box" @click="copyTraceId">
|
||||
<code class="trace-id">{{ traceId }}</code>
|
||||
<el-button type="primary" link size="small">
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="trace-tip">如需技术支持,请提供此追踪码</p>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" @click="retry">重试</el-button>
|
||||
<el-button @click="goHome">返回首页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.error-icon-wrapper {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 15px;
|
||||
color: #606266;
|
||||
margin: 0 0 24px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.trace-section {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.trace-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trace-id-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.trace-id-box:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.trace-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user