feat: 增强API Keys Excel导出功能和样式美化

- 添加输入/输出Token列到Excel导出
- 使用xlsx-js-style库实现专业的Excel样式
  - 彩色表头(蓝色/绿色区分)
  - 交替行背景色
  - 正确的列对齐(日期右对齐,名称左对齐)
  - 费用列特殊样式(蓝色加粗)
- 简化导出内容,仅包含用量数据
- Token数量使用K/M单位格式化
- 模型统计也包含输入/输出Token

🤖 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 09:02:36 +08:00
parent 8f43b9367b
commit fc5c60a9b4
3 changed files with 320 additions and 67 deletions

View File

@@ -16,7 +16,8 @@
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
@@ -2348,6 +2349,15 @@
"node": ">=0.10.0"
}
},
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
@@ -2423,6 +2433,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3857,6 +3873,18 @@
}
}
},
"node_modules/printj": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
"license": "Apache-2.0",
"bin": {
"printj": "bin/printj.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -5348,6 +5376,74 @@
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.2.0",
"cfb": "^1.1.4",
"codepage": "~1.14.0",
"commander": "~2.17.1",
"crc-32": "~1.2.0",
"exit-on-epipe": "~1.0.1",
"fflate": "^0.3.8",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/adler-32": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
"license": "Apache-2.0",
"dependencies": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
},
"bin": {
"adler32": "bin/adler32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
"license": "Apache-2.0",
"dependencies": {
"commander": "~2.14.1",
"exit-on-epipe": "~1.0.1"
},
"bin": {
"codepage": "bin/codepage.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"license": "MIT"
},
"node_modules/xlsx-js-style/node_modules/commander": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
"license": "MIT"
},
"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

@@ -19,7 +19,8 @@
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",

View File

@@ -1766,7 +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 * as XLSX from 'xlsx-js-style'
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue'
import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue'
@@ -2538,6 +2538,102 @@ const getPeriodTokens = (key) => {
}
}
// 获取日期范围内的输入token数量
const getPeriodInputTokens = (key) => {
// 根据全局日期筛选器返回对应的输入token数量
if (globalDateFilter.type === 'custom') {
// 自定义日期范围
if (key.usage) {
if (key.usage['custom'] && key.usage['custom'].inputTokens !== undefined) {
return key.usage['custom'].inputTokens
}
if (key.usage.total && key.usage.total.inputTokens !== undefined) {
return key.usage.total.inputTokens
}
}
return 0
} else if (globalDateFilter.preset === 'today') {
return key.usage?.daily?.inputTokens || 0
} else if (globalDateFilter.preset === '7days') {
// 使用 usage['7days'].inputTokens
if (key.usage && key.usage['7days'] && key.usage['7days'].inputTokens !== undefined) {
return key.usage['7days'].inputTokens
}
return 0
} else if (globalDateFilter.preset === '30days') {
// 使用 usage['30days'].inputTokens 或 usage.monthly.inputTokens
if (key.usage) {
if (key.usage['30days'] && key.usage['30days'].inputTokens !== undefined) {
return key.usage['30days'].inputTokens
}
if (key.usage.monthly && key.usage.monthly.inputTokens !== undefined) {
return key.usage.monthly.inputTokens
}
if (key.usage.total && key.usage.total.inputTokens !== undefined) {
return key.usage.total.inputTokens
}
}
return 0
} else if (globalDateFilter.preset === 'all') {
// 全部时间
if (key.usage && key.usage['all'] && key.usage['all'].inputTokens !== undefined) {
return key.usage['all'].inputTokens
}
return key.usage?.total?.inputTokens || 0
} else {
// 默认返回
return key.usage?.total?.inputTokens || 0
}
}
// 获取日期范围内的输出token数量
const getPeriodOutputTokens = (key) => {
// 根据全局日期筛选器返回对应的输出token数量
if (globalDateFilter.type === 'custom') {
// 自定义日期范围
if (key.usage) {
if (key.usage['custom'] && key.usage['custom'].outputTokens !== undefined) {
return key.usage['custom'].outputTokens
}
if (key.usage.total && key.usage.total.outputTokens !== undefined) {
return key.usage.total.outputTokens
}
}
return 0
} else if (globalDateFilter.preset === 'today') {
return key.usage?.daily?.outputTokens || 0
} else if (globalDateFilter.preset === '7days') {
// 使用 usage['7days'].outputTokens
if (key.usage && key.usage['7days'] && key.usage['7days'].outputTokens !== undefined) {
return key.usage['7days'].outputTokens
}
return 0
} else if (globalDateFilter.preset === '30days') {
// 使用 usage['30days'].outputTokens 或 usage.monthly.outputTokens
if (key.usage) {
if (key.usage['30days'] && key.usage['30days'].outputTokens !== undefined) {
return key.usage['30days'].outputTokens
}
if (key.usage.monthly && key.usage.monthly.outputTokens !== undefined) {
return key.usage.monthly.outputTokens
}
if (key.usage.total && key.usage.total.outputTokens !== undefined) {
return key.usage.total.outputTokens
}
}
return 0
} else if (globalDateFilter.preset === 'all') {
// 全部时间
if (key.usage && key.usage['all'] && key.usage['all'].outputTokens !== undefined) {
return key.usage['all'].outputTokens
}
return key.usage?.total?.outputTokens || 0
} else {
// 默认返回
return key.usage?.total?.outputTokens || 0
}
}
// 计算日期范围内的总费用(用于展开的详细统计)
const calculatePeriodCost = (key) => {
// 如果没有展开,使用缓存的费用数据
@@ -3255,74 +3351,57 @@ const clearSearch = () => {
// 导出数据到Excel
const exportToExcel = () => {
try {
// 准备导出的数据 - 仅导出用量数据
// 准备导出的数据 - 简化版本
const exportData = sortedApiKeys.value.map((key) => {
// 获取当前时间段的数据
const periodRequests = getPeriodRequests(key)
const periodCost = calculatePeriodCost(key)
const periodTokens = getPeriodTokens(key)
const periodInputTokens = getPeriodInputTokens(key)
const periodOutputTokens = getPeriodOutputTokens(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
名称: key.name || '',
请求总数: periodRequests,
'总费用($)': periodCost.toFixed(4),
Token数: formatTokenCount(periodTokens),
输入Token: formatTokenCount(periodInputTokens),
输出Token: formatTokenCount(periodOutputTokens),
最后使用时间: key.lastUsedAt ? formatDate(key.lastUsedAt) : '从未使用'
}
// 添加分模型统计(如果有)
// 添加分模型统计
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
})
// 根据当前时间筛选条件获取对应的模型统计
let modelsData = null
if (globalDateFilter.preset === 'today') {
modelsData = key.usage?.daily?.models
} else if (globalDateFilter.preset === '7days') {
modelsData = key.usage?.weekly?.models
} else if (globalDateFilter.preset === '30days') {
modelsData = key.usage?.monthly?.models
} else if (globalDateFilter.preset === 'all') {
modelsData = key.usage?.total?.models
}
// 处理总计模型统计
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 (modelsData) {
Object.entries(modelsData).forEach(([model, stats]) => {
// 简化模型名称,去掉前缀
let modelName = model
if (model.includes(':')) {
modelName = model.split(':').pop() // 取最后一部分
}
modelName = modelName.replace(/[:/]/g, '_')
// 处理筛选时间段的模型统计
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
modelStats[`${modelName}_请求数`] = stats.requests || 0
modelStats[`${modelName}_费用($)`] = (stats.cost || 0).toFixed(4)
modelStats[`${modelName}_Token`] = formatTokenCount(stats.totalTokens || 0)
modelStats[`${modelName}_输入Token`] = formatTokenCount(stats.inputTokens || 0)
modelStats[`${modelName}_输出Token`] = formatTokenCount(stats.outputTokens || 0)
})
}
@@ -3330,21 +3409,98 @@ const exportToExcel = () => {
})
// 创建工作簿
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(exportData)
// 动态设置列宽
// 获取工作表范围
const range = XLSX.utils.decode_range(ws['!ref'])
// 设置列宽
const headers = Object.keys(exportData[0] || {})
const colWidths = headers.map((header) => {
if (header.includes('名称') || header.includes('所有者')) return { wch: 20 }
const columnWidths = headers.map((header) => {
if (header === '名称') return { wch: 25 }
if (header === '最后使用时间') 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
ws['!cols'] = columnWidths
// 应用样式到标题行
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: 0, c: C })
if (!ws[cellAddress]) continue
const header = headers[C]
const isModelColumn = header && header.includes('_')
ws[cellAddress].s = {
fill: {
fgColor: { rgb: isModelColumn ? '70AD47' : '4472C4' }
},
font: {
color: { rgb: 'FFFFFF' },
bold: true,
sz: 12
},
alignment: {
horizontal: 'center',
vertical: 'center'
},
border: {
top: { style: 'thin', color: { rgb: '2F5597' } },
bottom: { style: 'thin', color: { rgb: '2F5597' } },
left: { style: 'thin', color: { rgb: '2F5597' } },
right: { style: 'thin', color: { rgb: '2F5597' } }
}
}
}
// 应用样式到数据行
for (let R = 1; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
if (!ws[cellAddress]) continue
const header = headers[C]
const value = ws[cellAddress].v
// 基础样式
const cellStyle = {
font: { sz: 11 },
border: {
top: { style: 'thin', color: { rgb: 'D3D3D3' } },
bottom: { style: 'thin', color: { rgb: 'D3D3D3' } },
left: { style: 'thin', color: { rgb: 'D3D3D3' } },
right: { style: 'thin', color: { rgb: 'D3D3D3' } }
}
}
// 偶数行背景色
if (R % 2 === 0) {
cellStyle.fill = { fgColor: { rgb: 'F2F2F2' } }
}
// 根据列类型设置对齐和特殊样式
if (header === '名称') {
cellStyle.alignment = { horizontal: 'left', vertical: 'center' }
} else if (header === '最后使用时间') {
cellStyle.alignment = { horizontal: 'right', vertical: 'center' }
if (value === '从未使用') {
cellStyle.font = { ...cellStyle.font, color: { rgb: '999999' }, italic: true }
}
} else if (header && header.includes('费用')) {
cellStyle.alignment = { horizontal: 'right', vertical: 'center' }
cellStyle.font = { ...cellStyle.font, color: { rgb: '0066CC' }, bold: true }
} else if (header && (header.includes('Token') || header.includes('请求'))) {
cellStyle.alignment = { horizontal: 'right', vertical: 'center' }
}
ws[cellAddress].s = cellStyle
}
}
// 创建工作簿
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '用量统计')
// 生成文件名(包含时间戳和筛选条件)