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
a413fa3b
Unverified
Commit
a413fa3b
authored
Dec 27, 2025
by
程序猿MT
Committed by
GitHub
Dec 27, 2025
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
3a8dbf5a
254f1254
Changes
45
Show whitespace changes
Inline
Side-by-side
frontend/src/components/layout/TablePageLayout.vue
0 → 100644
View file @
a413fa3b
<
template
>
<div
class=
"table-page-layout"
:class=
"
{ 'mobile-mode': isMobile }">
<!-- 固定区域:操作按钮 -->
<div
v-if=
"$slots.actions"
class=
"layout-section-fixed"
>
<slot
name=
"actions"
/>
</div>
<!-- 固定区域:搜索和过滤器 -->
<div
v-if=
"$slots.filters"
class=
"layout-section-fixed"
>
<slot
name=
"filters"
/>
</div>
<!-- 滚动区域:表格 -->
<div
class=
"layout-section-scrollable"
>
<div
class=
"card table-scroll-container"
>
<slot
name=
"table"
/>
</div>
</div>
<!-- 固定区域:分页器 -->
<div
v-if=
"$slots.pagination"
class=
"layout-section-fixed"
>
<slot
name=
"pagination"
/>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
,
onUnmounted
}
from
'
vue
'
const
isMobile
=
ref
(
false
)
const
checkMobile
=
()
=>
{
isMobile
.
value
=
window
.
innerWidth
<
1024
}
onMounted
(()
=>
{
checkMobile
()
window
.
addEventListener
(
'
resize
'
,
checkMobile
)
})
onUnmounted
(()
=>
{
window
.
removeEventListener
(
'
resize
'
,
checkMobile
)
})
</
script
>
<
style
scoped
>
/* 桌面端:Flexbox 布局 */
.table-page-layout
{
@apply
flex
flex-col
gap-6;
height
:
calc
(
100vh
-
64px
-
4rem
);
/* 减去 header + lg:p-8 的上下padding */
}
.layout-section-fixed
{
@apply
flex-shrink-0;
}
.layout-section-scrollable
{
@apply
flex-1
min-h-0
flex
flex-col;
}
/* 表格滚动容器 - 增强版表体滚动方案 */
.table-scroll-container
{
@apply
flex
flex-col
overflow-hidden
h-full
bg-white
dark
:
bg-dark-800
rounded-2xl
border
border-gray-200
dark
:
border-dark-700
shadow-sm
;
}
.table-scroll-container
:deep
(
.table-wrapper
)
{
@apply
flex-1
overflow-x-auto
overflow-y-auto;
/* 确保横向滚动条显示在最底部 */
scrollbar-gutter
:
stable
;
}
.table-scroll-container
:deep
(
table
)
{
@apply
w-full;
min-width
:
max-content
;
/* 关键:确保表格宽度根据内容撑开,从而触发横向滚动 */
display
:
table
;
/* 使用标准 table 布局以支持 sticky 列 */
}
.table-scroll-container
:deep
(
thead
)
{
@apply
bg-gray-50/80
dark
:
bg-dark-800
/
80
backdrop-blur-sm
;
}
.table-scroll-container
:deep
(
tbody
)
{
/* 保持默认 table-row-group 显示,不使用 block */
}
.table-scroll-container
:deep
(
th
)
{
/* 表头高度和文字加粗优化 */
@apply
px-5
py-4
text-left
text-sm
font-bold
text-gray-900
dark
:
text-white
border-b
border-gray-200
dark
:
border-dark-700
;
@apply
uppercase
tracking-wider;
/* 让表头更有设计感 */
}
.table-scroll-container
:deep
(
td
)
{
@apply
px-5
py-4
text-sm
text-gray-700
dark
:
text-gray-300
border-b
border-gray-100
dark
:
border-dark-800
;
}
/* 移动端:恢复正常滚动 */
.table-page-layout.mobile-mode
.table-scroll-container
{
@apply
h-auto
overflow-visible
border-none
shadow-none
bg-transparent;
}
.table-page-layout.mobile-mode
.layout-section-scrollable
{
@apply
flex-none
min-h-fit;
}
.table-page-layout.mobile-mode
.table-scroll-container
:deep
(
.table-wrapper
)
{
@apply
overflow-visible;
}
.table-page-layout.mobile-mode
.table-scroll-container
:deep
(
table
)
{
@apply
flex-none;
display
:
table
;
min-width
:
100%
;
}
</
style
>
frontend/src/i18n/locales/en.ts
View file @
a413fa3b
...
...
@@ -30,13 +30,56 @@ export default {
title
:
'
Supported Providers
'
,
description
:
'
Unified API interface for AI services
'
,
supported
:
'
Supported
'
,
soon
:
'
Soon
'
soon
:
'
Soon
'
,
claude
:
'
Claude
'
,
gemini
:
'
Gemini
'
,
more
:
'
More
'
},
footer
:
{
allRightsReserved
:
'
All rights reserved.
'
}
},
// Setup Wizard
setup
:
{
title
:
'
Sub2API Setup
'
,
description
:
'
Configure your Sub2API instance
'
,
database
:
{
title
:
'
Database Configuration
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
username
:
'
Username
'
,
password
:
'
Password
'
,
databaseName
:
'
Database Name
'
,
sslMode
:
'
SSL Mode
'
,
ssl
:
{
disable
:
'
Disable
'
,
require
:
'
Require
'
,
verifyCa
:
'
Verify CA
'
,
verifyFull
:
'
Verify Full
'
}
},
redis
:
{
title
:
'
Redis Configuration
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
password
:
'
Password (optional)
'
,
database
:
'
Database
'
},
admin
:
{
title
:
'
Admin Account
'
,
email
:
'
Email
'
,
password
:
'
Password
'
,
confirmPassword
:
'
Confirm Password
'
},
ready
:
{
title
:
'
Ready to Install
'
,
database
:
'
Database
'
,
redis
:
'
Redis
'
,
adminEmail
:
'
Admin Email
'
}
},
// Common
common
:
{
loading
:
'
Loading...
'
,
...
...
@@ -142,7 +185,20 @@ export default {
accountCreatedSuccess
:
'
Account created successfully! Welcome to {siteName}.
'
,
turnstileExpired
:
'
Verification expired, please try again
'
,
turnstileFailed
:
'
Verification failed, please try again
'
,
completeVerification
:
'
Please complete the verification
'
completeVerification
:
'
Please complete the verification
'
,
verifyYourEmail
:
'
Verify Your Email
'
,
sessionExpired
:
'
Session expired
'
,
sessionExpiredDesc
:
'
Please go back to the registration page and start again.
'
,
verificationCode
:
'
Verification Code
'
,
verificationCodeHint
:
'
Enter the 6-digit code sent to your email
'
,
sendingCode
:
'
Sending...
'
,
clickToResend
:
'
Click to resend code
'
,
resendCode
:
'
Resend verification code
'
,
oauth
:
{
code
:
'
Code
'
,
state
:
'
State
'
,
fullUrl
:
'
Full URL
'
}
},
// Dashboard
...
...
@@ -377,6 +433,12 @@ export default {
noData
:
'
No data found
'
},
// Table
table
:
{
expandActions
:
'
Expand More Actions
'
,
collapseActions
:
'
Collapse Actions
'
},
// Pagination
pagination
:
{
showing
:
'
Showing
'
,
...
...
@@ -584,6 +646,7 @@ export default {
actions
:
'
Actions
'
,
billingType
:
'
Billing Type
'
},
rateAndAccounts
:
'
{rate}x rate · {count} accounts
'
,
accountsCount
:
'
{count} accounts
'
,
form
:
{
name
:
'
Name
'
,
...
...
@@ -742,6 +805,13 @@ export default {
openai
:
'
OpenAI
'
,
gemini
:
'
Gemini
'
},
types
:
{
oauth
:
'
OAuth
'
,
chatgptOauth
:
'
ChatGPT OAuth
'
,
responsesApi
:
'
Responses API
'
,
googleOauth
:
'
Google OAuth
'
,
codeAssist
:
'
Code Assist
'
},
columns
:
{
name
:
'
Name
'
,
platformType
:
'
Platform/Type
'
,
...
...
@@ -1022,6 +1092,7 @@ export default {
todayOverview
:
'
Today Overview
'
,
cost
:
'
Cost
'
,
requests
:
'
Requests
'
,
tokens
:
'
Tokens
'
,
highestCostDay
:
'
Highest Cost Day
'
,
highestRequestDay
:
'
Highest Request Day
'
,
date
:
'
Date
'
,
...
...
@@ -1037,6 +1108,9 @@ export default {
todayCost
:
'
Today Cost
'
,
usageTrend
:
'
30-Day Cost & Request Trend
'
,
noData
:
'
No usage data available for this account
'
},
usageWindow
:
{
statsTitle
:
'
5-Hour Window Usage Statistics
'
}
},
...
...
@@ -1070,6 +1144,10 @@ export default {
enterProxyName
:
'
Enter proxy name
'
,
leaveEmptyToKeep
:
'
Leave empty to keep current
'
,
optionalAuth
:
'
Optional authentication
'
,
form
:
{
hostPlaceholder
:
'
proxy.example.com
'
,
portPlaceholder
:
'
8080
'
},
noProxiesYet
:
'
No proxies yet
'
,
createFirstProxy
:
'
Create your first proxy to route traffic through it.
'
,
// Batch import
...
...
@@ -1174,6 +1252,18 @@ export default {
searchUserPlaceholder
:
'
Search user by email...
'
,
selectedUser
:
'
Selected
'
,
user
:
'
User
'
,
account
:
'
Account
'
,
group
:
'
Group
'
,
requestId
:
'
Request ID
'
,
allModels
:
'
All Models
'
,
allAccounts
:
'
All Accounts
'
,
allGroups
:
'
All Groups
'
,
allTypes
:
'
All Types
'
,
allBillingTypes
:
'
All Billing
'
,
inputCost
:
'
Input Cost
'
,
outputCost
:
'
Output Cost
'
,
cacheCreationCost
:
'
Cache Creation Cost
'
,
cacheReadCost
:
'
Cache Read Cost
'
,
failedToLoad
:
'
Failed to load usage records
'
},
...
...
@@ -1211,16 +1301,20 @@ export default {
title
:
'
Site Settings
'
,
description
:
'
Customize site branding
'
,
siteName
:
'
Site Name
'
,
siteNamePlaceholder
:
'
Sub2API
'
,
siteNameHint
:
'
Displayed in emails and page titles
'
,
siteSubtitle
:
'
Site Subtitle
'
,
siteSubtitlePlaceholder
:
'
Subscription to API Conversion Platform
'
,
siteSubtitleHint
:
'
Displayed on login and register pages
'
,
apiBaseUrl
:
'
API Base URL
'
,
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
apiBaseUrlHint
:
'
Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.
'
,
contactInfo
:
'
Contact Info
'
,
contactInfoPlaceholder
:
'
e.g., QQ: 123456789
'
,
contactInfoHint
:
'
Customer support contact info, displayed on redeem page, profile, etc.
'
,
docUrl
:
'
Documentation URL
'
,
docUrlPlaceholder
:
'
https://docs.example.com
'
,
docUrlHint
:
'
Link to your documentation site. Leave empty to hide the documentation link.
'
,
siteLogo
:
'
Site Logo
'
,
uploadImage
:
'
Upload Image
'
,
...
...
@@ -1236,12 +1330,18 @@ export default {
testConnection
:
'
Test Connection
'
,
testing
:
'
Testing...
'
,
host
:
'
SMTP Host
'
,
hostPlaceholder
:
'
smtp.gmail.com
'
,
port
:
'
SMTP Port
'
,
portPlaceholder
:
'
587
'
,
username
:
'
SMTP Username
'
,
usernamePlaceholder
:
'
your-email@gmail.com
'
,
password
:
'
SMTP Password
'
,
passwordPlaceholder
:
'
********
'
,
passwordHint
:
'
Leave empty to keep existing password
'
,
fromEmail
:
'
From Email
'
,
fromEmailPlaceholder
:
'
noreply@example.com
'
,
fromName
:
'
From Name
'
,
fromNamePlaceholder
:
'
Sub2API
'
,
useTls
:
'
Use TLS
'
,
useTlsHint
:
'
Enable TLS encryption for SMTP connection
'
},
...
...
@@ -1249,6 +1349,7 @@ export default {
title
:
'
Send Test Email
'
,
description
:
'
Send a test email to verify your SMTP configuration
'
,
recipientEmail
:
'
Recipient Email
'
,
recipientEmailPlaceholder
:
'
test@example.com
'
,
sendTestEmail
:
'
Send Test Email
'
,
sending
:
'
Sending...
'
,
enterRecipientHint
:
'
Please enter a recipient email address
'
...
...
frontend/src/i18n/locales/zh.ts
View file @
a413fa3b
...
...
@@ -27,13 +27,56 @@ export default {
title
:
'
支持的服务商
'
,
description
:
'
AI 服务的统一 API 接口
'
,
supported
:
'
已支持
'
,
soon
:
'
即将推出
'
soon
:
'
即将推出
'
,
claude
:
'
Claude
'
,
gemini
:
'
Gemini
'
,
more
:
'
更多
'
},
footer
:
{
allRightsReserved
:
'
保留所有权利。
'
}
},
// Setup Wizard
setup
:
{
title
:
'
Sub2API 安装向导
'
,
description
:
'
配置您的 Sub2API 实例
'
,
database
:
{
title
:
'
数据库配置
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
username
:
'
用户名
'
,
password
:
'
密码
'
,
databaseName
:
'
数据库名称
'
,
sslMode
:
'
SSL 模式
'
,
ssl
:
{
disable
:
'
禁用
'
,
require
:
'
要求
'
,
verifyCa
:
'
验证 CA
'
,
verifyFull
:
'
完全验证
'
}
},
redis
:
{
title
:
'
Redis 配置
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
password
:
'
密码(可选)
'
,
database
:
'
数据库
'
},
admin
:
{
title
:
'
管理员账户
'
,
email
:
'
邮箱
'
,
password
:
'
密码
'
,
confirmPassword
:
'
确认密码
'
},
ready
:
{
title
:
'
准备安装
'
,
database
:
'
数据库
'
,
redis
:
'
Redis
'
,
adminEmail
:
'
管理员邮箱
'
}
},
// Common
common
:
{
loading
:
'
加载中...
'
,
...
...
@@ -139,7 +182,20 @@ export default {
accountCreatedSuccess
:
'
账户创建成功!欢迎使用 {siteName}。
'
,
turnstileExpired
:
'
验证已过期,请重试
'
,
turnstileFailed
:
'
验证失败,请重试
'
,
completeVerification
:
'
请完成验证
'
completeVerification
:
'
请完成验证
'
,
verifyYourEmail
:
'
验证您的邮箱
'
,
sessionExpired
:
'
会话已过期
'
,
sessionExpiredDesc
:
'
请返回注册页面重新开始。
'
,
verificationCode
:
'
验证码
'
,
verificationCodeHint
:
'
请输入发送到您邮箱的6位验证码
'
,
sendingCode
:
'
发送中...
'
,
clickToResend
:
'
点击重新发送验证码
'
,
resendCode
:
'
重新发送验证码
'
,
oauth
:
{
code
:
'
授权码
'
,
state
:
'
状态
'
,
fullUrl
:
'
完整URL
'
}
},
// Dashboard
...
...
@@ -373,6 +429,12 @@ export default {
noData
:
'
暂无数据
'
},
// Table
table
:
{
expandActions
:
'
展开更多操作
'
,
collapseActions
:
'
收起操作
'
},
// Pagination
pagination
:
{
showing
:
'
显示
'
,
...
...
@@ -689,6 +751,7 @@ export default {
exclusiveFilter
:
'
独占
'
,
nonExclusive
:
'
非独占
'
,
public
:
'
公开
'
,
rateAndAccounts
:
'
{rate}x 费率 · {count} 个账号
'
,
accountsCount
:
'
{count} 个账号
'
,
enterGroupName
:
'
请输入分组名称
'
,
optionalDescription
:
'
可选描述
'
,
...
...
@@ -848,6 +911,10 @@ export default {
},
types
:
{
oauth
:
'
OAuth
'
,
chatgptOauth
:
'
ChatGPT OAuth
'
,
responsesApi
:
'
Responses API
'
,
googleOauth
:
'
Google OAuth
'
,
codeAssist
:
'
Code Assist
'
,
api_key
:
'
API Key
'
,
cookie
:
'
Cookie
'
},
...
...
@@ -857,6 +924,9 @@ export default {
error
:
'
错误
'
,
cooldown
:
'
冷却中
'
},
usageWindow
:
{
statsTitle
:
'
5小时窗口用量统计
'
},
form
:
{
nameLabel
:
'
账号名称
'
,
namePlaceholder
:
'
请输入账号名称
'
,
...
...
@@ -1125,6 +1195,7 @@ export default {
todayOverview
:
'
今日概览
'
,
cost
:
'
费用
'
,
requests
:
'
请求
'
,
tokens
:
'
Token
'
,
highestCostDay
:
'
最高费用日
'
,
highestRequestDay
:
'
最高请求日
'
,
date
:
'
日期
'
,
...
...
@@ -1364,6 +1435,18 @@ export default {
searchUserPlaceholder
:
'
按邮箱搜索用户...
'
,
selectedUser
:
'
已选择
'
,
user
:
'
用户
'
,
account
:
'
账户
'
,
group
:
'
分组
'
,
requestId
:
'
请求ID
'
,
allModels
:
'
全部模型
'
,
allAccounts
:
'
全部账户
'
,
allGroups
:
'
全部分组
'
,
allTypes
:
'
全部类型
'
,
allBillingTypes
:
'
全部计费
'
,
inputCost
:
'
输入成本
'
,
outputCost
:
'
输出成本
'
,
cacheCreationCost
:
'
缓存创建成本
'
,
cacheReadCost
:
'
缓存读取成本
'
,
failedToLoad
:
'
加载使用记录失败
'
},
...
...
@@ -1402,15 +1485,19 @@ export default {
description
:
'
自定义站点品牌
'
,
siteName
:
'
站点名称
'
,
siteNameHint
:
'
显示在邮件和页面标题中
'
,
siteNamePlaceholder
:
'
Sub2API
'
,
siteSubtitle
:
'
站点副标题
'
,
siteSubtitleHint
:
'
显示在登录和注册页面
'
,
siteSubtitlePlaceholder
:
'
订阅转 API 转换平台
'
,
apiBaseUrl
:
'
API 端点地址
'
,
apiBaseUrlHint
:
'
用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址
'
,
apiBaseUrlPlaceholder
:
'
https://api.example.com
'
,
contactInfo
:
'
客服联系方式
'
,
contactInfoPlaceholder
:
'
例如:QQ: 123456789
'
,
contactInfoHint
:
'
填写客服联系方式,将展示在兑换页面、个人资料等位置
'
,
docUrl
:
'
文档链接
'
,
docUrlHint
:
'
文档网站的链接。留空则隐藏文档链接。
'
,
docUrlPlaceholder
:
'
https://docs.example.com
'
,
siteLogo
:
'
站点Logo
'
,
uploadImage
:
'
上传图片
'
,
remove
:
'
移除
'
,
...
...
@@ -1425,12 +1512,18 @@ export default {
testConnection
:
'
测试连接
'
,
testing
:
'
测试中...
'
,
host
:
'
SMTP 主机
'
,
hostPlaceholder
:
'
smtp.gmail.com
'
,
port
:
'
SMTP 端口
'
,
portPlaceholder
:
'
587
'
,
username
:
'
SMTP 用户名
'
,
usernamePlaceholder
:
'
your-email@gmail.com
'
,
password
:
'
SMTP 密码
'
,
passwordPlaceholder
:
'
********
'
,
passwordHint
:
'
留空以保留现有密码
'
,
fromEmail
:
'
发件人邮箱
'
,
fromEmailPlaceholder
:
'
noreply@example.com
'
,
fromName
:
'
发件人名称
'
,
fromNamePlaceholder
:
'
Sub2API
'
,
useTls
:
'
使用 TLS
'
,
useTlsHint
:
'
为 SMTP 连接启用 TLS 加密
'
},
...
...
@@ -1438,6 +1531,7 @@ export default {
title
:
'
发送测试邮件
'
,
description
:
'
发送测试邮件以验证 SMTP 配置
'
,
recipientEmail
:
'
收件人邮箱
'
,
recipientEmailPlaceholder
:
'
test@example.com
'
,
sendTestEmail
:
'
发送测试邮件
'
,
sending
:
'
发送中...
'
,
enterRecipientHint
:
'
请输入收件人邮箱地址
'
...
...
frontend/src/style.css
View file @
a413fa3b
...
...
@@ -488,6 +488,17 @@
@apply
bg-gray-900
text-gray-100;
@apply
overflow-x-auto
rounded-xl
p-4;
}
/* ============ 表格页面布局优化 ============ */
/* 表格容器 - 默认仅支持水平滚动 */
.table-wrapper
{
overflow-x
:
auto
;
}
/* 表头固定时添加底部阴影,增强视觉层次 */
.table-wrapper
thead
.sticky
{
box-shadow
:
0
1px
3px
0
rgb
(
0
0
0
/
0.1
);
}
}
@layer
utilities
{
...
...
frontend/src/types/index.ts
View file @
a413fa3b
...
...
@@ -442,22 +442,38 @@ export interface UsageLog {
user_id
:
number
api_key_id
:
number
account_id
:
number
|
null
request_id
:
string
model
:
string
group_id
:
number
|
null
subscription_id
:
number
|
null
input_tokens
:
number
output_tokens
:
number
cache_creation_tokens
:
number
cache_read_tokens
:
number
cache_creation_5m_tokens
:
number
cache_creation_1h_tokens
:
number
input_cost
:
number
output_cost
:
number
cache_creation_cost
:
number
cache_read_cost
:
number
total_cost
:
number
actual_cost
:
number
rate_multiplier
:
number
billing_type
:
BillingType
stream
:
boolean
duration_ms
:
number
first_token_ms
:
number
|
null
created_at
:
string
user
?:
User
api_key
?:
ApiKey
account
?:
Account
group
?:
Group
subscription
?:
UserSubscription
}
export
interface
RedeemCode
{
...
...
@@ -677,6 +693,11 @@ export interface UsageQueryParams {
page_size
?:
number
api_key_id
?:
number
user_id
?:
number
account_id
?:
number
group_id
?:
number
model
?:
string
stream
?:
boolean
billing_type
?:
number
start_date
?:
string
end_date
?:
string
}
...
...
frontend/src/utils/format.ts
View file @
a413fa3b
...
...
@@ -114,3 +114,30 @@ export function formatDate(
.
replace
(
'
mm
'
,
minutes
)
.
replace
(
'
ss
'
,
seconds
)
}
/**
* 格式化日期(只显示日期部分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期字符串,格式为 YYYY-MM-DD
*/
export
function
formatDateOnly
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD
'
)
}
/**
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期时间字符串,格式为 YYYY-MM-DD HH:mm:ss
*/
export
function
formatDateTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD HH:mm:ss
'
)
}
/**
* 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的时间字符串,格式为 HH:mm
*/
export
function
formatTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
HH:mm
'
)
}
frontend/src/views/HomeView.vue
View file @
a413fa3b
...
...
@@ -385,7 +385,7 @@
>
<span
class=
"text-xs font-bold text-white"
>
C
</span>
</div>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-200"
>
Claude
</span>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-200"
>
{{
t
(
'
home.providers.claude
'
)
}}
</span>
<span
class=
"rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{
t
(
'
home.providers.supported
'
)
}}
</span
...
...
@@ -415,7 +415,7 @@
>
<span
class=
"text-xs font-bold text-white"
>
G
</span>
</div>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-200"
>
Gemini
</span>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-200"
>
{{
t
(
'
home.providers.gemini
'
)
}}
</span>
<span
class=
"rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{
t
(
'
home.providers.supported
'
)
}}
</span
...
...
@@ -430,7 +430,7 @@
>
<span
class=
"text-xs font-bold text-white"
>
+
</span>
</div>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-200"
>
More
</span>
<span
class=
"text-sm font-medium text-gray-700 dark:text-dark-200"
>
{{
t
(
'
home.providers.more
'
)
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
>
{{
t
(
'
home.providers.soon
'
)
}}
</span
...
...
frontend/src/views/NotFoundView.vue
View file @
a413fa3b
...
...
@@ -43,7 +43,9 @@
<!-- Text Content -->
<div
class=
"mb-8"
>
<h1
class=
"mb-3 text-2xl font-bold text-gray-900 dark:text-white"
>
Page Not Found
</h1>
<h1
class=
"mb-3 text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
errors.pageNotFound
'
)
}}
</h1>
<p
class=
"text-gray-500 dark:text-dark-400"
>
The page you are looking for doesn't exist or has been moved.
</p>
...
...
@@ -100,8 +102,10 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRouter
}
from
'
vue-router
'
const
{
t
}
=
useI18n
()
const
router
=
useRouter
()
function
goBack
():
void
{
...
...
frontend/src/views/admin/AccountsView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<
div
class=
"space-y-6"
>
<
!-- Page Header A
ctions
--
>
<
TablePageLayout
>
<
template
#a
ctions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadAccounts"
...
...
@@ -23,7 +23,7 @@
/>
</svg>
</button>
<button
@
click=
"showCrsSyncModal = true"
class=
"btn btn-secondary"
title=
"
从 CRS 同步
"
>
<button
@
click=
"showCrsSyncModal = true"
class=
"btn btn-secondary"
:
title=
"
t('admin.accounts.syncFromCrs')
"
>
<svg
class=
"h-5 w-5"
fill=
"none"
...
...
@@ -51,8 +51,9 @@
{{
t
(
'
admin.accounts.createAccount
'
)
}}
</button>
</div>
</
template
>
<
!-- Search and F
ilters
--
>
<
template
#f
ilters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<svg
...
...
@@ -100,7 +101,9 @@
/>
</div>
</div>
</
template
>
<
template
#table
>
<!-- Bulk Actions Bar -->
<div
v-if=
"selectedAccountIds.length > 0"
...
...
@@ -162,8 +165,6 @@
<
/div
>
<
/div
>
<!--
Accounts
Table
-->
<
div
class
=
"
card overflow-hidden
"
>
<
DataTable
:
columns
=
"
columns
"
:
data
=
"
accounts
"
:
loading
=
"
loading
"
>
<
template
#
cell
-
select
=
"
{ row
}
"
>
<
input
...
...
@@ -274,8 +275,50 @@
<
/span
>
<
/template
>
<
template
#
cell
-
actions
=
"
{ row
}
"
>
<
template
#
cell
-
actions
=
"
{ row
, expanded
}
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<!--
主要操作
:
编辑和删除
(
始终显示
)
-->
<
button
@
click
=
"
handleEdit(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
:
title
=
"
t('common.edit')
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
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
>
<
/button
>
<
button
@
click
=
"
handleDelete(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('common.delete')
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
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
>
<
/button
>
<!--
次要操作
:
展开时显示
-->
<
template
v
-
if
=
"
expanded
"
>
<!--
Reset
Status
button
for
error
accounts
-->
<
button
v
-
if
=
"
row.status === 'error'
"
...
...
@@ -398,44 +441,7 @@
/>
<
/svg
>
<
/button
>
<
button
@
click
=
"
handleEdit(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
:
title
=
"
t('common.edit')
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
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
>
<
/button
>
<
button
@
click
=
"
handleDelete(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('common.delete')
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
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
>
<
/button
>
<
/template
>
<
/div
>
<
/template
>
...
...
@@ -448,9 +454,9 @@
/>
<
/template
>
<
/DataTable
>
<
/
div
>
<
/
template
>
<
!--
P
agination
--
>
<
template
#
p
agination
>
<
Pagination
v
-
if
=
"
pagination.total > 0
"
:
page
=
"
pagination.page
"
...
...
@@ -458,7 +464,8 @@
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
/>
<
/div
>
<
/template
>
<
/TablePageLayout
>
<!--
Create
Account
Modal
-->
<
CreateAccountModal
...
...
@@ -541,6 +548,7 @@ import { adminAPI } from '@/api/admin'
import
type
{
Account
,
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
...
...
frontend/src/views/admin/GroupsView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<
div
class=
"space-y-6"
>
<
!-- Page Header A
ctions
--
>
<
TablePageLayout
>
<
template
#a
ctions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadGroups"
...
...
@@ -36,34 +36,35 @@
{{
t
(
'
admin.groups.createGroup
'
)
}}
</button>
</div>
</
template
>
<
!-- F
ilters
--
>
<
template
#f
ilters
>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.platform"
:options=
"platformFilterOptions"
placeholder=
"
A
ll
Platforms"
:
placeholder=
"
t('admin.groups.a
llPlatforms
')
"
class=
"w-44"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.status"
:options=
"statusOptions"
placeholder=
"
A
ll
Status"
:
placeholder=
"
t('admin.groups.a
llStatus
')
"
class=
"w-40"
@
change=
"loadGroups"
/>
<Select
v-model=
"filters.is_exclusive"
:options=
"exclusiveOptions"
placeholder=
"
A
ll
Groups"
:
placeholder=
"
t('admin.groups.a
llGroups
')
"
class=
"w-44"
@
change=
"loadGroups"
/>
</div>
</
template
>
<!-- Groups Table -->
<div
class=
"card overflow-hidden"
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"groups"
:loading=
"loading"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
...
...
@@ -213,9 +214,9 @@
/>
<
/template
>
<
/DataTable
>
<
/
div
>
<
/
template
>
<
!--
P
agination
--
>
<
template
#
p
agination
>
<
Pagination
v
-
if
=
"
pagination.total > 0
"
:
page
=
"
pagination.page
"
...
...
@@ -223,7 +224,8 @@
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
/>
<
/div
>
<
/template
>
<
/TablePageLayout
>
<!--
Create
Group
Modal
-->
<
Modal
...
...
@@ -541,6 +543,7 @@ import { adminAPI } from '@/api/admin'
import
type
{
Group
,
GroupPlatform
,
SubscriptionType
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/Modal.vue
'
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<
div
class=
"space-y-6"
>
<
!-- Page Header A
ctions
--
>
<
TablePageLayout
>
<
template
#a
ctions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadProxies"
...
...
@@ -36,8 +36,9 @@
{{
t
(
'
admin.proxies.createProxy
'
)
}}
</button>
</div>
</
template
>
<
!-- Search and F
ilters
--
>
<
template
#f
ilters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<svg
...
...
@@ -78,9 +79,9 @@
/>
</div>
</div>
</
template
>
<!-- Proxies Table -->
<div
class=
"card overflow-hidden"
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"proxies"
:loading=
"loading"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
...
...
@@ -199,9 +200,9 @@
/>
</
template
>
</DataTable>
</
div
>
</
template
>
<
!-- P
agination
--
>
<
template
#p
agination
>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
...
...
@@ -209,7 +210,8 @@
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
/>
</div>
</
template
>
</TablePageLayout>
<!-- Create Proxy Modal -->
<Modal
...
...
@@ -291,7 +293,7 @@
v-model=
"createForm.host"
type=
"text"
required
placeholder=
"
proxy.example.com
"
:
placeholder=
"
t('admin.proxies.form.hostPlaceholder')
"
class=
"input"
/>
</div>
...
...
@@ -303,7 +305,7 @@
required
min=
"1"
max=
"65535"
placeholder=
"
8080
"
:
placeholder=
"
t('admin.proxies.form.portPlaceholder')
"
class=
"input"
/>
</div>
...
...
@@ -577,6 +579,7 @@ import { adminAPI } from '@/api/admin'
import
type
{
Proxy
,
ProxyProtocol
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/Modal.vue
'
...
...
frontend/src/views/admin/RedeemView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<
div
class=
"space-y-6"
>
<
!-- Page Header A
ctions
--
>
<
TablePageLayout
>
<
template
#a
ctions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadCodes"
...
...
@@ -27,8 +27,9 @@
{{
t
(
'
admin.redeem.generateCodes
'
)
}}
</button>
</div>
</
template
>
<
!-- Filters and Actions --
>
<
template
#filters
>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div
class=
"max-w-md flex-1"
>
<input
...
...
@@ -57,9 +58,9 @@
</button>
</div>
</div>
</
template
>
<!-- Redeem Codes Table -->
<div
class=
"card overflow-hidden"
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
>
<template
#cell-code
="
{ value }">
<div
class=
"flex items-center space-x-2"
>
...
...
@@ -151,7 +152,7 @@
<
template
#
cell
-
used_at
=
"
{ value
}
"
>
<
span
class
=
"
text-sm text-gray-500 dark:text-dark-400
"
>
{{
value
?
formatDate
(
value
)
:
'
-
'
value
?
formatDate
Time
(
value
)
:
'
-
'
}}
<
/span
>
<
/template
>
...
...
@@ -176,9 +177,9 @@
<
/div
>
<
/template
>
<
/DataTable
>
<
/
div
>
<
/
template
>
<
!--
P
agination
--
>
<
template
#
p
agination
>
<
Pagination
v
-
if
=
"
pagination.total > 0
"
:
page
=
"
pagination.page
"
...
...
@@ -193,7 +194,8 @@
{{
t
(
'
admin.redeem.deleteAllUnused
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
/TablePageLayout
>
<!--
Delete
Confirmation
Dialog
-->
<
ConfirmDialog
...
...
@@ -417,9 +419,11 @@ import { ref, reactive, computed, onMounted } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
RedeemCode
,
RedeemCodeType
,
Group
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
...
...
@@ -549,10 +553,6 @@ const generateForm = reactive({
validity_days
:
30
}
)
const
formatDate
=
(
dateString
:
string
):
string
=>
{
return
new
Date
(
dateString
).
toLocaleDateString
()
}
const
loadCodes
=
async
()
=>
{
loading
.
value
=
true
try
{
...
...
frontend/src/views/admin/SettingsView.vue
View file @
a413fa3b
...
...
@@ -326,7 +326,12 @@
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.settings.site.siteName
'
)
}}
</label>
<input
v-model=
"form.site_name"
type=
"text"
class=
"input"
placeholder=
"Sub2API"
/>
<input
v-model=
"form.site_name"
type=
"text"
class=
"input"
:placeholder=
"t('admin.settings.site.siteNamePlaceholder')"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.site.siteNameHint
'
)
}}
</p>
...
...
@@ -339,7 +344,7 @@
v-model=
"form.site_subtitle"
type=
"text"
class=
"input"
placeholder=
"
Subscription to API Conversion Platform
"
:
placeholder=
"
t('admin.settings.site.siteSubtitlePlaceholder')
"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.site.siteSubtitleHint
'
)
}}
...
...
@@ -356,7 +361,7 @@
v-model=
"form.api_base_url"
type=
"text"
class=
"input font-mono text-sm"
placeholder=
"
https://api.example.com
"
:
placeholder=
"
t('admin.settings.site.apiBaseUrlPlaceholder')
"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.site.apiBaseUrlHint
'
)
}}
...
...
@@ -388,7 +393,7 @@
v-model=
"form.doc_url"
type=
"url"
class=
"input font-mono text-sm"
placeholder=
"
https://docs.example.com
"
:
placeholder=
"
t('admin.settings.site.docUrlPlaceholder')
"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.site.docUrlHint
'
)
}}
...
...
@@ -537,7 +542,7 @@
v-model=
"form.smtp_host"
type=
"text"
class=
"input"
placeholder=
"
smtp.gmail.com
"
:
placeholder=
"
t('admin.settings.smtp.hostPlaceholder')
"
/>
</div>
<div>
...
...
@@ -550,7 +555,7 @@
min=
"1"
max=
"65535"
class=
"input"
placeholder=
"
587
"
:
placeholder=
"
t('admin.settings.smtp.portPlaceholder')
"
/>
</div>
<div>
...
...
@@ -561,7 +566,7 @@
v-model=
"form.smtp_username"
type=
"text"
class=
"input"
placeholder=
"
your-email@gmail.com
"
:
placeholder=
"
t('admin.settings.smtp.usernamePlaceholder')
"
/>
</div>
<div>
...
...
@@ -572,7 +577,7 @@
v-model=
"form.smtp_password"
type=
"password"
class=
"input"
placeholder=
"
********
"
:
placeholder=
"
t('admin.settings.smtp.passwordPlaceholder')
"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.smtp.passwordHint
'
)
}}
...
...
@@ -586,7 +591,7 @@
v-model=
"form.smtp_from_email"
type=
"email"
class=
"input"
placeholder=
"
noreply@example.com
"
:
placeholder=
"
t('admin.settings.smtp.fromEmailPlaceholder')
"
/>
</div>
<div>
...
...
@@ -597,7 +602,7 @@
v-model=
"form.smtp_from_name"
type=
"text"
class=
"input"
placeholder=
"
Sub2API
"
:
placeholder=
"
t('admin.settings.smtp.fromNamePlaceholder')
"
/>
</div>
</div>
...
...
@@ -639,7 +644,7 @@
v-model=
"testEmailAddress"
type=
"email"
class=
"input"
placeholder=
"t
est@example.com
"
:
placeholder=
"t
('admin.settings.testEmail.recipientEmailPlaceholder')
"
/>
</div>
<button
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<
div
class=
"space-y-6"
>
<
TablePageLayout
>
<!-- Page Header Actions -->
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadSubscriptions"
...
...
@@ -36,8 +37,10 @@
{{
t
(
'
admin.subscriptions.assignSubscription
'
)
}}
</button>
</div>
</
template
>
<!-- Filters -->
<
template
#filters
>
<div
class=
"flex flex-wrap gap-3"
>
<Select
v-model=
"filters.status"
...
...
@@ -54,9 +57,10 @@
@
change=
"loadSubscriptions"
/>
</div>
</
template
>
<!-- Subscriptions Table -->
<
div
class=
"card overflow-hidden"
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"subscriptions"
:loading=
"loading"
>
<template
#cell-user
="
{ row }">
<div
class=
"flex items-center gap-2"
>
...
...
@@ -222,7 +226,7 @@
: 'text-gray-700 dark:text-gray-300'
"
>
{{
formatDate
(
value
)
}}
{{
formatDate
Only
(
value
)
}}
<
/span
>
<
div
v
-
if
=
"
getDaysRemaining(value) !== null
"
class
=
"
text-xs text-gray-500
"
>
{{
getDaysRemaining
(
value
)
}}
{{
t
(
'
admin.subscriptions.daysRemaining
'
)
}}
...
...
@@ -302,9 +306,10 @@
/>
<
/template
>
<
/DataTable
>
<
/
div
>
<
/
template
>
<!--
Pagination
-->
<
template
#
pagination
>
<
Pagination
v
-
if
=
"
pagination.total > 0
"
:
page
=
"
pagination.page
"
...
...
@@ -312,7 +317,8 @@
:
page
-
size
=
"
pagination.page_size
"
@
update
:
page
=
"
handlePageChange
"
/>
<
/div
>
<
/template
>
<
/TablePageLayout
>
<!--
Assign
Subscription
Modal
-->
<
Modal
...
...
@@ -401,7 +407,7 @@
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
extendingSubscription
.
expires_at
?
formatDate
(
extendingSubscription
.
expires_at
)
?
formatDate
Only
(
extendingSubscription
.
expires_at
)
:
t
(
'
admin.subscriptions.noExpiration
'
)
}}
<
/span
>
...
...
@@ -444,7 +450,9 @@ import { useAppStore } from '@/stores/app'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
UserSubscription
,
Group
,
User
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateOnly
}
from
'
@/utils/format
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/Modal.vue
'
...
...
@@ -640,14 +648,6 @@ const confirmRevoke = async () => {
}
// Helper functions
const
formatDate
=
(
dateString
:
string
)
=>
{
return
new
Date
(
dateString
).
toLocaleDateString
(
undefined
,
{
year
:
'
numeric
'
,
month
:
'
short
'
,
day
:
'
numeric
'
}
)
}
const
getDaysRemaining
=
(
expiresAt
:
string
):
number
|
null
=>
{
const
now
=
new
Date
()
const
expires
=
new
Date
(
expiresAt
)
...
...
frontend/src/views/admin/UsageView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<div
class=
"space-y-6"
>
<!--
Summary
Stats Cards -->
<!-- Stats Cards -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Total Requests -->
<div
class=
"card p-4"
>
...
...
@@ -157,7 +157,7 @@
</div>
</div>
<!-- Filters -->
<!-- Filters
Section
-->
<div
class=
"card"
>
<div
class=
"px-6 py-4"
>
<div
class=
"flex flex-wrap items-end gap-4"
>
...
...
@@ -229,6 +229,61 @@
/>
</div>
<!-- Model Filter -->
<div
class=
"min-w-[180px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.model
'
)
}}
</label>
<Select
v-model=
"filters.model"
:options=
"modelOptions"
:placeholder=
"t('admin.usage.allModels')"
@
change=
"applyFilters"
/>
</div>
<!-- Account Filter -->
<div
class=
"min-w-[180px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.account
'
)
}}
</label>
<Select
v-model=
"filters.account_id"
:options=
"accountOptions"
:placeholder=
"t('admin.usage.allAccounts')"
@
change=
"applyFilters"
/>
</div>
<!-- Stream Type Filter -->
<div
class=
"min-w-[150px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.type
'
)
}}
</label>
<Select
v-model=
"filters.stream"
:options=
"streamOptions"
:placeholder=
"t('admin.usage.allTypes')"
@
change=
"applyFilters"
/>
</div>
<!-- Billing Type Filter -->
<div
class=
"min-w-[150px]"
>
<label
class=
"input-label"
>
{{
t
(
'
usage.billingType
'
)
}}
</label>
<Select
v-model=
"filters.billing_type"
:options=
"billingTypeOptions"
:placeholder=
"t('admin.usage.allBillingTypes')"
@
change=
"applyFilters"
/>
</div>
<!-- Group Filter -->
<div
class=
"min-w-[150px]"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.usage.group
'
)
}}
</label>
<Select
v-model=
"filters.group_id"
:options=
"groupOptions"
:placeholder=
"t('admin.usage.allGroups')"
@
change=
"applyFilters"
/>
</div>
<!-- Date Range Filter -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
usage.timeRange
'
)
}}
</label>
...
...
@@ -252,8 +307,9 @@
</div>
</div>
<!--
Usage Table
-->
<!--
Table Section
-->
<div
class=
"card overflow-hidden"
>
<div
class=
"overflow-auto"
>
<DataTable
:columns=
"columns"
:data=
"usageLogs"
:loading=
"loading"
>
<template
#cell-user
="
{ row }">
<div
class=
"text-sm"
>
...
...
@@ -270,10 +326,26 @@
}}
</span>
</
template
>
<
template
#cell-account=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
row
.
account
?.
name
||
'
-
'
}}
</span>
</
template
>
<
template
#cell-model=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-group=
"{ row }"
>
<span
v-if=
"row.group"
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
{{
row
.
group
.
name
}}
</span>
<span
v-else
class=
"text-sm text-gray-400 dark:text-gray-500"
>
-
</span>
</
template
>
<
template
#cell-stream=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
...
...
@@ -407,6 +479,27 @@
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"
>
<!-- Cost Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
成本明细
</div>
<div
v-if=
"row.input_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.inputCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
input_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
v-if=
"row.output_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.outputCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
output_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
v-if=
"row.cache_creation_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.cacheCreationCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
cache_creation_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
v-if=
"row.cache_read_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.cacheReadCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
cache_read_cost
.
toFixed
(
6
)
}}
</span>
</div>
</div>
<!-- Rate and Summary -->
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.rate
'
)
}}
</span>
<span
class=
"font-semibold text-blue-400"
...
...
@@ -471,11 +564,18 @@
}}
</span>
</
template
>
<
template
#cell-request_id=
"{ row }"
>
<span
class=
"font-mono text-xs text-gray-500 dark:text-gray-400"
>
{{
row
.
request_id
||
'
-
'
}}
</span>
</
template
>
<
template
#empty
>
<EmptyState
:message=
"t('usage.noRecords')"
/>
</
template
>
</DataTable>
</div>
</div>
<!-- Pagination -->
<Pagination
...
...
@@ -498,6 +598,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
Select
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
...
...
@@ -532,17 +633,23 @@ const granularityOptions = computed(() => [
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
user
'
,
label
:
t
(
'
admin.usage.user
'
),
sortable
:
false
},
{
key
:
'
api_key
'
,
label
:
t
(
'
usage.apiKeyFilter
'
),
sortable
:
false
},
{
key
:
'
account
'
,
label
:
t
(
'
admin.usage.account
'
),
sortable
:
false
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
group
'
,
label
:
t
(
'
admin.usage.group
'
),
sortable
:
false
},
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
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
:
'
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
}
])
const
usageLogs
=
ref
<
UsageLog
[]
>
([])
const
apiKeys
=
ref
<
SimpleApiKey
[]
>
([])
const
models
=
ref
<
string
[]
>
([])
const
accounts
=
ref
<
any
[]
>
([])
const
groups
=
ref
<
any
[]
>
([])
const
loading
=
ref
(
false
)
// User search state
...
...
@@ -564,6 +671,53 @@ const apiKeyOptions = computed(() => {
]
})
// Model options
const
modelOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allModels
'
)
},
...
models
.
value
.
map
((
model
)
=>
({
value
:
model
,
label
:
model
}))
]
})
// Account options
const
accountOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allAccounts
'
)
},
...
accounts
.
value
.
map
((
account
)
=>
({
value
:
account
.
id
,
label
:
account
.
name
}))
]
})
// Stream type options
const
streamOptions
=
computed
(()
=>
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allTypes
'
)
},
{
value
:
true
,
label
:
t
(
'
usage.stream
'
)
},
{
value
:
false
,
label
:
t
(
'
usage.sync
'
)
}
])
// Billing type options
const
billingTypeOptions
=
computed
(()
=>
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingTypes
'
)
},
{
value
:
0
,
label
:
t
(
'
usage.balance
'
)
},
{
value
:
1
,
label
:
t
(
'
usage.subscription
'
)
}
])
// Group options
const
groupOptions
=
computed
(()
=>
{
return
[
{
value
:
null
,
label
:
t
(
'
admin.usage.allGroups
'
)
},
...
groups
.
value
.
map
((
group
)
=>
({
value
:
group
.
id
,
label
:
group
.
name
}))
]
})
// Date range state
const
startDate
=
ref
(
''
)
const
endDate
=
ref
(
''
)
...
...
@@ -571,6 +725,11 @@ const endDate = ref('')
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
api_key_id
:
undefined
,
account_id
:
undefined
,
group_id
:
undefined
,
model
:
undefined
,
stream
:
undefined
,
billing_type
:
undefined
,
start_date
:
undefined
,
end_date
:
undefined
})
...
...
@@ -689,17 +848,6 @@ const formatCacheTokens = (value: number): string => {
return
value
.
toLocaleString
()
}
const
formatDateTime
=
(
dateString
:
string
):
string
=>
{
const
date
=
new
Date
(
dateString
)
return
date
.
toLocaleString
(
'
en-US
'
,
{
year
:
'
numeric
'
,
month
:
'
short
'
,
day
:
'
numeric
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
})
}
const
loadUsageLogs
=
async
()
=>
{
loading
.
value
=
true
try
{
...
...
@@ -713,6 +861,9 @@ const loadUsageLogs = async () => {
usageLogs
.
value
=
response
.
items
pagination
.
value
.
total
=
response
.
total
pagination
.
value
.
pages
=
response
.
pages
// Extract models from loaded logs for filter options
extractModelsFromLogs
()
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
usage.failedToLoad
'
))
}
finally
{
...
...
@@ -775,6 +926,32 @@ const applyFilters = () => {
loadChartData
()
}
// Load filter options
const
loadFilterOptions
=
async
()
=>
{
try
{
// Load accounts
const
accountsResponse
=
await
adminAPI
.
accounts
.
list
(
1
,
1000
)
accounts
.
value
=
accountsResponse
.
items
||
[]
// Load groups
const
groupsResponse
=
await
adminAPI
.
groups
.
list
(
1
,
1000
)
groups
.
value
=
groupsResponse
.
items
||
[]
}
catch
(
error
)
{
console
.
error
(
'
Failed to load filter options:
'
,
error
)
}
}
// Extract unique models from usage logs
const
extractModelsFromLogs
=
()
=>
{
const
uniqueModels
=
new
Set
<
string
>
()
usageLogs
.
value
.
forEach
(
log
=>
{
if
(
log
.
model
)
{
uniqueModels
.
add
(
log
.
model
)
}
})
models
.
value
=
Array
.
from
(
uniqueModels
).
sort
()
}
const
resetFilters
=
()
=>
{
selectedUser
.
value
=
null
userSearchKeyword
.
value
=
''
...
...
@@ -783,6 +960,11 @@ const resetFilters = () => {
filters
.
value
=
{
user_id
:
undefined
,
api_key_id
:
undefined
,
account_id
:
undefined
,
group_id
:
undefined
,
model
:
undefined
,
stream
:
undefined
,
billing_type
:
undefined
,
start_date
:
undefined
,
end_date
:
undefined
}
...
...
@@ -858,6 +1040,7 @@ const handleClickOutside = (event: MouseEvent) => {
onMounted
(()
=>
{
initializeDateRange
()
loadFilterOptions
()
loadUsageLogs
()
loadUsageStats
()
loadChartData
()
...
...
frontend/src/views/admin/UsersView.vue
View file @
a413fa3b
<
template
>
<AppLayout>
<
div
class=
"space-y-6"
>
<
TablePageLayout
>
<!-- Page Header Actions -->
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadUsers"
...
...
@@ -36,8 +37,10 @@
{{
t
(
'
admin.users.createUser
'
)
}}
</button>
</div>
</
template
>
<!-- Search and Filters -->
<
template
#filters
>
<div
class=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<div
class=
"relative max-w-md flex-1"
>
<svg
...
...
@@ -78,9 +81,10 @@
/>
</div>
</div>
</
template
>
<!-- Users Table -->
<
div
class=
"card overflow-hidden"
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"users"
:loading=
"loading"
>
<template
#cell-email
="
{ value }">
<div
class=
"flex items-center gap-2"
>
...
...
@@ -135,7 +139,7 @@
:subscription-type=
"sub.group?.subscription_type"
:rate-multiplier=
"sub.group?.rate_multiplier"
:days-remaining=
"sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
:title=
"sub.expires_at ? format
ExpiresAt
(sub.expires_at) : ''"
:title=
"sub.expires_at ? format
DateTime
(sub.expires_at) : ''"
/>
</div>
<span
...
...
@@ -191,11 +195,54 @@
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDate
(
value
)
}}
</span>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDate
Time
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<
template
#cell-actions=
"{ row
, expanded
}"
>
<div
class=
"flex items-center gap-1"
>
<!-- 主要操作:编辑和删除(始终显示) -->
<button
@
click=
"handleEdit(row)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title=
"t('common.edit')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
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>
</button>
<button
v-if=
"row.role !== 'admin'"
@
click=
"handleDelete(row)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"t('common.delete')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
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>
</button>
<!-- 次要操作:展开时显示 -->
<template
v-if=
"expanded"
>
<!-- Toggle Status (hidden for admin users) -->
<button
v-if=
"row.role !== 'admin'"
...
...
@@ -277,7 +324,7 @@
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1
1
21.75 8.25z"
d=
"M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1
2
21.75 8.25z"
/>
</svg>
</button>
...
...
@@ -313,47 +360,7 @@
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 12h14"
/>
</svg>
</button>
<!-- Edit -->
<button
@
click=
"handleEdit(row)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title=
"t('common.edit')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
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>
</button>
<!-- Delete (hidden for admin users) -->
<button
v-if=
"row.role !== 'admin'"
@
click=
"handleDelete(row)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"t('common.delete')"
>
<svg
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
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>
</button>
</
template
>
</div>
</template>
...
...
@@ -366,9 +373,10 @@
/>
</
template
>
</DataTable>
</
div
>
</
template
>
<!-- Pagination -->
<
template
#pagination
>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
...
...
@@ -376,7 +384,8 @@
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
/>
</div>
</
template
>
</TablePageLayout>
<!-- Create User Modal -->
<Modal
...
...
@@ -808,7 +817,7 @@
/>
</svg>
<span
>
{{ t('admin.users.columns.created') }}: {{ formatDate(key.created_at) }}
</span
>
{{ t('admin.users.columns.created') }}: {{ formatDate
Time
(key.created_at) }}
</span
>
</div>
</div>
...
...
@@ -1164,6 +1173,7 @@
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
formatDateTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
...
...
@@ -1171,6 +1181,7 @@ import type { User, ApiKey, Group } from '@/types'
import
type
{
BatchUserUsageStats
}
from
'
@/api/admin/dashboard
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Modal
from
'
@/components/common/Modal.vue
'
...
...
@@ -1274,15 +1285,6 @@ const editForm = reactive({
})
const
editPasswordCopied
=
ref
(
false
)
const
formatDate
=
(
dateString
:
string
):
string
=>
{
const
date
=
new
Date
(
dateString
)
return
date
.
toLocaleDateString
(
'
en-US
'
,
{
year
:
'
numeric
'
,
month
:
'
short
'
,
day
:
'
numeric
'
})
}
// 计算剩余天数
const
getDaysRemaining
=
(
expiresAt
:
string
):
number
=>
{
const
now
=
new
Date
()
...
...
@@ -1291,12 +1293,6 @@ const getDaysRemaining = (expiresAt: string): number => {
return
Math
.
ceil
(
diffMs
/
(
1000
*
60
*
60
*
24
))
}
// 格式化过期时间(用于 tooltip)
const
formatExpiresAt
=
(
expiresAt
:
string
):
string
=>
{
const
date
=
new
Date
(
expiresAt
)
return
date
.
toLocaleString
()
}
const
generateRandomPasswordStr
=
()
=>
{
const
chars
=
'
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*
'
let
password
=
''
...
...
frontend/src/views/auth/EmailVerifyView.vue
View file @
a413fa3b
...
...
@@ -3,7 +3,9 @@
<div
class=
"space-y-6"
>
<!-- Title -->
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
Verify Your Email
</h2>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.verifyYourEmail
'
)
}}
</h2>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
We'll send a verification code to
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
email
}}
</span>
...
...
@@ -32,8 +34,8 @@
</svg>
</div>
<div
class=
"text-sm text-amber-700 dark:text-amber-400"
>
<p
class=
"font-medium"
>
S
ession
e
xpired
</p>
<p
class=
"mt-1"
>
Please go back to the registration page and start again.
</p>
<p
class=
"font-medium"
>
{{
t
(
'
auth.s
ession
E
xpired
'
)
}}
</p>
<p
class=
"mt-1"
>
{{
t
(
'
auth.sessionExpiredDesc
'
)
}}
</p>
</div>
</div>
</div>
...
...
@@ -42,7 +44,9 @@
<form
v-else
@
submit.prevent=
"handleVerify"
class=
"space-y-5"
>
<!-- Verification Code Input -->
<div>
<label
for=
"code"
class=
"input-label text-center"
>
Verification Code
</label>
<label
for=
"code"
class=
"input-label text-center"
>
{{
t
(
'
auth.verificationCode
'
)
}}
</label>
<input
id=
"code"
v-model=
"verifyCode"
...
...
@@ -59,7 +63,7 @@
<p
v-if=
"errors.code"
class=
"input-error-text text-center"
>
{{
errors
.
code
}}
</p>
<p
v-else
class=
"input-hint text-center"
>
Enter the 6-digit code sent to your email
</p>
<p
v-else
class=
"input-hint text-center"
>
{{
t
(
'
auth.verificationCodeHint
'
)
}}
</p>
</div>
<!-- Code Status -->
...
...
@@ -190,9 +194,11 @@
"
class=
"text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
>
<span
v-if=
"isSendingCode"
>
Sending...
</span>
<span
v-else-if=
"turnstileEnabled && !showResendTurnstile"
>
Click to resend code
</span>
<span
v-else
>
Resend verification code
</span>
<span
v-if=
"isSendingCode"
>
{{
t
(
'
auth.sendingCode
'
)
}}
</span>
<span
v-else-if=
"turnstileEnabled && !showResendTurnstile"
>
{{
t
(
'
auth.clickToResend
'
)
}}
</span>
<span
v-else
>
{{
t
(
'
auth.resendCode
'
)
}}
</span>
</button>
</div>
</form>
...
...
@@ -226,11 +232,14 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
,
sendVerifyCode
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
// ==================== Router & Stores ====================
const
router
=
useRouter
()
...
...
frontend/src/views/auth/OAuthCallbackView.vue
View file @
a413fa3b
...
...
@@ -10,7 +10,7 @@
<div
class=
"mt-6 space-y-4"
>
<div>
<label
class=
"input-label"
>
Code
</label>
<label
class=
"input-label"
>
{{
t
(
'
auth.oauth.code
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<input
class=
"input flex-1 font-mono text-sm"
:value=
"code"
readonly
/>
<button
class=
"btn btn-secondary"
type=
"button"
:disabled=
"!code"
@
click=
"copy(code)"
>
...
...
@@ -20,7 +20,7 @@
</div>
<div>
<label
class=
"input-label"
>
State
</label>
<label
class=
"input-label"
>
{{
t
(
'
auth.oauth.state
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<input
class=
"input flex-1 font-mono text-sm"
:value=
"state"
readonly
/>
<button
...
...
@@ -35,7 +35,7 @@
</div>
<div>
<label
class=
"input-label"
>
Full URL
</label>
<label
class=
"input-label"
>
{{
t
(
'
auth.oauth.fullUrl
'
)
}}
</label>
<div
class=
"flex gap-2"
>
<input
class=
"input flex-1 font-mono text-xs"
:value=
"fullUrl"
readonly
/>
<button
...
...
@@ -63,10 +63,12 @@
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
{
copyToClipboard
}
=
useClipboard
()
const
code
=
computed
(()
=>
(
route
.
query
.
code
as
string
)
||
''
)
...
...
frontend/src/views/setup/SetupWizardView.vue
View file @
a413fa3b
...
...
@@ -27,8 +27,8 @@
/>
</svg>
</div>
<h1
class=
"text-3xl font-bold text-gray-900 dark:text-white"
>
Sub2API Setup
</h1>
<p
class=
"mt-2 text-gray-500 dark:text-dark-400"
>
Configure your Sub2API instance
</p>
<h1
class=
"text-3xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
setup.title
'
)
}}
</h1>
<p
class=
"mt-2 text-gray-500 dark:text-dark-400"
>
{{
t
(
'
setup.description
'
)
}}
</p>
</div>
<!-- Progress Steps -->
...
...
@@ -84,7 +84,7 @@
<div
v-if=
"currentStep === 0"
class=
"space-y-6"
>
<div
class=
"mb-6 text-center"
>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
Database Configuration
{{ t('setup.database.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Connect to your PostgreSQL database
...
...
@@ -93,7 +93,7 @@
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
Host
</label>
<label
class=
"input-label"
>
{{ t('setup.database.host') }}
</label>
<input
v-model=
"formData.database.host"
type=
"text"
...
...
@@ -102,7 +102,7 @@
/>
</div>
<div>
<label
class=
"input-label"
>
Port
</label>
<label
class=
"input-label"
>
{{ t('setup.database.port') }}
</label>
<input
v-model.number=
"formData.database.port"
type=
"number"
...
...
@@ -114,7 +114,7 @@
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
U
sername
</label>
<label
class=
"input-label"
>
{{ t('setup.database.u
sername
') }}
</label>
<input
v-model=
"formData.database.user"
type=
"text"
...
...
@@ -123,7 +123,7 @@
/>
</div>
<div>
<label
class=
"input-label"
>
P
assword
</label>
<label
class=
"input-label"
>
{{ t('setup.database.p
assword
') }}
</label>
<input
v-model=
"formData.database.password"
type=
"password"
...
...
@@ -135,7 +135,7 @@
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
D
atabase
Name
</label>
<label
class=
"input-label"
>
{{ t('setup.database.d
atabaseName
') }}
</label>
<input
v-model=
"formData.database.dbname"
type=
"text"
...
...
@@ -144,12 +144,12 @@
/>
</div>
<div>
<label
class=
"input-label"
>
SSL Mode
</label>
<label
class=
"input-label"
>
{{ t('setup.database.sslMode') }}
</label>
<select
v-model=
"formData.database.sslmode"
class=
"input"
>
<option
value=
"disable"
>
D
isable
</option>
<option
value=
"require"
>
R
equire
</option>
<option
value=
"verify-ca"
>
Verify CA
</option>
<option
value=
"verify-full"
>
V
erify
Full
</option>
<option
value=
"disable"
>
{{ t('setup.database.ssl.d
isable
') }}
</option>
<option
value=
"require"
>
{{ t('setup.database.ssl.r
equire
') }}
</option>
<option
value=
"verify-ca"
>
{{ t('setup.database.ssl.verifyCa') }}
</option>
<option
value=
"verify-full"
>
{{ t('setup.database.ssl.v
erifyFull
') }}
</option>
</select>
</div>
</div>
...
...
@@ -198,7 +198,9 @@
<!-- Step 2: Redis -->
<div
v-if=
"currentStep === 1"
class=
"space-y-6"
>
<div
class=
"mb-6 text-center"
>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
Redis Configuration
</h2>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
{{ t('setup.redis.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Connect to your Redis server
</p>
...
...
@@ -206,7 +208,7 @@
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
Host
</label>
<label
class=
"input-label"
>
{{ t('setup.redis.host') }}
</label>
<input
v-model=
"formData.redis.host"
type=
"text"
...
...
@@ -215,7 +217,7 @@
/>
</div>
<div>
<label
class=
"input-label"
>
Port
</label>
<label
class=
"input-label"
>
{{ t('setup.redis.port') }}
</label>
<input
v-model.number=
"formData.redis.port"
type=
"number"
...
...
@@ -227,7 +229,7 @@
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
Password (optional)
</label>
<label
class=
"input-label"
>
{{ t('setup.redis.password') }}
</label>
<input
v-model=
"formData.redis.password"
type=
"password"
...
...
@@ -236,7 +238,7 @@
/>
</div>
<div>
<label
class=
"input-label"
>
D
atabase
</label>
<label
class=
"input-label"
>
{{ t('setup.redis.d
atabase
') }}
</label>
<input
v-model.number=
"formData.redis.db"
type=
"number"
...
...
@@ -294,14 +296,16 @@
<!-- Step 3: Admin -->
<div
v-if=
"currentStep === 2"
class=
"space-y-6"
>
<div
class=
"mb-6 text-center"
>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
Admin Account
</h2>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
{{ t('setup.admin.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Create your administrator account
</p>
</div>
<div>
<label
class=
"input-label"
>
Email
</label>
<label
class=
"input-label"
>
{{ t('setup.admin.email') }}
</label>
<input
v-model=
"formData.admin.email"
type=
"email"
...
...
@@ -311,7 +315,7 @@
</div>
<div>
<label
class=
"input-label"
>
P
assword
</label>
<label
class=
"input-label"
>
{{ t('setup.admin.p
assword
') }}
</label>
<input
v-model=
"formData.admin.password"
type=
"password"
...
...
@@ -321,7 +325,7 @@
</div>
<div>
<label
class=
"input-label"
>
C
onfirm
Password
</label>
<label
class=
"input-label"
>
{{ t('setup.admin.c
onfirmPassword
') }}
</label>
<input
v-model=
"confirmPassword"
type=
"password"
...
...
@@ -340,7 +344,9 @@
<!-- Step 4: Complete -->
<div
v-if=
"currentStep === 3"
class=
"space-y-6"
>
<div
class=
"mb-6 text-center"
>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
Ready to Install
</h2>
<h2
class=
"text-xl font-semibold text-gray-900 dark:text-white"
>
{{ t('setup.ready.title') }}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
Review your configuration and complete setup
</p>
...
...
@@ -348,7 +354,9 @@
<div
class=
"space-y-4"
>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<h3
class=
"mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"
>
Database
</h3>
<h3
class=
"mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"
>
{{ t('setup.ready.database') }}
</h3>
<p
class=
"text-gray-900 dark:text-white"
>
{{ formData.database.user }}@{{ formData.database.host }}:{{
formData.database.port
...
...
@@ -357,14 +365,18 @@
</div>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<h3
class=
"mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"
>
Redis
</h3>
<h3
class=
"mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"
>
{{ t('setup.ready.redis') }}
</h3>
<p
class=
"text-gray-900 dark:text-white"
>
{{ formData.redis.host }}:{{ formData.redis.port }}
</p>
</div>
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<h3
class=
"mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"
>
Admin Email
</h3>
<h3
class=
"mb-2 text-sm font-medium text-gray-500 dark:text-dark-400"
>
{{ t('setup.ready.adminEmail') }}
</h3>
<p
class=
"text-gray-900 dark:text-white"
>
{{ formData.admin.email }}
</p>
</div>
</div>
...
...
@@ -526,8 +538,11 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
testDatabase
,
testRedis
,
install
,
type
InstallRequest
}
from
'
@/api/setup
'
const
{
t
}
=
useI18n
()
const
steps
=
[
{
id
:
'
database
'
,
title
:
'
Database
'
},
{
id
:
'
redis
'
,
title
:
'
Redis
'
},
...
...
frontend/src/views/user/DashboardView.vue
View file @
a413fa3b
...
...
@@ -452,16 +452,16 @@
{{
log
.
model
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
formatDate
(
log
.
created_at
)
}}
{{
formatDate
Time
(
log
.
created_at
)
}}
</p>
</div>
</div>
<div
class=
"text-right"
>
<p
class=
"text-sm font-semibold"
>
<span
class=
"text-green-600 dark:text-green-400"
title=
"
实际扣除
"
<span
class=
"text-green-600 dark:text-green-400"
:
title=
"
t('dashboard.actual')
"
>
$
{{
formatCost
(
log
.
actual_cost
)
}}
</span
>
<span
class=
"font-normal text-gray-400 dark:text-gray-500"
title=
"
标准计费
"
>
<span
class=
"font-normal text-gray-400 dark:text-gray-500"
:
title=
"
t('dashboard.standard')
"
>
/ $
{{
formatCost
(
log
.
total_cost
)
}}
</span
>
</p>
...
...
@@ -649,6 +649,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
formatDateTime
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
import
{
usageAPI
,
type
UserDashboardStats
}
from
'
@/api/usage
'
...
...
@@ -914,16 +915,6 @@ const formatDuration = (ms: number): string => {
return
`
${
Math
.
round
(
ms
)}
ms`
}
const
formatDate
=
(
dateString
:
string
):
string
=>
{
const
date
=
new
Date
(
dateString
)
return
date
.
toLocaleString
(
'
en-US
'
,
{
month
:
'
short
'
,
day
:
'
numeric
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
})
}
const
navigateTo
=
(
path
:
string
)
=>
{
router
.
push
(
path
)
}
...
...
Prev
1
2
3
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