feat(pool): add table-driven account batch actions

This commit is contained in:
elky
2026-07-19 16:09:20 +08:00
parent 95053f9502
commit 644ae9c1bf
20 changed files with 1403 additions and 1095 deletions
@@ -41,8 +41,9 @@ pub(crate) use self::support::{
admin_pool_provider_id_from_path, admin_pool_provider_id_from_scores_path, admin_pool_provider_id_from_path, admin_pool_provider_id_from_scores_path,
parse_admin_pool_key_sort, parse_admin_pool_page, parse_admin_pool_page_size, parse_admin_pool_key_sort, parse_admin_pool_page, parse_admin_pool_page_size,
parse_admin_pool_quick_selectors, parse_admin_pool_search, parse_admin_pool_status_filter, parse_admin_pool_quick_selectors, parse_admin_pool_search, parse_admin_pool_status_filter,
AdminPoolKeySort, AdminPoolKeySortDirection, AdminPoolKeySortField, parse_admin_pool_status_value, AdminPoolKeySort, AdminPoolKeySortDirection,
AdminPoolResolveSelectionRequest, ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE, AdminPoolKeySortField, AdminPoolResolveSelectionRequest,
ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL, ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL,
}; };
@@ -459,7 +459,7 @@ fn admin_pool_key_cost_exhausted(
>= limit >= limit
} }
fn admin_pool_key_visible_status_filter( pub(super) fn admin_pool_key_visible_status_filter(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
provider_type: &str, provider_type: &str,
@@ -654,17 +654,22 @@ pub(super) async fn build_admin_pool_list_keys_response(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
(keys, total, preloaded_pool_scores_by_key_id) (keys, total, preloaded_pool_scores_by_key_id)
} else if !quick_selectors.is_empty() || sort_by_score { } else if !quick_selectors.is_empty() || sort_by_score {
let use_full_search = !quick_selectors.is_empty();
let mut keys = state let mut keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id)) .list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await? .await?
.into_iter() .into_iter()
.filter(|key| { .filter(|key| {
pool_selection::admin_pool_matches_search( if use_full_search {
state, pool_selection::admin_pool_matches_search(
key, state,
&provider.provider_type, key,
search.as_deref(), &provider.provider_type,
) search.as_deref(),
)
} else {
pool_selection::admin_pool_matches_catalog_search(key, search.as_deref())
}
}) })
.filter(|key| { .filter(|key| {
quick_selectors.iter().all(|selector| { quick_selectors.iter().all(|selector| {
@@ -1,6 +1,9 @@
use super::read_keys::admin_pool_key_visible_status_filter;
use super::{ use super::{
admin_pool_provider_id_from_path, build_admin_pool_error_response, pool_selection, admin_pool_provider_id_from_path, admin_provider_pool_config, build_admin_pool_error_response,
AdminPoolResolveSelectionRequest, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL, parse_admin_pool_status_value, pool_selection, read_admin_provider_pool_runtime_state,
AdminPoolResolveSelectionRequest, AdminProviderPoolRuntimeState,
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
}; };
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::provider_key_auth::{ use crate::provider_key_auth::{
@@ -49,6 +52,15 @@ pub(super) async fn build_admin_pool_resolve_selection_response(
} }
}, },
}; };
let status = match parse_admin_pool_status_value(payload.status.as_deref()) {
Ok(value) => value,
Err(detail) => {
return Ok(build_admin_pool_error_response(
http::StatusCode::BAD_REQUEST,
detail,
));
}
};
let Some(provider) = state let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id)) .read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
@@ -66,13 +78,18 @@ pub(super) async fn build_admin_pool_resolve_selection_response(
let search = payload.search.trim(); let search = payload.search.trim();
let quick_selectors = let quick_selectors =
admin_provider_pool_pure::admin_pool_sanitize_quick_selectors(payload.quick_selectors); admin_provider_pool_pure::admin_pool_sanitize_quick_selectors(payload.quick_selectors);
let use_full_search = status != "all" || !quick_selectors.is_empty();
let mut keys = state let mut keys = state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id)) .list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
.await? .await?
.into_iter() .into_iter()
.filter(|key| { .filter(|key| {
pool_selection::admin_pool_matches_search(state, key, &provider_type, Some(search)) if use_full_search {
pool_selection::admin_pool_matches_search(state, key, &provider_type, Some(search))
} else {
pool_selection::admin_pool_matches_catalog_search(key, Some(search))
}
}) })
.filter(|key| { .filter(|key| {
quick_selectors.is_empty() quick_selectors.is_empty()
@@ -87,6 +104,35 @@ pub(super) async fn build_admin_pool_resolve_selection_response(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if status != "all" {
let pool_config = admin_provider_pool_config(&provider);
let key_ids = keys.iter().map(|key| key.id.clone()).collect::<Vec<_>>();
let runtime = match pool_config.as_ref() {
Some(pool_config) if !key_ids.is_empty() => {
read_admin_provider_pool_runtime_state(
state.runtime_state(),
&provider.id,
&key_ids,
pool_config,
None,
)
.await
}
_ => AdminProviderPoolRuntimeState::default(),
};
let now_unix_secs = admin_provider_pool_pure::admin_pool_now_unix_secs();
keys.retain(|key| {
admin_pool_key_visible_status_filter(
state,
key,
&provider_type,
pool_config.as_ref(),
&runtime,
now_unix_secs,
) == status
});
}
keys.sort_by(|left, right| { keys.sort_by(|left, right| {
left.internal_priority left.internal_priority
.cmp(&right.internal_priority) .cmp(&right.internal_priority)
@@ -60,6 +60,19 @@ pub(super) fn admin_pool_matches_search(
admin_provider_pool_pure::admin_pool_matches_search(key, search, oauth_plan_type.as_deref()) admin_provider_pool_pure::admin_pool_matches_search(key, search, oauth_plan_type.as_deref())
} }
pub(super) fn admin_pool_matches_catalog_search(
key: &StoredProviderCatalogKey,
search: Option<&str>,
) -> bool {
let Some(search) = search else {
return true;
};
let search = admin_pool_normalize_text(search);
search.is_empty()
|| admin_pool_normalize_text(&key.name).contains(&search)
|| admin_pool_normalize_text(&key.id).contains(&search)
}
pub(super) fn admin_pool_key_is_known_banned(key: &StoredProviderCatalogKey) -> bool { pub(super) fn admin_pool_key_is_known_banned(key: &StoredProviderCatalogKey) -> bool {
admin_provider_pool_pure::admin_pool_key_is_known_banned(key) admin_provider_pool_pure::admin_pool_key_is_known_banned(key)
} }
@@ -102,10 +102,12 @@ pub(crate) fn parse_admin_pool_quick_selectors(query: Option<&str>) -> Vec<Strin
} }
pub(crate) fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> { pub(crate) fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<String, String> {
let value = query_param_value(query, "status") let value = query_param_value(query, "status");
.unwrap_or_else(|| "all".to_string()) parse_admin_pool_status_value(value.as_deref())
.trim() }
.to_ascii_lowercase();
pub(crate) fn parse_admin_pool_status_value(value: Option<&str>) -> Result<String, String> {
let value = value.unwrap_or("all").trim().to_ascii_lowercase();
match value.as_str() { match value.as_str() {
"all" "all"
| "available" | "available"
@@ -3261,7 +3261,8 @@ async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ .json(&json!({
"search": "alpha", "search": "alpha",
"quick_selectors": ["enabled", "proxy_set"] "status": "available",
"quick_selectors": ["proxy_set"]
})) }))
.send() .send()
.await .await
@@ -3275,12 +3276,132 @@ async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin
assert_eq!(items[0]["key_id"], json!("key-openai-a")); assert_eq!(items[0]["key_id"], json!("key-openai-a"));
assert_eq!(items[0]["key_name"], json!("alpha proxy")); assert_eq!(items[0]["key_name"], json!("alpha proxy"));
assert_eq!(items[0]["auth_type"], json!("api_key")); assert_eq!(items[0]["auth_type"], json!("api_key"));
let invalid_status_response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/pool/provider-openai/keys/resolve-selection"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"status": "not-a-status"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(invalid_status_response.status(), StatusCode::BAD_REQUEST);
let invalid_status_payload: serde_json::Value = invalid_status_response
.json()
.await
.expect("json body should parse");
assert!(invalid_status_payload["detail"]
.as_str()
.is_some_and(|detail| detail.starts_with("status must be one of:")));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort(); gateway_handle.abort();
upstream_handle.abort(); upstream_handle.abort();
} }
#[tokio::test]
async fn gateway_pool_resolve_selection_matches_list_search_scope_for_status() {
let provider = sample_provider("provider-search-scope", "openai", 10);
let mut key = sample_key(
"key-visible-name",
"provider-search-scope",
"openai:chat",
"sk-search-scope",
);
key.name = "visible account".to_string();
key.note = Some("hidden-note-match".to_string());
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
vec![key],
));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
));
let all_list_response = local_admin_pool_response(
&state,
http::Method::GET,
"/api/admin/pool/provider-search-scope/keys?page=1&page_size=50&search=hidden-note-match&status=all&sort_by=score",
None,
)
.await;
assert_eq!(all_list_response.status(), StatusCode::OK);
let all_list_payload: serde_json::Value = serde_json::from_slice(
&to_bytes(all_list_response.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("json body should parse");
assert_eq!(all_list_payload["total"], json!(0));
let all_selection_response = local_admin_pool_response(
&state,
http::Method::POST,
"/api/admin/pool/provider-search-scope/keys/resolve-selection",
Some(json!({
"search": "hidden-note-match",
"status": "all"
})),
)
.await;
assert_eq!(all_selection_response.status(), StatusCode::OK);
let all_selection_payload: serde_json::Value = serde_json::from_slice(
&to_bytes(all_selection_response.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("json body should parse");
assert_eq!(all_selection_payload["total"], json!(0));
let available_list_response = local_admin_pool_response(
&state,
http::Method::GET,
"/api/admin/pool/provider-search-scope/keys?page=1&page_size=50&search=hidden-note-match&status=available",
None,
)
.await;
assert_eq!(available_list_response.status(), StatusCode::OK);
let available_list_payload: serde_json::Value = serde_json::from_slice(
&to_bytes(available_list_response.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("json body should parse");
assert_eq!(available_list_payload["total"], json!(1));
let available_selection_response = local_admin_pool_response(
&state,
http::Method::POST,
"/api/admin/pool/provider-search-scope/keys/resolve-selection",
Some(json!({
"search": "hidden-note-match",
"status": "available"
})),
)
.await;
assert_eq!(available_selection_response.status(), StatusCode::OK);
let available_selection_payload: serde_json::Value = serde_json::from_slice(
&to_bytes(available_selection_response.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("json body should parse");
assert_eq!(available_selection_payload["total"], json!(1));
assert_eq!(
available_selection_payload["items"][0]["key_id"],
json!("key-visible-name")
);
}
#[tokio::test] #[tokio::test]
async fn gateway_resolve_selection_marks_legacy_kiro_bearer_keys_as_oauth_managed() { async fn gateway_resolve_selection_marks_legacy_kiro_bearer_keys_as_oauth_managed() {
let mut provider = sample_provider("provider-kiro", "kiro", 10); let mut provider = sample_provider("provider-kiro", "kiro", 10);
+2
View File
@@ -13,6 +13,8 @@ pub struct AdminPoolResolveSelectionRequest {
#[serde(default)] #[serde(default)]
pub search: String, pub search: String,
#[serde(default)] #[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub quick_selectors: Vec<String>, pub quick_selectors: Vec<String>,
} }
+1
View File
@@ -306,6 +306,7 @@ export interface PoolScoresQuery {
export interface PoolKeySelectionRequest { export interface PoolKeySelectionRequest {
search?: string search?: string
status?: PoolKeysQuery['status']
quick_selectors?: string[] quick_selectors?: string[]
} }
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import Checkbox from '../checkbox.vue'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('Checkbox', () => {
it('applies and clears the native indeterminate state', async () => {
const indeterminate = ref(true)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(Checkbox, { indeterminate: indeterminate.value }),
})
app.mount(root)
mountedApps.push({ app, root })
const input = root.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(input?.indeterminate).toBe(true)
expect(input?.getAttribute('aria-checked')).toBe('mixed')
indeterminate.value = false
await nextTick()
expect(input?.indeterminate).toBe(false)
expect(input?.getAttribute('aria-checked')).toBe('false')
})
})
+12 -1
View File
@@ -1,20 +1,23 @@
<template> <template>
<input <input
ref="inputRef"
type="checkbox" type="checkbox"
:class="checkboxClass" :class="checkboxClass"
v-bind="$attrs" v-bind="$attrs"
:checked="isChecked" :checked="isChecked"
:aria-checked="indeterminate ? 'mixed' : isChecked"
@change="handleChange" @change="handleChange"
> >
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watchEffect } from 'vue'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface Props { interface Props {
modelValue?: boolean modelValue?: boolean
checked?: boolean checked?: boolean
indeterminate?: boolean
class?: string class?: string
} }
@@ -38,6 +41,14 @@ const isChecked = computed<boolean>(() => {
return props.modelValue ?? false return props.modelValue ?? false
}) })
const inputRef = ref<HTMLInputElement | null>(null)
watchEffect(() => {
if (inputRef.value) {
inputRef.value.indeterminate = props.indeterminate ?? false
}
}, { flush: 'post' })
function handleChange(event: Event) { function handleChange(event: Event) {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
const value = target.checked const value = target.checked
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,19 @@
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col gap-3 xl:hidden"> <div class="flex flex-col gap-3 xl:hidden">
<div class="min-w-0"> <div class="min-w-0">
<h3 class="text-base font-semibold"> <div class="flex min-w-0 items-center gap-2">
{{ legacyT('号池管理') }} <h3 class="text-base font-semibold">
</h3> {{ legacyT('号池管理') }}
</h3>
<span
v-if="selectedCount > 0"
class="shrink-0 text-xs font-medium tabular-nums text-primary"
aria-live="polite"
data-testid="pool-selected-count-mobile"
>
{{ selectedCountLabel }}
</span>
</div>
<p <p
v-if="metaText" v-if="metaText"
class="mt-1 text-xs text-muted-foreground" class="mt-1 text-xs text-muted-foreground"
@@ -76,12 +86,9 @@
class="min-w-0 flex-1 flex justify-center" class="min-w-0 flex-1 flex justify-center"
> >
<Button <Button
v-if="action.key !== 'providerProxy' && action.key !== 'refresh'"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8 shrink-0" class="h-8 w-8 shrink-0"
:class="action.key === 'toggleProvider' ? providerToggleButtonClass : ''"
:disabled="action.key === 'toggleProvider' ? togglingProviderStatus : false"
:title="action.title" :title="action.title"
@click="emit(action.event)" @click="emit(action.event)"
> >
@@ -90,20 +97,61 @@
class="w-3.5 h-3.5" class="w-3.5 h-3.5"
/> />
</Button> </Button>
</div>
<ProviderProxyPopover <div class="min-w-0 flex-1 flex justify-center">
v-else-if="action.key === 'providerProxy'" <Button
:open="providerProxyMobileOpen" variant="ghost"
:node-id="providerProxyNodeId" size="icon"
:saving="savingProviderProxy" class="h-8 w-8 shrink-0"
:title="providerProxyButtonTitle" :class="isAllFilteredSelected ? 'bg-primary/10 text-primary' : ''"
@update:open="emit('update:providerProxyMobileOpen', $event)" :disabled="selectionDisabled"
@select="emit('selectProviderProxy', $event)" :aria-pressed="isAllFilteredSelected"
@clear="emit('clearProviderProxy')" :title="legacyT(isAllFilteredSelected ? '取消全选' : '全选')"
/> data-testid="pool-select-all-mobile"
@click="emit('toggleSelectAll')"
>
<SquareCheckBig class="h-3.5 w-3.5" />
</Button>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<DropdownMenu :modal="false">
<DropdownMenuTrigger
as-child
:disabled="batchActionsDisabled"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:disabled="batchActionsDisabled"
:title="legacyT('选择执行动作')"
:aria-label="legacyT('选择执行动作')"
data-testid="pool-batch-actions-mobile"
>
<ListChecks class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-48"
>
<DropdownMenuItem
v-for="action in POOL_BATCH_ACTION_OPTIONS"
:key="`mobile-${action.value}`"
:class="action.destructive ? 'text-destructive focus:text-destructive' : ''"
:data-testid="`pool-batch-action-${action.value}-mobile`"
@select="emit('batchAction', action.value)"
>
{{ legacyT(action.label) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="min-w-0 flex-1 flex justify-center">
<RefreshButton <RefreshButton
v-else
:loading="refreshLoading" :loading="refreshLoading"
:title="refreshTitle" :title="refreshTitle"
@click="emit('refresh')" @click="emit('refresh')"
@@ -193,36 +241,12 @@
/> />
<Button <Button
v-if="hasSelectedProvider" v-for="action in desktopActions"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="legacyT('添加账号')"
@click="emit('import')"
>
<Upload class="w-3.5 h-3.5" />
</Button>
<ProviderProxyPopover
v-if="hasSelectedProvider"
:open="providerProxyDesktopOpen"
:node-id="providerProxyNodeId"
:saving="savingProviderProxy"
:title="providerProxyButtonTitle"
@update:open="emit('update:providerProxyDesktopOpen', $event)"
@select="emit('selectProviderProxy', $event)"
@clear="emit('clearProviderProxy')"
/>
<Button
v-for="action in desktopPostProxyActions"
v-show="hasSelectedProvider" v-show="hasSelectedProvider"
:key="action.key" :key="action.key"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
:class="action.key === 'toggleProvider' ? providerToggleButtonClass : ''"
:disabled="action.key === 'toggleProvider' ? togglingProviderStatus : false"
:data-testid="action.key === 'demandMetrics' ? 'pool-demand-metrics-button' : undefined" :data-testid="action.key === 'demandMetrics' ? 'pool-demand-metrics-button' : undefined"
:title="action.title" :title="action.title"
@click="emit(action.event)" @click="emit(action.event)"
@@ -233,6 +257,57 @@
/> />
</Button> </Button>
<Button
v-if="hasSelectedProvider"
variant="ghost"
size="icon"
class="h-8 w-8"
:class="isAllFilteredSelected ? 'bg-primary/10 text-primary' : ''"
:disabled="selectionDisabled"
:aria-pressed="isAllFilteredSelected"
:title="legacyT(isAllFilteredSelected ? '取消全选' : '全选')"
data-testid="pool-select-all-desktop"
@click="emit('toggleSelectAll')"
>
<SquareCheckBig class="h-3.5 w-3.5" />
</Button>
<DropdownMenu
v-if="hasSelectedProvider"
:modal="false"
>
<DropdownMenuTrigger
as-child
:disabled="batchActionsDisabled"
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="batchActionsDisabled"
:title="legacyT('选择执行动作')"
:aria-label="legacyT('选择执行动作')"
data-testid="pool-batch-actions-desktop"
>
<ListChecks class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-48"
>
<DropdownMenuItem
v-for="action in POOL_BATCH_ACTION_OPTIONS"
:key="`desktop-${action.value}`"
:class="action.destructive ? 'text-destructive focus:text-destructive' : ''"
:data-testid="`pool-batch-action-${action.value}-desktop`"
@select="emit('batchAction', action.value)"
>
{{ legacyT(action.label) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RefreshButton <RefreshButton
:loading="refreshLoading" :loading="refreshLoading"
:title="refreshTitle" :title="refreshTitle"
@@ -248,17 +323,19 @@ import { computed } from 'vue'
import { import {
Activity, Activity,
ChevronDown, ChevronDown,
Edit, Eye,
Plug, ListChecks,
Power,
Search, Search,
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
Upload, SquareCheckBig,
Users,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
Button, Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input, Input,
Select, Select,
SelectContent, SelectContent,
@@ -267,31 +344,24 @@ import {
SelectValue, SelectValue,
} from '@/components/ui' } from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue' import RefreshButton from '@/components/ui/refresh-button.vue'
import ProviderProxyPopover from '@/features/pool/components/ProviderProxyPopover.vue'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import type { PoolOverviewItem } from '@/api/endpoints/pool' import type { PoolOverviewItem } from '@/api/endpoints/pool'
import {
POOL_BATCH_ACTION_OPTIONS,
type PoolBatchActionValue,
} from '@/features/pool/utils/poolBatchActions'
type HeaderActionEvent = type HeaderActionEvent =
| 'import'
| 'scheduling' | 'scheduling'
| 'accountBatch' | 'viewProvider'
| 'editProvider'
| 'editEndpoint'
| 'demandMetrics' | 'demandMetrics'
| 'advanced' | 'advanced'
| 'toggleProvider'
type HeaderActionKey = type HeaderActionKey =
| 'import'
| 'providerProxy'
| 'scheduling' | 'scheduling'
| 'accountBatch' | 'viewProvider'
| 'editProvider'
| 'editEndpoint'
| 'demandMetrics' | 'demandMetrics'
| 'advanced' | 'advanced'
| 'toggleProvider'
| 'refresh'
interface HeaderAction { interface HeaderAction {
key: HeaderActionKey key: HeaderActionKey
@@ -308,41 +378,30 @@ const props = withDefaults(defineProps<{
statusOptions: Array<{ value: string, label: string }> statusOptions: Array<{ value: string, label: string }>
search: string search: string
metaText?: string metaText?: string
providerProxyNodeId?: string | null
providerProxyMobileOpen: boolean
providerProxyDesktopOpen: boolean
providerProxyButtonTitle: string
savingProviderProxy: boolean
poolSchedulingLabel: string poolSchedulingLabel: string
showAdaptiveHotPoolMetricsButton: boolean showAdaptiveHotPoolMetricsButton: boolean
providerToggleButtonTitle: string selectedCount?: number
providerToggleButtonClass?: string isAllFilteredSelected: boolean
togglingProviderStatus: boolean selectionDisabled: boolean
batchActionsDisabled: boolean
refreshLoading: boolean refreshLoading: boolean
refreshTitle: string refreshTitle: string
}>(), { }>(), {
metaText: '', metaText: '',
providerProxyNodeId: null, selectedCount: 0,
providerToggleButtonClass: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:providerId': [value: string] 'update:providerId': [value: string]
'update:status': [value: string] 'update:status': [value: string]
'update:search': [value: string] 'update:search': [value: string]
'update:providerProxyMobileOpen': [value: boolean]
'update:providerProxyDesktopOpen': [value: boolean]
import: []
scheduling: [] scheduling: []
accountBatch: [] viewProvider: []
editProvider: []
editEndpoint: []
demandMetrics: [] demandMetrics: []
advanced: [] advanced: []
toggleProvider: [] toggleSelectAll: []
batchAction: [action: PoolBatchActionValue]
refresh: [] refresh: []
selectProviderProxy: [nodeId: string]
clearProviderProxy: []
}>() }>()
const { legacyT } = useI18n() const { legacyT } = useI18n()
@@ -364,38 +423,31 @@ const searchModel = computed({
const hasSelectedProvider = computed(() => Boolean(props.providerId)) const hasSelectedProvider = computed(() => Boolean(props.providerId))
const mobileActions = computed(() => { const selectedCountLabel = computed(() => legacyT(`已选 ${Math.max(0, props.selectedCount)} 个`))
const actions: Array<HeaderAction | { key: 'providerProxy' | 'refresh' }> = [
{ key: 'import', title: legacyT('添加账号'), event: 'import', icon: Upload }, const mobileActions = computed<HeaderAction[]>(() => {
{ key: 'providerProxy' }, const actions: HeaderAction[] = [
{ key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye },
{ key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal }, { key: 'scheduling', title: legacyT('号池调度'), event: 'scheduling', icon: SlidersHorizontal },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
] ]
if (props.showAdaptiveHotPoolMetricsButton) { if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity }) actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
} }
actions.push( actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 }, { key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
{ key: 'refresh' },
) )
return actions return actions
}) })
const desktopPostProxyActions = computed<HeaderAction[]>(() => { const desktopActions = computed<HeaderAction[]>(() => {
const actions: HeaderAction[] = [ const actions: HeaderAction[] = [
{ key: 'editProvider', title: legacyT('编辑提供商'), event: 'editProvider', icon: Edit }, { key: 'viewProvider', title: legacyT('查看详情'), event: 'viewProvider', icon: Eye },
{ key: 'editEndpoint', title: legacyT('编辑端点'), event: 'editEndpoint', icon: Plug },
] ]
if (props.showAdaptiveHotPoolMetricsButton) { if (props.showAdaptiveHotPoolMetricsButton) {
actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity }) actions.push({ key: 'demandMetrics', title: legacyT('查看自适应热池指标'), event: 'demandMetrics', icon: Activity })
} }
actions.push( actions.push(
{ key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 }, { key: 'advanced', title: legacyT('高级设置'), event: 'advanced', icon: Settings2 },
{ key: 'accountBatch', title: legacyT('账号批量操作'), event: 'accountBatch', icon: Users },
{ key: 'toggleProvider', title: props.providerToggleButtonTitle, event: 'toggleProvider', icon: Power },
) )
return actions return actions
}) })
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { PoolKeyDetail } from '@/api/endpoints/pool'
import PoolAccountBatchDialog from '../PoolAccountBatchDialog.vue'
const apiMocks = vi.hoisted(() => ({
batchActionPoolKeys: vi.fn(),
getPoolBatchDeleteTask: vi.fn(),
resolvePoolKeySelection: vi.fn(),
exportKey: vi.fn(),
refreshProviderQuota: vi.fn(),
refreshProviderOAuth: vi.fn(),
}))
vi.mock('@/api/endpoints/pool', () => ({
batchActionPoolKeys: apiMocks.batchActionPoolKeys,
getPoolBatchDeleteTask: apiMocks.getPoolBatchDeleteTask,
resolvePoolKeySelection: apiMocks.resolvePoolKeySelection,
}))
vi.mock('@/api/endpoints/keys', () => ({
exportKey: apiMocks.exportKey,
refreshProviderQuota: apiMocks.refreshProviderQuota,
}))
vi.mock('@/api/endpoints/provider_oauth', () => ({
refreshProviderOAuth: apiMocks.refreshProviderOAuth,
}))
vi.mock('@/stores/proxy-nodes', () => ({
useProxyNodesStore: () => ({ nodes: [], ensureLoaded: vi.fn() }),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), warning: vi.fn(), error: vi.fn() }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }),
}))
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
const { defineComponent } = await import('vue')
return { default: defineComponent({ name: 'ProxyNodeSelectStub', render: () => null }) }
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function createKey(id: string, authType: PoolKeyDetail['auth_type']): PoolKeyDetail {
return {
key_id: id,
key_name: id,
is_active: true,
auth_type: authType,
api_formats: ['openai:chat'],
} as PoolKeyDetail
}
async function settle(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
apiMocks.refreshProviderQuota.mockReset().mockResolvedValue({
success: 2,
failed: 0,
total: 2,
results: [],
})
apiMocks.resolvePoolKeySelection.mockReset()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('PoolAccountBatchDialog initial selection', () => {
it('runs the action selected from the header menu when the dialog opens', async () => {
const selectedKeys = [createKey('oauth-key', 'oauth'), createKey('api-key', 'api_key')]
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'codex',
selectedKeys,
selectAllFiltered: false,
selectedCount: selectedKeys.length,
selectionFilters: { status: 'all' },
initialAction: 'refresh_quota',
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await settle()
expect(apiMocks.refreshProviderQuota).toHaveBeenCalledTimes(1)
expect(apiMocks.refreshProviderQuota).toHaveBeenCalledWith('provider-1', ['oauth-key', 'api-key'])
})
it('opens required configuration instead of executing an incomplete menu action', async () => {
const selectedKeys = [createKey('api-key', 'api_key')]
const open = ref(true)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'openai',
selectedKeys,
selectAllFiltered: false,
selectedCount: selectedKeys.length,
selectionFilters: { status: 'all' },
initialAction: 'set_proxy',
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
expect(document.body.textContent).toContain('选择要绑定的代理节点')
expect(apiMocks.refreshProviderQuota).not.toHaveBeenCalled()
})
it('uses explicit table selections without loading a second account list', async () => {
const selectedKeys = [createKey('oauth-key', 'oauth'), createKey('api-key', 'api_key')]
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'codex',
selectedKeys,
selectAllFiltered: false,
selectedCount: selectedKeys.length,
selectionFilters: { status: 'all' },
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
open.value = true
await settle()
expect(document.body.textContent).toContain('已选择 2 个账号')
expect(apiMocks.resolvePoolKeySelection).not.toHaveBeenCalled()
const actionButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.includes('刷新额度'))
actionButton?.click()
await settle()
expect(apiMocks.refreshProviderQuota).toHaveBeenCalledWith('provider-1', ['oauth-key', 'api-key'])
})
it('resolves all filtered table results with the supplied filter snapshot', async () => {
const selectedKeys = [createKey('visible-key', 'api_key')]
apiMocks.resolvePoolKeySelection.mockResolvedValue({
total: 2,
items: [
{ key_id: 'visible-key', key_name: 'visible-key', auth_type: 'api_key' },
{ key_id: 'hidden-page-key', key_name: 'hidden-page-key', auth_type: 'api_key' },
],
})
const open = ref(true)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(PoolAccountBatchDialog, {
modelValue: open.value,
providerId: 'provider-1',
providerName: 'Provider 1',
providerType: 'codex',
selectedKeys,
selectAllFiltered: true,
selectedCount: 2,
selectionFilters: { search: 'inactive', status: 'inactive' },
'onUpdate:modelValue': (value: boolean) => { open.value = value },
})
},
}))
app.mount(root)
mountedApps.push({ app, root })
await settle()
const actionButton = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent?.includes('刷新额度'))
expect(actionButton).not.toBeUndefined()
actionButton?.click()
await settle()
expect(apiMocks.resolvePoolKeySelection).toHaveBeenCalledWith('provider-1', {
search: 'inactive',
status: 'inactive',
})
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { createApp, defineComponent, h } from 'vue' import { createApp, defineComponent, h, nextTick } from 'vue'
import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue' import PoolManagementHeader from '@/features/pool/components/PoolManagementHeader.vue'
import { createI18n } from '@/i18n' import { createI18n } from '@/i18n'
@@ -14,7 +14,7 @@ const provider = {
} as PoolOverviewItem } as PoolOverviewItem
describe('PoolManagementHeader', () => { describe('PoolManagementHeader', () => {
it('keeps page actions wired through component events', () => { it('keeps page actions wired through component events', async () => {
const events: string[] = [] const events: string[] = []
const Probe = defineComponent({ const Probe = defineComponent({
setup() { setup() {
@@ -26,21 +26,20 @@ describe('PoolManagementHeader', () => {
statusOptions: [{ value: 'all', label: '全部状态' }], statusOptions: [{ value: 'all', label: '全部状态' }],
search: '', search: '',
metaText: 'codex | 启用', metaText: 'codex | 启用',
providerProxyNodeId: null,
providerProxyMobileOpen: false,
providerProxyDesktopOpen: false,
providerProxyButtonTitle: '提供商代理(未设置)',
savingProviderProxy: false,
poolSchedulingLabel: '2 维度', poolSchedulingLabel: '2 维度',
showAdaptiveHotPoolMetricsButton: true, showAdaptiveHotPoolMetricsButton: true,
providerToggleButtonTitle: '当前状态:已启用,点击禁用提供商', selectedCount: 2,
togglingProviderStatus: false, isAllFilteredSelected: false,
selectionDisabled: false,
batchActionsDisabled: false,
refreshLoading: false, refreshLoading: false,
refreshTitle: '刷新', refreshTitle: '刷新',
onImport: () => events.push('import'), onViewProvider: () => events.push('viewProvider'),
onScheduling: () => events.push('scheduling'), onScheduling: () => events.push('scheduling'),
onAccountBatch: () => events.push('accountBatch'),
onDemandMetrics: () => events.push('demandMetrics'), onDemandMetrics: () => events.push('demandMetrics'),
onAdvanced: () => events.push('advanced'),
onToggleSelectAll: () => events.push('toggleSelectAll'),
onBatchAction: (action: string) => events.push(`batchAction:${action}`),
onRefresh: () => events.push('refresh'), onRefresh: () => events.push('refresh'),
}) })
}, },
@@ -52,14 +51,44 @@ describe('PoolManagementHeader', () => {
app.use(createI18n()) app.use(createI18n())
app.mount(root) app.mount(root)
root.querySelector<HTMLButtonElement>('[title="添加账号"]')?.click() root.querySelector<HTMLButtonElement>('[title="查看详情"]')?.click()
root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click() root.querySelector<HTMLButtonElement>('[title="点击调整号池调度"]')?.click()
root.querySelector<HTMLButtonElement>('[title="账号批量操作"]')?.click()
root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click() root.querySelector<HTMLButtonElement>('[title="查看自适应热池指标"]')?.click()
const desktopActions = root.querySelector('[data-testid="pool-header-actions"]')
const advancedButton = desktopActions?.querySelector<HTMLButtonElement>('[title="高级设置"]')
const selectAllButton = desktopActions?.querySelector<HTMLButtonElement>('[data-testid="pool-select-all-desktop"]')
const batchActionsButton = desktopActions?.querySelector<HTMLButtonElement>('[data-testid="pool-batch-actions-desktop"]')
advancedButton?.click()
selectAllButton?.click()
batchActionsButton?.click()
await nextTick()
document.body.querySelector<HTMLElement>('[data-testid="pool-batch-action-refresh_quota-desktop"]')?.click()
await nextTick()
root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click() root.querySelector<HTMLButtonElement>('[title="刷新"]')?.click()
expect(events).toEqual(['import', 'scheduling', 'accountBatch', 'demandMetrics', 'refresh']) expect(events).toEqual([
'viewProvider',
'scheduling',
'demandMetrics',
'advanced',
'toggleSelectAll',
'batchAction:refresh_quota',
'refresh',
])
expect(selectAllButton?.textContent?.trim()).toBe('')
expect(selectAllButton?.getAttribute('title')).toBe('全选')
expect(advancedButton?.nextElementSibling).toBe(selectAllButton)
expect(selectAllButton?.nextElementSibling).toBe(batchActionsButton)
expect(batchActionsButton?.getAttribute('title')).toBe('选择执行动作')
expect(root.querySelector('[title="账号批量操作"]')).toBeNull()
expect(root.querySelector('[title="添加账号"]')).toBeNull()
expect(root.querySelector('[title="提供商代理(未设置)"]')).toBeNull()
expect(root.querySelector('[title="编辑端点"]')).toBeNull()
expect(root.querySelector('[title="编辑提供商"]')).toBeNull()
expect(root.querySelector('[title="当前状态:已启用,点击禁用提供商"]')).toBeNull()
expect(root.textContent).toContain('2 维度') expect(root.textContent).toContain('2 维度')
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-selected-count-mobile"]')?.textContent).toContain('已选 2 个')
app.unmount() app.unmount()
root.remove() root.remove()
@@ -0,0 +1,31 @@
export type PoolBatchActionValue =
| 'edit_config'
| 'export'
| 'delete'
| 'refresh_oauth'
| 'refresh_quota'
| 'clear_proxy'
| 'set_proxy'
| 'update_settings'
| 'enable'
| 'disable'
export interface PoolBatchActionOption {
value: PoolBatchActionValue
label: string
hint: string
destructive?: boolean
}
export const POOL_BATCH_ACTION_OPTIONS: readonly PoolBatchActionOption[] = [
{ value: 'edit_config', label: '编辑配置', hint: '统一修改支持 API、调度参数与自动获取模型设置。' },
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
{ value: 'update_settings', label: '更多设置', hint: '选择性修改 RPM、并发、熔断、备注和代理等配置。' },
{ value: 'clear_proxy', label: '清除代理', hint: '移除账号独立代理,回退到提供商默认代理。' },
{ value: 'enable', label: '启用', hint: '批量启用账号,恢复可调度状态。' },
{ value: 'disable', label: '禁用', hint: '批量禁用账号,保留数据但停止调度。' },
{ value: 'export', label: '导出凭据', hint: '仅导出 OAuth 凭据,其他类型账号将被跳过。' },
{ value: 'delete', label: '删除账号', hint: '永久删除账号数据,执行后不可恢复。', destructive: true },
]
@@ -37,7 +37,8 @@ describe('provider key batch import UI contract', () => {
expect(source).toContain("selectedAction === 'update_settings'") expect(source).toContain("selectedAction === 'update_settings'")
expect(source).toContain('buildPoolKeySettingsPatch') expect(source).toContain('buildPoolKeySettingsPatch')
expect(source).toContain("confirmAndExecuteAction('update_settings')") expect(source).toContain('confirmAndExecuteAction(selectedAction)')
expect(source).toContain('仅更新已勾选字段') expect(source).toContain('仅更新已勾选字段')
expect(source).not.toContain('v-for="item in ACTION_OPTIONS"')
}) })
}) })
+2
View File
@@ -1067,6 +1067,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
'选择一个或多个分组': 'Select one or more groups', '选择一个或多个分组': 'Select one or more groups',
'暂无用户分组': 'No user groups', '暂无用户分组': 'No user groups',
'选择批量动作': 'Select batch action', '选择批量动作': 'Select batch action',
'选择执行动作': 'Select action to execute',
'只会提交当前动作对应的字段': 'Only fields for the current action will be submitted', '只会提交当前动作对应的字段': 'Only fields for the current action will be submitted',
'批量修改用户角色': 'Batch update user roles', '批量修改用户角色': 'Batch update user roles',
'将所选用户统一调整为同一个角色。管理员角色拥有后台管理权限,请确认选择范围。': 'Set selected users to the same role. Admin roles have backend management permissions, so verify the target scope.', '将所选用户统一调整为同一个角色。管理员角色拥有后台管理权限,请确认选择范围。': 'Set selected users to the same role. Admin roles have backend management permissions, so verify the target scope.',
@@ -2581,6 +2582,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^成功 (.+) 个,失败 (.+) 个$/u, match => `Succeeded ${match[1]}, failed ${match[2]}`], [/^成功 (.+) 个,失败 (.+) 个$/u, match => `Succeeded ${match[1]}, failed ${match[2]}`],
[/^批量操作完成:成功 (.+) 个,失败 (.+) 个$/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`], [/^批量操作完成:成功 (.+) 个,失败 (.+) 个$/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`],
[/^匹配 (.+) 个,当前页 (.+) 个,已选 (.+) 个$/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`], [/^匹配 (.+) 个,当前页 (.+) 个,已选 (.+) 个$/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`],
[/^已选 (.+) 个$/u, match => `Selected ${match[1]} items`],
[/^已强制下线 (.+) 个设备$/u, match => `Signed out ${match[1]} devices`], [/^已强制下线 (.+) 个设备$/u, match => `Signed out ${match[1]} devices`],
[/^用户已(.+)$/u, match => `User ${translateLegacyByTokens(match[1]).toLowerCase()}d`], [/^用户已(.+)$/u, match => `User ${translateLegacyByTokens(match[1]).toLowerCase()}d`],
[/^(.+)用户失败$/u, match => `Failed to ${translateLegacyByTokens(match[1]).toLowerCase()} user`], [/^(.+)用户失败$/u, match => `Failed to ${translateLegacyByTokens(match[1]).toLowerCase()} user`],
+305 -266
View File
@@ -12,31 +12,21 @@
:provider-select-disabled="providerSelectDisabled" :provider-select-disabled="providerSelectDisabled"
:status-options="poolKeyStatusFilterOptions" :status-options="poolKeyStatusFilterOptions"
:meta-text="poolHeaderMetaText" :meta-text="poolHeaderMetaText"
:provider-proxy-node-id="selectedProviderData?.proxy?.node_id"
:provider-proxy-mobile-open="providerProxyMobilePopoverOpen"
:provider-proxy-desktop-open="providerProxyDesktopPopoverOpen"
:provider-proxy-button-title="getProviderProxyButtonTitle()"
:saving-provider-proxy="savingProviderProxy"
:pool-scheduling-label="poolSchedulingLabel" :pool-scheduling-label="poolSchedulingLabel"
:show-adaptive-hot-pool-metrics-button="showAdaptiveHotPoolMetricsButton" :show-adaptive-hot-pool-metrics-button="showAdaptiveHotPoolMetricsButton"
:provider-toggle-button-title="getProviderToggleButtonTitle()" :selected-count="selectedKeyCount"
:provider-toggle-button-class="getProviderToggleButtonClass()" :is-all-filtered-selected="isAllFilteredPoolKeysSelected"
:toggling-provider-status="togglingProviderStatus" :selection-disabled="keyPage.total === 0 || poolKeySelectionBusy"
:batch-actions-disabled="selectedKeyCount === 0 || poolKeySelectionBusy"
:refresh-loading="refreshCurrentPageLoading" :refresh-loading="refreshCurrentPageLoading"
:refresh-title="refreshButtonTitle" :refresh-title="refreshButtonTitle"
@import="showImportDialog = true" @view-provider="openProviderDrawer"
@scheduling="openSchedulingDialog" @scheduling="openSchedulingDialog"
@account-batch="showAccountBatchDialog = true"
@edit-provider="openProviderEditDialog"
@edit-endpoint="openEndpointEditDialog"
@demand-metrics="showDemandMetricsDialog = true" @demand-metrics="showDemandMetricsDialog = true"
@advanced="showAdvancedDialog = true" @advanced="showAdvancedDialog = true"
@toggle-provider="toggleSelectedProviderStatus" @toggle-select-all="toggleAllFilteredPoolKeys"
@batch-action="openAccountBatchDialog"
@refresh="refreshCurrentPage" @refresh="refreshCurrentPage"
@update:provider-proxy-mobile-open="handleProviderProxyPopoverToggle('mobile', $event)"
@update:provider-proxy-desktop-open="handleProviderProxyPopoverToggle('desktop', $event)"
@select-provider-proxy="setProviderProxy"
@clear-provider-proxy="clearProviderProxy"
/> />
<!-- Loading (initial) --> <!-- Loading (initial) -->
@@ -91,13 +81,47 @@
class="hidden xl:block overflow-x-auto" class="hidden xl:block overflow-x-auto"
> >
<Table class="w-full table-fixed"> <Table class="w-full table-fixed">
<colgroup>
<col :style="{ width: desktopColumnWidths.name }">
<col
v-if="showAccountQuotaColumn"
:style="{ width: desktopColumnWidths.quota }"
>
<col :style="{ width: desktopColumnWidths.stats }">
<col :style="{ width: desktopColumnWidths.imported }">
<col :style="{ width: desktopColumnWidths.lastUsed }">
<col :style="{ width: desktopColumnWidths.score }">
<col :style="{ width: desktopColumnWidths.status }">
<col :style="{ width: desktopColumnWidths.actions }">
</colgroup>
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead <TableHead
class="font-semibold whitespace-nowrap" class="px-4 font-semibold whitespace-nowrap"
:style="{ width: desktopColumnWidths.name }" :style="{ width: desktopColumnWidths.name }"
> >
名称 <div class="flex items-center gap-2">
<Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="selectAllFilteredPoolKeys || isCurrentPoolKeyPageFullySelected"
:indeterminate="!selectAllFilteredPoolKeys && isCurrentPoolKeyPagePartiallySelected"
:disabled="keyPage.keys.length === 0 || poolKeySelectionBusy || selectAllFilteredPoolKeys"
aria-label="选择当前页账号"
data-testid="pool-select-page-desktop"
@update:checked="toggleCurrentPoolKeyPage"
/>
<div class="flex items-baseline gap-2">
<span class="leading-none">名称</span>
<span
v-if="selectedKeyCount > 0"
class="text-[11px] font-medium leading-none tabular-nums text-primary"
aria-live="polite"
data-testid="pool-selected-count-desktop"
>
{{ selectedKeyCountLabel }}
</span>
</div>
</div>
</TableHead> </TableHead>
<TableHead <TableHead
v-if="showAccountQuotaColumn" v-if="showAccountQuotaColumn"
@@ -183,106 +207,116 @@
v-for="key in keyPage.keys" v-for="key in keyPage.keys"
:key="key.key_id" :key="key.key_id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors" class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
:class="keyUiStateMap[key.key_id]?.rowClass || ''" :class="getPoolKeyRowClass(key.key_id)"
> >
<TableCell <TableCell
class="py-3" class="px-4 py-3"
> >
<div class="min-w-0"> <div class="flex min-w-0 items-center gap-2">
<div class="flex items-center gap-1.5 min-w-0"> <Checkbox
<span class="text-sm truncate block"> class="h-3.5 w-3.5 shrink-0"
{{ key.key_name || '未命名' }} :checked="isPoolKeySelected(key.key_id)"
</span> :disabled="poolKeySelectionBusy || selectAllFilteredPoolKeys"
</div> :aria-label="`选择账号 ${key.key_name || key.key_id}`"
<div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0"> :data-testid="`pool-select-desktop-${key.key_id}`"
<input @update:checked="togglePoolKeySelection(key.key_id, $event === true)"
v-if="editingPriorityKeyId === key.key_id" />
:value="editingPriorityValue" <div class="min-w-0 flex-1">
type="number" <div class="flex items-center gap-1.5 min-w-0">
min="1" <span class="text-sm truncate block">
max="999999" {{ key.key_name || '未命名' }}
autofocus </span>
class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" </div>
@input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)" <div class="flex items-center flex-wrap gap-1 text-[11px] text-muted-foreground mt-0.5 min-w-0">
@blur="(e) => finishEditInternalPriority(key, e)" <input
@keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)" v-if="editingPriorityKeyId === key.key_id"
@keydown.esc.prevent="cancelEditInternalPriority" :value="editingPriorityValue"
> type="number"
<button min="1"
v-else max="999999"
type="button" autofocus
class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0" class="h-[18px] w-10 rounded border border-primary/50 bg-background px-1 text-[10px] tabular-nums text-foreground outline-none ring-1 ring-primary/30 shrink-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
title="点击编辑优先级" @input="(e) => editingPriorityValue = Number((e.target as HTMLInputElement).value || 0)"
@click="startEditInternalPriority(key)" @blur="(e) => finishEditInternalPriority(key, e)"
> @keydown.enter.prevent="(e) => finishEditInternalPriority(key, e)"
P{{ key.internal_priority ?? 50 }} @keydown.esc.prevent="cancelEditInternalPriority"
</button> >
<Button <button
v-if="canExportOAuthCredential(key)" v-else
variant="ghost" type="button"
size="icon" class="h-4 px-1 rounded text-[10px] tabular-nums text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors shrink-0"
class="h-4 w-4 shrink-0" title="点击编辑优先级"
title="下载 OAuth 授权文件" @click="startEditInternalPriority(key)"
@click.stop="downloadRefreshToken(key)" >
> P{{ key.internal_priority ?? 50 }}
<Download class="w-2.5 h-2.5" /> </button>
</Button>
<Button
v-else
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-2.5 h-2.5" />
</Button>
<span class="font-mono">
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
</span>
<template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
<Button <Button
v-if="canExportOAuthCredential(key)"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-4 w-4 shrink-0" class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken" title="下载 OAuth 授权文件"
:title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''" @click.stop="downloadRefreshToken(key)"
@click.stop="handleRefreshOAuth(key)"
> >
<RefreshCw <Download class="w-2.5 h-2.5" />
class="w-2.5 h-2.5"
:class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
/>
</Button> </Button>
<span <Button
v-if="keyUiStateMap[key.key_id]?.visibleOAuthState" v-else
class="text-[10px]" variant="ghost"
:class="{ size="icon"
'text-destructive': keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid || keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired, class="h-4 w-4 shrink-0"
'text-warning': keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid, title="复制密钥"
'text-muted-foreground': !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid @click.stop="copyFullKey(key)"
}"
:title="keyUiStateMap[key.key_id]?.oauthStatusTitle || ''"
> >
{{ keyUiStateMap[key.key_id]?.visibleOAuthState?.text }} <Copy class="w-2.5 h-2.5" />
</Button>
<span class="font-mono">
{{ getProviderMaskedSecretLabel(key, selectedProviderType) }}
</span> </span>
</template> <template v-if="keyUiStateMap[key.key_id]?.showOAuthRefreshControl">
<Badge <Button
v-if="keyUiStateMap[key.key_id]?.planLabel" variant="ghost"
variant="outline" size="icon"
class="text-[9px] px-1 py-0 h-4 shrink-0" class="h-4 w-4 shrink-0"
:class="keyUiStateMap[key.key_id]?.planClass || ''" :disabled="refreshingOAuthKeyId === key.key_id || !keyUiStateMap[key.key_id]?.canRefreshToken"
> :title="keyUiStateMap[key.key_id]?.oauthRefreshButtonTitle || ''"
{{ keyUiStateMap[key.key_id]?.planLabel }} @click.stop="handleRefreshOAuth(key)"
</Badge> >
<Badge <RefreshCw
v-if="keyUiStateMap[key.key_id]?.oauthOrgBadge" class="w-2.5 h-2.5"
variant="secondary" :class="{ 'animate-spin': refreshingOAuthKeyId === key.key_id }"
class="text-[9px] px-1 py-0 h-4 shrink-0" />
:title="keyUiStateMap[key.key_id]?.oauthOrgBadge?.title" </Button>
> <span
{{ keyUiStateMap[key.key_id]?.oauthOrgBadge?.label }} v-if="keyUiStateMap[key.key_id]?.visibleOAuthState"
</Badge> class="text-[10px]"
:class="{
'text-destructive': keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid || keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired,
'text-warning': keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid,
'text-muted-foreground': !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpired && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isExpiringSoon && !keyUiStateMap[key.key_id]?.visibleOAuthState?.isInvalid
}"
:title="keyUiStateMap[key.key_id]?.oauthStatusTitle || ''"
>
{{ keyUiStateMap[key.key_id]?.visibleOAuthState?.text }}
</span>
</template>
<Badge
v-if="keyUiStateMap[key.key_id]?.planLabel"
variant="outline"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:class="keyUiStateMap[key.key_id]?.planClass || ''"
>
{{ keyUiStateMap[key.key_id]?.planLabel }}
</Badge>
<Badge
v-if="keyUiStateMap[key.key_id]?.oauthOrgBadge"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="keyUiStateMap[key.key_id]?.oauthOrgBadge?.title"
>
{{ keyUiStateMap[key.key_id]?.oauthOrgBadge?.label }}
</Badge>
</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
@@ -512,11 +546,21 @@
v-for="key in keyPage.keys" v-for="key in keyPage.keys"
:key="key.key_id" :key="key.key_id"
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors" class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
:class="keyUiStateMap[key.key_id]?.rowClass || ''" :class="getPoolKeyRowClass(key.key_id)"
> >
<div class="space-y-3"> <div class="space-y-3">
<div class="text-sm font-medium truncate"> <div class="flex items-center gap-1.5">
{{ key.key_name || '未命名' }} <Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="isPoolKeySelected(key.key_id)"
:disabled="poolKeySelectionBusy || selectAllFilteredPoolKeys"
:aria-label="`选择账号 ${key.key_name || key.key_id}`"
:data-testid="`pool-select-mobile-${key.key_id}`"
@update:checked="togglePoolKeySelection(key.key_id, $event === true)"
/>
<div class="min-w-0 truncate text-sm font-medium">
{{ key.key_name || '未命名' }}
</div>
</div> </div>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
@@ -900,20 +944,21 @@
:provider-name="selectedProviderOverview?.provider_name" :provider-name="selectedProviderOverview?.provider_name"
:samples="providerDemandMetricSamples" :samples="providerDemandMetricSamples"
/> />
<ProviderDetailDrawer
v-if="providerDrawerOpen && selectedProviderId"
:open="providerDrawerOpen"
:provider-id="selectedProviderId"
:initial-provider="selectedProviderData"
@update:open="providerDrawerOpen = $event"
@edit="openProviderEditDialog"
@toggle-status="toggleSelectedProviderStatus"
@refresh="handleProviderDrawerRefresh"
/>
<ProviderFormDialog <ProviderFormDialog
v-model="providerEditDialogOpen" v-model="providerEditDialogOpen"
:provider="providerToEdit" :provider="providerToEdit"
@provider-updated="handleProviderEditSaved" @provider-updated="handleProviderEditSaved"
/> />
<EndpointFormDialog
v-if="selectedProviderData"
v-model="endpointEditDialogOpen"
:provider="selectedProviderData"
:endpoints="providerEndpointsForEdit"
:provider-format-conversion-enabled="selectedProviderData.enable_format_conversion"
@endpoint-created="handleEndpointEditSaved"
@endpoint-updated="handleEndpointEditSaved"
/>
<PoolAccountBatchDialog <PoolAccountBatchDialog
v-if="selectedProviderId" v-if="selectedProviderId"
v-model="showAccountBatchDialog" v-model="showAccountBatchDialog"
@@ -921,6 +966,11 @@
:provider-name="selectedProviderData?.name || ''" :provider-name="selectedProviderData?.name || ''"
:provider-type="selectedProviderData?.provider_type || selectedProviderType" :provider-type="selectedProviderData?.provider_type || selectedProviderType"
:batch-concurrency="selectedProviderConfig?.batch_concurrency" :batch-concurrency="selectedProviderConfig?.batch_concurrency"
:selected-keys="selectedPoolKeys"
:select-all-filtered="selectAllFilteredPoolKeys"
:selected-count="selectedKeyCount"
:selection-filters="poolKeySelectionFilters"
:initial-action="pendingAccountBatchAction"
@changed="handleAccountBatchChanged" @changed="handleAccountBatchChanged"
@edit-config="openKeyBatchEditDialog" @edit-config="openKeyBatchEditDialog"
/> />
@@ -963,7 +1013,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
import { import {
Upload, Upload,
RefreshCw, RefreshCw,
@@ -984,6 +1034,7 @@ import {
Card, Card,
Badge, Badge,
Button, Button,
Checkbox,
Table, Table,
TableHeader, TableHeader,
TableBody, TableBody,
@@ -1002,6 +1053,8 @@ import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer' import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useRouteQuery } from '@/composables/useRouteQuery' import { useRouteQuery } from '@/composables/useRouteQuery'
import { useBatchSelection } from '@/composables/useBatchSelection'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { import {
getPoolOverview, getPoolOverview,
@@ -1021,18 +1074,18 @@ import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import type { import type {
PoolOverviewItem, PoolOverviewItem,
PoolKeyDetail, PoolKeyDetail,
PoolKeySelectionRequest,
PoolKeysPageResponse, PoolKeysPageResponse,
PoolPresetMeta, PoolPresetMeta,
} from '@/api/endpoints/pool' } from '@/api/endpoints/pool'
import type { import type {
ClaudeCodeAdvancedConfig, ClaudeCodeAdvancedConfig,
EndpointAPIKey, EndpointAPIKey,
ProviderEndpoint,
PoolAdvancedConfig, PoolAdvancedConfig,
ProviderWithEndpointsSummary, ProviderWithEndpointsSummary,
} from '@/api/endpoints/types/provider' } from '@/api/endpoints/types/provider'
import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types' import type { QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types'
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints' import { getProvider, updateProvider } from '@/api/endpoints'
import { useProxyNodesStore } from '@/stores/proxy-nodes' import { useProxyNodesStore } from '@/stores/proxy-nodes'
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue' import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue' import PoolAdvancedDialog from '@/features/pool/components/PoolAdvancedDialog.vue'
@@ -1046,7 +1099,6 @@ import KeyAllowedModelsEditDialog from '@/features/providers/components/KeyAllow
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue' import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue' import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue' import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
import ProviderFormDialog from '@/features/providers/components/ProviderFormDialog.vue' import ProviderFormDialog from '@/features/providers/components/ProviderFormDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue' import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { import {
@@ -1065,6 +1117,7 @@ import {
type PoolManagementViewState, type PoolManagementViewState,
writePoolManagementViewState, writePoolManagementViewState,
} from '@/features/pool/utils/poolManagementState' } from '@/features/pool/utils/poolManagementState'
import type { PoolBatchActionValue } from '@/features/pool/utils/poolBatchActions'
import { import {
buildPoolStatsDisplay, buildPoolStatsDisplay,
type PoolCodexCycleStatsGroup, type PoolCodexCycleStatsGroup,
@@ -1098,9 +1151,14 @@ import {
getQuotaDisplayText, getQuotaDisplayText,
} from '@/utils/providerKeyQuota' } from '@/utils/providerKeyQuota'
const ProviderDetailDrawer = defineAsyncComponent(
() => import('@/features/providers/components/ProviderDetailDrawer.vue'),
)
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']> type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
const { success, error: showError, warning: showWarning } = useToast() const { success, error: showError, warning: showWarning } = useToast()
const { legacyT } = useI18n()
const { confirm } = useConfirm() const { confirm } = useConfirm()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer() const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
@@ -1129,6 +1187,7 @@ let selectProviderRequestId = 0
let providerDataRequestId = 0 let providerDataRequestId = 0
let keysRequestId = 0 let keysRequestId = 0
let keysSearchDebounceTimer: number | null = null let keysSearchDebounceTimer: number | null = null
const keysSearchPending = ref(false)
let demandMetricsPollingTimer: number | null = null let demandMetricsPollingTimer: number | null = null
let demandMetricsRequestId = 0 let demandMetricsRequestId = 0
let suppressFiltersWatch = false let suppressFiltersWatch = false
@@ -1242,12 +1301,11 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
selectedProviderId.value = null selectedProviderId.value = null
selectedProviderData.value = null selectedProviderData.value = null
keysLoadedOnce.value = false keysLoadedOnce.value = false
endpointEditDialogOpen.value = false resetPoolKeySelection(true)
providerEndpointsForEdit.value = [] providerDrawerOpen.value = false
showAccountBatchDialog.value = false showAccountBatchDialog.value = false
keyBatchEditDialogOpen.value = false keyBatchEditDialogOpen.value = false
keyBatchEditKeyIds.value = [] keyBatchEditKeyIds.value = []
closeProviderProxyPopovers()
resetKeyPage() resetKeyPage()
} }
} catch (err) { } catch (err) {
@@ -1603,8 +1661,8 @@ async function selectProvider(
hasHydratedInitialProviderSelection = true hasHydratedInitialProviderSelection = true
selectedProviderId.value = id selectedProviderId.value = id
selectedProviderData.value = null selectedProviderData.value = null
endpointEditDialogOpen.value = false resetPoolKeySelection(true)
providerEndpointsForEdit.value = [] providerDrawerOpen.value = false
editingKeyDetail.value = null editingKeyDetail.value = null
showAccountBatchDialog.value = false showAccountBatchDialog.value = false
keyBatchEditDialogOpen.value = false keyBatchEditDialogOpen.value = false
@@ -1612,7 +1670,6 @@ async function selectProvider(
keyPermissionsDialogOpen.value = false keyPermissionsDialogOpen.value = false
keyFormDialogOpen.value = false keyFormDialogOpen.value = false
oauthKeyEditDialogOpen.value = false oauthKeyEditDialogOpen.value = false
closeProviderProxyPopovers()
proxyDesktopPopoverOpenKeyId.value = null proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null proxyMobilePopoverOpenKeyId.value = null
scoreDesktopPopoverOpenKeyId.value = null scoreDesktopPopoverOpenKeyId.value = null
@@ -1632,6 +1689,7 @@ async function selectProvider(
clearTimeout(keysSearchDebounceTimer) clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null keysSearchDebounceTimer = null
} }
keysSearchPending.value = false
keysLoadedOnce.value = false keysLoadedOnce.value = false
resetKeyPage(currentPage.value, pageSize.value) resetKeyPage(currentPage.value, pageSize.value)
const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 }) const keysTask = loadKeys({ cacheTtlMs: options.cacheTtlMs ?? 0 })
@@ -1664,10 +1722,67 @@ function createEmptyKeyPage(page = 1, pageSizeValue = 50): PoolKeysPageResponse
const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage()) const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage())
const keysLoading = ref(false) const keysLoading = ref(false)
const poolKeySelectionBusy = computed(() => keysLoading.value || keysSearchPending.value)
const keysLoadedOnce = ref(false) const keysLoadedOnce = ref(false)
const poolKeyPageItems = computed(() => keyPage.value.keys)
const poolKeyFilteredTotal = computed(() => keyPage.value.total)
const {
selectedIds: selectedPoolKeyIds,
selectedIdSet: selectedPoolKeyIdSet,
selectedCount: selectedKeyCount,
selectAllFiltered: selectAllFilteredPoolKeys,
isAllFilteredSelected: isAllFilteredPoolKeysSelected,
isCurrentPageFullySelected: isCurrentPoolKeyPageFullySelected,
rememberItems: rememberPoolKeys,
knownItemsById: knownPoolKeysById,
resetSelection: resetPoolKeySelection,
toggleOne: togglePoolKeySelection,
toggleSelectFiltered: toggleSelectFilteredPoolKeys,
toggleSelectCurrentPage: toggleCurrentPoolKeyPage,
} = useBatchSelection<PoolKeyDetail>({
pageItems: poolKeyPageItems,
filteredTotal: poolKeyFilteredTotal,
getItemId: key => key.key_id,
})
const selectedKeyCountLabel = computed(() => legacyT(`已选 ${selectedKeyCount.value} 个`))
const selectedPoolKeys = computed(() => selectedPoolKeyIds.value
.map(keyId => knownPoolKeysById.value[keyId])
.filter((key): key is PoolKeyDetail => Boolean(key)))
const selectedOnCurrentPoolKeyPageCount = computed(() => poolKeyPageItems.value
.filter(key => selectedPoolKeyIdSet.value.has(key.key_id)).length)
const isCurrentPoolKeyPagePartiallySelected = computed(() => (
selectedOnCurrentPoolKeyPageCount.value > 0
&& !isCurrentPoolKeyPageFullySelected.value
))
watch(poolKeyPageItems, (keys) => rememberPoolKeys(keys), { immediate: true })
function isPoolKeySelected(keyId: string): boolean {
return selectAllFilteredPoolKeys.value || selectedPoolKeyIdSet.value.has(keyId)
}
function toggleAllFilteredPoolKeys(): void {
if (poolKeySelectionBusy.value || keyPage.value.total === 0) return
toggleSelectFilteredPoolKeys(!isAllFilteredPoolKeysSelected.value)
}
function getPoolKeyRowClass(keyId: string): string {
return [
keyUiStateMap.value[keyId]?.rowClass || '',
isPoolKeySelected(keyId) ? 'bg-primary/5' : '',
].filter(Boolean).join(' ')
}
const refreshingCurrentPageQuota = ref(false) const refreshingCurrentPageQuota = ref(false)
const searchQuery = ref(restoredViewState.search) const searchQuery = ref(restoredViewState.search)
const statusFilter = ref(restoredViewState.status) const statusFilter = ref(restoredViewState.status)
const poolKeySelectionFilters = computed<PoolKeySelectionRequest>(() => {
const search = searchQuery.value.trim()
return {
...(search ? { search } : {}),
status: statusFilter.value,
}
})
const currentPage = ref(restoredViewState.page) const currentPage = ref(restoredViewState.page)
const pageSize = ref(restoredViewState.pageSize) const pageSize = ref(restoredViewState.pageSize)
const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy) const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy)
@@ -1696,6 +1811,12 @@ const editingKeyDetail = ref<PoolKeyDetail | null>(null)
function clearPoolKeyFilters() { function clearPoolKeyFilters() {
if (!hasPoolKeyFilters.value) return if (!hasPoolKeyFilters.value) return
resetPoolKeySelection(true)
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysSearchPending.value = false
suppressFiltersWatch = true suppressFiltersWatch = true
searchQuery.value = '' searchQuery.value = ''
statusFilter.value = 'all' statusFilter.value = 'all'
@@ -2140,12 +2261,22 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
} }
} }
watch([currentPage, pageSize], () => { watch(currentPage, () => {
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
})
watch(pageSize, () => {
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS }) void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}) })
watch(statusFilter, () => { watch(statusFilter, () => {
if (suppressFiltersWatch) return if (suppressFiltersWatch) return
resetPoolKeySelection(true)
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
keysSearchPending.value = false
currentPage.value = 1 currentPage.value = 1
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS }) void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}) })
@@ -2160,12 +2291,15 @@ watch([sortBy, sortOrder], () => {
watch(searchQuery, () => { watch(searchQuery, () => {
if (suppressFiltersWatch) return if (suppressFiltersWatch) return
resetPoolKeySelection(true)
currentPage.value = 1 currentPage.value = 1
if (keysSearchDebounceTimer !== null) { if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer) clearTimeout(keysSearchDebounceTimer)
} }
keysSearchPending.value = true
keysSearchDebounceTimer = window.setTimeout(() => { keysSearchDebounceTimer = window.setTimeout(() => {
keysSearchDebounceTimer = null keysSearchDebounceTimer = null
keysSearchPending.value = false
void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS }) void loadKeys({ cacheTtlMs: POOL_KEYS_CACHE_TTL_MS })
}, 300) }, 300)
}) })
@@ -2329,6 +2463,7 @@ function closeKeyBatchEditDialog(): void {
} }
async function handleKeyBatchEditSaved(): Promise<void> { async function handleKeyBatchEditSaved(): Promise<void> {
resetPoolKeySelection(true)
await Promise.all([loadKeys(), loadOverview()]) await Promise.all([loadKeys(), loadOverview()])
} }
@@ -2437,6 +2572,7 @@ async function handleDeleteKey(key: PoolKeyDetail) {
try { try {
await deleteEndpointKey(key.key_id) await deleteEndpointKey(key.key_id)
success('账号已删除') success('账号已删除')
togglePoolKeySelection(key.key_id, false)
// 乐观更新:直接从本地列表移除,避免等待网络重载 // 乐观更新:直接从本地列表移除,避免等待网络重载
keyPage.value.keys = keyPage.value.keys.filter(k => k.key_id !== key.key_id) keyPage.value.keys = keyPage.value.keys.filter(k => k.key_id !== key.key_id)
keyPage.value.total = Math.max(0, keyPage.value.total - 1) keyPage.value.total = Math.max(0, keyPage.value.total - 1)
@@ -2607,25 +2743,48 @@ async function toggleKeyActive(key: PoolKeyDetail) {
const showImportDialog = ref(false) const showImportDialog = ref(false)
const showSchedulingDialog = ref(false) const showSchedulingDialog = ref(false)
const showAdvancedDialog = ref(false) const showAdvancedDialog = ref(false)
const providerDrawerOpen = ref(false)
const providerEditDialogOpen = ref(false) const providerEditDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null) const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const endpointEditDialogOpen = ref(false)
const providerEndpointsForEdit = ref<ProviderEndpoint[]>([])
const showAccountBatchDialog = ref(false) const showAccountBatchDialog = ref(false)
const providerProxyMobilePopoverOpen = ref(false) const pendingAccountBatchAction = ref<PoolBatchActionValue | null>(null)
const providerProxyDesktopPopoverOpen = ref(false)
const savingProviderProxy = ref(false)
const togglingProviderStatus = ref(false) const togglingProviderStatus = ref(false)
let endpointEditRequestId = 0
function openSchedulingDialog() { function openSchedulingDialog() {
showSchedulingDialog.value = true showSchedulingDialog.value = true
} }
async function openProviderEditDialog(): Promise<void> { function openAccountBatchDialog(action: PoolBatchActionValue = 'refresh_quota'): void {
if (!selectedProviderId.value || selectedKeyCount.value === 0) return
pendingAccountBatchAction.value = action
showAccountBatchDialog.value = true
}
watch(showAccountBatchDialog, (open) => {
if (!open) pendingAccountBatchAction.value = null
})
function openProviderDrawer(): void {
if (!selectedProviderId.value) return
providerDrawerOpen.value = true
}
async function handleProviderDrawerRefresh(): Promise<void> {
const providerId = selectedProviderId.value const providerId = selectedProviderId.value
if (!providerId) return if (!providerId) return
await Promise.all([
loadKeys(),
loadOverview({ silent: true }),
loadProviderData(providerId),
])
resetPoolKeySelection(true)
}
async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary): Promise<void> {
const providerId = provider?.id || selectedProviderId.value
if (!providerId) return
try { try {
const latest = await getProvider(providerId) const latest = await getProvider(providerId)
if (selectedProviderId.value !== providerId) return if (selectedProviderId.value !== providerId) return
@@ -2633,11 +2792,12 @@ async function openProviderEditDialog(): Promise<void> {
providerToEdit.value = latest providerToEdit.value = latest
} catch (err) { } catch (err) {
if (selectedProviderId.value !== providerId) return if (selectedProviderId.value !== providerId) return
if (!selectedProviderData.value) { const fallbackProvider = provider ?? selectedProviderData.value
if (!fallbackProvider) {
showError(parseApiError(err, '刷新提供商状态失败')) showError(parseApiError(err, '刷新提供商状态失败'))
return return
} }
providerToEdit.value = selectedProviderData.value providerToEdit.value = fallbackProvider
} }
providerEditDialogOpen.value = true providerEditDialogOpen.value = true
@@ -2652,134 +2812,10 @@ async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSum
await loadOverview() await loadOverview()
} }
async function openEndpointEditDialog(): Promise<void> { async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> {
const providerId = selectedProviderId.value
if (!providerId) return
const requestId = ++endpointEditRequestId
try {
const [provider, endpoints] = await Promise.all([
getProvider(providerId),
getProviderEndpoints(providerId),
])
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
selectedProviderData.value = provider
providerEndpointsForEdit.value = endpoints
endpointEditDialogOpen.value = true
} catch (err) {
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
showError(parseApiError(err, '加载端点失败'))
}
}
async function handleEndpointEditSaved(): Promise<void> {
const providerId = selectedProviderId.value
if (!providerId) return
const requestId = ++endpointEditRequestId
try {
const [provider, endpoints] = await Promise.all([
getProvider(providerId),
getProviderEndpoints(providerId),
])
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
selectedProviderData.value = provider
providerEndpointsForEdit.value = endpoints
await Promise.all([loadOverview(), loadKeys()])
} catch (err) {
if (requestId !== endpointEditRequestId || selectedProviderId.value !== providerId) return
showError(parseApiError(err, '刷新端点失败'))
}
}
function getProviderProxyNodeName(): string | null {
const nodeId = selectedProviderData.value?.proxy?.node_id
if (!nodeId) return null
const node = proxyNodesStore.nodes.find(n => n.id === nodeId)
return node ? node.name : `${nodeId.slice(0, 8)}...`
}
function getProviderProxyButtonTitle(): string {
const nodeName = getProviderProxyNodeName()
if (nodeName) return `提供商代理(当前: ${nodeName})`
return '提供商代理(未设置)'
}
function closeProviderProxyPopovers(): void {
providerProxyMobilePopoverOpen.value = false
providerProxyDesktopPopoverOpen.value = false
}
function handleProviderProxyPopoverToggle(scope: 'mobile' | 'desktop', open: boolean): void {
if (scope === 'mobile') {
providerProxyMobilePopoverOpen.value = open
if (open) {
providerProxyDesktopPopoverOpen.value = false
}
} else {
providerProxyDesktopPopoverOpen.value = open
if (open) {
providerProxyMobilePopoverOpen.value = false
}
}
if (open) {
proxyNodesStore.ensureLoaded()
proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null
}
}
async function setProviderProxy(nodeId: string): Promise<void> {
const providerId = selectedProviderId.value
if (!providerId) return
savingProviderProxy.value = true
try {
const updated = await updateProvider(providerId, {
proxy: { node_id: nodeId, enabled: true },
})
if (selectedProviderId.value === providerId) {
selectedProviderData.value = updated
}
closeProviderProxyPopovers()
success('提供商代理已设置')
} catch (err) {
showError(parseApiError(err, '设置提供商代理失败'))
} finally {
savingProviderProxy.value = false
}
}
async function clearProviderProxy(): Promise<void> {
const providerId = selectedProviderId.value
if (!providerId) return
savingProviderProxy.value = true
try {
const updated = await updateProvider(providerId, { proxy: null })
if (selectedProviderId.value === providerId) {
selectedProviderData.value = updated
}
closeProviderProxyPopovers()
success('提供商代理已清除')
} catch (err) {
showError(parseApiError(err, '清除提供商代理失败'))
} finally {
savingProviderProxy.value = false
}
}
function getProviderToggleButtonTitle(): string {
const active = selectedProviderData.value?.is_active !== false
return active ? '当前状态:已启用,点击禁用提供商' : '当前状态:已禁用,点击启用提供商'
}
function getProviderToggleButtonClass(): string {
return ''
}
async function toggleSelectedProviderStatus(): Promise<void> {
if (togglingProviderStatus.value) return if (togglingProviderStatus.value) return
const providerId = selectedProviderId.value const providerId = selectedProviderId.value
const current = selectedProviderData.value const current = provider?.id === providerId ? provider : selectedProviderData.value
if (!providerId || !current) return if (!providerId || !current) return
const nextStatus = !current.is_active const nextStatus = !current.is_active
@@ -2796,6 +2832,7 @@ async function toggleSelectedProviderStatus(): Promise<void> {
togglingProviderStatus.value = true togglingProviderStatus.value = true
try { try {
const updated = await updateProvider(providerId, { is_active: nextStatus }) const updated = await updateProvider(providerId, { is_active: nextStatus })
Object.assign(current, updated)
if (selectedProviderId.value === providerId) { if (selectedProviderId.value === providerId) {
selectedProviderData.value = updated selectedProviderData.value = updated
} }
@@ -2809,6 +2846,7 @@ async function toggleSelectedProviderStatus(): Promise<void> {
} }
async function handleAccountBatchChanged(): Promise<void> { async function handleAccountBatchChanged(): Promise<void> {
resetPoolKeySelection(true)
await Promise.all([loadKeys(), loadOverview()]) await Promise.all([loadKeys(), loadOverview()])
} }
@@ -3787,6 +3825,7 @@ onBeforeUnmount(() => {
clearTimeout(keysSearchDebounceTimer) clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null keysSearchDebounceTimer = null
} }
keysSearchPending.value = false
overviewRequestId += 1 overviewRequestId += 1
selectProviderRequestId += 1 selectProviderRequestId += 1
providerDataRequestId += 1 providerDataRequestId += 1
@@ -141,7 +141,9 @@ vi.mock('lucide-vue-next', async () => {
SlidersHorizontal: Icon, SlidersHorizontal: Icon,
CircleHelp: Icon, CircleHelp: Icon,
Edit: Icon, Edit: Icon,
Plug: Icon, Eye: Icon,
ListChecks: Icon,
SquareCheckBig: Icon,
} }
}) })
@@ -200,6 +202,32 @@ vi.mock('@/components/ui', async () => {
}, },
}) })
const Checkbox = defineComponent({
name: 'CheckboxStub',
inheritAttrs: false,
props: {
checked: Boolean,
modelValue: Boolean,
indeterminate: Boolean,
disabled: Boolean,
},
emits: ['update:checked', 'update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
type: 'checkbox',
checked: props.checked || props.modelValue,
disabled: props.disabled,
'data-indeterminate': props.indeterminate ? 'true' : undefined,
onChange: (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
emit('update:checked', checked)
emit('update:modelValue', checked)
},
})
},
})
const Pagination = defineComponent({ const Pagination = defineComponent({
name: 'PaginationStub', name: 'PaginationStub',
setup() { setup() {
@@ -207,6 +235,19 @@ vi.mock('@/components/ui', async () => {
}, },
}) })
const DropdownMenuItem = defineComponent({
name: 'DropdownMenuItemStub',
inheritAttrs: false,
emits: ['select'],
setup(_, { attrs, emit, slots }) {
return () => h('button', {
...attrs,
type: 'button',
onClick: (event: Event) => emit('select', event),
}, slots.default?.())
},
})
const popoverContextKey = Symbol('PopoverStubContext') const popoverContextKey = Symbol('PopoverStubContext')
const Popover = defineComponent({ const Popover = defineComponent({
@@ -258,6 +299,7 @@ vi.mock('@/components/ui', async () => {
Card: passthrough('CardStub'), Card: passthrough('CardStub'),
Badge: passthrough('BadgeStub', 'span'), Badge: passthrough('BadgeStub', 'span'),
Button, Button,
Checkbox,
Input, Input,
Select: passthrough('SelectStub'), Select: passthrough('SelectStub'),
SelectTrigger: passthrough('SelectTriggerStub', 'button'), SelectTrigger: passthrough('SelectTriggerStub', 'button'),
@@ -272,6 +314,10 @@ vi.mock('@/components/ui', async () => {
SortableTableHead: passthrough('SortableTableHeadStub', 'th'), SortableTableHead: passthrough('SortableTableHeadStub', 'th'),
TableFilterMenu: passthrough('TableFilterMenuStub'), TableFilterMenu: passthrough('TableFilterMenuStub'),
TableCell: passthrough('TableCellStub', 'td'), TableCell: passthrough('TableCellStub', 'td'),
DropdownMenu: passthrough('DropdownMenuStub'),
DropdownMenuTrigger: passthrough('DropdownMenuTriggerStub'),
DropdownMenuContent: passthrough('DropdownMenuContentStub'),
DropdownMenuItem,
Switch, Switch,
Pagination, Pagination,
Popover, Popover,
@@ -326,32 +372,40 @@ vi.mock('@/features/pool/components/PoolDemandMetricsDialog.vue', async () => {
} }
}) })
vi.mock('@/features/pool/components/PoolAccountBatchDialog.vue', async () => { vi.mock('@/features/pool/components/PoolAccountBatchDialog.vue', async () => {
const { defineComponent } = await import('vue') const { defineComponent, h } = await import('vue')
return { return {
default: defineComponent({ default: defineComponent({
name: 'PoolAccountBatchDialogStub', name: 'PoolAccountBatchDialogStub',
setup() { props: {
return () => null modelValue: Boolean,
selectedKeys: { type: Array, default: () => [] },
selectAllFiltered: Boolean,
selectedCount: { type: Number, default: 0 },
selectionFilters: { type: Object, default: () => ({}) },
initialAction: { type: String, default: null },
},
emits: ['update:modelValue', 'changed', 'editConfig'],
setup(props) {
return () => h('div', {
'data-testid': 'pool-account-batch-dialog',
'data-open': props.modelValue ? 'true' : 'false',
'data-selected-ids': (props.selectedKeys as PoolKeyDetail[])
.map(key => key.key_id)
.join(','),
'data-select-all-filtered': props.selectAllFiltered ? 'true' : 'false',
'data-selected-count': String(props.selectedCount),
'data-selection-filters': JSON.stringify(props.selectionFilters),
'data-initial-action': props.initialAction || '',
})
}, },
}), }),
} }
}) })
vi.mock('@/features/pool/components/ProviderProxyPopover.vue', async () => { vi.mock('@/features/pool/components/PoolKeyBatchEditDialog.vue', async () => {
const { defineComponent } = await import('vue') const { defineComponent } = await import('vue')
return { return {
default: defineComponent({ default: defineComponent({
name: 'ProviderProxyPopoverStub', name: 'PoolKeyBatchEditDialogStub',
setup() {
return () => null
},
}),
}
})
vi.mock('@/features/providers/components/EndpointFormDialog.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'EndpointFormDialogStub',
setup() { setup() {
return () => null return () => null
}, },
@@ -858,6 +912,150 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.textContent).toContain('$1.25') expect(root.textContent).toContain('$1.25')
}) })
it('supports page selection across desktop/mobile rows and seeds batch actions', async () => {
const firstKey = createPoolKey('codex', { key_id: 'codex-selection-1', key_name: 'First key' })
const secondKey = createPoolKey('codex', { key_id: 'codex-selection-2', key_name: 'Second key' })
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue({
total: 2,
page: 1,
page_size: 50,
keys: [firstKey, secondKey],
})
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
let pageCheckbox = root.querySelector<HTMLInputElement>('[data-testid="pool-select-page-desktop"]')
const firstDesktopCheckbox = root.querySelector<HTMLInputElement>('[data-testid="pool-select-desktop-codex-selection-1"]')
const firstMobileCheckbox = root.querySelector<HTMLInputElement>('[data-testid="pool-select-mobile-codex-selection-1"]')
expect(pageCheckbox).not.toBeNull()
expect(firstDesktopCheckbox).not.toBeNull()
expect(firstMobileCheckbox).not.toBeNull()
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-selected-count-mobile"]')).toBeNull()
expect(root.querySelector<HTMLElement>('colgroup col')?.style.width).toBe('19%')
expect(root.querySelectorAll('colgroup col')).toHaveLength(8)
expect(pageCheckbox?.closest('th')?.textContent).toContain('名称')
expect(firstDesktopCheckbox?.closest('td')?.textContent).toContain('First key')
firstDesktopCheckbox?.click()
await settle()
expect(firstDesktopCheckbox?.checked).toBe(true)
expect(firstMobileCheckbox?.checked).toBe(true)
expect(pageCheckbox?.dataset.indeterminate).toBe('true')
const desktopSelectedCount = root.querySelector('[data-testid="pool-selected-count-desktop"]')
expect(desktopSelectedCount?.textContent).toContain('已选 1 个')
expect(desktopSelectedCount?.closest('th')).toBe(pageCheckbox?.closest('th'))
expect(root.querySelector('[data-testid="pool-selected-count-mobile"]')?.textContent).toContain('已选 1 个')
expect(root.querySelector('[data-testid="pool-selection-toolbar"]')).toBeNull()
expect(root.querySelector<HTMLButtonElement>('[data-testid="pool-batch-actions-desktop"]')?.disabled).toBe(false)
pageCheckbox?.click()
await settle()
expect(root.querySelector<HTMLInputElement>('[data-testid="pool-select-desktop-codex-selection-2"]')?.checked).toBe(true)
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')?.textContent).toContain('已选 2 个')
pageCheckbox?.click()
await settle()
expect(root.querySelector<HTMLButtonElement>('[data-testid="pool-batch-actions-desktop"]')?.disabled).toBe(true)
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-selected-count-mobile"]')).toBeNull()
pageCheckbox = root.querySelector<HTMLInputElement>('[data-testid="pool-select-page-desktop"]')
pageCheckbox?.click()
await settle()
root.querySelector<HTMLButtonElement>('[data-testid="pool-batch-action-refresh_quota-desktop"]')?.click()
await settle()
const batchDialog = root.querySelector('[data-testid="pool-account-batch-dialog"]')
expect(batchDialog?.getAttribute('data-open')).toBe('true')
expect(batchDialog?.getAttribute('data-selected-ids')).toBe('codex-selection-1,codex-selection-2')
expect(batchDialog?.getAttribute('data-select-all-filtered')).toBe('false')
expect(batchDialog?.getAttribute('data-initial-action')).toBe('refresh_quota')
})
it('passes the current table search and status to filtered selection actions', async () => {
routeMocks.query.providerId = 'codex-provider'
routeMocks.query.search = 'inactive-account'
routeMocks.query.status = 'inactive'
const firstKey = createPoolKey('codex', { key_id: 'codex-filtered-1', key_name: 'inactive-account one' })
const secondKey = createPoolKey('codex', { key_id: 'codex-filtered-2', key_name: 'inactive-account two' })
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue({
total: 37,
page: 1,
page_size: 50,
keys: [firstKey, secondKey],
})
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
const selectAllButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-select-all-desktop"]')
expect(selectAllButton).not.toBeNull()
expect(selectAllButton?.title).toBe('全选')
selectAllButton?.click()
await settle()
expect(selectAllButton?.title).toBe('取消全选')
expect(selectAllButton?.getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector<HTMLInputElement>('[data-testid="pool-select-desktop-codex-filtered-1"]')?.checked).toBe(true)
expect(root.querySelector<HTMLInputElement>('[data-testid="pool-select-desktop-codex-filtered-1"]')?.disabled).toBe(true)
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')?.textContent).toContain('已选 37 个')
expect(root.querySelector('[data-testid="pool-selected-count-mobile"]')?.textContent).toContain('已选 37 个')
const batchButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-batch-actions-desktop"]')
expect(batchButton?.disabled).toBe(false)
selectAllButton?.click()
await settle()
expect(selectAllButton?.title).toBe('全选')
expect(selectAllButton?.getAttribute('aria-pressed')).toBe('false')
expect(root.querySelector<HTMLInputElement>('[data-testid="pool-select-desktop-codex-filtered-1"]')?.checked).toBe(false)
expect(root.querySelector('[data-testid="pool-selected-count-desktop"]')).toBeNull()
expect(batchButton?.disabled).toBe(true)
selectAllButton?.click()
await settle()
root.querySelector<HTMLButtonElement>('[data-testid="pool-batch-action-refresh_quota-desktop"]')?.click()
await settle()
const batchDialog = root.querySelector('[data-testid="pool-account-batch-dialog"]')
expect(batchDialog?.getAttribute('data-select-all-filtered')).toBe('true')
expect(batchDialog?.getAttribute('data-selected-count')).toBe('37')
expect(batchDialog?.getAttribute('data-initial-action')).toBe('refresh_quota')
expect(JSON.parse(batchDialog?.getAttribute('data-selection-filters') || '{}')).toEqual({
search: 'inactive-account',
status: 'inactive',
})
})
it('disables selection while a debounced search is waiting for fresh rows', async () => {
const key = createPoolKey('codex', { key_id: 'codex-stale-search-row' })
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(key))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
const rowCheckbox = root.querySelector<HTMLInputElement>('[data-testid="pool-select-desktop-codex-stale-search-row"]')
expect(rowCheckbox?.disabled).toBe(false)
const searchInput = root.querySelector<HTMLInputElement>('input[placeholder="搜索账号..."]')
expect(searchInput).not.toBeNull()
if (searchInput) {
searchInput.value = 'fresh-filter'
searchInput.dispatchEvent(new Event('input', { bubbles: true }))
}
await nextTick()
expect(rowCheckbox?.disabled).toBe(true)
expect(root.querySelector<HTMLButtonElement>('[data-testid="pool-select-all-desktop"]')?.disabled).toBe(true)
})
it('shows adaptive hot pool metrics entry only when probing is enabled', async () => { it('shows adaptive hot pool metrics entry only when probing is enabled', async () => {
endpointMocks.getPoolOverview.mockResolvedValue({ endpointMocks.getPoolOverview.mockResolvedValue({
items: [{ ...createOverview('codex'), provider_desired_hot: 4, provider_in_flight: 2, provider_ema_in_flight: 1.8 }], items: [{ ...createOverview('codex'), provider_desired_hot: 4, provider_in_flight: 2, provider_ema_in_flight: 1.8 }],
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
resolve(process.cwd(), 'src/views/admin/PoolManagement.vue'),
'utf8',
)
describe('PoolManagement provider detail drawer', () => {
it('keeps the heavy drawer out of the initial route chunk', () => {
expect(source).not.toContain(
"import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'",
)
expect(source).toContain(
"() => import('@/features/providers/components/ProviderDetailDrawer.vue')",
)
})
it('only mounts the drawer after the provider detail action is opened', () => {
const drawerTemplate = source
.split('<ProviderDetailDrawer')[1]
?.split('/>')[0]
expect(drawerTemplate).toBeTruthy()
expect(drawerTemplate).toContain('v-if="providerDrawerOpen && selectedProviderId"')
expect(source).toContain('@view-provider="openProviderDrawer"')
})
})