Merge branch 'token' into ai-hub

This commit is contained in:
chenchun
2025-12-04 16:35:17 +08:00
28 changed files with 2921 additions and 688 deletions

View File

@@ -1,9 +1,8 @@
import type { GetSessionListVO } from './types';
import { get, post } from '@/utils/request';
import { del, get, post, put } from '@/utils/request';
// 获取当前用户的模型列表
export function getModelList() {
// return get<GetSessionListVO[]>('/system/model/modelList');
return get<GetSessionListVO[]>('/ai-chat/model').json();
}
// 申请ApiKey
@@ -21,10 +20,99 @@ export function getRechargeLog() {
}
// 查询用户近7天token消耗
export function getLast7DaysTokenUsage() {
return get<any>('/usage-statistics/last7Days-token-usage').json();
// tokenId: 可选传入则查询该token的用量不传则查询全部
export function getLast7DaysTokenUsage(tokenId?: string) {
const url = tokenId
? `/usage-statistics/last7Days-token-usage?tokenId=${tokenId}`
: '/usage-statistics/last7Days-token-usage';
return get<any>(url).json();
}
// 查询用户token消耗各模型占比
export function getModelTokenUsage() {
return get<any>('/usage-statistics/model-token-usage').json();
// tokenId: 可选传入则查询该token的用量不传则查询全部
export function getModelTokenUsage(tokenId?: string) {
const url = tokenId
? `/usage-statistics/model-token-usage?tokenId=${tokenId}`
: '/usage-statistics/model-token-usage';
return get<any>(url).json();
}
// 获取当前用户得token列表
export function getTokenList(params?: {
skipCount?: number;
maxResultCount?: number;
orderByColumn?: string;
isAsc?: string;
}) {
// 构建查询参数
const queryParams = new URLSearchParams();
if (params?.skipCount !== undefined) {
queryParams.append('SkipCount', params.skipCount.toString());
}
if (params?.maxResultCount !== undefined) {
queryParams.append('MaxResultCount', params.maxResultCount.toString());
}
if (params?.orderByColumn) {
queryParams.append('OrderByColumn', params.orderByColumn);
}
if (params?.isAsc) {
queryParams.append('IsAsc', params.isAsc);
}
const queryString = queryParams.toString();
const url = queryString ? `/token/list?${queryString}` : '/token/list';
return get<any>(url).json();
}
// 创建token
export function createToken(data: any) {
return post<any>('/token', data).json();
}
// 编辑token
export function editToken(data: any) {
return put('/token', data).json();
}
// 删除token
export function deleteToken(id: string) {
return del(`/token/${id}`).json();
}
// 启用token
export function enableToken(id: string) {
return post(`/token/${id}/enable`).json();
}
// 禁用token
export function disableToken(id: string) {
return post(`/token/${id}/disable`).json();
}
// 新增接口2
// 获取可选择的token信息
export function getSelectableTokenInfo() {
return get<any>('/token/select-list').json();
}
/*
返回数据
[
{
"tokenId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "string",
"isDisabled": true
}
] */
// 获取当前用户尊享包不同token用量占比饼图
export function getPremiumPackageTokenUsage() {
return get<any>('/usage-statistics/premium-token-usage/by-token').json();
}
/* 返回数据
[
{
"tokenId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tokenName": "string",
"tokens": 0,
"percentage": 0
}
] */

View File

@@ -1,7 +1,36 @@
<script lang="ts" setup>
import { Clock, Coin, TrophyBase, WarningFilled } from '@element-plus/icons-vue';
import { PieChart as EPieChart } from 'echarts/charts';
import {
GraphicComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { getPremiumPackageTokenUsage } from '@/api';
import { showProductPackage } from '@/utils/product-package.ts';
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
// Emits
const emit = defineEmits<{
refresh: [];
}>();
// 注册必要的组件
echarts.use([
EPieChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GraphicComponent,
CanvasRenderer,
]);
// Props
interface Props {
packageData: {
@@ -15,14 +44,11 @@ interface Props {
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
// Emits
const emit = defineEmits<{
refresh: [];
}>();
// 饼图相关
const tokenPieChart = ref(null);
let tokenPieChartInstance: any = null;
const tokenUsageData = ref<any[]>([]);
const tokenUsageLoading = ref(false);
// 计算属性
const usagePercent = computed(() => {
@@ -64,6 +90,193 @@ function formatRawNumber(num: number): string {
function onProductPackage() {
showProductPackage();
}
// 获取Token用量数据
async function fetchTokenUsageData() {
try {
tokenUsageLoading.value = true;
const res = await getPremiumPackageTokenUsage();
if (res.data) {
tokenUsageData.value = res.data;
updateTokenPieChart();
}
}
catch (error) {
console.error('获取Token用量数据失败:', error);
}
finally {
tokenUsageLoading.value = false;
}
}
// 初始化Token饼图
function initTokenPieChart() {
if (tokenPieChart.value) {
tokenPieChartInstance = echarts.init(tokenPieChart.value);
}
window.addEventListener('resize', resizeTokenPieChart);
}
// 更新Token饼图
function updateTokenPieChart() {
if (!tokenPieChartInstance)
return;
// 空数据状态
if (tokenUsageData.value.length === 0) {
const emptyOption = {
graphic: [
{
type: 'group',
left: 'center',
top: 'center',
children: [
{
type: 'circle',
shape: {
r: 80,
},
style: {
fill: '#f5f7fa',
stroke: '#e9ecef',
lineWidth: 2,
},
},
{
type: 'text',
style: {
text: '📊',
fontSize: 48,
x: -24,
y: -40,
},
},
{
type: 'text',
style: {
text: '暂无数据',
fontSize: 18,
fontWeight: 'bold',
fill: '#909399',
x: -36,
y: 20,
},
},
{
type: 'text',
style: {
text: '还没有Token使用记录',
fontSize: 14,
fill: '#c0c4cc',
x: -70,
y: 50,
},
},
],
},
],
};
tokenPieChartInstance.setOption(emptyOption, true);
return;
}
const data = tokenUsageData.value.map(item => ({
name: item.tokenName,
value: item.tokens,
}));
const option = {
graphic: [],
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
},
legend: {
show: false, // 隐藏图例,使用标签线代替
},
series: [
{
name: 'Token用量',
type: 'pie',
radius: ['50%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
position: 'outside',
formatter: (params: any) => {
const item = tokenUsageData.value.find(d => d.tokenName === params.name);
const percentage = item?.percentage || 0;
return `${params.name}: ${percentage.toFixed(1)}%`;
},
fontSize: 13,
fontWeight: 600,
color: '#333',
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
labelLine: {
show: true,
length: 15,
length2: 10,
lineStyle: {
width: 1.5,
},
},
data,
},
],
};
tokenPieChartInstance.setOption(option, true);
}
// 调整饼图大小
function resizeTokenPieChart() {
tokenPieChartInstance?.resize();
}
// 根据索引获取Token颜色
function getTokenColor(index: number) {
const colors = [
'#667eea',
'#764ba2',
'#f093fb',
'#f5576c',
'#4facfe',
'#00f2fe',
'#43e97b',
'#38f9d7',
'#fa709a',
'#fee140',
];
return colors[index % colors.length];
}
onMounted(() => {
initTokenPieChart();
fetchTokenUsageData();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeTokenPieChart);
tokenPieChartInstance?.dispose();
});
</script>
<template>
@@ -231,6 +444,98 @@ function onProductPackage() {
</el-alert>
</div>
</el-card>
<!-- Token用量占比卡片 -->
<el-card v-loading="tokenUsageLoading" class="package-card token-usage-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="card-header-left">
<el-icon class="header-icon token-icon">
<i-ep-pie-chart />
</el-icon>
<div class="header-text">
<span class="header-title">各API密钥用量占比</span>
<span class="header-subtitle">Premium APIKEY Usage Distribution</span>
</div>
</div>
</div>
</template>
<div class="token-usage-content">
<div class="chart-container-wrapper">
<div ref="tokenPieChart" class="token-pie-chart" />
</div>
<!-- Token统计列表 -->
<div v-if="tokenUsageData.length > 0" class="token-stats-list">
<div
v-for="(item, index) in tokenUsageData"
:key="item.tokenId"
class="token-stat-item"
>
<div class="token-stat-header">
<div class="token-rank">
<span class="rank-badge" :class="`rank-${index + 1}`">#{{ index + 1 }}</span>
</div>
<div class="token-name">
<el-icon><i-ep-key /></el-icon>
<span>{{ item.tokenName }}</span>
</div>
</div>
<div class="token-stat-data">
<div class="stat-tokens">
<span class="label">用量:</span>
<span class="value">{{ item.tokens.toLocaleString() }}</span>
<span class="unit">tokens</span>
</div>
<div class="stat-percentage">
<el-progress
:percentage="item.percentage"
:color="getTokenColor(index)"
:stroke-width="8"
:show-text="true"
/>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-else
description="暂无Token使用数据"
class="token-empty-state"
:image-size="120"
>
<template #image>
<div class="custom-empty-image">
<el-icon class="empty-main-icon">
<i-ep-pie-chart />
</el-icon>
<div class="empty-decoration">
<div class="decoration-circle" />
<div class="decoration-circle" />
<div class="decoration-circle" />
</div>
</div>
</template>
<template #description>
<div class="empty-description">
<h3 class="empty-title">
暂无Token使用数据
</h3>
<p class="empty-text">
当您开始使用Token后这里将展示各Token的用量占比统计
</p>
<div class="empty-tips">
<el-icon><i-ep-info-filled /></el-icon>
<span>创建并使用Token后即可查看详细的用量分析</span>
</div>
</div>
</template>
</el-empty>
</div>
</el-card>
</div>
</template>
@@ -495,6 +800,270 @@ function onProductPackage() {
font-size: 13px;
}
/* Token用量占比卡片 */
.token-usage-card {
margin-top: 24px;
}
.token-icon {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
color: #0284c7;
}
.token-usage-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.chart-container-wrapper {
width: 100%;
background: linear-gradient(135deg, #fafbfc 0%, #f5f6f8 100%);
border-radius: 12px;
padding: 20px;
}
.token-pie-chart {
width: 100%;
height: 400px;
}
/* Token统计列表 */
.token-stats-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.token-stat-item {
padding: 16px;
background: linear-gradient(135deg, #fafbfc 0%, #ffffff 100%);
border-radius: 12px;
border: 1px solid #f0f2f5;
transition: all 0.3s;
&:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
}
}
.token-stat-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.token-rank {
flex-shrink: 0;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&.rank-1 {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
&.rank-2 {
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
}
&.rank-3 {
background: linear-gradient(135deg, #fb923c 0%, #ea580c 100%);
}
}
.token-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
.el-icon {
color: #667eea;
font-size: 18px;
}
}
.token-stat-data {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-tokens {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 14px;
.label {
color: #909399;
}
.value {
font-size: 20px;
font-weight: 700;
color: #667eea;
}
.unit {
color: #909399;
font-size: 12px;
}
}
.stat-percentage {
:deep(.el-progress__text) {
font-size: 14px !important;
font-weight: 700;
}
}
/* 空状态 */
.token-empty-state {
padding: 60px 20px;
:deep(.el-empty__image) {
width: auto;
}
}
.custom-empty-image {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 140px;
margin: 0 auto;
}
.empty-main-icon {
font-size: 80px;
color: #667eea;
position: relative;
z-index: 2;
animation: float 3s ease-in-out infinite;
}
.empty-decoration {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
&:nth-child(1) {
width: 60px;
height: 60px;
top: 10%;
left: 10%;
animation: pulse 2s ease-in-out infinite;
}
&:nth-child(2) {
width: 40px;
height: 40px;
bottom: 20%;
right: 15%;
animation: pulse 2s ease-in-out infinite 0.5s;
}
&:nth-child(3) {
width: 30px;
height: 30px;
top: 60%;
right: 5%;
animation: pulse 2s ease-in-out infinite 1s;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.2);
opacity: 0.3;
}
}
.empty-description {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 20px;
}
.empty-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.empty-text {
margin: 0;
font-size: 14px;
color: #606266;
line-height: 1.6;
max-width: 360px;
}
.empty-tips {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
border-radius: 8px;
font-size: 13px;
color: #667eea;
margin-top: 8px;
.el-icon {
font-size: 16px;
flex-shrink: 0;
}
}
/* 警告卡片 */
.warning-card {
border-radius: 12px;

View File

@@ -0,0 +1,358 @@
<script lang="ts" setup>
import { ElMessage } from 'element-plus';
import { computed, ref, watch } from 'vue';
interface TokenFormData {
id?: string;
name: string;
expireTime: string;
premiumQuotaLimit: number | null;
quotaUnit: string;
}
interface Props {
visible: boolean;
mode: 'create' | 'edit';
formData?: TokenFormData;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
mode: 'create',
formData: () => ({
name: '',
expireTime: '',
premiumQuotaLimit: 0,
quotaUnit: '万',
}),
});
const emit = defineEmits<{
'update:visible': [value: boolean];
'confirm': [data: TokenFormData];
}>();
const localFormData = ref<TokenFormData>({
name: '',
expireTime: '',
premiumQuotaLimit: 0,
quotaUnit: '万',
});
const submitting = ref(false);
const neverExpire = ref(false); // 永不过期开关
const unlimitedQuota = ref(false); // 无限制额度开关
const quotaUnitOptions = [
{ label: '个', value: '个', multiplier: 1 },
{ label: '十', value: '十', multiplier: 10 },
{ label: '百', value: '百', multiplier: 100 },
{ label: '千', value: '千', multiplier: 1000 },
{ label: '万', value: '万', multiplier: 10000 },
{ label: '亿', value: '亿', multiplier: 100000000 },
];
// 监听visible变化重置表单
watch(() => props.visible, (newVal) => {
if (newVal) {
if (props.mode === 'edit' && props.formData) {
// 编辑模式:转换后端数据为展示数据
const quota = props.formData.premiumQuotaLimit || 0;
let displayValue = quota;
let unit = '个';
// 判断是否无限制
unlimitedQuota.value = quota === 0;
if (!unlimitedQuota.value) {
// 自动选择合适的单位
if (quota >= 100000000 && quota % 100000000 === 0) {
displayValue = quota / 100000000;
unit = '亿';
}
else if (quota >= 10000 && quota % 10000 === 0) {
displayValue = quota / 10000;
unit = '万';
}
else if (quota >= 1000 && quota % 1000 === 0) {
displayValue = quota / 1000;
unit = '千';
}
else if (quota >= 100 && quota % 100 === 0) {
displayValue = quota / 100;
unit = '百';
}
else if (quota >= 10 && quota % 10 === 0) {
displayValue = quota / 10;
unit = '十';
}
}
// 判断是否永不过期
neverExpire.value = !props.formData.expireTime;
localFormData.value = {
...props.formData,
premiumQuotaLimit: displayValue,
quotaUnit: unit,
};
}
else {
// 新增模式:重置表单
localFormData.value = {
name: '',
expireTime: '',
premiumQuotaLimit: 1,
quotaUnit: '万',
};
neverExpire.value = false;
unlimitedQuota.value = false;
}
submitting.value = false;
}
});
// 监听永不过期开关
watch(neverExpire, (newVal) => {
if (newVal) {
localFormData.value.expireTime = '';
}
});
// 监听无限制开关
watch(unlimitedQuota, (newVal) => {
if (newVal) {
localFormData.value.premiumQuotaLimit = 0;
}
});
// 关闭对话框
function handleClose() {
if (submitting.value)
return;
emit('update:visible', false);
}
// 确认提交
async function handleConfirm() {
if (!localFormData.value.name.trim()) {
ElMessage.warning('请输入API密钥名称');
return;
}
if (!neverExpire.value && !localFormData.value.expireTime) {
ElMessage.warning('请选择过期时间');
return;
}
if (!unlimitedQuota.value && localFormData.value.premiumQuotaLimit <= 0) {
ElMessage.warning('请输入有效的配额限制');
return;
}
submitting.value = true;
try {
// 将展示值转换为实际值
let actualQuota = null;
if (!unlimitedQuota.value) {
const unit = quotaUnitOptions.find(u => u.value === localFormData.value.quotaUnit);
actualQuota = localFormData.value.premiumQuotaLimit * (unit?.multiplier || 1);
}
const submitData: TokenFormData = {
...localFormData.value,
expireTime: neverExpire.value ? '' : localFormData.value.expireTime,
premiumQuotaLimit: actualQuota,
};
emit('confirm', submitData);
}
finally {
// 注意:这里不设置 submitting.value = false
// 因为父组件会关闭对话框watch会重置状态
}
}
const dialogTitle = computed(() => props.mode === 'create' ? '新增 API密钥' : '编辑 API密钥');
</script>
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="540px"
:close-on-click-modal="false"
:show-close="!submitting"
@close="handleClose"
>
<el-form :model="localFormData" label-width="110px" label-position="right">
<el-form-item label="API密钥名称" required>
<el-input
v-model="localFormData.name"
placeholder="例如:生产环境、测试环境、开发环境"
maxlength="50"
show-word-limit
clearable
:disabled="submitting"
>
<template #prefix>
<el-icon><i-ep-collection-tag /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="过期时间">
<div class="form-item-with-switch">
<el-switch
v-model="neverExpire"
active-text="永不过期"
:disabled="submitting"
class="expire-switch"
/>
<el-date-picker
v-if="!neverExpire"
v-model="localFormData.expireTime"
type="datetime"
placeholder="选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 100%"
clearable
:disabled="submitting"
:disabled-date="(time: Date) => time.getTime() < Date.now()"
>
<template #prefix>
<el-icon><i-ep-clock /></el-icon>
</template>
</el-date-picker>
</div>
<div v-if="!neverExpire" class="form-hint">
<el-icon><i-ep-warning /></el-icon>
API密钥将在过期时间后自动失效
</div>
</el-form-item>
<el-form-item label="配额限制">
<div class="form-item-with-switch">
<el-switch
v-model="unlimitedQuota"
active-text="无限制"
:disabled="submitting"
class="quota-switch"
/>
<div v-if="!unlimitedQuota" class="quota-input-group">
<el-input-number
v-model="localFormData.premiumQuotaLimit"
:min="1"
:precision="0"
:controls="true"
controls-position="right"
placeholder="请输入配额"
class="quota-number"
:disabled="submitting"
/>
<el-select
v-model="localFormData.quotaUnit"
class="quota-unit"
placeholder="单位"
:disabled="submitting"
>
<el-option
v-for="option in quotaUnitOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
</div>
<div v-if="!unlimitedQuota" class="form-hint">
<el-icon><i-ep-info-filled /></el-icon>
超出配额后API密钥将无法继续使用
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button :disabled="submitting" @click="handleClose">
取消
</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="submitting"
@click="handleConfirm"
>
{{ mode === 'create' ? '创建' : '保存' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.form-item-with-switch {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.expire-switch,
.quota-switch {
--el-switch-on-color: #67c23a;
}
.quota-input-group {
display: flex;
gap: 10px;
width: 100%;
}
.quota-number {
flex: 1;
:deep(.el-input__wrapper) {
width: 100%;
}
}
.quota-unit {
width: 100px;
}
.form-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
font-size: 13px;
color: #606266;
background: #f4f4f5;
border-radius: 6px;
border-left: 3px solid #409eff;
.el-icon {
font-size: 14px;
color: #409eff;
flex-shrink: 0;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:deep(.el-form-item__label) {
font-weight: 600;
color: #303133;
}
:deep(.el-input__prefix) {
color: #909399;
}
</style>

View File

@@ -11,7 +11,7 @@ import {
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { getLast7DaysTokenUsage, getModelTokenUsage } from '@/api';
import { getLast7DaysTokenUsage, getModelTokenUsage, getSelectableTokenInfo } from '@/api';
// 注册必要的组件
echarts.use([
@@ -48,16 +48,54 @@ const totalTokens = ref(0);
const usageData = ref<any[]>([]);
const modelUsageData = ref<any[]>([]);
// Token选择相关
const selectedTokenId = ref<string>(''); // 空字符串表示查询全部
const tokenOptions = ref<any[]>([]);
const tokenOptionsLoading = ref(false);
// 计算属性:是否有模型数据
const hasModelData = computed(() => modelUsageData.value.length > 0);
// 计算属性当前选择的token名称
const selectedTokenName = computed(() => {
if (!selectedTokenId.value)
return '全部API密钥';
const token = tokenOptions.value.find(t => t.tokenId === selectedTokenId.value);
return token?.name || '未知API密钥';
});
// 获取可选择的Token列表
async function fetchTokenOptions() {
try {
tokenOptionsLoading.value = true;
const res = await getSelectableTokenInfo();
if (res.data) {
// 不再过滤禁用的token全部显示
tokenOptions.value = res.data;
}
}
catch (error) {
console.error('获取API密钥列表失败:', error);
ElMessage.error('获取TAPI密钥列表失败');
}
finally {
tokenOptionsLoading.value = false;
}
}
// Token选择变化
function handleTokenChange() {
fetchUsageData();
}
// 获取用量数据
async function fetchUsageData() {
loading.value = true;
try {
const tokenId = selectedTokenId.value || undefined;
const [res, res2] = await Promise.all([
getLast7DaysTokenUsage(),
getModelTokenUsage(),
getLast7DaysTokenUsage(tokenId),
getModelTokenUsage(tokenId),
]);
usageData.value = res.data || [];
@@ -235,49 +273,47 @@ function updatePieChart() {
formatter: '{a} <br/>{b}: {c} tokens ({d}%)',
},
legend: {
orient: isManyItems ? 'vertical' : 'horizontal',
right: isManyItems ? 10 : 'auto',
bottom: isManyItems ? 0 : 10,
type: isManyItems ? 'scroll' : 'plain',
pageIconColor: '#3a4de9',
pageIconInactiveColor: '#ccc',
pageTextStyle: { color: '#333' },
itemGap: isSmallContainer ? 5 : 10,
itemWidth: isSmallContainer ? 15 : 25,
itemHeight: isSmallContainer ? 10 : 14,
textStyle: {
fontSize: isSmallContainer ? 10 : 12,
},
formatter(name: string) {
return name.length > 15 ? `${name.substring(0, 12)}...` : name;
},
data: data.map(item => item.name),
show: false, // 隐藏图例,使用标签线代替
},
series: [
{
name: '模型用量',
type: 'pie',
radius: ['50%', '70%'],
center: isManyItems ? ['40%', '50%'] : ['50%', '50%'],
avoidLabelOverlap: false,
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
show: true,
position: 'outside',
formatter: '{b}: {d}%',
fontSize: 13,
fontWeight: 600,
color: '#333',
},
emphasis: {
label: {
show: true,
fontSize: '18',
fontSize: 16,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
labelLine: {
show: false,
show: true,
length: 15,
length2: 10,
lineStyle: {
width: 1.5,
},
},
data,
},
@@ -453,6 +489,7 @@ watch([pieContainerSize.width, barContainerSize.width], () => {
onMounted(() => {
initCharts();
fetchTokenOptions();
fetchUsageData();
});
@@ -475,19 +512,56 @@ onBeforeUnmount(() => {
<el-icon><PieChart /></el-icon>
Token用量统计
</h2>
<el-button
:icon="FullScreen"
circle
plain
size="small"
@click="toggleFullscreen"
/>
<div class="header-actions">
<el-select
v-model="selectedTokenId"
placeholder="选择API密钥"
clearable
filterable
:loading="tokenOptionsLoading"
class="token-selector"
@change="handleTokenChange"
>
<el-option label="全部Token" value="">
<div class="token-option">
<el-icon class="option-icon all-icon">
<i-ep-folder-opened />
</el-icon>
<span class="option-label">全部Token</span>
</div>
</el-option>
<el-option
v-for="token in tokenOptions"
:key="token.tokenId"
:label="token.name"
:value="token.tokenId"
:disabled="token.isDisabled"
>
<div class="token-option" :class="{ 'disabled-token': token.isDisabled }">
<el-icon class="option-icon" :class="{ 'disabled-icon': token.isDisabled }">
<i-ep-key />
</el-icon>
<span class="option-label">{{ token.name }}</span>
<el-tag v-if="token.isDisabled" type="info" size="small" effect="plain" class="disabled-tag">
已禁用
</el-tag>
</div>
</el-option>
</el-select>
<el-button
:icon="FullScreen"
circle
plain
size="small"
@click="toggleFullscreen"
/>
</div>
</div>
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">📊 近七天每日Token消耗量</span>
<span class="card-title">📊 近七天每日Token消耗量{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
<el-tag type="primary" size="large" effect="dark">
近七日总计: {{ totalTokens }} tokens
</el-tag>
@@ -501,7 +575,7 @@ onBeforeUnmount(() => {
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">🥧 各模型Token消耗占比</span>
<span class="card-title">🥧 各模型Token消耗占比{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
</div>
</template>
<div class="chart-container">
@@ -512,7 +586,7 @@ onBeforeUnmount(() => {
<el-card v-loading="loading" class="chart-card">
<template #header>
<div class="card-header">
<span class="card-title">📈 各模型总Token消耗量</span>
<span class="card-title">📈 各模型总Token消耗量{{ selectedTokenId ? ` (${selectedTokenName})` : '' }}</span>
</div>
</template>
<div class="chart-container">
@@ -560,6 +634,62 @@ onBeforeUnmount(() => {
border-bottom: 2px solid #e9ecef;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.token-selector {
width: 240px;
}
.token-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
&.disabled-token {
opacity: 0.6;
.option-label {
text-decoration: line-through;
color: #909399;
}
}
}
.option-icon {
color: #667eea;
font-size: 16px;
flex-shrink: 0;
&.all-icon {
color: #409eff;
}
&.disabled-icon {
color: #c0c4cc;
}
}
.option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.disabled-tag {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
padding: 0 6px;
height: 18px;
line-height: 18px;
}
.header h2 {
display: flex;
align-items: center;

View File

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

View File

@@ -69,6 +69,7 @@ declare module 'vue' {
SupportModelList: typeof import('./../src/components/userPersonalCenter/components/SupportModelList.vue')['default']
SvgIcon: typeof import('./../src/components/SvgIcon/index.vue')['default']
SystemAnnouncementDialog: typeof import('./../src/components/SystemAnnouncementDialog/index.vue')['default']
TokenFormDialog: typeof import('./../src/components/userPersonalCenter/components/TokenFormDialog.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']

View File

@@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly VITE_WEB_ENV: string;
readonly VITE_WEB_BASE_API: string;
readonly VITE_API_URL: string;
readonly VITE_BUILD_COMPRESS: string;
readonly VITE_SSO_SEVER_URL: string;
readonly VITE_APP_VERSION: string;
}