From 298357f97cd47880af406a6442388ec6c75618d6 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sun, 5 Jul 2026 22:15:52 +0800 Subject: [PATCH] feat: refine admin console ergonomics Co-authored-by: Codex --- backend/src/config/schema.rs | 2 +- backend/src/http/middleware/request_id.rs | 3 +- backend/src/upstream/accounts/quota/mod.rs | 95 ++++--- backend/src/upstream/protocol/events.rs | 9 +- backend/tests/http/trace_middleware/mod.rs | 31 +++ .../tests/proxy/dispatch/chat_upstream/mod.rs | 24 +- .../chat_upstream/responses_websocket.rs | 43 +++- .../upstream/accounts/account_repository.rs | 44 ++++ .../tests/upstream/accounts/quota_refresh.rs | 47 ++++ .../upstream/protocol/usage_rate_limits.rs | 20 ++ deploy/README.md | 4 +- deploy/config.example.yaml | 5 +- frontend/index.html | 5 +- frontend/src/components/base/BaseModal.vue | 3 +- .../src/components/base/BaseSegmented.vue | 5 +- .../src/components/base/BaseTable/columns.ts | 7 +- .../src/layout/components/AppAboutModal.vue | 110 ++++++++ frontend/src/layout/components/AppSidebar.vue | 121 ++++++--- .../components/FloatingSidebarToggle.vue | 242 ++++++++++++++++++ .../layout/components/SystemUpdateModal.vue | 2 +- frontend/src/layout/index.vue | 70 ++++- frontend/src/views/accounts/constants.ts | 21 +- frontend/src/views/accounts/index.vue | 3 +- .../api-keys/components/ApiKeyUseModal.vue | 145 +++-------- frontend/src/views/api-keys/constants.ts | 10 +- frontend/src/views/api-keys/index.vue | 2 +- .../dashboard/composables/useDashboard.ts | 2 +- frontend/src/views/login/index.vue | 2 +- .../components/UsageRecordDetailModal.vue | 4 +- 29 files changed, 850 insertions(+), 231 deletions(-) create mode 100644 frontend/src/layout/components/AppAboutModal.vue create mode 100644 frontend/src/layout/components/FloatingSidebarToggle.vue diff --git a/backend/src/config/schema.rs b/backend/src/config/schema.rs index 0aea910b9..bb8ea7837 100644 --- a/backend/src/config/schema.rs +++ b/backend/src/config/schema.rs @@ -60,7 +60,7 @@ pub struct ServerConfig { pub host: String, /// 监听端口。 pub port: u16, - /// 可信反向代理 IP 或 CIDR。只有这些 peer 的转发 IP 头会被采信。 + /// 可信反向代理 IP 或 CIDR。非空时只有这些 peer 的转发 IP 头会被采信。 #[serde(default)] pub trusted_proxies: Vec, } diff --git a/backend/src/http/middleware/request_id.rs b/backend/src/http/middleware/request_id.rs index a69aec806..6a16c901b 100644 --- a/backend/src/http/middleware/request_id.rs +++ b/backend/src/http/middleware/request_id.rs @@ -214,8 +214,9 @@ fn forwarded_for_public_ip(headers: &HeaderMap) -> Option { fn trusted_forwarded_client_ip(headers: &HeaderMap) -> Option { header_string(headers, CF_CONNECTING_IP_HEADER) - .or_else(|| first_forwarded_for_value(headers)) .or_else(|| header_string(headers, REAL_IP_HEADER)) + .or_else(|| forwarded_for_public_ip(headers)) + .or_else(|| first_forwarded_for_value(headers)) } fn header_string(headers: &HeaderMap, name: &str) -> Option { diff --git a/backend/src/upstream/accounts/quota/mod.rs b/backend/src/upstream/accounts/quota/mod.rs index 90beede39..c81096d83 100644 --- a/backend/src/upstream/accounts/quota/mod.rs +++ b/backend/src/upstream/accounts/quota/mod.rs @@ -10,7 +10,7 @@ const MONTH_WINDOW_SECONDS: u64 = 2_592_000; /// 判断配额快照是否已经触顶。 pub fn quota_snapshot_limit_reached(quota: &Value) -> bool { - core_snapshot_blocked(quota) + snapshot_blocked(quota) || spend_control_limit_reached(quota) || monthly_limit_reached(quota) || credits_overage_limit_reached(quota) @@ -20,8 +20,10 @@ pub fn quota_snapshot_limit_reached(quota: &Value) -> bool { pub fn quota_snapshot_reset_at(quota: &Value) -> Option> { let reset_at = if monthly_limit_reached(quota) || spend_control_limit_reached(quota) { monthly_limit_reset_at(quota) - .or_else(|| blocking_core_bucket(quota).and_then(bucket_reset_at)) + .or_else(|| blocking_bucket(quota).and_then(bucket_reset_at)) .or_else(|| core_bucket_with_reset_at(quota).and_then(bucket_reset_at)) + } else if let Some(reset_at) = blocking_bucket(quota).and_then(bucket_reset_at) { + Some(reset_at) } else { core_bucket_with_reset_at(quota) .and_then(bucket_reset_at) @@ -35,9 +37,14 @@ pub fn quota_snapshot_limit_window_seconds(quota: &Value) -> Option { if monthly_limit_reached(quota) || spend_control_limit_reached(quota) { return monthly_limit_window_seconds(quota).or(Some(MONTH_WINDOW_SECONDS)); } - core_bucket_with_window_minutes(quota) + blocking_bucket(quota) .and_then(bucket_window_minutes) .and_then(|minutes| minutes.checked_mul(60)) + .or_else(|| { + core_bucket_with_window_minutes(quota) + .and_then(bucket_window_minutes) + .and_then(|minutes| minutes.checked_mul(60)) + }) .or_else(|| monthly_limit_window_seconds(quota)) } @@ -266,41 +273,66 @@ fn monthly_limit_reached(quota: &Value) -> bool { .pointer("/monthly_limit/limit_reached") .and_then(Value::as_bool) .unwrap_or(false) + || quota + .pointer("/monthly_limit/used_percent") + .and_then(number_value) + .is_some_and(|used_percent| used_percent >= 100.0) } -fn core_snapshot_blocked(quota: &Value) -> bool { +fn quota_bucket_limit_reached(bucket: &Value) -> bool { + !bucket.is_null() + && (bucket + .get("limit_reached") + .and_then(Value::as_bool) + .unwrap_or(false) + || bucket + .get("used_percent") + .and_then(number_value) + .is_some_and(|used_percent| used_percent >= 100.0)) +} + +fn quota_snapshot_blocked(snapshot: &Value) -> bool { + snapshot + .get("blocked") + .and_then(Value::as_bool) + .unwrap_or(false) + || snapshot + .get("allowed") + .and_then(Value::as_bool) + .is_some_and(|allowed| !allowed) + || snapshot_buckets(snapshot).any(quota_bucket_limit_reached) +} + +fn snapshot_blocked(quota: &Value) -> bool { quota .get("snapshots") .and_then(Value::as_array) .into_iter() .flatten() - .any(|snapshot| { - snapshot.get("source").and_then(Value::as_str) == Some("core") - && snapshot - .get("blocked") - .and_then(Value::as_bool) - .unwrap_or(false) + .any(quota_snapshot_blocked) +} + +fn blocking_bucket(quota: &Value) -> Option<&Value> { + let snapshots = quota.get("snapshots").and_then(Value::as_array)?; + snapshots + .iter() + .filter(|snapshot| quota_snapshot_blocked(snapshot)) + .flat_map(snapshot_buckets) + .find(|bucket| quota_bucket_limit_reached(bucket)) + .or_else(|| { + snapshots + .iter() + .filter(|snapshot| quota_snapshot_blocked(snapshot)) + .flat_map(snapshot_buckets) + .find(|bucket| bucket_reset_at(bucket).is_some()) }) } -fn blocking_core_bucket(quota: &Value) -> Option<&Value> { - quota - .get("snapshots") - .and_then(Value::as_array)? - .iter() - .filter(|snapshot| snapshot.get("source").and_then(Value::as_str) == Some("core")) - .flat_map(|snapshot| { - ["primary", "secondary"] - .into_iter() - .filter_map(|key| snapshot.get(key)) - }) - .find(|bucket| { - !bucket.is_null() - && bucket - .get("limit_reached") - .and_then(Value::as_bool) - .unwrap_or(false) - }) +fn snapshot_buckets(snapshot: &Value) -> impl Iterator { + ["primary", "secondary"] + .into_iter() + .filter_map(|key| snapshot.get(key)) + .filter(|bucket| !bucket.is_null()) } fn core_bucket_with_reset_at(quota: &Value) -> Option<&Value> { @@ -318,12 +350,7 @@ fn core_buckets<'a>(quota: &'a Value) -> impl Iterator + 'a { .into_iter() .flatten() .filter(|snapshot| snapshot.get("source").and_then(Value::as_str) == Some("core")) - .flat_map(|snapshot| { - ["primary", "secondary"] - .into_iter() - .filter_map(|key| snapshot.get(key)) - }) - .filter(|bucket| !bucket.is_null()) + .flat_map(snapshot_buckets) } fn bucket_reset_at(bucket: &Value) -> Option { diff --git a/backend/src/upstream/protocol/events.rs b/backend/src/upstream/protocol/events.rs index 1f5dd14df..96731a2f4 100644 --- a/backend/src/upstream/protocol/events.rs +++ b/backend/src/upstream/protocol/events.rs @@ -540,11 +540,10 @@ fn quota_snapshot_from_windows( if primary.is_none() && secondary.is_none() { return None; } - let blocked = limit_reached.unwrap_or_else(|| { - allowed.is_some_and(|allowed| !allowed) - || primary.is_some_and(|window| window.used_percent >= 100.0) - || secondary.is_some_and(|window| window.used_percent >= 100.0) - }); + let blocked = limit_reached.unwrap_or(false) + || allowed.is_some_and(|allowed| !allowed) + || primary.is_some_and(|window| window.used_percent >= 100.0) + || secondary.is_some_and(|window| window.used_percent >= 100.0); Some(json!({ "source": source, "limit_name": limit_name, diff --git a/backend/tests/http/trace_middleware/mod.rs b/backend/tests/http/trace_middleware/mod.rs index 84bb80a2a..eeeb519cf 100644 --- a/backend/tests/http/trace_middleware/mod.rs +++ b/backend/tests/http/trace_middleware/mod.rs @@ -384,6 +384,37 @@ async fn request_context_should_accept_real_ip_from_trusted_proxy_when_forwarded assert_eq!(std::str::from_utf8(&bytes).unwrap(), "198.51.100.23"); } +#[tokio::test] +async fn request_context_should_prefer_real_ip_over_forwarded_for_from_trusted_proxy() { + let trusted_proxies = + TrustedProxyConfig::from_entries(&["127.0.0.1".to_string()]).expect("trusted proxy"); + let app = Router::new() + .route( + "/ip", + get(|Extension(client_ip): Extension| async move { + client_ip.as_str().to_string() + }), + ) + .layer(from_fn_with_state( + trusted_proxies, + attach_request_id_with_proxy_config, + )); + let mut request = Request::builder() + .uri("/ip") + .header("x-forwarded-for", "198.51.100.99, 203.0.113.42") + .header("x-real-ip", "203.0.113.42") + .body(Body::empty()) + .expect("request should build"); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 61234)))); + + let response = app.oneshot(request).await.expect("response"); + let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); + + assert_eq!(std::str::from_utf8(&bytes).unwrap(), "203.0.113.42"); +} + #[tokio::test] async fn request_context_should_ignore_forwarded_ip_from_untrusted_peer() { let trusted_proxies = diff --git a/backend/tests/proxy/dispatch/chat_upstream/mod.rs b/backend/tests/proxy/dispatch/chat_upstream/mod.rs index 4d3d6bee5..ff23ab248 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/mod.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/mod.rs @@ -29,7 +29,8 @@ use codex_proxy_rs::{ runtime::services::{BackgroundTaskStores, Services, UsageRecordOptions}, runtime::state::AppState, upstream::accounts::{ - cookies::SqliteCookieStore, store::SqliteAccountStore, token_refresh::RefreshLeaseStore, + cookies::SqliteCookieStore, model::AccountStatus, store::SqliteAccountStore, + token_refresh::RefreshLeaseStore, }, upstream::fingerprint::FingerprintRepository, }; @@ -549,6 +550,19 @@ async fn test_app_with_two_accounts_and_affinity_status( async fn test_app_with_two_accounts( base_url: String, ) -> (axum::Router, String, SqlitePool, tempfile::TempDir) { + let (app, _state, api_key, pool, dir) = test_app_with_two_accounts_and_state(base_url).await; + (app, api_key, pool, dir) +} + +async fn test_app_with_two_accounts_and_state( + base_url: String, +) -> ( + axum::Router, + AppState, + String, + SqlitePool, + tempfile::TempDir, +) { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("openai-responses-fallback.sqlite"); let url = format!("sqlite://{}", db.display()); @@ -574,7 +588,13 @@ async fn test_app_with_two_accounts( .restore_from_repository() .await .expect("account pool should restore"); - (router::router().with_state(state), api_key, pool, dir) + ( + router::router().with_state(state.clone()), + state, + api_key, + pool, + dir, + ) } async fn seed_openai_admin_session(pool: &SqlitePool, session_id: &str) { diff --git a/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs b/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs index 61ebbdc1e..e8c36ff54 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/responses_websocket.rs @@ -781,15 +781,37 @@ async fn responses_websocket_should_implicitly_resume_full_history_with_reasonin #[tokio::test] async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switching_accounts() { + responses_websocket_should_strip_implicit_affinity_when_switching_accounts( + AccountStatus::Disabled, + ) + .await; +} + +#[tokio::test] +async fn responses_websocket_should_strip_implicit_banned_affinity_when_switching_accounts() { + responses_websocket_should_strip_implicit_affinity_when_switching_accounts( + AccountStatus::Banned, + ) + .await; +} + +async fn responses_websocket_should_strip_implicit_affinity_when_switching_accounts( + status: AccountStatus, +) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); + let status_name = status.as_str(); + let first_response_id = format!("resp_implicit_{status_name}_first"); + let second_response_id = format!("resp_implicit_{status_name}_second"); + let upstream_first_response_id = first_response_id.clone(); + let upstream_second_response_id = second_response_id.clone(); let upstream = tokio::spawn(async move { let (first_stream, _) = listener.accept().await.unwrap(); let mut first_websocket = accept_websocket_with_authorization(first_stream, "Bearer access-primary").await; let first_payload = send_websocket_response_and_capture_payload( &mut first_websocket, - websocket_completed_response("resp_implicit_ban_first", 4, 1), + websocket_completed_response(&upstream_first_response_id, 4, 1), ) .await; first_websocket.close(None).await.unwrap(); @@ -804,7 +826,7 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch .unwrap(); let second_payload = send_websocket_response_and_capture_payload( &mut second_websocket, - websocket_completed_response("resp_implicit_ban_second", 3, 1), + websocket_completed_response(&upstream_second_response_id, 3, 1), ) .await; second_websocket.close(None).await.unwrap(); @@ -812,8 +834,7 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch (first_payload, second_payload, captured_second_headers) }); - let (app, api_key, pool, _dir) = test_app_with_two_accounts(base_url).await; - seed_openai_admin_session(&pool, "session_status_cycle").await; + let (app, state, api_key, pool, _dir) = test_app_with_two_accounts_and_state(base_url).await; let first_response = app .clone() @@ -831,9 +852,15 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch assert_eq!(first_response.status(), StatusCode::OK); assert!(response_text(first_response) .await - .contains("\"id\":\"resp_implicit_ban_first\"")); + .contains(&format!("\"id\":\"{first_response_id}\""))); - update_admin_account_status(&app, "acct_primary", "disabled").await; + assert!( + state + .services + .account_pool + .set_status("acct_primary", status) + .await + ); let second_response = app .oneshot(responses_json_request( @@ -854,11 +881,11 @@ async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switch assert_eq!(second_response.status(), StatusCode::OK); assert!(response_text(second_response) .await - .contains("\"id\":\"resp_implicit_ban_second\"")); + .contains(&format!("\"id\":\"{second_response_id}\""))); let (first_payload, second_payload, second_headers) = upstream.await.unwrap(); let affinity_count: (i64,) = sqlx::query_as("select count(*) from session_affinities where response_id = ?") - .bind("resp_implicit_ban_first") + .bind(&first_response_id) .fetch_one(&pool) .await .unwrap(); diff --git a/backend/tests/upstream/accounts/account_repository.rs b/backend/tests/upstream/accounts/account_repository.rs index 89d72e44a..aad362e0a 100644 --- a/backend/tests/upstream/accounts/account_repository.rs +++ b/backend/tests/upstream/accounts/account_repository.rs @@ -3,10 +3,12 @@ use codex_proxy_rs::{ infra::database::connect_sqlite, upstream::accounts::{ model::{AccountStatus, AccountUsageDelta}, + quota::{quota_snapshot_limit_reached, quota_snapshot_reset_at}, store::{AccountClaimsUpdate, AccountStore, NewAccount, SqliteAccountStore}, }, }; use secrecy::{ExposeSecret, SecretString}; +use serde_json::json; #[tokio::test] async fn account_repository_should_store_plain_tokens_and_load_secret_wrappers() { @@ -518,6 +520,48 @@ async fn account_repository_should_map_quota_snapshot_to_account_status() { assert!(restored_status.2.is_none()); } +#[tokio::test] +async fn account_repository_should_mark_quota_exhausted_for_any_exhausted_window() { + let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 47).await; + let repo = SqliteAccountStore::new(pool.clone()); + seed_repo_account(&pool, "acct_a", "2026-06-11T00:00:00Z").await; + let quota = json!({ + "plan_type": "plus", + "snapshots": [{ + "source": "additional", + "blocked": false, + "allowed": true, + "primary": { + "used_percent": 100, + "remaining_percent": 0, + "reset_at": 1_893_456_300, + "window_minutes": 300, + "limit_reached": false + }, + "secondary": null + }] + }); + let limit_reached = quota_snapshot_limit_reached("a); + let cooldown_until = quota_snapshot_reset_at("a); + + let updated = repo + .apply_quota_snapshot("acct_a", "a.to_string(), limit_reached, cooldown_until) + .await + .unwrap(); + let status: (String, i64, Option) = sqlx::query_as( + "select status, quota_limit_reached, quota_cooldown_until from accounts where id = ?", + ) + .bind("acct_a") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!( + (updated, status.0, status.1, status.2.is_some()), + (true, "quota_exhausted".to_string(), 1, true) + ); +} + #[tokio::test] async fn account_repository_runtime_state_sync_should_not_clear_newer_future_quota_cooldown() { let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 42).await; diff --git a/backend/tests/upstream/accounts/quota_refresh.rs b/backend/tests/upstream/accounts/quota_refresh.rs index 5980c80de..a7f95dd9a 100644 --- a/backend/tests/upstream/accounts/quota_refresh.rs +++ b/backend/tests/upstream/accounts/quota_refresh.rs @@ -227,6 +227,53 @@ fn quota_snapshot_limit_reached_should_block_explicit_credit_overage_limit() { assert!(quota_snapshot_limit_reached("a)); } +#[test] +fn quota_snapshot_limit_reached_should_block_any_exhausted_window() { + let quota = json!({ + "snapshots": [{ + "source": "additional", + "blocked": false, + "allowed": true, + "primary": { + "used_percent": 100, + "remaining_percent": 0, + "reset_at": 1_893_456_300, + "window_minutes": 300, + "limit_reached": false + }, + "secondary": null + }] + }); + + assert!(quota_snapshot_limit_reached("a)); +} + +#[test] +fn quota_snapshot_window_should_follow_blocking_window() { + let quota = json!({ + "snapshots": [{ + "source": "additional", + "blocked": false, + "primary": { + "used_percent": 100, + "remaining_percent": 0, + "reset_at": 1_893_456_300, + "window_minutes": 10_080, + "limit_reached": false + }, + "secondary": null + }] + }); + + assert_eq!( + ( + quota_snapshot_reset_at("a).map(|reset_at| reset_at.timestamp()), + quota_snapshot_limit_window_seconds("a), + ), + (Some(1_893_456_300), Some(604_800)) + ); +} + #[tokio::test] async fn quota_refresh_service_should_send_usage_cookie_when_cookie_store_is_configured() { let server = MockServer::start().await; diff --git a/backend/tests/upstream/protocol/usage_rate_limits.rs b/backend/tests/upstream/protocol/usage_rate_limits.rs index 3c345623b..b0e123ff1 100644 --- a/backend/tests/upstream/protocol/usage_rate_limits.rs +++ b/backend/tests/upstream/protocol/usage_rate_limits.rs @@ -344,6 +344,26 @@ fn rate_limit_quota_should_preserve_existing_monthly_and_credits_when_passive_da assert_eq!(quota["credits"]["balance"], 12); } +#[test] +fn rate_limit_quota_should_block_when_window_is_exhausted_even_if_flag_is_false() { + let event = json!({ + "type": "codex.rate_limits", + "rate_limits": { + "limit_reached": false, + "primary": { + "used_percent": 100, + "window_minutes": 300, + "reset_at": 1893456300 + } + } + }); + let parsed = parse_rate_limits_event(&event).expect("event should parse"); + + let quota = rate_limit_quota(&parsed, Some("plus"), None); + + assert_eq!(quota["snapshots"][0]["blocked"], true); +} + #[tokio::test] async fn refresh_scheduler_should_refresh_before_expiry_and_preserve_refresh_token() { use async_trait::async_trait; diff --git a/deploy/README.md b/deploy/README.md index 7b0c8d698..ce7668943 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,7 +18,9 @@ cp deploy/config.example.yaml .runtime/config.yaml - `.runtime/logs` -> `/app/logs` 复制后必须设置 `admin.default_password`,空值或常见弱口令会被后端拒绝启动。 -如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,只在该代理会覆盖或清理 `X-Forwarded-For` / `X-Real-IP` / `CF-Connecting-IP` 时,把代理的 IP 或 CIDR 写入 `server.trusted_proxies`;默认空列表会剥离这些客户端可伪造的头并使用 TCP peer IP。 +如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,建议把后端实际看到的代理 peer IP 或 CIDR 写入 `server.trusted_proxies`。Docker 端口映射经宿主反代访问时,这个 peer 通常是 Compose 网络网关,例如 `172.18.0.1/32`,不是宿主 `127.0.0.1/32`。 + +`server.trusted_proxies` 非空时,只有这些 peer 的 `CF-Connecting-IP` / `X-Real-IP` / `X-Forwarded-For` 会被采信;为空时进入兼容自动模式,按同一组头解析真实 IP。 构建并启动: diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 5a949f029..a18899e38 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -3,7 +3,10 @@ server: host: '0.0.0.0' # 容器内服务端口,需与 docker-compose.yml 的 target port 一致。 port: 8080 - # 可信反向代理 IP 或 CIDR。为空时会忽略并剥离客户端传入的转发 IP 头。 + # 可信反向代理 IP 或 CIDR。非空时只有这些 peer 的转发 IP 头会被采信。 + # Docker 端口映射经宿主反代访问时,后端看到的 peer 通常是 Compose 网络网关 + # (例如 172.18.0.1),不是宿主 127.0.0.1。 + # 为空时进入兼容自动模式,按 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 解析。 trusted_proxies: [] api: diff --git a/frontend/index.html b/frontend/index.html index 4b99e07db..e52563499 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,10 @@ - + Codex Proxy RS diff --git a/frontend/src/components/base/BaseModal.vue b/frontend/src/components/base/BaseModal.vue index 5a79ed1b9..d431e76bd 100644 --- a/frontend/src/components/base/BaseModal.vue +++ b/frontend/src/components/base/BaseModal.vue @@ -16,6 +16,7 @@ const props = defineProps<{ scrollable?: boolean bodyMaxHeight?: string bodyViewClass?: string + hideFooter?: boolean }>() const open = defineModel({ default: false }) @@ -123,7 +124,7 @@ function closeModal() { -