mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(pool): add table-driven account batch actions
This commit is contained in:
@@ -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| {
|
||||||
|
|||||||
+49
-3
@@ -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);
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
+224
@@ -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 },
|
||||||
|
]
|
||||||
+3
-2
@@ -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"')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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`],
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user