feat: 优化Excel导出功能,专注用量数据统计

- 简化导出内容,仅包含用量相关数据
- 保留API Key名称和所有者信息
- 导出详细的分模型用量统计:
  * 今日各模型请求数、费用、输入/输出/总Token
  * 累计各模型请求数、费用、输入/输出/总Token
  * 根据时间筛选条件导出对应周期的模型统计
- 文件名包含时间筛选条件,便于识别数据范围
- 动态设置列宽,优化Excel显示效果
- 移除冗余的配置信息,专注核心用量数据

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Edric Li
2025-09-07 08:33:15 +08:00
parent 8dd58900d6
commit 8f43b9367b
3 changed files with 258 additions and 2 deletions

View File

@@ -15,7 +15,8 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
@@ -1366,6 +1367,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
@@ -1643,6 +1653,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -1710,6 +1733,15 @@
"node": ">= 6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -1766,6 +1798,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2497,6 +2541,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -4083,6 +4136,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
@@ -5126,6 +5191,24 @@
"node": ">= 8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5244,6 +5327,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",

View File

@@ -18,7 +18,8 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.55.0",

View File

@@ -168,6 +168,18 @@
<span>{{ showCheckboxes ? '取消选择' : '选择' }}</span>
</button>
<!-- 导出数据按钮 -->
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 sm:w-auto"
@click="exportToExcel"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-emerald-500 to-green-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-file-excel relative text-emerald-500" />
<span class="relative">导出数据</span>
</button>
<!-- 批量编辑按钮 - 移到刷新按钮旁边 -->
<button
v-if="selectedApiKeys.length > 0"
@@ -1754,6 +1766,7 @@ import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
import { useAuthStore } from '@/stores/auth'
import * as XLSX from 'xlsx'
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
@@ -3207,6 +3220,13 @@ const getWeeklyOpusCostProgressColor = (key) => {
return 'bg-green-500'
}
// 获取总费用进度 - 暂时不用
// const getTotalCostProgress = (key) => {
// if (!key.totalCostLimit || key.totalCostLimit === 0) return 0
// const percentage = ((key.totalCost || 0) / key.totalCostLimit) * 100
// return Math.min(percentage, 100)
// }
// 显示使用详情
const showUsageDetails = (apiKey) => {
selectedApiKeyForDetail.value = apiKey
@@ -3232,6 +3252,137 @@ const clearSearch = () => {
currentPage.value = 1
}
// 导出数据到Excel
const exportToExcel = () => {
try {
// 准备导出的数据 - 仅导出用量数据
const exportData = sortedApiKeys.value.map((key) => {
// 基础数据
const baseData = {
'API Key名称': key.name || '',
所有者: key.ownerDisplayName || '',
// 当前筛选时间段的统计
请求总数: getPeriodRequests(key),
'总费用($)': calculatePeriodCost(key).toFixed(4),
总Token数: getPeriodTokens(key),
// 今日统计
今日请求数: key.usage?.daily?.requests || 0,
'今日费用($)': (key.dailyCost || 0).toFixed(4),
今日Token数: key.usage?.daily?.totalTokens || 0,
// 累计总统计
累计总请求数: key.usage?.total?.requests || 0,
'累计总费用($)': (key.totalCost || 0).toFixed(4),
累计总Token数: key.usage?.total?.totalTokens || 0
}
// 添加分模型统计(如果有)
const modelStats = {}
// 处理每日模型统计
if (key.usage?.daily?.models) {
Object.entries(key.usage.daily.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_') // 处理模型名中的特殊字符
modelStats[`今日_${modelName}_请求数`] = stats.requests || 0
modelStats[`今日_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`今日_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`今日_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`今日_${modelName}_总Token`] = stats.totalTokens || 0
})
}
// 处理总计模型统计
if (key.usage?.total?.models) {
Object.entries(key.usage.total.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_')
modelStats[`累计_${modelName}_请求数`] = stats.requests || 0
modelStats[`累计_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`累计_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`累计_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`累计_${modelName}_总Token`] = stats.totalTokens || 0
})
}
// 处理筛选时间段的模型统计
if (globalDateFilter.preset === '7days' && key.usage?.weekly?.models) {
Object.entries(key.usage.weekly.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_')
modelStats[`本周_${modelName}_请求数`] = stats.requests || 0
modelStats[`本周_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`本周_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`本周_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`本周_${modelName}_总Token`] = stats.totalTokens || 0
})
} else if (globalDateFilter.preset === '30days' && key.usage?.monthly?.models) {
Object.entries(key.usage.monthly.models).forEach(([model, stats]) => {
const modelName = model.replace(/[:/]/g, '_')
modelStats[`本月_${modelName}_请求数`] = stats.requests || 0
modelStats[`本月_${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`本月_${modelName}_输入Token`] = stats.inputTokens || 0
modelStats[`本月_${modelName}_输出Token`] = stats.outputTokens || 0
modelStats[`本月_${modelName}_总Token`] = stats.totalTokens || 0
})
}
return { ...baseData, ...modelStats }
})
// 创建工作簿
const ws = XLSX.utils.json_to_sheet(exportData)
// 动态设置列宽
const headers = Object.keys(exportData[0] || {})
const colWidths = headers.map((header) => {
if (header.includes('名称') || header.includes('所有者')) return { wch: 20 }
if (header.includes('费用')) return { wch: 15 }
if (header.includes('Token')) return { wch: 15 }
if (header.includes('请求')) return { wch: 12 }
return { wch: 15 }
})
ws['!cols'] = colWidths
// 创建工作簿
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '用量统计')
// 生成文件名(包含时间戳和筛选条件)
const now = new Date()
const timestamp =
now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
'_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0')
let timeRangeLabel = ''
if (globalDateFilter.type === 'preset') {
const presetLabels = {
today: '今日',
'7days': '最近7天',
'30days': '最近30天',
all: '全部时间'
}
timeRangeLabel = presetLabels[globalDateFilter.preset] || globalDateFilter.preset
} else {
timeRangeLabel = '自定义时间'
}
const filename = `API_Keys_用量统计_${timeRangeLabel}_${timestamp}.xlsx`
// 导出文件
XLSX.writeFile(wb, filename)
showToast(`成功导出 ${exportData.length} 条API Key用量数据`, 'success')
} catch (error) {
console.error('导出失败:', error)
showToast('导出失败,请重试', 'error')
}
}
// 监听筛选条件变化,重置页码和选中状态
// 监听筛选条件变化(不包括搜索),清空选中状态
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {