Merge PR #70: feat(frontend): 优化弹窗组件架构和用户体验
## 主要变更 ### 对话框系统重构 - 升级 BaseDialog 组件,添加动画、焦点管理、响应式宽度 - 删除旧的 Modal.vue,统一使用 BaseDialog ### 使用量数据导出升级 - 改为 Excel 格式导出,支持分页全量导出 - 添加导出进度对话框,支持取消操作 - 新增依赖:xlsx、file-saver ### 使用量页面优化 - Token 明细悬浮提示 - 请求 ID 一键复制 - 新增 first_token 列 ### 后端修复 - 账户统计查询添加软删除过滤 ## 冲突解决 - 保留 driver.js 依赖(onboarding 功能需要) - 合并 package.json 变更
This commit is contained in:
@@ -186,7 +186,7 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
caseSql += " END WHERE id IN ?"
|
||||
caseSql += " END WHERE id IN ? AND deleted_at IS NULL"
|
||||
args = append(args, ids)
|
||||
|
||||
return r.db.WithContext(ctx).Exec(caseSql, args...).Error
|
||||
|
||||
@@ -119,6 +119,7 @@ func (r *proxyRepository) CountAccountsByProxyID(ctx context.Context, proxyID in
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Table("accounts").
|
||||
Where("proxy_id = ?", proxyID).
|
||||
Where("deleted_at IS NULL").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -134,6 +135,7 @@ func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (map[i
|
||||
Table("accounts").
|
||||
Select("proxy_id, COUNT(*) as count").
|
||||
Where("proxy_id IS NOT NULL").
|
||||
Where("deleted_at IS NULL").
|
||||
Group("proxy_id").
|
||||
Scan(&results).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -182,6 +182,7 @@ func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardS
|
||||
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
|
||||
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
`, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
208
frontend/package-lock.json
generated
208
frontend/package-lock.json
generated
@@ -12,13 +12,16 @@
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"driver.js": "^1.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
@@ -936,6 +939,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.27",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.27.tgz",
|
||||
@@ -1173,6 +1183,15 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||
@@ -1406,6 +1425,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/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/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||
@@ -1456,6 +1488,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/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/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1478,6 +1519,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/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/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1504,15 +1557,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1705,15 +1749,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -1724,6 +1759,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1773,6 +1814,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -2001,22 +2051,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "~0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2207,26 +2241,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"levn": "^0.4.1",
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "^0.4.0",
|
||||
"word-wrap": "^1.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2477,18 +2491,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2646,6 +2648,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/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/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
@@ -2834,21 +2848,6 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz",
|
||||
@@ -3202,16 +3201,43 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/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/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmmirror.com/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"driver.js": "^1.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
|
||||
@@ -362,6 +362,10 @@ const resetState = () => {
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// 防止在连接测试进行中关闭对话框
|
||||
if (status.value === 'connecting') {
|
||||
return
|
||||
}
|
||||
closeEventSource()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.createAccount')"
|
||||
width="wide"
|
||||
width="normal"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step Indicator for OAuth accounts -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.editAccount')"
|
||||
width="wide"
|
||||
width="normal"
|
||||
@close="handleClose"
|
||||
>
|
||||
<form
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.reAuthorizeAccount')"
|
||||
width="wide"
|
||||
width="normal"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="account" class="space-y-4">
|
||||
|
||||
@@ -151,6 +151,10 @@ watch(
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
// 防止在同步进行中关闭对话框
|
||||
if (syncing.value) {
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +1,63 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div :class="['modal-content', widthClasses]" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title" class="modal-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay"
|
||||
:aria-labelledby="dialogId"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div ref="dialogRef" :class="['modal-content', widthClasses]" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h3 :id="dialogId" class="modal-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
<!-- Footer -->
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, watch, onMounted, onUnmounted, ref, nextTick } from 'vue'
|
||||
|
||||
// 生成唯一ID以避免多个对话框时ID冲突
|
||||
let dialogIdCounter = 0
|
||||
const dialogId = `modal-title-${++dialogIdCounter}`
|
||||
|
||||
// 焦点管理
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
|
||||
|
||||
@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
|
||||
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
|
||||
// full=full-screen or very dense layouts.
|
||||
const widths: Record<DialogWidth, string> = {
|
||||
narrow: 'max-w-md',
|
||||
normal: 'max-w-lg',
|
||||
wide: 'max-w-4xl',
|
||||
'extra-wide': 'max-w-6xl',
|
||||
full: 'max-w-7xl'
|
||||
wide: 'w-full sm:max-w-2xl md:max-w-3xl lg:max-w-4xl',
|
||||
'extra-wide': 'w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl',
|
||||
full: 'w-full sm:max-w-4xl md:max-w-5xl lg:max-w-6xl xl:max-w-7xl'
|
||||
}
|
||||
return widths[props.width]
|
||||
})
|
||||
@@ -94,14 +107,31 @@ const handleEscape = (event: KeyboardEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
// Prevent body scroll when modal is open and manage focus
|
||||
watch(
|
||||
() => props.show,
|
||||
(isOpen) => {
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
// 保存当前焦点元素
|
||||
previousActiveElement = document.activeElement as HTMLElement
|
||||
// 使用CSS类而不是直接操作style,更易于管理多个对话框
|
||||
document.body.classList.add('modal-open')
|
||||
|
||||
// 等待DOM更新后设置焦点到对话框
|
||||
await nextTick()
|
||||
if (dialogRef.value) {
|
||||
const firstFocusable = dialogRef.value.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
firstFocusable?.focus()
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
document.body.classList.remove('modal-open')
|
||||
// 恢复之前的焦点
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus()
|
||||
}
|
||||
previousActiveElement = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -113,6 +143,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
// 确保组件卸载时移除滚动锁定
|
||||
document.body.classList.remove('modal-open')
|
||||
})
|
||||
</script>
|
||||
|
||||
68
frontend/src/components/common/ExportProgressDialog.vue
Normal file
68
frontend/src/components/common/ExportProgressDialog.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('usage.exporting')" width="narrow" @close="handleCancel">
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('usage.exportingProgress') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>{{ t('usage.exportedCount', { current, total }) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ normalizedProgress }}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="normalizedProgress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="`${t('usage.exportingProgress')}: ${normalizedProgress}%`"
|
||||
class="h-2 rounded-full bg-primary-600 transition-all"
|
||||
:style="{ width: `${normalizedProgress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="estimatedTime" class="text-xs text-gray-500 dark:text-gray-400" aria-live="polite" aria-atomic="true">
|
||||
{{ t('usage.estimatedTime', { time: estimatedTime }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button
|
||||
@click="handleCancel"
|
||||
type="button"
|
||||
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
|
||||
>
|
||||
{{ t('usage.cancelExport') }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from './BaseDialog.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
progress: number
|
||||
current: number
|
||||
total: number
|
||||
estimatedTime: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const normalizedProgress = computed(() => {
|
||||
const value = Number.isFinite(props.progress) ? props.progress : 0
|
||||
return Math.min(100, Math.max(0, Math.round(value)))
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
@@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div :class="['modal-content', sizeClasses]" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title" class="modal-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
title: string
|
||||
size?: ModalSize
|
||||
closeOnEscape?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-5xl',
|
||||
full: 'max-w-4xl'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
if (props.closeOnClickOutside) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (props.show && props.closeOnEscape && event.key === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
watch(
|
||||
() => props.show,
|
||||
(isOpen) => {
|
||||
console.log('[Modal] show changed to:', isOpen)
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
@@ -1,7 +1,6 @@
|
||||
// Export all common components
|
||||
export { default as DataTable } from './DataTable.vue'
|
||||
export { default as Pagination } from './Pagination.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as BaseDialog } from './BaseDialog.vue'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||
export { default as StatCard } from './StatCard.vue'
|
||||
@@ -9,6 +8,7 @@ export { default as Toast } from './Toast.vue'
|
||||
export { default as LoadingSpinner } from './LoadingSpinner.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
|
||||
export { default as ExportProgressDialog } from './ExportProgressDialog.vue'
|
||||
|
||||
// Export types
|
||||
export type { Column } from './types'
|
||||
|
||||
@@ -326,7 +326,8 @@ export default {
|
||||
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
|
||||
customKeyTooShort: 'Custom key must be at least 16 characters',
|
||||
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
|
||||
customKeyRequired: 'Please enter a custom key'
|
||||
customKeyRequired: 'Please enter a custom key',
|
||||
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.'
|
||||
},
|
||||
|
||||
// Usage
|
||||
@@ -345,6 +346,12 @@ export default {
|
||||
allApiKeys: 'All API Keys',
|
||||
timeRange: 'Time Range',
|
||||
exportCsv: 'Export CSV',
|
||||
exportExcel: 'Export Excel',
|
||||
exportingProgress: 'Exporting data...',
|
||||
exportedCount: 'Exported {current}/{total} records',
|
||||
estimatedTime: 'Estimated time remaining: {time}',
|
||||
cancelExport: 'Cancel Export',
|
||||
exportCancelled: 'Export cancelled',
|
||||
exporting: 'Exporting...',
|
||||
preparingExport: 'Preparing export...',
|
||||
model: 'Model',
|
||||
@@ -368,6 +375,8 @@ export default {
|
||||
noDataToExport: 'No data to export',
|
||||
exportSuccess: 'Usage data exported successfully',
|
||||
exportFailed: 'Failed to export usage data',
|
||||
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
|
||||
exportExcelFailed: 'Failed to export usage data',
|
||||
billingType: 'Billing',
|
||||
balance: 'Balance',
|
||||
subscription: 'Subscription'
|
||||
@@ -1291,6 +1300,7 @@ export default {
|
||||
account: 'Account',
|
||||
group: 'Group',
|
||||
requestId: 'Request ID',
|
||||
requestIdCopied: 'Request ID copied',
|
||||
allModels: 'All Models',
|
||||
allAccounts: 'All Accounts',
|
||||
allGroups: 'All Groups',
|
||||
@@ -1300,6 +1310,10 @@ export default {
|
||||
outputCost: 'Output Cost',
|
||||
cacheCreationCost: 'Cache Creation Cost',
|
||||
cacheReadCost: 'Cache Read Cost',
|
||||
inputTokens: 'Input Tokens',
|
||||
outputTokens: 'Output Tokens',
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records'
|
||||
},
|
||||
|
||||
|
||||
@@ -322,7 +322,8 @@ export default {
|
||||
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
|
||||
customKeyTooShort: '自定义密钥至少需要16个字符',
|
||||
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
||||
customKeyRequired: '请输入自定义密钥'
|
||||
customKeyRequired: '请输入自定义密钥',
|
||||
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。'
|
||||
},
|
||||
|
||||
// Usage
|
||||
@@ -341,6 +342,12 @@ export default {
|
||||
allApiKeys: '全部密钥',
|
||||
timeRange: '时间范围',
|
||||
exportCsv: '导出 CSV',
|
||||
exportExcel: '导出 Excel',
|
||||
exportingProgress: '正在导出数据...',
|
||||
exportedCount: '已导出 {current}/{total} 条',
|
||||
estimatedTime: '预计剩余时间:{time}',
|
||||
cancelExport: '取消导出',
|
||||
exportCancelled: '导出已取消',
|
||||
exporting: '导出中...',
|
||||
preparingExport: '正在准备导出...',
|
||||
model: '模型',
|
||||
@@ -364,6 +371,8 @@ export default {
|
||||
noDataToExport: '没有可导出的数据',
|
||||
exportSuccess: '使用数据导出成功',
|
||||
exportFailed: '使用数据导出失败',
|
||||
exportExcelSuccess: '使用数据导出成功(Excel格式)',
|
||||
exportExcelFailed: '使用数据导出失败',
|
||||
billingType: '消费类型',
|
||||
balance: '余额',
|
||||
subscription: '订阅'
|
||||
@@ -1490,6 +1499,7 @@ export default {
|
||||
account: '账户',
|
||||
group: '分组',
|
||||
requestId: '请求ID',
|
||||
requestIdCopied: '请求ID已复制',
|
||||
allModels: '全部模型',
|
||||
allAccounts: '全部账户',
|
||||
allGroups: '全部分组',
|
||||
@@ -1499,6 +1509,10 @@ export default {
|
||||
outputCost: '输出成本',
|
||||
cacheCreationCost: '缓存创建成本',
|
||||
cacheReadCost: '缓存读取成本',
|
||||
inputTokens: '输入 Token',
|
||||
outputTokens: '输出 Token',
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败'
|
||||
},
|
||||
|
||||
|
||||
@@ -79,6 +79,20 @@
|
||||
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-gradient-to-r from-emerald-500 to-emerald-600;
|
||||
@apply text-white shadow-md shadow-emerald-500/25;
|
||||
@apply hover:from-emerald-600 hover:to-emerald-700 hover:shadow-lg hover:shadow-emerald-500/30;
|
||||
@apply dark:shadow-emerald-500/20;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply bg-gradient-to-r from-amber-500 to-amber-600;
|
||||
@apply text-white shadow-md shadow-amber-500/25;
|
||||
@apply hover:from-amber-600 hover:to-amber-700 hover:shadow-lg hover:shadow-amber-500/30;
|
||||
@apply dark:shadow-amber-500/20;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply rounded-lg px-3 py-1.5 text-xs;
|
||||
}
|
||||
@@ -130,6 +144,20 @@
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* ============ 玻璃效果 ============ */
|
||||
.glass {
|
||||
@apply bg-white/80 backdrop-blur-xl dark:bg-dark-800/80;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply bg-white/70 dark:bg-dark-800/70;
|
||||
@apply backdrop-blur-xl;
|
||||
@apply rounded-2xl;
|
||||
@apply border border-white/20 dark:border-dark-700/50;
|
||||
@apply shadow-glass;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
/* ============ 卡片样式 ============ */
|
||||
.card {
|
||||
@apply bg-white dark:bg-dark-800/50;
|
||||
@@ -151,6 +179,20 @@
|
||||
@apply shadow-glass;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply border-b border-gray-100 dark:border-dark-700;
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply border-t border-gray-100 dark:border-dark-700;
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
/* ============ 统计卡片 ============ */
|
||||
.stat-card {
|
||||
@apply card p-5;
|
||||
@@ -256,6 +298,10 @@
|
||||
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
@apply bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400;
|
||||
}
|
||||
|
||||
/* ============ 下拉菜单 ============ */
|
||||
.dropdown {
|
||||
@apply absolute z-50;
|
||||
@@ -283,15 +329,19 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply w-full;
|
||||
@apply max-h-[95vh] sm:max-h-[90vh];
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-2xl shadow-2xl;
|
||||
@apply w-full;
|
||||
@apply max-h-[90vh] overflow-y-auto;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply border-b border-gray-100 px-6 py-4 dark:border-dark-700;
|
||||
@apply border-b border-gray-200 px-4 py-3 dark:border-dark-700;
|
||||
@apply sm:px-6 sm:py-4;
|
||||
@apply flex items-center justify-between;
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@@ -299,12 +349,69 @@
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply px-6 py-4;
|
||||
@apply px-4 py-3;
|
||||
@apply sm:px-6 sm:py-4;
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply border-t border-gray-100 px-6 py-4 dark:border-dark-700;
|
||||
@apply border-t border-gray-200 px-4 py-3 dark:border-dark-700;
|
||||
@apply sm:px-6 sm:py-4;
|
||||
@apply flex items-center justify-end gap-3;
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
/* 防止body滚动的工具类 */
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-enter-active {
|
||||
transition: opacity 250ms ease-out;
|
||||
}
|
||||
|
||||
.modal-leave-active {
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-content {
|
||||
transition: transform 250ms ease-out, opacity 250ms ease-out;
|
||||
}
|
||||
|
||||
.modal-leave-active .modal-content {
|
||||
transition: transform 200ms ease-in, opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-content,
|
||||
.modal-leave-to .modal-content {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-to .modal-content,
|
||||
.modal-leave-from .modal-content {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-enter-active,
|
||||
.modal-leave-active,
|
||||
.modal-enter-active .modal-content,
|
||||
.modal-leave-active .modal-content {
|
||||
transition-duration: 1ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-content,
|
||||
.modal-leave-to .modal-content {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ Dialog ============ */
|
||||
|
||||
@@ -105,65 +105,65 @@
|
||||
|
||||
<template #table>
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div
|
||||
v-if="selectedAccountIds.length > 0"
|
||||
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="selectCurrentPageAccounts"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="selectedAccountIds = []"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
<div
|
||||
v-if="selectedAccountIds.length > 0"
|
||||
class="mb-[5px] mt-[10px] px-5 py-1"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="selectCurrentPageAccounts"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.delete') }}
|
||||
</button>
|
||||
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||
</button>
|
||||
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||
<button
|
||||
@click="selectedAccountIds = []"
|
||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.edit') }}
|
||||
</button>
|
||||
{{ t('admin.accounts.bulkActions.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.delete') }}
|
||||
</button>
|
||||
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
class="mr-1.5 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.bulkActions.edit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||
<template #cell-select="{ row }">
|
||||
|
||||
@@ -300,8 +300,8 @@
|
||||
<button @click="resetFilters" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button @click="exportToCSV" class="btn btn-primary">
|
||||
{{ t('usage.exportCsv') }}
|
||||
<button @click="exportToExcel" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,90 +361,114 @@
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<!-- Input / Output Tokens -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Input -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.input_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<!-- Input / Output Tokens -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Input -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.input_tokens.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<!-- Cache Tokens (Read + Write) -->
|
||||
<div
|
||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<!-- Cache Read -->
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-sky-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||||
formatCacheTokens(row.cache_read_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cache Write -->
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||
formatCacheTokens(row.cache_creation_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cache Tokens (Read + Write) -->
|
||||
<!-- Token Detail Tooltip -->
|
||||
<div
|
||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||
class="flex items-center gap-2"
|
||||
class="group relative"
|
||||
@mouseenter="showTokenTooltip($event, row)"
|
||||
@mouseleave="hideTokenTooltip"
|
||||
>
|
||||
<!-- Cache Read -->
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<div
|
||||
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-sky-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||||
formatCacheTokens(row.cache_read_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cache Write -->
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||
formatCacheTokens(row.cache_creation_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,9 +540,50 @@
|
||||
</template>
|
||||
|
||||
<template #cell-request_id="{ row }">
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
|
||||
row.request_id || '-'
|
||||
}}</span>
|
||||
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
|
||||
<span
|
||||
class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate"
|
||||
:title="row.request_id"
|
||||
>
|
||||
{{ row.request_id }}
|
||||
</span>
|
||||
<button
|
||||
@click="copyRequestId(row.request_id)"
|
||||
class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="
|
||||
copiedRequestId === row.request_id
|
||||
? 'text-green-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
"
|
||||
:title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"
|
||||
>
|
||||
<svg
|
||||
v-if="copiedRequestId === row.request_id"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
@@ -540,6 +605,63 @@
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<ExportProgressDialog
|
||||
:show="exportProgress.show"
|
||||
:progress="exportProgress.progress"
|
||||
:current="exportProgress.current"
|
||||
:total="exportProgress.total"
|
||||
:estimated-time="exportProgress.estimatedTime"
|
||||
@cancel="cancelExport"
|
||||
/>
|
||||
|
||||
<!-- Token Tooltip Portal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="tokenTooltipVisible"
|
||||
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||
:style="{
|
||||
left: tokenTooltipPosition.x + 'px',
|
||||
top: tokenTooltipPosition.y + 'px'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<!-- Token Breakdown -->
|
||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tooltip Arrow (left side) -->
|
||||
<div
|
||||
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Tooltip Portal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -602,10 +724,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
@@ -615,6 +741,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import type {
|
||||
@@ -626,12 +753,21 @@ import type {
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
// Tooltip state
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tooltipData = ref<UsageLog | null>(null)
|
||||
|
||||
// Token tooltip state
|
||||
const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||
|
||||
// Request ID copy state
|
||||
const copiedRequestId = ref<string | null>(null)
|
||||
|
||||
// Usage stats from API
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||
|
||||
@@ -657,6 +793,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
||||
@@ -669,6 +806,15 @@ const accounts = ref<any[]>([])
|
||||
const groups = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
let exportAbortController: AbortController | null = null
|
||||
const exporting = ref(false)
|
||||
const exportProgress = reactive({
|
||||
show: false,
|
||||
progress: 0,
|
||||
current: 0,
|
||||
total: 0,
|
||||
estimatedTime: ''
|
||||
})
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
@@ -868,6 +1014,16 @@ const formatCacheTokens = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const copyRequestId = async (requestId: string) => {
|
||||
const success = await clipboardCopy(requestId, t('admin.usage.requestIdCopied'))
|
||||
if (success) {
|
||||
copiedRequestId.value = requestId
|
||||
setTimeout(() => {
|
||||
copiedRequestId.value = null
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown): boolean => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return true
|
||||
@@ -879,6 +1035,40 @@ const isAbortError = (error: unknown): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
const formatExportTimestamp = (date: Date): string => {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
const formatRemainingTime = (ms: number): string => {
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1000))
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const parts = []
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0 || hours > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
parts.push(`${seconds}s`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const updateExportProgress = (current: number, total: number, startedAt: number) => {
|
||||
exportProgress.current = current
|
||||
exportProgress.total = total
|
||||
exportProgress.progress = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0
|
||||
if (current > 0 && total > 0) {
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const remainingMs = Math.max(0, Math.round((elapsedMs / current) * (total - current)))
|
||||
exportProgress.estimatedTime = formatRemainingTime(remainingMs)
|
||||
} else {
|
||||
exportProgress.estimatedTime = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -1051,52 +1241,129 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (usageLogs.value.length === 0) {
|
||||
const cancelExport = () => {
|
||||
if (!exporting.value) {
|
||||
return
|
||||
}
|
||||
exportAbortController?.abort()
|
||||
}
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (pagination.value.total === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'User',
|
||||
'API Key',
|
||||
'Model',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
'Cache Read Tokens',
|
||||
'Cache Write Tokens',
|
||||
'Total Cost',
|
||||
'Billing Type',
|
||||
'Duration (ms)',
|
||||
'Time'
|
||||
]
|
||||
const rows = usageLogs.value.map((log) => [
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
log.total_cost.toFixed(6),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
|
||||
exporting.value = true
|
||||
exportProgress.show = true
|
||||
exportProgress.progress = 0
|
||||
exportProgress.current = 0
|
||||
exportProgress.total = pagination.value.total
|
||||
exportProgress.estimatedTime = ''
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
const startedAt = Date.now()
|
||||
const controller = new AbortController()
|
||||
exportAbortController = controller
|
||||
|
||||
appStore.showSuccess(t('usage.exportSuccess'))
|
||||
try {
|
||||
const allLogs: UsageLog[] = []
|
||||
const pageSize = 100
|
||||
let page = 1
|
||||
let total = pagination.value.total
|
||||
|
||||
while (true) {
|
||||
const params: AdminUsageQueryParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters.value
|
||||
}
|
||||
const response = await adminUsageAPI.list(params, { signal: controller.signal })
|
||||
if (controller.signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (page === 1) {
|
||||
total = response.total
|
||||
exportProgress.total = total
|
||||
}
|
||||
if (response.items?.length) {
|
||||
allLogs.push(...response.items)
|
||||
}
|
||||
|
||||
updateExportProgress(allLogs.length, total, startedAt)
|
||||
|
||||
if (allLogs.length >= total || response.items.length < pageSize) {
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
appStore.showInfo(t('usage.exportCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
if (allLogs.length === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'User',
|
||||
'API Key',
|
||||
'Model',
|
||||
'Type',
|
||||
'Input Tokens',
|
||||
'Output Tokens',
|
||||
'Cache Read Tokens',
|
||||
'Cache Write Tokens',
|
||||
'Total Cost',
|
||||
'Billing Type',
|
||||
'Duration (ms)',
|
||||
'Time'
|
||||
]
|
||||
const rows = allLogs.map((log) => [
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
Number(log.total_cost.toFixed(6)),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||
const workbook = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Usage')
|
||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
saveAs(blob, `admin_usage_${formatExportTimestamp(new Date())}.xlsx`)
|
||||
appStore.showSuccess(t('usage.exportExcelSuccess'))
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) {
|
||||
appStore.showInfo(t('usage.exportCancelled'))
|
||||
return
|
||||
}
|
||||
appStore.showError(t('usage.exportExcelFailed'))
|
||||
console.error('Excel export failed:', error)
|
||||
} finally {
|
||||
if (exportAbortController === controller) {
|
||||
exportAbortController = null
|
||||
}
|
||||
exporting.value = false
|
||||
exportProgress.show = false
|
||||
}
|
||||
}
|
||||
|
||||
// Click outside to close dropdown
|
||||
@@ -1123,6 +1390,22 @@ const hideTooltip = () => {
|
||||
tooltipData.value = null
|
||||
}
|
||||
|
||||
// Token tooltip functions
|
||||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
|
||||
tokenTooltipData.value = row
|
||||
tokenTooltipPosition.value.x = rect.right + 8
|
||||
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||
tokenTooltipVisible.value = true
|
||||
}
|
||||
|
||||
const hideTokenTooltip = () => {
|
||||
tokenTooltipVisible.value = false
|
||||
tokenTooltipData.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFilterOptions()
|
||||
loadApiKeys()
|
||||
@@ -1140,5 +1423,8 @@ onUnmounted(() => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
if (exportAbortController) {
|
||||
exportAbortController.abort()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
<BaseDialog
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||
width="narrow"
|
||||
width="normal"
|
||||
@close="closeModals"
|
||||
>
|
||||
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
||||
@@ -893,7 +893,20 @@ const importToCcswitch = (apiKey: string) => {
|
||||
usageAutoInterval: '30'
|
||||
})
|
||||
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
||||
window.open(deeplink, '_self')
|
||||
|
||||
try {
|
||||
window.open(deeplink, '_self')
|
||||
|
||||
// Check if the protocol handler worked by detecting if we're still focused
|
||||
setTimeout(() => {
|
||||
if (document.hasFocus()) {
|
||||
// Still focused means the protocol handler likely failed
|
||||
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
||||
}
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -219,90 +219,114 @@
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<!-- Input / Output Tokens -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Input -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.input_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<!-- Input / Output Tokens -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Input -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-emerald-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.input_tokens.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Output -->
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-violet-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.output_tokens.toLocaleString()
|
||||
}}</span>
|
||||
<!-- Cache Tokens (Read + Write) -->
|
||||
<div
|
||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<!-- Cache Read -->
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-sky-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||||
formatCacheTokens(row.cache_read_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cache Write -->
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||
formatCacheTokens(row.cache_creation_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cache Tokens (Read + Write) -->
|
||||
<!-- Token Detail Tooltip -->
|
||||
<div
|
||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||
class="flex items-center gap-2"
|
||||
class="group relative"
|
||||
@mouseenter="showTokenTooltip($event, row)"
|
||||
@mouseleave="hideTokenTooltip"
|
||||
>
|
||||
<!-- Cache Read -->
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<div
|
||||
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-sky-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{
|
||||
formatCacheTokens(row.cache_read_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cache Write -->
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-amber-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{
|
||||
formatCacheTokens(row.cache_creation_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,6 +416,54 @@
|
||||
</TablePageLayout>
|
||||
</AppLayout>
|
||||
|
||||
<!-- Token Tooltip Portal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="tokenTooltipVisible"
|
||||
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||
:style="{
|
||||
left: tokenTooltipPosition.x + 'px',
|
||||
top: tokenTooltipPosition.y + 'px'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<!-- Token Breakdown -->
|
||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token 明细</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tooltip Arrow (left side) -->
|
||||
<div
|
||||
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Tooltip Portal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -458,6 +530,11 @@ const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tooltipData = ref<UsageLog | null>(null)
|
||||
|
||||
// Token tooltip state
|
||||
const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||
|
||||
// Usage stats from API
|
||||
const usageStats = ref<UsageStatsResponse | null>(null)
|
||||
|
||||
@@ -778,6 +855,22 @@ const hideTooltip = () => {
|
||||
tooltipData.value = null
|
||||
}
|
||||
|
||||
// Token tooltip functions
|
||||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
|
||||
tokenTooltipData.value = row
|
||||
tokenTooltipPosition.value.x = rect.right + 8
|
||||
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||
tokenTooltipVisible.value = true
|
||||
}
|
||||
|
||||
const hideTokenTooltip = () => {
|
||||
tokenTooltipVisible.value = false
|
||||
tokenTooltipData.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadUsageLogs()
|
||||
|
||||
Reference in New Issue
Block a user