Merge pull request #457 from touwaeriol/pr/group-copy-accounts

feat(groups): 添加从其他分组复制账号功能
This commit is contained in:
Wesley Liddick
2026-02-03 08:45:13 +08:00
committed by GitHub
13 changed files with 420 additions and 35 deletions

View File

@@ -43,6 +43,8 @@ type CreateGroupRequest struct {
// 模型路由配置(仅 anthropic 平台使用) // 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"` ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled bool `json:"model_routing_enabled"` ModelRoutingEnabled bool `json:"model_routing_enabled"`
// 从指定分组复制账号(创建后自动绑定)
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
} }
// UpdateGroupRequest represents update group request // UpdateGroupRequest represents update group request
@@ -66,6 +68,8 @@ type UpdateGroupRequest struct {
// 模型路由配置(仅 anthropic 平台使用) // 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"` ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled *bool `json:"model_routing_enabled"` ModelRoutingEnabled *bool `json:"model_routing_enabled"`
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
} }
// List handles listing all groups with pagination // List handles listing all groups with pagination
@@ -155,22 +159,23 @@ func (h *GroupHandler) Create(c *gin.Context) {
} }
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{ group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Platform: req.Platform, Platform: req.Platform,
RateMultiplier: req.RateMultiplier, RateMultiplier: req.RateMultiplier,
IsExclusive: req.IsExclusive, IsExclusive: req.IsExclusive,
SubscriptionType: req.SubscriptionType, SubscriptionType: req.SubscriptionType,
DailyLimitUSD: req.DailyLimitUSD, DailyLimitUSD: req.DailyLimitUSD,
WeeklyLimitUSD: req.WeeklyLimitUSD, WeeklyLimitUSD: req.WeeklyLimitUSD,
MonthlyLimitUSD: req.MonthlyLimitUSD, MonthlyLimitUSD: req.MonthlyLimitUSD,
ImagePrice1K: req.ImagePrice1K, ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K, ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K, ImagePrice4K: req.ImagePrice4K,
ClaudeCodeOnly: req.ClaudeCodeOnly, ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID, FallbackGroupID: req.FallbackGroupID,
ModelRouting: req.ModelRouting, ModelRouting: req.ModelRouting,
ModelRoutingEnabled: req.ModelRoutingEnabled, ModelRoutingEnabled: req.ModelRoutingEnabled,
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
}) })
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
@@ -196,23 +201,24 @@ func (h *GroupHandler) Update(c *gin.Context) {
} }
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{ group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Platform: req.Platform, Platform: req.Platform,
RateMultiplier: req.RateMultiplier, RateMultiplier: req.RateMultiplier,
IsExclusive: req.IsExclusive, IsExclusive: req.IsExclusive,
Status: req.Status, Status: req.Status,
SubscriptionType: req.SubscriptionType, SubscriptionType: req.SubscriptionType,
DailyLimitUSD: req.DailyLimitUSD, DailyLimitUSD: req.DailyLimitUSD,
WeeklyLimitUSD: req.WeeklyLimitUSD, WeeklyLimitUSD: req.WeeklyLimitUSD,
MonthlyLimitUSD: req.MonthlyLimitUSD, MonthlyLimitUSD: req.MonthlyLimitUSD,
ImagePrice1K: req.ImagePrice1K, ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K, ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K, ImagePrice4K: req.ImagePrice4K,
ClaudeCodeOnly: req.ClaudeCodeOnly, ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID, FallbackGroupID: req.FallbackGroupID,
ModelRouting: req.ModelRouting, ModelRouting: req.ModelRouting,
ModelRoutingEnabled: req.ModelRoutingEnabled, ModelRoutingEnabled: req.ModelRoutingEnabled,
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
}) })
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)

View File

@@ -425,3 +425,61 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6
return counts, nil return counts, nil
} }
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID去重
func (r *groupRepository) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
if len(groupIDs) == 0 {
return nil, nil
}
rows, err := r.sql.QueryContext(
ctx,
"SELECT DISTINCT account_id FROM account_groups WHERE group_id = ANY($1) ORDER BY account_id",
pq.Array(groupIDs),
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var accountIDs []int64
for rows.Next() {
var accountID int64
if err := rows.Scan(&accountID); err != nil {
return nil, err
}
accountIDs = append(accountIDs, accountID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return accountIDs, nil
}
// BindAccountsToGroup 将多个账号绑定到指定分组(批量插入,忽略已存在的绑定)
func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
if len(accountIDs) == 0 {
return nil
}
// 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定
_, err := r.sql.ExecContext(
ctx,
`INSERT INTO account_groups (account_id, group_id, priority, created_at)
SELECT unnest($1::bigint[]), $2, 50, NOW()
ON CONFLICT (account_id, group_id) DO NOTHING`,
pq.Array(accountIDs),
groupID,
)
if err != nil {
return err
}
// 发送调度器事件
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
log.Printf("[SchedulerOutbox] enqueue bind accounts to group failed: group=%d err=%v", groupID, err)
}
return nil
}

View File

@@ -880,6 +880,14 @@ func (stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID i
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }
func (stubGroupRepo) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
return errors.New("not implemented")
}
func (stubGroupRepo) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
return nil, errors.New("not implemented")
}
type stubAccountRepo struct { type stubAccountRepo struct {
bulkUpdateIDs []int64 bulkUpdateIDs []int64
} }

View File

@@ -110,6 +110,8 @@ type CreateGroupInput struct {
// 模型路由配置(仅 anthropic 平台使用) // 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 ModelRouting map[string][]int64
ModelRoutingEnabled bool // 是否启用模型路由 ModelRoutingEnabled bool // 是否启用模型路由
// 从指定分组复制账号(创建分组后在同一事务内绑定)
CopyAccountsFromGroupIDs []int64
} }
type UpdateGroupInput struct { type UpdateGroupInput struct {
@@ -132,6 +134,8 @@ type UpdateGroupInput struct {
// 模型路由配置(仅 anthropic 平台使用) // 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 ModelRouting map[string][]int64
ModelRoutingEnabled *bool // 是否启用模型路由 ModelRoutingEnabled *bool // 是否启用模型路由
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
CopyAccountsFromGroupIDs []int64
} }
type CreateAccountInput struct { type CreateAccountInput struct {
@@ -572,6 +576,38 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
} }
} }
// 如果指定了复制账号的源分组,先获取账号 ID 列表
var accountIDsToCopy []int64
if len(input.CopyAccountsFromGroupIDs) > 0 {
// 去重源分组 IDs
seen := make(map[int64]struct{})
uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs))
for _, srcGroupID := range input.CopyAccountsFromGroupIDs {
if _, exists := seen[srcGroupID]; !exists {
seen[srcGroupID] = struct{}{}
uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID)
}
}
// 校验源分组的平台是否与新分组一致
for _, srcGroupID := range uniqueSourceGroupIDs {
srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID)
if err != nil {
return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err)
}
if srcGroup.Platform != platform {
return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform)
}
}
// 获取所有源分组的账号(去重)
var err error
accountIDsToCopy, err = s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs)
if err != nil {
return nil, fmt.Errorf("failed to get accounts from source groups: %w", err)
}
}
group := &Group{ group := &Group{
Name: input.Name, Name: input.Name,
Description: input.Description, Description: input.Description,
@@ -593,6 +629,15 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
if err := s.groupRepo.Create(ctx, group); err != nil { if err := s.groupRepo.Create(ctx, group); err != nil {
return nil, err return nil, err
} }
// 如果有需要复制的账号,绑定到新分组
if len(accountIDsToCopy) > 0 {
if err := s.groupRepo.BindAccountsToGroup(ctx, group.ID, accountIDsToCopy); err != nil {
return nil, fmt.Errorf("failed to bind accounts to new group: %w", err)
}
group.AccountCount = int64(len(accountIDsToCopy))
}
return group, nil return group, nil
} }
@@ -728,6 +773,54 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if err := s.groupRepo.Update(ctx, group); err != nil { if err := s.groupRepo.Update(ctx, group); err != nil {
return nil, err return nil, err
} }
// 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号)
if len(input.CopyAccountsFromGroupIDs) > 0 {
// 去重源分组 IDs
seen := make(map[int64]struct{})
uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs))
for _, srcGroupID := range input.CopyAccountsFromGroupIDs {
// 校验:源分组不能是自身
if srcGroupID == id {
return nil, fmt.Errorf("cannot copy accounts from self")
}
// 去重
if _, exists := seen[srcGroupID]; !exists {
seen[srcGroupID] = struct{}{}
uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID)
}
}
// 校验源分组的平台是否与当前分组一致
for _, srcGroupID := range uniqueSourceGroupIDs {
srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID)
if err != nil {
return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err)
}
if srcGroup.Platform != group.Platform {
return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform)
}
}
// 获取所有源分组的账号(去重)
accountIDsToCopy, err := s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs)
if err != nil {
return nil, fmt.Errorf("failed to get accounts from source groups: %w", err)
}
// 先清空当前分组的所有账号绑定
if _, err := s.groupRepo.DeleteAccountGroupsByGroupID(ctx, id); err != nil {
return nil, fmt.Errorf("failed to clear existing account bindings: %w", err)
}
// 再绑定源分组的账号
if len(accountIDsToCopy) > 0 {
if err := s.groupRepo.BindAccountsToGroup(ctx, id, accountIDsToCopy); err != nil {
return nil, fmt.Errorf("failed to bind accounts to group: %w", err)
}
}
}
if s.authCacheInvalidator != nil { if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id)
} }

View File

@@ -164,6 +164,14 @@ func (s *groupRepoStub) DeleteAccountGroupsByGroupID(ctx context.Context, groupI
panic("unexpected DeleteAccountGroupsByGroupID call") panic("unexpected DeleteAccountGroupsByGroupID call")
} }
func (s *groupRepoStub) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
panic("unexpected BindAccountsToGroup call")
}
func (s *groupRepoStub) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
panic("unexpected GetAccountIDsByGroupIDs call")
}
type proxyRepoStub struct { type proxyRepoStub struct {
deleteErr error deleteErr error
countErr error countErr error

View File

@@ -108,6 +108,14 @@ func (s *groupRepoStubForAdmin) DeleteAccountGroupsByGroupID(_ context.Context,
panic("unexpected DeleteAccountGroupsByGroupID call") panic("unexpected DeleteAccountGroupsByGroupID call")
} }
func (s *groupRepoStubForAdmin) BindAccountsToGroup(_ context.Context, _ int64, _ []int64) error {
panic("unexpected BindAccountsToGroup call")
}
func (s *groupRepoStubForAdmin) GetAccountIDsByGroupIDs(_ context.Context, _ []int64) ([]int64, error) {
panic("unexpected GetAccountIDsByGroupIDs call")
}
// TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递 // TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递
func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) { func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) {
repo := &groupRepoStubForAdmin{} repo := &groupRepoStubForAdmin{}
@@ -378,3 +386,11 @@ func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int
func (s *groupRepoStubForFallbackCycle) DeleteAccountGroupsByGroupID(_ context.Context, _ int64) (int64, error) { func (s *groupRepoStubForFallbackCycle) DeleteAccountGroupsByGroupID(_ context.Context, _ int64) (int64, error) {
panic("unexpected DeleteAccountGroupsByGroupID call") panic("unexpected DeleteAccountGroupsByGroupID call")
} }
func (s *groupRepoStubForFallbackCycle) BindAccountsToGroup(_ context.Context, _ int64, _ []int64) error {
panic("unexpected BindAccountsToGroup call")
}
func (s *groupRepoStubForFallbackCycle) GetAccountIDsByGroupIDs(_ context.Context, _ []int64) ([]int64, error) {
panic("unexpected GetAccountIDsByGroupIDs call")
}

View File

@@ -266,6 +266,14 @@ func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Conte
return 0, nil return 0, nil
} }
func (m *mockGroupRepoForGateway) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
return nil
}
func (m *mockGroupRepoForGateway) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
return nil, nil
}
func ptr[T any](v T) *T { func ptr[T any](v T) *T {
return &v return &v
} }

View File

@@ -218,6 +218,14 @@ func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Contex
return 0, nil return 0, nil
} }
func (m *mockGroupRepoForGemini) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
return nil
}
func (m *mockGroupRepoForGemini) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
return nil, nil
}
var _ GroupRepository = (*mockGroupRepoForGemini)(nil) var _ GroupRepository = (*mockGroupRepoForGemini)(nil)
// mockGatewayCacheForGemini Gemini 测试用的 cache mock // mockGatewayCacheForGemini Gemini 测试用的 cache mock

View File

@@ -29,6 +29,10 @@ type GroupRepository interface {
ExistsByName(ctx context.Context, name string) (bool, error) ExistsByName(ctx context.Context, name string) (bool, error)
GetAccountCount(ctx context.Context, groupID int64) (int64, error) GetAccountCount(ctx context.Context, groupID int64) (int64, error)
DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error)
// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID去重
GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error)
// BindAccountsToGroup 将多个账号绑定到指定分组
BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error
} }
// CreateGroupRequest 创建分组请求 // CreateGroupRequest 创建分组请求

View File

@@ -1004,6 +1004,14 @@ export default {
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)' noFallback: 'No Fallback (Reject)'
}, },
copyAccounts: {
title: 'Copy Accounts from Groups',
tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).',
tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).',
selectPlaceholder: 'Select groups to copy accounts from...',
hint: 'Multiple groups can be selected, accounts will be deduplicated',
hintEdit: '⚠️ Warning: This will replace all existing account bindings'
},
modelRouting: { modelRouting: {
title: 'Model Routing', title: 'Model Routing',
tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.', tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.',

View File

@@ -1079,6 +1079,14 @@ export default {
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝', fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)' noFallback: '不降级(直接拒绝)'
}, },
copyAccounts: {
title: '从分组复制账号',
tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。',
tooltipEdit: '选择一个或多个相同平台的分组,保存后当前分组的账号会被替换为这些分组的账号(去重)。',
selectPlaceholder: '选择分组以复制其账号...',
hint: '可选多个分组,账号会自动去重',
hintEdit: '⚠️ 注意:这会替换当前分组的所有账号绑定'
},
modelRouting: { modelRouting: {
title: '模型路由配置', title: '模型路由配置',
tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。', tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。',

View File

@@ -411,6 +411,8 @@ export interface CreateGroupRequest {
image_price_4k?: number | null image_price_4k?: number | null
claude_code_only?: boolean claude_code_only?: boolean
fallback_group_id?: number | null fallback_group_id?: number | null
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
} }
export interface UpdateGroupRequest { export interface UpdateGroupRequest {
@@ -429,6 +431,7 @@ export interface UpdateGroupRequest {
image_price_4k?: number | null image_price_4k?: number | null
claude_code_only?: boolean claude_code_only?: boolean
fallback_group_id?: number | null fallback_group_id?: number | null
copy_accounts_from_group_ids?: number[]
} }
// ==================== Account & Proxy Types ==================== // ==================== Account & Proxy Types ====================

View File

@@ -240,9 +240,73 @@
v-model="createForm.platform" v-model="createForm.platform"
:options="platformOptions" :options="platformOptions"
data-tour="group-form-platform" data-tour="group-form-platform"
@change="createForm.copy_accounts_from_group_ids = []"
/> />
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p> <p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
</div> </div>
<!-- 从分组复制账号 -->
<div v-if="copyAccountsGroupOptions.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div v-if="createForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="groupId in createForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptions.find(o => o.value === groupId)?.label || `#${groupId}` }}
<button
type="button"
@click="createForm.copy_accounts_from_group_ids = createForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !createForm.copy_accounts_from_group_ids.includes(val)) {
createForm.copy_accounts_from_group_ids.push(val)
}
(e.target as HTMLSelectElement).value = ''
}"
>
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
<option
v-for="opt in copyAccountsGroupOptions"
:key="opt.value"
:value="opt.value"
:disabled="createForm.copy_accounts_from_group_ids.includes(opt.value)"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hint') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input <input
@@ -680,6 +744,69 @@
/> />
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p> <p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
</div> </div>
<!-- 从分组复制账号编辑时 -->
<div v-if="copyAccountsGroupOptionsForEdit.length > 0">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.copyAccounts.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.copyAccounts.tooltipEdit') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<!-- 已选分组标签 -->
<div v-if="editForm.copy_accounts_from_group_ids.length > 0" class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="groupId in editForm.copy_accounts_from_group_ids"
:key="groupId"
class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{ copyAccountsGroupOptionsForEdit.find(o => o.value === groupId)?.label || `#${groupId}` }}
<button
type="button"
@click="editForm.copy_accounts_from_group_ids = editForm.copy_accounts_from_group_ids.filter(id => id !== groupId)"
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
>
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- 分组选择下拉 -->
<select
class="input"
@change="(e) => {
const val = Number((e.target as HTMLSelectElement).value)
if (val && !editForm.copy_accounts_from_group_ids.includes(val)) {
editForm.copy_accounts_from_group_ids.push(val)
}
(e.target as HTMLSelectElement).value = ''
}"
>
<option value="">{{ t('admin.groups.copyAccounts.selectPlaceholder') }}</option>
<option
v-for="opt in copyAccountsGroupOptionsForEdit"
:key="opt.value"
:value="opt.value"
:disabled="editForm.copy_accounts_from_group_ids.includes(opt.value)"
>
{{ opt.label }}
</option>
</select>
<p class="input-hint">{{ t('admin.groups.copyAccounts.hintEdit') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label> <label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input <input
@@ -1202,6 +1329,29 @@ const fallbackGroupOptionsForEdit = computed(() => {
return options return options
}) })
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter(
(g) => g.platform === createForm.platform && (g.account_count || 0) > 0
)
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`
}))
})
// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身
const copyAccountsGroupOptionsForEdit = computed(() => {
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) => g.platform === editForm.platform && (g.account_count || 0) > 0 && g.id !== currentId
)
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`
}))
})
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
@@ -1244,7 +1394,9 @@ const createForm = reactive({
claude_code_only: false, claude_code_only: false,
fallback_group_id: null as number | null, fallback_group_id: null as number | null,
// 模型路由开关 // 模型路由开关
model_routing_enabled: false model_routing_enabled: false,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
}) })
// 简单账号类型(用于模型路由选择) // 简单账号类型(用于模型路由选择)
@@ -1415,7 +1567,9 @@ const editForm = reactive({
claude_code_only: false, claude_code_only: false,
fallback_group_id: null as number | null, fallback_group_id: null as number | null,
// 模型路由开关 // 模型路由开关
model_routing_enabled: false model_routing_enabled: false,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
}) })
// 根据分组类型返回不同的删除确认消息 // 根据分组类型返回不同的删除确认消息
@@ -1497,6 +1651,7 @@ const closeCreateModal = () => {
createForm.image_price_4k = null createForm.image_price_4k = null
createForm.claude_code_only = false createForm.claude_code_only = false
createForm.fallback_group_id = null createForm.fallback_group_id = null
createForm.copy_accounts_from_group_ids = []
createModelRoutingRules.value = [] createModelRoutingRules.value = []
} }
@@ -1547,6 +1702,7 @@ const handleEdit = async (group: AdminGroup) => {
editForm.claude_code_only = group.claude_code_only || false editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id editForm.fallback_group_id = group.fallback_group_id
editForm.model_routing_enabled = group.model_routing_enabled || false editForm.model_routing_enabled = group.model_routing_enabled || false
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称) // 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing) editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
showEditModal.value = true showEditModal.value = true
@@ -1556,6 +1712,7 @@ const closeEditModal = () => {
showEditModal.value = false showEditModal.value = false
editingGroup.value = null editingGroup.value = null
editModelRoutingRules.value = [] editModelRoutingRules.value = []
editForm.copy_accounts_from_group_ids = []
} }
const handleUpdateGroup = async () => { const handleUpdateGroup = async () => {