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
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/usage_handler.go
View file @
a413fa3b
...
@@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) {
...
@@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) {
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
// Parse filters
// Parse filters
var
userID
,
apiKeyID
int64
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
)
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
)
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) {
...
@@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) {
apiKeyID
=
id
apiKeyID
=
id
}
}
if
accountIDStr
:=
c
.
Query
(
"account_id"
);
accountIDStr
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
accountIDStr
,
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid account_id"
)
return
}
accountID
=
id
}
if
groupIDStr
:=
c
.
Query
(
"group_id"
);
groupIDStr
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
groupIDStr
,
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid group_id"
)
return
}
groupID
=
id
}
model
:=
c
.
Query
(
"model"
)
var
stream
*
bool
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
val
,
err
:=
strconv
.
ParseBool
(
streamStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid stream value, use true or false"
)
return
}
stream
=
&
val
}
var
billingType
*
int8
if
billingTypeStr
:=
c
.
Query
(
"billing_type"
);
billingTypeStr
!=
""
{
val
,
err
:=
strconv
.
ParseInt
(
billingTypeStr
,
10
,
8
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid billing_type"
)
return
}
bt
:=
int8
(
val
)
billingType
=
&
bt
}
// Parse date range
// Parse date range
var
startTime
,
endTime
*
time
.
Time
var
startTime
,
endTime
*
time
.
Time
if
startDateStr
:=
c
.
Query
(
"start_date"
);
startDateStr
!=
""
{
if
startDateStr
:=
c
.
Query
(
"start_date"
);
startDateStr
!=
""
{
...
@@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) {
...
@@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) {
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
filters
:=
usagestats
.
UsageLogFilters
{
filters
:=
usagestats
.
UsageLogFilters
{
UserID
:
userID
,
UserID
:
userID
,
ApiKeyID
:
apiKeyID
,
ApiKeyID
:
apiKeyID
,
StartTime
:
startTime
,
AccountID
:
accountID
,
EndTime
:
endTime
,
GroupID
:
groupID
,
Model
:
model
,
Stream
:
stream
,
BillingType
:
billingType
,
StartTime
:
startTime
,
EndTime
:
endTime
,
}
}
records
,
result
,
err
:=
h
.
usageService
.
ListWithFilters
(
c
.
Request
.
Context
(),
params
,
filters
)
records
,
result
,
err
:=
h
.
usageService
.
ListWithFilters
(
c
.
Request
.
Context
(),
params
,
filters
)
...
...
backend/internal/handler/usage_handler.go
View file @
a413fa3b
...
@@ -8,6 +8,7 @@ import (
...
@@ -8,6 +8,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
...
@@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) {
...
@@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) {
apiKeyID
=
id
apiKeyID
=
id
}
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
// Parse additional filters
var
records
[]
service
.
UsageLog
model
:=
c
.
Query
(
"model"
)
var
result
*
pagination
.
PaginationResult
var
err
error
if
apiKeyID
>
0
{
var
stream
*
bool
records
,
result
,
err
=
h
.
usageService
.
ListByApiKey
(
c
.
Request
.
Context
(),
apiKeyID
,
params
)
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
}
else
{
val
,
err
:=
strconv
.
ParseBool
(
streamStr
)
records
,
result
,
err
=
h
.
usageService
.
ListByUser
(
c
.
Request
.
Context
(),
subject
.
UserID
,
params
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid stream value, use true or false"
)
return
}
stream
=
&
val
}
var
billingType
*
int8
if
billingTypeStr
:=
c
.
Query
(
"billing_type"
);
billingTypeStr
!=
""
{
val
,
err
:=
strconv
.
ParseInt
(
billingTypeStr
,
10
,
8
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid billing_type"
)
return
}
bt
:=
int8
(
val
)
billingType
=
&
bt
}
// Parse date range
var
startTime
,
endTime
*
time
.
Time
if
startDateStr
:=
c
.
Query
(
"start_date"
);
startDateStr
!=
""
{
t
,
err
:=
timezone
.
ParseInLocation
(
"2006-01-02"
,
startDateStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid start_date format, use YYYY-MM-DD"
)
return
}
startTime
=
&
t
}
if
endDateStr
:=
c
.
Query
(
"end_date"
);
endDateStr
!=
""
{
t
,
err
:=
timezone
.
ParseInLocation
(
"2006-01-02"
,
endDateStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid end_date format, use YYYY-MM-DD"
)
return
}
// Set end time to end of day
t
=
t
.
Add
(
24
*
time
.
Hour
-
time
.
Nanosecond
)
endTime
=
&
t
}
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
filters
:=
usagestats
.
UsageLogFilters
{
UserID
:
subject
.
UserID
,
// Always filter by current user for security
ApiKeyID
:
apiKeyID
,
Model
:
model
,
Stream
:
stream
,
BillingType
:
billingType
,
StartTime
:
startTime
,
EndTime
:
endTime
,
}
records
,
result
,
err
:=
h
.
usageService
.
ListWithFilters
(
c
.
Request
.
Context
(),
params
,
filters
)
if
err
!=
nil
{
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
...
...
backend/internal/pkg/usagestats/usage_log_types.go
View file @
a413fa3b
...
@@ -127,10 +127,15 @@ type UserDashboardStats struct {
...
@@ -127,10 +127,15 @@ type UserDashboardStats struct {
// UsageLogFilters represents filters for usage log queries
// UsageLogFilters represents filters for usage log queries
type
UsageLogFilters
struct
{
type
UsageLogFilters
struct
{
UserID
int64
UserID
int64
ApiKeyID
int64
ApiKeyID
int64
StartTime
*
time
.
Time
AccountID
int64
EndTime
*
time
.
Time
GroupID
int64
Model
string
Stream
*
bool
BillingType
*
int8
StartTime
*
time
.
Time
EndTime
*
time
.
Time
}
}
// UsageStats represents usage statistics
// UsageStats represents usage statistics
...
...
backend/internal/repository/usage_log_repo.go
View file @
a413fa3b
...
@@ -631,6 +631,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
...
@@ -631,6 +631,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
if
filters
.
ApiKeyID
>
0
{
if
filters
.
ApiKeyID
>
0
{
db
=
db
.
Where
(
"api_key_id = ?"
,
filters
.
ApiKeyID
)
db
=
db
.
Where
(
"api_key_id = ?"
,
filters
.
ApiKeyID
)
}
}
if
filters
.
AccountID
>
0
{
db
=
db
.
Where
(
"account_id = ?"
,
filters
.
AccountID
)
}
if
filters
.
GroupID
>
0
{
db
=
db
.
Where
(
"group_id = ?"
,
filters
.
GroupID
)
}
if
filters
.
Model
!=
""
{
db
=
db
.
Where
(
"model = ?"
,
filters
.
Model
)
}
if
filters
.
Stream
!=
nil
{
db
=
db
.
Where
(
"stream = ?"
,
*
filters
.
Stream
)
}
if
filters
.
BillingType
!=
nil
{
db
=
db
.
Where
(
"billing_type = ?"
,
*
filters
.
BillingType
)
}
if
filters
.
StartTime
!=
nil
{
if
filters
.
StartTime
!=
nil
{
db
=
db
.
Where
(
"created_at >= ?"
,
*
filters
.
StartTime
)
db
=
db
.
Where
(
"created_at >= ?"
,
*
filters
.
StartTime
)
}
}
...
@@ -642,8 +657,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
...
@@ -642,8 +657,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
return
nil
,
nil
,
err
return
nil
,
nil
,
err
}
}
// Preload user
and
api_key for display
// Preload user
,
api_key
, account, and group
for display
if
err
:=
db
.
Preload
(
"User"
)
.
Preload
(
"ApiKey"
)
.
if
err
:=
db
.
Preload
(
"User"
)
.
Preload
(
"ApiKey"
)
.
Preload
(
"Account"
)
.
Preload
(
"Group"
)
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Order
(
"id DESC"
)
.
Find
(
&
logs
)
.
Error
;
err
!=
nil
{
Order
(
"id DESC"
)
.
Find
(
&
logs
)
.
Error
;
err
!=
nil
{
return
nil
,
nil
,
err
return
nil
,
nil
,
err
...
...
backend/internal/server/api_contract_test.go
View file @
a413fa3b
...
@@ -924,7 +924,10 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64,
...
@@ -924,7 +924,10 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64,
}
}
func
(
r
*
stubUsageLogRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
usagestats
.
UsageLogFilters
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
stubUsageLogRepo
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
usagestats
.
UsageLogFilters
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
logs
:=
r
.
userLogs
[
filters
.
UserID
]
total
:=
int64
(
len
(
logs
))
out
:=
paginateLogs
(
logs
,
params
)
return
out
,
paginationResult
(
total
,
params
),
nil
}
}
func
(
r
*
stubUsageLogRepo
)
GetGlobalStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
{
func
(
r
*
stubUsageLogRepo
)
GetGlobalStats
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
{
...
...
backend/internal/server/middleware/api_key_auth_google.go
View file @
a413fa3b
...
@@ -81,7 +81,11 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
...
@@ -81,7 +81,11 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
}
}
c
.
Set
(
string
(
ContextKeyApiKey
),
apiKey
)
c
.
Set
(
string
(
ContextKeyApiKey
),
apiKey
)
c
.
Set
(
string
(
ContextKeyUser
),
apiKey
.
User
)
c
.
Set
(
string
(
ContextKeyUser
),
AuthSubject
{
UserID
:
apiKey
.
User
.
ID
,
Concurrency
:
apiKey
.
User
.
Concurrency
,
})
c
.
Set
(
string
(
ContextKeyUserRole
),
apiKey
.
User
.
Role
)
c
.
Next
()
c
.
Next
()
}
}
}
}
...
...
backend/internal/web/embed_on.go
View file @
a413fa3b
...
@@ -29,7 +29,8 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
...
@@ -29,7 +29,8 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
strings
.
HasPrefix
(
path
,
"/v1/"
)
||
strings
.
HasPrefix
(
path
,
"/v1/"
)
||
strings
.
HasPrefix
(
path
,
"/v1beta/"
)
||
strings
.
HasPrefix
(
path
,
"/v1beta/"
)
||
strings
.
HasPrefix
(
path
,
"/setup/"
)
||
strings
.
HasPrefix
(
path
,
"/setup/"
)
||
path
==
"/health"
{
path
==
"/health"
||
path
==
"/responses"
{
c
.
Next
()
c
.
Next
()
return
return
}
}
...
...
frontend/src/components/account/AccountStatsModal.vue
View file @
a413fa3b
...
@@ -226,7 +226,9 @@
...
@@ -226,7 +226,9 @@
}}
<
/span
>
}}
<
/span
>
<
/div
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
Tokens
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.tokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
}}
<
/span
>
...
...
frontend/src/components/account/AccountStatusIndicator.vue
View file @
a413fa3b
...
@@ -89,6 +89,7 @@
...
@@ -89,6 +89,7 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
type
{
Account
}
from
'
@/types
'
import
type
{
Account
}
from
'
@/types
'
import
{
formatTime
}
from
'
@/utils/format
'
const
props
=
defineProps
<
{
const
props
=
defineProps
<
{
account
:
Account
account
:
Account
...
@@ -139,13 +140,4 @@ const statusText = computed(() => {
...
@@ -139,13 +140,4 @@ const statusText = computed(() => {
return
props
.
account
.
status
return
props
.
account
.
status
})
})
// Format time helper
const
formatTime
=
(
dateStr
:
string
|
null
|
undefined
)
=>
{
if
(
!
dateStr
)
return
'
N/A
'
const
date
=
new
Date
(
dateStr
)
return
date
.
toLocaleTimeString
(
'
en-US
'
,
{
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
})
}
</
script
>
</
script
>
frontend/src/components/account/AccountTodayStatsCell.vue
View file @
a413fa3b
...
@@ -16,21 +16,27 @@
...
@@ -16,21 +16,27 @@
<div
v-else-if=
"stats"
class=
"space-y-0.5 text-xs"
>
<div
v-else-if=
"stats"
class=
"space-y-0.5 text-xs"
>
<!-- Requests -->
<!-- Requests -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Req:
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
:
</span
>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatNumber
(
stats
.
requests
)
formatNumber
(
stats
.
requests
)
}}
</span>
}}
</span>
</div>
</div>
<!-- Tokens -->
<!-- Tokens -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Tok:
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.tokens
'
)
}}
:
</span
>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatTokens
(
stats
.
tokens
)
formatTokens
(
stats
.
tokens
)
}}
</span>
}}
</span>
</div>
</div>
<!-- Cost -->
<!-- Cost -->
<div
class=
"flex items-center gap-1"
>
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
Cost:
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
:
</span
>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
formatCurrency
(
stats
.
cost
)
}}
</span>
}}
</span>
...
@@ -44,6 +50,7 @@
...
@@ -44,6 +50,7 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
}
from
'
vue
'
import
{
ref
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
WindowStats
}
from
'
@/types
'
import
type
{
Account
,
WindowStats
}
from
'
@/types
'
import
{
formatNumber
,
formatCurrency
}
from
'
@/utils/format
'
import
{
formatNumber
,
formatCurrency
}
from
'
@/utils/format
'
...
@@ -52,6 +59,8 @@ const props = defineProps<{
...
@@ -52,6 +59,8 @@ const props = defineProps<{
account
:
Account
account
:
Account
}
>
()
}
>
()
const
{
t
}
=
useI18n
()
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
const
error
=
ref
<
string
|
null
>
(
null
)
const
error
=
ref
<
string
|
null
>
(
null
)
const
stats
=
ref
<
WindowStats
|
null
>
(
null
)
const
stats
=
ref
<
WindowStats
|
null
>
(
null
)
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
a413fa3b
...
@@ -105,6 +105,7 @@
...
@@ -105,6 +105,7 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageInfo
}
from
'
@/types
'
import
type
{
Account
,
AccountUsageInfo
}
from
'
@/types
'
import
UsageProgressBar
from
'
./UsageProgressBar.vue
'
import
UsageProgressBar
from
'
./UsageProgressBar.vue
'
...
@@ -113,6 +114,8 @@ const props = defineProps<{
...
@@ -113,6 +114,8 @@ const props = defineProps<{
account
:
Account
account
:
Account
}
>
()
}
>
()
const
{
t
}
=
useI18n
()
const
loading
=
ref
(
false
)
const
loading
=
ref
(
false
)
const
error
=
ref
<
string
|
null
>
(
null
)
const
error
=
ref
<
string
|
null
>
(
null
)
const
usageInfo
=
ref
<
AccountUsageInfo
|
null
>
(
null
)
const
usageInfo
=
ref
<
AccountUsageInfo
|
null
>
(
null
)
...
@@ -282,7 +285,7 @@ const loadUsage = async () => {
...
@@ -282,7 +285,7 @@ const loadUsage = async () => {
try
{
try
{
usageInfo
.
value
=
await
adminAPI
.
accounts
.
getUsage
(
props
.
account
.
id
)
usageInfo
.
value
=
await
adminAPI
.
accounts
.
getUsage
(
props
.
account
.
id
)
}
catch
(
e
:
any
)
{
}
catch
(
e
:
any
)
{
error
.
value
=
'
Failed
'
error
.
value
=
t
(
'
common.error
'
)
console
.
error
(
'
Failed to load usage:
'
,
e
)
console
.
error
(
'
Failed to load usage:
'
,
e
)
}
finally
{
}
finally
{
loading
.
value
=
false
loading
.
value
=
false
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
a413fa3b
...
@@ -256,7 +256,7 @@
...
@@ -256,7 +256,7 @@
</div>
</div>
<div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
ChatGPT OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.chatgptOauth
'
)
}}
</span>
</div>
</div>
</button>
</button>
...
@@ -294,7 +294,7 @@
...
@@ -294,7 +294,7 @@
</div>
</div>
<div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
API Key
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
API Key
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
Responses API
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.responsesApi
'
)
}}
</span>
</div>
</div>
</button>
</button>
</div>
</div>
...
@@ -338,7 +338,7 @@
...
@@ -338,7 +338,7 @@
</div>
</div>
<div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
Google OAuth
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.googleOauth
'
)
}}
</span>
</div>
</div>
</button>
</button>
...
@@ -408,7 +408,7 @@
...
@@ -408,7 +408,7 @@
</svg>
</svg>
</div>
</div>
<div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
C
ode
Assist
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.types.c
odeAssist
'
)
}}
</span>
<span
class=
"block text-xs font-medium text-blue-600 dark:text-blue-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectId
'
)
}}
</span>
<span
class=
"block text-xs font-medium text-blue-600 dark:text-blue-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectId
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectIdDesc
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectIdDesc
'
)
}}
</span>
</div>
</div>
...
@@ -488,7 +488,7 @@
...
@@ -488,7 +488,7 @@
value=
"oauth"
value=
"oauth"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Oauth
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.types.oauth
'
)
}}
</span>
</label>
</label>
<label
class=
"flex cursor-pointer items-center"
>
<label
class=
"flex cursor-pointer items-center"
>
<input
<input
...
...
frontend/src/components/account/ReAuthAccountModal.vue
View file @
a413fa3b
...
@@ -63,7 +63,9 @@
...
@@ -63,7 +63,9 @@
value=
"oauth"
value=
"oauth"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
class=
"mr-2 text-primary-600 focus:ring-primary-500"
/>
/>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Oauth
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.accounts.types.oauth
'
)
}}
</span>
</label>
</label>
<label
class=
"flex cursor-pointer items-center"
>
<label
class=
"flex cursor-pointer items-center"
>
<input
<input
...
@@ -116,7 +118,9 @@
...
@@ -116,7 +118,9 @@
</svg>
</svg>
</div>
</div>
<div>
<div>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
Code Assist
</span>
<span
class=
"block text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.types.codeAssist
'
)
}}
</span>
<span
class=
"block text-xs font-medium text-blue-600 dark:text-blue-400"
>
{{
<span
class=
"block text-xs font-medium text-blue-600 dark:text-blue-400"
>
{{
t
(
'
admin.accounts.oauth.gemini.needsProjectId
'
)
t
(
'
admin.accounts.oauth.gemini.needsProjectId
'
)
}}
</span>
}}
</span>
...
...
frontend/src/components/account/UsageProgressBar.vue
View file @
a413fa3b
...
@@ -4,7 +4,7 @@
...
@@ -4,7 +4,7 @@
<div
<div
v-if=
"windowStats"
v-if=
"windowStats"
class=
"mb-0.5 flex items-center justify-between"
class=
"mb-0.5 flex items-center justify-between"
:title=
"
`5h 窗口用量统计`
"
:title=
"
t('admin.accounts.usageWindow.statsTitle')
"
>
>
<div
<div
class=
"flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
class=
"flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
...
@@ -51,6 +51,7 @@
...
@@ -51,6 +51,7 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
WindowStats
}
from
'
@/types
'
import
type
{
WindowStats
}
from
'
@/types
'
const
props
=
defineProps
<
{
const
props
=
defineProps
<
{
...
@@ -61,6 +62,8 @@ const props = defineProps<{
...
@@ -61,6 +62,8 @@ const props = defineProps<{
windowStats
?:
WindowStats
|
null
windowStats
?:
WindowStats
|
null
}
>
()
}
>
()
const
{
t
}
=
useI18n
()
// Label background colors
// Label background colors
const
labelClass
=
computed
(()
=>
{
const
labelClass
=
computed
(()
=>
{
const
colors
=
{
const
colors
=
{
...
...
frontend/src/components/common/DataTable.vue
View file @
a413fa3b
<
template
>
<
template
>
<div
class=
"overflow-x-auto"
>
<div
ref=
"tableWrapperRef"
class=
"table-wrapper"
:class=
"
{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
}"
>
<table
class=
"min-w-full divide-y divide-gray-200 dark:divide-dark-700"
>
<table
class=
"min-w-full divide-y divide-gray-200 dark:divide-dark-700"
>
<thead
class=
"bg-gray-50 dark:bg-dark-800"
>
<thead
class=
"
table-header
bg-gray-50 dark:bg-dark-800"
>
<tr>
<tr>
<th
<th
v-for=
"column in columns"
v-for=
"
(
column
, index)
in columns"
:key=
"column.key"
:key=
"column.key"
scope=
"col"
scope=
"col"
class=
"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
:class=
"[
:class=
"
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
'sticky-header-cell px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index)
]"
@click="column.sortable
&&
handleSort(column.key)"
@click="column.sortable
&&
handleSort(column.key)"
>
>
<div
class=
"flex items-center space-x-1"
>
<div
class=
"flex items-center space-x-1"
>
<span>
{{
column
.
label
}}
</span>
<span>
{{
column
.
label
}}
</span>
<!-- 操作列展开/折叠按钮 -->
<button
v-if=
"column.key === 'actions' && hasExpandableActions"
type=
"button"
@
click.stop=
"toggleActionsExpanded"
class=
"ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title=
"actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
>
<!-- 展开状态:收起图标 -->
<svg
v-if=
"actionsExpanded"
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
/>
</svg>
<!-- 折叠状态:展开图标 -->
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<span
v-if=
"column.sortable"
class=
"text-gray-400 dark:text-dark-500"
>
<svg
<svg
v-if=
"sortKey === column.key"
v-if=
"sortKey === column.key"
...
@@ -37,7 +78,7 @@
...
@@ -37,7 +78,7 @@
</th>
</th>
</tr>
</tr>
</thead>
</thead>
<tbody
class=
"divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900"
>
<tbody
class=
"
table-body
divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900"
>
<!-- Loading skeleton -->
<!-- Loading skeleton -->
<tr
v-if=
"loading"
v-for=
"i in 5"
:key=
"i"
>
<tr
v-if=
"loading"
v-for=
"i in 5"
:key=
"i"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"whitespace-nowrap px-6 py-4"
>
<td
v-for=
"column in columns"
:key=
"column.key"
class=
"whitespace-nowrap px-6 py-4"
>
...
@@ -84,11 +125,14 @@
...
@@ -84,11 +125,14 @@
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
class=
"hover:bg-gray-50 dark:hover:bg-dark-800"
>
>
<td
<td
v-for=
"column in columns"
v-for=
"
(
column
, colIndex)
in columns"
:key=
"column.key"
:key=
"column.key"
class=
"whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
:class=
"[
'whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100',
getStickyColumnClass(column, colIndex)
]"
>
>
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]">
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]"
:expanded="actionsExpanded"
>
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
</slot>
</slot>
</td>
</td>
...
@@ -99,24 +143,71 @@
...
@@ -99,24 +143,71 @@
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
ref
}
from
'
vue
'
import
{
computed
,
ref
,
onMounted
,
onUnmounted
,
watch
,
nextTick
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Column
}
from
'
./types
'
import
type
{
Column
}
from
'
./types
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
// 表格容器引用
const
tableWrapperRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
isScrollable
=
ref
(
false
)
// 检查是否可滚动
const
checkScrollable
=
()
=>
{
if
(
tableWrapperRef
.
value
)
{
isScrollable
.
value
=
tableWrapperRef
.
value
.
scrollWidth
>
tableWrapperRef
.
value
.
clientWidth
}
}
// 监听尺寸变化
let
resizeObserver
:
ResizeObserver
|
null
=
null
onMounted
(()
=>
{
checkScrollable
()
if
(
tableWrapperRef
.
value
&&
typeof
ResizeObserver
!==
'
undefined
'
)
{
resizeObserver
=
new
ResizeObserver
(
checkScrollable
)
resizeObserver
.
observe
(
tableWrapperRef
.
value
)
}
else
{
// 降级方案:不支持 ResizeObserver 时使用 window resize
window
.
addEventListener
(
'
resize
'
,
checkScrollable
)
}
})
onUnmounted
(()
=>
{
resizeObserver
?.
disconnect
()
window
.
removeEventListener
(
'
resize
'
,
checkScrollable
)
})
interface
Props
{
interface
Props
{
columns
:
Column
[]
columns
:
Column
[]
data
:
any
[]
data
:
any
[]
loading
?:
boolean
loading
?:
boolean
stickyFirstColumn
?:
boolean
stickyActionsColumn
?:
boolean
expandableActions
?:
boolean
}
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
loading
:
false
loading
:
false
,
stickyFirstColumn
:
true
,
stickyActionsColumn
:
true
,
expandableActions
:
true
})
})
const
sortKey
=
ref
<
string
>
(
''
)
const
sortKey
=
ref
<
string
>
(
''
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
sortOrder
=
ref
<
'
asc
'
|
'
desc
'
>
(
'
asc
'
)
const
actionsExpanded
=
ref
(
false
)
// 数据/列/展开状态变化时重新检查滚动状态
watch
(
[()
=>
props
.
data
.
length
,
()
=>
props
.
columns
,
actionsExpanded
],
async
()
=>
{
await
nextTick
()
checkScrollable
()
},
{
flush
:
'
post
'
}
)
const
handleSort
=
(
key
:
string
)
=>
{
const
handleSort
=
(
key
:
string
)
=>
{
if
(
sortKey
.
value
===
key
)
{
if
(
sortKey
.
value
===
key
)
{
...
@@ -140,4 +231,186 @@ const sortedData = computed(() => {
...
@@ -140,4 +231,186 @@ const sortedData = computed(() => {
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
})
})
})
})
// 检查是否有可展开的操作列
const
hasExpandableActions
=
computed
(()
=>
{
return
props
.
expandableActions
&&
props
.
columns
.
some
((
col
)
=>
col
.
key
===
'
actions
'
)
})
// 切换操作列展开/折叠状态
const
toggleActionsExpanded
=
()
=>
{
actionsExpanded
.
value
=
!
actionsExpanded
.
value
}
// 检查第一列是否为勾选列
const
hasSelectColumn
=
computed
(()
=>
{
return
props
.
columns
.
length
>
0
&&
props
.
columns
[
0
].
key
===
'
select
'
})
// 生成固定列的 CSS 类
const
getStickyColumnClass
=
(
column
:
Column
,
index
:
number
)
=>
{
const
classes
:
string
[]
=
[]
if
(
props
.
stickyFirstColumn
)
{
// 如果第一列是勾选列,固定前两列(勾选+名称)
if
(
hasSelectColumn
.
value
)
{
if
(
index
===
0
)
{
classes
.
push
(
'
sticky-col sticky-col-left-first
'
)
}
else
if
(
index
===
1
)
{
classes
.
push
(
'
sticky-col sticky-col-left-second
'
)
}
}
else
{
// 否则只固定第一列
if
(
index
===
0
)
{
classes
.
push
(
'
sticky-col sticky-col-left
'
)
}
}
}
// 操作列固定(最后一列)
if
(
props
.
stickyActionsColumn
&&
column
.
key
===
'
actions
'
)
{
classes
.
push
(
'
sticky-col sticky-col-right
'
)
}
return
classes
.
join
(
'
'
)
}
</
script
>
</
script
>
<
style
scoped
>
/* 表格横向滚动 */
.table-wrapper
{
--select-col-width
:
52px
;
/* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
position
:
relative
;
overflow-x
:
auto
;
isolation
:
isolate
;
}
/* 表头容器,确保在滚动时覆盖表体内容 */
.table-wrapper
.table-header
{
position
:
sticky
;
top
:
0
;
z-index
:
200
;
background-color
:
rgb
(
249
250
251
);
}
.dark
.table-wrapper
.table-header
{
background-color
:
rgb
(
31
41
55
);
}
/* 表体保持在表头下方 */
.table-body
{
position
:
relative
;
z-index
:
0
;
}
/* 所有表头单元格固定在顶部 */
.sticky-header-cell
{
position
:
sticky
;
top
:
0
;
z-index
:
210
;
/* 必须高于所有表体内容 */
background-color
:
rgb
(
249
250
251
);
}
.dark
.sticky-header-cell
{
background-color
:
rgb
(
31
41
55
);
}
/* Sticky 列基础样式 */
.sticky-col
{
position
:
sticky
;
z-index
:
20
;
/* 表体固定列 */
}
/* 单列固定(无勾选列时) */
.sticky-col-left
{
left
:
0
;
}
/* 双列固定(有勾选列时):第一列(勾选) */
.sticky-col-left-first
{
left
:
0
;
}
/* 双列固定(有勾选列时):第二列(名称) */
.sticky-col-left-second
{
left
:
var
(
--select-col-width
);
}
/* 操作列固定 */
.sticky-col-right
{
right
:
0
;
}
/* 表头 sticky 列 - 需要比普通表头单元格更高的 z-index */
.sticky-header-cell.sticky-col
{
z-index
:
220
;
/* 高于普通表头单元格和表体固定列 */
}
/* 表体 sticky 列背景 */
tbody
.sticky-col
{
background-color
:
white
;
}
.dark
tbody
.sticky-col
{
background-color
:
rgb
(
17
24
39
);
}
/* hover 状态保持 */
tbody
tr
:hover
.sticky-col
{
background-color
:
rgb
(
249
250
251
);
}
.dark
tbody
tr
:hover
.sticky-col
{
background-color
:
rgb
(
31
41
55
);
}
/* 阴影只在可滚动时显示 */
/* 单列固定右侧阴影 */
.is-scrollable
.sticky-col-left
::after
{
content
:
''
;
position
:
absolute
;
top
:
0
;
right
:
0
;
bottom
:
0
;
width
:
10px
;
transform
:
translateX
(
100%
);
background
:
linear-gradient
(
to
right
,
rgba
(
0
,
0
,
0
,
0.08
),
transparent
);
pointer-events
:
none
;
}
/* 双列固定:只在第二列显示阴影 */
.is-scrollable
.sticky-col-left-second
::after
{
content
:
''
;
position
:
absolute
;
top
:
0
;
right
:
0
;
bottom
:
0
;
width
:
10px
;
transform
:
translateX
(
100%
);
background
:
linear-gradient
(
to
right
,
rgba
(
0
,
0
,
0
,
0.08
),
transparent
);
pointer-events
:
none
;
}
/* 操作列左侧阴影 */
.is-scrollable
.sticky-col-right
::before
{
content
:
''
;
position
:
absolute
;
top
:
0
;
left
:
0
;
bottom
:
0
;
width
:
10px
;
transform
:
translateX
(
-100%
);
background
:
linear-gradient
(
to
left
,
rgba
(
0
,
0
,
0
,
0.08
),
transparent
);
pointer-events
:
none
;
}
/* 暗色模式阴影 */
.dark
.is-scrollable
.sticky-col-left
::after
,
.dark
.is-scrollable
.sticky-col-left-second
::after
{
background
:
linear-gradient
(
to
right
,
rgba
(
0
,
0
,
0
,
0.2
),
transparent
);
}
.dark
.is-scrollable
.sticky-col-right
::before
{
background
:
linear-gradient
(
to
left
,
rgba
(
0
,
0
,
0
,
0.2
),
transparent
);
}
</
style
>
frontend/src/components/common/DateRangePicker.vue
View file @
a413fa3b
...
@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate)
...
@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate)
const
localEndDate
=
ref
(
props
.
endDate
)
const
localEndDate
=
ref
(
props
.
endDate
)
const
activePreset
=
ref
<
string
|
null
>
(
'
7days
'
)
const
activePreset
=
ref
<
string
|
null
>
(
'
7days
'
)
const
today
=
computed
(()
=>
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
])
const
today
=
computed
(()
=>
{
// Use local timezone to avoid UTC timezone issues
const
now
=
new
Date
()
const
year
=
now
.
getFullYear
()
const
month
=
String
(
now
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
const
day
=
String
(
now
.
getDate
()).
padStart
(
2
,
'
0
'
)
return
`
${
year
}
-
${
month
}
-
${
day
}
`
})
// Helper function to format date to YYYY-MM-DD using local timezone
const
formatDateToString
=
(
date
:
Date
):
string
=>
{
const
year
=
date
.
getFullYear
()
const
month
=
String
(
date
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
const
day
=
String
(
date
.
getDate
()).
padStart
(
2
,
'
0
'
)
return
`
${
year
}
-
${
month
}
-
${
day
}
`
}
const
presets
:
DatePreset
[]
=
[
const
presets
:
DatePreset
[]
=
[
{
{
...
@@ -152,7 +167,7 @@ const presets: DatePreset[] = [
...
@@ -152,7 +167,7 @@ const presets: DatePreset[] = [
getRange
:
()
=>
{
getRange
:
()
=>
{
const
d
=
new
Date
()
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
1
)
d
.
setDate
(
d
.
getDate
()
-
1
)
const
yesterday
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
const
yesterday
=
formatDateToString
(
d
)
return
{
start
:
yesterday
,
end
:
yesterday
}
return
{
start
:
yesterday
,
end
:
yesterday
}
}
}
},
},
...
@@ -163,7 +178,7 @@ const presets: DatePreset[] = [
...
@@ -163,7 +178,7 @@ const presets: DatePreset[] = [
const
end
=
today
.
value
const
end
=
today
.
value
const
d
=
new
Date
()
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
6
)
d
.
setDate
(
d
.
getDate
()
-
6
)
const
start
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
const
start
=
formatDateToString
(
d
)
return
{
start
,
end
}
return
{
start
,
end
}
}
}
},
},
...
@@ -174,7 +189,7 @@ const presets: DatePreset[] = [
...
@@ -174,7 +189,7 @@ const presets: DatePreset[] = [
const
end
=
today
.
value
const
end
=
today
.
value
const
d
=
new
Date
()
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
13
)
d
.
setDate
(
d
.
getDate
()
-
13
)
const
start
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
const
start
=
formatDateToString
(
d
)
return
{
start
,
end
}
return
{
start
,
end
}
}
}
},
},
...
@@ -185,7 +200,7 @@ const presets: DatePreset[] = [
...
@@ -185,7 +200,7 @@ const presets: DatePreset[] = [
const
end
=
today
.
value
const
end
=
today
.
value
const
d
=
new
Date
()
const
d
=
new
Date
()
d
.
setDate
(
d
.
getDate
()
-
29
)
d
.
setDate
(
d
.
getDate
()
-
29
)
const
start
=
d
.
toISOString
().
split
(
'
T
'
)[
0
]
const
start
=
formatDateToString
(
d
)
return
{
start
,
end
}
return
{
start
,
end
}
}
}
},
},
...
@@ -194,7 +209,7 @@ const presets: DatePreset[] = [
...
@@ -194,7 +209,7 @@ const presets: DatePreset[] = [
value
:
'
thisMonth
'
,
value
:
'
thisMonth
'
,
getRange
:
()
=>
{
getRange
:
()
=>
{
const
now
=
new
Date
()
const
now
=
new
Date
()
const
start
=
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
(),
1
)
.
toISOString
().
split
(
'
T
'
)[
0
]
const
start
=
formatDateToString
(
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
(),
1
)
)
return
{
start
,
end
:
today
.
value
}
return
{
start
,
end
:
today
.
value
}
}
}
},
},
...
@@ -203,8 +218,8 @@ const presets: DatePreset[] = [
...
@@ -203,8 +218,8 @@ const presets: DatePreset[] = [
value
:
'
lastMonth
'
,
value
:
'
lastMonth
'
,
getRange
:
()
=>
{
getRange
:
()
=>
{
const
now
=
new
Date
()
const
now
=
new
Date
()
const
start
=
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
()
-
1
,
1
)
.
toISOString
().
split
(
'
T
'
)[
0
]
const
start
=
formatDateToString
(
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
()
-
1
,
1
)
)
const
end
=
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
(),
0
)
.
toISOString
().
split
(
'
T
'
)[
0
]
const
end
=
formatDateToString
(
new
Date
(
now
.
getFullYear
(),
now
.
getMonth
(),
0
)
)
return
{
start
,
end
}
return
{
start
,
end
}
}
}
}
}
...
...
frontend/src/components/common/GroupSelector.vue
View file @
a413fa3b
...
@@ -11,7 +11,7 @@
...
@@ -11,7 +11,7 @@
v-for=
"group in filteredGroups"
v-for=
"group in filteredGroups"
:key=
"group.id"
:key=
"group.id"
class=
"flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
class=
"flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
:title=
"
`$
{
group.rate_multiplier
}x rate · ${
group.account_count || 0
} accounts`
"
:title=
"
t('admin.groups.rateAndAccounts',
{ rate:
group.rate_multiplier
, count:
group.account_count || 0
})
"
>
>
<input
<input
type=
"checkbox"
type=
"checkbox"
...
@@ -40,9 +40,12 @@
...
@@ -40,9 +40,12 @@
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
GroupBadge
from
'
./GroupBadge.vue
'
import
GroupBadge
from
'
./GroupBadge.vue
'
import
type
{
Group
,
GroupPlatform
}
from
'
@/types
'
import
type
{
Group
,
GroupPlatform
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
interface
Props
{
interface
Props
{
modelValue
:
number
[]
modelValue
:
number
[]
groups
:
Group
[]
groups
:
Group
[]
...
...
frontend/src/components/common/Pagination.vue
View file @
a413fa3b
...
@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
...
@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
}
}
}
}
const
handlePageSizeChange
=
(
value
:
string
|
number
|
null
)
=>
{
const
handlePageSizeChange
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
if
(
value
===
null
)
return
if
(
value
===
null
||
typeof
value
===
'
boolean
'
)
return
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
const
newPageSize
=
typeof
value
===
'
string
'
?
parseInt
(
value
)
:
value
emit
(
'
update:pageSize
'
,
newPageSize
)
emit
(
'
update:pageSize
'
,
newPageSize
)
// Reset to first page when page size changes
// Reset to first page when page size changes
...
...
frontend/src/components/common/Select.vue
View file @
a413fa3b
...
@@ -60,7 +60,7 @@
...
@@ -60,7 +60,7 @@
<div
class=
"select-options"
>
<div
class=
"select-options"
>
<div
<div
v-for=
"option in filteredOptions"
v-for=
"option in filteredOptions"
:key=
"getOptionValue(option)
?? undefined
"
:key=
"
`$
{typeof
getOptionValue(option)
}:${String(getOptionValue(option) ?? '')}`
"
@click="selectOption(option)"
@click="selectOption(option)"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
>
>
...
@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n'
...
@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
export
interface
SelectOption
{
export
interface
SelectOption
{
value
:
string
|
number
|
null
value
:
string
|
number
|
boolean
|
null
label
:
string
label
:
string
disabled
?:
boolean
disabled
?:
boolean
[
key
:
string
]:
unknown
[
key
:
string
]:
unknown
}
}
interface
Props
{
interface
Props
{
modelValue
:
string
|
number
|
null
|
undefined
modelValue
:
string
|
number
|
boolean
|
null
|
undefined
options
:
SelectOption
[]
|
Array
<
Record
<
string
,
unknown
>>
options
:
SelectOption
[]
|
Array
<
Record
<
string
,
unknown
>>
placeholder
?:
string
placeholder
?:
string
disabled
?:
boolean
disabled
?:
boolean
...
@@ -116,8 +116,8 @@ interface Props {
...
@@ -116,8 +116,8 @@ interface Props {
}
}
interface
Emits
{
interface
Emits
{
(
e
:
'
update:modelValue
'
,
value
:
string
|
number
|
null
):
void
(
e
:
'
update:modelValue
'
,
value
:
string
|
number
|
boolean
|
null
):
void
(
e
:
'
change
'
,
value
:
string
|
number
|
null
,
option
:
SelectOption
|
null
):
void
(
e
:
'
change
'
,
value
:
string
|
number
|
boolean
|
null
,
option
:
SelectOption
|
null
):
void
}
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
...
@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
...
@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
const
getOptionValue
=
(
const
getOptionValue
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
null
|
undefined
=>
{
):
string
|
number
|
boolean
|
null
|
undefined
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
option
[
props
.
valueKey
]
as
string
|
number
|
null
|
undefined
return
option
[
props
.
valueKey
]
as
string
|
number
|
boolean
|
null
|
undefined
}
}
return
option
as
string
|
number
|
null
return
option
as
string
|
number
|
boolean
|
null
}
}
const
getOptionLabel
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
=>
{
const
getOptionLabel
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
=>
{
...
...
frontend/src/components/common/VersionBadge.vue
View file @
a413fa3b
...
@@ -10,7 +10,7 @@
...
@@ -10,7 +10,7 @@
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
]"
]"
:title=
"hasUpdate ?
'New
version
a
vailable' :
'Up to d
ate'"
:title=
"hasUpdate ?
t('
version
.updateA
vailable'
)
:
t('version.upToD
ate'
)
"
>
>
<span
v-if=
"currentVersion"
class=
"font-medium"
>
v
{{
currentVersion
}}
</span>
<span
v-if=
"currentVersion"
class=
"font-medium"
>
v
{{
currentVersion
}}
</span>
<span
<span
...
...
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