Merge remote-tracking branch 'origin/ai-hub' into ai-hub

This commit is contained in:
ccnetcore
2025-08-10 11:53:52 +08:00
10 changed files with 545 additions and 37 deletions

View File

@@ -5,6 +5,8 @@
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElMessage": true,
"ElMessageBox": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,

View File

@@ -38,6 +38,7 @@
"@vueuse/core": "^13.5.0",
"@vueuse/integrations": "^13.5.0",
"driver.js": "^1.3.6",
"echarts": "^6.0.0",
"element-plus": "^2.10.4",
"fingerprintjs": "^0.5.3",
"hook-fetch": "^2.0.4-beta.1",

View File

@@ -35,6 +35,9 @@ importers:
driver.js:
specifier: ^1.3.6
version: 1.3.6
echarts:
specifier: ^6.0.0
version: 6.0.0
element-plus:
specifier: ^2.10.4
version: 2.10.4(vue@3.5.17(typescript@5.8.3))
@@ -1919,6 +1922,9 @@ packages:
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.165:
resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==}
@@ -4450,6 +4456,9 @@ packages:
peerDependencies:
typescript: '>=4.0.0'
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -4853,6 +4862,9 @@ packages:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -6789,6 +6801,11 @@ snapshots:
duplexer@0.1.2: {}
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.165: {}
element-plus@2.10.4(vue@3.5.17(typescript@5.8.3)):
@@ -9819,6 +9836,8 @@ snapshots:
picomatch: 4.0.2
typescript: 5.8.3
tslib@2.3.0: {}
tslib@2.8.1: {}
type-check@0.4.0:
@@ -10359,4 +10378,8 @@ snapshots:
yocto-queue@1.2.1: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}

View File

@@ -19,3 +19,12 @@ export function getApiKey() {
export function getRechargeLog() {
return get<any>('/recharge/account').json();
}
// 查询用户近7天token消耗
export function getLast7DaysTokenUsage() {
return get<any>('/usage-statistics/last7Days-token-usage').json();
}
// 查询用户token消耗各模型占比
export function getModelTokenUsage() {
return get<any>('/usage-statistics/model-token-usage').json();
}

View File

@@ -17,7 +17,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
title: '弹窗标题',
width: '800px',
width: '1000px',
defaultActive: '',
});
@@ -55,6 +55,7 @@ function handleConfirm() {
<template>
<el-dialog
v-model="visible"
:title="title"
:width="width"

View File

@@ -0,0 +1,426 @@
<script lang="ts" setup>
import { PieChart } from '@element-plus/icons-vue';
import { BarChart, PieChart as EPieChart, LineChart } from 'echarts/charts';
import {
GraphicComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
// 按需引入 ECharts
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { getLast7DaysTokenUsage, getModelTokenUsage } from '@/api';
// 注册必要的组件
echarts.use([
LineChart,
EPieChart,
BarChart,
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
GraphicComponent,
CanvasRenderer,
]);
// 图表引用
const lineChart = ref(null);
const pieChart = ref(null);
const barChart = ref(null);
let lineChartInstance: any = null;
let pieChartInstance: any = null;
let barChartInstance: any = null;
// 数据
const dateRange = ref([
new Date(new Date().setDate(new Date().getDate() - 30)),
new Date(),
]);
const loading = ref(false);
const totalTokens = ref(0);
const usageData = ref<any[]>([]);
const modelUsageData = ref<any[]>([]);
// 日期选择器选项
const pickerOptions = {
disabledDate(time: Date) {
return time > new Date();
},
shortcuts: [{
text: '最近一周',
onClick(picker: any) {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 7);
picker.$emit('pick', [start, end]);
},
}, {
text: '最近一个月',
onClick(picker: any) {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 1);
picker.$emit('pick', [start, end]);
},
}, {
text: '最近三个月',
onClick(picker: any) {
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 3);
picker.$emit('pick', [start, end]);
},
}],
};
// 获取用量数据
async function fetchUsageData() {
loading.value = true;
try {
const [res, res2] = await Promise.all([
getLast7DaysTokenUsage(),
getModelTokenUsage(),
]);
usageData.value = res.data || [];
modelUsageData.value = res2.data || [];
totalTokens.value = usageData.value.reduce((sum, item) => sum + item.tokens, 0);
updateCharts();
}
catch (error) {
console.error('获取用量数据失败:', error);
ElMessage.error('获取用量数据失败');
}
finally {
loading.value = false;
}
}
// 初始化图表
function initCharts() {
if (lineChart.value) {
lineChartInstance = echarts.init(lineChart.value);
}
if (pieChart.value) {
pieChartInstance = echarts.init(pieChart.value);
}
if (barChart.value) {
barChartInstance = echarts.init(barChart.value);
}
window.addEventListener('resize', resizeCharts);
}
// 更新图表数据
function updateCharts() {
updateLineChart();
updatePieChart();
updateBarChart();
}
// 更新折线图
function updateLineChart() {
if (!lineChartInstance)
return;
const dates = usageData.value.map((item) => {
const date = new Date(item.date);
return `${date.getMonth() + 1}${date.getDate()}`;
});
const tokens = usageData.value.map(item => item.tokens);
const option = {
tooltip: {
trigger: 'axis',
formatter: '{b}<br/>用量: {c} ',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
rotate: 45,
},
},
yAxis: {
type: 'value',
name: 'Token用量',
axisLine: {
show: true,
},
},
series: [{
data: tokens,
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(58, 77, 233, 0.8)' },
{ offset: 1, color: 'rgba(58, 77, 233, 0.1)' },
]),
},
itemStyle: {
color: '#3a4de9',
},
lineStyle: {
width: 3,
},
symbolSize: 8,
}],
};
lineChartInstance.setOption(option);
}
// 更新饼图
function updatePieChart() {
if (!pieChartInstance || modelUsageData.value.length === 0)
return;
const data = modelUsageData.value.map(item => ({
name: item.model,
value: item.tokens,
}));
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
data: data.map(item => item.name),
},
series: [
{
name: '模型用量',
type: 'pie',
radius: ['50%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: '18',
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data,
},
],
};
pieChartInstance.setOption(option);
}
// 更新柱状图
function updateBarChart() {
if (!barChartInstance || modelUsageData.value.length === 0)
return;
const models = modelUsageData.value.map(item => item.model);
const tokens = modelUsageData.value.map(item => item.tokens);
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: '{b}<br/>用量: {c} tokens',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'value',
name: 'Token用量',
},
yAxis: {
type: 'category',
data: models,
axisLabel: {
interval: 0,
},
},
series: [
{
name: '用量',
type: 'bar',
data: tokens,
itemStyle: {
color(params: any) {
const colorList = [
'#3a4de9',
'#6a5acd',
'#9370db',
'#8a2be2',
'#9932cc',
'#ba55d3',
'#da70d6',
'#ee82ee',
'#dda0dd',
'#ff00ff',
];
return colorList[params.dataIndex % colorList.length];
},
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
formatter: '{c} ',
},
},
],
};
barChartInstance.setOption(option);
}
// 调整图表大小
function resizeCharts() {
lineChartInstance?.resize();
pieChartInstance?.resize();
barChartInstance?.resize();
}
onMounted(() => {
initCharts();
fetchUsageData();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeCharts);
lineChartInstance?.dispose();
pieChartInstance?.dispose();
barChartInstance?.dispose();
});
</script>
<template>
<div class="usage-statistics">
<div class="header">
<h2>
<el-icon><PieChart /></el-icon>
Token用量统计
</h2>
</div>
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span>近七天每日Token消耗量</span>
<el-tag type="primary">
总计: {{ totalTokens }} tokens
</el-tag>
</div>
</template>
<div class="chart-container">
<div ref="lineChart" class="chart" style="height: 400px;" />
</div>
</el-card>
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span>各模型Token消耗占比</span>
</div>
</template>
<div class="chart-container">
<div ref="pieChart" class="chart" style="height: 400px;" />
</div>
</el-card>
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span>各模型Token消耗量</span>
</div>
</template>
<div class="chart-container">
<div ref="barChart" class="chart" style="height: 400px;" />
</div>
</el-card>
</div>
</template>
<style scoped>
/* 样式保持不变 */
.usage-statistics {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h2 {
display: flex;
align-items: center;
margin: 0;
font-size: 20px;
color: #333;
}
.header .el-icon {
margin-right: 8px;
color: #3a4de9;
}
.chart-card {
margin-bottom: 20px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-container {
width: 100%;
}
.chart {
width: 100%;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -62,6 +62,7 @@ const navItems = [
// { name: 'permission', label: '权限管理', icon: 'Key' },
{ name: 'apiKey', label: 'API密钥', icon: 'Key' },
{ name: 'rechargeLog', label: '充值记录', icon: 'Document' },
{ name: 'usageStatistics', label: '用量统计', icon: 'Histogram' },
];
function openDialog() {
dialogVisible.value = true;
@@ -259,6 +260,10 @@ function openVipGuide() {
<template #user>
<user-management />
</template>
<!-- 用量统计 -->
<template #usageStatistics>
<usage-statistics />
</template>
<!-- 角色管理内容 -->
<template #role>

View File

@@ -111,52 +111,55 @@ watch(
// 封装数据处理逻辑
function handleDataChunk(chunk: AnyObject) {
try {
const reasoningChunk = chunk.choices?.[0].delta.reasoning_content;
if (reasoningChunk) {
// 开始思考链状态
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += reasoningChunk;
}
// 安全获取 delta 和 content
const delta = chunk.choices?.[0]?.delta;
const reasoningChunk = delta?.reasoning_content;
const parsedChunk = delta?.content;
// usage 处理(可以移动到 startSSE 里也可以写这里)
if (chunk.usage) {
const { prompt_tokens, completion_tokens, total_tokens } = chunk.usage;
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.tokenUsage = {
prompt: prompt_tokens,
completion: completion_tokens,
total: total_tokens,
};
}
if (reasoningChunk) {
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += reasoningChunk;
}
// 另一种思考中形式content中有 <think></think> 的格式
// 一开始匹配到 <think> 开始,匹配到 </think> 结束,并处理标签中的内容为思考内容
const parsedChunk = chunk.choices?.[0].delta.content;
if (parsedChunk) {
const thinkStart = parsedChunk.includes('<think>');
const thinkEnd = parsedChunk.includes('</think>');
if (thinkStart) {
if (thinkStart)
isThinking = true;
}
if (thinkEnd) {
if (thinkEnd)
isThinking = false;
}
const latest = bubbleItems.value[bubbleItems.value.length - 1];
if (isThinking) {
// 开始思考链状态
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += parsedChunk
.replace('<think>', '')
.replace('</think>', '');
}
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += parsedChunk.replace('<think>', '').replace('</think>', '');
}
else {
// 结束 思考链状态
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'end';
bubbleItems.value[bubbleItems.value.length - 1].loading = false;
if (bubbleItems.value.length) {
bubbleItems.value[bubbleItems.value.length - 1].content += parsedChunk;
}
latest.thinkingStatus = 'end';
latest.loading = false;
latest.content += parsedChunk;
}
}
}
catch (err) {
// 这里如果使用了中断,会有报错,可以忽略不管
console.error('解析数据时出错:', err);
}
}
@@ -308,8 +311,9 @@ function copy(item: any) {
<div class="footer-wrapper">
<div class="footer-container">
<div class="footer-time">
{{ item.creationTime }}
<span v-if="item.creationTime "> {{ item.creationTime }}</span>
<span v-if="((item.role === 'ai' || item.role === 'assistant') && item?.tokenUsage?.total) " class="footer-token">
{{ ((item.role === 'ai' || item.role === 'assistant') && item?.tokenUsage?.total) ? `token:${item?.tokenUsage?.total}` : '' }}</span>
<el-button icon="DocumentCopy" size="small" circle @click="copy(item)" />
</div>
</div>
@@ -421,4 +425,41 @@ function copy(item: any) {
margin-bottom: 22px;
}
}
.footer-wrapper {
display: flex;
align-items: center;
gap: 10px;
.footer-time {
font-size: 12px;
margin-top: 3px;
.footer-token {
background: rgba(1, 183, 86, 0.53);
padding: 0 4px;
margin: 0 2px;
border-radius: 4px;
color: #ffffff;
}
}
}
.footer-container {
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.loading-container {
font-size: 14px;
color: #333;
padding: 12px;
background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.loading-container span {
display: inline-block;
margin-left: 8px;
}
</style>

View File

@@ -3,8 +3,6 @@ import { useUserStore } from '@/stores/index.js';
// 判断是否是 VIP 用户
export function isUserVip(): boolean {
const userStore = useUserStore();
console.log('isUserVip----', userStore);
const userRoles = userStore.userInfo?.roles ?? [];
return userRoles.some((role: any) => role.roleCode === 'YiXinAi-Vip');
}

View File

@@ -34,6 +34,7 @@ declare module 'vue' {
ElPagination: typeof import('element-plus/es')['ElPagination']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
FilesSelect: typeof import('./../src/components/FilesSelect/index.vue')['default']
IconSelect: typeof import('./../src/components/IconSelect/index.vue')['default']
@@ -49,6 +50,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./../src/components/SvgIcon/index.vue')['default']
UsageStatistics: typeof import('./../src/components/userPersonalCenter/components/UsageStatistics.vue')['default']
UserManagement: typeof import('./../src/components/userPersonalCenter/components/UserManagement.vue')['default']
VerificationCode: typeof import('./../src/components/LoginDialog/components/FormLogin/VerificationCode.vue')['default']
WelecomeText: typeof import('./../src/components/WelecomeText/index.vue')['default']