Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
b63b338e
Commit
b63b338e
authored
Dec 30, 2025
by
yangjianbo
Browse files
Merge branch 'main' into test-dev
parents
57db688d
e85b35c6
Changes
30
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
b63b338e
...
...
@@ -322,7 +322,8 @@ export default {
customKeyHint
:
'
仅允许字母、数字、下划线和连字符,最少16个字符。
'
,
customKeyTooShort
:
'
自定义密钥至少需要16个字符
'
,
customKeyInvalidChars
:
'
自定义密钥只能包含字母、数字、下划线和连字符
'
,
customKeyRequired
:
'
请输入自定义密钥
'
customKeyRequired
:
'
请输入自定义密钥
'
,
ccSwitchNotInstalled
:
'
CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。
'
},
// Usage
...
...
@@ -341,6 +342,12 @@ export default {
allApiKeys
:
'
全部密钥
'
,
timeRange
:
'
时间范围
'
,
exportCsv
:
'
导出 CSV
'
,
exportExcel
:
'
导出 Excel
'
,
exportingProgress
:
'
正在导出数据...
'
,
exportedCount
:
'
已导出 {current}/{total} 条
'
,
estimatedTime
:
'
预计剩余时间:{time}
'
,
cancelExport
:
'
取消导出
'
,
exportCancelled
:
'
导出已取消
'
,
exporting
:
'
导出中...
'
,
preparingExport
:
'
正在准备导出...
'
,
model
:
'
模型
'
,
...
...
@@ -364,6 +371,8 @@ export default {
noDataToExport
:
'
没有可导出的数据
'
,
exportSuccess
:
'
使用数据导出成功
'
,
exportFailed
:
'
使用数据导出失败
'
,
exportExcelSuccess
:
'
使用数据导出成功(Excel格式)
'
,
exportExcelFailed
:
'
使用数据导出失败
'
,
billingType
:
'
消费类型
'
,
balance
:
'
余额
'
,
subscription
:
'
订阅
'
...
...
@@ -1490,6 +1499,7 @@ export default {
account
:
'
账户
'
,
group
:
'
分组
'
,
requestId
:
'
请求ID
'
,
requestIdCopied
:
'
请求ID已复制
'
,
allModels
:
'
全部模型
'
,
allAccounts
:
'
全部账户
'
,
allGroups
:
'
全部分组
'
,
...
...
@@ -1499,6 +1509,10 @@ export default {
outputCost
:
'
输出成本
'
,
cacheCreationCost
:
'
缓存创建成本
'
,
cacheReadCost
:
'
缓存读取成本
'
,
inputTokens
:
'
输入 Token
'
,
outputTokens
:
'
输出 Token
'
,
cacheCreationTokens
:
'
缓存创建 Token
'
,
cacheReadTokens
:
'
缓存读取 Token
'
,
failedToLoad
:
'
加载使用记录失败
'
},
...
...
@@ -1685,5 +1699,150 @@ export default {
resetIn
:
'
{time} 后重置
'
,
windowNotActive
:
'
等待首次使用
'
,
usageOf
:
'
已用 {used} / {limit}
'
},
// Onboarding Tour
onboarding
:
{
restartTour
:
'
重新查看新手引导
'
,
dontShowAgain
:
'
不再提示
'
,
dontShowAgainTitle
:
'
永久关闭新手引导
'
,
confirmDontShow
:
'
确定不再显示新手引导吗?
\n\n
您可以随时在右上角头像菜单中重新开启。
'
,
confirmExit
:
'
确定要退出新手引导吗?您可以随时在右上角菜单重新开始。
'
,
interactiveHint
:
'
按 Enter 或点击继续
'
,
navigation
:
{
flipPage
:
'
翻页
'
,
exit
:
'
退出
'
},
// Admin tour steps
admin
:
{
welcome
:
{
title
:
'
👋 欢迎使用 Sub2API
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>
'
,
nextBtn
:
'
开始配置 🚀
'
,
prevBtn
:
'
跳过
'
},
groupManage
:
{
title
:
'
📦 第一步:分组管理
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>
'
},
createGroup
:
{
title
:
'
➕ 创建新分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>
'
},
groupName
:
{
title
:
'
✏️ 1. 分组名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupPlatform
:
{
title
:
'
🤖 2. 选择平台
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupMultiplier
:
{
title
:
'
💰 3. 费率倍数
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupExclusive
:
{
title
:
'
🔒 4. 专属分组(可选)
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupSubmit
:
{
title
:
'
✅ 保存分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
},
accountManage
:
{
title
:
'
🔗 第二步:添加账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>
'
},
createAccount
:
{
title
:
'
➕ 添加新账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>
'
},
accountName
:
{
title
:
'
✏️ 1. 账号名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountPlatform
:
{
title
:
'
🤖 2. 选择平台
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountType
:
{
title
:
'
🔐 3. 授权方式
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>
'
,
nextBtn
:
'
下一步
'
},
accountPriority
:
{
title
:
'
⚖️ 4. 优先级(可选)
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越大,优先级越高</li><li>系统优先使用高优先级账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置高优先级,备用账号设置低优先级</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountGroups
:
{
title
:
'
🎯 5. 分配分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountSubmit
:
{
title
:
'
✅ 保存账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>
'
},
keyManage
:
{
title
:
'
🔑 第三步:生成密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>
'
},
createKey
:
{
title
:
'
➕ 创建密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>
'
},
keyName
:
{
title
:
'
✏️ 1. 密钥名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>
'
,
nextBtn
:
'
下一步
'
},
keyGroup
:
{
title
:
'
🎯 2. 选择分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>
'
,
nextBtn
:
'
下一步
'
},
keySubmit
:
{
title
:
'
🎉 生成并复制
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
}
},
// User tour steps
user
:
{
welcome
:
{
title
:
'
👋 欢迎使用 Sub2API
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>
'
,
nextBtn
:
'
开始 🚀
'
,
prevBtn
:
'
跳过
'
},
keyManage
:
{
title
:
'
🔑 API 密钥管理
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>
'
},
createKey
:
{
title
:
'
➕ 创建新密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>
'
},
keyName
:
{
title
:
'
✏️ 密钥名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>
'
,
nextBtn
:
'
下一步
'
},
keyGroup
:
{
title
:
'
🎯 选择分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>
'
,
nextBtn
:
'
下一步
'
},
keySubmit
:
{
title
:
'
🎉 完成创建
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
}
}
}
}
frontend/src/stores/index.ts
View file @
b63b338e
...
...
@@ -6,6 +6,7 @@
export
{
useAuthStore
}
from
'
./auth
'
export
{
useAppStore
}
from
'
./app
'
export
{
useSubscriptionStore
}
from
'
./subscriptions
'
export
{
useOnboardingStore
}
from
'
./onboarding
'
// Re-export types for convenience
export
type
{
User
,
LoginRequest
,
RegisterRequest
,
AuthResponse
}
from
'
@/types
'
...
...
frontend/src/stores/onboarding.ts
0 → 100644
View file @
b63b338e
/**
* Onboarding Store
* Manages onboarding tour state and control methods
*/
import
{
defineStore
}
from
'
pinia
'
import
{
markRaw
,
ref
,
shallowRef
}
from
'
vue
'
import
type
{
Driver
}
from
'
driver.js
'
type
VoidCallback
=
()
=>
void
type
NextStepCallback
=
(
delay
?:
number
)
=>
Promise
<
void
>
type
IsCurrentStepCallback
=
(
selector
:
string
)
=>
boolean
export
const
useOnboardingStore
=
defineStore
(
'
onboarding
'
,
()
=>
{
const
replayCallback
=
ref
<
VoidCallback
|
null
>
(
null
)
const
nextStepCallback
=
ref
<
NextStepCallback
|
null
>
(
null
)
const
isCurrentStepCallback
=
ref
<
IsCurrentStepCallback
|
null
>
(
null
)
// 全局 driver 实例,跨组件保持
const
driverInstance
=
shallowRef
<
Driver
|
null
>
(
null
)
function
setReplayCallback
(
callback
:
VoidCallback
|
null
):
void
{
replayCallback
.
value
=
callback
}
function
setControlMethods
(
methods
:
{
nextStep
:
NextStepCallback
,
isCurrentStep
:
IsCurrentStepCallback
}):
void
{
nextStepCallback
.
value
=
methods
.
nextStep
isCurrentStepCallback
.
value
=
methods
.
isCurrentStep
}
function
clearControlMethods
():
void
{
nextStepCallback
.
value
=
null
isCurrentStepCallback
.
value
=
null
}
function
setDriverInstance
(
driver
:
Driver
|
null
):
void
{
driverInstance
.
value
=
driver
?
markRaw
(
driver
)
:
null
}
function
getDriverInstance
():
Driver
|
null
{
return
driverInstance
.
value
}
function
isDriverActive
():
boolean
{
return
driverInstance
.
value
?.
isActive
?.()
??
false
}
function
replay
():
void
{
if
(
replayCallback
.
value
)
{
replayCallback
.
value
()
}
}
/**
* Manually advance to the next step
* @param delay Optional delay in ms (useful for waiting for animations)
*/
async
function
nextStep
(
delay
=
0
):
Promise
<
void
>
{
if
(
nextStepCallback
.
value
)
{
await
nextStepCallback
.
value
(
delay
)
}
}
/**
* Check if the tour is currently highlighting a specific element
*/
function
isCurrentStep
(
selector
:
string
):
boolean
{
if
(
isCurrentStepCallback
.
value
)
{
return
isCurrentStepCallback
.
value
(
selector
)
}
return
false
}
return
{
setReplayCallback
,
setControlMethods
,
clearControlMethods
,
setDriverInstance
,
getDriverInstance
,
isDriverActive
,
replay
,
nextStep
,
isCurrentStep
}
})
frontend/src/style.css
View file @
b63b338e
...
...
@@ -79,6 +79,20 @@
@apply
hover
:
from-red-600
hover
:
to-red-700
hover
:
shadow-lg
hover
:
shadow-red-500
/
30
;
}
.btn-success
{
@apply
bg-gradient-to-r
from-emerald-500
to-emerald-600;
@apply
text-white
shadow-md
shadow-emerald-500/25;
@apply
hover
:
from-emerald-600
hover
:
to-emerald-700
hover
:
shadow-lg
hover
:
shadow-emerald-500
/
30
;
@apply
dark
:
shadow-emerald-500
/
20
;
}
.btn-warning
{
@apply
bg-gradient-to-r
from-amber-500
to-amber-600;
@apply
text-white
shadow-md
shadow-amber-500/25;
@apply
hover
:
from-amber-600
hover
:
to-amber-700
hover
:
shadow-lg
hover
:
shadow-amber-500
/
30
;
@apply
dark
:
shadow-amber-500
/
20
;
}
.btn-sm
{
@apply
rounded-lg
px-3
py-1.5
text-xs;
}
...
...
@@ -130,6 +144,20 @@
-moz-appearance
:
textfield
;
}
/* ============ 玻璃效果 ============ */
.glass
{
@apply
bg-white/80
backdrop-blur-xl
dark
:
bg-dark-800
/
80
;
}
.glass-card
{
@apply
bg-white/70
dark
:
bg-dark-800
/
70
;
@apply
backdrop-blur-xl;
@apply
rounded-2xl;
@apply
border
border-white/20
dark
:
border-dark-700
/
50
;
@apply
shadow-glass;
@apply
transition-all
duration-300;
}
/* ============ 卡片样式 ============ */
.card
{
@apply
bg-white
dark
:
bg-dark-800
/
50
;
...
...
@@ -151,6 +179,20 @@
@apply
shadow-glass;
}
.card-header
{
@apply
border-b
border-gray-100
dark
:
border-dark-700
;
@apply
px-6
py-4;
}
.card-body
{
@apply
p-6;
}
.card-footer
{
@apply
border-t
border-gray-100
dark
:
border-dark-700
;
@apply
px-6
py-4;
}
/* ============ 统计卡片 ============ */
.stat-card
{
@apply
card
p-5;
...
...
@@ -256,6 +298,10 @@
@apply
bg-gray-100
text-gray-700
dark
:
bg-dark-700
dark
:
text-dark-300
;
}
.badge-purple
{
@apply
bg-purple-100
text-purple-700
dark
:
bg-purple-900
/
30
dark
:
text-purple-400
;
}
/* ============ 下拉菜单 ============ */
.dropdown
{
@apply
absolute
z-50;
...
...
@@ -283,15 +329,19 @@
}
.modal-content
{
@apply
w-full;
@apply
max-h-[95vh]
sm
:
max-h-
[
90vh
];
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-2xl
shadow-2xl;
@apply
w-full
;
@apply
max-h-[90vh]
overflow-y-auto
;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
flex
flex-col
;
}
.modal-header
{
@apply
border-b
border-gray-100
px-6
py-4
dark
:
border-dark-700
;
@apply
border-b
border-gray-200
px-4
py-3
dark
:
border-dark-700
;
@apply
sm
:
px-6
sm
:
py-4
;
@apply
flex
items-center
justify-between;
@apply
flex-shrink-0;
}
.modal-title
{
...
...
@@ -299,12 +349,69 @@
}
.modal-body
{
@apply
px-6
py-4;
@apply
px-4
py-3;
@apply
sm
:
px-6
sm
:
py-4
;
@apply
flex-1
overflow-y-auto;
}
.modal-footer
{
@apply
border-t
border-gray-100
px-6
py-4
dark
:
border-dark-700
;
@apply
border-t
border-gray-200
px-4
py-3
dark
:
border-dark-700
;
@apply
sm
:
px-6
sm
:
py-4
;
@apply
flex
items-center
justify-end
gap-3;
@apply
flex-shrink-0;
}
/* 防止body滚动的工具类 */
body
.modal-open
{
overflow
:
hidden
;
}
.modal-enter-active
{
transition
:
opacity
250ms
ease-out
;
}
.modal-leave-active
{
transition
:
opacity
200ms
ease-in
;
}
.modal-enter-from
,
.modal-leave-to
{
opacity
:
0
;
}
.modal-enter-active
.modal-content
{
transition
:
transform
250ms
ease-out
,
opacity
250ms
ease-out
;
}
.modal-leave-active
.modal-content
{
transition
:
transform
200ms
ease-in
,
opacity
200ms
ease-in
;
}
.modal-enter-from
.modal-content
,
.modal-leave-to
.modal-content
{
transform
:
scale
(
0.95
);
opacity
:
0
;
}
.modal-enter-to
.modal-content
,
.modal-leave-from
.modal-content
{
transform
:
scale
(
1
);
opacity
:
1
;
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.modal-enter-active
,
.modal-leave-active
,
.modal-enter-active
.modal-content
,
.modal-leave-active
.modal-content
{
transition-duration
:
1ms
;
transition-delay
:
0ms
;
}
.modal-enter-from
.modal-content
,
.modal-leave-to
.modal-content
{
transform
:
none
;
}
}
/* ============ Dialog ============ */
...
...
@@ -518,6 +625,43 @@
@apply
overflow-x-auto
rounded-xl
p-4;
}
/* ============ Tour Description ============ */
.tour-step-description
{
@apply
space-y-3
text-sm
leading-relaxed
text-gray-700
dark
:
text-gray-200
;
}
.tour-step-description
ul
{
@apply
list-disc
pl-5;
}
.tour-step-description
ol
{
@apply
list-decimal
pl-5;
}
.tour-step-description
li
+
li
{
@apply
mt-1;
}
.tour-info-box
{
@apply
rounded-md
border-l-4
border-blue-500
bg-blue-50
px-3
py-2
text-xs
text-blue-900;
@apply
dark
:
border-blue-400
dark
:
bg-blue-950
/
40
dark
:
text-blue-200
;
}
.tour-success-box
{
@apply
rounded-md
border-l-4
border-emerald-500
bg-emerald-50
px-3
py-2
text-xs
text-emerald-900;
@apply
dark
:
border-emerald-400
dark
:
bg-emerald-950
/
40
dark
:
text-emerald-200
;
}
.tour-warning-box
{
@apply
rounded-md
border-l-4
border-amber-500
bg-amber-50
px-3
py-2
text-xs
text-amber-900;
@apply
dark
:
border-amber-400
dark
:
bg-amber-950
/
40
dark
:
text-amber-200
;
}
.tour-error-box
{
@apply
rounded-md
border-l-4
border-red-500
bg-red-50
px-3
py-2
text-xs
text-red-900;
@apply
dark
:
border-red-400
dark
:
bg-red-950
/
40
dark
:
text-red-200
;
}
/* ============ 表格页面布局优化 ============ */
/* 表格容器 - 默认仅支持水平滚动 */
.table-wrapper
{
...
...
frontend/src/styles/onboarding.css
0 → 100644
View file @
b63b338e
/* Sub2API Interactive Tour Styles - DOM Restructured Version */
/* 1. Overlay & Highlight */
.driver-overlay
{
position
:
fixed
!important
;
inset
:
0
!important
;
z-index
:
99999998
!important
;
background-color
:
transparent
!important
;
/*
* 关键修复:让 overlay 不拦截点击事件
* 因为已设置 allowClose: false,用户不能通过点击遮罩关闭引导
* 这样 Select 下拉菜单等脱离高亮区域的元素才能正常交互
* 视觉遮罩效果保持不变(SVG 仍然渲染,pointer-events 只影响交互不影响渲染)
*/
pointer-events
:
none
!important
;
}
.driver-overlay
svg
{
pointer-events
:
none
!important
;
}
.driver-active-element
{
position
:
relative
!important
;
z-index
:
99999999
!important
;
outline
:
4px
solid
rgba
(
20
,
184
,
166
,
0.2
)
!important
;
border-radius
:
4px
!important
;
}
/* 2. Popover Container */
.driver-popover.theme-tour-popover
{
position
:
fixed
!important
;
z-index
:
100000000
!important
;
background-color
:
#ffffff
!important
;
border
:
1px
solid
#e5e7eb
!important
;
box-shadow
:
0
20px
25px
-5px
rgba
(
0
,
0
,
0
,
0.1
),
0
10px
10px
-5px
rgba
(
0
,
0
,
0
,
0.04
)
!important
;
border-radius
:
12px
!important
;
padding
:
0
!important
;
max-width
:
min
(
440px
,
90vw
)
!important
;
/* Responsive on small screens */
color
:
#1f2937
!important
;
font-family
:
ui-sans-serif
,
system-ui
,
sans-serif
!important
;
overflow
:
hidden
!important
;
}
.dark
.driver-popover.theme-tour-popover
{
background-color
:
#1e293b
!important
;
border-color
:
#334155
!important
;
color
:
#f3f4f6
!important
;
}
/* 3. Header Area */
.theme-tour-popover
.driver-popover-title
{
display
:
flex
!important
;
align-items
:
center
!important
;
padding
:
20px
24px
12px
24px
!important
;
margin
:
0
!important
;
background-color
:
transparent
!important
;
position
:
relative
!important
;
}
.driver-popover-title-text
{
font-size
:
18px
!important
;
font-weight
:
700
!important
;
color
:
#111827
!important
;
line-height
:
1.3
!important
;
padding-right
:
100px
!important
;
/* Ensure title doesn't overlap Skip/Close */
}
.dark
.driver-popover-title-text
{
color
:
#ffffff
!important
;
}
/* Close Button */
.theme-tour-popover
.driver-popover-close-btn
{
position
:
absolute
!important
;
top
:
18px
!important
;
right
:
20px
!important
;
width
:
28px
!important
;
height
:
28px
!important
;
padding
:
0
!important
;
color
:
#9ca3af
!important
;
background-color
:
transparent
!important
;
border
:
none
!important
;
z-index
:
20
!important
;
border-radius
:
4px
!important
;
display
:
flex
!important
;
align-items
:
center
!important
;
justify-content
:
center
!important
;
}
.theme-tour-popover
.driver-popover-close-btn
:hover
{
background-color
:
#f3f4f6
!important
;
color
:
#4b5563
!important
;
}
.dark
.theme-tour-popover
.driver-popover-close-btn
:hover
{
background-color
:
#334155
!important
;
}
/* 4. Body Content */
.theme-tour-popover
.driver-popover-description
{
display
:
block
!important
;
font-size
:
14px
!important
;
font-weight
:
400
!important
;
color
:
#4b5563
!important
;
padding
:
0
24px
24px
24px
!important
;
margin
:
0
!important
;
line-height
:
1.6
!important
;
background-color
:
transparent
!important
;
}
.dark
.theme-tour-popover
.driver-popover-description
{
color
:
#cbd5e1
!important
;
}
/* 5. Footer Area - Flex Row with Left/Right Containers */
.theme-tour-popover
.driver-popover-footer
{
display
:
flex
!important
;
align-items
:
center
!important
;
justify-content
:
space-between
!important
;
/* Push Left and Right apart */
padding
:
16px
24px
!important
;
background-color
:
#f9fafb
!important
;
border-top
:
1px
solid
#f3f4f6
!important
;
margin
:
0
!important
;
}
.dark
.theme-tour-popover
.driver-popover-footer
{
background-color
:
#0f172a
!important
;
border-top-color
:
#1e293b
!important
;
}
/* Left Container: Progress + Shortcuts */
.footer-left
{
display
:
flex
!important
;
align-items
:
center
!important
;
gap
:
16px
!important
;
}
/* Right Container: Buttons */
.footer-right
{
display
:
flex
!important
;
align-items
:
center
!important
;
gap
:
8px
!important
;
}
/* Progress */
.theme-tour-popover
.driver-popover-progress-text
{
font-size
:
13px
!important
;
color
:
#6b7280
!important
;
margin
:
0
!important
;
font-weight
:
500
!important
;
white-space
:
nowrap
!important
;
}
.dark
.theme-tour-popover
.driver-popover-progress-text
{
color
:
#9ca3af
!important
;
}
/* Shortcuts (Divider + Keys) */
.footer-shortcuts
{
display
:
flex
!important
;
align-items
:
center
!important
;
gap
:
12px
!important
;
padding-left
:
16px
!important
;
border-left
:
1px
solid
#e5e7eb
!important
;
height
:
20px
!important
;
}
.dark
.footer-shortcuts
{
border-left-color
:
#334155
!important
;
}
.shortcut-item
{
display
:
flex
!important
;
align-items
:
center
!important
;
gap
:
4px
!important
;
font-size
:
12px
!important
;
color
:
#6b7280
!important
;
white-space
:
nowrap
!important
;
}
.dark
.shortcut-item
{
color
:
#94a3b8
!important
;
}
.shortcut-item
kbd
{
font-family
:
ui-monospace
,
SFMono-Regular
,
Menlo
,
Monaco
,
monospace
!important
;
background-color
:
#ffffff
!important
;
border
:
1px
solid
#e5e7eb
!important
;
border-radius
:
4px
!important
;
padding
:
1px
6px
!important
;
font-size
:
11px
!important
;
font-weight
:
600
!important
;
color
:
#4b5563
!important
;
box-shadow
:
0
1px
0
rgba
(
0
,
0
,
0
,
0.05
)
!important
;
min-width
:
20px
!important
;
text-align
:
center
!important
;
display
:
inline-block
!important
;
}
.dark
.shortcut-item
kbd
{
background-color
:
#1e293b
!important
;
border-color
:
#475569
!important
;
color
:
#cbd5e1
!important
;
}
/* Nav Buttons */
.theme-tour-popover
button
{
display
:
inline-flex
!important
;
align-items
:
center
!important
;
justify-content
:
center
!important
;
padding
:
8px
16px
!important
;
font-size
:
13px
!important
;
font-weight
:
500
!important
;
border-radius
:
6px
!important
;
cursor
:
pointer
!important
;
transition
:
all
0.2s
!important
;
border
:
1px
solid
transparent
!important
;
line-height
:
1.2
!important
;
white-space
:
nowrap
!important
;
/* Force no wrap */
}
.theme-tour-popover
.driver-popover-next-btn
{
background-color
:
#14b8a6
!important
;
color
:
#ffffff
!important
;
box-shadow
:
0
1px
2px
0
rgba
(
0
,
0
,
0
,
0.05
)
!important
;
}
.theme-tour-popover
.driver-popover-next-btn
:hover
{
background-color
:
#0d9488
!important
;
}
.theme-tour-popover
.driver-popover-prev-btn
{
background-color
:
white
!important
;
color
:
#6b7280
!important
;
border
:
1px
solid
#e5e7eb
!important
;
box-shadow
:
0
1px
2px
0
rgba
(
0
,
0
,
0
,
0.05
)
!important
;
}
.theme-tour-popover
.driver-popover-prev-btn
:hover
{
background-color
:
#f9fafb
!important
;
color
:
#374151
!important
;
}
.dark
.theme-tour-popover
.driver-popover-prev-btn
{
background-color
:
#1e293b
!important
;
border-color
:
#475569
!important
;
color
:
#9ca3af
!important
;
}
/* Arrows */
.driver-popover-arrow
{
z-index
:
100000001
!important
;
}
.driver-popover-arrow-side-left.driver-popover-arrow
{
border-left-color
:
#ffffff
!important
;
}
.driver-popover-arrow-side-right.driver-popover-arrow
{
border-right-color
:
#ffffff
!important
;
}
.driver-popover-arrow-side-top.driver-popover-arrow
{
border-top-color
:
#ffffff
!important
;
}
.driver-popover-arrow-side-bottom.driver-popover-arrow
{
border-bottom-color
:
#ffffff
!important
;
}
.dark
.driver-popover-arrow-side-left.driver-popover-arrow
{
border-left-color
:
#1e293b
!important
;
}
.dark
.driver-popover-arrow-side-right.driver-popover-arrow
{
border-right-color
:
#1e293b
!important
;
}
.dark
.driver-popover-arrow-side-top.driver-popover-arrow
{
border-top-color
:
#1e293b
!important
;
}
.dark
.driver-popover-arrow-side-bottom.driver-popover-arrow
{
border-bottom-color
:
#1e293b
!important
;
}
frontend/src/views/admin/AccountsView.vue
View file @
b63b338e
...
...
@@ -38,7 +38,7 @@
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"accounts-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
...
...
@@ -107,7 +107,7 @@
<!-- Bulk Actions Bar -->
<div
v-if=
"selectedAccountIds.length > 0"
class=
"
card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20
"
class=
"
mb-[5px] mt-[10px] px-5 py-1
"
>
<div
class=
"flex flex-wrap items-center justify-between gap-3"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
...
...
@@ -373,7 +373,7 @@
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showCreateModal = false
"
@
created
=
"
loadAccounts
"
@
created
=
"
() => {
loadAccounts
(); if (onboardingStore.isCurrentStep(`[data-tour='account-form-submit']`)) onboardingStore.nextStep(500)
}
"
/>
<!--
Edit
Account
Modal
-->
...
...
@@ -495,6 +495,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicIn
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
...
...
@@ -524,6 +525,7 @@ import { formatRelativeTime } from '@/utils/format'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
authStore
=
useAuthStore
()
const
onboardingStore
=
useOnboardingStore
()
// Table columns
const
columns
=
computed
<
Column
[]
>
(()
=>
{
...
...
frontend/src/views/admin/GroupsView.vue
View file @
b63b338e
...
...
@@ -23,7 +23,11 @@
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"groups-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
...
...
@@ -244,6 +248,7 @@
required
class
=
"
input
"
:
placeholder
=
"
t('admin.groups.enterGroupName')
"
data
-
tour
=
"
group-form-name
"
/>
<
/div
>
<
div
>
...
...
@@ -257,7 +262,11 @@
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.platform
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
createForm.platform
"
:
options
=
"
platformOptions
"
/>
<
Select
v
-
model
=
"
createForm.platform
"
:
options
=
"
platformOptions
"
data
-
tour
=
"
group-form-platform
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.platformHint
'
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
createForm.subscription_type !== 'subscription'
"
>
...
...
@@ -269,10 +278,11 @@
min
=
"
0.001
"
required
class
=
"
input
"
data
-
tour
=
"
group-form-multiplier
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.rateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
createForm.subscription_type !== 'subscription'
"
>
<
div
v
-
if
=
"
createForm.subscription_type !== 'subscription'
"
data
-
tour
=
"
group-form-exclusive
"
>
<
div
class
=
"
mb-1.5 flex items-center gap-1
"
>
<
label
class
=
"
text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.groups.form.exclusive
'
)
}}
...
...
@@ -390,6 +400,7 @@
form
=
"
create-group-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
data
-
tour
=
"
group-form-submit
"
>
<
svg
v
-
if
=
"
submitting
"
...
...
@@ -432,7 +443,13 @@
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.name
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
/>
<
input
v
-
model
=
"
editForm.name
"
type
=
"
text
"
required
class
=
"
input
"
data
-
tour
=
"
edit-group-form-name
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.description
'
)
}}
<
/label
>
...
...
@@ -440,7 +457,12 @@
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.groups.form.platform
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
editForm.platform
"
:
options
=
"
platformOptions
"
:
disabled
=
"
true
"
/>
<
Select
v
-
model
=
"
editForm.platform
"
:
options
=
"
platformOptions
"
:
disabled
=
"
true
"
data
-
tour
=
"
group-form-platform
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.groups.platformNotEditable
'
)
}}
<
/p
>
<
/div
>
<
div
v
-
if
=
"
editForm.subscription_type !== 'subscription'
"
>
...
...
@@ -452,6 +474,7 @@
min
=
"
0.001
"
required
class
=
"
input
"
data
-
tour
=
"
group-form-multiplier
"
/>
<
/div
>
<
div
v
-
if
=
"
editForm.subscription_type !== 'subscription'
"
>
...
...
@@ -580,6 +603,7 @@
form
=
"
edit-group-form
"
:
disabled
=
"
submitting
"
class
=
"
btn btn-primary
"
data
-
tour
=
"
group-form-submit
"
>
<
svg
v
-
if
=
"
submitting
"
...
...
@@ -625,6 +649,7 @@
import
{
ref
,
reactive
,
computed
,
onMounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Group
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
...
...
@@ -640,6 +665,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
onboardingStore
=
useOnboardingStore
()
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
name
'
,
label
:
t
(
'
admin.groups.columns.name
'
),
sortable
:
true
}
,
...
...
@@ -809,9 +835,14 @@ const handleCreateGroup = async () => {
appStore
.
showSuccess
(
t
(
'
admin.groups.groupCreated
'
))
closeCreateModal
()
loadGroups
()
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="group-form-submit"]
'
))
{
onboardingStore
.
nextStep
(
500
)
}
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.groups.failedToCreate
'
))
console
.
error
(
'
Error creating group:
'
,
error
)
// Don't advance tour on error
}
finally
{
submitting
.
value
=
false
}
...
...
frontend/src/views/admin/UsageView.vue
View file @
b63b338e
...
...
@@ -300,8 +300,8 @@
<button
@
click=
"resetFilters"
class=
"btn btn-secondary"
>
{{
t
(
'
common.reset
'
)
}}
</button>
<button
@
click=
"exportTo
CSV
"
class=
"btn btn-primary"
>
{{
t
(
'
usage.export
Csv
'
)
}}
<button
@
click=
"exportTo
Excel"
:disabled=
"exporting
"
class=
"btn btn-primary"
>
{{
t
(
'
usage.export
Excel
'
)
}}
</button>
</div>
</div>
...
...
@@ -361,6 +361,7 @@
</
template
>
<
template
#cell-tokens=
"{ row }"
>
<div
class=
"flex items-center gap-1.5"
>
<div
class=
"space-y-1.5 text-sm"
>
<!-- Input / Output Tokens -->
<div
class=
"flex items-center gap-2"
>
...
...
@@ -448,6 +449,29 @@
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
class=
"group relative"
@
mouseenter=
"showTokenTooltip($event, row)"
@
mouseleave=
"hideTokenTooltip"
>
<div
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class=
"h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule=
"evenodd"
/>
</svg>
</div>
</div>
</div>
</
template
>
<
template
#cell-cost=
"{ row }"
>
...
...
@@ -516,9 +540,50 @@
</
template
>
<
template
#cell-request_id=
"{ row }"
>
<span
class=
"font-mono text-xs text-gray-500 dark:text-gray-400"
>
{{
row
.
request_id
||
'
-
'
}}
</span>
<div
v-if=
"row.request_id"
class=
"flex items-center gap-1.5 max-w-[120px]"
>
<span
class=
"font-mono text-xs text-gray-500 dark:text-gray-400 truncate"
:title=
"row.request_id"
>
{{
row
.
request_id
}}
</span>
<button
@
click=
"copyRequestId(row.request_id)"
class=
"flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:class=
"
copiedRequestId === row.request_id
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
"
:title=
"copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')"
>
<svg
v-if=
"copiedRequestId === row.request_id"
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
<svg
v-else
class=
"h-3.5 w-3.5"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<span
v-else
class=
"text-gray-400 dark:text-gray-500"
>
-
</span>
</
template
>
<
template
#empty
>
...
...
@@ -540,6 +605,63 @@
</div>
</AppLayout>
<ExportProgressDialog
:show=
"exportProgress.show"
:progress=
"exportProgress.progress"
:current=
"exportProgress.current"
:total=
"exportProgress.total"
:estimated-time=
"exportProgress.estimatedTime"
@
cancel=
"cancelExport"
/>
<!-- Token Tooltip Portal -->
<Teleport
to=
"body"
>
<div
v-if=
"tokenTooltipVisible"
class=
"fixed z-[9999] pointer-events-none -translate-y-1/2"
:style=
"{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<!-- Token Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
Token 明细
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.input_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.inputTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.input_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.output_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.output_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.cache_read_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheReadTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}
</span>
</div>
</div>
<!-- Total -->
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.totalTokens') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}
</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
<!-- Tooltip Portal -->
<Teleport
to=
"body"
>
<div
...
...
@@ -602,10 +724,14 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
ref
,
computed
,
reactive
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
*
as
XLSX
from
'
xlsx
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
...
...
@@ -615,6 +741,7 @@ import Select from '@/components/common/Select.vue'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
ExportProgressDialog
from
'
@/components/common/ExportProgressDialog.vue
'
import
type
{
UsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
type
{
...
...
@@ -626,12 +753,21 @@ import type {
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
// Tooltip state
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Token tooltip state
const
tokenTooltipVisible
=
ref
(
false
)
const
tokenTooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tokenTooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Request ID copy state
const
copiedRequestId
=
ref
<
string
|
null
>
(
null
)
// Usage stats from API
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
)
...
...
@@ -657,6 +793,7 @@ const columns = computed<Column[]>(() => [
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
),
sortable
:
false
},
{
key
:
'
billing_type
'
,
label
:
t
(
'
usage.billingType
'
),
sortable
:
false
},
{
key
:
'
first_token
'
,
label
:
t
(
'
usage.firstToken
'
),
sortable
:
false
},
{
key
:
'
duration
'
,
label
:
t
(
'
usage.duration
'
),
sortable
:
false
},
{
key
:
'
created_at
'
,
label
:
t
(
'
usage.time
'
),
sortable
:
true
},
{
key
:
'
request_id
'
,
label
:
t
(
'
admin.usage.requestId
'
),
sortable
:
false
}
...
...
@@ -669,6 +806,15 @@ const accounts = ref<any[]>([])
const
groups
=
ref
<
any
[]
>
([])
const
loading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
let
exportAbortController
:
AbortController
|
null
=
null
const
exporting
=
ref
(
false
)
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
// User search state
const
userSearchKeyword
=
ref
(
''
)
...
...
@@ -868,6 +1014,16 @@ const formatCacheTokens = (value: number): string => {
return
value
.
toLocaleString
()
}
const
copyRequestId
=
async
(
requestId
:
string
)
=>
{
const
success
=
await
clipboardCopy
(
requestId
,
t
(
'
admin.usage.requestIdCopied
'
))
if
(
success
)
{
copiedRequestId
.
value
=
requestId
setTimeout
(()
=>
{
copiedRequestId
.
value
=
null
},
800
)
}
}
const
isAbortError
=
(
error
:
unknown
):
boolean
=>
{
if
(
error
instanceof
DOMException
&&
error
.
name
===
'
AbortError
'
)
{
return
true
...
...
@@ -879,6 +1035,40 @@ const isAbortError = (error: unknown): boolean => {
return
false
}
const
formatExportTimestamp
=
(
date
:
Date
):
string
=>
{
const
pad
=
(
value
:
number
)
=>
String
(
value
).
padStart
(
2
,
'
0
'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
_
${
pad
(
date
.
getHours
())}
-
${
pad
(
date
.
getMinutes
())}
-
${
pad
(
date
.
getSeconds
())}
`
}
const
formatRemainingTime
=
(
ms
:
number
):
string
=>
{
const
totalSeconds
=
Math
.
max
(
0
,
Math
.
round
(
ms
/
1000
))
const
hours
=
Math
.
floor
(
totalSeconds
/
3600
)
const
minutes
=
Math
.
floor
((
totalSeconds
%
3600
)
/
60
)
const
seconds
=
totalSeconds
%
60
const
parts
=
[]
if
(
hours
>
0
)
{
parts
.
push
(
`
${
hours
}
h`
)
}
if
(
minutes
>
0
||
hours
>
0
)
{
parts
.
push
(
`
${
minutes
}
m`
)
}
parts
.
push
(
`
${
seconds
}
s`
)
return
parts
.
join
(
'
'
)
}
const
updateExportProgress
=
(
current
:
number
,
total
:
number
,
startedAt
:
number
)
=>
{
exportProgress
.
current
=
current
exportProgress
.
total
=
total
exportProgress
.
progress
=
total
>
0
?
Math
.
min
(
100
,
Math
.
round
((
current
/
total
)
*
100
))
:
0
if
(
current
>
0
&&
total
>
0
)
{
const
elapsedMs
=
Date
.
now
()
-
startedAt
const
remainingMs
=
Math
.
max
(
0
,
Math
.
round
((
elapsedMs
/
current
)
*
(
total
-
current
)))
exportProgress
.
estimatedTime
=
formatRemainingTime
(
remainingMs
)
}
else
{
exportProgress
.
estimatedTime
=
''
}
}
const
loadUsageLogs
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
...
...
@@ -1051,8 +1241,72 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsageLogs
()
}
const
exportToCSV
=
()
=>
{
if
(
usageLogs
.
value
.
length
===
0
)
{
const
cancelExport
=
()
=>
{
if
(
!
exporting
.
value
)
{
return
}
exportAbortController
?.
abort
()
}
const
exportToExcel
=
async
()
=>
{
if
(
pagination
.
value
.
total
===
0
)
{
appStore
.
showWarning
(
t
(
'
usage.noDataToExport
'
))
return
}
if
(
exporting
.
value
)
{
return
}
exporting
.
value
=
true
exportProgress
.
show
=
true
exportProgress
.
progress
=
0
exportProgress
.
current
=
0
exportProgress
.
total
=
pagination
.
value
.
total
exportProgress
.
estimatedTime
=
''
const
startedAt
=
Date
.
now
()
const
controller
=
new
AbortController
()
exportAbortController
=
controller
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
}
...
...
@@ -1071,7 +1325,7 @@ const exportToCSV = () => {
'
Duration (ms)
'
,
'
Time
'
]
const
rows
=
usageLogs
.
value
.
map
((
log
)
=>
[
const
rows
=
allLogs
.
map
((
log
)
=>
[
log
.
user
?.
email
||
''
,
log
.
api_key
?.
name
||
''
,
log
.
model
,
...
...
@@ -1080,23 +1334,36 @@ const exportToCSV = () => {
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
total_cost
.
toFixed
(
6
),
Number
(
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
'
)
const
blob
=
new
Blob
([
csvContent
],
{
type
:
'
text/csv
'
})
const
url
=
window
.
URL
.
createObjectURL
(
blob
)
const
link
=
document
.
createElement
(
'
a
'
)
link
.
href
=
url
link
.
download
=
`admin_usage_
${
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
]}
.csv`
link
.
click
()
window
.
URL
.
revokeObjectURL
(
url
)
const
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
'
})
appStore
.
showSuccess
(
t
(
'
usage.exportSuccess
'
))
saveAs
(
blob
,
`admin_usage_
${
formatExportTimestamp
(
new
Date
())}
.xlsx`
)
appStore
.
showSuccess
(
t
(
'
usage.exportExcelSuccess
'
))
}
catch
(
error
)
{
if
(
controller
.
signal
.
aborted
||
isAbortError
(
error
))
{
appStore
.
showInfo
(
t
(
'
usage.exportCancelled
'
))
return
}
appStore
.
showError
(
t
(
'
usage.exportExcelFailed
'
))
console
.
error
(
'
Excel export failed:
'
,
error
)
}
finally
{
if
(
exportAbortController
===
controller
)
{
exportAbortController
=
null
}
exporting
.
value
=
false
exportProgress
.
show
=
false
}
}
// Click outside to close dropdown
...
...
@@ -1123,6 +1390,22 @@ const hideTooltip = () => {
tooltipData
.
value
=
null
}
// Token tooltip functions
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tokenTooltipData
.
value
=
row
tokenTooltipPosition
.
value
.
x
=
rect
.
right
+
8
tokenTooltipPosition
.
value
.
y
=
rect
.
top
+
rect
.
height
/
2
tokenTooltipVisible
.
value
=
true
}
const
hideTokenTooltip
=
()
=>
{
tokenTooltipVisible
.
value
=
false
tokenTooltipData
.
value
=
null
}
onMounted
(()
=>
{
loadFilterOptions
()
loadApiKeys
()
...
...
@@ -1140,5 +1423,8 @@ onUnmounted(() => {
if
(
abortController
)
{
abortController
.
abort
()
}
if
(
exportAbortController
)
{
exportAbortController
.
abort
()
}
})
</
script
>
frontend/src/views/user/KeysView.vue
View file @
b63b338e
...
...
@@ -23,7 +23,7 @@
/>
</svg>
</button>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
>
<button
@
click=
"showCreateModal = true"
class=
"btn btn-primary"
data-tour=
"keys-create-btn"
>
<svg
class=
"mr-2 h-5 w-5"
fill=
"none"
...
...
@@ -301,7 +301,7 @@
<BaseDialog
:show=
"showCreateModal || showEditModal"
:title=
"showEditModal ? t('keys.editKey') : t('keys.createKey')"
width=
"n
arrow
"
width=
"n
ormal
"
@
close=
"closeModals"
>
<form
id=
"key-form"
@
submit.prevent=
"handleSubmit"
class=
"space-y-5"
>
...
...
@@ -313,6 +313,7 @@
required
class=
"input"
:placeholder=
"t('keys.namePlaceholder')"
data-tour=
"key-form-name"
/>
</div>
...
...
@@ -322,6 +323,7 @@
v-model=
"formData.group_id"
:options=
"groupOptions"
:placeholder=
"t('keys.selectGroup')"
data-tour=
"key-form-group"
>
<
template
#selected=
"{ option }"
>
<GroupBadge
...
...
@@ -391,7 +393,13 @@
<button
@
click=
"closeModals"
type=
"button"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
form=
"key-form"
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
>
<button
form=
"key-form"
type=
"submit"
:disabled=
"submitting"
class=
"btn btn-primary"
data-tour=
"key-form-submit"
>
<svg
v-if=
"submitting"
class=
"-ml-1 mr-2 h-4 w-4 animate-spin"
...
...
@@ -496,6 +504,7 @@
import
{
ref
,
computed
,
onMounted
,
onUnmounted
,
type
ComponentPublicInstance
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useOnboardingStore
}
from
'
@/stores/onboarding
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
const
{
t
}
=
useI18n
()
...
...
@@ -524,6 +533,7 @@ interface GroupOption {
}
const
appStore
=
useAppStore
()
const
onboardingStore
=
useOnboardingStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
const
columns
=
computed
<
Column
[]
>
(()
=>
[
...
...
@@ -812,12 +822,17 @@ const handleSubmit = async () => {
const
customKey
=
formData
.
value
.
use_custom_key
?
formData
.
value
.
custom_key
:
undefined
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
)
appStore
.
showSuccess
(
t
(
'
keys.keyCreatedSuccess
'
))
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
onboardingStore
.
nextStep
(
500
)
}
}
closeModals
()
loadApiKeys
()
}
catch
(
error
:
any
)
{
const
errorMsg
=
error
.
response
?.
data
?.
detail
||
t
(
'
keys.failedToSave
'
)
appStore
.
showError
(
errorMsg
)
// Don't advance tour on error
}
finally
{
submitting
.
value
=
false
}
...
...
@@ -885,7 +900,20 @@ const importToCcswitch = (apiKey: string) => {
usageAutoInterval
:
'
30
'
})
const
deeplink
=
`ccswitch://v1/import?
${
params
.
toString
()}
`
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
(()
=>
{
...
...
frontend/src/views/user/UsageView.vue
View file @
b63b338e
...
...
@@ -219,6 +219,7 @@
</
template
>
<
template
#cell-tokens=
"{ row }"
>
<div
class=
"flex items-center gap-1.5"
>
<div
class=
"space-y-1.5 text-sm"
>
<!-- Input / Output Tokens -->
<div
class=
"flex items-center gap-2"
>
...
...
@@ -306,6 +307,29 @@
</div>
</div>
</div>
<!-- Token Detail Tooltip -->
<div
class=
"group relative"
@
mouseenter=
"showTokenTooltip($event, row)"
@
mouseleave=
"hideTokenTooltip"
>
<div
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
<svg
class=
"h-3 w-3 text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400"
fill=
"currentColor"
viewBox=
"0 0 20 20"
>
<path
fill-rule=
"evenodd"
d=
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule=
"evenodd"
/>
</svg>
</div>
</div>
</div>
</
template
>
<
template
#cell-cost=
"{ row }"
>
...
...
@@ -392,6 +416,54 @@
</TablePageLayout>
</AppLayout>
<!-- Token Tooltip Portal -->
<Teleport
to=
"body"
>
<div
v-if=
"tokenTooltipVisible"
class=
"fixed z-[9999] pointer-events-none -translate-y-1/2"
:style=
"{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<!-- Token Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
Token 明细
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.input_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.inputTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.input_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.output_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.output_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}
</span>
</div>
<div
v-if=
"tokenTooltipData && tokenTooltipData.cache_read_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheReadTokens') }}
</span>
<span
class=
"font-medium text-white"
>
{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}
</span>
</div>
</div>
<!-- Total -->
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.totalTokens') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}
</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
<!-- Tooltip Portal -->
<Teleport
to=
"body"
>
<div
...
...
@@ -458,6 +530,11 @@ const tooltipVisible = ref(false)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Token tooltip state
const
tokenTooltipVisible
=
ref
(
false
)
const
tokenTooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tokenTooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Usage stats from API
const
usageStats
=
ref
<
UsageStatsResponse
|
null
>
(
null
)
...
...
@@ -778,6 +855,22 @@ const hideTooltip = () => {
tooltipData
.
value
=
null
}
// Token tooltip functions
const
showTokenTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tokenTooltipData
.
value
=
row
tokenTooltipPosition
.
value
.
x
=
rect
.
right
+
8
tokenTooltipPosition
.
value
.
y
=
rect
.
top
+
rect
.
height
/
2
tokenTooltipVisible
.
value
=
true
}
const
hideTokenTooltip
=
()
=>
{
tokenTooltipVisible
.
value
=
false
tokenTooltipData
.
value
=
null
}
onMounted
(()
=>
{
loadApiKeys
()
loadUsageLogs
()
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment