2026-05-28 17:18:07 +08:00

756 lines
19 KiB
Vue

<template>
<div class="token-usage-page">
<div class="usage-shell">
<!-- <div class="page-header">
<div>
<div class="title-line">
<span class="title-icon">
<i class="el-icon-data-line"></i>
</span>
<h2>Token用量</h2>
</div>
<p>查看模型调用 Token 消耗调用次数和费用趋势</p>
</div>
<div class="header-actions">
<span class="range-badge">{{ filterTimeText }}</span>
<el-button size="small" icon="el-icon-refresh" class="refresh-btn" :loading="loading" @click="getTokenList">刷新</el-button>
</div>
</div> -->
<div class="stat-row">
<div v-for="item in statCards" :key="item.label" class="stat-card" :class="item.type">
<div class="stat-card-head">
<span>{{ item.label }}</span>
<i :class="item.icon"></i>
</div>
<strong>{{ item.value }}</strong>
<em>{{ item.desc }}</em>
</div>
</div>
<div class="usage-dashboard">
<div class="dashboard-card token-ratio-card">
<div class="dashboard-title">
<h3>Token 使用概览</h3>
<span>{{ rangeLabel }}</span>
</div>
<div class="token-total">{{ formatNumber(summary.total_tokens) }}</div>
<p> Token 消耗</p>
<div ref="tokenRatioChart" class="chart-box token-ratio-chart"></div>
</div>
<div class="dashboard-card model-rank-card">
<div class="dashboard-title">
<h3>模型消耗排行</h3>
<span>当前页数据</span>
</div>
<div ref="modelRankChart" class="chart-box model-rank-chart"></div>
</div>
</div>
<div class="content-card">
<div class="card-header">
<div>
<h3>用量明细</h3>
<p>按模型维度统计 Token 输入输出与费用</p>
</div>
<div class="filter-actions">
<el-select v-model="query.range" size="small" placeholder="快捷范围" @change="handleRangeChange">
<el-option label="最近1小时" value="hour"></el-option>
<el-option label="今天" value="day"></el-option>
<el-option label="最近一周" value="week"></el-option>
</el-select>
<el-input
v-model="query.model"
size="small"
clearable
placeholder="搜索模型"
@keyup.enter.native="handleSearch"
@clear="handleSearch"
></el-input>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
value-format="yyyy-MM-dd"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
></el-date-picker>
</div>
</div>
<el-table v-loading="loading" :data="usageList" class="usage-table" style="width: 100%">
<el-table-column prop="model" label="模型名称" min-width="180" show-overflow-tooltip></el-table-column>
<el-table-column prop="request_count" label="调用次数" width="120"></el-table-column>
<el-table-column prop="prompt_tokens" label="输入Token" min-width="140"></el-table-column>
<el-table-column prop="completion_tokens" label="输出Token" min-width="140"></el-table-column>
<el-table-column prop="total_tokens" label="总Token" min-width="140"></el-table-column>
<el-table-column prop="amount" label="预估费用" width="130">
<template slot-scope="scope">¥ {{ formatAmount(scope.row.amount) }}</template>
</el-table-column>
<el-table-column prop="usage_time" label="使用时间" min-width="170" show-overflow-tooltip></el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="query.current_page"
:page-size="query.page_size"
:page-sizes="[10, 20, 50, 100]"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
></el-pagination>
</div>
</div>
</div>
</div>
</template>
<script>
import echarts from 'echarts'
import { reqTokenUsage } from '@/api/model/model'
export default {
name: 'TokenUsage',
data() {
return {
loading: false,
dateRange: [],
summary: {
request_count: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
amount: 0
},
statCards: [
{ label: '总消耗 Token', value: '0', desc: '当前筛选范围', type: 'primary', icon: 'el-icon-coin' },
{ label: '调用次数', value: '0', desc: '当前筛选范围', type: 'success', icon: 'el-icon-s-promotion' },
{ label: '预估费用', value: '¥ 0.00', desc: '按当前单价估算', type: 'warning', icon: 'el-icon-wallet' },
{ label: '输入/输出 Token', value: '0 / 0', desc: 'Prompt / Completion', type: 'purple', icon: 'el-icon-pie-chart' }
],
usageList: [],
total: 0,
tokenRatioChart: null,
modelRankChart: null,
query:{
userid:this.getCurrentUserId(),
range:'week',
start_time:'',
end_time:'',
model: '',
current_page:1,
page_size:10
}
}
},
computed: {
rangeLabel() {
const labelMap = {
hour: '最近1小时',
day: '今天',
week: '最近一周'
}
return labelMap[this.query.range] || '当前范围'
},
filterTimeText() {
if (this.query.start_time && this.query.end_time) {
return `${this.query.start_time}${this.query.end_time}`
}
return this.rangeLabel
},
modelUsageRank() {
const maxTokens = Math.max(...this.usageList.map(item => Number(item.total_tokens || 0)), 0)
return this.usageList
.map(item => ({
model: item.model,
total_tokens: Number(item.total_tokens || 0),
percent: maxTokens ? Math.round((Number(item.total_tokens || 0) / maxTokens) * 100) : 0
}))
.sort((a, b) => b.total_tokens - a.total_tokens)
.slice(0, 5)
}
},
created() {
this.getTokenList()
},
mounted() {
this.initCharts()
this.renderCharts()
window.addEventListener('resize', this.resizeCharts)
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeCharts)
if (this.tokenRatioChart) {
this.tokenRatioChart.dispose()
this.tokenRatioChart = null
}
if (this.modelRankChart) {
this.modelRankChart.dispose()
this.modelRankChart = null
}
},
methods: {
getCurrentUserId() {
return sessionStorage.getItem('userId') || localStorage.getItem('userId') || ''
},
handleDateChange(value) {
if (value && value.length === 2) {
this.query.start_time = value[0]
this.query.end_time = value[1]
} else {
this.query.start_time = ''
this.query.end_time = ''
}
this.query.current_page = 1
this.getTokenList()
},
handleRangeChange() {
this.query.current_page = 1
this.getTokenList()
},
handleSearch() {
this.query.current_page = 1
this.getTokenList()
},
async getTokenList() {
this.query.userid = this.getCurrentUserId()
this.loading = true
try {
const res = await reqTokenUsage(this.query)
if(res.status === true) {
const data = res.data || {}
this.summary = {
...this.summary,
...(data.summary || {})
}
this.updateStatCards()
this.usageList = data.items || []
this.total = Number(data.total_count || 0)
this.query.current_page = Number(data.current_page || this.query.current_page)
this.query.page_size = Number(data.page_size || this.query.page_size)
this.$nextTick(this.renderCharts)
}
console.log('token用量',res);
} finally {
this.loading = false
}
},
updateStatCards() {
this.statCards = [
{ label: '总消耗 Token', value: this.formatNumber(this.summary.total_tokens), desc: '当前筛选范围', type: 'primary', icon: 'el-icon-coin' },
{ label: '调用次数', value: this.formatNumber(this.summary.request_count), desc: '当前筛选范围', type: 'success', icon: 'el-icon-s-promotion' },
{ label: '预估费用', value: `¥ ${this.formatAmount(this.summary.amount)}`, desc: '按当前单价估算', type: 'warning', icon: 'el-icon-wallet' },
{ label: '输入/输出 Token', value: `${this.formatNumber(this.summary.prompt_tokens)} / ${this.formatNumber(this.summary.completion_tokens)}`, desc: 'Prompt / Completion', type: 'purple', icon: 'el-icon-pie-chart' }
]
},
formatNumber(value) {
return Number(value || 0).toLocaleString()
},
formatAmount(value) {
return Number(value || 0).toFixed(6).replace(/0+$/, '').replace(/\.$/, '.00')
},
initCharts() {
if (this.$refs.tokenRatioChart && !this.tokenRatioChart) {
this.tokenRatioChart = echarts.init(this.$refs.tokenRatioChart)
}
if (this.$refs.modelRankChart && !this.modelRankChart) {
this.modelRankChart = echarts.init(this.$refs.modelRankChart)
}
},
renderCharts() {
if (!this.$refs.tokenRatioChart || !this.$refs.modelRankChart) return
this.initCharts()
this.renderTokenRatioChart()
this.renderModelRankChart()
},
renderTokenRatioChart() {
if (!this.tokenRatioChart) return
const promptTokens = Number(this.summary.prompt_tokens || 0)
const completionTokens = Number(this.summary.completion_tokens || 0)
const hasData = promptTokens || completionTokens
this.tokenRatioChart.setOption({
tooltip: {
trigger: 'item',
formatter: '{b}: {c} Token ({d}%)'
},
legend: {
bottom: 0,
icon: 'circle',
textStyle: {
color: '#606266'
}
},
color: ['#409eff', '#67c23a'],
series: [
{
name: 'Token占比',
type: 'pie',
radius: ['52%', '72%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
label: {
formatter: '{b}\n{d}%'
},
data: hasData
? [
{ name: '输入Token', value: promptTokens },
{ name: '输出Token', value: completionTokens }
]
: [{ name: '暂无数据', value: 1 }],
itemStyle: {
borderColor: '#fff',
borderWidth: 3
}
}
]
})
},
renderModelRankChart() {
if (!this.modelRankChart) return
const rankList = this.modelUsageRank.slice().reverse()
this.modelRankChart.setOption({
grid: {
top: 12,
right: 24,
bottom: 20,
left: 92
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: params => {
const item = params && params[0]
return item ? `${item.name}<br/>${this.formatNumber(item.value)} Token` : ''
}
},
xAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
lineStyle: {
color: '#edf1f7'
}
}
},
yAxis: {
type: 'category',
data: rankList.map(item => item.model || '-'),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#606266',
width: 82,
overflow: 'truncate'
}
},
series: [
{
name: 'Token消耗',
type: 'bar',
barWidth: 12,
data: rankList.map(item => item.total_tokens),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#a78bfa' },
{ offset: 1, color: '#7c3aed' }
]),
barBorderRadius: [0, 8, 8, 0]
},
label: {
show: true,
position: 'right',
color: '#344054',
formatter: params => this.formatNumber(params.value)
}
}
]
})
},
resizeCharts() {
if (this.tokenRatioChart) this.tokenRatioChart.resize()
if (this.modelRankChart) this.modelRankChart.resize()
},
handleSizeChange(size) {
this.query.page_size = size
this.query.current_page = 1
this.getTokenList()
},
handleCurrentChange(page) {
this.query.current_page = page
this.getTokenList()
},
handleReset() {
this.dateRange = []
this.query = {
userid: this.getCurrentUserId(),
range: 'week',
start_time: '',
end_time: '',
model: '',
current_page: 1,
page_size: 10
}
this.getTokenList()
}
}
}
</script>
<style lang="less" scoped>
.token-usage-page {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 34%),
linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
}
.usage-shell {
min-height: calc(100vh - 48px);
padding: 24px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
}
.page-header,
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.page-header {
align-items: center;
padding: 20px 22px;
margin-bottom: 20px;
overflow: hidden;
color: #ffffff;
background: linear-gradient(135deg, #1e6fff 0%, #409eff 48%, #7c3aed 100%);
border-radius: 18px;
box-shadow: 0 14px 34px rgba(64, 158, 255, 0.22);
p {
color: rgba(255, 255, 255, 0.82);
}
}
.title-line {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
h2 {
margin: 0;
color: #ffffff;
font-size: 24px;
}
}
.title-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: #ffffff;
font-size: 18px;
background: rgba(255, 255, 255, 0.18);
border-radius: 12px;
}
.page-header p,
.card-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.refresh-btn {
color: #1e6fff;
border: none;
border-radius: 10px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.range-badge {
padding: 7px 12px;
color: #ffffff;
font-size: 12px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
position: relative;
overflow: hidden;
padding: 20px;
border-radius: 16px;
border: 1px solid #edf1f7;
background: #ffffff;
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.04);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(31, 45, 61, 0.08);
}
span,
em {
display: block;
color: #909399;
font-style: normal;
font-size: 13px;
}
strong {
display: block;
margin: 10px 0 8px;
color: #1f2d3d;
font-size: 26px;
}
&:before {
position: absolute;
top: 0;
right: 0;
width: 86px;
height: 86px;
content: '';
background: rgba(255, 255, 255, 0.52);
border-radius: 0 0 0 86px;
}
&.primary {
background: linear-gradient(135deg, #eef5ff 0%, #ffffff 100%);
}
&.success {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
}
&.warning {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
}
&.purple {
background: linear-gradient(135deg, #f5f3ff 0%, #ffffff 100%);
}
}
.stat-card-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
i {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
color: #409eff;
font-size: 18px;
background: rgba(64, 158, 255, 0.12);
border-radius: 12px;
}
}
.usage-dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 16px;
margin-bottom: 20px;
}
.dashboard-card {
padding: 22px;
border: 1px solid #edf1f7;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.05);
}
.dashboard-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
margin: 0;
color: #1f2d3d;
font-size: 16px;
}
span {
color: #909399;
font-size: 12px;
}
}
.token-total {
color: #1f2d3d;
font-size: 34px;
font-weight: 700;
line-height: 1.2;
}
.token-ratio-card {
p {
margin: 6px 0 18px;
color: #909399;
font-size: 13px;
}
}
.chart-box {
width: 100%;
height: 240px;
}
.model-rank-chart {
height: 300px;
}
.ratio-list,
.rank-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.ratio-label,
.rank-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
strong {
color: #1f2d3d;
font-weight: 700;
}
}
.content-card {
padding: 22px;
border: 1px solid #edf1f7;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(31, 45, 61, 0.05);
}
.card-header {
margin-bottom: 18px;
h3 {
margin: 0 0 8px;
color: #1f2d3d;
}
}
.filter-actions {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: #f6f8fb;
border: 1px solid #edf1f7;
border-radius: 12px;
/deep/ .el-select {
width: 110px;
}
/deep/ .el-input {
width: 160px;
}
/deep/ .el-input__inner {
border-color: transparent;
}
}
.usage-table {
border-radius: 12px;
overflow: hidden;
/deep/ .el-table__header th {
color: #475467;
font-weight: 700;
background: #f8fafc;
}
/deep/ .el-table__row:hover > td {
background: #f6faff;
}
}
.table-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (max-width: 1100px) {
.stat-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-dashboard {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.stat-row {
grid-template-columns: 1fr;
}
.page-header,
.card-header {
flex-direction: column;
}
.filter-actions {
align-items: flex-start;
flex-direction: column;
width: 100%;
/deep/ .el-date-editor {
width: 100%;
}
}
}
</style>