diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 9a13b57c..85400c6f 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -424,10 +424,17 @@ type TestAccountRequest struct { } type SyncFromCRSRequest struct { - BaseURL string `json:"base_url" binding:"required"` - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` - SyncProxies *bool `json:"sync_proxies"` + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + SyncProxies *bool `json:"sync_proxies"` + SelectedAccountIDs []string `json:"selected_account_ids"` +} + +type PreviewFromCRSRequest struct { + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` } // Test handles testing account connectivity with SSE streaming @@ -466,10 +473,11 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) { } result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{ - BaseURL: req.BaseURL, - Username: req.Username, - Password: req.Password, - SyncProxies: syncProxies, + BaseURL: req.BaseURL, + Username: req.Username, + Password: req.Password, + SyncProxies: syncProxies, + SelectedAccountIDs: req.SelectedAccountIDs, }) if err != nil { // Provide detailed error message for CRS sync failures @@ -480,6 +488,28 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) { response.Success(c, result) } +// PreviewFromCRS handles previewing accounts from CRS before sync +// POST /api/v1/admin/accounts/sync/crs/preview +func (h *AccountHandler) PreviewFromCRS(c *gin.Context) { + var req PreviewFromCRSRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.crsSyncService.PreviewFromCRS(c.Request.Context(), service.SyncFromCRSInput{ + BaseURL: req.BaseURL, + Username: req.Username, + Password: req.Password, + }) + if err != nil { + response.InternalError(c, "CRS preview failed: "+err.Error()) + return + } + + response.Success(c, result) +} + // Refresh handles refreshing account credentials // POST /api/v1/admin/accounts/:id/refresh func (h *AccountHandler) Refresh(c *gin.Context) { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 7fb7d4ed..d73e0521 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -282,6 +282,34 @@ func (r *accountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID return &accounts[0], nil } +func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT id, extra->>'crs_account_id' + FROM accounts + WHERE deleted_at IS NULL + AND extra->>'crs_account_id' IS NOT NULL + AND extra->>'crs_account_id' != '' + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make(map[string]int64) + for rows.Next() { + var id int64 + var crsID string + if err := rows.Scan(&id, &crsID); err != nil { + return nil, err + } + result[crsID] = id + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + func (r *accountRepository) Update(ctx context.Context, account *service.Account) error { if account == nil { return nil diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index bd6788b2..39c5d2fc 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -209,6 +209,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) + accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 9bf58988..6c0cca31 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -25,6 +25,9 @@ type AccountRepository interface { // GetByCRSAccountID finds an account previously synced from CRS. // Returns (nil, nil) if not found. GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) + // ListCRSAccountIDs returns a map of crs_account_id -> local account ID + // for all accounts that have been synced from CRS. + ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) Update(ctx context.Context, account *Account) error Delete(ctx context.Context, id int64) error diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index af3a3784..25bd0576 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -54,6 +54,10 @@ func (s *accountRepoStub) GetByCRSAccountID(ctx context.Context, crsAccountID st panic("unexpected GetByCRSAccountID call") } +func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + panic("unexpected ListCRSAccountIDs call") +} + func (s *accountRepoStub) Update(ctx context.Context, account *Account) error { panic("unexpected Update call") } diff --git a/backend/internal/service/crs_sync_helpers_test.go b/backend/internal/service/crs_sync_helpers_test.go new file mode 100644 index 00000000..0dc05335 --- /dev/null +++ b/backend/internal/service/crs_sync_helpers_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "testing" +) + +func TestBuildSelectedSet(t *testing.T) { + tests := []struct { + name string + ids []string + wantNil bool + wantSize int + }{ + { + name: "nil input returns nil (backward compatible: create all)", + ids: nil, + wantNil: true, + }, + { + name: "empty slice returns empty map (create none)", + ids: []string{}, + wantNil: false, + wantSize: 0, + }, + { + name: "single ID", + ids: []string{"abc-123"}, + wantNil: false, + wantSize: 1, + }, + { + name: "multiple IDs", + ids: []string{"a", "b", "c"}, + wantNil: false, + wantSize: 3, + }, + { + name: "duplicate IDs are deduplicated", + ids: []string{"a", "a", "b"}, + wantNil: false, + wantSize: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSelectedSet(tt.ids) + if tt.wantNil { + if got != nil { + t.Errorf("buildSelectedSet(%v) = %v, want nil", tt.ids, got) + } + return + } + if got == nil { + t.Fatalf("buildSelectedSet(%v) = nil, want non-nil map", tt.ids) + } + if len(got) != tt.wantSize { + t.Errorf("buildSelectedSet(%v) has %d entries, want %d", tt.ids, len(got), tt.wantSize) + } + // Verify all unique IDs are present + for _, id := range tt.ids { + if _, ok := got[id]; !ok { + t.Errorf("buildSelectedSet(%v) missing key %q", tt.ids, id) + } + } + }) + } +} + +func TestShouldCreateAccount(t *testing.T) { + tests := []struct { + name string + crsID string + selectedSet map[string]struct{} + want bool + }{ + { + name: "nil set allows all (backward compatible)", + crsID: "any-id", + selectedSet: nil, + want: true, + }, + { + name: "empty set blocks all", + crsID: "any-id", + selectedSet: map[string]struct{}{}, + want: false, + }, + { + name: "ID in set is allowed", + crsID: "abc-123", + selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}}, + want: true, + }, + { + name: "ID not in set is blocked", + crsID: "xyz-789", + selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldCreateAccount(tt.crsID, tt.selectedSet) + if got != tt.want { + t.Errorf("shouldCreateAccount(%q, %v) = %v, want %v", + tt.crsID, tt.selectedSet, got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index a6ccb967..040b2357 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -45,10 +45,11 @@ func NewCRSSyncService( } type SyncFromCRSInput struct { - BaseURL string - Username string - Password string - SyncProxies bool + BaseURL string + Username string + Password string + SyncProxies bool + SelectedAccountIDs []string // if non-empty, only create new accounts with these CRS IDs } type SyncFromCRSItemResult struct { @@ -190,25 +191,27 @@ type crsGeminiAPIKeyAccount struct { Extra map[string]any `json:"extra"` } -func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { +// fetchCRSExport validates the connection parameters, authenticates with CRS, +// and returns the exported accounts. Shared by SyncFromCRS and PreviewFromCRS. +func (s *CRSSyncService) fetchCRSExport(ctx context.Context, baseURL, username, password string) (*crsExportResponse, error) { if s.cfg == nil { return nil, errors.New("config is not available") } - baseURL := strings.TrimSpace(input.BaseURL) + normalizedURL := strings.TrimSpace(baseURL) if s.cfg.Security.URLAllowlist.Enabled { - normalized, err := normalizeBaseURL(baseURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts) + normalized, err := normalizeBaseURL(normalizedURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts) if err != nil { return nil, err } - baseURL = normalized + normalizedURL = normalized } else { - normalized, err := urlvalidator.ValidateURLFormat(baseURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + normalized, err := urlvalidator.ValidateURLFormat(normalizedURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) if err != nil { return nil, fmt.Errorf("invalid base_url: %w", err) } - baseURL = normalized + normalizedURL = normalized } - if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" { + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { return nil, errors.New("username and password are required") } @@ -221,12 +224,16 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput client = &http.Client{Timeout: 20 * time.Second} } - adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password) + adminToken, err := crsLogin(ctx, client, normalizedURL, username, password) if err != nil { return nil, err } - exported, err := crsExportAccounts(ctx, client, baseURL, adminToken) + return crsExportAccounts(ctx, client, normalizedURL, adminToken) +} + +func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { + exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password) if err != nil { return nil, err } @@ -241,6 +248,8 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput ), } + selectedSet := buildSelectedSet(input.SelectedAccountIDs) + var proxies []Proxy if input.SyncProxies { proxies, _ = s.proxyRepo.ListActive(ctx) @@ -329,6 +338,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformAnthropic, @@ -446,6 +462,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformAnthropic, @@ -569,6 +592,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformOpenAI, @@ -690,6 +720,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformOpenAI, @@ -798,6 +835,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformGemini, @@ -909,6 +953,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformGemini, @@ -1253,3 +1304,102 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account return newCredentials } + +// buildSelectedSet converts a slice of selected CRS account IDs to a set for O(1) lookup. +// Returns nil if ids is nil (field not sent → backward compatible: create all). +// Returns an empty map if ids is non-nil but empty (user selected none → create none). +func buildSelectedSet(ids []string) map[string]struct{} { + if ids == nil { + return nil + } + set := make(map[string]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + return set +} + +// shouldCreateAccount checks if a new CRS account should be created based on user selection. +// Returns true if selectedSet is nil (backward compatible: create all) or if crsID is in the set. +func shouldCreateAccount(crsID string, selectedSet map[string]struct{}) bool { + if selectedSet == nil { + return true + } + _, ok := selectedSet[crsID] + return ok +} + +// PreviewFromCRSResult contains the preview of accounts from CRS before sync. +type PreviewFromCRSResult struct { + NewAccounts []CRSPreviewAccount `json:"new_accounts"` + ExistingAccounts []CRSPreviewAccount `json:"existing_accounts"` +} + +// CRSPreviewAccount represents a single account in the preview result. +type CRSPreviewAccount struct { + CRSAccountID string `json:"crs_account_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` +} + +// PreviewFromCRS connects to CRS, fetches all accounts, and classifies them +// as new or existing by batch-querying local crs_account_id mappings. +func (s *CRSSyncService) PreviewFromCRS(ctx context.Context, input SyncFromCRSInput) (*PreviewFromCRSResult, error) { + exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password) + if err != nil { + return nil, err + } + + // Batch query all existing CRS account IDs + existingCRSIDs, err := s.accountRepo.ListCRSAccountIDs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list existing CRS accounts: %w", err) + } + + result := &PreviewFromCRSResult{ + NewAccounts: make([]CRSPreviewAccount, 0), + ExistingAccounts: make([]CRSPreviewAccount, 0), + } + + classify := func(crsID, kind, name, platform, accountType string) { + preview := CRSPreviewAccount{ + CRSAccountID: crsID, + Kind: kind, + Name: defaultName(name, crsID), + Platform: platform, + Type: accountType, + } + if _, exists := existingCRSIDs[crsID]; exists { + result.ExistingAccounts = append(result.ExistingAccounts, preview) + } else { + result.NewAccounts = append(result.NewAccounts, preview) + } + } + + for _, src := range exported.Data.ClaudeAccounts { + authType := strings.TrimSpace(src.AuthType) + if authType == "" { + authType = AccountTypeOAuth + } + classify(src.ID, src.Kind, src.Name, PlatformAnthropic, authType) + } + for _, src := range exported.Data.ClaudeConsoleAccounts { + classify(src.ID, src.Kind, src.Name, PlatformAnthropic, AccountTypeAPIKey) + } + for _, src := range exported.Data.OpenAIOAuthAccounts { + classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeOAuth) + } + for _, src := range exported.Data.OpenAIResponsesAccounts { + classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeAPIKey) + } + for _, src := range exported.Data.GeminiOAuthAccounts { + classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeOAuth) + } + for _, src := range exported.Data.GeminiAPIKeyAccounts { + classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeAPIKey) + } + + return result, nil +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 8dbb30ab..b4b93ace 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -77,6 +77,9 @@ func (m *mockAccountRepoForPlatform) Create(ctx context.Context, account *Accoun func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) { return nil, nil } +func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + return nil, nil +} func (m *mockAccountRepoForPlatform) Update(ctx context.Context, account *Account) error { return nil } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index d03b75df..080352ba 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -66,6 +66,9 @@ func (m *mockAccountRepoForGemini) Create(ctx context.Context, account *Account) func (m *mockAccountRepoForGemini) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) { return nil, nil } +func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + return nil, nil +} func (m *mockAccountRepoForGemini) Update(ctx context.Context, account *Account) error { return nil } func (m *mockAccountRepoForGemini) Delete(ctx context.Context, id int64) error { return nil } func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) { diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 0dec4da5..4cb1a6f2 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -327,11 +327,34 @@ export async function getAvailableModels(id: number): Promise { return data } +export interface CRSPreviewAccount { + crs_account_id: string + kind: string + name: string + platform: string + type: string +} + +export interface PreviewFromCRSResult { + new_accounts: CRSPreviewAccount[] + existing_accounts: CRSPreviewAccount[] +} + +export async function previewFromCrs(params: { + base_url: string + username: string + password: string +}): Promise { + const { data } = await apiClient.post('/admin/accounts/sync/crs/preview', params) + return data +} + export async function syncFromCrs(params: { base_url: string username: string password: string sync_proxies?: boolean + selected_account_ids?: string[] }): Promise<{ created: number updated: number @@ -345,7 +368,19 @@ export async function syncFromCrs(params: { error?: string }> }> { - const { data } = await apiClient.post('/admin/accounts/sync/crs', params) + const { data } = await apiClient.post<{ + created: number + updated: number + skipped: number + failed: number + items: Array<{ + crs_account_id: string + kind: string + name: string + action: string + error?: string + }> + }>('/admin/accounts/sync/crs', params) return data } @@ -442,6 +477,7 @@ export const accountsAPI = { batchCreate, batchUpdateCredentials, bulkUpdate, + previewFromCrs, syncFromCrs, exportData, importData, diff --git a/frontend/src/components/account/SyncFromCrsModal.vue b/frontend/src/components/account/SyncFromCrsModal.vue index 4bd0320a..5191b7cc 100644 --- a/frontend/src/components/account/SyncFromCrsModal.vue +++ b/frontend/src/components/account/SyncFromCrsModal.vue @@ -6,15 +6,20 @@ close-on-click-outside @close="handleClose" > -
+ +
{{ t('admin.accounts.syncFromCrsDesc') }}
- 已有账号仅同步 CRS - 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。 + {{ t('admin.accounts.crsUpdateBehaviorNote') }}
- +
- - + +
- +
@@ -58,9 +67,101 @@ {{ t('admin.accounts.syncProxies') }}
+ + +
+ +
+
+ {{ t('admin.accounts.crsExistingAccounts') }} + ({{ previewResult.existing_accounts.length }}) +
+
+
+ {{ acc.platform }} / {{ acc.type }} + {{ acc.name }} +
+
+
+ + +
+
+
+ {{ t('admin.accounts.crsNewAccounts') }} + ({{ previewResult.new_accounts.length }}) +
+
+ + +
+
+
+ +
+
+ {{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }} +
+
+ + +
+ {{ t('admin.accounts.syncProxies') }}: + + {{ form.sync_proxies ? t('common.yes') : t('common.no') }} + +
+ + +
+ {{ t('admin.accounts.crsNoNewAccounts') }} + + {{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }} + +
+
+ + +
@@ -84,21 +185,56 @@
- +
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import type { PreviewFromCRSResult } from '@/api/admin/accounts' interface Props { show: boolean @@ -126,7 +263,12 @@ const emit = defineEmits() const { t } = useI18n() const appStore = useAppStore() +type Step = 'input' | 'preview' | 'result' +const currentStep = ref('input') +const previewing = ref(false) const syncing = ref(false) +const previewResult = ref(null) +const selectedIds = ref(new Set()) const result = ref> | null>(null) const form = reactive({ @@ -136,28 +278,90 @@ const form = reactive({ sync_proxies: true }) +const hasNewButNoneSelected = computed(() => { + if (!previewResult.value) return false + return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0 +}) + const errorItems = computed(() => { if (!result.value?.items) return [] - return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped') + return result.value.items.filter( + (i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected') + ) }) watch( () => props.show, (open) => { if (open) { + currentStep.value = 'input' + previewResult.value = null + selectedIds.value = new Set() result.value = null + form.base_url = '' + form.username = '' + form.password = '' + form.sync_proxies = true } } ) const handleClose = () => { - // 防止在同步进行中关闭对话框 - if (syncing.value) { + if (syncing.value || previewing.value) { return } emit('close') } +const handleBack = () => { + currentStep.value = 'input' + previewResult.value = null + selectedIds.value = new Set() +} + +const selectAll = () => { + if (!previewResult.value) return + selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id)) +} + +const selectNone = () => { + selectedIds.value = new Set() +} + +const toggleSelect = (id: string) => { + const s = new Set(selectedIds.value) + if (s.has(id)) { + s.delete(id) + } else { + s.add(id) + } + selectedIds.value = s +} + +const handlePreview = async () => { + if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) { + appStore.showError(t('admin.accounts.syncMissingFields')) + return + } + + previewing.value = true + try { + const res = await adminAPI.accounts.previewFromCrs({ + base_url: form.base_url.trim(), + username: form.username.trim(), + password: form.password + }) + previewResult.value = res + // Auto-select all new accounts + selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id)) + currentStep.value = 'preview' + } catch (error: any) { + appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed')) + } finally { + previewing.value = false + } +} + const handleSync = async () => { if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) { appStore.showError(t('admin.accounts.syncMissingFields')) @@ -170,16 +374,18 @@ const handleSync = async () => { base_url: form.base_url.trim(), username: form.username.trim(), password: form.password, - sync_proxies: form.sync_proxies + sync_proxies: form.sync_proxies, + selected_account_ids: [...selectedIds.value] }) result.value = res + currentStep.value = 'result' if (res.failed > 0) { appStore.showError(t('admin.accounts.syncCompletedWithErrors', res)) } else { appStore.showSuccess(t('admin.accounts.syncCompleted', res)) - emit('synced') } + emit('synced') } catch (error: any) { appStore.showError(error?.message || t('admin.accounts.syncFailed')) } finally { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3103a23f..a2d42cb1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1309,10 +1309,23 @@ export default { syncResult: 'Sync Result', syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}', syncErrors: 'Errors / Skipped Details', - syncCompleted: 'Sync completed: created {created}, updated {updated}', + syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}', syncCompletedWithErrors: - 'Sync completed with errors: failed {failed} (created {created}, updated {updated})', + 'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})', syncFailed: 'Sync failed', + crsPreview: 'Preview', + crsPreviewing: 'Previewing...', + crsPreviewFailed: 'Preview failed', + crsExistingAccounts: 'Existing accounts (will be updated)', + crsNewAccounts: 'New accounts (select to sync)', + crsSelectAll: 'Select all', + crsSelectNone: 'Select none', + crsNoNewAccounts: 'All CRS accounts are already synced.', + crsWillUpdate: 'Will update {count} existing accounts.', + crsSelectedCount: '{count} new accounts selected', + crsUpdateBehaviorNote: + 'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.', + crsBack: 'Back', editAccount: 'Edit Account', deleteAccount: 'Delete Account', searchAccounts: 'Search accounts...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a1221318..6d49e169 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1397,9 +1397,22 @@ export default { syncResult: '同步结果', syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}', syncErrors: '错误/跳过详情', - syncCompleted: '同步完成:创建 {created},更新 {updated}', - syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})', + syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}', + syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})', syncFailed: '同步失败', + crsPreview: '预览', + crsPreviewing: '预览中...', + crsPreviewFailed: '预览失败', + crsExistingAccounts: '将自动更新的已有账号', + crsNewAccounts: '新账号(可选择)', + crsSelectAll: '全选', + crsSelectNone: '全不选', + crsNoNewAccounts: '所有 CRS 账号均已同步。', + crsWillUpdate: '将更新 {count} 个已有账号。', + crsSelectedCount: '已选择 {count} 个新账号', + crsUpdateBehaviorNote: + '已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。', + crsBack: '返回', editAccount: '编辑账号', deleteAccount: '删除账号', deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",