mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
feat: improve routing policy diagnostics
This commit is contained in:
@@ -688,8 +688,18 @@ impl<'a> PoolKeyCursor<'a> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.scanned_keys = self.scanned_keys.saturating_add(scores.len() as u32);
|
||||
self.budget_scanned_keys = self.budget_scanned_keys.saturating_add(scores.len() as u32);
|
||||
let materialized_row_count = rows.len() as u32;
|
||||
let missing_score_count = scores.len().saturating_sub(rows.len());
|
||||
if missing_score_count > 0 {
|
||||
*self
|
||||
.skip_reason_counts
|
||||
.entry("pool_score_member_missing")
|
||||
.or_insert(0) += u32::try_from(missing_score_count).unwrap_or(u32::MAX);
|
||||
}
|
||||
self.scanned_keys = self.scanned_keys.saturating_add(materialized_row_count);
|
||||
self.budget_scanned_keys = self
|
||||
.budget_scanned_keys
|
||||
.saturating_add(materialized_row_count);
|
||||
Some(self.build_page_eligible_candidates(rows).await)
|
||||
}
|
||||
|
||||
@@ -1044,7 +1054,10 @@ impl<'a> PoolKeyCursor<'a> {
|
||||
}
|
||||
|
||||
fn pool_skip_reason_releases_scan_budget(skip_reason: &str) -> bool {
|
||||
skip_reason == POOL_ACCOUNT_EXHAUSTED_SKIP_REASON
|
||||
matches!(
|
||||
skip_reason,
|
||||
POOL_ACCOUNT_EXHAUSTED_SKIP_REASON | POOL_ACCOUNT_BLOCKED_SKIP_REASON
|
||||
)
|
||||
}
|
||||
|
||||
fn pool_candidate_transport_policy_facts(
|
||||
@@ -1587,7 +1600,8 @@ mod tests {
|
||||
ROUTING_PROFILE_DISALLOWED_KEY_SKIP_REASON,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
apply_local_runtime_candidate_terminal_reason, EligibleLocalExecutionCandidate,
|
||||
apply_local_runtime_candidate_terminal_reason, provider_key_pool_score_id,
|
||||
provider_key_pool_score_scope, EligibleLocalExecutionCandidate,
|
||||
LocalExecutionCandidateKind, PlannerAppState,
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
@@ -1597,10 +1611,14 @@ mod tests {
|
||||
use crate::orchestration::LocalExecutionCandidateMetadata;
|
||||
use crate::{AppState, LocalExecutionRuntimeMissDiagnostic};
|
||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||
use aether_data::repository::pool_scores::InMemoryPoolMemberScoreRepository;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder,
|
||||
};
|
||||
use aether_data_contracts::repository::pool_scores::{
|
||||
PoolMemberHardState, PoolMemberIdentity, PoolMemberProbeStatus, StoredPoolMemberScore,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
@@ -3082,6 +3100,131 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_key_cursor_does_not_spend_effective_scan_budget_on_blocked_accounts() {
|
||||
const BLOCKED_COUNT: usize = 1_600;
|
||||
let provider_config = Some(json!({ "pool_advanced": {} }));
|
||||
let (provider, endpoint, mut keys, rows) =
|
||||
large_pool_fixture(BLOCKED_COUNT + 100, provider_config.clone());
|
||||
for key in keys.iter_mut().take(BLOCKED_COUNT) {
|
||||
key.oauth_invalid_reason = Some("blocked account".to_string());
|
||||
}
|
||||
|
||||
let data_state =
|
||||
GatewayDataState::with_provider_catalog_and_minimal_candidate_selection_for_tests(
|
||||
Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
keys,
|
||||
)),
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(rows)),
|
||||
)
|
||||
.with_encryption_key_for_tests(aether_crypto::DEVELOPMENT_ENCRYPTION_KEY);
|
||||
let app = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let group = sample_eligible_candidate(
|
||||
"provider-pool",
|
||||
"endpoint-1",
|
||||
"pool-group",
|
||||
10,
|
||||
provider_config,
|
||||
);
|
||||
|
||||
let mut cursor = PoolKeyCursor::new(PlannerAppState::new(&app), group, None, None, None);
|
||||
assert_eq!(
|
||||
cursor.max_scanned_keys,
|
||||
aether_dispatch_core::DEFAULT_POOL_MAX_SCAN
|
||||
);
|
||||
assert!(
|
||||
cursor.absolute_max_scanned_keys >= u32::try_from(BLOCKED_COUNT + 1).unwrap(),
|
||||
"default absolute scan cap should allow scanning past a large blocked prefix"
|
||||
);
|
||||
|
||||
let candidate = cursor
|
||||
.next_key()
|
||||
.await
|
||||
.expect("cursor should scan past blocked accounts within the absolute cap");
|
||||
let key_index = candidate
|
||||
.candidate
|
||||
.key_id
|
||||
.strip_prefix("key-")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.expect("fixture key id should contain a numeric suffix");
|
||||
assert!(
|
||||
key_index >= BLOCKED_COUNT,
|
||||
"cursor should not return one of the blocked leading keys"
|
||||
);
|
||||
assert_eq!(candidate.orchestration.pool_key_index, Some(0));
|
||||
assert!(
|
||||
cursor.budget_scanned_keys <= aether_dispatch_core::DEFAULT_POOL_PAGE_SIZE,
|
||||
"blocked accounts should not consume effective scan budget"
|
||||
);
|
||||
assert_eq!(
|
||||
cursor
|
||||
.skip_reason_counts
|
||||
.get(aether_pool_core::POOL_ACCOUNT_BLOCKED_SKIP_REASON),
|
||||
Some(&(BLOCKED_COUNT as u32))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_key_cursor_does_not_spend_scan_budget_on_missing_score_rows() {
|
||||
let provider_config = Some(json!({
|
||||
"pool_advanced": {
|
||||
"score_top_n": 128
|
||||
}
|
||||
}));
|
||||
let (provider, endpoint, keys, rows) = large_pool_fixture(1, provider_config.clone());
|
||||
let scores = (0..128)
|
||||
.map(|index| {
|
||||
sample_provider_key_pool_score(
|
||||
"provider-pool",
|
||||
&format!("missing-key-{index:03}"),
|
||||
1_000.0 - index as f64,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let data_state =
|
||||
GatewayDataState::with_provider_catalog_and_minimal_candidate_selection_for_tests(
|
||||
Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
keys,
|
||||
)),
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(rows)),
|
||||
)
|
||||
.with_pool_score_repository_for_tests(Arc::new(
|
||||
InMemoryPoolMemberScoreRepository::seed(scores),
|
||||
))
|
||||
.with_encryption_key_for_tests(aether_crypto::DEVELOPMENT_ENCRYPTION_KEY);
|
||||
let app = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let group = sample_eligible_candidate(
|
||||
"provider-pool",
|
||||
"endpoint-1",
|
||||
"pool-group",
|
||||
10,
|
||||
provider_config,
|
||||
);
|
||||
|
||||
let mut cursor = PoolKeyCursor::new(PlannerAppState::new(&app), group, None, None, None);
|
||||
let candidate = cursor
|
||||
.next_key()
|
||||
.await
|
||||
.expect("cursor should fall back to catalog rows after stale scores");
|
||||
|
||||
assert_eq!(candidate.candidate.key_id, "key-00000");
|
||||
assert_eq!(cursor.scanned_keys, 1);
|
||||
assert_eq!(cursor.budget_scanned_keys, 1);
|
||||
assert_eq!(
|
||||
cursor.skip_reason_counts.get("pool_score_member_missing"),
|
||||
Some(&128)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_scheduler_skips_invalid_and_exhausted_high_priority_hot_pool_before_fallback_provider(
|
||||
) {
|
||||
@@ -3766,6 +3909,40 @@ mod tests {
|
||||
(provider, endpoint, keys, rows)
|
||||
}
|
||||
|
||||
fn sample_provider_key_pool_score(
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
score: f64,
|
||||
) -> StoredPoolMemberScore {
|
||||
let identity = PoolMemberIdentity::provider_api_key(provider_id, key_id);
|
||||
let scope = provider_key_pool_score_scope();
|
||||
StoredPoolMemberScore {
|
||||
id: provider_key_pool_score_id(&identity, &scope),
|
||||
pool_kind: identity.pool_kind,
|
||||
pool_id: identity.pool_id,
|
||||
member_kind: identity.member_kind,
|
||||
member_id: identity.member_id,
|
||||
capability: scope.capability,
|
||||
scope_kind: scope.scope_kind,
|
||||
scope_id: scope.scope_id,
|
||||
score,
|
||||
hard_state: PoolMemberHardState::Available,
|
||||
score_version: 1,
|
||||
score_reason: json!({}),
|
||||
last_ranked_at: Some(1_000),
|
||||
last_scheduled_at: None,
|
||||
last_success_at: None,
|
||||
last_failure_at: None,
|
||||
failure_count: 0,
|
||||
last_probe_attempt_at: None,
|
||||
last_probe_success_at: None,
|
||||
last_probe_failure_at: None,
|
||||
probe_failure_count: 0,
|
||||
probe_status: PoolMemberProbeStatus::Ok,
|
||||
updated_at: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_codex_pool_provider(
|
||||
provider_id: &str,
|
||||
provider_priority: i32,
|
||||
|
||||
@@ -208,6 +208,79 @@ async fn admin_monitoring_trace_request_resolves_usage_request_id_to_metadata_tr
|
||||
assert_eq!(payload["candidates"][0]["id"], json!("cand-used"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_monitoring_trace_request_falls_back_to_usage_routing_snapshot() {
|
||||
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
));
|
||||
let mut usage = sample_usage(
|
||||
"request-usage-snapshot",
|
||||
"provider-1",
|
||||
"OpenAI",
|
||||
0,
|
||||
0.0,
|
||||
"failed",
|
||||
Some(503),
|
||||
100,
|
||||
);
|
||||
usage.candidate_id = Some("routing-cand-1".to_string());
|
||||
usage.candidate_index = Some(0);
|
||||
usage.planner_kind = Some("openai_responses_stream".to_string());
|
||||
usage.execution_path = Some("local_execution_runtime_miss".to_string());
|
||||
usage.local_execution_runtime_miss_reason = Some("no_local_stream_plans".to_string());
|
||||
usage.api_format = Some("openai:responses".to_string());
|
||||
usage.endpoint_api_format = Some("openai:responses".to_string());
|
||||
usage.provider_api_key_id = Some("provider-key-1".to_string());
|
||||
usage.error_message = Some("no local stream plans".to_string());
|
||||
usage.response_time_ms = Some(45);
|
||||
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![usage]));
|
||||
let data_state =
|
||||
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||
request_candidates,
|
||||
usage_repository,
|
||||
)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let context = request_context(
|
||||
http::Method::GET,
|
||||
"/api/admin/monitoring/trace/request-usage-snapshot?attempted_only=true",
|
||||
);
|
||||
|
||||
let response = local_monitoring_response(&state, &context)
|
||||
.await
|
||||
.expect("handler should not error")
|
||||
.expect("route should be handled locally");
|
||||
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
||||
assert_eq!(payload["request_id"], json!("request-usage-snapshot"));
|
||||
assert_eq!(payload["total_candidates"], json!(1));
|
||||
assert_eq!(payload["final_status"], json!("failed"));
|
||||
assert_eq!(payload["candidates"][0]["id"], json!("routing-cand-1"));
|
||||
assert_eq!(payload["candidates"][0]["status"], json!("failed"));
|
||||
assert_eq!(
|
||||
payload["candidates"][0]["error_type"],
|
||||
json!("no_local_stream_plans")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["candidates"][0]["extra_data"]["source"],
|
||||
json!("usage_routing_snapshot")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["candidates"][0]["extra_data"]["execution_path"],
|
||||
json!("local_execution_runtime_miss")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_monitoring_trace_request_returns_oauth_account_label_from_auth_config() {
|
||||
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||
|
||||
@@ -10,7 +10,10 @@ use aether_admin::observability::monitoring::{
|
||||
parse_admin_monitoring_attempted_only, AdminMonitoringKeyAccountDisplay,
|
||||
};
|
||||
use aether_data_contracts::repository::{
|
||||
candidates::{DecisionTrace, RequestCandidateStatus},
|
||||
candidates::{
|
||||
DecisionTrace, DecisionTraceCandidate, RequestCandidateFinalStatus, RequestCandidateStatus,
|
||||
StoredRequestCandidate,
|
||||
},
|
||||
provider_catalog::StoredProviderCatalogKey,
|
||||
usage::StoredRequestUsageAudit,
|
||||
};
|
||||
@@ -18,7 +21,7 @@ use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -107,6 +110,7 @@ async fn resolve_admin_monitoring_trace(
|
||||
}
|
||||
}
|
||||
|
||||
let mut usage_snapshot_fallback = None;
|
||||
for usage in usage_candidates {
|
||||
for trace_request_id in admin_monitoring_usage_trace_request_ids(&usage) {
|
||||
if trace_request_id == request_id {
|
||||
@@ -124,9 +128,211 @@ async fn resolve_admin_monitoring_trace(
|
||||
}));
|
||||
}
|
||||
}
|
||||
if usage_snapshot_fallback.is_none() {
|
||||
if let Some(trace) = build_admin_monitoring_usage_routing_snapshot_trace(&usage) {
|
||||
usage_snapshot_fallback = Some(ResolvedAdminMonitoringTrace {
|
||||
trace,
|
||||
usage: Some(usage.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
Ok(usage_snapshot_fallback)
|
||||
}
|
||||
|
||||
fn build_admin_monitoring_usage_routing_snapshot_trace(
|
||||
usage: &StoredRequestUsageAudit,
|
||||
) -> Option<DecisionTrace> {
|
||||
if !admin_monitoring_usage_has_routing_snapshot_trace_data(usage) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = admin_monitoring_usage_candidate_status(usage);
|
||||
let final_status = match status {
|
||||
RequestCandidateStatus::Success => RequestCandidateFinalStatus::Success,
|
||||
RequestCandidateStatus::Cancelled => RequestCandidateFinalStatus::Cancelled,
|
||||
RequestCandidateStatus::Streaming => RequestCandidateFinalStatus::Streaming,
|
||||
RequestCandidateStatus::Pending => RequestCandidateFinalStatus::Pending,
|
||||
RequestCandidateStatus::Available
|
||||
| RequestCandidateStatus::Unused
|
||||
| RequestCandidateStatus::Failed
|
||||
| RequestCandidateStatus::Skipped => RequestCandidateFinalStatus::Failed,
|
||||
};
|
||||
let latency_ms = usage.response_time_ms.unwrap_or_default();
|
||||
let candidate = StoredRequestCandidate {
|
||||
id: usage
|
||||
.routing_candidate_id()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("usage-routing-snapshot:{}", usage.id)),
|
||||
request_id: admin_monitoring_usage_primary_trace_request_id(usage),
|
||||
user_id: usage.user_id.clone(),
|
||||
api_key_id: usage.api_key_id.clone(),
|
||||
username: usage.username.clone(),
|
||||
api_key_name: usage.api_key_name.clone(),
|
||||
candidate_index: usage
|
||||
.routing_candidate_index()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(0),
|
||||
retry_index: 0,
|
||||
provider_id: usage.provider_id.clone(),
|
||||
endpoint_id: usage.provider_endpoint_id.clone(),
|
||||
key_id: usage.provider_api_key_id.clone(),
|
||||
status,
|
||||
skip_reason: None,
|
||||
is_cached: false,
|
||||
status_code: usage.status_code,
|
||||
error_type: usage
|
||||
.routing_local_execution_runtime_miss_reason()
|
||||
.or(usage.error_category.as_deref())
|
||||
.map(ToOwned::to_owned),
|
||||
error_message: usage.error_message.clone(),
|
||||
latency_ms: usage.response_time_ms,
|
||||
concurrent_requests: None,
|
||||
extra_data: build_admin_monitoring_usage_routing_snapshot_extra_data(usage),
|
||||
required_capabilities: None,
|
||||
created_at_unix_ms: usage.created_at_unix_ms,
|
||||
started_at_unix_ms: Some(usage.created_at_unix_ms),
|
||||
finished_at_unix_ms: admin_monitoring_usage_finished_at_unix_ms(usage),
|
||||
};
|
||||
|
||||
Some(DecisionTrace {
|
||||
request_id: candidate.request_id.clone(),
|
||||
total_candidates: 1,
|
||||
final_status,
|
||||
total_latency_ms: latency_ms,
|
||||
candidates: vec![DecisionTraceCandidate {
|
||||
candidate,
|
||||
provider_name: non_empty_string(usage.provider_name.as_str()),
|
||||
provider_website: None,
|
||||
provider_type: None,
|
||||
provider_priority: None,
|
||||
provider_keep_priority_on_conversion: None,
|
||||
provider_enable_format_conversion: None,
|
||||
endpoint_api_format: usage
|
||||
.endpoint_api_format
|
||||
.clone()
|
||||
.or_else(|| usage.api_format.clone()),
|
||||
endpoint_api_family: usage
|
||||
.provider_api_family
|
||||
.clone()
|
||||
.or_else(|| usage.api_family.clone()),
|
||||
endpoint_kind: usage
|
||||
.provider_endpoint_kind
|
||||
.clone()
|
||||
.or_else(|| usage.endpoint_kind.clone()),
|
||||
endpoint_format_acceptance_config: None,
|
||||
provider_key_name: usage.routing_key_name().map(ToOwned::to_owned),
|
||||
provider_key_auth_type: None,
|
||||
provider_key_api_formats: None,
|
||||
provider_key_internal_priority: None,
|
||||
provider_key_global_priority_by_format: None,
|
||||
provider_key_capabilities: None,
|
||||
provider_key_is_active: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_monitoring_usage_has_routing_snapshot_trace_data(usage: &StoredRequestUsageAudit) -> bool {
|
||||
usage.routing_candidate_id().is_some()
|
||||
|| usage.routing_candidate_index().is_some()
|
||||
|| usage.routing_execution_path().is_some()
|
||||
|| usage
|
||||
.routing_local_execution_runtime_miss_reason()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn admin_monitoring_usage_candidate_status(
|
||||
usage: &StoredRequestUsageAudit,
|
||||
) -> RequestCandidateStatus {
|
||||
if usage.status.trim().eq_ignore_ascii_case("cancelled")
|
||||
|| usage.status.trim().eq_ignore_ascii_case("canceled")
|
||||
{
|
||||
return RequestCandidateStatus::Cancelled;
|
||||
}
|
||||
|
||||
match usage.status_code {
|
||||
Some(status_code) if (200..300).contains(&status_code) => RequestCandidateStatus::Success,
|
||||
Some(_) => RequestCandidateStatus::Failed,
|
||||
None if usage.status.trim().eq_ignore_ascii_case("completed")
|
||||
|| usage.status.trim().eq_ignore_ascii_case("success") =>
|
||||
{
|
||||
RequestCandidateStatus::Success
|
||||
}
|
||||
None => RequestCandidateStatus::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_monitoring_usage_primary_trace_request_id(usage: &StoredRequestUsageAudit) -> String {
|
||||
if let Some(trace_id) = usage.trace_id() {
|
||||
return trace_id.to_string();
|
||||
}
|
||||
if let Some(trace_id) = usage_trace_id_from_headers(usage.request_headers.as_ref()) {
|
||||
return trace_id;
|
||||
}
|
||||
if let Some(trace_id) = usage_trace_id_from_headers(usage.provider_request_headers.as_ref()) {
|
||||
return trace_id;
|
||||
}
|
||||
usage.request_id.clone()
|
||||
}
|
||||
|
||||
fn admin_monitoring_usage_finished_at_unix_ms(usage: &StoredRequestUsageAudit) -> Option<u64> {
|
||||
usage
|
||||
.finalized_at_unix_secs
|
||||
.map(|value| value.saturating_mul(1_000))
|
||||
.or_else(|| {
|
||||
usage
|
||||
.response_time_ms
|
||||
.map(|latency_ms| usage.created_at_unix_ms.saturating_add(latency_ms))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_admin_monitoring_usage_routing_snapshot_extra_data(
|
||||
usage: &StoredRequestUsageAudit,
|
||||
) -> Option<Value> {
|
||||
let mut object = Map::new();
|
||||
object.insert("source".to_string(), json!("usage_routing_snapshot"));
|
||||
insert_optional_string(&mut object, "planner_kind", usage.routing_planner_kind());
|
||||
insert_optional_string(&mut object, "route_family", usage.routing_route_family());
|
||||
insert_optional_string(&mut object, "route_kind", usage.routing_route_kind());
|
||||
insert_optional_string(
|
||||
&mut object,
|
||||
"execution_path",
|
||||
usage.routing_execution_path(),
|
||||
);
|
||||
insert_optional_string(
|
||||
&mut object,
|
||||
"local_execution_runtime_miss_reason",
|
||||
usage.routing_local_execution_runtime_miss_reason(),
|
||||
);
|
||||
insert_optional_string(&mut object, "key_name", usage.routing_key_name());
|
||||
insert_optional_string(&mut object, "model", Some(usage.model.as_str()));
|
||||
insert_optional_string(&mut object, "target_model", usage.target_model.as_deref());
|
||||
insert_optional_string(
|
||||
&mut object,
|
||||
"client_api_format",
|
||||
usage.api_format.as_deref(),
|
||||
);
|
||||
insert_optional_string(
|
||||
&mut object,
|
||||
"provider_api_format",
|
||||
usage.endpoint_api_format.as_deref(),
|
||||
);
|
||||
insert_optional_string(
|
||||
&mut object,
|
||||
"provider_api_family",
|
||||
usage.provider_api_family.as_deref(),
|
||||
);
|
||||
insert_optional_string(
|
||||
&mut object,
|
||||
"provider_endpoint_kind",
|
||||
usage.provider_endpoint_kind.as_deref(),
|
||||
);
|
||||
insert_optional_string(&mut object, "candidate_id", usage.routing_candidate_id());
|
||||
if let Some(candidate_index) = usage.routing_candidate_index() {
|
||||
object.insert("candidate_index".to_string(), json!(candidate_index));
|
||||
}
|
||||
Some(Value::Object(object))
|
||||
}
|
||||
|
||||
fn admin_monitoring_usage_trace_request_ids(usage: &StoredRequestUsageAudit) -> Vec<String> {
|
||||
@@ -167,6 +373,18 @@ fn push_non_empty_unique(values: &mut Vec<String>, value: &str) {
|
||||
values.push(value.to_string());
|
||||
}
|
||||
|
||||
fn non_empty_string(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn insert_optional_string(object: &mut Map<String, Value>, key: &str, value: Option<&str>) {
|
||||
let Some(value) = value.and_then(non_empty_string) else {
|
||||
return;
|
||||
};
|
||||
object.insert(key.to_string(), Value::String(value));
|
||||
}
|
||||
|
||||
async fn build_admin_monitoring_key_account_display_map(
|
||||
state: &AdminAppState<'_>,
|
||||
trace: &DecisionTrace,
|
||||
|
||||
@@ -414,7 +414,7 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
|
||||
account_self_check_interval_minutes: 60,
|
||||
account_self_check_concurrency: 4,
|
||||
score_top_n: 128,
|
||||
score_fallback_scan_limit: 1024,
|
||||
score_fallback_scan_limit: 4096,
|
||||
score_rules: PoolMemberScoreRules::default(),
|
||||
stream_timeout_threshold: 3,
|
||||
stream_timeout_window_seconds: 1800,
|
||||
@@ -512,7 +512,7 @@ pub(crate) fn admin_provider_pool_config_from_config_value(
|
||||
.and_then(json_u64)
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| value.min(50_000))
|
||||
.unwrap_or(1024),
|
||||
.unwrap_or(4096),
|
||||
score_rules,
|
||||
stream_timeout_threshold: pool_advanced
|
||||
.get("stream_timeout_threshold")
|
||||
|
||||
@@ -646,7 +646,7 @@ mod tests {
|
||||
account_self_check_interval_minutes: 60,
|
||||
account_self_check_concurrency: 4,
|
||||
score_top_n: 128,
|
||||
score_fallback_scan_limit: 1024,
|
||||
score_fallback_scan_limit: 4096,
|
||||
score_rules: aether_pool_core::PoolMemberScoreRules::default(),
|
||||
stream_timeout_threshold: 3,
|
||||
stream_timeout_window_seconds: 1800,
|
||||
|
||||
@@ -190,16 +190,26 @@ pub(crate) fn snapshot_local_request_candidate_status(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let metadata = parse_request_candidate_report_context(report_context)?;
|
||||
let candidate_index = metadata.candidate_index.unwrap_or(0);
|
||||
let metadata = parse_request_candidate_report_context(report_context);
|
||||
let candidate_index = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.candidate_index)
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(LocalRequestCandidateStatusSnapshot {
|
||||
candidate_id: candidate_id.to_string(),
|
||||
request_id: plan.request_id.clone(),
|
||||
user_id: metadata.user_id,
|
||||
api_key_id: metadata.api_key_id,
|
||||
user_id: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.user_id.clone()),
|
||||
api_key_id: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.api_key_id.clone()),
|
||||
candidate_index,
|
||||
retry_index: metadata.retry_index,
|
||||
retry_index: metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.retry_index)
|
||||
.unwrap_or(0),
|
||||
provider_id: plan.provider_id.clone(),
|
||||
endpoint_id: plan.endpoint_id.clone(),
|
||||
key_id: plan.key_id.clone(),
|
||||
@@ -438,11 +448,16 @@ pub(crate) async fn ensure_execution_request_candidate_slot(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if plan
|
||||
let existing_candidate_id = plan
|
||||
.candidate_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let report_candidate_id = parse_request_candidate_report_context(report_context.as_ref())
|
||||
.and_then(|metadata| metadata.candidate_id);
|
||||
if existing_candidate_id.as_deref().is_some()
|
||||
&& report_candidate_id.as_deref() == existing_candidate_id.as_deref()
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -451,7 +466,7 @@ pub(crate) async fn ensure_execution_request_candidate_slot(
|
||||
plan,
|
||||
report_context.as_ref(),
|
||||
current_unix_ms(),
|
||||
Uuid::new_v4().to_string(),
|
||||
existing_candidate_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
);
|
||||
let generated_candidate_id = seed.upsert_record.id.clone();
|
||||
let request_id = short_request_id(plan.request_id.as_str());
|
||||
@@ -879,13 +894,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_not_reseed_execution_request_candidate_slot_when_plan_already_has_candidate_id() {
|
||||
async fn does_not_reseed_execution_request_candidate_slot_when_report_context_matches_plan_candidate_id(
|
||||
) {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(Arc::clone(&repository));
|
||||
let mut plan = sample_plan();
|
||||
plan.candidate_id = Some("cand-existing-123".to_string());
|
||||
let mut report_context = Some(json!({
|
||||
"request_id": "req-request-candidate-seed-123"
|
||||
"request_id": "req-request-candidate-seed-123",
|
||||
"candidate_id": "cand-existing-123"
|
||||
}));
|
||||
|
||||
ensure_execution_request_candidate_slot(&state, &mut plan, &mut report_context).await;
|
||||
@@ -901,10 +918,37 @@ mod tests {
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("candidate_id"))
|
||||
.and_then(|value| value.as_str()),
|
||||
None
|
||||
Some("cand-existing-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_execution_request_candidate_slot_when_plan_candidate_id_lacks_report_context() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(Arc::clone(&repository));
|
||||
let mut plan = sample_plan();
|
||||
plan.candidate_id = Some("cand-existing-123".to_string());
|
||||
let mut report_context = None;
|
||||
|
||||
ensure_execution_request_candidate_slot(&state, &mut plan, &mut report_context).await;
|
||||
|
||||
assert_eq!(plan.candidate_id.as_deref(), Some("cand-existing-123"));
|
||||
let report_context = report_context.expect("report context should be populated");
|
||||
assert_eq!(
|
||||
report_context
|
||||
.get("candidate_id")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("cand-existing-123")
|
||||
);
|
||||
let stored = repository
|
||||
.list_by_request_id("req-request-candidate-seed-123")
|
||||
.await
|
||||
.expect("request candidates should read");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].id, "cand-existing-123");
|
||||
assert_eq!(stored[0].status, RequestCandidateStatus::Pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_report_request_candidate_status_for_existing_slot() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||
|
||||
@@ -462,44 +462,32 @@ pub fn build_local_request_candidate_status_record(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let metadata = parse_request_candidate_report_context(report_context)?;
|
||||
let candidate_index = metadata.candidate_index.unwrap_or(0);
|
||||
let extra_data = build_report_candidate_extra_data(ReportCandidateExtraDataInput {
|
||||
client_api_format: metadata.client_api_format.clone(),
|
||||
provider_api_format: metadata.provider_api_format.clone(),
|
||||
request_path: metadata.request_path.clone(),
|
||||
request_query_string: metadata.request_query_string.clone(),
|
||||
request_path_and_query: metadata.request_path_and_query.clone(),
|
||||
upstream_url: metadata.upstream_url.clone(),
|
||||
mapped_model: metadata.mapped_model.clone(),
|
||||
key_name: metadata.key_name.clone(),
|
||||
header_rules: metadata.header_rules.clone(),
|
||||
body_rules: metadata.body_rules.clone(),
|
||||
upstream_response: metadata.upstream_response.clone(),
|
||||
proxy: metadata.proxy.clone(),
|
||||
error_flow: metadata.error_flow.clone(),
|
||||
candidate_group_id: metadata.candidate_group_id.clone(),
|
||||
pool_key_index: metadata.pool_key_index,
|
||||
ranking_mode: metadata.ranking_mode.clone(),
|
||||
priority_mode: metadata.priority_mode.clone(),
|
||||
ranking_index: metadata.ranking_index,
|
||||
priority_slot: metadata.priority_slot,
|
||||
promoted_by: metadata.promoted_by.clone(),
|
||||
demoted_by: metadata.demoted_by.clone(),
|
||||
routing_trace: metadata.routing_trace.clone(),
|
||||
});
|
||||
let metadata = parse_request_candidate_report_context(report_context);
|
||||
let candidate_index = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.candidate_index)
|
||||
.unwrap_or(0);
|
||||
let retry_index = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.retry_index)
|
||||
.unwrap_or(0);
|
||||
let extra_data = build_local_request_candidate_extra_data(plan, metadata.as_ref());
|
||||
let extra_data = mark_request_candidate_stream_completed_if_success(status, extra_data);
|
||||
let created_at_unix_ms = started_at_unix_ms.or(finished_at_unix_ms);
|
||||
|
||||
Some(UpsertRequestCandidateRecord {
|
||||
id: candidate_id.to_string(),
|
||||
request_id: plan.request_id.clone(),
|
||||
user_id: metadata.user_id,
|
||||
api_key_id: metadata.api_key_id,
|
||||
user_id: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.user_id.clone()),
|
||||
api_key_id: metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.api_key_id.clone()),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
candidate_index,
|
||||
retry_index: metadata.retry_index,
|
||||
retry_index,
|
||||
provider_id: Some(plan.provider_id.clone()),
|
||||
endpoint_id: Some(plan.endpoint_id.clone()),
|
||||
key_id: Some(plan.key_id.clone()),
|
||||
@@ -519,6 +507,45 @@ pub fn build_local_request_candidate_status_record(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_local_request_candidate_extra_data(
|
||||
plan: &ExecutionPlan,
|
||||
metadata: Option<&SchedulerRequestCandidateReportContext>,
|
||||
) -> Option<Value> {
|
||||
build_report_candidate_extra_data(ReportCandidateExtraDataInput {
|
||||
client_api_format: metadata
|
||||
.and_then(|metadata| metadata.client_api_format.clone())
|
||||
.or_else(|| non_empty_string(plan.client_api_format.as_str())),
|
||||
provider_api_format: metadata
|
||||
.and_then(|metadata| metadata.provider_api_format.clone())
|
||||
.or_else(|| non_empty_string(plan.provider_api_format.as_str())),
|
||||
request_path: metadata.and_then(|metadata| metadata.request_path.clone()),
|
||||
request_query_string: metadata.and_then(|metadata| metadata.request_query_string.clone()),
|
||||
request_path_and_query: metadata
|
||||
.and_then(|metadata| metadata.request_path_and_query.clone()),
|
||||
upstream_url: metadata
|
||||
.and_then(|metadata| metadata.upstream_url.clone())
|
||||
.or_else(|| non_empty_string(plan.url.as_str())),
|
||||
mapped_model: metadata
|
||||
.and_then(|metadata| metadata.mapped_model.clone())
|
||||
.or_else(|| plan.model_name.as_deref().and_then(non_empty_string)),
|
||||
key_name: metadata.and_then(|metadata| metadata.key_name.clone()),
|
||||
header_rules: metadata.and_then(|metadata| metadata.header_rules.clone()),
|
||||
body_rules: metadata.and_then(|metadata| metadata.body_rules.clone()),
|
||||
upstream_response: metadata.and_then(|metadata| metadata.upstream_response.clone()),
|
||||
proxy: metadata.and_then(|metadata| metadata.proxy.clone()),
|
||||
error_flow: metadata.and_then(|metadata| metadata.error_flow.clone()),
|
||||
candidate_group_id: metadata.and_then(|metadata| metadata.candidate_group_id.clone()),
|
||||
pool_key_index: metadata.and_then(|metadata| metadata.pool_key_index),
|
||||
ranking_mode: metadata.and_then(|metadata| metadata.ranking_mode.clone()),
|
||||
priority_mode: metadata.and_then(|metadata| metadata.priority_mode.clone()),
|
||||
ranking_index: metadata.and_then(|metadata| metadata.ranking_index),
|
||||
priority_slot: metadata.and_then(|metadata| metadata.priority_slot),
|
||||
promoted_by: metadata.and_then(|metadata| metadata.promoted_by.clone()),
|
||||
demoted_by: metadata.and_then(|metadata| metadata.demoted_by.clone()),
|
||||
routing_trace: metadata.and_then(|metadata| metadata.routing_trace.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_report_request_candidate_status_record(
|
||||
input: ReportRequestCandidateStatusRecordInput,
|
||||
) -> UpsertRequestCandidateRecord {
|
||||
@@ -637,6 +664,11 @@ fn string_field_from_object(object: &Map<String, Value>, key: &str) -> Option<St
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn non_empty_string(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn u32_field(value: &Value, key: &str) -> Option<u32> {
|
||||
value
|
||||
.as_object()
|
||||
@@ -1278,6 +1310,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_local_request_candidate_status_record_without_report_context() {
|
||||
let mut plan = sample_plan();
|
||||
plan.candidate_id = Some("cand-plan-only".to_string());
|
||||
|
||||
let record =
|
||||
build_local_request_candidate_status_record(LocalRequestCandidateStatusRecordInput {
|
||||
plan: &plan,
|
||||
report_context: None,
|
||||
status_update: SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(401),
|
||||
error_type: Some("Unauthorized".to_string()),
|
||||
error_message: Some("oauth refresh failed".to_string()),
|
||||
latency_ms: Some(12),
|
||||
started_at_unix_ms: Some(1_000),
|
||||
finished_at_unix_ms: Some(1_012),
|
||||
},
|
||||
})
|
||||
.expect("record should build from plan fields");
|
||||
|
||||
assert_eq!(record.id, "cand-plan-only");
|
||||
assert_eq!(record.request_id, "req-1");
|
||||
assert_eq!(record.candidate_index, 0);
|
||||
assert_eq!(record.retry_index, 0);
|
||||
assert_eq!(record.provider_id.as_deref(), Some("provider-1"));
|
||||
assert_eq!(record.endpoint_id.as_deref(), Some("endpoint-1"));
|
||||
assert_eq!(record.key_id.as_deref(), Some("key-1"));
|
||||
assert_eq!(record.status, RequestCandidateStatus::Failed);
|
||||
assert_eq!(record.status_code, Some(401));
|
||||
assert_eq!(
|
||||
record
|
||||
.extra_data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("client_api_format")),
|
||||
Some(&json!("openai:chat"))
|
||||
);
|
||||
assert_eq!(
|
||||
record
|
||||
.extra_data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("upstream_url")),
|
||||
Some(&json!("https://example.com/v1/chat/completions"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_report_request_candidate_status_record_with_terminal_timestamps() {
|
||||
let record =
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<Select
|
||||
v-model="selectedPreset"
|
||||
>
|
||||
<SelectTrigger :class="['h-8 w-32 text-xs border-border/60', presetTriggerClass]">
|
||||
<SelectTrigger
|
||||
class="h-8 w-32 text-xs border-border/60"
|
||||
:class="[presetTriggerClass]"
|
||||
>
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :searchable="false">
|
||||
@@ -74,6 +77,18 @@ import {
|
||||
} from '@/components/ui'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: DateRangeParams
|
||||
showGranularity?: boolean
|
||||
allowHourly?: boolean
|
||||
presetOptions?: SelectablePreset[]
|
||||
presetTriggerClass?: string
|
||||
}>(), {
|
||||
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom']
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DateRangeParams]
|
||||
}>()
|
||||
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
|
||||
type SelectablePreset = typeof selectablePresets[number]
|
||||
|
||||
@@ -86,20 +101,6 @@ const presetLabels: Record<SelectablePreset, string> = {
|
||||
custom: '自定义'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: DateRangeParams
|
||||
showGranularity?: boolean
|
||||
allowHourly?: boolean
|
||||
presetOptions?: SelectablePreset[]
|
||||
presetTriggerClass?: string
|
||||
}>(), {
|
||||
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom']
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DateRangeParams]
|
||||
}>()
|
||||
|
||||
const activePresetOptions = computed<SelectablePreset[]>(() => {
|
||||
const unique = new Set(props.presetOptions)
|
||||
const filtered = selectablePresets.filter((preset) => unique.has(preset))
|
||||
|
||||
@@ -195,8 +195,6 @@ import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
currentVersion: string
|
||||
@@ -227,6 +225,8 @@ const emit = defineEmits<{
|
||||
rollback: []
|
||||
}>()
|
||||
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const updating = computed(() => props.updating ?? false)
|
||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||
|
||||
@@ -345,9 +345,6 @@ import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { marked } from 'marked'
|
||||
import { ChevronRight, ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
|
||||
const props = defineProps<{
|
||||
status: CheckUpdateResponse | null
|
||||
loading?: boolean
|
||||
@@ -359,7 +356,6 @@ const props = defineProps<{
|
||||
downloadProgressText?: string | null
|
||||
downloadProgressPercent?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
openRelease: []
|
||||
@@ -367,6 +363,8 @@ const emit = defineEmits<{
|
||||
previewRelease: [release: ReleaseEntry]
|
||||
rollback: []
|
||||
}>()
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const showReleases = ref(false)
|
||||
|
||||
@@ -329,6 +329,23 @@ import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
requireEmailVerification: false,
|
||||
emailConfigured: true,
|
||||
passwordPolicyLevel: 'weak',
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: null,
|
||||
privacyPolicy: () => ({
|
||||
enabled: false,
|
||||
format: 'markdown',
|
||||
content: '',
|
||||
version: ''
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const INVITE_CODE_STORAGE_KEY = 'aether_invite_code'
|
||||
|
||||
interface Props {
|
||||
@@ -347,22 +364,6 @@ interface Emits {
|
||||
(e: 'switchToLogin'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: false,
|
||||
requireEmailVerification: false,
|
||||
emailConfigured: true,
|
||||
passwordPolicyLevel: 'weak',
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: null,
|
||||
privacyPolicy: () => ({
|
||||
enabled: false,
|
||||
format: 'markdown',
|
||||
content: '',
|
||||
version: ''
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// Form nonce for password fields (prevent autofill)
|
||||
|
||||
@@ -137,7 +137,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 默认定价 -->
|
||||
|
||||
@@ -301,19 +301,17 @@ type ImageOutputPriceRangeRow = {
|
||||
prices: Partial<Record<ImageOutputQuality, number>>
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
|
||||
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
|
||||
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: TieredPricingConfig | null
|
||||
showCache1h?: boolean
|
||||
showImagePricing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TieredPricingConfig | null]
|
||||
}>()
|
||||
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
|
||||
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
|
||||
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
||||
|
||||
// 本地状态
|
||||
const localTiers = ref<PricingTier[]>([])
|
||||
|
||||
@@ -122,20 +122,36 @@
|
||||
>
|
||||
<div class="grid gap-2 text-xs sm:grid-cols-4">
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">号池账号</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.totalKeys }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
号池账号
|
||||
</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">
|
||||
{{ plan.totalKeys }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">代理节点</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.nodeCount }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
代理节点
|
||||
</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">
|
||||
{{ plan.nodeCount }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">单节点上限</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.maxPerNode }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
单节点上限
|
||||
</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">
|
||||
{{ plan.maxPerNode }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-background px-3 py-2">
|
||||
<div class="text-muted-foreground">待写入</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.changedCount }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
待写入
|
||||
</div>
|
||||
<div class="mt-1 text-base font-semibold tabular-nums">
|
||||
{{ plan.changedCount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,8 +179,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="outline">目标 {{ item.targetCount }}</Badge>
|
||||
<Badge variant="secondary">保留 {{ item.retainedCount }}</Badge>
|
||||
<Badge variant="outline">
|
||||
目标 {{ item.targetCount }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
保留 {{ item.retainedCount }}
|
||||
</Badge>
|
||||
<Badge :variant="item.changedCount > 0 ? 'default' : 'outline'">
|
||||
写入 {{ item.changedCount }}
|
||||
</Badge>
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{{ group.description || '未填写描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-md border px-2 py-1 text-xs text-muted-foreground">
|
||||
v{{ group.version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +25,6 @@ export interface RoutingGroupListItem {
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -69,37 +69,35 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
<button
|
||||
v-if="effectivePriorityMode === 'provider'"
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-xs font-medium transition-colors"
|
||||
:class="providerMultiSelectEnabled
|
||||
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||
@click="toggleProviderMultiSelect"
|
||||
>
|
||||
<ListChecks class="h-3.5 w-3.5" />
|
||||
{{ providerMultiSelectEnabled ? '退出多选' : '多选' }}
|
||||
</button>
|
||||
<Select
|
||||
v-if="effectivePriorityMode === 'global_key'"
|
||||
v-model="selectedApiFormat"
|
||||
class="h-9 min-w-[180px] rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option
|
||||
v-for="format in apiFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ formatLabel(format) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-xs"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md border border-border px-3 text-xs text-muted-foreground"
|
||||
@click="clearActiveOverrides"
|
||||
>
|
||||
清空排序
|
||||
</button>
|
||||
<SelectTrigger class="h-8 w-[180px] rounded-lg border-border/60 bg-background/80 px-3 text-xs">
|
||||
<SelectValue placeholder="选择端点" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="format in apiFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ formatLabel(format) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,12 +129,8 @@
|
||||
v-for="(row, index) in providerRows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
|
||||
:class="draggedProviderId === row.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: dragOverProviderId === row.id
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:bg-muted/30'"
|
||||
class="group grid min-h-[56px] items-center gap-3 rounded-lg border px-3 py-2 transition-colors"
|
||||
:class="[providerGridClass, providerRowClass(row.id)]"
|
||||
draggable="true"
|
||||
@dragstart="handleProviderDragStart(row.id, $event)"
|
||||
@dragend="handleProviderDragEnd"
|
||||
@@ -144,6 +138,15 @@
|
||||
@dragleave="handleProviderDragLeave"
|
||||
@drop="handleProviderDrop(row.id)"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="providerMultiSelectEnabled"
|
||||
class="shrink-0"
|
||||
:checked="isProviderSelected(row.id)"
|
||||
:aria-label="`选择 ${row.name}`"
|
||||
@click.stop
|
||||
@change.stop
|
||||
@update:checked="checked => setProviderSelected(row.id, checked)"
|
||||
/>
|
||||
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
@@ -151,7 +154,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === 0"
|
||||
:disabled="providerMoveDisabled(row.id, index, -1)"
|
||||
@click="moveProvider(row.id, -1)"
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
@@ -159,7 +162,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="index === providerRows.length - 1"
|
||||
:disabled="providerMoveDisabled(row.id, index, 1)"
|
||||
@click="moveProvider(row.id, 1)"
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
@@ -169,7 +172,7 @@
|
||||
:value="row.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
class="priority-input h-8 w-14 rounded-md border border-border bg-background px-2 text-center text-sm"
|
||||
@change="event => setProviderPriority(row.id, event)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
@@ -188,17 +191,15 @@
|
||||
停用
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
|
||||
<span
|
||||
v-for="format in row.api_formats.slice(0, 3)"
|
||||
:key="format"
|
||||
:title="formatLabel(format)"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ format }}
|
||||
{{ formatShortLabel(format) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,7 +219,7 @@
|
||||
v-for="(row, index) in keyRows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
|
||||
class="group grid min-h-[56px] items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]"
|
||||
:class="draggedKeyId === row.id
|
||||
? 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
: dragOverKeyId === row.id
|
||||
@@ -256,7 +257,7 @@
|
||||
:value="row.priority"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
class="priority-input h-8 w-14 rounded-md border border-border bg-background px-2 text-center text-sm"
|
||||
@change="event => setKeyPriority(row.id, event)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
@@ -277,9 +278,10 @@
|
||||
<span
|
||||
v-for="format in row.api_formats.slice(0, 3)"
|
||||
:key="format"
|
||||
:title="formatLabel(format)"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ format }}
|
||||
{{ formatShortLabel(format) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,14 +293,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, RefreshCw } from 'lucide-vue-next'
|
||||
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, ListChecks } from 'lucide-vue-next'
|
||||
|
||||
import client from '@/api/client'
|
||||
import {
|
||||
Checkbox,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
getProvidersSummary,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { formatApiFormat, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
|
||||
import { formatApiFormat, formatApiFormatShort, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
@@ -385,6 +395,8 @@ const draggedProviderId = ref<string | null>(null)
|
||||
const dragOverProviderId = ref<string | null>(null)
|
||||
const draggedKeyId = ref<string | null>(null)
|
||||
const dragOverKeyId = ref<string | null>(null)
|
||||
const providerMultiSelectEnabled = ref(false)
|
||||
const selectedProviderIds = ref<Set<string>>(new Set())
|
||||
|
||||
const config = computed(() => normalizeRoutingGroupConfig(props.config))
|
||||
const targetModel = computed(() => props.model?.trim() || DEFAULT_ROUTING_POLICY_MODEL)
|
||||
@@ -398,6 +410,9 @@ const effectiveSchedulingMode = computed(() => props.schedulingMode ?? config.va
|
||||
const subtitle = computed(() => props.subtitle ?? '默认作用于全部模型')
|
||||
const loading = computed(() => loadingProviders.value || loadingKeys.value)
|
||||
const apiFormats = computed(() => sortApiFormats(Object.keys(keysByFormat.value)))
|
||||
const providerGridClass = computed(() => providerMultiSelectEnabled.value
|
||||
? 'sm:grid-cols-[auto_auto_auto_56px_minmax(0,1fr)_auto]'
|
||||
: 'sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]')
|
||||
const providerById = computed(() => {
|
||||
const map = new Map<string, ProviderWithEndpointsSummary>()
|
||||
for (const provider of providers.value) {
|
||||
@@ -477,6 +492,16 @@ const keyRows = computed<KeyPriorityRow[]>(() => {
|
||||
watch(effectivePriorityMode, mode => {
|
||||
if (mode === 'global_key') {
|
||||
void loadGlobalKeys()
|
||||
providerMultiSelectEnabled.value = false
|
||||
selectedProviderIds.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
watch(providerRows, rows => {
|
||||
const visibleIds = new Set(rows.map(row => row.id))
|
||||
const next = new Set([...selectedProviderIds.value].filter(id => visibleIds.has(id)))
|
||||
if (next.size !== selectedProviderIds.value.size) {
|
||||
selectedProviderIds.value = next
|
||||
}
|
||||
})
|
||||
|
||||
@@ -525,15 +550,6 @@ function updateSchedulingMode(mode: RoutingSchedulingMode): void {
|
||||
updateDefaultPolicy({ scheduling_mode: mode })
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (effectivePriorityMode.value === 'provider') {
|
||||
await loadProviders()
|
||||
} else {
|
||||
await loadProviders()
|
||||
await loadGlobalKeys(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders(): Promise<void> {
|
||||
loadingProviders.value = true
|
||||
loadError.value = null
|
||||
@@ -583,7 +599,10 @@ function setProviderPriority(providerId: string, event: Event): void {
|
||||
}
|
||||
|
||||
function moveProvider(providerId: string, direction: -1 | 1): void {
|
||||
const rows = moveRow(providerRows.value, providerId, direction)
|
||||
const movingIds = providerMoveIds(providerId)
|
||||
const rows = movingIds.length > 1
|
||||
? moveRowsByGroup(providerRows.value, movingIds, direction)
|
||||
: moveRow(providerRows.value, providerId, direction)
|
||||
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
|
||||
}
|
||||
|
||||
@@ -591,6 +610,70 @@ function updateProviderOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelProviderPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
}
|
||||
|
||||
function isProviderSelected(providerId: string): boolean {
|
||||
return providerMultiSelectEnabled.value && selectedProviderIds.value.has(providerId)
|
||||
}
|
||||
|
||||
function setProviderSelected(providerId: string, selected: boolean): void {
|
||||
if (!providerMultiSelectEnabled.value) return
|
||||
const next = new Set(selectedProviderIds.value)
|
||||
if (selected) {
|
||||
next.add(providerId)
|
||||
} else {
|
||||
next.delete(providerId)
|
||||
}
|
||||
selectedProviderIds.value = next
|
||||
}
|
||||
|
||||
function toggleProviderMultiSelect(): void {
|
||||
providerMultiSelectEnabled.value = !providerMultiSelectEnabled.value
|
||||
if (!providerMultiSelectEnabled.value) {
|
||||
selectedProviderIds.value = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function providerMoveIds(providerId: string): string[] {
|
||||
if (!providerMultiSelectEnabled.value || !selectedProviderIds.value.has(providerId)) {
|
||||
return [providerId]
|
||||
}
|
||||
return providerRows.value
|
||||
.map(row => row.id)
|
||||
.filter(id => selectedProviderIds.value.has(id))
|
||||
}
|
||||
|
||||
function providerMoveDisabled(providerId: string, index: number, direction: -1 | 1): boolean {
|
||||
const movingIds = providerMoveIds(providerId)
|
||||
if (movingIds.length <= 1) {
|
||||
return direction === -1 ? index === 0 : index === providerRows.value.length - 1
|
||||
}
|
||||
const movingSet = new Set(movingIds)
|
||||
const movingIndexes = providerRows.value
|
||||
.map((row, rowIndex) => movingSet.has(row.id) ? rowIndex : -1)
|
||||
.filter(rowIndex => rowIndex >= 0)
|
||||
if (movingIndexes.length === 0) return true
|
||||
return direction === -1
|
||||
? Math.min(...movingIndexes) === 0
|
||||
: Math.max(...movingIndexes) === providerRows.value.length - 1
|
||||
}
|
||||
|
||||
function providerRowClass(providerId: string): string {
|
||||
if (isProviderDragged(providerId)) {
|
||||
return 'border-primary/50 bg-primary/5 shadow-sm'
|
||||
}
|
||||
if (dragOverProviderId.value === providerId) {
|
||||
return 'border-primary/30 bg-primary/5'
|
||||
}
|
||||
if (isProviderSelected(providerId)) {
|
||||
return 'border-primary/40 bg-primary/5'
|
||||
}
|
||||
return 'border-border/50 bg-background hover:bg-muted/30'
|
||||
}
|
||||
|
||||
function isProviderDragged(providerId: string): boolean {
|
||||
const draggedId = draggedProviderId.value
|
||||
return Boolean(draggedId && providerMoveIds(draggedId).includes(providerId))
|
||||
}
|
||||
|
||||
function setKeyPriority(keyId: string, event: Event): void {
|
||||
const priority = readPriorityInput(event)
|
||||
if (priority == null) return
|
||||
@@ -684,7 +767,14 @@ function handleProviderDrop(providerId: string): void {
|
||||
handleProviderDragEnd()
|
||||
return
|
||||
}
|
||||
const rows = reorderRows(providerRows.value, draggedId, providerId)
|
||||
const movingIds = providerMoveIds(draggedId)
|
||||
if (movingIds.includes(providerId)) {
|
||||
handleProviderDragEnd()
|
||||
return
|
||||
}
|
||||
const rows = movingIds.length > 1
|
||||
? reorderRowsByGroup(providerRows.value, movingIds, providerId)
|
||||
: reorderRows(providerRows.value, draggedId, providerId)
|
||||
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
|
||||
handleProviderDragEnd()
|
||||
}
|
||||
@@ -721,14 +811,6 @@ function handleKeyDrop(keyId: string): void {
|
||||
handleKeyDragEnd()
|
||||
}
|
||||
|
||||
function clearActiveOverrides(): void {
|
||||
if (effectivePriorityMode.value === 'provider') {
|
||||
updateProviderOverrides({})
|
||||
} else {
|
||||
updateVisibleKeyAndPoolOverrides([])
|
||||
}
|
||||
}
|
||||
|
||||
function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1 | 1): T[] {
|
||||
const next = [...rows]
|
||||
const index = next.findIndex(row => row.id === id)
|
||||
@@ -741,6 +823,26 @@ function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1
|
||||
return next
|
||||
}
|
||||
|
||||
function moveRowsByGroup<T extends { id: string }>(rows: T[], movingIds: string[], direction: -1 | 1): T[] {
|
||||
const movingSet = new Set(movingIds)
|
||||
const movingRows = rows.filter(row => movingSet.has(row.id))
|
||||
if (movingRows.length === 0) return [...rows]
|
||||
|
||||
const firstMovingIndex = rows.findIndex(row => movingSet.has(row.id))
|
||||
const remainingRows = rows.filter(row => !movingSet.has(row.id))
|
||||
const baseInsertIndex = rows
|
||||
.slice(0, firstMovingIndex)
|
||||
.filter(row => !movingSet.has(row.id))
|
||||
.length
|
||||
const insertIndex = direction === -1
|
||||
? Math.max(0, baseInsertIndex - 1)
|
||||
: Math.min(remainingRows.length, baseInsertIndex + 1)
|
||||
|
||||
const next = [...remainingRows]
|
||||
next.splice(insertIndex, 0, ...movingRows)
|
||||
return next
|
||||
}
|
||||
|
||||
function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, targetId: string): T[] {
|
||||
const next = [...rows]
|
||||
const fromIndex = next.findIndex(row => row.id === draggedId)
|
||||
@@ -751,6 +853,29 @@ function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, tar
|
||||
return next
|
||||
}
|
||||
|
||||
function reorderRowsByGroup<T extends { id: string }>(rows: T[], movingIds: string[], targetId: string): T[] {
|
||||
const movingSet = new Set(movingIds)
|
||||
if (movingSet.has(targetId)) return [...rows]
|
||||
|
||||
const movingRows = rows.filter(row => movingSet.has(row.id))
|
||||
if (movingRows.length === 0) return [...rows]
|
||||
|
||||
const targetIndex = rows.findIndex(row => row.id === targetId)
|
||||
const firstMovingIndex = rows.findIndex(row => movingSet.has(row.id))
|
||||
if (targetIndex < 0 || firstMovingIndex < 0) return [...rows]
|
||||
|
||||
const remainingRows = rows.filter(row => !movingSet.has(row.id))
|
||||
const remainingTargetIndex = remainingRows.findIndex(row => row.id === targetId)
|
||||
if (remainingTargetIndex < 0) return [...rows]
|
||||
|
||||
const insertIndex = firstMovingIndex < targetIndex
|
||||
? remainingTargetIndex + 1
|
||||
: remainingTargetIndex
|
||||
const next = [...remainingRows]
|
||||
next.splice(insertIndex, 0, ...movingRows)
|
||||
return next
|
||||
}
|
||||
|
||||
function readPriorityInput(event: Event): number | null {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
@@ -858,6 +983,10 @@ function formatLabel(format: string): string {
|
||||
return formatApiFormat(format)
|
||||
}
|
||||
|
||||
function formatShortLabel(format: string): string {
|
||||
return formatApiFormatShort(format)
|
||||
}
|
||||
|
||||
function normalizePriorityMap(value: Record<string, unknown> | null | undefined): Record<string, number> {
|
||||
if (!value) return {}
|
||||
const normalized: Record<string, number> = {}
|
||||
@@ -882,3 +1011,15 @@ function comparePriorityRows(left: ProviderPriorityRow | KeyPriorityRow, right:
|
||||
|| left.id.localeCompare(right.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.priority-input::-webkit-outer-spin-button,
|
||||
.priority-input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.priority-input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -776,14 +776,6 @@ import {
|
||||
|
||||
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
|
||||
'pending',
|
||||
'streaming',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
])
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
requestId: string | null
|
||||
@@ -802,6 +794,14 @@ const emit = defineEmits<{
|
||||
}]
|
||||
}>()
|
||||
|
||||
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
|
||||
'pending',
|
||||
'streaming',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const detail = ref<RequestDetail | null>(null)
|
||||
|
||||
@@ -354,35 +354,107 @@
|
||||
:class="[desktopTableMinWidthClass]"
|
||||
>
|
||||
<colgroup v-if="isAdmin">
|
||||
<col v-if="isColumnVisible('time')" class="w-[8%]">
|
||||
<col v-if="isColumnVisible('user')" class="w-[12%]">
|
||||
<col v-if="isColumnVisible('model')" class="w-[14%]">
|
||||
<col v-if="isColumnVisible('provider')" class="w-[16%]">
|
||||
<col v-if="isColumnVisible('api_format')" class="w-[15%]">
|
||||
<col v-if="isColumnVisible('status')" class="w-[10%]">
|
||||
<col v-if="isColumnVisible('tokens')" class="w-[10%]">
|
||||
<col v-if="isColumnVisible('cost')" class="w-[6%]">
|
||||
<col v-if="isColumnVisible('performance')" class="w-[9%]">
|
||||
<col v-if="isColumnVisible('client_family')" class="w-[12%]">
|
||||
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
|
||||
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
|
||||
<col
|
||||
v-if="isColumnVisible('time')"
|
||||
class="w-[8%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('user')"
|
||||
class="w-[12%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('model')"
|
||||
class="w-[14%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('provider')"
|
||||
class="w-[16%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('api_format')"
|
||||
class="w-[15%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('status')"
|
||||
class="w-[10%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('tokens')"
|
||||
class="w-[10%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('cost')"
|
||||
class="w-[6%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('performance')"
|
||||
class="w-[9%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('client_family')"
|
||||
class="w-[12%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('client_ip')"
|
||||
class="w-[10%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('user_agent')"
|
||||
class="w-[13%]"
|
||||
>
|
||||
</colgroup>
|
||||
<colgroup v-else>
|
||||
<col v-if="isColumnVisible('time')" class="w-[9%]">
|
||||
<col v-if="isColumnVisible('key')" class="w-[17%]">
|
||||
<col v-if="isColumnVisible('model')" class="w-[22%]">
|
||||
<col v-if="isColumnVisible('api_format')" class="w-[14%]">
|
||||
<col v-if="isColumnVisible('status')" class="w-[10%]">
|
||||
<col v-if="isColumnVisible('tokens')" class="w-[11%]">
|
||||
<col v-if="isColumnVisible('cost')" class="w-[7%]">
|
||||
<col v-if="isColumnVisible('performance')" class="w-[10%]">
|
||||
<col v-if="isColumnVisible('client_family')" class="w-[12%]">
|
||||
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
|
||||
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
|
||||
<col
|
||||
v-if="isColumnVisible('time')"
|
||||
class="w-[9%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('key')"
|
||||
class="w-[17%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('model')"
|
||||
class="w-[22%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('api_format')"
|
||||
class="w-[14%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('status')"
|
||||
class="w-[10%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('tokens')"
|
||||
class="w-[11%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('cost')"
|
||||
class="w-[7%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('performance')"
|
||||
class="w-[10%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('client_family')"
|
||||
class="w-[12%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('client_ip')"
|
||||
class="w-[10%]"
|
||||
>
|
||||
<col
|
||||
v-if="isColumnVisible('user_agent')"
|
||||
class="w-[13%]"
|
||||
>
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead v-if="isColumnVisible('time')" class="h-12 font-semibold w-[8%]">
|
||||
<TableHead
|
||||
v-if="isColumnVisible('time')"
|
||||
class="h-12 font-semibold w-[8%]"
|
||||
>
|
||||
时间
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
@@ -489,13 +561,22 @@
|
||||
/>
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<TableHead v-if="isColumnVisible('tokens')" class="h-12 font-semibold w-[10%] text-center">
|
||||
<TableHead
|
||||
v-if="isColumnVisible('tokens')"
|
||||
class="h-12 font-semibold w-[10%] text-center"
|
||||
>
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead v-if="isColumnVisible('cost')" class="h-12 font-semibold w-[6%] text-right">
|
||||
<TableHead
|
||||
v-if="isColumnVisible('cost')"
|
||||
class="h-12 font-semibold w-[6%] text-right"
|
||||
>
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead v-if="isColumnVisible('performance')" class="h-12 font-semibold w-[9%] text-right">
|
||||
<TableHead
|
||||
v-if="isColumnVisible('performance')"
|
||||
class="h-12 font-semibold w-[9%] text-right"
|
||||
>
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="whitespace-nowrap">首字/总耗时</span>
|
||||
<span class="text-muted-foreground font-normal">输出速度</span>
|
||||
@@ -520,10 +601,16 @@
|
||||
/>
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<TableHead v-if="isColumnVisible('client_ip')" class="h-12 font-semibold w-[10%]">
|
||||
<TableHead
|
||||
v-if="isColumnVisible('client_ip')"
|
||||
class="h-12 font-semibold w-[10%]"
|
||||
>
|
||||
IP 地址
|
||||
</TableHead>
|
||||
<TableHead v-if="isColumnVisible('user_agent')" class="h-12 font-semibold w-[13%]">
|
||||
<TableHead
|
||||
v-if="isColumnVisible('user_agent')"
|
||||
class="h-12 font-semibold w-[13%]"
|
||||
>
|
||||
User-Agent
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -545,7 +632,10 @@
|
||||
@mousedown="handleRowMouseDown($event, record.id)"
|
||||
@click="handleRowClick($event, record.id)"
|
||||
>
|
||||
<TableCell v-if="isColumnVisible('time')" class="py-4 w-[8%] align-top">
|
||||
<TableCell
|
||||
v-if="isColumnVisible('time')"
|
||||
class="py-4 w-[8%] align-top"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 leading-tight">
|
||||
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
|
||||
{{ formatRecordTime(record.created_at) }}
|
||||
@@ -728,7 +818,10 @@
|
||||
class="text-muted-foreground text-xs"
|
||||
>-</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
|
||||
<TableCell
|
||||
v-if="isColumnVisible('status')"
|
||||
class="text-center py-4 w-[10%]"
|
||||
>
|
||||
<!-- 优先显示请求状态 -->
|
||||
<Badge
|
||||
v-if="isUsageRecordFailed(record)"
|
||||
@@ -779,7 +872,10 @@
|
||||
{{ getStreamModeLabel(record) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell v-if="isColumnVisible('tokens')" class="py-4 w-[10%]">
|
||||
<TableCell
|
||||
v-if="isColumnVisible('tokens')"
|
||||
class="py-4 w-[10%]"
|
||||
>
|
||||
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
|
||||
<span class="justify-self-end whitespace-nowrap text-right">
|
||||
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
|
||||
@@ -813,7 +909,10 @@
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell v-if="isColumnVisible('cost')" class="text-right py-4 w-[6%]">
|
||||
<TableCell
|
||||
v-if="isColumnVisible('cost')"
|
||||
class="text-right py-4 w-[6%]"
|
||||
>
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||
<span
|
||||
@@ -824,7 +923,10 @@
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell v-if="isColumnVisible('performance')" class="text-right py-4 w-[9%]">
|
||||
<TableCell
|
||||
v-if="isColumnVisible('performance')"
|
||||
class="text-right py-4 w-[9%]"
|
||||
>
|
||||
<!-- pending/streaming 状态:首字与动态总耗时保留在同一行 -->
|
||||
<div
|
||||
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
|
||||
@@ -986,45 +1088,6 @@ interface UsageRecordColumnOption {
|
||||
userOnly?: boolean
|
||||
}
|
||||
|
||||
const USAGE_RECORD_COLUMN_OPTIONS: UsageRecordColumnOption[] = [
|
||||
{ id: 'time', label: '时间' },
|
||||
{ id: 'user', label: '用户', adminOnly: true },
|
||||
{ id: 'key', label: '密钥', userOnly: true },
|
||||
{ id: 'model', label: '模型' },
|
||||
{ id: 'provider', label: '提供商', adminOnly: true },
|
||||
{ id: 'api_format', label: 'API格式' },
|
||||
{ id: 'status', label: '类型/状态' },
|
||||
{ id: 'tokens', label: 'Tokens' },
|
||||
{ id: 'cost', label: '费用' },
|
||||
{ id: 'performance', label: '耗时/速度' },
|
||||
{ id: 'client_family', label: '客户端类型' },
|
||||
{ id: 'client_ip', label: 'IP 地址' },
|
||||
{ id: 'user_agent', label: 'User-Agent' },
|
||||
]
|
||||
|
||||
const DEFAULT_ADMIN_COLUMNS: UsageRecordColumnId[] = [
|
||||
'time',
|
||||
'user',
|
||||
'model',
|
||||
'provider',
|
||||
'api_format',
|
||||
'status',
|
||||
'tokens',
|
||||
'cost',
|
||||
'performance',
|
||||
]
|
||||
|
||||
const DEFAULT_USER_COLUMNS: UsageRecordColumnId[] = [
|
||||
'time',
|
||||
'key',
|
||||
'model',
|
||||
'api_format',
|
||||
'status',
|
||||
'tokens',
|
||||
'cost',
|
||||
'performance',
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
records: UsageRecord[]
|
||||
isAdmin: boolean
|
||||
@@ -1070,6 +1133,45 @@ const emit = defineEmits<{
|
||||
'prefetchDetail': [id: string]
|
||||
}>()
|
||||
|
||||
const USAGE_RECORD_COLUMN_OPTIONS: UsageRecordColumnOption[] = [
|
||||
{ id: 'time', label: '时间' },
|
||||
{ id: 'user', label: '用户', adminOnly: true },
|
||||
{ id: 'key', label: '密钥', userOnly: true },
|
||||
{ id: 'model', label: '模型' },
|
||||
{ id: 'provider', label: '提供商', adminOnly: true },
|
||||
{ id: 'api_format', label: 'API格式' },
|
||||
{ id: 'status', label: '类型/状态' },
|
||||
{ id: 'tokens', label: 'Tokens' },
|
||||
{ id: 'cost', label: '费用' },
|
||||
{ id: 'performance', label: '耗时/速度' },
|
||||
{ id: 'client_family', label: '客户端类型' },
|
||||
{ id: 'client_ip', label: 'IP 地址' },
|
||||
{ id: 'user_agent', label: 'User-Agent' },
|
||||
]
|
||||
|
||||
const DEFAULT_ADMIN_COLUMNS: UsageRecordColumnId[] = [
|
||||
'time',
|
||||
'user',
|
||||
'model',
|
||||
'provider',
|
||||
'api_format',
|
||||
'status',
|
||||
'tokens',
|
||||
'cost',
|
||||
'performance',
|
||||
]
|
||||
|
||||
const DEFAULT_USER_COLUMNS: UsageRecordColumnId[] = [
|
||||
'time',
|
||||
'key',
|
||||
'model',
|
||||
'api_format',
|
||||
'status',
|
||||
'tokens',
|
||||
'cost',
|
||||
'performance',
|
||||
]
|
||||
|
||||
// 使用统一 API 格式枚举,避免使用记录筛选项和系统格式列表漂移。
|
||||
const availableApiFormats = API_FORMAT_ORDER.map((value) => ({
|
||||
value,
|
||||
|
||||
@@ -131,208 +131,208 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">配置标识</Label>
|
||||
<Input
|
||||
v-model="form.new_provider_type"
|
||||
class="mt-1"
|
||||
placeholder="custom_oidc_work"
|
||||
autocomplete="off"
|
||||
@blur="normalizeNewProviderType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 凭证配置 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client ID</Label>
|
||||
<Input
|
||||
v-model="form.client_id"
|
||||
class="mt-1"
|
||||
placeholder="client_id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client Secret</Label>
|
||||
<Input
|
||||
v-model="form.client_secret"
|
||||
masked
|
||||
class="mt-1"
|
||||
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调地址 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
|
||||
<Input
|
||||
v-model="form.redirect_uri"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:8084/api/oauth/xxx/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">前端回调页</Label>
|
||||
<Input
|
||||
v-model="form.frontend_callback_url"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:5173/auth/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom_oidc 必填端点 -->
|
||||
<div
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/api/user"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图标 URL -->
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">图标 URL</Label>
|
||||
<Input
|
||||
v-model="form.icon_url"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/icon.svg"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
登录页显示的 Provider 图标,留空使用默认图标
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
高级选项
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Scopes</Label>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- linuxdo 的可选端点覆盖 -->
|
||||
<div
|
||||
v-if="!isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Attribute Mapping</Label>
|
||||
<Textarea
|
||||
v-model="form.attribute_mapping_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
placeholder="{"id": "user_id", "username": "login"}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||
</Label>
|
||||
<Textarea
|
||||
v-model="form.extra_config_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
:placeholder="extraConfigPlaceholder"
|
||||
/>
|
||||
<p
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">配置标识</Label>
|
||||
<Input
|
||||
v-model="form.new_provider_type"
|
||||
class="mt-1"
|
||||
placeholder="custom_oidc_work"
|
||||
autocomplete="off"
|
||||
@blur="normalizeNewProviderType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 凭证配置 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client ID</Label>
|
||||
<Input
|
||||
v-model="form.client_id"
|
||||
class="mt-1"
|
||||
placeholder="client_id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client Secret</Label>
|
||||
<Input
|
||||
v-model="form.client_secret"
|
||||
masked
|
||||
class="mt-1"
|
||||
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调地址 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
|
||||
<Input
|
||||
v-model="form.redirect_uri"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:8084/api/oauth/xxx/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">前端回调页</Label>
|
||||
<Input
|
||||
v-model="form.frontend_callback_url"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:5173/auth/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom_oidc 必填端点 -->
|
||||
<div
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/api/user"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图标 URL -->
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">图标 URL</Label>
|
||||
<Input
|
||||
v-model="form.icon_url"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/icon.svg"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
登录页显示的 Provider 图标,留空使用默认图标
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
高级选项
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Scopes</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- linuxdo 的可选端点覆盖 -->
|
||||
<div
|
||||
v-if="!isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Attribute Mapping</Label>
|
||||
<Textarea
|
||||
v-model="form.attribute_mapping_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
placeholder="{"id": "user_id", "username": "login"}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||
</Label>
|
||||
<Textarea
|
||||
v-model="form.extra_config_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
:placeholder="extraConfigPlaceholder"
|
||||
/>
|
||||
<p
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
|
||||
@@ -1155,12 +1155,12 @@
|
||||
>
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="text-muted-foreground font-medium shrink-0">{{ getQuotaProgressLabel(item.label) }}</span>
|
||||
<span
|
||||
v-if="getQuotaProgressResetDisplayText(item)"
|
||||
data-testid="pool-quota-reset-text"
|
||||
class="text-muted-foreground/80 tabular-nums truncate"
|
||||
:title="getQuotaProgressResetDisplayText(item)"
|
||||
>{{ getQuotaProgressResetDisplayText(item) }}</span>
|
||||
<span
|
||||
v-if="getQuotaProgressResetDisplayText(item)"
|
||||
data-testid="pool-quota-reset-text"
|
||||
class="text-muted-foreground/80 tabular-nums truncate"
|
||||
:title="getQuotaProgressResetDisplayText(item)"
|
||||
>{{ getQuotaProgressResetDisplayText(item) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="relative flex-1 h-1.5 rounded-full bg-border overflow-hidden">
|
||||
|
||||
@@ -3,25 +3,7 @@
|
||||
<PageHeader
|
||||
title="调度策略"
|
||||
description="管理调度分组、模型范围、默认调度模式和规则配置"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="refreshPage"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-4 w-4"
|
||||
:class="{ 'animate-spin': loading || loadingGlobalModels }"
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button @click="startCreate">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
新建策略
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
/>
|
||||
|
||||
<div class="mt-6 grid gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<Card class="overflow-hidden">
|
||||
@@ -35,7 +17,17 @@
|
||||
共 {{ groups.length }} 个
|
||||
</p>
|
||||
</div>
|
||||
<SlidersHorizontal class="h-4 w-4 text-muted-foreground" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
|
||||
:disabled="loading"
|
||||
aria-label="添加策略"
|
||||
title="添加策略"
|
||||
@click="startCreate"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,49 +39,111 @@
|
||||
正在加载调度策略
|
||||
</div>
|
||||
<div
|
||||
v-else-if="groups.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium">
|
||||
暂无调度策略
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
可以先创建一个默认分组
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-for="group in groups"
|
||||
v-else
|
||||
:key="group.id"
|
||||
type="button"
|
||||
class="mb-2 w-full rounded-lg border px-4 py-3 text-left transition-colors"
|
||||
:class="group.id === selectedGroupId
|
||||
? 'border-primary/60 bg-primary/10'
|
||||
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/50'"
|
||||
@click="selectGroup(group)"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{{ group.description || '未填写描述' }}
|
||||
</p>
|
||||
<div
|
||||
v-if="groups.length === 0 && !isCreating"
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center"
|
||||
>
|
||||
<p class="text-sm font-medium">
|
||||
暂无调度策略
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
可以先创建一个默认分组
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreating && draft"
|
||||
class="rounded-lg border border-primary/60 bg-primary/10 px-4 py-2.5 text-left"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ draft.name || '新调度策略' }}
|
||||
</p>
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{{ draft.description || '未填写描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Badge :variant="draft.enabled ? 'default' : 'secondary'">
|
||||
{{ draft.enabled ? '启用' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-end justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span v-if="draft.is_system_default">系统默认</span>
|
||||
<span>{{ draft.config_json.allowed_models.length || '全部' }} 模型范围</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="draft.enabled
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'text-muted-foreground/70 hover:text-foreground'"
|
||||
:aria-label="draft.enabled ? '停用策略' : '启用策略'"
|
||||
:title="draft.enabled ? '已启用,点击停用' : '已停用,点击启用'"
|
||||
@click="setDraftEnabled(!draft.enabled)"
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="group.enabled ? 'default' : 'secondary'"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ group.enabled ? '启用' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>v{{ group.version }}</span>
|
||||
<span v-if="group.is_system_default">系统默认</span>
|
||||
<span>{{ group.config_json.allowed_models.length || '全部' }} 模型范围</span>
|
||||
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="w-full rounded-lg border px-4 py-2.5 text-left transition-colors"
|
||||
:class="group.id === selectedGroupId
|
||||
? 'border-primary/60 bg-primary/10'
|
||||
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/50'"
|
||||
@click="selectGroup(group)"
|
||||
@keydown.enter.prevent="selectGroup(group)"
|
||||
@keydown.space.prevent="selectGroup(group)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{{ group.description || '未填写描述' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Badge :variant="displayGroupEnabled(group) ? 'default' : 'secondary'">
|
||||
{{ displayGroupEnabled(group) ? '启用' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-end justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span v-if="group.is_system_default">系统默认</span>
|
||||
<span>{{ group.config_json.allowed_models.length || '全部' }} 模型范围</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="displayGroupEnabled(group)
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'text-muted-foreground/70 hover:text-foreground'"
|
||||
:aria-label="displayGroupEnabled(group) ? '停用策略' : '启用策略'"
|
||||
:title="displayGroupEnabled(group) ? '已启用,点击停用' : '已停用,点击启用'"
|
||||
@keydown.stop
|
||||
@click.stop="updateGroupEnabled(group, !displayGroupEnabled(group))"
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -104,12 +158,6 @@
|
||||
<h2 class="text-base font-semibold">
|
||||
{{ isCreating ? '新建调度策略' : draft.name || '未命名策略' }}
|
||||
</h2>
|
||||
<Badge
|
||||
v-if="!isCreating"
|
||||
variant="outline"
|
||||
>
|
||||
v{{ draft.version }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="draft.is_system_default"
|
||||
variant="secondary"
|
||||
@@ -123,42 +171,50 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="draft.is_system_default ? 'border-primary/50 bg-primary/10 text-primary' : ''"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="draft.is_system_default
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'text-muted-foreground/70 hover:text-foreground'"
|
||||
:aria-label="draft.is_system_default ? '系统默认' : '设为系统默认'"
|
||||
:title="draft.is_system_default ? '系统默认' : '设为系统默认'"
|
||||
@click="draft.is_system_default = !draft.is_system_default"
|
||||
>
|
||||
<Star class="mr-2 h-4 w-4" />
|
||||
{{ draft.is_system_default ? '系统默认' : '设为系统默认' }}
|
||||
<Star class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
|
||||
:disabled="!canSaveDraft"
|
||||
aria-label="保存"
|
||||
title="保存"
|
||||
@click="saveDraft"
|
||||
>
|
||||
<Save
|
||||
class="mr-2 h-4 w-4"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-pulse': saving }"
|
||||
/>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isCreating"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
|
||||
:disabled="deleting"
|
||||
aria-label="删除"
|
||||
title="删除"
|
||||
@click="deleteDraft"
|
||||
>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
删除
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 p-5">
|
||||
<div class="grid gap-3 lg:grid-cols-[240px_minmax(0,1fr)_160px]">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_320px]">
|
||||
<label class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">名称</span>
|
||||
<Input
|
||||
@@ -173,45 +229,35 @@
|
||||
placeholder="例如:默认策略 / 高推理策略 / 号池优先策略"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm">
|
||||
<span>启用策略</span>
|
||||
<Switch v-model="draft.enabled" />
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
维度
|
||||
</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="sortingScope === 'unified'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="setSortingScope('unified')"
|
||||
>
|
||||
统一调度
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="sortingScope === 'per_model'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="setSortingScope('per_model')"
|
||||
>
|
||||
区分模型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border/60 p-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
排序作用范围
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
统一排序对策略内全部模型生效;按模型排序需先挑选模型,再逐个配置排序与调度方式。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 sm:max-w-[320px]">
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="sortingScope === 'unified'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="setSortingScope('unified')"
|
||||
>
|
||||
统一排序
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="sortingScope === 'per_model'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="setSortingScope('per_model')"
|
||||
>
|
||||
按模型排序
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="sortingScope === 'unified'"
|
||||
class="space-y-4"
|
||||
@@ -225,7 +271,7 @@
|
||||
统一作用于策略范围内的全部模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">优先级模式</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
@@ -284,28 +330,20 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-border/60 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium">全局模型</span>
|
||||
<Badge variant="outline">
|
||||
{{ filteredGlobalModels.length }}
|
||||
</Badge>
|
||||
</div>
|
||||
<section v-else>
|
||||
<div class="flex max-h-[560px] flex-col gap-3 overflow-hidden rounded-lg border border-border/60 p-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
v-model="globalModelSearch"
|
||||
placeholder="搜索模型"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
|
||||
<button
|
||||
v-for="filter in modelFilterOptions"
|
||||
v-for="filter in modelFilters"
|
||||
:key="filter.value"
|
||||
type="button"
|
||||
class="h-7 rounded-md px-2 text-xs font-medium transition-colors"
|
||||
class="h-9 rounded-md px-3 font-medium transition-colors"
|
||||
:class="modelFilter === filter.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@@ -314,82 +352,71 @@
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="loadingGlobalModels"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
正在加载全局模型
|
||||
</div>
|
||||
<div
|
||||
v-else-if="globalModelsError"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ globalModelsError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="globalModels.length === 0"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无可选择的全局模型
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredGlobalModels.length === 0"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
未匹配到模型
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-[640px] overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="model in filteredGlobalModels"
|
||||
:key="model.id"
|
||||
type="button"
|
||||
class="mb-1 flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||
:class="activePerModelPolicy?.model === model.name
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'hover:bg-muted/60'"
|
||||
@click="selectGlobalModel(model.name)"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 shrink-0 rounded-full"
|
||||
:class="hasModelPolicy(model.name)
|
||||
? 'bg-primary'
|
||||
: 'bg-muted-foreground/20'"
|
||||
:title="hasModelPolicy(model.name) ? '已配置' : '未配置'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">{{ model.display_name || model.name }}</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ model.name }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 rounded-lg border border-border/60 p-4">
|
||||
<template v-if="activePerModelPolicy">
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0 space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">当前模型</span>
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ globalModelLabel(activePerModelPolicy.model) }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ activePerModelPolicy.model }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
v-if="loadingGlobalModels"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
正在加载模型
|
||||
</div>
|
||||
<div
|
||||
v-else-if="globalModelsError"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ globalModelsError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="modelRows.length === 0"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="row in modelRows"
|
||||
:key="row.name"
|
||||
class="rounded-lg border transition-colors"
|
||||
:class="selectedPerModelName === row.name
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border/60'"
|
||||
>
|
||||
<div class="flex w-full items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-3 text-left text-sm"
|
||||
@click="selectGlobalModel(row.name)"
|
||||
>
|
||||
<span
|
||||
v-if="row.configured"
|
||||
class="h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Plus
|
||||
v-else
|
||||
class="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">{{ row.displayName }}</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ row.name }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<template v-if="selectedPerModelName === row.name && activePerModelPolicy">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
||||
:disabled="copySourceCandidates.length === 0"
|
||||
title="加载其他模型配置"
|
||||
>
|
||||
<Copy class="mr-2 h-4 w-4" />
|
||||
加载
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -410,108 +437,110 @@
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
||||
:disabled="!canSaveCurrentModel"
|
||||
title="保存到草稿"
|
||||
@click="saveCurrentModel"
|
||||
>
|
||||
<Save
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
保存到草稿
|
||||
<Save class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasModelPolicy(activePerModelPolicy.model)"
|
||||
type="button"
|
||||
:variant="canRemoveCurrentModel ? 'destructive' : 'outline'"
|
||||
size="sm"
|
||||
:class="canRemoveCurrentModel ? 'shadow-sm' : 'text-muted-foreground'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="canRemoveCurrentModel ? 'text-muted-foreground/70 hover:text-destructive' : 'text-muted-foreground/30'"
|
||||
:disabled="!canRemoveCurrentModel"
|
||||
:title="canRemoveCurrentModel ? '移除当前模型排序' : '当前有未保存改动,不能移除'"
|
||||
@click="removePerModelPolicy(activePerModelPolicy.model)"
|
||||
>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
移除
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0"
|
||||
@click="selectGlobalModel(row.name)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="selectedPerModelName === row.name ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 space-y-3 rounded-lg border border-border/60 p-4">
|
||||
<div>
|
||||
<div
|
||||
v-if="selectedPerModelName === row.name && activePerModelPolicy"
|
||||
class="border-t border-border/60 p-4"
|
||||
>
|
||||
<div class="mb-4 space-y-3 rounded-lg border border-border/60 p-4">
|
||||
<h3 class="text-sm font-medium">
|
||||
优先级模式与调度策略
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
仅作用于当前选中的模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">优先级模式</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="modelPriorityMode(activePerModelPolicy.model) === 'provider'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateModelPriorityMode(activePerModelPolicy.model, 'provider')"
|
||||
>
|
||||
<Layers class="h-4 w-4" />
|
||||
Provider
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="modelPriorityMode(activePerModelPolicy.model) === 'global_key'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateModelPriorityMode(activePerModelPolicy.model, 'global_key')"
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
Key
|
||||
</button>
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">优先级模式</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="modelPriorityMode(activePerModelPolicy.model) === 'provider'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateModelPriorityMode(activePerModelPolicy.model, 'provider')"
|
||||
>
|
||||
<Layers class="h-4 w-4" />
|
||||
Provider
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="modelPriorityMode(activePerModelPolicy.model) === 'global_key'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateModelPriorityMode(activePerModelPolicy.model, 'global_key')"
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">调度策略</span>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
v-for="mode in schedulingModes"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="modelSchedulingMode(activePerModelPolicy.model) === mode.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateModelSchedulingMode(activePerModelPolicy.model, mode.value)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">调度策略</span>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
v-for="mode in schedulingModes"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="modelSchedulingMode(activePerModelPolicy.model) === mode.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="updateModelSchedulingMode(activePerModelPolicy.model, mode.value)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RoutingPriorityPolicyEditor
|
||||
:config="activeConfigForReading"
|
||||
:model="activePerModelPolicy.model"
|
||||
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
|
||||
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
|
||||
:show-priority-mode="false"
|
||||
:show-scheduling-mode="false"
|
||||
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
|
||||
@update:config="updateEditingConfig"
|
||||
@update:priority-mode="mode => updateModelPriorityMode(activePerModelPolicy.model, mode)"
|
||||
@update:scheduling-mode="mode => updateModelSchedulingMode(activePerModelPolicy.model, mode)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RoutingPriorityPolicyEditor
|
||||
:config="activeConfigForReading"
|
||||
:model="activePerModelPolicy.model"
|
||||
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
|
||||
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
|
||||
:show-priority-mode="false"
|
||||
:show-scheduling-mode="false"
|
||||
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
|
||||
@update:config="updateEditingConfig"
|
||||
@update:priority-mode="mode => updateModelPriorityMode(activePerModelPolicy.model, mode)"
|
||||
@update:scheduling-mode="mode => updateModelSchedulingMode(activePerModelPolicy.model, mode)"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
在左侧添加模型后即可配置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -556,10 +585,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Copy, Key, Layers, Plus, RefreshCw, Save, SlidersHorizontal, Star, Trash2 } from 'lucide-vue-next'
|
||||
import { ChevronDown, Copy, Key, Layers, Plus, Power, Save, Star, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/layout'
|
||||
import { Badge, Button, Card, Input, Switch } from '@/components/ui'
|
||||
import { Badge, Button, Card, Input } from '@/components/ui'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { AlertDialog } from '@/components/common'
|
||||
import {
|
||||
@@ -605,7 +634,12 @@ interface RoutingGroupDraft {
|
||||
}
|
||||
|
||||
type SortingScope = 'unified' | 'per_model'
|
||||
type ModelFilter = 'all' | 'configured' | 'unconfigured'
|
||||
type ModelFilter = 'configured' | 'unconfigured'
|
||||
|
||||
const modelFilters: Array<{ value: ModelFilter; label: string }> = [
|
||||
{ value: 'unconfigured', label: '未配置' },
|
||||
{ value: 'configured', label: '已配置' },
|
||||
]
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
@@ -615,12 +649,6 @@ const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> =
|
||||
{ value: 'fixed_order', label: '固定顺序' },
|
||||
]
|
||||
|
||||
const modelFilterOptions: Array<{ value: ModelFilter; label: string }> = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'configured', label: '已配置' },
|
||||
{ value: 'unconfigured', label: '未配置' },
|
||||
]
|
||||
|
||||
const groups = ref<RoutingGroupRecord[]>([])
|
||||
const selectedGroupId = ref<string | null>(null)
|
||||
const draft = ref<RoutingGroupDraft | null>(null)
|
||||
@@ -629,7 +657,7 @@ const sortingScope = ref<SortingScope>('unified')
|
||||
const selectedPerModelName = ref<string | null>(null)
|
||||
const editingConfig = ref<RoutingGroupConfig | null>(null)
|
||||
const globalModelSearch = ref('')
|
||||
const modelFilter = ref<ModelFilter>('all')
|
||||
const modelFilter = ref<ModelFilter>('unconfigured')
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const loadingGlobalModels = ref(false)
|
||||
const globalModelsError = ref<string | null>(null)
|
||||
@@ -668,22 +696,53 @@ const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
|
||||
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
|
||||
})
|
||||
|
||||
const filteredGlobalModels = computed(() => {
|
||||
interface ModelRow {
|
||||
name: string
|
||||
displayName: string
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
const modelRows = computed<ModelRow[]>(() => {
|
||||
const query = globalModelSearch.value.trim().toLowerCase()
|
||||
const filter = modelFilter.value
|
||||
const models = [...globalModels.value].sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
)
|
||||
return models.filter(model => {
|
||||
if (query
|
||||
&& !model.name.toLowerCase().includes(query)
|
||||
&& !model.display_name?.toLowerCase().includes(query)) {
|
||||
return false
|
||||
}
|
||||
if (filter === 'configured' && !hasModelPolicy(model.name)) return false
|
||||
if (filter === 'unconfigured' && hasModelPolicy(model.name)) return false
|
||||
return true
|
||||
})
|
||||
const seen = new Set<string>()
|
||||
const rows: ModelRow[] = []
|
||||
|
||||
for (const policy of perModelPolicies.value) {
|
||||
const name = policy.model
|
||||
const found = globalModels.value.find(item => item.name === name)
|
||||
rows.push({
|
||||
name,
|
||||
displayName: found?.display_name || name,
|
||||
configured: true,
|
||||
})
|
||||
seen.add(name)
|
||||
}
|
||||
|
||||
for (const model of globalModels.value) {
|
||||
if (seen.has(model.name)) continue
|
||||
rows.push({
|
||||
name: model.name,
|
||||
displayName: model.display_name || model.name,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter(row => {
|
||||
if (modelFilter.value === 'configured' && !row.configured) return false
|
||||
if (modelFilter.value === 'unconfigured' && row.configured) return false
|
||||
if (!query) return true
|
||||
return (
|
||||
row.name.toLowerCase().includes(query)
|
||||
|| row.displayName.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.configured !== right.configured) {
|
||||
return left.configured ? -1 : 1
|
||||
}
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
})
|
||||
|
||||
function normalizeRecord(group: RoutingGroupRecord): RoutingGroupRecord {
|
||||
@@ -730,13 +789,32 @@ function selectGroup(group: RoutingGroupRecord): void {
|
||||
resetEditingConfig()
|
||||
}
|
||||
|
||||
function displayGroupEnabled(group: RoutingGroupRecord): boolean {
|
||||
if (!isCreating.value && group.id === selectedGroupId.value && draft.value) {
|
||||
return draft.value.enabled
|
||||
}
|
||||
return group.enabled
|
||||
}
|
||||
|
||||
function setDraftEnabled(value: boolean): void {
|
||||
if (!draft.value) return
|
||||
draft.value.enabled = value
|
||||
}
|
||||
|
||||
function updateGroupEnabled(group: RoutingGroupRecord, value: boolean): void {
|
||||
if (isCreating.value || group.id !== selectedGroupId.value || !draft.value) {
|
||||
selectGroup(group)
|
||||
}
|
||||
setDraftEnabled(value)
|
||||
}
|
||||
|
||||
function startCreate(): void {
|
||||
isCreating.value = true
|
||||
selectedGroupId.value = null
|
||||
draft.value = {
|
||||
name: '新调度策略',
|
||||
description: '',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
is_system_default: groups.value.length === 0,
|
||||
config_json: createEmptyRoutingGroupConfig(),
|
||||
version: 1,
|
||||
@@ -803,7 +881,6 @@ const canRemoveCurrentModel = computed(() => {
|
||||
&& currentModelPersisted.value
|
||||
&& !saving.value
|
||||
&& !editingDirty.value
|
||||
&& !draftDirty.value
|
||||
})
|
||||
|
||||
function syncEditorStateFromConfig(config: RoutingGroupConfig): void {
|
||||
@@ -863,7 +940,7 @@ function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
|
||||
|
||||
function removePerModelPolicy(model: string): void {
|
||||
if (!draft.value) return
|
||||
if (perModelEditingActive.value && (editingDirty.value || draftDirty.value)) {
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
showError('请先保存当前改动后再移除模型')
|
||||
return
|
||||
}
|
||||
@@ -873,18 +950,27 @@ function removePerModelPolicy(model: string): void {
|
||||
if (selectedPerModelName.value === model) {
|
||||
selectedPerModelName.value = null
|
||||
}
|
||||
modelFilter.value = 'unconfigured'
|
||||
updateDraftConfig(next)
|
||||
resetEditingConfig()
|
||||
}
|
||||
|
||||
function selectGlobalModel(model: string): void {
|
||||
if (!model) return
|
||||
if (model === selectedPerModelName.value) return
|
||||
if (model === selectedPerModelName.value) {
|
||||
resetEditingConfig()
|
||||
selectedPerModelName.value = null
|
||||
return
|
||||
}
|
||||
const shouldAddModel = !hasModelPolicy(model)
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
switchModelTarget.value = model
|
||||
switchModelDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
if (shouldAddModel) {
|
||||
resetEditingConfig()
|
||||
}
|
||||
selectedPerModelName.value = model
|
||||
}
|
||||
|
||||
@@ -1013,11 +1099,6 @@ function replaceGroup(group: RoutingGroupRecord): void {
|
||||
selectGroup(normalized)
|
||||
}
|
||||
|
||||
function refreshPage(): void {
|
||||
void fetchGroups()
|
||||
void loadGlobalModels()
|
||||
}
|
||||
|
||||
async function fetchGroups(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -1106,6 +1187,7 @@ function saveCurrentModel(): void {
|
||||
next = { ...next, allowed_models: [...next.allowed_models, model] }
|
||||
}
|
||||
updateDraftConfig(next)
|
||||
modelFilter.value = 'configured'
|
||||
resetEditingConfig()
|
||||
success('当前模型配置已保存到草稿,点击外层保存后生效')
|
||||
}
|
||||
|
||||
@@ -33,375 +33,375 @@
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<CloudUpload class="w-4.5 h-4.5" />
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<CloudUpload class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-medium">
|
||||
自动备份
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-medium">
|
||||
自动备份
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="backup.config.value.enabled"
|
||||
@update:model-value="backup.config.value.enabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label
|
||||
for="backup-scope"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
备份范围
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.scope"
|
||||
@update:model-value="setScope"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-scope"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="config">
|
||||
配置数据
|
||||
</SelectItem>
|
||||
<SelectItem value="users">
|
||||
用户数据
|
||||
</SelectItem>
|
||||
<SelectItem value="data">
|
||||
完整备份
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-bucket"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Bucket
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-bucket"
|
||||
:model-value="backup.config.value.bucket"
|
||||
class="mt-1"
|
||||
placeholder="aether-backups"
|
||||
@update:model-value="backup.config.value.bucket = String($event)"
|
||||
<Switch
|
||||
:model-value="backup.config.value.enabled"
|
||||
@update:model-value="backup.config.value.enabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-endpoint"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Endpoint
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-endpoint"
|
||||
:model-value="backup.config.value.endpoint"
|
||||
class="mt-1"
|
||||
placeholder="https://s3.example.com"
|
||||
@update:model-value="backup.config.value.endpoint = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-access-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Access Key ID
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-access-key"
|
||||
:model-value="backup.config.value.accessKeyId"
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
@update:model-value="backup.config.value.accessKeyId = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label
|
||||
for="backup-secret-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Secret Access Key
|
||||
</Label>
|
||||
<button
|
||||
v-if="backup.config.value.secretAccessKeyIsSet"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="backup.saving.value"
|
||||
@click="backup.clearS3SecretAccessKey"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative mt-1">
|
||||
<Input
|
||||
id="backup-secret-key"
|
||||
:model-value="backup.config.value.secretAccessKey"
|
||||
masked
|
||||
disable-autofill
|
||||
autocomplete="new-password"
|
||||
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
|
||||
@update:model-value="backup.config.value.secretAccessKey = String($event)"
|
||||
/>
|
||||
<KeyRound
|
||||
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
|
||||
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-retention"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
最多保留备份数
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-retention"
|
||||
:model-value="backup.config.value.retentionCount"
|
||||
type="number"
|
||||
min="1"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('retentionCount', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
|
||||
<div>
|
||||
<Label
|
||||
for="backup-interval"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
周期间隔
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-interval"
|
||||
:model-value="backup.config.value.scheduleInterval"
|
||||
type="number"
|
||||
min="1"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleInterval', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-unit"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
周期单位
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.scheduleUnit"
|
||||
@update:model-value="setScheduleUnit"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-unit"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hours">
|
||||
小时
|
||||
</SelectItem>
|
||||
<SelectItem value="days">
|
||||
天
|
||||
</SelectItem>
|
||||
<SelectItem value="weeks">
|
||||
周
|
||||
</SelectItem>
|
||||
<SelectItem value="months">
|
||||
月
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="px-0"
|
||||
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
|
||||
>
|
||||
<ChevronDown
|
||||
v-if="backup.advancedOpen.value"
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
/>
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
/>
|
||||
高级选项
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="backup.advancedOpen.value"
|
||||
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label
|
||||
for="backup-region"
|
||||
for="backup-scope"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Region
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-region"
|
||||
:model-value="backup.config.value.region"
|
||||
class="mt-1"
|
||||
placeholder="auto"
|
||||
@update:model-value="backup.config.value.region = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-prefix"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Prefix
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-prefix"
|
||||
:model-value="backup.config.value.prefix"
|
||||
class="mt-1"
|
||||
placeholder="aether/backups/"
|
||||
@update:model-value="backup.config.value.prefix = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-hour"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
执行小时
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-hour"
|
||||
:model-value="backup.config.value.scheduleHour"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleHour', $event, 0)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-minute"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
执行分钟
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-minute"
|
||||
:model-value="backup.config.value.scheduleMinute"
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleMinute', $event, 0)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
|
||||
<Label
|
||||
for="backup-weekday"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
星期
|
||||
备份范围
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="String(backup.config.value.scheduleWeekday)"
|
||||
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
|
||||
:model-value="backup.config.value.scope"
|
||||
@update:model-value="setScope"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-weekday"
|
||||
id="backup-scope"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in weekdays"
|
||||
:key="item.value"
|
||||
:value="String(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<SelectItem value="config">
|
||||
配置数据
|
||||
</SelectItem>
|
||||
<SelectItem value="users">
|
||||
用户数据
|
||||
</SelectItem>
|
||||
<SelectItem value="data">
|
||||
完整备份
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="backup.config.value.scheduleUnit === 'months'">
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-month-day"
|
||||
for="backup-bucket"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
每月日期
|
||||
Bucket
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-month-day"
|
||||
:model-value="backup.config.value.scheduleMonthDay"
|
||||
id="backup-bucket"
|
||||
:model-value="backup.config.value.bucket"
|
||||
class="mt-1"
|
||||
placeholder="aether-backups"
|
||||
@update:model-value="backup.config.value.bucket = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-endpoint"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Endpoint
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-endpoint"
|
||||
:model-value="backup.config.value.endpoint"
|
||||
class="mt-1"
|
||||
placeholder="https://s3.example.com"
|
||||
@update:model-value="backup.config.value.endpoint = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-access-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Access Key ID
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-access-key"
|
||||
:model-value="backup.config.value.accessKeyId"
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
@update:model-value="backup.config.value.accessKeyId = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label
|
||||
for="backup-secret-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Secret Access Key
|
||||
</Label>
|
||||
<button
|
||||
v-if="backup.config.value.secretAccessKeyIsSet"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="backup.saving.value"
|
||||
@click="backup.clearS3SecretAccessKey"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative mt-1">
|
||||
<Input
|
||||
id="backup-secret-key"
|
||||
:model-value="backup.config.value.secretAccessKey"
|
||||
masked
|
||||
disable-autofill
|
||||
autocomplete="new-password"
|
||||
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
|
||||
@update:model-value="backup.config.value.secretAccessKey = String($event)"
|
||||
/>
|
||||
<KeyRound
|
||||
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
|
||||
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-retention"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
最多保留备份数
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-retention"
|
||||
:model-value="backup.config.value.retentionCount"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
|
||||
@update:model-value="setNumber('retentionCount', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<span class="text-sm font-medium">Path Style</span>
|
||||
<Switch
|
||||
:model-value="backup.config.value.pathStyle"
|
||||
@update:model-value="backup.config.value.pathStyle = $event"
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
|
||||
<div>
|
||||
<Label
|
||||
for="backup-interval"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
周期间隔
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-interval"
|
||||
:model-value="backup.config.value.scheduleInterval"
|
||||
type="number"
|
||||
min="1"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleInterval', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-compression"
|
||||
for="backup-unit"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
压缩格式
|
||||
周期单位
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.compression"
|
||||
@update:model-value="backup.config.value.compression = $event"
|
||||
:model-value="backup.config.value.scheduleUnit"
|
||||
@update:model-value="setScheduleUnit"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-compression"
|
||||
id="backup-unit"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zstd">
|
||||
zstd
|
||||
<SelectItem value="hours">
|
||||
小时
|
||||
</SelectItem>
|
||||
<SelectItem value="days">
|
||||
天
|
||||
</SelectItem>
|
||||
<SelectItem value="weeks">
|
||||
周
|
||||
</SelectItem>
|
||||
<SelectItem value="months">
|
||||
月
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="px-0"
|
||||
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
|
||||
>
|
||||
<ChevronDown
|
||||
v-if="backup.advancedOpen.value"
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
/>
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
/>
|
||||
高级选项
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="backup.advancedOpen.value"
|
||||
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-region"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Region
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-region"
|
||||
:model-value="backup.config.value.region"
|
||||
class="mt-1"
|
||||
placeholder="auto"
|
||||
@update:model-value="backup.config.value.region = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-prefix"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Prefix
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-prefix"
|
||||
:model-value="backup.config.value.prefix"
|
||||
class="mt-1"
|
||||
placeholder="aether/backups/"
|
||||
@update:model-value="backup.config.value.prefix = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-hour"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
执行小时
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-hour"
|
||||
:model-value="backup.config.value.scheduleHour"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleHour', $event, 0)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-minute"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
执行分钟
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-minute"
|
||||
:model-value="backup.config.value.scheduleMinute"
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleMinute', $event, 0)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
|
||||
<Label
|
||||
for="backup-weekday"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
星期
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="String(backup.config.value.scheduleWeekday)"
|
||||
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-weekday"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in weekdays"
|
||||
:key="item.value"
|
||||
:value="String(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="backup.config.value.scheduleUnit === 'months'">
|
||||
<Label
|
||||
for="backup-month-day"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
每月日期
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-month-day"
|
||||
:model-value="backup.config.value.scheduleMonthDay"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<span class="text-sm font-medium">Path Style</span>
|
||||
<Switch
|
||||
:model-value="backup.config.value.pathStyle"
|
||||
@update:model-value="backup.config.value.pathStyle = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-compression"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
压缩格式
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.compression"
|
||||
@update:model-value="backup.config.value.compression = $event"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-compression"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zstd">
|
||||
zstd
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</PageContainer>
|
||||
|
||||
@@ -111,9 +111,9 @@
|
||||
>
|
||||
{{ apiKey.name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">
|
||||
创建于 {{ formatDate(apiKey.created_at) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">
|
||||
创建于 {{ formatDate(apiKey.created_at) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
IP 限制:{{ formatIpRules(apiKey.ip_rules) }}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user