Commit b5a3b3db authored by yangjianbo's avatar yangjianbo
Browse files

Merge branch 'test' into release

parents 888f2936 9cafa46d
...@@ -3,10 +3,15 @@ package service ...@@ -3,10 +3,15 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -133,6 +138,38 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) { ...@@ -133,6 +138,38 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) {
} }
} }
func TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLogs(t *testing.T) {
gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
svc := &GeminiMessagesCompatService{
cfg: &config.Config{
Gateway: config.GatewayConfig{
GeminiDebugResponseHeaders: false,
},
},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-RateLimit-Limit": []string{"60"},
},
Body: io.NopCloser(strings.NewReader(`{"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2}}`)),
}
usage, err := svc.handleNativeNonStreamingResponse(c, resp, false)
require.NoError(t, err)
require.NotNil(t, usage)
require.False(t, logSink.ContainsMessage("[GeminiAPI]"), "debug 关闭时不应输出 Gemini 响应头日志")
}
func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse(t *testing.T) { func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse(t *testing.T) {
claudeReq := map[string]any{ claudeReq := map[string]any{
"model": "claude-haiku-4-5-20251001", "model": "claude-haiku-4-5-20251001",
......
...@@ -313,7 +313,6 @@ func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Acco ...@@ -313,7 +313,6 @@ func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Acco
} }
log := logger.FromContext(ctx).With(fields...) log := logger.FromContext(ctx).With(fields...)
if result.Matched { if result.Matched {
log.Warn("OpenAI codex_cli_only 允许官方客户端请求")
return return
} }
log.Warn("OpenAI codex_cli_only 拒绝非官方客户端请求") log.Warn("OpenAI codex_cli_only 拒绝非官方客户端请求")
...@@ -1277,6 +1276,29 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ...@@ -1277,6 +1276,29 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
startTime time.Time, startTime time.Time,
) (*OpenAIForwardResult, error) { ) (*OpenAIForwardResult, error) {
if account != nil && account.Type == AccountTypeOAuth { if account != nil && account.Type == AccountTypeOAuth {
if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
setOpsUpstreamError(c, http.StatusForbidden, rejectMsg, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: http.StatusForbidden,
Passthrough: true,
Kind: "request_error",
Message: rejectMsg,
Detail: rejectReason,
})
logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body)
c.JSON(http.StatusForbidden, gin.H{
"error": gin.H{
"type": "forbidden_error",
"message": rejectMsg,
},
})
return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
}
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body) normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body)
if err != nil { if err != nil {
return nil, err return nil, err
...@@ -1396,6 +1418,37 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( ...@@ -1396,6 +1418,37 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}, nil }, nil
} }
func logOpenAIPassthroughInstructionsRejected(
ctx context.Context,
c *gin.Context,
account *Account,
reqModel string,
rejectReason string,
body []byte,
) {
if ctx == nil {
ctx = context.Background()
}
accountID := int64(0)
accountName := ""
accountType := ""
if account != nil {
accountID = account.ID
accountName = strings.TrimSpace(account.Name)
accountType = strings.TrimSpace(string(account.Type))
}
fields := []zap.Field{
zap.String("component", "service.openai_gateway"),
zap.Int64("account_id", accountID),
zap.String("account_name", accountName),
zap.String("account_type", accountType),
zap.String("request_model", strings.TrimSpace(reqModel)),
zap.String("reject_reason", strings.TrimSpace(rejectReason)),
}
fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
logger.FromContext(ctx).With(fields...).Warn("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")
}
func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
ctx context.Context, ctx context.Context,
c *gin.Context, c *gin.Context,
...@@ -1688,8 +1741,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( ...@@ -1688,8 +1741,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
resp *http.Response, resp *http.Response,
c *gin.Context, c *gin.Context,
) (*OpenAIUsage, error) { ) (*OpenAIUsage, error) {
body, err := io.ReadAll(resp.Body) maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
if err != nil { if err != nil {
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"type": "upstream_error",
"message": "Upstream response too large",
},
})
}
return nil, err return nil, err
} }
...@@ -2318,8 +2381,18 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) { ...@@ -2318,8 +2381,18 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
} }
func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*OpenAIUsage, error) { func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*OpenAIUsage, error) {
body, err := io.ReadAll(resp.Body) maxBytes := resolveUpstreamResponseReadLimit(s.cfg)
body, err := readUpstreamResponseBodyLimited(resp.Body, maxBytes)
if err != nil { if err != nil {
if errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
setOpsUpstreamError(c, http.StatusBadGateway, "upstream response too large", "")
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"type": "upstream_error",
"message": "Upstream response too large",
},
})
}
return nil, err return nil, err
} }
...@@ -2877,6 +2950,25 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte) ([]byte, bool, error) { ...@@ -2877,6 +2950,25 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte) ([]byte, bool, error) {
return normalized, changed, nil return normalized, changed, nil
} }
func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string {
model := strings.ToLower(strings.TrimSpace(reqModel))
if !strings.Contains(model, "codex") {
return ""
}
instructions := gjson.GetBytes(body, "instructions")
if !instructions.Exists() {
return "instructions_missing"
}
if instructions.Type != gjson.String {
return "instructions_not_string"
}
if strings.TrimSpace(instructions.String()) == "" {
return "instructions_empty"
}
return ""
}
func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string { func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string {
reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
if reasoningEffort == "" { if reasoningEffort == "" {
......
...@@ -103,7 +103,7 @@ func TestLogCodexCLIOnlyDetection_NilSafety(t *testing.T) { ...@@ -103,7 +103,7 @@ func TestLogCodexCLIOnlyDetection_NilSafety(t *testing.T) {
}) })
} }
func TestLogCodexCLIOnlyDetection_LogsBothMatchedAndRejected(t *testing.T) { func TestLogCodexCLIOnlyDetection_OnlyLogsRejected(t *testing.T) {
logSink, restore := captureStructuredLog(t) logSink, restore := captureStructuredLog(t)
defer restore() defer restore()
...@@ -119,7 +119,7 @@ func TestLogCodexCLIOnlyDetection_LogsBothMatchedAndRejected(t *testing.T) { ...@@ -119,7 +119,7 @@ func TestLogCodexCLIOnlyDetection_LogsBothMatchedAndRejected(t *testing.T) {
Reason: CodexClientRestrictionReasonNotMatchedUA, Reason: CodexClientRestrictionReasonNotMatchedUA,
}, nil) }, nil)
require.True(t, logSink.ContainsMessage("OpenAI codex_cli_only 允许官方客户端请求")) require.False(t, logSink.ContainsMessage("OpenAI codex_cli_only 允许官方客户端请求"))
require.True(t, logSink.ContainsMessage("OpenAI codex_cli_only 拒绝非官方客户端请求")) require.True(t, logSink.ContainsMessage("OpenAI codex_cli_only 拒绝非官方客户端请求"))
} }
...@@ -131,7 +131,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) { ...@@ -131,7 +131,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec) c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil)) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "curl/8.0") c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("OpenAI-Beta", "assistants=v2") c.Request.Header.Set("OpenAI-Beta", "assistants=v2")
...@@ -143,7 +143,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) { ...@@ -143,7 +143,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
Reason: CodexClientRestrictionReasonNotMatchedUA, Reason: CodexClientRestrictionReasonNotMatchedUA,
}, body) }, body)
require.True(t, logSink.ContainsFieldValue("request_user_agent", "curl/8.0")) require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.2")) require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.2"))
require.True(t, logSink.ContainsFieldValue("request_query", "trace=1")) require.True(t, logSink.ContainsFieldValue("request_query", "trace=1"))
require.True(t, logSink.ContainsFieldValue("request_prompt_cache_key_sha256", hashSensitiveValueForLog("pc-123"))) require.True(t, logSink.ContainsFieldValue("request_prompt_cache_key_sha256", hashSensitiveValueForLog("pc-123")))
......
...@@ -164,7 +164,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali ...@@ -164,7 +164,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
c.Request.Header.Set("Proxy-Authorization", "Basic abc") c.Request.Header.Set("Proxy-Authorization", "Basic abc")
c.Request.Header.Set("X-Test", "keep") c.Request.Header.Set("X-Test", "keep")
originalBody := []byte(`{"model":"gpt-5.2","stream":true,"store":true,"input":[{"type":"text","text":"hi"}]}`) originalBody := []byte(`{"model":"gpt-5.2","stream":true,"store":true,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`)
upstreamSSE := strings.Join([]string{ upstreamSSE := strings.Join([]string{
`data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`, `data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`,
...@@ -211,6 +211,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali ...@@ -211,6 +211,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
// 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。 // 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。
require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool()) require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool())
require.Equal(t, "local-test-instructions", strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String()))
// 其余关键字段保持原值。 // 其余关键字段保持原值。
require.Equal(t, "gpt-5.2", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "gpt-5.2", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "hi", gjson.GetBytes(upstream.lastBody, "input.0.text").String()) require.Equal(t, "hi", gjson.GetBytes(upstream.lastBody, "input.0.text").String())
...@@ -235,6 +236,59 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali ...@@ -235,6 +236,59 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
require.NotContains(t, body, "\"name\":\"edit\"") require.NotContains(t, body, "\"name\":\"edit\"")
} }
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("OpenAI-Beta", "responses=experimental")
// Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。
originalBody := []byte(`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)
upstream := &httpUpstreamRecorder{
resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}},
Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
},
}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: upstream,
}
account := &Account{
ID: 123,
Name: "acc",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true},
Status: StatusActive,
Schedulable: true,
RateMultiplier: f64p(1),
}
result, err := svc.Forward(context.Background(), c, account, originalBody)
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, http.StatusForbidden, rec.Code)
require.Contains(t, rec.Body.String(), "requires a non-empty instructions field")
require.Nil(t, upstream.lastReq)
require.True(t, logSink.ContainsMessage("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions"))
require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
require.True(t, logSink.ContainsFieldValue("reject_reason", "instructions_missing"))
}
func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) { func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
......
package service
import (
"errors"
"fmt"
"io"
"github.com/Wei-Shaw/sub2api/internal/config"
)
var ErrUpstreamResponseBodyTooLarge = errors.New("upstream response body too large")
const defaultUpstreamResponseReadMaxBytes int64 = 8 * 1024 * 1024
func resolveUpstreamResponseReadLimit(cfg *config.Config) int64 {
if cfg != nil && cfg.Gateway.UpstreamResponseReadMaxBytes > 0 {
return cfg.Gateway.UpstreamResponseReadMaxBytes
}
return defaultUpstreamResponseReadMaxBytes
}
func readUpstreamResponseBodyLimited(reader io.Reader, maxBytes int64) ([]byte, error) {
if reader == nil {
return nil, errors.New("response body is nil")
}
if maxBytes <= 0 {
maxBytes = defaultUpstreamResponseReadMaxBytes
}
body, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("%w: limit=%d", ErrUpstreamResponseBodyTooLarge, maxBytes)
}
return body, nil
}
package service
import (
"bytes"
"errors"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func TestResolveUpstreamResponseReadLimit(t *testing.T) {
t.Run("use default when config missing", func(t *testing.T) {
require.Equal(t, defaultUpstreamResponseReadMaxBytes, resolveUpstreamResponseReadLimit(nil))
})
t.Run("use configured value", func(t *testing.T) {
cfg := &config.Config{}
cfg.Gateway.UpstreamResponseReadMaxBytes = 1234
require.Equal(t, int64(1234), resolveUpstreamResponseReadLimit(cfg))
})
}
func TestReadUpstreamResponseBodyLimited(t *testing.T) {
t.Run("within limit", func(t *testing.T) {
body, err := readUpstreamResponseBodyLimited(bytes.NewReader([]byte("ok")), 2)
require.NoError(t, err)
require.Equal(t, []byte("ok"), body)
})
t.Run("exceeds limit", func(t *testing.T) {
body, err := readUpstreamResponseBodyLimited(bytes.NewReader([]byte("toolong")), 3)
require.Nil(t, body)
require.Error(t, err)
require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
})
}
...@@ -146,6 +146,15 @@ gateway: ...@@ -146,6 +146,15 @@ gateway:
# Max request body size in bytes (default: 100MB) # Max request body size in bytes (default: 100MB)
# 请求体最大字节数(默认 100MB) # 请求体最大字节数(默认 100MB)
max_body_size: 104857600 max_body_size: 104857600
# Max bytes to read for non-stream upstream responses (default: 8MB)
# 非流式上游响应体读取上限(默认 8MB)
upstream_response_read_max_bytes: 8388608
# Max bytes to read for proxy probe responses (default: 1MB)
# 代理探测响应体读取上限(默认 1MB)
proxy_probe_response_read_max_bytes: 1048576
# Enable Gemini upstream response header debug logs (default: false)
# 是否开启 Gemini 上游响应头调试日志(默认 false)
gemini_debug_response_headers: false
# Sora max request body size in bytes (0=use max_body_size) # Sora max request body size in bytes (0=use max_body_size)
# Sora 请求体最大字节数(0=使用 max_body_size) # Sora 请求体最大字节数(0=使用 max_body_size)
sora_max_body_size: 268435456 sora_max_body_size: 268435456
......
...@@ -39,16 +39,6 @@ watch( ...@@ -39,16 +39,6 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(
() => appStore.siteName,
(newName) => {
if (newName) {
document.title = `${newName} - AI API Gateway`
}
},
{ immediate: true }
)
// Watch for authentication state and manage subscription data // Watch for authentication state and manage subscription data
watch( watch(
() => authStore.isAuthenticated, () => authStore.isAuthenticated,
......
...@@ -58,12 +58,16 @@ describe('ImportDataModal', () => { ...@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
const input = wrapper.find('input[type="file"]') const input = wrapper.find('input[type="file"]')
const file = new File(['invalid json'], 'data.json', { type: 'application/json' }) const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
Object.defineProperty(file, 'text', {
value: () => Promise.resolve('invalid json')
})
Object.defineProperty(input.element, 'files', { Object.defineProperty(input.element, 'files', {
value: [file] value: [file]
}) })
await input.trigger('change') await input.trigger('change')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await Promise.resolve()
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed') expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
}) })
......
...@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => { ...@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
const input = wrapper.find('input[type="file"]') const input = wrapper.find('input[type="file"]')
const file = new File(['invalid json'], 'data.json', { type: 'application/json' }) const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
Object.defineProperty(file, 'text', {
value: () => Promise.resolve('invalid json')
})
Object.defineProperty(input.element, 'files', { Object.defineProperty(input.element, 'files', {
value: [file] value: [file]
}) })
await input.trigger('change') await input.trigger('change')
await wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
await Promise.resolve()
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed') expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
}) })
......
...@@ -164,10 +164,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> { ...@@ -164,10 +164,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
/** /**
* Clear account rate limit status * Clear account rate limit status
* @param id - Account ID * @param id - Account ID
* @returns Success confirmation * @returns Updated account
*/ */
export async function clearRateLimit(id: number): Promise<{ message: string }> { export async function clearRateLimit(id: number): Promise<Account> {
const { data } = await apiClient.post<{ message: string }>( const { data } = await apiClient.post<Account>(
`/admin/accounts/${id}/clear-rate-limit` `/admin/accounts/${id}/clear-rate-limit`
) )
return data return data
......
...@@ -209,7 +209,7 @@ ...@@ -209,7 +209,7 @@
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2"> <div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="getModelMappingKey(mapping)"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
...@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue' ...@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
interface Props { interface Props {
show: boolean show: boolean
...@@ -695,6 +696,7 @@ const baseUrl = ref('') ...@@ -695,6 +696,7 @@ const baseUrl = ref('')
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('bulk-model-mapping')
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
......
...@@ -714,7 +714,7 @@ ...@@ -714,7 +714,7 @@
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2"> <div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in antigravityModelMappings" v-for="(mapping, index) in antigravityModelMappings"
:key="index" :key="getAntigravityModelMappingKey(mapping)"
class="space-y-1" class="space-y-1"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
...@@ -966,7 +966,7 @@ ...@@ -966,7 +966,7 @@
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2"> <div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="getModelMappingKey(mapping)"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
...@@ -1225,7 +1225,7 @@ ...@@ -1225,7 +1225,7 @@
<div v-if="tempUnschedRules.length > 0" class="space-y-3"> <div v-if="tempUnschedRules.length > 0" class="space-y-3">
<div <div
v-for="(rule, index) in tempUnschedRules" v-for="(rule, index) in tempUnschedRules"
:key="index" :key="getTempUnschedRuleKey(rule)"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600" class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
> >
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
...@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue' ...@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component
...@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([]) ...@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity')) const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
......
...@@ -169,7 +169,7 @@ ...@@ -169,7 +169,7 @@
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2"> <div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in modelMappings" v-for="(mapping, index) in modelMappings"
:key="index" :key="getModelMappingKey(mapping)"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
...@@ -417,7 +417,7 @@ ...@@ -417,7 +417,7 @@
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2"> <div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
<div <div
v-for="(mapping, index) in antigravityModelMappings" v-for="(mapping, index) in antigravityModelMappings"
:key="index" :key="getAntigravityModelMappingKey(mapping)"
class="space-y-1" class="space-y-1"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
...@@ -542,7 +542,7 @@ ...@@ -542,7 +542,7 @@
<div v-if="tempUnschedRules.length > 0" class="space-y-3"> <div v-if="tempUnschedRules.length > 0" class="space-y-3">
<div <div
v-for="(rule, index) in tempUnschedRules" v-for="(rule, index) in tempUnschedRules"
:key="index" :key="getTempUnschedRuleKey(rule)"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600" class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
> >
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
...@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue' ...@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { import {
getPresetMappingsByPlatform, getPresetMappingsByPlatform,
commonErrorCodes, commonErrorCodes,
...@@ -1110,7 +1111,7 @@ interface Props { ...@@ -1110,7 +1111,7 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
updated: [] updated: [account: Account]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
...@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([]) ...@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
const antigravityModelMappings = ref<ModelMapping[]>([]) const antigravityModelMappings = ref<ModelMapping[]>([])
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
// Mixed channel warning dialog state // Mixed channel warning dialog state
const showMixedChannelWarning = ref(false) const showMixedChannelWarning = ref(false)
...@@ -1845,9 +1849,9 @@ const handleSubmit = async () => { ...@@ -1845,9 +1849,9 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
await adminAPI.accounts.update(props.account.id, updatePayload) const updatedAccount = await adminAPI.accounts.update(props.account.id, updatePayload)
appStore.showSuccess(t('admin.accounts.accountUpdated')) appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated') emit('updated', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
// Handle 409 mixed_channel_warning - show confirmation dialog // Handle 409 mixed_channel_warning - show confirmation dialog
...@@ -1875,9 +1879,9 @@ const handleMixedChannelConfirm = async () => { ...@@ -1875,9 +1879,9 @@ const handleMixedChannelConfirm = async () => {
pendingUpdatePayload.value.confirm_mixed_channel_risk = true pendingUpdatePayload.value.confirm_mixed_channel_risk = true
submitting.value = true submitting.value = true
try { try {
await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value) const updatedAccount = await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value)
appStore.showSuccess(t('admin.accounts.accountUpdated')) appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated') emit('updated', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
......
...@@ -143,6 +143,24 @@ const handleClose = () => { ...@@ -143,6 +143,24 @@ const handleClose = () => {
emit('close') emit('close')
} }
const readFileAsText = async (sourceFile: File): Promise<string> => {
if (typeof sourceFile.text === 'function') {
return sourceFile.text()
}
if (typeof sourceFile.arrayBuffer === 'function') {
const buffer = await sourceFile.arrayBuffer()
return new TextDecoder().decode(buffer)
}
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsText(sourceFile)
})
}
const handleImport = async () => { const handleImport = async () => {
if (!file.value) { if (!file.value) {
appStore.showError(t('admin.accounts.dataImportSelectFile')) appStore.showError(t('admin.accounts.dataImportSelectFile'))
...@@ -151,7 +169,7 @@ const handleImport = async () => { ...@@ -151,7 +169,7 @@ const handleImport = async () => {
importing.value = true importing.value = true
try { try {
const text = await file.value.text() const text = await readFileAsText(file.value)
const dataPayload = JSON.parse(text) const dataPayload = JSON.parse(text)
const res = await adminAPI.accounts.importData({ const res = await adminAPI.accounts.importData({
......
...@@ -216,7 +216,7 @@ interface Props { ...@@ -216,7 +216,7 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
reauthorized: [] reauthorized: [account: Account]
}>() }>()
const appStore = useAppStore() const appStore = useAppStore()
...@@ -370,10 +370,10 @@ const handleExchangeCode = async () => { ...@@ -370,10 +370,10 @@ const handleExchangeCode = async () => {
}) })
// Clear error status after successful re-authorization // Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id) const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized') emit('reauthorized', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
...@@ -404,9 +404,9 @@ const handleExchangeCode = async () => { ...@@ -404,9 +404,9 @@ const handleExchangeCode = async () => {
type: 'oauth', type: 'oauth',
credentials credentials
}) })
await adminAPI.accounts.clearError(props.account.id) const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized') emit('reauthorized', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
...@@ -436,9 +436,9 @@ const handleExchangeCode = async () => { ...@@ -436,9 +436,9 @@ const handleExchangeCode = async () => {
type: 'oauth', type: 'oauth',
credentials credentials
}) })
await adminAPI.accounts.clearError(props.account.id) const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized') emit('reauthorized', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
...@@ -475,10 +475,10 @@ const handleExchangeCode = async () => { ...@@ -475,10 +475,10 @@ const handleExchangeCode = async () => {
}) })
// Clear error status after successful re-authorization // Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id) const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized') emit('reauthorized', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
...@@ -518,10 +518,10 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -518,10 +518,10 @@ const handleCookieAuth = async (sessionKey: string) => {
}) })
// Clear error status after successful re-authorization // Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id) const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized') emit('reauthorized', updatedAccount)
handleClose() handleClose()
} catch (error: any) { } catch (error: any) {
claudeOAuth.error.value = claudeOAuth.error.value =
......
...@@ -143,6 +143,24 @@ const handleClose = () => { ...@@ -143,6 +143,24 @@ const handleClose = () => {
emit('close') emit('close')
} }
const readFileAsText = async (sourceFile: File): Promise<string> => {
if (typeof sourceFile.text === 'function') {
return sourceFile.text()
}
if (typeof sourceFile.arrayBuffer === 'function') {
const buffer = await sourceFile.arrayBuffer()
return new TextDecoder().decode(buffer)
}
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.readAsText(sourceFile)
})
}
const handleImport = async () => { const handleImport = async () => {
if (!file.value) { if (!file.value) {
appStore.showError(t('admin.proxies.dataImportSelectFile')) appStore.showError(t('admin.proxies.dataImportSelectFile'))
...@@ -151,7 +169,7 @@ const handleImport = async () => { ...@@ -151,7 +169,7 @@ const handleImport = async () => {
importing.value = true importing.value = true
try { try {
const text = await file.value.text() const text = await readFileAsText(file.value)
const dataPayload = JSON.parse(text) const dataPayload = JSON.parse(text)
const res = await adminAPI.proxies.importData({ data: dataPayload }) const res = await adminAPI.proxies.importData({ data: dataPayload })
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<template v-if="loading"> <template v-if="loading">
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"> <div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
<div class="space-y-3"> <div class="space-y-3">
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between"> <div v-for="column in dataColumns" :key="column.key" class="flex justify-between">
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
> >
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="column in columns.filter(c => c.key !== 'actions')" v-for="column in dataColumns"
:key="column.key" :key="column.key"
class="flex items-start justify-between gap-4" class="flex items-start justify-between gap-4"
> >
...@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => { ...@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
return key ?? index return key ?? index
} }
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
const columnsSignature = computed(() =>
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
)
// 数据/列变化时重新检查滚动状态 // 数据/列变化时重新检查滚动状态
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环 // 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
watch( watch(
[() => props.data.length, () => props.columns], [() => props.data.length, columnsSignature],
async () => { async () => {
await nextTick() await nextTick()
checkScrollable() checkScrollable()
...@@ -555,7 +560,7 @@ onMounted(() => { ...@@ -555,7 +560,7 @@ onMounted(() => {
}) })
watch( watch(
() => props.columns, columnsSignature,
() => { () => {
// If current sort key is no longer sortable/visible, fall back to default/persisted. // If current sort key is no longer sortable/visible, fall back to default/persisted.
const normalized = normalizeSortKey(sortKey.value) const normalized = normalizeSortKey(sortKey.value)
...@@ -575,7 +580,7 @@ watch( ...@@ -575,7 +580,7 @@ watch(
} }
} }
}, },
{ deep: true } { flush: 'post' }
) )
watch( watch(
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
<div class="relative" ref="dropdownRef"> <div class="relative" ref="dropdownRef">
<button <button
@click="toggleDropdown" @click="toggleDropdown"
:disabled="switching"
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:title="currentLocale?.name" :title="currentLocale?.name"
> >
...@@ -23,6 +24,7 @@ ...@@ -23,6 +24,7 @@
<button <button
v-for="locale in availableLocales" v-for="locale in availableLocales"
:key="locale.code" :key="locale.code"
:disabled="switching"
@click="selectLocale(locale.code)" @click="selectLocale(locale.code)"
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700" class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class="{ :class="{
...@@ -49,6 +51,7 @@ const { locale } = useI18n() ...@@ -49,6 +51,7 @@ const { locale } = useI18n()
const isOpen = ref(false) const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const switching = ref(false)
const currentLocaleCode = computed(() => locale.value) const currentLocaleCode = computed(() => locale.value)
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value)) const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
...@@ -57,9 +60,18 @@ function toggleDropdown() { ...@@ -57,9 +60,18 @@ function toggleDropdown() {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
} }
function selectLocale(code: string) { async function selectLocale(code: string) {
setLocale(code) if (switching.value || code === currentLocaleCode.value) {
isOpen.value = false isOpen.value = false
return
}
switching.value = true
try {
await setLocale(code)
isOpen.value = false
} finally {
switching.value = false
}
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
......
...@@ -84,8 +84,8 @@ ...@@ -84,8 +84,8 @@
<!-- Page numbers --> <!-- Page numbers -->
<button <button
v-for="pageNum in visiblePages" v-for="(pageNum, index) in visiblePages"
:key="pageNum" :key="`${pageNum}-${index}`"
@click="typeof pageNum === 'number' && goToPage(pageNum)" @click="typeof pageNum === 'number' && goToPage(pageNum)"
:disabled="typeof pageNum !== 'number'" :disabled="typeof pageNum !== 'number'"
:class="[ :class="[
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment