feat: 新增告警、成本、配额、微信模块及缓存服务
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:
111
2026-01-24 16:53:47 +08:00
parent eab2533c36
commit 6c6c48cf71
29 changed files with 4607 additions and 41 deletions

View File

@@ -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)
}
)

View File

@@ -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'),

View File

@@ -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>

View 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>