Compare commits

...

8 Commits

Author SHA1 Message Date
CJACK.
de50fd3954 Merge pull request #96 from CJackHwang/codex/update-ci-line-count-limits-cihke3
ci: ignore test files in line gate and raise frontend limit to 500
2026-03-16 23:16:22 +08:00
CJACK.
7648d5f192 ci: keep entry line cap precedence over frontend cap 2026-03-16 23:06:58 +08:00
CJACK.
d35e5eab25 ci: ignore tests in line gate and raise frontend limit 2026-03-16 22:58:13 +08:00
CJACK.
90610a52ce Merge pull request #93 from latticeon/feature/session-management
feat: 添加会话管理功能
2026-03-16 22:12:00 +08:00
latticeon
f6296d506f fix: 修改批量删除会话方式
- 从逐条单个删除改为官方的批量删除接口
- 单个删除函数保留备用
2026-03-16 16:23:39 +08:00
latticeon
dfea092583 fix: 更新测试 mock 结构体以实现新增的接口方法
会话管理功能新增接口方法后,同步更新测试 mock 结构体:
- mockOpenAIConfig: 添加 AutoDeleteSessions() 方法
- streamStatusDSStub: 添加 DeleteAllSessionsForToken() 方法
- testingDSMock: 添加 DeleteAllSessionsForToken() 和 GetSessionCountForToken() 方法

同时修复 client_session_delete.go 中 fmt.Errorf 使用非常量格式字符串的编译错误,改用 errors.New()
2026-03-16 11:58:07 +08:00
latticeon
af7dc134bb fix: 修复会话管理相关问题并拆分文件
1. 修复无限循环问题
   - DeleteAllSessions/DeleteAllSessionsForToken 添加无进度检测
   - 连续 3 轮删除失败则退出循环
   - DeleteAllSessionsForToken 添加 cursor 推进逻辑

2. 修复字段语义不准确
   - TotalCount 重命名为 FirstPageCount
   - 明确该值仅统计第一页,多页账户需关注 HasMore

3. 修复 defer 执行顺序问题
   - 合并两个 defer,确保先删除会话再释放账号
   - 使用同步删除避免并发截断风险

4. 文件拆分
   - 新建 client_session_delete.go 处理会话删除
   - client_session.go 专注于会话查询
2026-03-16 01:44:21 +08:00
latticeon
2657d37f76 添加会话数量显示与清除功能
添加会话清除功能,增强安全性,避免账号被盗等情况泄露源代码
账号列表点击测试后显示账号的会话数量
设置页添加自动清除开关,每次调用后清除被调用账号的所有会话
2026-03-16 00:50:31 +08:00
29 changed files with 1684 additions and 935 deletions

View File

@@ -19,6 +19,7 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
} }
type ConfigReader interface { type ConfigReader interface {
@@ -28,6 +29,7 @@ type ConfigReader interface {
ToolcallEarlyEmitConfidence() string ToolcallEarlyEmitConfidence() string
ResponsesStoreTTLSeconds() int ResponsesStoreTTLSeconds() int
EmbeddingsProvider() string EmbeddingsProvider() string
AutoDeleteSessions() bool
} }
var _ AuthResolver = (*auth.Resolver)(nil) var _ AuthResolver = (*auth.Resolver)(nil)

View File

@@ -19,6 +19,7 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo
func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit } func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit }
func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL } func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL }
func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv } func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv }
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{ cfg := mockOpenAIConfig{

View File

@@ -35,7 +35,23 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, status, detail) writeOpenAIError(w, status, detail)
return return
} }
defer h.Auth.Release(a) defer func() {
// 自动删除会话(同步)
// 必须在 Release 之前同步删除,否则:
// 1. 异步删除时账号已被 Release
// 2. 新请求可能获取到同一账号并开始使用
// 3. 异步删除仍在进行,会截断新请求正在使用的会话
if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
err := h.DS.DeleteAllSessionsForToken(context.Background(), a.DeepSeekToken)
if err != nil {
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
} else {
config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID)
}
}
h.Auth.Release(a)
}()
r = r.WithContext(auth.WithAuth(r.Context(), a)) r = r.WithContext(auth.WithAuth(r.Context(), a))
var req map[string]any var req map[string]any

View File

@@ -53,6 +53,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
return m.resp, nil return m.resp, nil
} }
func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response { func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n") body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") { if !strings.HasSuffix(body, "\n") {

View File

@@ -27,6 +27,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int
AutoDeleteSessions() bool
} }
type PoolController interface { type PoolController interface {
@@ -40,6 +41,8 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
} }
var _ ConfigStore = (*config.Store)(nil) var _ ConfigStore = (*config.Store)(nil)

View File

@@ -31,6 +31,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/queue/status", h.queueStatus) pr.Get("/queue/status", h.queueStatus)
pr.Post("/accounts/test", h.testSingleAccount) pr.Post("/accounts/test", h.testSingleAccount)
pr.Post("/accounts/test-all", h.testAllAccounts) pr.Post("/accounts/test-all", h.testAllAccounts)
pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
pr.Post("/import", h.batchImport) pr.Post("/import", h.batchImport)
pr.Post("/test", h.testAPI) pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel) pr.Post("/vercel/sync", h.syncVercel)

View File

@@ -89,7 +89,7 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now() start := time.Now()
identifier := acc.Identifier() identifier := acc.Identifier()
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model} result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0}
defer func() { defer func() {
status := "failed" status := "failed"
if ok, _ := result["success"].(bool); ok { if ok, _ := result["success"].(bool); ok {
@@ -124,6 +124,13 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
return result return result
} }
} }
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
if sessionErr == nil && sessionStats != nil {
result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
result["success"] = true result["success"] = true
result["message"] = "API 测试成功(仅会话创建)" result["message"] = "API 测试成功(仅会话创建)"
@@ -210,3 +217,39 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
} }
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
// 获取 token
token := strings.TrimSpace(acc.Token)
if token == "" {
newToken, err := h.DS.Login(r.Context(), acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
}
// 删除所有会话
err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}

View File

@@ -9,6 +9,7 @@ import (
"ds2api/internal/auth" "ds2api/internal/auth"
"ds2api/internal/config" "ds2api/internal/config"
"ds2api/internal/deepseek"
) )
type testingDSMock struct { type testingDSMock struct {
@@ -38,6 +39,14 @@ func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _
return nil, errors.New("should not call CallCompletion in this test") return nil, errors.New("should not call CallCompletion in this test")
} }
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
return &deepseek.SessionStats{Success: true}, nil
}
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) { func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`) t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore() store := config.LoadStore()

View File

@@ -7,15 +7,30 @@ import (
"ds2api/internal/config" "ds2api/internal/config"
) )
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) { func boolFrom(v any) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.ToLower(strings.TrimSpace(x)) == "true"
default:
return false
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) {
var ( var (
adminCfg *config.AdminConfig adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig embCfg *config.EmbeddingsConfig
claudeMap map[string]string autoDeleteCfg *config.AutoDeleteConfig
aliasMap map[string]string claudeMap map[string]string
aliasMap map[string]string
) )
if raw, ok := req["admin"].(map[string]any); ok { if raw, ok := req["admin"].(map[string]any); ok {
@@ -23,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists { if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 720 { if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
} }
cfg.JWTExpireHours = n cfg.JWTExpireHours = n
} }
@@ -35,26 +50,26 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists { if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 256 { if n < 1 || n > 256 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
} }
cfg.AccountMaxInflight = n cfg.AccountMaxInflight = n
} }
if v, exists := raw["account_max_queue"]; exists { if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
} }
cfg.AccountMaxQueue = n cfg.AccountMaxQueue = n
} }
if v, exists := raw["global_max_inflight"]; exists { if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
} }
cfg.GlobalMaxInflight = n cfg.GlobalMaxInflight = n
} }
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
} }
runtimeCfg = cfg runtimeCfg = cfg
} }
@@ -67,7 +82,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "feature_match", "off": case "feature_match", "off":
cfg.Mode = mode cfg.Mode = mode
default: default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
} }
} }
if v, exists := raw["early_emit_confidence"]; exists { if v, exists := raw["early_emit_confidence"]; exists {
@@ -76,7 +91,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "high", "low", "off": case "high", "low", "off":
cfg.EarlyEmitConfidence = level cfg.EarlyEmitConfidence = level
default: default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
} }
} }
toolcallCfg = cfg toolcallCfg = cfg
@@ -87,7 +102,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists { if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 30 || n > 86400 { if n < 30 || n > 86400 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
} }
cfg.StoreTTLSeconds = n cfg.StoreTTLSeconds = n
} }
@@ -98,9 +113,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
cfg := &config.EmbeddingsConfig{} cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists { if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v)) p := strings.TrimSpace(fmt.Sprintf("%v", v))
if p == "" {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
}
cfg.Provider = p cfg.Provider = p
} }
embCfg = cfg embCfg = cfg
@@ -130,5 +142,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
} }
} }
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil if raw, ok := req["auto_delete"].(map[string]any); ok {
cfg := &config.AutoDeleteConfig{}
if v, exists := raw["sessions"]; exists {
cfg.Sessions = boolFrom(v)
}
autoDeleteCfg = cfg
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
} }

View File

@@ -28,6 +28,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"toolcall": snap.Toolcall, "toolcall": snap.Toolcall,
"responses": snap.Responses, "responses": snap.Responses,
"embeddings": snap.Embeddings, "embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"claude_mapping": settingsClaudeMapping(snap), "claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases, "model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(), "env_backed": h.Store.IsEnvBacked(),

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return return
@@ -60,6 +60,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" { if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider) c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
} }
if autoDeleteCfg != nil {
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if claudeMap != nil { if claudeMap != nil {
c.ClaudeMapping = claudeMap c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil c.ClaudeModelMap = nil

View File

@@ -47,6 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Embeddings.Provider) != "" { if strings.TrimSpace(c.Embeddings.Provider) != "" {
m["embeddings"] = c.Embeddings m["embeddings"] = c.Embeddings
} }
m["auto_delete"] = c.AutoDelete
if c.VercelSyncHash != "" { if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash m["_vercel_sync_hash"] = c.VercelSyncHash
} }
@@ -108,6 +109,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Embeddings); err != nil { if err := json.Unmarshal(v, &c.Embeddings); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
} }
case "auto_delete":
if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash": case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
@@ -141,6 +146,7 @@ func (c Config) Clone() Config {
Toolcall: c.Toolcall, Toolcall: c.Toolcall,
Responses: c.Responses, Responses: c.Responses,
Embeddings: c.Embeddings, Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,
VercelSyncHash: c.VercelSyncHash, VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime, VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{}, AdditionalFields: map[string]any{},

View File

@@ -12,7 +12,8 @@ type Config struct {
Toolcall ToolcallConfig `json:"toolcall,omitempty"` Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"` Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"` AdditionalFields map[string]any `json:"-"`
} }
@@ -53,3 +54,7 @@ type ResponsesConfig struct {
type EmbeddingsConfig struct { type EmbeddingsConfig struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
} }
type AutoDeleteConfig struct {
Sessions bool `json:"sessions"`
}

View File

@@ -165,3 +165,9 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
} }
return defaultSize return defaultSize
} }
func (s *Store) AutoDeleteSessions() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.AutoDelete.Sessions
}

View File

@@ -62,3 +62,51 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
} }
return out, resp.StatusCode, nil return out, resp.StatusCode, nil
} }
func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
if err != nil {
return nil, err
}
if status == 0 {
return nil, errors.New("request failed")
}
return body, nil
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := doer.Do(req)
if err != nil {
config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err)
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if reqErr != nil {
return nil, 0, err
}
for k, v := range headers {
req2.Header.Set(k, v)
}
resp, err = c.fallback.Do(req2)
if err != nil {
return nil, 0, err
}
}
defer resp.Body.Close()
payloadBytes, err := readResponseBody(resp)
if err != nil {
return nil, resp.StatusCode, err
}
out := map[string]any{}
if len(payloadBytes) > 0 {
if err := json.Unmarshal(payloadBytes, &out); err != nil {
config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
}
}
return out, resp.StatusCode, nil
}

View File

@@ -0,0 +1,254 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// SessionInfo 会话信息
type SessionInfo struct {
ID string `json:"id"`
Title string `json:"title"`
TitleType string `json:"title_type"`
Pinned bool `json:"pinned"`
UpdatedAt float64 `json:"updated_at"`
}
// SessionStats 会话统计结果
type SessionStats struct {
AccountID string // 账号标识 (email 或 mobile)
FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大)
PinnedCount int // 置顶会话数量
HasMore bool // 是否还有更多页
Success bool // 请求是否成功
ErrorMessage string // 错误信息
}
// GetSessionCount 获取单个账号的会话数量
func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (*SessionStats, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
stats := &SessionStats{
AccountID: a.AccountID,
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
attempts++
continue
}
code := intFrom(resp["code"])
if status == http.StatusOK && code == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats.FirstPageCount = len(chatSessions)
stats.HasMore = hasMore
stats.Success = true
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
msg, _ := resp["msg"].(string)
stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "msg", msg, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
stats.Success = false
stats.ErrorMessage = "get session count failed after retries"
return stats, errors.New(stats.ErrorMessage)
}
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
headers := c.authHeaders(token)
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats := &SessionStats{
FirstPageCount: len(chatSessions),
HasMore: hasMore,
Success: true,
}
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
// GetSessionCountAll 获取所有账号的会话数量统计
func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
accounts := c.Store.Accounts()
results := make([]*SessionStats, 0, len(accounts))
for _, acc := range accounts {
token := acc.Token
accountID := acc.Email
if accountID == "" {
accountID = acc.Mobile
}
// 如果没有 token尝试登录获取
if token == "" {
var err error
token, err = c.Login(ctx, acc)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: fmt.Sprintf("login failed: %v", err),
})
continue
}
}
stats, err := c.GetSessionCountForToken(ctx, token)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: err.Error(),
})
continue
}
stats.AccountID = accountID
results = append(results, stats)
}
return results
}
// FetchSessionPage 获取会话列表(支持分页)
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
params := url.Values{}
params.Set("lte_cursor.pinned", "false")
if cursor != "" {
params.Set("lte_cursor", cursor)
}
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, false, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
return nil, false, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
sessions := make([]SessionInfo, 0, len(chatSessions))
for _, s := range chatSessions {
if m, ok := s.(map[string]any); ok {
session := SessionInfo{
ID: stringFromMap(m, "id"),
Title: stringFromMap(m, "title"),
TitleType: stringFromMap(m, "title_type"),
Pinned: boolFromMap(m, "pinned"),
UpdatedAt: floatFromMap(m, "updated_at"),
}
sessions = append(sessions, session)
}
}
return sessions, hasMore, nil
}
// 辅助函数
func stringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func boolFromMap(m map[string]any, key string) bool {
if v, ok := m[key].(bool); ok {
return v
}
return false
}
func floatFromMap(m map[string]any, key string) float64 {
if v, ok := m[key].(float64); ok {
return v
}
return 0
}

View File

@@ -0,0 +1,156 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// DeleteSessionResult 删除会话结果
type DeleteSessionResult struct {
SessionID string // 会话 ID
Success bool // 是否成功
ErrorMessage string // 错误信息
}
// DeleteSession 删除单个会话
func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
attempts++
continue
}
code := intFrom(resp["code"])
if status == http.StatusOK && code == 0 {
result.Success = true
return result, nil
}
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "msg", msg, "session_id", sessionID)
if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
result.Success = false
result.ErrorMessage = "delete session failed after retries"
return result, errors.New(result.ErrorMessage)
}
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
headers := c.authHeaders(token)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
result.ErrorMessage = err.Error()
return result, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
return result, errors.New(result.ErrorMessage)
}
result.Success = true
return result, nil
}
// DeleteAllSessions 删除所有会话(谨慎使用)
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
headers := c.authHeaders(token)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions_for_token] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}

View File

@@ -11,6 +11,9 @@ const (
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create" DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge" DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion" DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
) )
var defaultBaseHeaders = map[string]string{ var defaultBaseHeaders = map[string]string{

View File

@@ -1,6 +1,8 @@
# Line gate targets for large-file decoupling refactor. # Line gate targets for large-file decoupling refactor.
# Default limit: 300 lines # Backend default limit: 300 lines
# Frontend (webui/) default limit: 500 lines
# Entry/facade limit: 120 lines (enforced in script) # Entry/facade limit: 120 lines (enforced in script)
# Test files are ignored by the gate script.
internal/config/config.go internal/config/config.go
internal/config/logger.go internal/config/logger.go

View File

@@ -2,10 +2,11 @@
## Rules ## Rules
1. Production file default upper bound: `<= 300` lines. 1. Backend production files upper bound: `<= 300` lines.
2. Entry/facade files upper bound: `<= 120` lines. 2. Frontend (`webui/`) production files upper bound: `<= 500` lines.
3. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`. 3. Entry/facade files upper bound: `<= 120` lines.
4. Test files are out of scope for this gate. 4. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`.
5. Test files are out of scope for this gate.
## Command ## Command

View File

@@ -5,6 +5,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt" TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt"
DEFAULT_MAX=300 DEFAULT_MAX=300
FRONTEND_MAX=500
ENTRY_MAX=120 ENTRY_MAX=120
is_entry_file() { is_entry_file() {
@@ -22,6 +23,27 @@ is_entry_file() {
return 1 return 1
} }
is_frontend_file() {
[[ "$1" == webui/* ]]
}
is_test_file() {
local file="$1"
local base
base="$(basename "$file")"
[[ "$file" == tests/* ]] && return 0
[[ "$file" == */tests/* ]] && return 0
[[ "$file" == */__tests__/* ]] && return 0
[[ "$base" == *_test.go ]] && return 0
[[ "$base" == *.test.js ]] && return 0
[[ "$base" == *.test.jsx ]] && return 0
[[ "$base" == *.test.ts ]] && return 0
[[ "$base" == *.test.tsx ]] && return 0
return 1
}
if [[ ! -f "$TARGETS_FILE" ]]; then if [[ ! -f "$TARGETS_FILE" ]]; then
echo "missing targets file: $TARGETS_FILE" >&2 echo "missing targets file: $TARGETS_FILE" >&2
exit 1 exit 1
@@ -35,6 +57,10 @@ while IFS= read -r file; do
[[ -z "$file" ]] && continue [[ -z "$file" ]] && continue
[[ "${file:0:1}" == "#" ]] && continue [[ "${file:0:1}" == "#" ]] && continue
if is_test_file "$file"; then
continue
fi
checked=$((checked + 1)) checked=$((checked + 1))
abs="$ROOT_DIR/$file" abs="$ROOT_DIR/$file"
if [[ ! -f "$abs" ]]; then if [[ ! -f "$abs" ]]; then
@@ -47,6 +73,8 @@ while IFS= read -r file; do
limit="$DEFAULT_MAX" limit="$DEFAULT_MAX"
if is_entry_file "$file"; then if is_entry_file "$file"; then
limit="$ENTRY_MAX" limit="$ENTRY_MAX"
elif is_frontend_file "$file"; then
limit="$FRONTEND_MAX"
fi fi
if (( lines > limit )); then if (( lines > limit )); then

View File

@@ -1,121 +1,127 @@
import { useI18n } from '../../i18n' import { useI18n } from '../../i18n'
import { useAccountsData } from './useAccountsData' import { useAccountsData } from './useAccountsData'
import { useAccountActions } from './useAccountActions' import { useAccountActions } from './useAccountActions'
import QueueCards from './QueueCards' import QueueCards from './QueueCards'
import ApiKeysPanel from './ApiKeysPanel' import ApiKeysPanel from './ApiKeysPanel'
import AccountsTable from './AccountsTable' import AccountsTable from './AccountsTable'
import AddKeyModal from './AddKeyModal' import AddKeyModal from './AddKeyModal'
import AddAccountModal from './AddAccountModal' import AddAccountModal from './AddAccountModal'
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) { export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n() const { t } = useI18n()
const apiFetch = authFetch || fetch const apiFetch = authFetch || fetch
const { const {
queueStatus, queueStatus,
keysExpanded, keysExpanded,
setKeysExpanded, setKeysExpanded,
accounts, accounts,
page, page,
pageSize, pageSize,
totalPages, totalPages,
totalAccounts, totalAccounts,
loadingAccounts, loadingAccounts,
fetchAccounts, fetchAccounts,
changePageSize, changePageSize,
resolveAccountIdentifier, resolveAccountIdentifier,
searchQuery, searchQuery,
handleSearchChange, handleSearchChange,
} = useAccountsData({ apiFetch }) } = useAccountsData({ apiFetch })
const { const {
showAddKey, showAddKey,
setShowAddKey, setShowAddKey,
showAddAccount, showAddAccount,
setShowAddAccount, setShowAddAccount,
newKey, newKey,
setNewKey, setNewKey,
copiedKey, copiedKey,
setCopiedKey, setCopiedKey,
newAccount, newAccount,
setNewAccount, setNewAccount,
loading, loading,
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
addKey, sessionCounts,
deleteKey, deletingSessions,
addAccount, addKey,
deleteAccount, deleteKey,
testAccount, addAccount,
testAllAccounts, deleteAccount,
} = useAccountActions({ testAccount,
apiFetch, testAllAccounts,
t, deleteAllSessions,
onMessage, } = useAccountActions({
onRefresh, apiFetch,
config, t,
fetchAccounts, onMessage,
resolveAccountIdentifier, onRefresh,
}) config,
fetchAccounts,
return ( resolveAccountIdentifier,
<div className="space-y-6"> })
<QueueCards queueStatus={queueStatus} t={t} />
return (
<ApiKeysPanel <div className="space-y-6">
t={t} <QueueCards queueStatus={queueStatus} t={t} />
config={config}
keysExpanded={keysExpanded} <ApiKeysPanel
setKeysExpanded={setKeysExpanded} t={t}
setShowAddKey={setShowAddKey} config={config}
copiedKey={copiedKey} keysExpanded={keysExpanded}
setCopiedKey={setCopiedKey} setKeysExpanded={setKeysExpanded}
onDeleteKey={deleteKey} setShowAddKey={setShowAddKey}
/> copiedKey={copiedKey}
setCopiedKey={setCopiedKey}
<AccountsTable onDeleteKey={deleteKey}
t={t} />
accounts={accounts}
loadingAccounts={loadingAccounts} <AccountsTable
testing={testing} t={t}
testingAll={testingAll} accounts={accounts}
batchProgress={batchProgress} loadingAccounts={loadingAccounts}
totalAccounts={totalAccounts} testing={testing}
page={page} testingAll={testingAll}
pageSize={pageSize} batchProgress={batchProgress}
totalPages={totalPages} sessionCounts={sessionCounts}
resolveAccountIdentifier={resolveAccountIdentifier} deletingSessions={deletingSessions}
onTestAll={testAllAccounts} totalAccounts={totalAccounts}
onShowAddAccount={() => setShowAddAccount(true)} page={page}
onTestAccount={testAccount} pageSize={pageSize}
onDeleteAccount={deleteAccount} totalPages={totalPages}
onPrevPage={() => fetchAccounts(page - 1)} resolveAccountIdentifier={resolveAccountIdentifier}
onNextPage={() => fetchAccounts(page + 1)} onTestAll={testAllAccounts}
onPageSizeChange={changePageSize} onShowAddAccount={() => setShowAddAccount(true)}
searchQuery={searchQuery} onTestAccount={testAccount}
onSearchChange={handleSearchChange} onDeleteAccount={deleteAccount}
/> onDeleteAllSessions={deleteAllSessions}
onPrevPage={() => fetchAccounts(page - 1)}
<AddKeyModal onNextPage={() => fetchAccounts(page + 1)}
show={showAddKey} onPageSizeChange={changePageSize}
t={t} searchQuery={searchQuery}
newKey={newKey} onSearchChange={handleSearchChange}
setNewKey={setNewKey} />
loading={loading}
onClose={() => setShowAddKey(false)} <AddKeyModal
onAdd={addKey} show={showAddKey}
/> t={t}
newKey={newKey}
<AddAccountModal setNewKey={setNewKey}
show={showAddAccount} loading={loading}
t={t} onClose={() => setShowAddKey(false)}
newAccount={newAccount} onAdd={addKey}
setNewAccount={setNewAccount} />
loading={loading}
onClose={() => setShowAddAccount(false)} <AddAccountModal
onAdd={addAccount} show={showAddAccount}
/> t={t}
</div> newAccount={newAccount}
) setNewAccount={setNewAccount}
} loading={loading}
onClose={() => setShowAddAccount(false)}
onAdd={addAccount}
/>
</div>
)
}

View File

@@ -1,191 +1,213 @@
import { useState } from 'react' import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react' import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
export default function AccountsTable({ export default function AccountsTable({
t, t,
accounts, accounts,
loadingAccounts, loadingAccounts,
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
totalAccounts, sessionCounts,
page, deletingSessions,
pageSize, totalAccounts,
totalPages, page,
resolveAccountIdentifier, pageSize,
onTestAll, totalPages,
onShowAddAccount, resolveAccountIdentifier,
onTestAccount, onTestAll,
onDeleteAccount, onShowAddAccount,
onPrevPage, onTestAccount,
onNextPage, onDeleteAccount,
onPageSizeChange, onDeleteAllSessions,
searchQuery, onPrevPage,
onSearchChange, onNextPage,
}) { onPageSizeChange,
const [copiedId, setCopiedId] = useState(null) searchQuery,
onSearchChange,
const copyId = (id) => { }) {
navigator.clipboard.writeText(id).then(() => { const [copiedId, setCopiedId] = useState(null)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 1500) const copyId = (id) => {
}) navigator.clipboard.writeText(id).then(() => {
} setCopiedId(id)
return ( setTimeout(() => setCopiedId(null), 1500)
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm"> })
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4"> }
<div> return (
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2> <div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p> <div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
</div> <div>
<div className="flex flex-wrap gap-2"> <h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
<input <p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
type="text" </div>
value={searchQuery} <div className="flex flex-wrap gap-2">
onChange={e => onSearchChange(e.target.value)} <input
placeholder={t('accountManager.searchPlaceholder')} type="text"
className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground" value={searchQuery}
/> onChange={e => onSearchChange(e.target.value)}
<button placeholder={t('accountManager.searchPlaceholder')}
onClick={onTestAll} className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
disabled={testingAll || totalAccounts === 0} />
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50" <button
> onClick={onTestAll}
{testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />} disabled={testingAll || totalAccounts === 0}
{t('accountManager.testAll')} className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
</button> >
<button {testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />}
onClick={onShowAddAccount} {t('accountManager.testAll')}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm" </button>
> <button
<Plus className="w-4 h-4" /> onClick={onShowAddAccount}
{t('accountManager.addAccount')} className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
</button> >
</div> <Plus className="w-4 h-4" />
</div> {t('accountManager.addAccount')}
</button>
{testingAll && batchProgress.total > 0 && ( </div>
<div className="p-4 border-b border-border bg-muted/30"> </div>
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span> {testingAll && batchProgress.total > 0 && (
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span> <div className="p-4 border-b border-border bg-muted/30">
</div> <div className="flex items-center justify-between text-sm mb-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4"> <span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
<div <span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
className="bg-primary h-full transition-all duration-300" </div>
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }} <div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
/> <div
</div> className="bg-primary h-full transition-all duration-300"
{batchProgress.results.length > 0 && ( style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar"> />
{batchProgress.results.map((r, i) => ( </div>
<div key={i} className={clsx( {batchProgress.results.length > 0 && (
"text-xs px-2 py-1 rounded border truncate", <div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive" {batchProgress.results.map((r, i) => (
)}> <div key={i} className={clsx(
{r.success ? '✓' : '✗'} {r.id} "text-xs px-2 py-1 rounded border truncate",
</div> r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
))} )}>
</div> {r.success ? '✓' : '✗'} {r.id}
)} </div>
</div> ))}
)} </div>
)}
<div className="divide-y divide-border"> </div>
{loadingAccounts ? ( )}
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
) : accounts.length > 0 ? ( <div className="divide-y divide-border">
accounts.map((acc, i) => { {loadingAccounts ? (
const id = resolveAccountIdentifier(acc) <div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
return ( ) : accounts.length > 0 ? (
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors"> accounts.map((acc, i) => {
<div className="flex items-center gap-3 min-w-0"> const id = resolveAccountIdentifier(acc)
<div className={clsx( return (
"w-2 h-2 rounded-full shrink-0", <div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" : <div className="flex items-center gap-3 min-w-0">
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : <div className={clsx(
"bg-amber-500" "w-2 h-2 rounded-full shrink-0",
)} /> acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
<div className="min-w-0"> (acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
<div "bg-amber-500"
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group" )} />
onClick={() => copyId(id)} <div className="min-w-0">
> <div
<span className="truncate">{id || '-'}</span> className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
{copiedId === id onClick={() => copyId(id)}
? <Check className="w-3 h-3 text-emerald-500 shrink-0" /> >
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" /> <span className="truncate">{id || '-'}</span>
} {copiedId === id
</div> ? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5"> : <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span> }
{acc.token_preview && ( </div>
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]"> <div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
{acc.token_preview} <span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
</span> {acc.token_preview && (
)} <span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
</div> {acc.token_preview}
</div> </span>
</div> )}
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0"> {sessionCounts && sessionCounts[id] !== undefined && (
<button <span className="font-mono bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded text-[10px]">
onClick={() => onTestAccount(id)} {t('accountManager.sessionCount', { count: sessionCounts[id] })}
disabled={testing[id]} </span>
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50" )}
> {sessionCounts && sessionCounts[id] !== undefined && sessionCounts[id] > 0 && (
{testing[id] ? t('actions.testing') : t('actions.test')} <button
</button> onClick={() => onDeleteAllSessions(id)}
<button disabled={deletingSessions && deletingSessions[id]}
onClick={() => onDeleteAccount(id)} className="flex items-center gap-1 font-mono bg-red-500/10 text-red-500 hover:bg-red-500/20 px-1.5 py-0.5 rounded text-[10px] transition-colors disabled:opacity-50"
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors" title={t('accountManager.deleteAllSessions')}
> >
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" /> {deletingSessions && deletingSessions[id] ? (
</button> <span className="animate-spin"></span>
</div> ) : (
</div> <FolderX className="w-3 h-3" />
) )}
}) </button>
) : ( )}
<div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div> </div>
)} </div>
</div> </div>
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
{totalPages > 1 && ( <button
<div className="p-4 border-t border-border flex items-center justify-between"> onClick={() => onTestAccount(id)}
<div className="flex items-center gap-3"> disabled={testing[id]}
<div className="text-sm text-muted-foreground"> className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} >
</div> {testing[id] ? t('actions.testing') : t('actions.test')}
<select </button>
value={pageSize} <button
onChange={e => onPageSizeChange(Number(e.target.value))} onClick={() => onDeleteAccount(id)}
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground" className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
> >
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => ( <Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
<option key={s} value={s}>{s}</option> </button>
))} </div>
</select> </div>
</div> )
<div className="flex items-center gap-2"> })
<button ) : (
onClick={onPrevPage} <div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div>
disabled={page <= 1 || loadingAccounts} )}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed" </div>
>
<ChevronLeft className="w-4 h-4" /> {totalPages > 1 && (
</button> <div className="p-4 border-t border-border flex items-center justify-between">
<span className="text-sm font-medium px-2">{page} / {totalPages}</span> <div className="flex items-center gap-3">
<button <div className="text-sm text-muted-foreground">
onClick={onNextPage} {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
disabled={page >= totalPages || loadingAccounts} </div>
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed" <select
> value={pageSize}
<ChevronRight className="w-4 h-4" /> onChange={e => onPageSizeChange(Number(e.target.value))}
</button> className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
</div> >
</div> {[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
)} <option key={s} value={s}>{s}</option>
</div> ))}
) </select>
} </div>
<div className="flex items-center gap-2">
<button
onClick={onPrevPage}
disabled={page <= 1 || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
<button
onClick={onNextPage}
disabled={page >= totalPages || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -10,6 +10,8 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [testing, setTesting] = useState({}) const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false) const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] }) const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
const [sessionCounts, setSessionCounts] = useState({})
const [deletingSessions, setDeletingSessions] = useState({})
const addKey = async () => { const addKey = async () => {
if (!newKey.trim()) return if (!newKey.trim()) return
@@ -115,6 +117,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
body: JSON.stringify({ identifier: accountID }), body: JSON.stringify({ identifier: accountID }),
}) })
const data = await res.json() const data = await res.json()
// 更新会话数
if (data.session_count !== undefined) {
setSessionCounts(prev => ({ ...prev, [accountID]: data.session_count }))
}
const statusMessage = data.success const statusMessage = data.success
? t('apiTester.testSuccess', { account: accountID, time: data.response_time }) ? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
: `${accountID}: ${data.message}` : `${accountID}: ${data.message}`
@@ -170,6 +178,41 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
setTestingAll(false) setTestingAll(false)
} }
const deleteAllSessions = async (identifier) => {
const accountID = String(identifier || '').trim()
if (!accountID) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
if (!confirm(t('accountManager.deleteAllSessionsConfirm'))) return
setDeletingSessions(prev => ({ ...prev, [accountID]: true }))
try {
const res = await apiFetch('/admin/accounts/sessions/delete-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: accountID }),
})
const data = await res.json()
if (data.success) {
onMessage('success', t('accountManager.deleteAllSessionsSuccess'))
// 清除会话数显示
setSessionCounts(prev => {
const newCounts = { ...prev }
delete newCounts[accountID]
return newCounts
})
} else {
onMessage('error', data.message || t('messages.requestFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setDeletingSessions(prev => ({ ...prev, [accountID]: false }))
}
}
return { return {
showAddKey, showAddKey,
setShowAddKey, setShowAddKey,
@@ -185,11 +228,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
addKey, addKey,
deleteKey, deleteKey,
addAccount, addAccount,
deleteAccount, deleteAccount,
testAccount, testAccount,
testAllAccounts, testAllAccounts,
deleteAllSessions,
} }
} }

View File

@@ -0,0 +1,39 @@
import { Trash2 } from 'lucide-react'
export default function AutoDeleteSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<Trash2 className="w-4 h-4 text-muted-foreground" />
<h3 className="font-semibold">{t('settings.autoDeleteTitle')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('settings.autoDeleteDesc')}</p>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{t('settings.autoDeleteSessions')}</label>
<button
type="button"
role="switch"
aria-checked={form.auto_delete?.sessions || false}
onClick={() => setForm((prev) => ({
...prev,
auto_delete: { ...prev.auto_delete, sessions: !prev.auto_delete?.sessions },
}))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
form.auto_delete?.sessions ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
form.auto_delete?.sessions ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{form.auto_delete?.sessions && (
<p className="text-xs text-amber-500 flex items-center gap-1">
{t('settings.autoDeleteWarning')}
</p>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
import SecuritySection from './SecuritySection' import SecuritySection from './SecuritySection'
import RuntimeSection from './RuntimeSection' import RuntimeSection from './RuntimeSection'
import BehaviorSection from './BehaviorSection' import BehaviorSection from './BehaviorSection'
import AutoDeleteSection from './AutoDeleteSection'
import ModelSection from './ModelSection' import ModelSection from './ModelSection'
import BackupSection from './BackupSection' import BackupSection from './BackupSection'
@@ -91,6 +92,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
<BehaviorSection t={t} form={form} setForm={setForm} /> <BehaviorSection t={t} form={form} setForm={setForm} />
<AutoDeleteSection t={t} form={form} setForm={setForm} />
<ModelSection t={t} form={form} setForm={setForm} /> <ModelSection t={t} form={form} setForm={setForm} />
<BackupSection <BackupSection

View File

@@ -16,6 +16,7 @@ const DEFAULT_FORM = {
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' }, toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
responses: { store_ttl_seconds: 900 }, responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' }, embeddings: { provider: '' },
auto_delete: { sessions: false },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}', claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}', model_aliases_text: '{}',
} }
@@ -55,6 +56,9 @@ function fromServerForm(data) {
embeddings: { embeddings: {
provider: data.embeddings?.provider || '', provider: data.embeddings?.provider || '',
}, },
auto_delete: {
sessions: Boolean(data.auto_delete?.sessions || false),
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2), claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
} }
@@ -74,6 +78,7 @@ function toServerPayload(form) {
}, },
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() }, embeddings: { provider: String(form.embeddings.provider || '').trim() },
auto_delete: { sessions: Boolean(form.auto_delete?.sessions) },
} }
} }

View File

@@ -1,297 +1,305 @@
{ {
"language": { "language": {
"label": "Language", "label": "Language",
"english": "English", "english": "English",
"chinese": "中文" "chinese": "中文"
}, },
"nav": { "nav": {
"accounts": { "accounts": {
"label": "Account Management", "label": "Account Management",
"desc": "Manage the DeepSeek account pool" "desc": "Manage the DeepSeek account pool"
}, },
"test": { "test": {
"label": "API Test", "label": "API Test",
"desc": "Test API connectivity and responses" "desc": "Test API connectivity and responses"
}, },
"import": { "import": {
"label": "Batch Import", "label": "Batch Import",
"desc": "Bulk import account configuration" "desc": "Bulk import account configuration"
}, },
"vercel": { "vercel": {
"label": "Vercel Sync", "label": "Vercel Sync",
"desc": "Sync configuration to Vercel" "desc": "Sync configuration to Vercel"
}, },
"settings": { "settings": {
"label": "Settings", "label": "Settings",
"desc": "Edit runtime and security settings online" "desc": "Edit runtime and security settings online"
} }
}, },
"sidebar": { "sidebar": {
"onlineAdminConsole": "Online Admin Console", "onlineAdminConsole": "Online Admin Console",
"systemStatus": "System Status", "systemStatus": "System Status",
"statusOnline": "Online", "statusOnline": "Online",
"accounts": "Accounts", "accounts": "Accounts",
"keys": "Keys", "keys": "Keys",
"signOut": "Sign out" "signOut": "Sign out"
}, },
"auth": { "auth": {
"expired": "Authentication expired. Please sign in again.", "expired": "Authentication expired. Please sign in again.",
"checking": "Checking authentication status..." "checking": "Checking authentication status..."
}, },
"errors": { "errors": {
"fetchConfig": "Failed to fetch configuration: {error}" "fetchConfig": "Failed to fetch configuration: {error}"
}, },
"actions": { "actions": {
"cancel": "Cancel", "cancel": "Cancel",
"add": "Add", "add": "Add",
"delete": "Delete", "delete": "Delete",
"copy": "Copy", "copy": "Copy",
"generate": "Generate", "generate": "Generate",
"test": "Test", "test": "Test",
"testing": "Testing...", "testing": "Testing...",
"loading": "Loading..." "loading": "Loading..."
}, },
"messages": { "messages": {
"deleted": "Deleted successfully", "deleted": "Deleted successfully",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"failedToAdd": "Failed to add", "failedToAdd": "Failed to add",
"networkError": "Network error.", "networkError": "Network error.",
"requestFailed": "Request failed.", "requestFailed": "Request failed.",
"generationStopped": "Generation stopped.", "generationStopped": "Generation stopped.",
"invalidJson": "Invalid JSON format.", "invalidJson": "Invalid JSON format.",
"importFailed": "Import failed.", "importFailed": "Import failed.",
"copyFailed": "Copy failed." "copyFailed": "Copy failed."
}, },
"landing": { "landing": {
"adminConsole": "Admin Console", "adminConsole": "Admin Console",
"apiStatus": "API Status", "apiStatus": "API Status",
"features": { "features": {
"compatibility": { "compatibility": {
"title": "Full Compatibility", "title": "Full Compatibility",
"desc": "OpenAI & Claude format support" "desc": "OpenAI & Claude format support"
}, },
"loadBalancing": { "loadBalancing": {
"title": "Load Balancing", "title": "Load Balancing",
"desc": "Smart rotation with stable throughput" "desc": "Smart rotation with stable throughput"
}, },
"reasoning": { "reasoning": {
"title": "Deep Reasoning", "title": "Deep Reasoning",
"desc": "Expose reasoning traces when enabled" "desc": "Expose reasoning traces when enabled"
}, },
"search": { "search": {
"title": "Web Search", "title": "Web Search",
"desc": "Integrated native web search" "desc": "Integrated native web search"
} }
} }
}, },
"accountManager": { "accountManager": {
"addKeySuccess": "API key added successfully.", "addKeySuccess": "API key added successfully.",
"addAccountSuccess": "Account added successfully.", "addAccountSuccess": "Account added successfully.",
"requiredFields": "Password and email/mobile are required.", "requiredFields": "Password and email/mobile are required.",
"deleteKeyConfirm": "Are you sure you want to delete this API key?", "deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?", "deleteAccountConfirm": "Are you sure you want to delete this account?",
"invalidIdentifier": "Invalid account identifier. Operation aborted.", "invalidIdentifier": "Invalid account identifier. Operation aborted.",
"testAllConfirm": "Test API connectivity for all accounts?", "testAllConfirm": "Test API connectivity for all accounts?",
"testAllCompleted": "Completed: {success}/{total} available", "testAllCompleted": "Completed: {success}/{total} available",
"testFailed": "Test failed: {error}", "testFailed": "Test failed: {error}",
"available": "Available", "available": "Available",
"inUse": "In use", "inUse": "In use",
"totalPool": "Total pool", "totalPool": "Total pool",
"accountsUnit": "accounts", "accountsUnit": "accounts",
"threadsUnit": "threads", "threadsUnit": "threads",
"apiKeysTitle": "API Keys", "apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool", "apiKeysDesc": "Manage the API access key pool",
"addKey": "Add key", "addKey": "Add key",
"copied": "Copied", "copied": "Copied",
"copyKeyTitle": "Copy key", "copyKeyTitle": "Copy key",
"deleteKeyTitle": "Delete key", "deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.", "noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts", "accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool", "accountsDesc": "Manage the DeepSeek account pool",
"testAll": "Test all", "testAll": "Test all",
"addAccount": "Add account", "addAccount": "Add account",
"testingAllAccounts": "Testing all accounts...", "testingAllAccounts": "Testing all accounts...",
"sessionActive": "Session active", "sessionActive": "Session active",
"reauthRequired": "Re-auth required", "reauthRequired": "Re-auth required",
"testStatusFailed": "Last test failed", "testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.", "noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key", "modalAddKeyTitle": "Add API key",
"newKeyLabel": "New key value", "newKeyLabel": "New key value",
"newKeyPlaceholder": "Enter a custom API key", "newKeyPlaceholder": "Enter a custom API key",
"generate": "Generate", "generate": "Generate",
"generateHint": "Click Generate to create a random key.", "generateHint": "Click Generate to create a random key.",
"addKeyLoading": "Adding...", "addKeyLoading": "Adding...",
"addKeyAction": "Add key", "addKeyAction": "Add key",
"modalAddAccountTitle": "Add DeepSeek account", "modalAddAccountTitle": "Add DeepSeek account",
"emailOptional": "Email (optional)", "emailOptional": "Email (optional)",
"mobileOptional": "Mobile (optional)", "mobileOptional": "Mobile (optional)",
"passwordLabel": "Password", "passwordLabel": "Password",
"passwordPlaceholder": "Account password", "passwordPlaceholder": "Account password",
"addAccountLoading": "Adding...", "addAccountLoading": "Adding...",
"addAccountAction": "Add account", "addAccountAction": "Add account",
"pageInfo": "Page {current}/{total}, {count} accounts total", "pageInfo": "Page {current}/{total}, {count} accounts total",
"searchPlaceholder": "Search accounts...", "searchPlaceholder": "Search accounts...",
"searchNoResults": "No accounts match your search" "searchNoResults": "No accounts match your search",
}, "sessionCount": "Sessions: {count}",
"apiTester": { "deleteAllSessions": "Delete all sessions",
"defaultMessage": "Hello, please introduce yourself in one sentence.", "deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
"models": { "deleteAllSessionsSuccess": "Successfully deleted all sessions"
"chat": "Non-reasoning model", },
"reasoner": "Reasoning model", "apiTester": {
"chatSearch": "Non-reasoning model (with search)", "defaultMessage": "Hello, please introduce yourself in one sentence.",
"reasonerSearch": "Reasoning model (with search)" "models": {
}, "chat": "Non-reasoning model",
"missingApiKey": "Please provide an API key.", "reasoner": "Reasoning model",
"requestFailed": "Request failed.", "chatSearch": "Non-reasoning model (with search)",
"networkError": "Network error: {error}", "reasonerSearch": "Reasoning model (with search)"
"testSuccess": "{account}: Test successful ({time}ms)", },
"config": "Configuration", "missingApiKey": "Please provide an API key.",
"modelLabel": "Model", "requestFailed": "Request failed.",
"streamMode": "Streaming", "networkError": "Network error: {error}",
"accountSelector": "Account", "testSuccess": "{account}: Test successful ({time}ms)",
"autoRandom": "🤖 Auto / Random", "config": "Configuration",
"apiKeyOptional": "API Key (optional)", "modelLabel": "Model",
"apiKeyDefault": "Default: ...{suffix}", "streamMode": "Streaming",
"apiKeyPlaceholder": "Enter a custom key", "accountSelector": "Account",
"modeManaged": "Managed key mode (uses account pool).", "autoRandom": "🤖 Auto / Random",
"modeDirect": "Direct token mode (requires a valid DeepSeek token).", "apiKeyOptional": "API Key (optional)",
"statusError": "Error", "apiKeyDefault": "Default: ...{suffix}",
"reasoningTrace": "Reasoning Trace", "apiKeyPlaceholder": "Enter a custom key",
"generating": "Generating response...", "modeManaged": "Managed key mode (uses account pool).",
"enterMessage": "Enter a message...", "modeDirect": "Direct token mode (requires a valid DeepSeek token).",
"adminConsoleLabel": "DeepSeek admin console" "statusError": "Error",
}, "reasoningTrace": "Reasoning Trace",
"batchImport": { "generating": "Generating response...",
"templates": { "enterMessage": "Enter a message...",
"full": { "adminConsoleLabel": "DeepSeek admin console"
"name": "Full configuration template", },
"desc": "Includes keys, accounts, and model mapping" "batchImport": {
}, "templates": {
"emailOnly": { "full": {
"name": "Email-only accounts", "name": "Full configuration template",
"desc": "Batch import accounts using email login" "desc": "Includes keys, accounts, and model mapping"
}, },
"mobileOnly": { "emailOnly": {
"name": "Mobile-only accounts", "name": "Email-only accounts",
"desc": "Batch import accounts using mobile login" "desc": "Batch import accounts using email login"
}, },
"keysOnly": { "mobileOnly": {
"name": "API keys only", "name": "Mobile-only accounts",
"desc": "Add API access keys only" "desc": "Batch import accounts using mobile login"
} },
}, "keysOnly": {
"enterJson": "Please provide JSON configuration content.", "name": "API keys only",
"importSuccess": "Import successful: {keys} keys, {accounts} accounts", "desc": "Add API access keys only"
"templateLoaded": "Template loaded: {name}", }
"currentConfigLoaded": "Current configuration loaded.", },
"fetchConfigFailed": "Failed to fetch configuration.", "enterJson": "Please provide JSON configuration content.",
"copySuccess": "Base64 configuration copied to clipboard.", "importSuccess": "Import successful: {keys} keys, {accounts} accounts",
"quickTemplates": "Quick Templates", "templateLoaded": "Template loaded: {name}",
"dataExport": "Data Export", "currentConfigLoaded": "Current configuration loaded.",
"dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.", "fetchConfigFailed": "Failed to fetch configuration.",
"copyBase64": "Copy Base64 config", "copySuccess": "Base64 configuration copied to clipboard.",
"copied": "Copied", "quickTemplates": "Quick Templates",
"variableName": "Variable name", "dataExport": "Data Export",
"jsonEditor": "JSON Editor", "dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.",
"loadCurrentConfig": "Load current config", "copyBase64": "Copy Base64 config",
"applyConfig": "Apply config", "copied": "Copied",
"importing": "Importing...", "variableName": "Variable name",
"importComplete": "Import complete", "jsonEditor": "JSON Editor",
"importSummary": "Imported {keys} API keys and updated {accounts} accounts." "loadCurrentConfig": "Load current config",
}, "applyConfig": "Apply config",
"settings": { "importing": "Importing...",
"loadFailed": "Failed to load settings.", "importComplete": "Import complete",
"nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).", "importSummary": "Imported {keys} API keys and updated {accounts} accounts."
"save": "Save settings", },
"saving": "Saving...", "settings": {
"saveSuccess": "Settings saved and hot reloaded.", "loadFailed": "Failed to load settings.",
"saveFailed": "Failed to save settings.", "nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).",
"securityTitle": "Security", "save": "Save settings",
"jwtExpireHours": "JWT expiry (hours)", "saving": "Saving...",
"newPassword": "New admin password", "saveSuccess": "Settings saved and hot reloaded.",
"newPasswordPlaceholder": "Enter new password (min 4 chars)", "saveFailed": "Failed to save settings.",
"updatePassword": "Update password", "securityTitle": "Security",
"updating": "Updating...", "jwtExpireHours": "JWT expiry (hours)",
"passwordTooShort": "Password must be at least 4 characters.", "newPassword": "New admin password",
"passwordUpdated": "Password updated. Please sign in again.", "newPasswordPlaceholder": "Enter new password (min 4 chars)",
"passwordUpdateFailed": "Failed to update password.", "updatePassword": "Update password",
"runtimeTitle": "Concurrency & Queue", "updating": "Updating...",
"accountMaxInflight": "Per-account max inflight", "passwordTooShort": "Password must be at least 4 characters.",
"accountMaxQueue": "Account max queue size", "passwordUpdated": "Password updated. Please sign in again.",
"globalMaxInflight": "Global max inflight", "passwordUpdateFailed": "Failed to update password.",
"behaviorTitle": "Behavior", "runtimeTitle": "Concurrency & Queue",
"toolcallMode": "Toolcall mode", "accountMaxInflight": "Per-account max inflight",
"earlyEmitConfidence": "Early emit confidence", "accountMaxQueue": "Account max queue size",
"responsesTTL": "Responses store TTL (seconds)", "globalMaxInflight": "Global max inflight",
"embeddingsProvider": "Embeddings provider", "behaviorTitle": "Behavior",
"modelTitle": "Model mapping", "toolcallMode": "Toolcall mode",
"claudeMapping": "Claude mapping (JSON)", "earlyEmitConfidence": "Early emit confidence",
"modelAliases": "Model aliases (JSON)", "responsesTTL": "Responses store TTL (seconds)",
"backupTitle": "Backup & Restore", "embeddingsProvider": "Embeddings provider",
"loadExport": "Load current export", "modelTitle": "Model mapping",
"importModeMerge": "Merge import (default)", "claudeMapping": "Claude mapping (JSON)",
"importModeReplace": "Replace all import", "modelAliases": "Model aliases (JSON)",
"importNow": "Import now", "autoDeleteTitle": "Auto Delete Sessions",
"importing": "Importing...", "autoDeleteDesc": "When enabled, all sessions will be automatically deleted after each request completes.",
"importPlaceholder": "Paste config JSON to import", "autoDeleteSessions": "Auto delete sessions",
"importEmpty": "Please input import JSON.", "autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.",
"importInvalidJson": "Import JSON is invalid.", "backupTitle": "Backup & Restore",
"importFailed": "Import failed.", "loadExport": "Load current export",
"importSuccess": "Config imported (mode: {mode}).", "importModeMerge": "Merge import (default)",
"exportFailed": "Export failed.", "importModeReplace": "Replace all import",
"exportLoaded": "Current export loaded.", "importNow": "Import now",
"exportJson": "Export JSON", "importing": "Importing...",
"invalidJsonField": "{field} is not a valid JSON object.", "importPlaceholder": "Paste config JSON to import",
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.", "importEmpty": "Please input import JSON.",
"vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.", "importInvalidJson": "Import JSON is invalid.",
"autoFetchPaused": "Auto loading paused after {count} failures: {error}", "importFailed": "Import failed.",
"retryLoad": "Retry now" "importSuccess": "Config imported (mode: {mode}).",
}, "exportFailed": "Export failed.",
"login": { "exportLoaded": "Current export loaded.",
"welcome": "Welcome back", "exportJson": "Export JSON",
"subtitle": "Enter your admin key to continue", "invalidJsonField": "{field} is not a valid JSON object.",
"adminKeyLabel": "Admin key", "defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
"adminKeyPlaceholder": "Enter your admin key...", "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.",
"rememberSession": "Remember this session", "autoFetchPaused": "Auto loading paused after {count} failures: {error}",
"signIn": "Sign in", "retryLoad": "Retry now"
"secureConnection": "Secure connection", },
"adminPortal": "DS2API admin portal", "login": {
"signInFailed": "Sign-in failed.", "welcome": "Welcome back",
"networkError": "Network error: {error}" "subtitle": "Enter your admin key to continue",
}, "adminKeyLabel": "Admin key",
"vercel": { "adminKeyPlaceholder": "Enter your admin key...",
"tokenRequired": "Vercel access token is required.", "rememberSession": "Remember this session",
"projectRequired": "Project ID is required.", "signIn": "Sign in",
"syncFailed": "Sync failed.", "secureConnection": "Secure connection",
"networkError": "Network error.", "adminPortal": "DS2API admin portal",
"title": "Vercel Deployment", "signInFailed": "Sign-in failed.",
"description": "Sync the current keys and accounts directly to Vercel environment variables.", "networkError": "Network error: {error}"
"tokenLabel": "Vercel Access Token", },
"getToken": "Get token", "vercel": {
"tokenPlaceholderPreconfig": "Using preconfigured token", "tokenRequired": "Vercel access token is required.",
"tokenPlaceholder": "Enter Vercel access token", "projectRequired": "Project ID is required.",
"projectIdLabel": "Project ID", "syncFailed": "Sync failed.",
"projectIdHint": "Find it in Project Settings → General.", "networkError": "Network error.",
"teamIdLabel": "Team ID", "title": "Vercel Deployment",
"optional": "optional", "description": "Sync the current keys and accounts directly to Vercel environment variables.",
"syncing": "Syncing...", "tokenLabel": "Vercel Access Token",
"syncRedeploy": "Sync & redeploy", "getToken": "Get token",
"redeployHint": "This triggers a Vercel redeploy and usually takes 3060 seconds.", "tokenPlaceholderPreconfig": "Using preconfigured token",
"syncSucceeded": "Sync succeeded", "tokenPlaceholder": "Enter Vercel access token",
"syncFailedLabel": "Sync failed", "projectIdLabel": "Project ID",
"openDeployment": "Open deployment", "projectIdHint": "Find it in Project Settings → General.",
"statusSynced": "Synced", "teamIdLabel": "Team ID",
"statusNotSynced": "Not synced", "optional": "optional",
"statusNeverSynced": "Never synced", "syncing": "Syncing...",
"lastSyncTime": "Last sync: {time}", "syncRedeploy": "Sync & redeploy",
"pollPaused": "Status polling paused after {count} failures.", "redeployHint": "This triggers a Vercel redeploy and usually takes 3060 seconds.",
"manualRefresh": "Refresh manually", "syncSucceeded": "Sync succeeded",
"howItWorks": "How it works", "syncFailedLabel": "Sync failed",
"steps": { "openDeployment": "Open deployment",
"one": "The current configuration (keys and accounts) is exported as JSON.", "statusSynced": "Synced",
"two": "The JSON is Base64-encoded for safe formatting.", "statusNotSynced": "Not synced",
"three": "Update the env var in Vercel:", "statusNeverSynced": "Never synced",
"four": "Trigger a redeploy to apply the updated environment variables." "lastSyncTime": "Last sync: {time}",
} "pollPaused": "Status polling paused after {count} failures.",
} "manualRefresh": "Refresh manually",
} "howItWorks": "How it works",
"steps": {
"one": "The current configuration (keys and accounts) is exported as JSON.",
"two": "The JSON is Base64-encoded for safe formatting.",
"three": "Update the env var in Vercel:",
"four": "Trigger a redeploy to apply the updated environment variables."
}
}
}

View File

@@ -1,297 +1,305 @@
{ {
"language": { "language": {
"label": "语言", "label": "语言",
"english": "English", "english": "English",
"chinese": "中文" "chinese": "中文"
}, },
"nav": { "nav": {
"accounts": { "accounts": {
"label": "账号管理", "label": "账号管理",
"desc": "管理 DeepSeek 账号池" "desc": "管理 DeepSeek 账号池"
}, },
"test": { "test": {
"label": "API 测试", "label": "API 测试",
"desc": "测试 API 连接与响应" "desc": "测试 API 连接与响应"
}, },
"import": { "import": {
"label": "批量导入", "label": "批量导入",
"desc": "批量导入账号配置" "desc": "批量导入账号配置"
}, },
"vercel": { "vercel": {
"label": "Vercel 同步", "label": "Vercel 同步",
"desc": "同步配置到 Vercel" "desc": "同步配置到 Vercel"
}, },
"settings": { "settings": {
"label": "设置中心", "label": "设置中心",
"desc": "在线修改系统设置与配置" "desc": "在线修改系统设置与配置"
} }
}, },
"sidebar": { "sidebar": {
"onlineAdminConsole": "在线管理面板", "onlineAdminConsole": "在线管理面板",
"systemStatus": "系统状态", "systemStatus": "系统状态",
"statusOnline": "在线", "statusOnline": "在线",
"accounts": "账号", "accounts": "账号",
"keys": "密钥", "keys": "密钥",
"signOut": "退出登录" "signOut": "退出登录"
}, },
"auth": { "auth": {
"expired": "认证已过期,请重新登录", "expired": "认证已过期,请重新登录",
"checking": "正在检查登录状态..." "checking": "正在检查登录状态..."
}, },
"errors": { "errors": {
"fetchConfig": "获取配置失败: {error}" "fetchConfig": "获取配置失败: {error}"
}, },
"actions": { "actions": {
"cancel": "取消", "cancel": "取消",
"add": "添加", "add": "添加",
"delete": "删除", "delete": "删除",
"copy": "复制", "copy": "复制",
"generate": "生成", "generate": "生成",
"test": "测试", "test": "测试",
"testing": "正在测试...", "testing": "正在测试...",
"loading": "加载中..." "loading": "加载中..."
}, },
"messages": { "messages": {
"deleted": "删除成功", "deleted": "删除成功",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"failedToAdd": "添加失败", "failedToAdd": "添加失败",
"networkError": "网络错误", "networkError": "网络错误",
"requestFailed": "请求失败", "requestFailed": "请求失败",
"generationStopped": "已停止生成", "generationStopped": "已停止生成",
"invalidJson": "无效的 JSON 格式", "invalidJson": "无效的 JSON 格式",
"importFailed": "导入失败", "importFailed": "导入失败",
"copyFailed": "复制失败" "copyFailed": "复制失败"
}, },
"landing": { "landing": {
"adminConsole": "管理面板", "adminConsole": "管理面板",
"apiStatus": "API 状态", "apiStatus": "API 状态",
"features": { "features": {
"compatibility": { "compatibility": {
"title": "全面兼容", "title": "全面兼容",
"desc": "适配 OpenAI 与 Claude 格式" "desc": "适配 OpenAI 与 Claude 格式"
}, },
"loadBalancing": { "loadBalancing": {
"title": "负载均衡", "title": "负载均衡",
"desc": "智能轮询,稳定高效" "desc": "智能轮询,稳定高效"
}, },
"reasoning": { "reasoning": {
"title": "深度思考", "title": "深度思考",
"desc": "支持推理过程输出" "desc": "支持推理过程输出"
}, },
"search": { "search": {
"title": "联网搜索", "title": "联网搜索",
"desc": "集成原生网页搜索能力" "desc": "集成原生网页搜索能力"
} }
} }
}, },
"accountManager": { "accountManager": {
"addKeySuccess": "API 密钥添加成功", "addKeySuccess": "API 密钥添加成功",
"addAccountSuccess": "账号添加成功", "addAccountSuccess": "账号添加成功",
"requiredFields": "需要填写密码以及邮箱或手机号", "requiredFields": "需要填写密码以及邮箱或手机号",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?", "deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?", "deleteAccountConfirm": "确定要删除此账号吗?",
"invalidIdentifier": "账号标识无效,无法执行操作", "invalidIdentifier": "账号标识无效,无法执行操作",
"testAllConfirm": "测试所有账号的 API 连通性?", "testAllConfirm": "测试所有账号的 API 连通性?",
"testAllCompleted": "完成:{success}/{total} 可用", "testAllCompleted": "完成:{success}/{total} 可用",
"testFailed": "测试失败: {error}", "testFailed": "测试失败: {error}",
"available": "可用", "available": "可用",
"inUse": "正在使用", "inUse": "正在使用",
"totalPool": "账号池总数", "totalPool": "账号池总数",
"accountsUnit": "个账号", "accountsUnit": "个账号",
"threadsUnit": "线程", "threadsUnit": "线程",
"apiKeysTitle": "API 密钥", "apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池", "apiKeysDesc": "管理 API 访问密钥池",
"addKey": "添加密钥", "addKey": "添加密钥",
"copied": "已复制", "copied": "已复制",
"copyKeyTitle": "复制密钥", "copyKeyTitle": "复制密钥",
"deleteKeyTitle": "删除密钥", "deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥", "noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号", "accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池", "accountsDesc": "管理 DeepSeek 账号池",
"testAll": "测试全部", "testAll": "测试全部",
"addAccount": "添加账号", "addAccount": "添加账号",
"testingAllAccounts": "正在测试所有账号...", "testingAllAccounts": "正在测试所有账号...",
"sessionActive": "已建立会话", "sessionActive": "已建立会话",
"reauthRequired": "需重新登录", "reauthRequired": "需重新登录",
"testStatusFailed": "上次测试失败", "testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号", "noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥", "modalAddKeyTitle": "添加 API 密钥",
"newKeyLabel": "新密钥值", "newKeyLabel": "新密钥值",
"newKeyPlaceholder": "输入自定义 API 密钥", "newKeyPlaceholder": "输入自定义 API 密钥",
"generate": "生成", "generate": "生成",
"generateHint": "点击「生成」自动创建随机密钥", "generateHint": "点击「生成」自动创建随机密钥",
"addKeyLoading": "添加中...", "addKeyLoading": "添加中...",
"addKeyAction": "添加密钥", "addKeyAction": "添加密钥",
"modalAddAccountTitle": "添加 DeepSeek 账号", "modalAddAccountTitle": "添加 DeepSeek 账号",
"emailOptional": "邮箱 (可选)", "emailOptional": "邮箱 (可选)",
"mobileOptional": "手机号 (可选)", "mobileOptional": "手机号 (可选)",
"passwordLabel": "密码", "passwordLabel": "密码",
"passwordPlaceholder": "账号密码", "passwordPlaceholder": "账号密码",
"addAccountLoading": "添加中...", "addAccountLoading": "添加中...",
"addAccountAction": "添加账号", "addAccountAction": "添加账号",
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号", "pageInfo": "第 {current}/{total} 页,共 {count} 个账号",
"searchPlaceholder": "搜索账号...", "searchPlaceholder": "搜索账号...",
"searchNoResults": "未找到匹配的账号" "searchNoResults": "未找到匹配的账号",
}, "sessionCount": "会话: {count}",
"apiTester": { "deleteAllSessions": "删除所有会话",
"defaultMessage": "你好,请用一句话介绍你自己。", "deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
"models": { "deleteAllSessionsSuccess": "删除成功"
"chat": "非思考模型", },
"reasoner": "思考模型", "apiTester": {
"chatSearch": "非思考模型 (带搜索)", "defaultMessage": "你好,请用一句话介绍你自己。",
"reasonerSearch": "思考模型 (带搜索)" "models": {
}, "chat": "非思考模型",
"missingApiKey": "请提供 API 密钥", "reasoner": "思考模型",
"requestFailed": "请求失败", "chatSearch": "非思考模型 (带搜索)",
"networkError": "网络错误: {error}", "reasonerSearch": "思考模型 (带搜索)"
"testSuccess": "{account}: 测试成功 ({time}ms)", },
"config": "配置", "missingApiKey": "请提供 API 密钥",
"modelLabel": "模型", "requestFailed": "请求失败",
"streamMode": "流式模式", "networkError": "网络错误: {error}",
"accountSelector": "选择账号", "testSuccess": "{account}: 测试成功 ({time}ms)",
"autoRandom": "🤖 自动 / 随机", "config": "配置",
"apiKeyOptional": "API 密钥 (可选)", "modelLabel": "模型",
"apiKeyDefault": "默认: ...{suffix}", "streamMode": "流式模式",
"apiKeyPlaceholder": "输入自定义密钥", "accountSelector": "选择账号",
"modeManaged": "当前使用托管 key 模式(会走账号池)。", "autoRandom": "🤖 自动 / 随机",
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token", "apiKeyOptional": "API 密钥 (可选)",
"statusError": "错误", "apiKeyDefault": "默认: ...{suffix}",
"reasoningTrace": "思维链过程", "apiKeyPlaceholder": "输入自定义密钥",
"generating": "正在生成响应...", "modeManaged": "当前使用托管 key 模式(会走账号池)。",
"enterMessage": "输入消息...", "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token",
"adminConsoleLabel": "DeepSeek 管理员界面" "statusError": "错误",
}, "reasoningTrace": "思维链过程",
"batchImport": { "generating": "正在生成响应...",
"templates": { "enterMessage": "输入消息...",
"full": { "adminConsoleLabel": "DeepSeek 管理员界面"
"name": "全量配置模板", },
"desc": "包含密钥、账号及模型映射" "batchImport": {
}, "templates": {
"emailOnly": { "full": {
"name": "仅邮箱账号", "name": "全量配置模板",
"desc": "批量导入邮箱格式账号" "desc": "包含密钥、账号及模型映射"
}, },
"mobileOnly": { "emailOnly": {
"name": "仅手机号账号", "name": "仅邮箱账号",
"desc": "批量导入手机号格式账号" "desc": "批量导入邮箱格式账号"
}, },
"keysOnly": { "mobileOnly": {
"name": "仅 API 密钥", "name": "仅手机号账号",
"desc": "仅添加 API 访问密钥" "desc": "批量导入手机号格式账号"
} },
}, "keysOnly": {
"enterJson": "请输入 JSON 配置内容", "name": "仅 API 密钥",
"importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号", "desc": "仅添加 API 访问密钥"
"templateLoaded": "已加载模板: {name}", }
"currentConfigLoaded": "当前配置已加载", },
"fetchConfigFailed": "获取配置失败", "enterJson": "请输入 JSON 配置内容",
"copySuccess": "Base64 配置已复制到剪贴板", "importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号",
"quickTemplates": "快速模板", "templateLoaded": "已加载模板: {name}",
"dataExport": "数据导出", "currentConfigLoaded": "当前配置已加载",
"dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。", "fetchConfigFailed": "获取配置失败",
"copyBase64": "复制 Base64 配置", "copySuccess": "Base64 配置已复制到剪贴板",
"copied": "已复制", "quickTemplates": "快速模板",
"variableName": "变量名", "dataExport": "数据导出",
"jsonEditor": "JSON 编辑器", "dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。",
"loadCurrentConfig": "加载当前配置", "copyBase64": "复制 Base64 配置",
"applyConfig": "应用配置", "copied": "已复制",
"importing": "正在导入...", "variableName": "变量名",
"importComplete": "导入操作已完成", "jsonEditor": "JSON 编辑器",
"importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。" "loadCurrentConfig": "加载当前配置",
}, "applyConfig": "应用配置",
"settings": { "importing": "正在导入...",
"loadFailed": "加载设置失败", "importComplete": "导入操作已完成",
"nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status}", "importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。"
"save": "保存设置", },
"saving": "保存中...", "settings": {
"saveSuccess": "设置已保存并热更新生效", "loadFailed": "加载设置失败",
"saveFailed": "保存设置失败", "nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status}",
"securityTitle": "安全设置", "save": "保存设置",
"jwtExpireHours": "JWT 有效期(小时)", "saving": "保存中...",
"newPassword": "面板新密码", "saveSuccess": "设置已保存并热更新生效",
"newPasswordPlaceholder": "输入新密码(至少 4 位)", "saveFailed": "保存设置失败",
"updatePassword": "修改密码", "securityTitle": "安全设置",
"updating": "更新中...", "jwtExpireHours": "JWT 有效期(小时)",
"passwordTooShort": "新密码至少 4 位", "newPassword": "面板新密码",
"passwordUpdated": "密码已更新,需重新登录", "newPasswordPlaceholder": "输入新密码(至少 4 位)",
"passwordUpdateFailed": "密码更新失败", "updatePassword": "修改密码",
"runtimeTitle": "并发与队列", "updating": "更新中...",
"accountMaxInflight": "每账号并发上限", "passwordTooShort": "新密码至少 4 位",
"accountMaxQueue": "账号等待队列上限", "passwordUpdated": "密码已更新,需重新登录",
"globalMaxInflight": "全局并发上限", "passwordUpdateFailed": "密码更新失败",
"behaviorTitle": "行为设置", "runtimeTitle": "并发与队列",
"toolcallMode": "Toolcall 模式", "accountMaxInflight": "每账号并发上限",
"earlyEmitConfidence": "早发置信度", "accountMaxQueue": "账号等待队列上限",
"responsesTTL": "Responses 缓存 TTL", "globalMaxInflight": "全局并发上限",
"embeddingsProvider": "Embeddings Provider", "behaviorTitle": "行为设置",
"modelTitle": "模型映射", "toolcallMode": "Toolcall 模式",
"claudeMapping": "Claude 映射JSON", "earlyEmitConfidence": "早发置信度",
"modelAliases": "模型别名JSON", "responsesTTL": "Responses 缓存 TTL",
"backupTitle": "备份与恢复", "embeddingsProvider": "Embeddings Provider",
"loadExport": "加载当前导出", "modelTitle": "模型映射",
"importModeMerge": "合并导入(默认", "claudeMapping": "Claude 映射JSON",
"importModeReplace": "全量覆盖导入", "modelAliases": "模型别名JSON",
"importNow": "立即导入", "autoDeleteTitle": "自动删除会话",
"importing": "导入中...", "autoDeleteDesc": "开启后,每次请求完成后会自动删除该账号的所有会话记录。",
"importPlaceholder": "粘贴要导入的 JSON 配置", "autoDeleteSessions": "自动删除会话",
"importEmpty": "请先输入导入 JSON", "autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。",
"importInvalidJson": "导入 JSON 格式无效", "backupTitle": "备份与恢复",
"importFailed": "导入失败", "loadExport": "加载当前导出",
"importSuccess": "配置导入成功(模式:{mode}", "importModeMerge": "合并导入(默认",
"exportFailed": "导出失败", "importModeReplace": "全量覆盖导入",
"exportLoaded": "已加载当前配置导出", "importNow": "立即导入",
"exportJson": "导出 JSON", "importing": "导入中...",
"invalidJsonField": "{field} 不是有效 JSON 对象", "importPlaceholder": "粘贴要导入的 JSON 配置",
"defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。", "importEmpty": "请先输入导入 JSON",
"vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。", "importInvalidJson": "导入 JSON 格式无效",
"autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error}", "importFailed": "导入失败",
"retryLoad": "立即重试" "importSuccess": "配置导入成功(模式:{mode}",
}, "exportFailed": "导出失败",
"login": { "exportLoaded": "已加载当前配置导出",
"welcome": "欢迎回来", "exportJson": "导出 JSON",
"subtitle": "请输入管理员密钥以继续", "invalidJsonField": "{field} 不是有效 JSON 对象",
"adminKeyLabel": "管理员密钥", "defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。",
"adminKeyPlaceholder": "输入您的管理员密钥...", "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。",
"rememberSession": "记住登录状态", "autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error}",
"signIn": "登录", "retryLoad": "立即重试"
"secureConnection": "安全连接", },
"adminPortal": "DS2API 管理员门户", "login": {
"signInFailed": "登录失败", "welcome": "欢迎回来",
"networkError": "网络错误: {error}" "subtitle": "请输入管理员密钥以继续",
}, "adminKeyLabel": "管理员密钥",
"vercel": { "adminKeyPlaceholder": "输入您的管理员密钥...",
"tokenRequired": "需要 Vercel 访问令牌", "rememberSession": "记住登录状态",
"projectRequired": "需要项目 ID", "signIn": "登录",
"syncFailed": "同步失败", "secureConnection": "安全连接",
"networkError": "网络错误", "adminPortal": "DS2API 管理员门户",
"title": "Vercel 部署", "signInFailed": "登录失败",
"description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。", "networkError": "网络错误: {error}"
"tokenLabel": "Vercel 访问令牌", },
"getToken": "获取令牌", "vercel": {
"tokenPlaceholderPreconfig": "正在使用预配置的令牌", "tokenRequired": "需要 Vercel 访问令牌",
"tokenPlaceholder": "输入 Vercel 访问令牌", "projectRequired": "需要项目 ID",
"projectIdLabel": "项目 ID", "syncFailed": "同步失败",
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到", "networkError": "网络错误",
"teamIdLabel": "团队 ID", "title": "Vercel 部署",
"optional": "可选", "description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。",
"syncing": "正在同步...", "tokenLabel": "Vercel 访问令牌",
"syncRedeploy": "同步并重新部署", "getToken": "获取令牌",
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。", "tokenPlaceholderPreconfig": "正在使用预配置的令牌",
"syncSucceeded": "同步成功", "tokenPlaceholder": "输入 Vercel 访问令牌",
"syncFailedLabel": "同步失败", "projectIdLabel": "项目 ID",
"openDeployment": "访问部署地址", "projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
"statusSynced": "已同步", "teamIdLabel": "团队 ID",
"statusNotSynced": "未同步", "optional": "可选",
"statusNeverSynced": "从未同步", "syncing": "正在同步...",
"lastSyncTime": "上次同步: {time}", "syncRedeploy": "同步并重新部署",
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。", "redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
"manualRefresh": "手动刷新", "syncSucceeded": "同步成功",
"howItWorks": "工作原理", "syncFailedLabel": "同步失败",
"steps": { "openDeployment": "访问部署地址",
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。", "statusSynced": "已同步",
"two": "JSON 被编码为 Base64 以确保格式兼容性。", "statusNotSynced": "未同步",
"three": "更新 Vercel 项目中的环境变量:", "statusNeverSynced": "从未同步",
"four": "触发重新部署以应用新的环境变量。" "lastSyncTime": "上次同步: {time}",
} "pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
} "manualRefresh": "手动刷新",
} "howItWorks": "工作原理",
"steps": {
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
"two": "JSON 被编码为 Base64 以确保格式兼容性。",
"three": "更新 Vercel 项目中的环境变量:",
"four": "触发重新部署以应用新的环境变量。"
}
}
}