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
d36392b7
Commit
d36392b7
authored
Jan 04, 2026
by
IanShaw027
Browse files
fix(frontend): comprehensive i18n cleanup and Select component hardening
parent
c86d445c
Changes
12
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/gateway_handler.go
View file @
d36392b7
...
@@ -128,7 +128,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -128,7 +128,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 2. 【新增】Wait后二次检查余额/订阅
// 2. 【新增】Wait后二次检查余额/订阅
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
log
.
Printf
(
"Billing eligibility check failed after wait: %v"
,
err
)
log
.
Printf
(
"Billing eligibility check failed after wait: %v"
,
err
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusForbidden
,
"
billing_error"
,
err
.
Error
()
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusForbidden
,
"
permission_error"
,
"Insufficient balance or active subscription required"
,
streamStarted
)
return
return
}
}
...
@@ -156,8 +156,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -156,8 +156,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
for
{
for
{
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
)
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
)
if
err
!=
nil
{
if
err
!=
nil
{
log
.
Printf
(
"Select account failed: %v"
,
err
)
if
len
(
failedAccountIDs
)
==
0
{
if
len
(
failedAccountIDs
)
==
0
{
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts
: "
+
err
.
Error
()
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts
for requested model"
,
streamStarted
)
return
return
}
}
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
streamStarted
)
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
streamStarted
)
...
@@ -280,8 +281,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -280,8 +281,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 选择支持该模型的账号
// 选择支持该模型的账号
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
)
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
)
if
err
!=
nil
{
if
err
!=
nil
{
log
.
Printf
(
"Select account failed: %v"
,
err
)
if
len
(
failedAccountIDs
)
==
0
{
if
len
(
failedAccountIDs
)
==
0
{
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts
: "
+
err
.
Error
()
,
streamStarted
)
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts
for requested model"
,
streamStarted
)
return
return
}
}
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
streamStarted
)
h
.
handleFailoverExhausted
(
c
,
lastFailoverStatus
,
streamStarted
)
...
@@ -566,32 +568,68 @@ func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int,
...
@@ -566,32 +568,68 @@ func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int,
func
(
h
*
GatewayHandler
)
mapUpstreamError
(
statusCode
int
)
(
int
,
string
,
string
)
{
func
(
h
*
GatewayHandler
)
mapUpstreamError
(
statusCode
int
)
(
int
,
string
,
string
)
{
switch
statusCode
{
switch
statusCode
{
case
401
:
case
401
:
return
http
.
StatusBadGateway
,
"
upstream
_error"
,
"Upstream authentication failed, please contact administrator"
return
http
.
StatusBadGateway
,
"
api
_error"
,
"Upstream authentication failed, please contact administrator"
case
403
:
case
403
:
return
http
.
StatusBadGateway
,
"
upstream
_error"
,
"Upstream access forbidden, please contact administrator"
return
http
.
StatusBadGateway
,
"
api
_error"
,
"Upstream access forbidden, please contact administrator"
case
429
:
case
429
:
return
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
"Upstream rate limit exceeded, please retry later"
return
http
.
StatusTooManyRequests
,
"rate_limit_error"
,
"Upstream rate limit exceeded, please retry later"
case
529
:
case
529
:
return
http
.
StatusServiceUnavailable
,
"overloaded_error"
,
"Upstream service overloaded, please retry later"
return
http
.
StatusServiceUnavailable
,
"overloaded_error"
,
"Upstream service overloaded, please retry later"
case
500
,
502
,
503
,
504
:
case
500
,
502
,
503
,
504
:
return
http
.
StatusBadGateway
,
"
upstream
_error"
,
"Upstream service temporarily unavailable"
return
http
.
StatusBadGateway
,
"
api
_error"
,
"Upstream service temporarily unavailable"
default
:
default
:
return
http
.
StatusBadGateway
,
"
upstream
_error"
,
"Upstream request failed"
return
http
.
StatusBadGateway
,
"
api
_error"
,
"Upstream request failed"
}
}
}
}
func
normalizeAnthropicErrorType
(
errType
string
)
string
{
switch
errType
{
case
"invalid_request_error"
,
"authentication_error"
,
"permission_error"
,
"not_found_error"
,
"rate_limit_error"
,
"api_error"
,
"overloaded_error"
:
return
errType
case
"billing_error"
:
// Not an Anthropic-standard error type; map to the closest equivalent.
return
"permission_error"
case
"upstream_error"
:
// Not an Anthropic-standard error type; keep clients compatible.
return
"api_error"
default
:
return
"api_error"
}
}
const
maxPublicErrorMessageLen
=
512
func
sanitizePublicErrorMessage
(
message
string
)
string
{
cleaned
:=
strings
.
TrimSpace
(
message
)
cleaned
=
strings
.
ReplaceAll
(
cleaned
,
"
\r
"
,
" "
)
cleaned
=
strings
.
ReplaceAll
(
cleaned
,
"
\n
"
,
" "
)
if
len
(
cleaned
)
>
maxPublicErrorMessageLen
{
cleaned
=
cleaned
[
:
maxPublicErrorMessageLen
]
+
"..."
}
return
cleaned
}
// handleStreamingAwareError handles errors that may occur after streaming has started
// handleStreamingAwareError handles errors that may occur after streaming has started
func
(
h
*
GatewayHandler
)
handleStreamingAwareError
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
,
streamStarted
bool
)
{
func
(
h
*
GatewayHandler
)
handleStreamingAwareError
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
,
streamStarted
bool
)
{
normalizedType
:=
normalizeAnthropicErrorType
(
errType
)
publicMessage
:=
sanitizePublicErrorMessage
(
message
)
if
streamStarted
{
if
streamStarted
{
// Stream already started, send error as SSE event then close
// Stream already started, send error as SSE event then close
flusher
,
ok
:=
c
.
Writer
.
(
http
.
Flusher
)
flusher
,
ok
:=
c
.
Writer
.
(
http
.
Flusher
)
if
ok
{
if
ok
{
//
Send error event in SSE format with proper JSON marshaling
//
Anthropic streaming spec: send `event: error` with JSON `data`.
errorData
:=
map
[
string
]
any
{
errorData
:=
map
[
string
]
any
{
"type"
:
"error"
,
"type"
:
"error"
,
"error"
:
map
[
string
]
string
{
"error"
:
map
[
string
]
string
{
"type"
:
err
Type
,
"type"
:
normalized
Type
,
"message"
:
m
essage
,
"message"
:
publicM
essage
,
},
},
}
}
jsonBytes
,
err
:=
json
.
Marshal
(
errorData
)
jsonBytes
,
err
:=
json
.
Marshal
(
errorData
)
...
@@ -599,8 +637,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
...
@@ -599,8 +637,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
_
=
c
.
Error
(
err
)
_
=
c
.
Error
(
err
)
return
return
}
}
errorEvent
:=
fmt
.
Sprintf
(
"data: %s
\n\n
"
,
string
(
jsonBytes
))
if
_
,
err
:=
fmt
.
Fprintf
(
c
.
Writer
,
"event: error
\n
"
);
err
!=
nil
{
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
errorEvent
);
err
!=
nil
{
_
=
c
.
Error
(
err
)
return
}
if
_
,
err
:=
fmt
.
Fprintf
(
c
.
Writer
,
"data: %s
\n\n
"
,
string
(
jsonBytes
));
err
!=
nil
{
_
=
c
.
Error
(
err
)
_
=
c
.
Error
(
err
)
}
}
flusher
.
Flush
()
flusher
.
Flush
()
...
@@ -609,16 +650,19 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
...
@@ -609,16 +650,19 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
}
}
// Normal case: return JSON response with proper status code
// Normal case: return JSON response with proper status code
h
.
errorResponse
(
c
,
status
,
errType
,
m
essage
)
h
.
errorResponse
(
c
,
status
,
normalizedType
,
publicM
essage
)
}
}
// errorResponse 返回Claude API格式的错误响应
// errorResponse 返回Claude API格式的错误响应
func
(
h
*
GatewayHandler
)
errorResponse
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
)
{
func
(
h
*
GatewayHandler
)
errorResponse
(
c
*
gin
.
Context
,
status
int
,
errType
,
message
string
)
{
normalizedType
:=
normalizeAnthropicErrorType
(
errType
)
publicMessage
:=
sanitizePublicErrorMessage
(
message
)
c
.
JSON
(
status
,
gin
.
H
{
c
.
JSON
(
status
,
gin
.
H
{
"type"
:
"error"
,
"type"
:
"error"
,
"error"
:
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
err
Type
,
"type"
:
normalized
Type
,
"message"
:
m
essage
,
"message"
:
publicM
essage
,
},
},
})
})
}
}
...
@@ -674,7 +718,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
...
@@ -674,7 +718,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
// 校验 billing eligibility(订阅/余额)
// 校验 billing eligibility(订阅/余额)
// 【注意】不计算并发,但需要校验订阅/余额
// 【注意】不计算并发,但需要校验订阅/余额
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
h
.
errorResponse
(
c
,
http
.
StatusForbidden
,
"billing_error"
,
err
.
Error
())
log
.
Printf
(
"Billing eligibility check failed: %v"
,
err
)
h
.
errorResponse
(
c
,
http
.
StatusForbidden
,
"permission_error"
,
"Insufficient balance or active subscription required"
)
return
return
}
}
...
@@ -684,7 +729,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
...
@@ -684,7 +729,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
// 选择支持该模型的账号
// 选择支持该模型的账号
account
,
err
:=
h
.
gatewayService
.
SelectAccountForModel
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionHash
,
parsedReq
.
Model
)
account
,
err
:=
h
.
gatewayService
.
SelectAccountForModel
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionHash
,
parsedReq
.
Model
)
if
err
!=
nil
{
if
err
!=
nil
{
h
.
errorResponse
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts: "
+
err
.
Error
())
log
.
Printf
(
"Select account failed: %v"
,
err
)
h
.
errorResponse
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts for requested model"
)
return
return
}
}
...
...
backend/internal/service/gateway_service.go
View file @
d36392b7
...
@@ -929,8 +929,16 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s
...
@@ -929,8 +929,16 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s
// 重试相关常量
// 重试相关常量
const
(
const
(
maxRetries
=
10
// 最大重试次数
// 最大尝试次数(包含首次请求)。过多重试会导致请求堆积与资源耗尽。
retryDelay
=
3
*
time
.
Second
// 重试等待时间
maxRetryAttempts
=
5
// 指数退避:第 N 次失败后的等待 = retryBaseDelay * 2^(N-1),并且上限为 retryMaxDelay。
retryBaseDelay
=
300
*
time
.
Millisecond
retryMaxDelay
=
3
*
time
.
Second
// 最大重试耗时(包含请求本身耗时 + 退避等待时间)。
// 用于防止极端情况下 goroutine 长时间堆积导致资源耗尽。
maxRetryElapsed
=
10
*
time
.
Second
)
)
func
(
s
*
GatewayService
)
shouldRetryUpstreamError
(
account
*
Account
,
statusCode
int
)
bool
{
func
(
s
*
GatewayService
)
shouldRetryUpstreamError
(
account
*
Account
,
statusCode
int
)
bool
{
...
@@ -953,6 +961,40 @@ func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
...
@@ -953,6 +961,40 @@ func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
}
}
}
}
func
retryBackoffDelay
(
attempt
int
)
time
.
Duration
{
// attempt 从 1 开始,表示第 attempt 次请求刚失败,需要等待后进行第 attempt+1 次请求。
if
attempt
<=
0
{
return
retryBaseDelay
}
delay
:=
retryBaseDelay
*
time
.
Duration
(
1
<<
(
attempt
-
1
))
if
delay
>
retryMaxDelay
{
return
retryMaxDelay
}
return
delay
}
func
sleepWithContext
(
ctx
context
.
Context
,
d
time
.
Duration
)
error
{
if
d
<=
0
{
return
nil
}
timer
:=
time
.
NewTimer
(
d
)
defer
func
()
{
if
!
timer
.
Stop
()
{
select
{
case
<-
timer
.
C
:
default
:
}
}
}()
select
{
case
<-
ctx
.
Done
()
:
return
ctx
.
Err
()
case
<-
timer
.
C
:
return
nil
}
}
// isClaudeCodeClient 判断请求是否来自 Claude Code 客户端
// isClaudeCodeClient 判断请求是否来自 Claude Code 客户端
// 简化判断:User-Agent 匹配 + metadata.user_id 存在
// 简化判断:User-Agent 匹配 + metadata.user_id 存在
func
isClaudeCodeClient
(
userAgent
string
,
metadataUserID
string
)
bool
{
func
isClaudeCodeClient
(
userAgent
string
,
metadataUserID
string
)
bool
{
...
@@ -1069,7 +1111,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -1069,7 +1111,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 重试循环
// 重试循环
var
resp
*
http
.
Response
var
resp
*
http
.
Response
for
attempt
:=
1
;
attempt
<=
maxRetries
;
attempt
++
{
retryStart
:=
time
.
Now
()
for
attempt
:=
1
;
attempt
<=
maxRetryAttempts
;
attempt
++
{
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
upstreamReq
,
err
:=
s
.
buildUpstreamRequest
(
ctx
,
c
,
account
,
body
,
token
,
tokenType
,
reqModel
)
upstreamReq
,
err
:=
s
.
buildUpstreamRequest
(
ctx
,
c
,
account
,
body
,
token
,
tokenType
,
reqModel
)
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -1079,6 +1122,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -1079,6 +1122,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 发送请求
// 发送请求
resp
,
err
=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
resp
,
err
=
s
.
httpUpstream
.
Do
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
)
if
err
!=
nil
{
if
err
!=
nil
{
if
resp
!=
nil
&&
resp
.
Body
!=
nil
{
_
=
resp
.
Body
.
Close
()
}
return
nil
,
fmt
.
Errorf
(
"upstream request failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"upstream request failed: %w"
,
err
)
}
}
...
@@ -1089,6 +1135,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -1089,6 +1135,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
_
=
resp
.
Body
.
Close
()
_
=
resp
.
Body
.
Close
()
if
s
.
isThinkingBlockSignatureError
(
respBody
)
{
if
s
.
isThinkingBlockSignatureError
(
respBody
)
{
// 避免在重试预算已耗尽时再发起额外请求
if
time
.
Since
(
retryStart
)
>=
maxRetryElapsed
{
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
break
}
log
.
Printf
(
"Account %d: detected thinking block signature error, retrying with filtered thinking blocks"
,
account
.
ID
)
log
.
Printf
(
"Account %d: detected thinking block signature error, retrying with filtered thinking blocks"
,
account
.
ID
)
// 过滤thinking blocks并重试(使用更激进的过滤)
// 过滤thinking blocks并重试(使用更激进的过滤)
...
@@ -1121,11 +1172,27 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -1121,11 +1172,27 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了)
// 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了)
if
resp
.
StatusCode
>=
400
&&
resp
.
StatusCode
!=
400
&&
s
.
shouldRetryUpstreamError
(
account
,
resp
.
StatusCode
)
{
if
resp
.
StatusCode
>=
400
&&
resp
.
StatusCode
!=
400
&&
s
.
shouldRetryUpstreamError
(
account
,
resp
.
StatusCode
)
{
if
attempt
<
maxRetries
{
if
attempt
<
maxRetryAttempts
{
log
.
Printf
(
"Account %d: upstream error %d, retry %d/%d after %v"
,
elapsed
:=
time
.
Since
(
retryStart
)
account
.
ID
,
resp
.
StatusCode
,
attempt
,
maxRetries
,
retryDelay
)
if
elapsed
>=
maxRetryElapsed
{
break
}
delay
:=
retryBackoffDelay
(
attempt
)
remaining
:=
maxRetryElapsed
-
elapsed
if
delay
>
remaining
{
delay
=
remaining
}
if
delay
<=
0
{
break
}
log
.
Printf
(
"Account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)"
,
account
.
ID
,
resp
.
StatusCode
,
attempt
,
maxRetryAttempts
,
delay
,
elapsed
,
maxRetryElapsed
)
_
=
resp
.
Body
.
Close
()
_
=
resp
.
Body
.
Close
()
time
.
Sleep
(
retryDelay
)
if
err
:=
sleepWithContext
(
ctx
,
delay
);
err
!=
nil
{
return
nil
,
err
}
continue
continue
}
}
// 最后一次尝试也失败,跳出循环处理重试耗尽
// 最后一次尝试也失败,跳出循环处理重试耗尽
...
@@ -1142,6 +1209,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -1142,6 +1209,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
}
break
break
}
}
if
resp
==
nil
||
resp
.
Body
==
nil
{
return
nil
,
errors
.
New
(
"upstream request failed: empty response"
)
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
// 处理重试耗尽的情况
// 处理重试耗尽的情况
...
...
frontend/src/components/common/GroupSelector.vue
View file @
d36392b7
<
template
>
<
template
>
<div>
<div>
<label
class=
"input-label"
>
<label
class=
"input-label"
>
Groups
{{
t
(
'
admin.users.groups
'
)
}}
<span
class=
"font-normal text-gray-400"
>
(
{{
modelValue
.
length
}
}
selected)
</span>
<span
class=
"font-normal text-gray-400"
>
{{
t
(
'
common.selectedCount
'
,
{
count
:
modelValue
.
length
}
)
}}
<
/span
>
<
/label
>
<
/label
>
<
div
<
div
class
=
"
grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800
"
class
=
"
grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800
"
...
@@ -32,7 +32,7 @@
...
@@ -32,7 +32,7 @@
v
-
if
=
"
filteredGroups.length === 0
"
v
-
if
=
"
filteredGroups.length === 0
"
class
=
"
col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400
"
class
=
"
col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400
"
>
>
No g
roups
a
vailable
{{
t
(
'
common.noG
roups
A
vailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/components/common/Select.vue
View file @
d36392b7
<
template
>
<
template
>
<div
class=
"relative"
ref=
"containerRef"
>
<div
class=
"relative"
ref=
"containerRef"
>
<button
<button
ref=
"triggerRef"
type=
"button"
type=
"button"
@
click=
"toggle"
@
click=
"toggle"
:disabled=
"disabled"
:disabled=
"disabled"
:aria-expanded=
"isOpen"
:aria-haspopup=
"true"
aria-label=
"Select option"
:class=
"[
:class=
"[
'select-trigger',
'select-trigger',
isOpen && 'select-trigger-open',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
disabled && 'select-trigger-disabled'
]"
]"
@
keydown.down.prevent=
"onTriggerKeyDown"
@
keydown.up.prevent=
"onTriggerKeyDown"
>
>
<span
class=
"select-value"
>
<span
class=
"select-value"
>
<slot
name=
"selected"
:option=
"selectedOption"
>
<slot
name=
"selected"
:option=
"selectedOption"
>
...
@@ -29,16 +35,19 @@
...
@@ -29,16 +35,19 @@
</span>
</span>
</button>
</button>
<!-- Teleport dropdown to body to escape stacking context
(for driver.js overlay compatibility)
-->
<!-- Teleport dropdown to body to escape stacking context -->
<Teleport
to=
"body"
>
<Teleport
to=
"body"
>
<Transition
name=
"select-dropdown"
>
<Transition
name=
"select-dropdown"
>
<div
<div
v-if=
"isOpen"
v-if=
"isOpen"
ref=
"dropdownRef"
ref=
"dropdownRef"
class=
"select-dropdown-portal"
class=
"select-dropdown-portal"
:class=
"[instanceId]"
:style=
"dropdownStyle"
:style=
"dropdownStyle"
role=
"listbox"
@
click.stop
@
click.stop
@
mousedown.stop
@
mousedown.stop
@
keydown=
"onDropdownKeyDown"
>
>
<!-- Search input -->
<!-- Search input -->
<div
v-if=
"searchable"
class=
"select-search"
>
<div
v-if=
"searchable"
class=
"select-search"
>
...
@@ -66,12 +75,21 @@
...
@@ -66,12 +75,21 @@
</div>
</div>
<!-- Options list -->
<!-- Options list -->
<div
class=
"select-options"
>
<div
class=
"select-options"
ref=
"optionsListRef"
>
<div
<div
v-for=
"option in filteredOptions"
v-for=
"
(
option
, index)
in filteredOptions"
:key=
"`$
{typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
:key=
"`$
{typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
role="option"
:class="['select-option', isSelected(option)
&&
'select-option-selected']"
:aria-selected="isSelected(option)"
:aria-disabled="isOptionDisabled(option)"
@click.stop="!isOptionDisabled(option)
&&
selectOption(option)"
@mouseenter="focusedIndex = index"
:class="[
'select-option',
isSelected(option)
&&
'select-option-selected',
isOptionDisabled(option)
&&
'select-option-disabled',
focusedIndex === index
&&
'select-option-focused'
]"
>
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<slot
name=
"option"
:option=
"option"
:selected=
"isSelected(option)"
>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
<span
class=
"select-option-label"
>
{{
getOptionLabel
(
option
)
}}
</span>
...
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
...
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
// Instance ID for unique click-outside detection
const
instanceId
=
`select-
${
Math
.
random
().
toString
(
36
).
substring
(
2
,
9
)}
`
export
interface
SelectOption
{
export
interface
SelectOption
{
value
:
string
|
number
|
boolean
|
null
value
:
string
|
number
|
boolean
|
null
label
:
string
label
:
string
...
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
...
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
labelKey
:
'
label
'
labelKey
:
'
label
'
})
})
// Use computed for i18n default values
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(
()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
)
)
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
const
emit
=
defineEmits
<
Emits
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
isOpen
=
ref
(
false
)
const
isOpen
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
searchQuery
=
ref
(
''
)
const
focusedIndex
=
ref
(
-
1
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
triggerRef
=
ref
<
HTMLButtonElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
searchInputRef
=
ref
<
HTMLInputElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
optionsListRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
dropdownPosition
=
ref
<
'
bottom
'
|
'
top
'
>
(
'
bottom
'
)
const
triggerRect
=
ref
<
DOMRect
|
null
>
(
null
)
const
triggerRect
=
ref
<
DOMRect
|
null
>
(
null
)
// i18n placeholders
const
placeholderText
=
computed
(()
=>
props
.
placeholder
??
t
(
'
common.selectOption
'
))
const
searchPlaceholderText
=
computed
(()
=>
props
.
searchPlaceholder
??
t
(
'
common.searchPlaceholder
'
))
const
emptyTextDisplay
=
computed
(()
=>
props
.
emptyText
??
t
(
'
common.noOptionsFound
'
))
// Computed style for teleported dropdown
// Computed style for teleported dropdown
const
dropdownStyle
=
computed
(()
=>
{
const
dropdownStyle
=
computed
(()
=>
{
if
(
!
triggerRect
.
value
)
return
{}
if
(
!
triggerRect
.
value
)
return
{}
...
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
...
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
position
:
'
fixed
'
,
position
:
'
fixed
'
,
left
:
`
${
rect
.
left
}
px`
,
left
:
`
${
rect
.
left
}
px`
,
minWidth
:
`
${
rect
.
width
}
px`
,
minWidth
:
`
${
rect
.
width
}
px`
,
zIndex
:
'
100000020
'
// Higher than driver.js overlay (99999998)
zIndex
:
'
100000020
'
}
}
if
(
dropdownPosition
.
value
===
'
top
'
)
{
if
(
dropdownPosition
.
value
===
'
top
'
)
{
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
8
}
px`
style
.
bottom
=
`
${
window
.
innerHeight
-
rect
.
top
+
4
}
px`
}
else
{
}
else
{
style
.
top
=
`
${
rect
.
bottom
+
8
}
px`
style
.
top
=
`
${
rect
.
bottom
+
4
}
px`
}
}
return
style
return
style
})
})
const
getOptionValue
=
(
const
getOptionValue
=
(
option
:
any
):
any
=>
{
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
|
number
|
boolean
|
null
|
undefined
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
option
[
props
.
valueKey
]
as
string
|
number
|
boolean
|
null
|
undefined
return
option
[
props
.
valueKey
]
}
}
return
option
as
string
|
number
|
boolean
|
null
return
option
}
}
const
getOptionLabel
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
string
=>
{
const
getOptionLabel
=
(
option
:
any
):
string
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
String
(
option
[
props
.
labelKey
]
??
''
)
return
String
(
option
[
props
.
labelKey
]
??
''
)
}
}
return
String
(
option
??
''
)
return
String
(
option
??
''
)
}
}
const
isOptionDisabled
=
(
option
:
any
):
boolean
=>
{
if
(
typeof
option
===
'
object
'
&&
option
!==
null
)
{
return
!!
option
.
disabled
}
return
false
}
const
selectedOption
=
computed
(()
=>
{
const
selectedOption
=
computed
(()
=>
{
return
props
.
options
.
find
((
opt
)
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
return
props
.
options
.
find
((
opt
)
=>
getOptionValue
(
opt
)
===
props
.
modelValue
)
||
null
})
})
...
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
...
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
})
})
const
filteredOptions
=
computed
(()
=>
{
const
filteredOptions
=
computed
(()
=>
{
if
(
!
props
.
searchable
||
!
searchQuery
.
value
)
{
let
opts
=
props
.
options
as
any
[]
return
props
.
options
if
(
props
.
searchable
&&
searchQuery
.
value
)
{
const
query
=
searchQuery
.
value
.
toLowerCase
()
opts
=
opts
.
filter
((
opt
)
=>
getOptionLabel
(
opt
).
toLowerCase
().
includes
(
query
))
}
}
const
query
=
searchQuery
.
value
.
toLowerCase
()
return
opts
return
props
.
options
.
filter
((
opt
)
=>
{
const
label
=
getOptionLabel
(
opt
).
toLowerCase
()
return
label
.
includes
(
query
)
})
})
})
const
isSelected
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
):
boolean
=>
{
const
isSelected
=
(
option
:
any
):
boolean
=>
{
return
getOptionValue
(
option
)
===
props
.
modelValue
return
getOptionValue
(
option
)
===
props
.
modelValue
}
}
// Update trigger rect periodically while open to follow scroll/resize
const
updateTriggerRect
=
()
=>
{
if
(
containerRef
.
value
)
{
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
}
}
const
calculateDropdownPosition
=
()
=>
{
const
calculateDropdownPosition
=
()
=>
{
if
(
!
containerRef
.
value
)
return
if
(
!
containerRef
.
value
)
return
updateTriggerRect
()
// Update trigger rect for positioning
triggerRect
.
value
=
containerRef
.
value
.
getBoundingClientRect
()
nextTick
(()
=>
{
nextTick
(()
=>
{
if
(
!
containerRef
.
value
||
!
dropdownRef
.
value
)
return
if
(
!
dropdownRef
.
value
||
!
triggerRect
.
value
)
return
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
const
rect
=
triggerRect
.
value
!
const
spaceBelow
=
window
.
innerHeight
-
triggerRect
.
value
.
bottom
const
dropdownHeight
=
dropdownRef
.
value
.
offsetHeight
||
240
// Max height fallback
const
spaceAbove
=
triggerRect
.
value
.
top
const
viewportHeight
=
window
.
innerHeight
const
spaceBelow
=
viewportHeight
-
rect
.
bottom
const
spaceAbove
=
rect
.
top
// If not enough space below but enough space above, show dropdown on top
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
if
(
spaceBelow
<
dropdownHeight
&&
spaceAbove
>
dropdownHeight
)
{
dropdownPosition
.
value
=
'
top
'
dropdownPosition
.
value
=
'
top
'
}
else
{
}
else
{
...
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
...
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
const
toggle
=
()
=>
{
const
toggle
=
()
=>
{
if
(
props
.
disabled
)
return
if
(
props
.
disabled
)
return
isOpen
.
value
=
!
isOpen
.
value
isOpen
.
value
=
!
isOpen
.
value
if
(
isOpen
.
value
)
{
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
open
)
{
calculateDropdownPosition
()
calculateDropdownPosition
()
// Reset focused index to current selection or first item
const
selectedIdx
=
filteredOptions
.
value
.
findIndex
(
isSelected
)
focusedIndex
.
value
=
selectedIdx
>=
0
?
selectedIdx
:
0
if
(
props
.
searchable
)
{
if
(
props
.
searchable
)
{
nextTick
(()
=>
{
nextTick
(()
=>
searchInputRef
.
value
?.
focus
())
searchInputRef
.
value
?.
focus
()
})
}
}
// Add scroll listener to update position
window
.
addEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
,
passive
:
true
})
window
.
addEventListener
(
'
resize
'
,
calculateDropdownPosition
)
}
else
{
searchQuery
.
value
=
''
focusedIndex
.
value
=
-
1
window
.
removeEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
})
window
.
removeEventListener
(
'
resize
'
,
calculateDropdownPosition
)
}
}
}
}
)
const
selectOption
=
(
option
:
SelectOption
|
Record
<
string
,
unknown
>
)
=>
{
const
selectOption
=
(
option
:
any
)
=>
{
const
value
=
getOptionValue
(
option
)
??
null
const
value
=
getOptionValue
(
option
)
??
null
emit
(
'
update:modelValue
'
,
value
)
emit
(
'
update:modelValue
'
,
value
)
emit
(
'
change
'
,
value
,
option
as
SelectOption
)
emit
(
'
change
'
,
value
,
option
)
isOpen
.
value
=
false
isOpen
.
value
=
false
searchQuery
.
value
=
''
triggerRef
.
value
?.
focus
()
}
}
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
// Keyboards
const
target
=
event
.
target
as
HTMLElement
const
onTriggerKeyDown
=
()
=>
{
if
(
!
isOpen
.
value
)
{
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
isOpen
.
value
=
true
if
(
target
.
closest
(
'
.select-dropdown-portal
'
))
{
return
// 点击在下拉菜单内,不关闭
}
}
}
// 检查是否点击在触发器内
const
onDropdownKeyDown
=
(
e
:
KeyboardEvent
)
=>
{
if
(
containerRef
.
value
&&
containerRef
.
value
.
contains
(
target
))
{
switch
(
e
.
key
)
{
return
// 点击在触发器内,让 toggle 处理
case
'
ArrowDown
'
:
e
.
preventDefault
()
focusedIndex
.
value
=
(
focusedIndex
.
value
+
1
)
%
filteredOptions
.
value
.
length
scrollToFocused
()
break
case
'
ArrowUp
'
:
e
.
preventDefault
()
focusedIndex
.
value
=
(
focusedIndex
.
value
-
1
+
filteredOptions
.
value
.
length
)
%
filteredOptions
.
value
.
length
scrollToFocused
()
break
case
'
Enter
'
:
e
.
preventDefault
()
if
(
focusedIndex
.
value
>=
0
&&
focusedIndex
.
value
<
filteredOptions
.
value
.
length
)
{
const
opt
=
filteredOptions
.
value
[
focusedIndex
.
value
]
if
(
!
isOptionDisabled
(
opt
))
selectOption
(
opt
)
}
break
case
'
Escape
'
:
e
.
preventDefault
()
isOpen
.
value
=
false
triggerRef
.
value
?.
focus
()
break
case
'
Tab
'
:
isOpen
.
value
=
false
break
}
}
}
// 点击在外部,关闭下拉菜单
const
scrollToFocused
=
()
=>
{
isOpen
.
value
=
false
nextTick
(()
=>
{
searchQuery
.
value
=
''
const
list
=
optionsListRef
.
value
if
(
!
list
)
return
const
focusedEl
=
list
.
children
[
focusedIndex
.
value
]
as
HTMLElement
if
(
!
focusedEl
)
return
if
(
focusedEl
.
offsetTop
<
list
.
scrollTop
)
{
list
.
scrollTop
=
focusedEl
.
offsetTop
}
else
if
(
focusedEl
.
offsetTop
+
focusedEl
.
offsetHeight
>
list
.
scrollTop
+
list
.
offsetHeight
)
{
list
.
scrollTop
=
focusedEl
.
offsetTop
+
focusedEl
.
offsetHeight
-
list
.
offsetHeight
}
})
}
}
const
handleEscape
=
(
event
:
KeyboardEvent
)
=>
{
const
handleClickOutside
=
(
event
:
MouseEvent
)
=>
{
if
(
event
.
key
===
'
Escape
'
&&
isOpen
.
value
)
{
const
target
=
event
.
target
as
HTMLElement
// Check if click is inside THIS specific instance's dropdown or trigger
const
isInDropdown
=
!!
target
.
closest
(
`.
${
instanceId
}
`
)
const
isInTrigger
=
containerRef
.
value
?.
contains
(
target
)
if
(
!
isInDropdown
&&
!
isInTrigger
&&
isOpen
.
value
)
{
isOpen
.
value
=
false
isOpen
.
value
=
false
searchQuery
.
value
=
''
}
}
}
}
watch
(
isOpen
,
(
open
)
=>
{
if
(
!
open
)
{
searchQuery
.
value
=
''
}
})
onMounted
(()
=>
{
onMounted
(()
=>
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
keydown
'
,
handleEscape
)
})
})
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
document
.
removeEventListener
(
'
keydown
'
,
handleEscape
)
window
.
removeEventListener
(
'
scroll
'
,
updateTriggerRect
,
{
capture
:
true
})
window
.
removeEventListener
(
'
resize
'
,
calculateDropdownPosition
)
})
})
</
script
>
</
script
>
...
@@ -339,16 +410,14 @@ onUnmounted(() => {
...
@@ -339,16 +410,14 @@ onUnmounted(() => {
}
}
</
style
>
</
style
>
<!-- Global styles for teleported dropdown -->
<
style
>
<
style
>
.select-dropdown-portal
{
.select-dropdown-portal
{
@apply
w-max
max-w-[3
0
0px];
@apply
w-max
min-w-[160px]
max-w-[3
2
0px];
@apply
bg-white
dark
:
bg-dark-800
;
@apply
bg-white
dark
:
bg-dark-800
;
@apply
rounded-xl;
@apply
rounded-xl;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
border
border-gray-200
dark
:
border-dark-700
;
@apply
shadow-lg
shadow-black/10
dark
:
shadow-black
/
30
;
@apply
shadow-lg
shadow-black/10
dark
:
shadow-black
/
30
;
@apply
overflow-hidden;
@apply
overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events
:
auto
!important
;
pointer-events
:
auto
!important
;
}
}
...
@@ -365,7 +434,7 @@ onUnmounted(() => {
...
@@ -365,7 +434,7 @@ onUnmounted(() => {
}
}
.select-dropdown-portal
.select-options
{
.select-dropdown-portal
.select-options
{
@apply
max-h-60
overflow-y-auto
py-1;
@apply
max-h-60
overflow-y-auto
py-1
outline-none
;
}
}
.select-dropdown-portal
.select-option
{
.select-dropdown-portal
.select-option
{
...
@@ -374,7 +443,6 @@ onUnmounted(() => {
...
@@ -374,7 +443,6 @@ onUnmounted(() => {
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
text-gray-700
dark
:
text-gray-300
;
@apply
cursor-pointer
transition-colors
duration-150;
@apply
cursor-pointer
transition-colors
duration-150;
@apply
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
;
@apply
hover
:
bg-gray-50
dark
:
hover
:
bg-dark-700
;
/* 确保选项在引导期间可点击 */
pointer-events
:
auto
!important
;
pointer-events
:
auto
!important
;
}
}
...
@@ -383,6 +451,14 @@ onUnmounted(() => {
...
@@ -383,6 +451,14 @@ onUnmounted(() => {
@apply
text-primary-700
dark
:
text-primary-300
;
@apply
text-primary-700
dark
:
text-primary-300
;
}
}
.select-dropdown-portal
.select-option-focused
{
@apply
bg-gray-100
dark
:
bg-dark-700
;
}
.select-dropdown-portal
.select-option-disabled
{
@apply
cursor-not-allowed
opacity-40;
}
.select-dropdown-portal
.select-option-label
{
.select-dropdown-portal
.select-option-label
{
@apply
flex-1
min-w-0
truncate
text-left;
@apply
flex-1
min-w-0
truncate
text-left;
}
}
...
@@ -392,7 +468,6 @@ onUnmounted(() => {
...
@@ -392,7 +468,6 @@ onUnmounted(() => {
@apply
text-gray-500
dark
:
text-dark-400
;
@apply
text-gray-500
dark
:
text-dark-400
;
}
}
/* Dropdown animation */
.select-dropdown-enter-active
,
.select-dropdown-enter-active
,
.select-dropdown-leave-active
{
.select-dropdown-leave-active
{
transition
:
all
0.2s
ease
;
transition
:
all
0.2s
ease
;
...
@@ -403,4 +478,4 @@ onUnmounted(() => {
...
@@ -403,4 +478,4 @@ onUnmounted(() => {
opacity
:
0
;
opacity
:
0
;
transform
:
translateY
(
-8px
);
transform
:
translateY
(
-8px
);
}
}
</
style
>
</
style
>
\ No newline at end of file
frontend/src/composables/useClipboard.ts
View file @
d36392b7
import
{
ref
}
from
'
vue
'
import
{
ref
}
from
'
vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
i18n
}
from
'
@/i18n
'
const
{
t
}
=
i18n
.
global
/**
/**
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
...
@@ -31,7 +34,7 @@ export function useClipboard() {
...
@@ -31,7 +34,7 @@ export function useClipboard() {
const
copyToClipboard
=
async
(
const
copyToClipboard
=
async
(
text
:
string
,
text
:
string
,
successMessage
=
'
Copied to clipboard
'
successMessage
?:
string
):
Promise
<
boolean
>
=>
{
):
Promise
<
boolean
>
=>
{
if
(
!
text
)
return
false
if
(
!
text
)
return
false
...
@@ -50,12 +53,12 @@ export function useClipboard() {
...
@@ -50,12 +53,12 @@ export function useClipboard() {
if
(
success
)
{
if
(
success
)
{
copied
.
value
=
true
copied
.
value
=
true
appStore
.
showSuccess
(
successMessage
)
appStore
.
showSuccess
(
successMessage
||
t
(
'
common.copiedToClipboard
'
)
)
setTimeout
(()
=>
{
setTimeout
(()
=>
{
copied
.
value
=
false
copied
.
value
=
false
},
2000
)
},
2000
)
}
else
{
}
else
{
appStore
.
showError
(
'
C
opy
f
ailed
'
)
appStore
.
showError
(
t
(
'
common.c
opy
F
ailed
'
)
)
}
}
return
success
return
success
...
...
frontend/src/i18n/locales/en.ts
View file @
d36392b7
...
@@ -145,11 +145,13 @@ export default {
...
@@ -145,11 +145,13 @@ export default {
copiedToClipboard
:
'
Copied to clipboard
'
,
copiedToClipboard
:
'
Copied to clipboard
'
,
copyFailed
:
'
Failed to copy
'
,
copyFailed
:
'
Failed to copy
'
,
contactSupport
:
'
Contact Support
'
,
contactSupport
:
'
Contact Support
'
,
selectOption
:
'
Select an option
'
,
selectOption
:
'
Select an option
'
,
searchPlaceholder
:
'
Search...
'
,
searchPlaceholder
:
'
Search...
'
,
noOptionsFound
:
'
No options found
'
,
noOptionsFound
:
'
No options found
'
,
saving
:
'
Saving...
'
,
noGroupsAvailable
:
'
No groups available
'
,
refresh
:
'
Refresh
'
,
unknownError
:
'
Unknown error occurred
'
,
saving
:
'
Saving...
'
,
selectedCount
:
'
({count} selected)
'
,
refresh
:
'
Refresh
'
,
notAvailable
:
'
N/A
'
,
notAvailable
:
'
N/A
'
,
now
:
'
Now
'
,
now
:
'
Now
'
,
unknown
:
'
Unknown
'
,
unknown
:
'
Unknown
'
,
...
@@ -687,6 +689,10 @@ export default {
...
@@ -687,6 +689,10 @@ export default {
failedToWithdraw
:
'
Failed to withdraw
'
,
failedToWithdraw
:
'
Failed to withdraw
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
insufficientBalance
:
'
Insufficient balance, balance cannot be negative after withdrawal
'
,
insufficientBalance
:
'
Insufficient balance, balance cannot be negative after withdrawal
'
,
roles
:
{
admin
:
'
Admin
'
,
user
:
'
User
'
},
// Settings Dropdowns
// Settings Dropdowns
filterSettings
:
'
Filter Settings
'
,
filterSettings
:
'
Filter Settings
'
,
columnSettings
:
'
Column Settings
'
,
columnSettings
:
'
Column Settings
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
d36392b7
...
@@ -145,7 +145,10 @@ export default {
...
@@ -145,7 +145,10 @@ export default {
selectOption
:
'
请选择
'
,
selectOption
:
'
请选择
'
,
searchPlaceholder
:
'
搜索...
'
,
searchPlaceholder
:
'
搜索...
'
,
noOptionsFound
:
'
无匹配选项
'
,
noOptionsFound
:
'
无匹配选项
'
,
noGroupsAvailable
:
'
无可用分组
'
,
unknownError
:
'
发生未知错误
'
,
saving
:
'
保存中...
'
,
saving
:
'
保存中...
'
,
selectedCount
:
'
(已选 {count} 个)
'
,
refresh
:
'
刷新
'
,
refresh
:
'
刷新
'
,
notAvailable
:
'
不可用
'
,
notAvailable
:
'
不可用
'
,
now
:
'
现在
'
,
now
:
'
现在
'
,
...
@@ -679,10 +682,6 @@ export default {
...
@@ -679,10 +682,6 @@ export default {
admin
:
'
管理员
'
,
admin
:
'
管理员
'
,
user
:
'
用户
'
user
:
'
用户
'
},
},
statuses
:
{
active
:
'
正常
'
,
banned
:
'
禁用
'
},
form
:
{
form
:
{
emailLabel
:
'
邮箱
'
,
emailLabel
:
'
邮箱
'
,
emailPlaceholder
:
'
请输入邮箱
'
,
emailPlaceholder
:
'
请输入邮箱
'
,
...
...
frontend/src/utils/format.ts
View file @
d36392b7
...
@@ -3,7 +3,7 @@
...
@@ -3,7 +3,7 @@
* 参考 CRS 项目的 format.js 实现
* 参考 CRS 项目的 format.js 实现
*/
*/
import
{
i18n
}
from
'
@/i18n
'
import
{
i18n
,
getLocale
}
from
'
@/i18n
'
/**
/**
* 格式化相对时间
* 格式化相对时间
...
@@ -39,33 +39,39 @@ export function formatRelativeTime(date: string | Date | null | undefined): stri
...
@@ -39,33 +39,39 @@ export function formatRelativeTime(date: string | Date | null | undefined): stri
export
function
formatNumber
(
num
:
number
|
null
|
undefined
):
string
{
export
function
formatNumber
(
num
:
number
|
null
|
undefined
):
string
{
if
(
num
===
null
||
num
===
undefined
)
return
'
0
'
if
(
num
===
null
||
num
===
undefined
)
return
'
0
'
const
locale
=
getLocale
()
const
absNum
=
Math
.
abs
(
num
)
const
absNum
=
Math
.
abs
(
num
)
if
(
absNum
>=
1
e9
)
{
// Use Intl.NumberFormat for compact notation if supported and needed
return
(
num
/
1
e9
).
toFixed
(
2
)
+
'
B
'
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
}
else
if
(
absNum
>=
1
e6
)
{
const
formatter
=
new
Intl
.
NumberFormat
(
locale
,
{
return
(
num
/
1
e6
).
toFixed
(
2
)
+
'
M
'
notation
:
absNum
>=
10000
?
'
compact
'
:
'
standard
'
,
}
else
if
(
absNum
>=
1
e3
)
{
maximumFractionDigits
:
1
return
(
num
/
1
e3
).
toFixed
(
1
)
+
'
K
'
})
}
return
num
.
toLocaleString
(
)
return
formatter
.
format
(
num
)
}
}
/**
/**
* 格式化货币金额
* 格式化货币金额
* @param amount 金额
* @param amount 金额
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
* @param currency 货币代码,默认 USD
* @returns 格式化后的字符串,如 "$1.25"
*/
*/
export
function
formatCurrency
(
amount
:
number
|
null
|
undefined
):
string
{
export
function
formatCurrency
(
amount
:
number
|
null
|
undefined
,
currency
:
string
=
'
USD
'
):
string
{
if
(
amount
===
null
||
amount
===
undefined
)
return
'
$0.00
'
if
(
amount
===
null
||
amount
===
undefined
)
return
'
$0.00
'
// 小于 0.01 时显示更多小数位
const
locale
=
getLocale
()
if
(
amount
>
0
&&
amount
<
0.01
)
{
return
'
$
'
+
amount
.
toFixed
(
6
)
// For very small amounts, show more decimals
}
const
fractionDigits
=
amount
>
0
&&
amount
<
0.01
?
6
:
2
return
'
$
'
+
amount
.
toFixed
(
2
)
return
new
Intl
.
NumberFormat
(
locale
,
{
style
:
'
currency
'
,
currency
:
currency
,
minimumFractionDigits
:
fractionDigits
,
maximumFractionDigits
:
fractionDigits
}).
format
(
amount
)
}
}
/**
/**
...
@@ -89,57 +95,61 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
...
@@ -89,57 +95,61 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
/**
/**
* 格式化日期
* 格式化日期
* @param date 日期字符串或 Date 对象
* @param date 日期字符串或 Date 对象
* @param
format 格式字符串,支持 YYYY, MM, DD, HH, mm, s
s
* @param
options Intl.DateTimeFormatOption
s
* @returns 格式化后的日期字符串
* @returns 格式化后的日期字符串
*/
*/
export
function
formatDate
(
export
function
formatDate
(
date
:
string
|
Date
|
null
|
undefined
,
date
:
string
|
Date
|
null
|
undefined
,
format
:
string
=
'
YYYY-MM-DD HH:mm:ss
'
options
:
Intl
.
DateTimeFormatOptions
=
{
year
:
'
numeric
'
,
month
:
'
2-digit
'
,
day
:
'
2-digit
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
,
second
:
'
2-digit
'
,
hour12
:
false
}
):
string
{
):
string
{
if
(
!
date
)
return
''
if
(
!
date
)
return
''
const
d
=
new
Date
(
date
)
const
d
=
new
Date
(
date
)
if
(
isNaN
(
d
.
getTime
()))
return
''
if
(
isNaN
(
d
.
getTime
()))
return
''
const
year
=
d
.
getFullYear
()
const
locale
=
getLocale
()
const
month
=
String
(
d
.
getMonth
()
+
1
).
padStart
(
2
,
'
0
'
)
return
new
Intl
.
DateTimeFormat
(
locale
,
options
).
format
(
d
)
const
day
=
String
(
d
.
getDate
()).
padStart
(
2
,
'
0
'
)
const
hours
=
String
(
d
.
getHours
()).
padStart
(
2
,
'
0
'
)
const
minutes
=
String
(
d
.
getMinutes
()).
padStart
(
2
,
'
0
'
)
const
seconds
=
String
(
d
.
getSeconds
()).
padStart
(
2
,
'
0
'
)
return
format
.
replace
(
'
YYYY
'
,
String
(
year
))
.
replace
(
'
MM
'
,
month
)
.
replace
(
'
DD
'
,
day
)
.
replace
(
'
HH
'
,
hours
)
.
replace
(
'
mm
'
,
minutes
)
.
replace
(
'
ss
'
,
seconds
)
}
}
/**
/**
* 格式化日期(只显示日期部分)
* 格式化日期(只显示日期部分)
* @param date 日期字符串或 Date 对象
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期字符串
,格式为 YYYY-MM-DD
* @returns 格式化后的日期字符串
*/
*/
export
function
formatDateOnly
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
export
function
formatDateOnly
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD
'
)
return
formatDate
(
date
,
{
year
:
'
numeric
'
,
month
:
'
2-digit
'
,
day
:
'
2-digit
'
})
}
}
/**
/**
* 格式化日期时间(完整格式)
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期时间字符串
,格式为 YYYY-MM-DD HH:mm:ss
* @returns 格式化后的日期时间字符串
*/
*/
export
function
formatDateTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
export
function
formatDateTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
YYYY-MM-DD HH:mm:ss
'
)
return
formatDate
(
date
)
}
}
/**
/**
* 格式化时间(只显示时分)
* 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象
* @param date 日期字符串或 Date 对象
* @returns 格式化后的时间字符串
,格式为 HH:mm
* @returns 格式化后的时间字符串
*/
*/
export
function
formatTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
export
function
formatTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
return
formatDate
(
date
,
'
HH:mm
'
)
return
formatDate
(
date
,
{
}
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
,
hour12
:
false
})
}
\ No newline at end of file
frontend/src/views/admin/DashboardView.vue
View file @
d36392b7
...
@@ -652,16 +652,4 @@ onMounted(() => {
...
@@ -652,16 +652,4 @@ onMounted(() => {
</
script
>
</
script
>
<
style
scoped
>
<
style
scoped
>
/* Compact Select styling for dashboard */
:deep
(
.select-trigger
)
{
@apply
rounded-lg
px-3
py-1.5
text-sm;
}
:deep
(
.select-dropdown
)
{
@apply
rounded-lg;
}
:deep
(
.select-option
)
{
@apply
px-3
py-2
text-sm;
}
</
style
>
</
style
>
frontend/src/views/admin/UsersView.vue
View file @
d36392b7
...
@@ -381,7 +381,7 @@
...
@@ -381,7 +381,7 @@
]"
]"
></span>
></span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
value
===
'
active
'
?
t
(
'
common.
active
'
)
:
t
(
'
admin.users.disabled
'
)
}}
{{
t
(
'
admin.accounts.status.
'
+
(
value
===
'
disabled
'
?
'
in
active
'
:
value
)
)
}}
</span>
</span>
</div>
</div>
</
template
>
</
template
>
...
...
frontend/src/views/user/DashboardView.vue
View file @
d36392b7
...
@@ -1052,16 +1052,4 @@ watch(isDarkMode, () => {
...
@@ -1052,16 +1052,4 @@ watch(isDarkMode, () => {
</
script
>
</
script
>
<
style
scoped
>
<
style
scoped
>
/* Compact Select styling for dashboard */
:deep
(
.select-trigger
)
{
@apply
rounded-lg
px-3
py-1.5
text-sm;
}
:deep
(
.select-dropdown
)
{
@apply
rounded-lg;
}
:deep
(
.select-option
)
{
@apply
px-3
py-2
text-sm;
}
</
style
>
</
style
>
frontend/src/views/user/KeysView.vue
View file @
d36392b7
...
@@ -141,7 +141,7 @@
...
@@ -141,7 +141,7 @@
<
template
#cell-status=
"{ value }"
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
{{
value
}}
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
</span>
</span>
</
template
>
</
template
>
...
@@ -501,7 +501,8 @@
...
@@ -501,7 +501,8 @@
<div
<div
v-if=
"groupSelectorKeyId !== null && dropdownPosition"
v-if=
"groupSelectorKeyId !== null && dropdownPosition"
ref=
"dropdownRef"
ref=
"dropdownRef"
class=
"animate-in fade-in slide-in-from-top-2 fixed z-[9999] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
class=
"animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
style=
"pointer-events: auto !important;"
:style=
"{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
:style=
"{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
>
<div
class=
"max-h-64 overflow-y-auto p-1.5"
>
<div
class=
"max-h-64 overflow-y-auto p-1.5"
>
...
...
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