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)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
caseSql += " END WHERE id IN ?"
|
caseSql += " END WHERE id IN ? AND deleted_at IS NULL"
|
||||||
args = append(args, ids)
|
args = append(args, ids)
|
||||||
|
|
||||||
return r.db.WithContext(ctx).Exec(caseSql, args...).Error
|
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
|
var count int64
|
||||||
err := r.db.WithContext(ctx).Table("accounts").
|
err := r.db.WithContext(ctx).Table("accounts").
|
||||||
Where("proxy_id = ?", proxyID).
|
Where("proxy_id = ?", proxyID).
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
@@ -134,6 +135,7 @@ func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (map[i
|
|||||||
Table("accounts").
|
Table("accounts").
|
||||||
Select("proxy_id, COUNT(*) as count").
|
Select("proxy_id, COUNT(*) as count").
|
||||||
Where("proxy_id IS NOT NULL").
|
Where("proxy_id IS NOT NULL").
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
Group("proxy_id").
|
Group("proxy_id").
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
if err != nil {
|
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 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
|
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
|
||||||
FROM accounts
|
FROM accounts
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
`, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil {
|
`, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
208
frontend/package-lock.json
generated
208
frontend/package-lock.json
generated
@@ -12,13 +12,16 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-i18n": "^9.14.5",
|
"vue-i18n": "^9.14.5",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
@@ -936,6 +939,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.27",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.27.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.27.tgz",
|
||||||
@@ -1173,6 +1183,15 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/alien-signals": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
|
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||||
@@ -1406,6 +1425,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
@@ -1456,6 +1488,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -1478,6 +1519,18 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -1504,15 +1557,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1705,15 +1749,6 @@
|
|||||||
"node": ">= 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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
|
||||||
@@ -1724,6 +1759,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -1773,6 +1814,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -2001,22 +2051,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -2207,26 +2241,6 @@
|
|||||||
"node": ">= 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": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -2477,18 +2491,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -2646,6 +2648,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
@@ -2834,21 +2848,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz",
|
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz",
|
||||||
@@ -3202,16 +3201,43 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/wmf": {
|
||||||
"version": "1.2.5",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
"dev": true,
|
"license": "Apache-2.0",
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"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",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-i18n": "^9.14.5",
|
"vue-i18n": "^9.14.5",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
|||||||
@@ -362,6 +362,10 @@ const resetState = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 防止在连接测试进行中关闭对话框
|
||||||
|
if (status.value === 'connecting') {
|
||||||
|
return
|
||||||
|
}
|
||||||
closeEventSource()
|
closeEventSource()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
:show="show"
|
:show="show"
|
||||||
:title="t('admin.accounts.createAccount')"
|
:title="t('admin.accounts.createAccount')"
|
||||||
width="wide"
|
width="normal"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<!-- Step Indicator for OAuth accounts -->
|
<!-- Step Indicator for OAuth accounts -->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
:show="show"
|
:show="show"
|
||||||
:title="t('admin.accounts.editAccount')"
|
:title="t('admin.accounts.editAccount')"
|
||||||
width="wide"
|
width="normal"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
:show="show"
|
:show="show"
|
||||||
:title="t('admin.accounts.reAuthorizeAccount')"
|
:title="t('admin.accounts.reAuthorizeAccount')"
|
||||||
width="wide"
|
width="normal"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<div v-if="account" class="space-y-4">
|
<div v-if="account" class="space-y-4">
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// 防止在同步进行中关闭对话框
|
||||||
|
if (syncing.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<Transition name="modal">
|
||||||
v-if="show"
|
<div
|
||||||
class="modal-overlay"
|
v-if="show"
|
||||||
aria-labelledby="modal-title"
|
class="modal-overlay"
|
||||||
role="dialog"
|
:aria-labelledby="dialogId"
|
||||||
aria-modal="true"
|
role="dialog"
|
||||||
@click.self="handleClose"
|
aria-modal="true"
|
||||||
>
|
@click.self="handleClose"
|
||||||
<!-- Modal panel -->
|
>
|
||||||
<div :class="['modal-content', widthClasses]" @click.stop>
|
<!-- Modal panel -->
|
||||||
<!-- Header -->
|
<div ref="dialogRef" :class="['modal-content', widthClasses]" @click.stop>
|
||||||
<div class="modal-header">
|
<!-- Header -->
|
||||||
<h3 id="modal-title" class="modal-title">
|
<div class="modal-header">
|
||||||
{{ title }}
|
<h3 :id="dialogId" class="modal-title">
|
||||||
</h3>
|
{{ title }}
|
||||||
<button
|
</h3>
|
||||||
@click="emit('close')"
|
<button
|
||||||
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"
|
@click="emit('close')"
|
||||||
aria-label="Close modal"
|
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
|
||||||
</svg>
|
class="h-5 w-5"
|
||||||
</button>
|
fill="none"
|
||||||
</div>
|
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 -->
|
<!-- Body -->
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div v-if="$slots.footer" class="modal-footer">
|
<div v-if="$slots.footer" class="modal-footer">
|
||||||
<slot name="footer"></slot>
|
<slot name="footer"></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
|
||||||
|
|
||||||
@@ -72,12 +82,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const widthClasses = computed(() => {
|
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> = {
|
const widths: Record<DialogWidth, string> = {
|
||||||
narrow: 'max-w-md',
|
narrow: 'max-w-md',
|
||||||
normal: 'max-w-lg',
|
normal: 'max-w-lg',
|
||||||
wide: 'max-w-4xl',
|
wide: 'w-full sm:max-w-2xl md:max-w-3xl lg:max-w-4xl',
|
||||||
'extra-wide': 'max-w-6xl',
|
'extra-wide': 'w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl',
|
||||||
full: 'max-w-7xl'
|
full: 'w-full sm:max-w-4xl md:max-w-5xl lg:max-w-6xl xl:max-w-7xl'
|
||||||
}
|
}
|
||||||
return widths[props.width]
|
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(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(isOpen) => {
|
async (isOpen) => {
|
||||||
if (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 {
|
} else {
|
||||||
document.body.style.overflow = ''
|
document.body.classList.remove('modal-open')
|
||||||
|
// 恢复之前的焦点
|
||||||
|
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||||
|
previousActiveElement.focus()
|
||||||
|
}
|
||||||
|
previousActiveElement = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -113,6 +143,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleEscape)
|
document.removeEventListener('keydown', handleEscape)
|
||||||
document.body.style.overflow = ''
|
// 确保组件卸载时移除滚动锁定
|
||||||
|
document.body.classList.remove('modal-open')
|
||||||
})
|
})
|
||||||
</script>
|
</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 all common components
|
||||||
export { default as DataTable } from './DataTable.vue'
|
export { default as DataTable } from './DataTable.vue'
|
||||||
export { default as Pagination } from './Pagination.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 BaseDialog } from './BaseDialog.vue'
|
||||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||||
export { default as StatCard } from './StatCard.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 LoadingSpinner } from './LoadingSpinner.vue'
|
||||||
export { default as EmptyState } from './EmptyState.vue'
|
export { default as EmptyState } from './EmptyState.vue'
|
||||||
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
|
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
|
||||||
|
export { default as ExportProgressDialog } from './ExportProgressDialog.vue'
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { Column } from './types'
|
export type { Column } from './types'
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ export default {
|
|||||||
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
|
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
|
||||||
customKeyTooShort: 'Custom key must be at least 16 characters',
|
customKeyTooShort: 'Custom key must be at least 16 characters',
|
||||||
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
|
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
|
// Usage
|
||||||
@@ -345,6 +346,12 @@ export default {
|
|||||||
allApiKeys: 'All API Keys',
|
allApiKeys: 'All API Keys',
|
||||||
timeRange: 'Time Range',
|
timeRange: 'Time Range',
|
||||||
exportCsv: 'Export CSV',
|
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...',
|
exporting: 'Exporting...',
|
||||||
preparingExport: 'Preparing export...',
|
preparingExport: 'Preparing export...',
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
@@ -368,6 +375,8 @@ export default {
|
|||||||
noDataToExport: 'No data to export',
|
noDataToExport: 'No data to export',
|
||||||
exportSuccess: 'Usage data exported successfully',
|
exportSuccess: 'Usage data exported successfully',
|
||||||
exportFailed: 'Failed to export usage data',
|
exportFailed: 'Failed to export usage data',
|
||||||
|
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
|
||||||
|
exportExcelFailed: 'Failed to export usage data',
|
||||||
billingType: 'Billing',
|
billingType: 'Billing',
|
||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
subscription: 'Subscription'
|
subscription: 'Subscription'
|
||||||
@@ -1291,6 +1300,7 @@ export default {
|
|||||||
account: 'Account',
|
account: 'Account',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
requestId: 'Request ID',
|
requestId: 'Request ID',
|
||||||
|
requestIdCopied: 'Request ID copied',
|
||||||
allModels: 'All Models',
|
allModels: 'All Models',
|
||||||
allAccounts: 'All Accounts',
|
allAccounts: 'All Accounts',
|
||||||
allGroups: 'All Groups',
|
allGroups: 'All Groups',
|
||||||
@@ -1300,6 +1310,10 @@ export default {
|
|||||||
outputCost: 'Output Cost',
|
outputCost: 'Output Cost',
|
||||||
cacheCreationCost: 'Cache Creation Cost',
|
cacheCreationCost: 'Cache Creation Cost',
|
||||||
cacheReadCost: 'Cache Read 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'
|
failedToLoad: 'Failed to load usage records'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ export default {
|
|||||||
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
|
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
|
||||||
customKeyTooShort: '自定义密钥至少需要16个字符',
|
customKeyTooShort: '自定义密钥至少需要16个字符',
|
||||||
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
||||||
customKeyRequired: '请输入自定义密钥'
|
customKeyRequired: '请输入自定义密钥',
|
||||||
|
ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
@@ -341,6 +342,12 @@ export default {
|
|||||||
allApiKeys: '全部密钥',
|
allApiKeys: '全部密钥',
|
||||||
timeRange: '时间范围',
|
timeRange: '时间范围',
|
||||||
exportCsv: '导出 CSV',
|
exportCsv: '导出 CSV',
|
||||||
|
exportExcel: '导出 Excel',
|
||||||
|
exportingProgress: '正在导出数据...',
|
||||||
|
exportedCount: '已导出 {current}/{total} 条',
|
||||||
|
estimatedTime: '预计剩余时间:{time}',
|
||||||
|
cancelExport: '取消导出',
|
||||||
|
exportCancelled: '导出已取消',
|
||||||
exporting: '导出中...',
|
exporting: '导出中...',
|
||||||
preparingExport: '正在准备导出...',
|
preparingExport: '正在准备导出...',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
@@ -364,6 +371,8 @@ export default {
|
|||||||
noDataToExport: '没有可导出的数据',
|
noDataToExport: '没有可导出的数据',
|
||||||
exportSuccess: '使用数据导出成功',
|
exportSuccess: '使用数据导出成功',
|
||||||
exportFailed: '使用数据导出失败',
|
exportFailed: '使用数据导出失败',
|
||||||
|
exportExcelSuccess: '使用数据导出成功(Excel格式)',
|
||||||
|
exportExcelFailed: '使用数据导出失败',
|
||||||
billingType: '消费类型',
|
billingType: '消费类型',
|
||||||
balance: '余额',
|
balance: '余额',
|
||||||
subscription: '订阅'
|
subscription: '订阅'
|
||||||
@@ -1490,6 +1499,7 @@ export default {
|
|||||||
account: '账户',
|
account: '账户',
|
||||||
group: '分组',
|
group: '分组',
|
||||||
requestId: '请求ID',
|
requestId: '请求ID',
|
||||||
|
requestIdCopied: '请求ID已复制',
|
||||||
allModels: '全部模型',
|
allModels: '全部模型',
|
||||||
allAccounts: '全部账户',
|
allAccounts: '全部账户',
|
||||||
allGroups: '全部分组',
|
allGroups: '全部分组',
|
||||||
@@ -1499,6 +1509,10 @@ export default {
|
|||||||
outputCost: '输出成本',
|
outputCost: '输出成本',
|
||||||
cacheCreationCost: '缓存创建成本',
|
cacheCreationCost: '缓存创建成本',
|
||||||
cacheReadCost: '缓存读取成本',
|
cacheReadCost: '缓存读取成本',
|
||||||
|
inputTokens: '输入 Token',
|
||||||
|
outputTokens: '输出 Token',
|
||||||
|
cacheCreationTokens: '缓存创建 Token',
|
||||||
|
cacheReadTokens: '缓存读取 Token',
|
||||||
failedToLoad: '加载使用记录失败'
|
failedToLoad: '加载使用记录失败'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,20 @@
|
|||||||
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
|
@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 {
|
.btn-sm {
|
||||||
@apply rounded-lg px-3 py-1.5 text-xs;
|
@apply rounded-lg px-3 py-1.5 text-xs;
|
||||||
}
|
}
|
||||||
@@ -130,6 +144,20 @@
|
|||||||
-moz-appearance: textfield;
|
-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 {
|
.card {
|
||||||
@apply bg-white dark:bg-dark-800/50;
|
@apply bg-white dark:bg-dark-800/50;
|
||||||
@@ -151,6 +179,20 @@
|
|||||||
@apply shadow-glass;
|
@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 {
|
.stat-card {
|
||||||
@apply card p-5;
|
@apply card p-5;
|
||||||
@@ -256,6 +298,10 @@
|
|||||||
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
|
@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 {
|
.dropdown {
|
||||||
@apply absolute z-50;
|
@apply absolute z-50;
|
||||||
@@ -283,15 +329,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
@apply w-full;
|
||||||
|
@apply max-h-[95vh] sm:max-h-[90vh];
|
||||||
@apply bg-white dark:bg-dark-800;
|
@apply bg-white dark:bg-dark-800;
|
||||||
@apply rounded-2xl shadow-2xl;
|
@apply rounded-2xl shadow-2xl;
|
||||||
@apply w-full;
|
@apply border border-gray-200 dark:border-dark-700;
|
||||||
@apply max-h-[90vh] overflow-y-auto;
|
@apply flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.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 items-center justify-between;
|
||||||
|
@apply flex-shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
@@ -299,12 +349,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.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 {
|
.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 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 ============ */
|
/* ============ Dialog ============ */
|
||||||
|
|||||||
@@ -105,65 +105,65 @@
|
|||||||
|
|
||||||
<template #table>
|
<template #table>
|
||||||
<!-- Bulk Actions Bar -->
|
<!-- Bulk Actions Bar -->
|
||||||
<div
|
<div
|
||||||
v-if="selectedAccountIds.length > 0"
|
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"
|
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 justify-between gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||||
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
{{ t('admin.accounts.bulkActions.selected', { count: selectedAccountIds.length }) }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@click="selectCurrentPageAccounts"
|
@click="selectCurrentPageAccounts"
|
||||||
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
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"
|
|
||||||
>
|
>
|
||||||
<path
|
{{ t('admin.accounts.bulkActions.selectCurrentPage') }}
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
stroke-linejoin="round"
|
<span class="text-gray-300 dark:text-primary-800">•</span>
|
||||||
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"
|
<button
|
||||||
/>
|
@click="selectedAccountIds = []"
|
||||||
</svg>
|
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.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
|
{{ t('admin.accounts.bulkActions.clear') }}
|
||||||
stroke-linecap="round"
|
</button>
|
||||||
stroke-linejoin="round"
|
</div>
|
||||||
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"
|
<div class="flex items-center gap-2">
|
||||||
/>
|
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
|
||||||
</svg>
|
<svg
|
||||||
{{ t('admin.accounts.bulkActions.edit') }}
|
class="mr-1.5 h-4 w-4"
|
||||||
</button>
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||||
<template #cell-select="{ row }">
|
<template #cell-select="{ row }">
|
||||||
|
|||||||
@@ -300,8 +300,8 @@
|
|||||||
<button @click="resetFilters" class="btn btn-secondary">
|
<button @click="resetFilters" class="btn btn-secondary">
|
||||||
{{ t('common.reset') }}
|
{{ t('common.reset') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="exportToCSV" class="btn btn-primary">
|
<button @click="exportToExcel" :disabled="exporting" class="btn btn-primary">
|
||||||
{{ t('usage.exportCsv') }}
|
{{ t('usage.exportExcel') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,90 +361,114 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-tokens="{ row }">
|
<template #cell-tokens="{ row }">
|
||||||
<div class="space-y-1.5 text-sm">
|
<div class="flex items-center gap-1.5">
|
||||||
<!-- Input / Output Tokens -->
|
<div class="space-y-1.5 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Input / Output Tokens -->
|
||||||
<!-- Input -->
|
<div class="flex items-center gap-2">
|
||||||
<div class="inline-flex items-center gap-1">
|
<!-- Input -->
|
||||||
<svg
|
<div class="inline-flex items-center gap-1">
|
||||||
class="h-3.5 w-3.5 text-emerald-500"
|
<svg
|
||||||
fill="none"
|
class="h-3.5 w-3.5 text-emerald-500"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
stroke-width="2"
|
||||||
/>
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
</svg>
|
/>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
</svg>
|
||||||
row.input_tokens.toLocaleString()
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
}}</span>
|
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>
|
</div>
|
||||||
<!-- Output -->
|
<!-- Cache Tokens (Read + Write) -->
|
||||||
<div class="inline-flex items-center gap-1">
|
<div
|
||||||
<svg
|
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||||
class="h-3.5 w-3.5 text-violet-500"
|
class="flex items-center gap-2"
|
||||||
fill="none"
|
>
|
||||||
stroke="currentColor"
|
<!-- Cache Read -->
|
||||||
viewBox="0 0 24 24"
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
>
|
<svg
|
||||||
<path
|
class="h-3.5 w-3.5 text-sky-500"
|
||||||
stroke-linecap="round"
|
fill="none"
|
||||||
stroke-linejoin="round"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
viewBox="0 0 24 24"
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
stroke-linejoin="round"
|
||||||
row.output_tokens.toLocaleString()
|
stroke-width="2"
|
||||||
}}</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Cache Tokens (Read + Write) -->
|
<!-- Token Detail Tooltip -->
|
||||||
<div
|
<div
|
||||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
class="group relative"
|
||||||
class="flex items-center gap-2"
|
@mouseenter="showTokenTooltip($event, row)"
|
||||||
|
@mouseleave="hideTokenTooltip"
|
||||||
>
|
>
|
||||||
<!-- Cache Read -->
|
<div
|
||||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
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
|
<svg
|
||||||
class="h-3.5 w-3.5 text-sky-500"
|
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||||
fill="none"
|
fill="currentColor"
|
||||||
stroke="currentColor"
|
viewBox="0 0 20 20"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
fill-rule="evenodd"
|
||||||
stroke-linejoin="round"
|
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"
|
||||||
stroke-width="2"
|
clip-rule="evenodd"
|
||||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,9 +540,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-request_id="{ row }">
|
<template #cell-request_id="{ row }">
|
||||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
|
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
|
||||||
row.request_id || '-'
|
<span
|
||||||
}}</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>
|
||||||
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
@@ -540,6 +605,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</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 -->
|
<!-- Tooltip Portal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -602,10 +724,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
|
import * as XLSX from 'xlsx'
|
||||||
|
import { saveAs } from 'file-saver'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
|
import { adminUsageAPI } from '@/api/admin/usage'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.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 DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||||
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
|
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
|
||||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import type {
|
import type {
|
||||||
@@ -626,12 +753,21 @@ import type {
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
// Tooltip state
|
// Tooltip state
|
||||||
const tooltipVisible = ref(false)
|
const tooltipVisible = ref(false)
|
||||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tooltipData = ref<UsageLog | null>(null)
|
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
|
// Usage stats from API
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -657,6 +793,7 @@ const columns = computed<Column[]>(() => [
|
|||||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||||
{ key: 'billing_type', label: t('usage.billingType'), 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: 'duration', label: t('usage.duration'), sortable: false },
|
||||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||||
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
||||||
@@ -669,6 +806,15 @@ const accounts = ref<any[]>([])
|
|||||||
const groups = ref<any[]>([])
|
const groups = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
let abortController: AbortController | null = null
|
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
|
// User search state
|
||||||
const userSearchKeyword = ref('')
|
const userSearchKeyword = ref('')
|
||||||
@@ -868,6 +1014,16 @@ const formatCacheTokens = (value: number): string => {
|
|||||||
return value.toLocaleString()
|
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 => {
|
const isAbortError = (error: unknown): boolean => {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
return true
|
return true
|
||||||
@@ -879,6 +1035,40 @@ const isAbortError = (error: unknown): boolean => {
|
|||||||
return false
|
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 () => {
|
const loadUsageLogs = async () => {
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
@@ -1051,52 +1241,129 @@ const handlePageSizeChange = (pageSize: number) => {
|
|||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportToCSV = () => {
|
const cancelExport = () => {
|
||||||
if (usageLogs.value.length === 0) {
|
if (!exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportAbortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToExcel = async () => {
|
||||||
|
if (pagination.value.total === 0) {
|
||||||
appStore.showWarning(t('usage.noDataToExport'))
|
appStore.showWarning(t('usage.noDataToExport'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [
|
if (exporting.value) {
|
||||||
'User',
|
return
|
||||||
'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
|
|
||||||
])
|
|
||||||
|
|
||||||
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 startedAt = Date.now()
|
||||||
const url = window.URL.createObjectURL(blob)
|
const controller = new AbortController()
|
||||||
const link = document.createElement('a')
|
exportAbortController = controller
|
||||||
link.href = url
|
|
||||||
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
|
|
||||||
link.click()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
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
|
// Click outside to close dropdown
|
||||||
@@ -1123,6 +1390,22 @@ const hideTooltip = () => {
|
|||||||
tooltipData.value = null
|
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(() => {
|
onMounted(() => {
|
||||||
loadFilterOptions()
|
loadFilterOptions()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
@@ -1140,5 +1423,8 @@ onUnmounted(() => {
|
|||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
|
if (exportAbortController) {
|
||||||
|
exportAbortController.abort()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -301,7 +301,7 @@
|
|||||||
<BaseDialog
|
<BaseDialog
|
||||||
:show="showCreateModal || showEditModal"
|
:show="showCreateModal || showEditModal"
|
||||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||||
width="narrow"
|
width="normal"
|
||||||
@close="closeModals"
|
@close="closeModals"
|
||||||
>
|
>
|
||||||
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
||||||
@@ -893,7 +893,20 @@ const importToCcswitch = (apiKey: string) => {
|
|||||||
usageAutoInterval: '30'
|
usageAutoInterval: '30'
|
||||||
})
|
})
|
||||||
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
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(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -219,90 +219,114 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-tokens="{ row }">
|
<template #cell-tokens="{ row }">
|
||||||
<div class="space-y-1.5 text-sm">
|
<div class="flex items-center gap-1.5">
|
||||||
<!-- Input / Output Tokens -->
|
<div class="space-y-1.5 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Input / Output Tokens -->
|
||||||
<!-- Input -->
|
<div class="flex items-center gap-2">
|
||||||
<div class="inline-flex items-center gap-1">
|
<!-- Input -->
|
||||||
<svg
|
<div class="inline-flex items-center gap-1">
|
||||||
class="h-3.5 w-3.5 text-emerald-500"
|
<svg
|
||||||
fill="none"
|
class="h-3.5 w-3.5 text-emerald-500"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
stroke-width="2"
|
||||||
/>
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
</svg>
|
/>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
</svg>
|
||||||
row.input_tokens.toLocaleString()
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
}}</span>
|
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>
|
</div>
|
||||||
<!-- Output -->
|
<!-- Cache Tokens (Read + Write) -->
|
||||||
<div class="inline-flex items-center gap-1">
|
<div
|
||||||
<svg
|
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
||||||
class="h-3.5 w-3.5 text-violet-500"
|
class="flex items-center gap-2"
|
||||||
fill="none"
|
>
|
||||||
stroke="currentColor"
|
<!-- Cache Read -->
|
||||||
viewBox="0 0 24 24"
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
>
|
<svg
|
||||||
<path
|
class="h-3.5 w-3.5 text-sky-500"
|
||||||
stroke-linecap="round"
|
fill="none"
|
||||||
stroke-linejoin="round"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
viewBox="0 0 24 24"
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
>
|
||||||
/>
|
<path
|
||||||
</svg>
|
stroke-linecap="round"
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
stroke-linejoin="round"
|
||||||
row.output_tokens.toLocaleString()
|
stroke-width="2"
|
||||||
}}</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Cache Tokens (Read + Write) -->
|
<!-- Token Detail Tooltip -->
|
||||||
<div
|
<div
|
||||||
v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0"
|
class="group relative"
|
||||||
class="flex items-center gap-2"
|
@mouseenter="showTokenTooltip($event, row)"
|
||||||
|
@mouseleave="hideTokenTooltip"
|
||||||
>
|
>
|
||||||
<!-- Cache Read -->
|
<div
|
||||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
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
|
<svg
|
||||||
class="h-3.5 w-3.5 text-sky-500"
|
class="h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
|
||||||
fill="none"
|
fill="currentColor"
|
||||||
stroke="currentColor"
|
viewBox="0 0 20 20"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
fill-rule="evenodd"
|
||||||
stroke-linejoin="round"
|
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"
|
||||||
stroke-width="2"
|
clip-rule="evenodd"
|
||||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,6 +416,54 @@
|
|||||||
</TablePageLayout>
|
</TablePageLayout>
|
||||||
</AppLayout>
|
</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 -->
|
<!-- Tooltip Portal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -458,6 +530,11 @@ const tooltipVisible = ref(false)
|
|||||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tooltipData = ref<UsageLog | null>(null)
|
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
|
// Usage stats from API
|
||||||
const usageStats = ref<UsageStatsResponse | null>(null)
|
const usageStats = ref<UsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -778,6 +855,22 @@ const hideTooltip = () => {
|
|||||||
tooltipData.value = null
|
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(() => {
|
onMounted(() => {
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
|
|||||||
Reference in New Issue
Block a user