diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8dedcefd..f762511c 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -165,6 +165,8 @@ type AccountWithConcurrency struct { CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +const accountListGroupUngroupedQueryValue = "ungrouped" + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: dto.AccountFromService(account), @@ -226,7 +228,20 @@ func (h *AccountHandler) List(c *gin.Context) { var groupID int64 if groupIDStr := c.Query("group"); groupIDStr != "" { - groupID, _ = strconv.ParseInt(groupIDStr, 10, 64) + if groupIDStr == accountListGroupUngroupedQueryValue { + groupID = service.AccountListGroupUngrouped + } else { + parsedGroupID, parseErr := strconv.ParseInt(groupIDStr, 10, 64) + if parseErr != nil { + response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")) + return + } + if parsedGroupID < 0 { + response.ErrorFrom(c, infraerrors.BadRequest("INVALID_GROUP_FILTER", "invalid group filter")) + return + } + groupID = parsedGroupID + } } accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 7802cce0..35b908de 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -474,7 +474,9 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati if search != "" { q = q.Where(dbaccount.NameContainsFold(search)) } - if groupID > 0 { + if groupID == service.AccountListGroupUngrouped { + q = q.Where(dbaccount.Not(dbaccount.HasAccountGroups())) + } else if groupID > 0 { q = q.Where(dbaccount.HasAccountGroupsWith(dbaccountgroup.GroupIDEQ(groupID))) } diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index e697802e..d6f0e337 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -214,6 +214,7 @@ func (s *AccountRepoSuite) TestListWithFilters() { accType string status string search string + groupID int64 wantCount int validate func(accounts []service.Account) }{ @@ -265,6 +266,21 @@ func (s *AccountRepoSuite) TestListWithFilters() { s.Require().Contains(accounts[0].Name, "alpha") }, }, + { + name: "filter_by_ungrouped", + setup: func(client *dbent.Client) { + group := mustCreateGroup(s.T(), client, &service.Group{Name: "g-ungrouped"}) + grouped := mustCreateAccount(s.T(), client, &service.Account{Name: "grouped-account"}) + mustCreateAccount(s.T(), client, &service.Account{Name: "ungrouped-account"}) + mustBindAccountToGroup(s.T(), client, grouped.ID, group.ID, 1) + }, + groupID: service.AccountListGroupUngrouped, + wantCount: 1, + validate: func(accounts []service.Account) { + s.Require().Equal("ungrouped-account", accounts[0].Name) + s.Require().Empty(accounts[0].GroupIDs) + }, + }, } for _, tt := range tests { @@ -277,7 +293,7 @@ func (s *AccountRepoSuite) TestListWithFilters() { tt.setup(client) - accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, 0) + accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, tt.groupID) s.Require().NoError(err) s.Require().Len(accounts, tt.wantCount) if tt.validate != nil { diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index a06d8048..2e91db6b 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -14,6 +14,8 @@ var ( ErrAccountNilInput = infraerrors.BadRequest("ACCOUNT_NIL_INPUT", "account input cannot be nil") ) +const AccountListGroupUngrouped int64 = -1 + type AccountRepository interface { Create(ctx context.Context, account *Account) error GetByID(ctx context.Context, id int64) (*Account, error) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 9857de05..751da25f 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -66,6 +66,7 @@ export async function listWithEtag( platform?: string type?: string status?: string + group?: string search?: string lite?: string }, diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index d8068336..a2a9ab04 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -26,5 +26,9 @@ const updateGroup = (value: string | number | boolean | null) => { emit('update: const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }]) const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }]) const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }]) -const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))]) +const gOpts = computed(() => [ + { value: '', label: t('admin.accounts.allGroups') }, + { value: 'ungrouped', label: t('admin.accounts.ungroupedGroup') }, + ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name })) +]) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6056e104..ba90e11a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1883,6 +1883,7 @@ export default { allTypes: 'All Types', allStatus: 'All Status', allGroups: 'All Groups', + ungroupedGroup: 'Ungrouped', oauthType: 'OAuth', setupToken: 'Setup Token', apiKey: 'API Key', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 602399b5..8180c568 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1965,6 +1965,7 @@ export default { allTypes: '全部类型', allStatus: '全部状态', allGroups: '全部分组', + ungroupedGroup: '未分配分组', oauthType: 'OAuth', // Schedulable toggle schedulable: '参与调度', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 2ec5b47d..761e8403 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -758,6 +758,7 @@ const refreshAccountsIncrementally = async () => { platform?: string type?: string status?: string + group?: string search?: string },