fix(frontend): comprehensive i18n cleanup and Select component hardening
This commit is contained in:
@@ -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) {
|
|||||||
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) {
|
|||||||
// 选择支持该模型的账号
|
// 选择支持该模型的账号
|
||||||
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,
|
|||||||
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": errType,
|
"type": normalizedType,
|
||||||
"message": message,
|
"message": publicMessage,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
jsonBytes, err := json.Marshal(errorData)
|
jsonBytes, err := json.Marshal(errorData)
|
||||||
@@ -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
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normal case: return JSON response with proper status code
|
// Normal case: return JSON response with proper status code
|
||||||
h.errorResponse(c, status, errType, message)
|
h.errorResponse(c, status, normalizedType, publicMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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": errType,
|
"type": normalizedType,
|
||||||
"message": message,
|
"message": publicMessage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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) {
|
|||||||
// 选择支持该模型的账号
|
// 选择支持该模型的账号
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
|||||||
|
|
||||||
// 重试循环
|
// 重试循环
|
||||||
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
|
|||||||
// 发送请求
|
// 发送请求
|
||||||
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
|
|||||||
_ = 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
|
|||||||
|
|
||||||
// 检查是否需要通用重试(排除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
|
|||||||
}
|
}
|
||||||
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() }()
|
||||||
|
|
||||||
// 处理重试耗尽的情况
|
// 处理重试耗尽的情况
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<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 @@
|
|||||||
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 groups available
|
{{ t('common.noGroupsAvailable') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
<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 @@
|
|||||||
</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 @@
|
|||||||
</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'
|
|||||||
|
|
||||||
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>(), {
|
|||||||
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(() => {
|
|||||||
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(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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 spaceBelow = window.innerHeight - triggerRect.value.bottom
|
||||||
|
const spaceAbove = triggerRect.value.top
|
||||||
|
|
||||||
const rect = triggerRect.value!
|
|
||||||
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
|
|
||||||
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 = () => {
|
|||||||
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: any) => {
|
||||||
|
const value = getOptionValue(option) ?? null
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
emit('change', value, option)
|
||||||
|
isOpen.value = false
|
||||||
|
triggerRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboards
|
||||||
|
const onTriggerKeyDown = () => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
isOpen.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (option: SelectOption | Record<string, unknown>) => {
|
const onDropdownKeyDown = (e: KeyboardEvent) => {
|
||||||
const value = getOptionValue(option) ?? null
|
switch (e.key) {
|
||||||
emit('update:modelValue', value)
|
case 'ArrowDown':
|
||||||
emit('change', value, option as SelectOption)
|
e.preventDefault()
|
||||||
isOpen.value = false
|
focusedIndex.value = (focusedIndex.value + 1) % filteredOptions.value.length
|
||||||
searchQuery.value = ''
|
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 = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
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 handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement
|
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)
|
||||||
|
|
||||||
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
|
if (!isInDropdown && !isInTrigger && isOpen.value) {
|
||||||
if (target.closest('.select-dropdown-portal')) {
|
|
||||||
return // 点击在下拉菜单内,不关闭
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否点击在触发器内
|
|
||||||
if (containerRef.value && containerRef.value.contains(target)) {
|
|
||||||
return // 点击在触发器内,让 toggle 处理
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击在外部,关闭下拉菜单
|
|
||||||
isOpen.value = false
|
|
||||||
searchQuery.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape' && 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(() => {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Global styles for teleported dropdown -->
|
|
||||||
<style>
|
<style>
|
||||||
.select-dropdown-portal {
|
.select-dropdown-portal {
|
||||||
@apply w-max max-w-[300px];
|
@apply w-max min-w-[160px] max-w-[320px];
|
||||||
@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(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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(() => {
|
|||||||
@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(() => {
|
|||||||
@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(() => {
|
|||||||
@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(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
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() {
|
|||||||
|
|
||||||
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() {
|
|||||||
|
|
||||||
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('Copy failed')
|
appStore.showError(t('common.copyFailed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|||||||
@@ -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 {
|
|||||||
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',
|
||||||
|
|||||||
@@ -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 {
|
|||||||
admin: '管理员',
|
admin: '管理员',
|
||||||
user: '用户'
|
user: '用户'
|
||||||
},
|
},
|
||||||
statuses: {
|
|
||||||
active: '正常',
|
|
||||||
banned: '禁用'
|
|
||||||
},
|
|
||||||
form: {
|
form: {
|
||||||
emailLabel: '邮箱',
|
emailLabel: '邮箱',
|
||||||
emailPlaceholder: '请输入邮箱',
|
emailPlaceholder: '请输入邮箱',
|
||||||
|
|||||||
@@ -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
|
|||||||
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 >= 1e9) {
|
// Use Intl.NumberFormat for compact notation if supported and needed
|
||||||
return (num / 1e9).toFixed(2) + 'B'
|
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
|
||||||
} else if (absNum >= 1e6) {
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
return (num / 1e6).toFixed(2) + 'M'
|
notation: absNum >= 10000 ? 'compact' : 'standard',
|
||||||
} else if (absNum >= 1e3) {
|
maximumFractionDigits: 1
|
||||||
return (num / 1e3).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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return '$' + amount.toFixed(2)
|
// For very small amounts, show more decimals
|
||||||
|
const fractionDigits = amount > 0 && amount < 0.01 ? 6 : 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 {
|
|||||||
/**
|
/**
|
||||||
* 格式化日期
|
* 格式化日期
|
||||||
* @param date 日期字符串或 Date 对象
|
* @param date 日期字符串或 Date 对象
|
||||||
* @param format 格式字符串,支持 YYYY, MM, DD, HH, mm, ss
|
* @param options Intl.DateTimeFormatOptions
|
||||||
* @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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' ? 'inactive' : value)) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user