feat: improve routing policy diagnostics

This commit is contained in:
fawney19
2026-05-28 12:11:43 +08:00
parent 14ad6e9b75
commit 93d3de1644
23 changed files with 2015 additions and 1087 deletions
@@ -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,
+194 -194
View File
@@ -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="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</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="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</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"
+6 -6
View File
@@ -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">
+395 -313
View File
@@ -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>
+3 -3
View File
@@ -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>