feat(admin): 新增 CRS 同步预览和账号选择功能

- 后端新增 PreviewFromCRS 接口,允许用户先预览 CRS 中的账号
- 后端支持在同步时选择特定账号,不选中的账号将被跳过
- 前端重构 SyncFromCrsModal 为三步向导:输入凭据 → 预览账号 → 执行同步
- 改进表单无障碍性:添加 for/id 关联和 required 属性
- 修复 Back 按钮返回时的状态清理
- 新增 buildSelectedSet 和 shouldCreateAccount 的单元测试
- 完整的向后兼容性:旧客户端不发送 selected_account_ids 时行为不变
This commit is contained in:
QTom
2026-02-09 10:38:26 +08:00
parent 149e4267cd
commit 5e0d789440
13 changed files with 653 additions and 51 deletions

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -327,11 +327,34 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
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<PreviewFromCRSResult> {
const { data } = await apiClient.post<PreviewFromCRSResult>('/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,

View File

@@ -6,15 +6,20 @@
close-on-click-outside
@close="handleClose"
>
<form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
<!-- Step 1: Input credentials -->
<form
v-if="currentStep === 'input'"
id="sync-from-crs-form"
class="space-y-4"
@submit.prevent="handlePreview"
>
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
<div
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段缺失字段保持原值凭据按键合并不会清空未下发的键未勾选"同步代理"时保留原有代理
{{ t('admin.accounts.crsUpdateBehaviorNote') }}
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
@@ -24,26 +29,30 @@
<div class="grid grid-cols-1 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<label for="crs-base-url" class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<input
id="crs-base-url"
v-model="form.base_url"
type="text"
class="input"
required
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input v-model="form.username" type="text" class="input" autocomplete="username" />
<label for="crs-username" class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input id="crs-username" v-model="form.username" type="text" class="input" required autocomplete="username" />
</div>
<div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<label for="crs-password" class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<input
id="crs-password"
v-model="form.password"
type="password"
class="input"
required
autocomplete="current-password"
/>
</div>
@@ -58,9 +67,101 @@
{{ t('admin.accounts.syncProxies') }}
</label>
</div>
</form>
<!-- Step 2: Preview & select -->
<div v-else-if="currentStep === 'preview' && previewResult" class="space-y-4">
<!-- Existing accounts (read-only info) -->
<div
v-if="previewResult.existing_accounts.length"
class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-dark-300">
{{ t('admin.accounts.crsExistingAccounts') }}
<span class="ml-1 text-xs text-gray-400">({{ previewResult.existing_accounts.length }})</span>
</div>
<div class="max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400">
<div
v-for="acc in previewResult.existing_accounts"
:key="acc.crs_account_id"
class="flex items-center gap-2 py-0.5"
>
<span
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>{{ acc.platform }} / {{ acc.type }}</span>
<span class="truncate">{{ acc.name }}</span>
</div>
</div>
</div>
<!-- New accounts (selectable) -->
<div v-if="previewResult.new_accounts.length">
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.crsNewAccounts') }}
<span class="ml-1 text-xs text-gray-400">({{ previewResult.new_accounts.length }})</span>
</div>
<div class="flex gap-2">
<button
type="button"
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="selectAll"
>{{ t('admin.accounts.crsSelectAll') }}</button>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
@click="selectNone"
>{{ t('admin.accounts.crsSelectNone') }}</button>
</div>
</div>
<div
class="max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
>
<label
v-for="acc in previewResult.new_accounts"
:key="acc.crs_account_id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
>
<input
type="checkbox"
:checked="selectedIds.has(acc.crs_account_id)"
class="rounded border-gray-300 dark:border-dark-600"
@change="toggleSelect(acc.crs_account_id)"
/>
<span
class="inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>{{ acc.platform }} / {{ acc.type }}</span>
<span class="truncate text-sm text-gray-700 dark:text-dark-300">{{ acc.name }}</span>
</label>
</div>
<div class="mt-1 text-xs text-gray-400">
{{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }}
</div>
</div>
<!-- Sync options summary -->
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
<span>{{ t('admin.accounts.syncProxies') }}:</span>
<span :class="form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'">
{{ form.sync_proxies ? t('common.yes') : t('common.no') }}
</span>
</div>
<!-- No new accounts -->
<div
v-if="!previewResult.new_accounts.length"
class="rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
{{ t('admin.accounts.crsNoNewAccounts') }}
<span v-if="previewResult.existing_accounts.length">
{{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }}
</span>
</div>
</div>
<!-- Step 3: Result -->
<div v-else-if="currentStep === 'result' && result" class="space-y-4">
<div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
@@ -84,21 +185,56 @@
</div>
</div>
</div>
</form>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="syncing"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
<!-- Step 1: Input -->
<template v-if="currentStep === 'input'">
<button
class="btn btn-secondary"
type="button"
:disabled="previewing"
@click="handleClose"
>
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="previewing"
>
{{ previewing ? t('admin.accounts.crsPreviewing') : t('admin.accounts.crsPreview') }}
</button>
</template>
<!-- Step 2: Preview -->
<template v-else-if="currentStep === 'preview'">
<button
class="btn btn-secondary"
type="button"
:disabled="syncing"
@click="handleBack"
>
{{ t('admin.accounts.crsBack') }}
</button>
<button
class="btn btn-primary"
type="button"
:disabled="syncing || hasNewButNoneSelected"
@click="handleSync"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
</template>
<!-- Step 3: Result -->
<template v-else-if="currentStep === 'result'">
<button class="btn btn-secondary" type="button" @click="handleClose">
{{ t('common.close') }}
</button>
</template>
</div>
</template>
</BaseDialog>
@@ -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<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
type Step = 'input' | 'preview' | 'result'
const currentStep = ref<Step>('input')
const previewing = ref(false)
const syncing = ref(false)
const previewResult = ref<PreviewFromCRSResult | null>(null)
const selectedIds = ref(new Set<string>())
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | 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 {

View File

@@ -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...',

View File

@@ -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}' 吗?",