mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: refine admin console ergonomics
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -214,8 +214,9 @@ fn forwarded_for_public_ip(headers: &HeaderMap) -> Option<String> {
|
||||
|
||||
fn trusted_forwarded_client_ip(headers: &HeaderMap) -> Option<String> {
|
||||
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<String> {
|
||||
|
||||
@@ -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<DateTime<Utc>> {
|
||||
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<u64> {
|
||||
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<Item = &Value> {
|
||||
["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<Item = &'a Value> + '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<i64> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ClientIp>| 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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>) = 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-1
@@ -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。
|
||||
|
||||
构建并启动:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Codex Proxy RS</title>
|
||||
</head>
|
||||
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
scrollable?: boolean
|
||||
bodyMaxHeight?: string
|
||||
bodyViewClass?: string
|
||||
hideFooter?: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -123,7 +124,7 @@ function closeModal() {
|
||||
</BaseScrollbar>
|
||||
<slot v-else />
|
||||
</div>
|
||||
<footer class="flex justify-end gap-3 px-7 pb-7">
|
||||
<footer v-if="!hideFooter" class="flex justify-end gap-3 px-7 pb-7">
|
||||
<slot name="footer">
|
||||
<BaseButton variant="default" @click="open = false">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">确认</BaseButton>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface SegmentedOption {
|
||||
label: string
|
||||
value: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -54,7 +56,7 @@ function selectOption(value: string) {
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="relative z-10 h-7 min-w-0 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-[650] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="relative z-10 inline-flex h-7 min-w-0 items-center justify-center gap-1.5 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-[650] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
:class="[
|
||||
model === option.value
|
||||
? 'text-(--cp-text-primary)'
|
||||
@@ -67,6 +69,7 @@ function selectOption(value: string) {
|
||||
:disabled="disabled"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" />
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ export function resolveColumns(columns: BaseTableColumn[], minWidth?: number | s
|
||||
const minWidthPixels = minWidth === undefined ? 0 : numericPixelWidth(minWidth)
|
||||
const flexibleColumns = columns.filter((column) => column.width === undefined)
|
||||
const flexTotal = sumBy(flexibleColumns, (column) => column.flex ?? 1)
|
||||
const flexibleColumnCount = clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)
|
||||
const available = clamp(100 - fixedPercentTotal, 0, Number.POSITIVE_INFINITY)
|
||||
const availablePixels = clamp(minWidthPixels - fixedPixelTotal, 0, Number.POSITIVE_INFINITY)
|
||||
|
||||
@@ -41,10 +40,12 @@ export function resolveColumns(columns: BaseTableColumn[], minWidth?: number | s
|
||||
const width =
|
||||
column.width === undefined
|
||||
? minWidthPixels > 0 && fixedPercentTotal === 0
|
||||
? `${availablePixels / flexibleColumnCount}px`
|
||||
? flexTotal > 0
|
||||
? `${(availablePixels * flex) / flexTotal}px`
|
||||
: `${availablePixels / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}px`
|
||||
: flexTotal > 0
|
||||
? `${(available * flex) / flexTotal}%`
|
||||
: `${available / flexibleColumnCount}%`
|
||||
: `${available / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}%`
|
||||
: normalizeWidth(column.width)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalLink, GitBranch } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import { useSystemUpdate } from '@/composables/useSystemUpdate'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const { version } = useSystemUpdate()
|
||||
|
||||
const author = 'Zyy'
|
||||
const githubUrl = 'https://github.com/zyycn/codex-proxy-rs'
|
||||
|
||||
function normalizeBuildValue(value: string | undefined) {
|
||||
const normalized = value?.trim()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ['unknown', 'null'].includes(normalized.toLowerCase()) ? '' : normalized
|
||||
}
|
||||
|
||||
const versionDisplay = computed(() => {
|
||||
const versionText = normalizeBuildValue(version.value?.version).replace(/^v/i, '')
|
||||
return versionText ? `v${versionText}` : ''
|
||||
})
|
||||
const gitShaDisplay = computed(() => {
|
||||
const gitSha = normalizeBuildValue(version.value?.gitSha)
|
||||
return gitSha ? gitSha.slice(0, 8) : ''
|
||||
})
|
||||
|
||||
const versionLine = computed(() => {
|
||||
const parts = [versionDisplay.value, gitShaDisplay.value].filter(Boolean)
|
||||
return parts.length ? `版本 ${parts.join(' · ')}` : '版本信息不可用'
|
||||
})
|
||||
|
||||
const linkItems = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
value: 'codex-proxy-rs',
|
||||
href: githubUrl,
|
||||
icon: GitBranch,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" title="关于" width="420px" hide-footer>
|
||||
<div class="grid gap-5">
|
||||
<section class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-subtle) font-mono text-[15px] leading-none font-[820] text-(--cp-text-primary)"
|
||||
>
|
||||
Z
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 truncate text-[15px] leading-none font-[780] text-(--cp-text-primary)">
|
||||
{{ author }}
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-[12px] leading-none font-[650] text-(--cp-text-secondary)">
|
||||
Built by Zyy · Codex Proxy RS
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-1">
|
||||
<div
|
||||
v-for="item in linkItems"
|
||||
:key="item.label"
|
||||
class="group flex min-w-0 items-center justify-between gap-3 rounded-(--cp-input-radius-base) px-1 py-2.5 transition-colors hover:bg-(--cp-bg-subtle)"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-secondary)"
|
||||
>
|
||||
<component :is="item.icon" class="size-4" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-[720] text-(--cp-text-primary)"
|
||||
:title="item.value"
|
||||
>
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="item.href"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) text-(--cp-text-muted) transition-colors hover:bg-(--cp-bg-muted) hover:text-(--cp-info)"
|
||||
:aria-label="`打开 ${item.label}`"
|
||||
>
|
||||
<ExternalLink class="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="m-0 font-mono text-[11px] leading-none font-[650] text-(--cp-text-muted)">
|
||||
{{ versionLine }}
|
||||
</p>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Cat,
|
||||
ChartNoAxesColumn,
|
||||
ArrowUpCircle,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
@@ -24,6 +25,7 @@ import { useSystemUpdate } from '@/composables/useSystemUpdate'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppAboutModal from './AppAboutModal.vue'
|
||||
import SystemUpdateModal from './SystemUpdateModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -35,6 +37,23 @@ const { toggleTheme } = uiStore
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const { version, hasUpdate, loadedOnce, loadVersion, loadSystem } = useSystemUpdate()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
mobile?: boolean
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
mobile: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: []
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', icon: LayoutDashboard, path: '/' },
|
||||
{ label: '账号管理', icon: Users, path: '/accounts' },
|
||||
@@ -49,7 +68,8 @@ const isActive = (path: string) => {
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
void router.push(path)
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
const systemUpdateOpening = shallowRef(false)
|
||||
@@ -72,26 +92,17 @@ async function openSystemUpdate() {
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
await router.push('/login')
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const sidebarEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelVisible = shallowRef(!props.collapsed)
|
||||
const systemUpdateOpen = shallowRef(false)
|
||||
const aboutOpen = shallowRef(false)
|
||||
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
|
||||
const sidebarWidth = computed(() => (isCollapsed.value ? 88 : 256))
|
||||
const brandLabelVisible = shallowRef(!isCollapsed.value)
|
||||
const themeToggleLabel = computed(() =>
|
||||
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
@@ -208,10 +219,10 @@ function animateSidebarWidth(collapsed: boolean) {
|
||||
|
||||
onMounted(() => {
|
||||
gsap.set(sidebarEl.value, {
|
||||
width: props.collapsed ? 88 : 256,
|
||||
flexBasis: props.collapsed ? 88 : 256,
|
||||
width: sidebarWidth.value,
|
||||
flexBasis: sidebarWidth.value,
|
||||
})
|
||||
if (props.collapsed) {
|
||||
if (isCollapsed.value) {
|
||||
hideBrandLabel()
|
||||
} else {
|
||||
gsap.set(brandLabelEl.value, {
|
||||
@@ -219,12 +230,12 @@ onMounted(() => {
|
||||
x: 0,
|
||||
})
|
||||
}
|
||||
animateSidebarLabels(Boolean(props.collapsed))
|
||||
animateSidebarLabels(isCollapsed.value)
|
||||
void loadVersion().catch(() => undefined)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
() => isCollapsed.value,
|
||||
async (collapsed) => {
|
||||
if (collapsed) {
|
||||
hideBrandLabel()
|
||||
@@ -245,12 +256,15 @@ watch(
|
||||
<template>
|
||||
<aside
|
||||
ref="sidebarEl"
|
||||
class="z-20 hidden h-screen shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar) min-[961px]:flex"
|
||||
:class="collapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64'"
|
||||
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-bg-surface) px-4 shadow-(--cp-shadow-sidebar)"
|
||||
:class="[
|
||||
mobile ? 'flex' : 'hidden min-[961px]:flex',
|
||||
isCollapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
|
||||
:class="collapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
||||
:class="isCollapsed ? 'w-11 justify-start' : 'w-full gap-3'"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
@@ -290,14 +304,14 @@ watch(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="mt-7 grid gap-3" :class="collapsed ? '' : 'w-full'" aria-label="主导航">
|
||||
<nav class="mt-7 grid gap-3" :class="isCollapsed ? '' : 'w-full'" aria-label="主导航">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="inline-flex h-11.5 items-center rounded-(--cp-icon-button-radius) text-sm leading-[1.15] border-0 cursor-pointer transition-colors duration-200"
|
||||
:class="[
|
||||
collapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
|
||||
isActive(item.path)
|
||||
? 'bg-(--cp-bg-nav-active) font-bold text-(--cp-text-primary)'
|
||||
: 'bg-transparent font-semibold text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
|
||||
@@ -307,32 +321,32 @@ watch(
|
||||
<component :is="item.icon" class="shrink-0" :size="20" />
|
||||
<span
|
||||
class="sidebar-label overflow-hidden whitespace-nowrap transition-[opacity,transform] duration-200"
|
||||
:class="collapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
:class="isCollapsed ? 'pointer-events-none w-0' : 'w-auto'"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto mb-6" :class="collapsed ? 'w-11' : 'w-full'">
|
||||
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
|
||||
<div
|
||||
class="bg-(--cp-bg-subtle)"
|
||||
:class="
|
||||
collapsed
|
||||
isCollapsed
|
||||
? 'grid gap-1 rounded-(--cp-icon-button-radius) p-1'
|
||||
: 'flex h-11 items-center justify-between rounded-(--cp-panel-radius) px-2'
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
v-if="!isCollapsed"
|
||||
class="inline-flex h-7 items-center gap-1.5 rounded-lg bg-(--cp-success-bg) px-2.5 text-xs leading-none font-[650] text-(--cp-success-text)"
|
||||
>
|
||||
<i class="size-1.5 rounded-full bg-(--cp-success)" />
|
||||
在线
|
||||
</span>
|
||||
|
||||
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseButton
|
||||
v-if="collapsed"
|
||||
v-if="isCollapsed && hasUpdate"
|
||||
icon-only
|
||||
:variant="hasUpdate ? 'success' : 'ghost'"
|
||||
size="default"
|
||||
@@ -346,34 +360,56 @@ watch(
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
label="退出登录"
|
||||
class="hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="collapsed ? 19 : 18" />
|
||||
<LogOut :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
:label="themeToggleLabel"
|
||||
@click="toggleTheme($event)"
|
||||
>
|
||||
<Sun v-if="effectiveTheme === 'dark'" :size="collapsed ? 19 : 18" />
|
||||
<Moon v-else :size="collapsed ? 19 : 18" />
|
||||
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
|
||||
<Moon v-else :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
data-sidebar-toggle
|
||||
:label="collapsed ? '展开侧边栏' : '收缩侧边栏'"
|
||||
@click="$emit('toggle')"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
label="关于"
|
||||
@click="aboutOpen = true"
|
||||
>
|
||||
<PanelLeftOpen v-if="collapsed" :size="19" />
|
||||
<Info :size="isCollapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="mobile"
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="关闭侧边栏"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<PanelLeftClose :size="18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-else
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="isCollapsed ? 'default' : 'sm'"
|
||||
data-sidebar-toggle
|
||||
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<PanelLeftOpen v-if="isCollapsed" :size="19" />
|
||||
<PanelLeftClose v-else :size="18" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -381,5 +417,6 @@ watch(
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<AppAboutModal v-model="aboutOpen" />
|
||||
<SystemUpdateModal v-model="systemUpdateOpen" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu } from '@lucide/vue'
|
||||
import {
|
||||
useDraggable,
|
||||
useElementSize,
|
||||
useScreenSafeArea,
|
||||
useStorage,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onScopeDispose,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
interface StoredPosition {
|
||||
initialized: boolean
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const edgeGap = 12
|
||||
const defaultButtonSize = 44
|
||||
const dragClickThreshold = 6
|
||||
const storageKey = 'codex-proxy:mobile-sidebar-toggle-position'
|
||||
|
||||
const toggleEl = useTemplateRef<HTMLElement>('toggleEl')
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize({
|
||||
initialWidth: 390,
|
||||
initialHeight: 760,
|
||||
type: 'visual',
|
||||
})
|
||||
const { width: toggleWidth, height: toggleHeight } = useElementSize(toggleEl, {
|
||||
width: defaultButtonSize,
|
||||
height: defaultButtonSize,
|
||||
})
|
||||
const safeArea = useScreenSafeArea()
|
||||
const storedPosition = useStorage<StoredPosition>(
|
||||
storageKey,
|
||||
{
|
||||
initialized: false,
|
||||
side: 'left',
|
||||
x: edgeGap,
|
||||
y: 136,
|
||||
},
|
||||
undefined,
|
||||
{ mergeDefaults: true, shallow: true },
|
||||
)
|
||||
const dragStart = shallowRef({ x: edgeGap, y: 136 })
|
||||
const pointerStart = shallowRef({ x: edgeGap, y: 136 })
|
||||
const ignoreNextClick = shallowRef(false)
|
||||
const positionReady = shallowRef(false)
|
||||
let ignoreClickTimer: number | undefined
|
||||
|
||||
const bounds = computed(() => {
|
||||
const buttonWidth = positiveNumber(toggleWidth.value, defaultButtonSize)
|
||||
const buttonHeight = positiveNumber(toggleHeight.value, defaultButtonSize)
|
||||
const windowWidth = positiveNumber(viewportWidth.value, 390)
|
||||
const windowHeight = positiveNumber(viewportHeight.value, 760)
|
||||
const minX = parseCssPixel(safeArea.left.value) + edgeGap
|
||||
const maxX = Math.max(
|
||||
minX,
|
||||
windowWidth - parseCssPixel(safeArea.right.value) - edgeGap - buttonWidth,
|
||||
)
|
||||
const minY = parseCssPixel(safeArea.top.value) + edgeGap
|
||||
const maxY = Math.max(
|
||||
minY,
|
||||
windowHeight - parseCssPixel(safeArea.bottom.value) - edgeGap - buttonHeight,
|
||||
)
|
||||
|
||||
return { minX, maxX, minY, maxY }
|
||||
})
|
||||
|
||||
const { x, y, isDragging } = useDraggable(toggleEl, {
|
||||
initialValue: {
|
||||
x: storedPosition.value.x,
|
||||
y: storedPosition.value.y,
|
||||
},
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
onStart(position) {
|
||||
dragStart.value = { x: position.x, y: position.y }
|
||||
},
|
||||
onEnd(position) {
|
||||
applyPosition(snapPosition(position.x, position.y))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleStyle = computed<CSSProperties>(() => ({
|
||||
transform: `translate3d(${x.value}px, ${y.value}px, 0)`,
|
||||
touchAction: 'none',
|
||||
}))
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
pointerStart.value = { x: event.clientX, y: event.clientY }
|
||||
event.currentTarget instanceof HTMLElement &&
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.currentTarget instanceof HTMLElement &&
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
||||
|
||||
const moved = Math.hypot(
|
||||
event.clientX - pointerStart.value.x,
|
||||
event.clientY - pointerStart.value.y,
|
||||
)
|
||||
ignoreNextClick.value = true
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
ignoreClickTimer = window.setTimeout(() => {
|
||||
ignoreNextClick.value = false
|
||||
}, 320)
|
||||
|
||||
if (moved <= dragClickThreshold) {
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (isDragging.value || ignoreNextClick.value) {
|
||||
ignoreNextClick.value = false
|
||||
return
|
||||
}
|
||||
|
||||
emit('open')
|
||||
}
|
||||
|
||||
function defaultPosition() {
|
||||
const targetY = Math.round(positiveNumber(viewportHeight.value, 760) * 0.36)
|
||||
return snapPosition(bounds.value.minX, targetY)
|
||||
}
|
||||
|
||||
function restorePosition() {
|
||||
if (!storedPosition.value.initialized) {
|
||||
applyPosition(defaultPosition())
|
||||
return
|
||||
}
|
||||
|
||||
const sideX = storedPosition.value.side === 'right' ? bounds.value.maxX : bounds.value.minX
|
||||
applyPosition({
|
||||
initialized: true,
|
||||
side: storedPosition.value.side,
|
||||
x: sideX,
|
||||
y: storedPosition.value.y,
|
||||
})
|
||||
}
|
||||
|
||||
function snapPosition(rawX: number, rawY: number) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const side = rawX <= (minX + maxX) / 2 ? 'left' : 'right'
|
||||
|
||||
return {
|
||||
initialized: true,
|
||||
side,
|
||||
x: side === 'left' ? minX : maxX,
|
||||
y: clamp(rawY, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
}
|
||||
|
||||
function applyPosition(position: StoredPosition) {
|
||||
const { minX, maxX, minY, maxY } = bounds.value
|
||||
const next = {
|
||||
initialized: true,
|
||||
side: position.side,
|
||||
x: clamp(position.x, minX, maxX),
|
||||
y: clamp(position.y, minY, maxY),
|
||||
} satisfies StoredPosition
|
||||
|
||||
x.value = next.x
|
||||
y.value = next.y
|
||||
storedPosition.value = next
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function positiveNumber(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function parseCssPixel(value: string) {
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
safeArea.update()
|
||||
restorePosition()
|
||||
positionReady.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
bounds,
|
||||
() => {
|
||||
if (!positionReady.value) return
|
||||
restorePosition()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearTimeout(ignoreClickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="toggleEl"
|
||||
class="fixed top-0 left-0 z-40 min-[961px]:hidden"
|
||||
:class="positionReady ? 'opacity-100' : 'opacity-0'"
|
||||
:style="toggleStyle"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
|
||||
:class="
|
||||
isDragging
|
||||
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
|
||||
: 'cursor-grab'
|
||||
"
|
||||
aria-label="打开侧边栏"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -356,7 +356,7 @@ onUnmounted(() => {
|
||||
variant="success"
|
||||
width="820px"
|
||||
scrollable
|
||||
body-max-height="72vh"
|
||||
body-max-height="72dvh"
|
||||
body-view-class="pr-3"
|
||||
:close-disabled="updating || restarting"
|
||||
>
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const { sidebarCollapsed } = storeToRefs(uiStore)
|
||||
const { toggleSidebar } = uiStore
|
||||
const route = useRoute()
|
||||
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
|
||||
const mobileSidebarOpen = shallowRef(false)
|
||||
|
||||
function openMobileSidebar() {
|
||||
mobileSidebarOpen.value = true
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
mobileSidebarOpen.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
async () => {
|
||||
closeMobileSidebar()
|
||||
await nextTick()
|
||||
await pageScrollbarRef.value?.scrollToTop()
|
||||
},
|
||||
@@ -25,14 +36,65 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-(--cp-bg-page)">
|
||||
<div class="relative flex h-dvh overflow-hidden bg-(--cp-bg-page)">
|
||||
<AppSidebar :collapsed="sidebarCollapsed" @toggle="toggleSidebar" />
|
||||
<main class="h-screen min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar ref="pageScrollbarRef" view-class="flex min-h-full min-w-0 flex-col p-6">
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
ref="pageScrollbarRef"
|
||||
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
|
||||
>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="min-h-0 flex-1" />
|
||||
</RouterView>
|
||||
</BaseScrollbar>
|
||||
</main>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-sidebar">
|
||||
<div v-if="mobileSidebarOpen" class="fixed inset-0 z-50 min-[961px]:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 border-0 bg-black/32 backdrop-blur-[1px] cursor-default"
|
||||
aria-label="关闭侧边栏"
|
||||
@click="closeMobileSidebar"
|
||||
/>
|
||||
<div class="mobile-sidebar-panel absolute inset-y-0 left-0 flex">
|
||||
<AppSidebar mobile @close="closeMobileSidebar" @navigate="closeMobileSidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-sidebar-enter-active,
|
||||
.mobile-sidebar-leave-active {
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-active .mobile-sidebar-panel,
|
||||
.mobile-sidebar-leave-active .mobile-sidebar-panel {
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-from,
|
||||
.mobile-sidebar-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-from .mobile-sidebar-panel,
|
||||
.mobile-sidebar-leave-to .mobile-sidebar-panel {
|
||||
transform: translate3d(-100%, 0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-sidebar-enter-active,
|
||||
.mobile-sidebar-leave-active,
|
||||
.mobile-sidebar-enter-active .mobile-sidebar-panel,
|
||||
.mobile-sidebar-leave-active .mobile-sidebar-panel {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ export const accountColumns = [
|
||||
key: 'expander',
|
||||
label: '',
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
align: 'center' as const,
|
||||
headerClass: '!px-2',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
@@ -13,30 +14,42 @@ export const accountColumns = [
|
||||
key: 'selection',
|
||||
label: '',
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
align: 'center' as const,
|
||||
headerClass: '!px-2',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
{ key: 'identity', label: '邮箱', flex: 2.6, minWidth: '260px', cellClass: relaxedCellClass },
|
||||
{ key: 'status', label: '状态', flex: 0.8, cellClass: relaxedCellClass },
|
||||
{ key: 'planType', label: '套餐', flex: 0.8, cellClass: relaxedCellClass },
|
||||
{ key: 'usage', label: '用量', flex: 1.8, minWidth: '250px', cellClass: relaxedCellClass },
|
||||
{
|
||||
key: 'identity',
|
||||
label: '邮箱',
|
||||
width: '340px',
|
||||
minWidth: '340px',
|
||||
fixed: 'left' as const,
|
||||
cellClass: relaxedCellClass,
|
||||
},
|
||||
{ key: 'status', label: '状态', flex: 0.6, minWidth: '60px', cellClass: relaxedCellClass },
|
||||
{ key: 'planType', label: '套餐', flex: 0.8, minWidth: '112px', cellClass: relaxedCellClass },
|
||||
{ key: 'usage', label: '用量', flex: 1.3, minWidth: '220px', cellClass: relaxedCellClass },
|
||||
{
|
||||
key: 'updatedAtDisplay',
|
||||
label: '最后使用',
|
||||
flex: 1.2,
|
||||
minWidth: '160px',
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'accessTokenExpiresAtDisplay',
|
||||
label: '过期时间',
|
||||
flex: 1.2,
|
||||
minWidth: '160px',
|
||||
cellClass: `${relaxedCellClass} text-(--cp-text-secondary)`,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '116px',
|
||||
minWidth: '116px',
|
||||
fixed: 'right' as const,
|
||||
headerClass: '!pr-3',
|
||||
cellClass: `!px-2 ${relaxedCellClass}`,
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@ bindAccountLoader(loadAccounts)
|
||||
|
||||
<BaseCard
|
||||
:padded="false"
|
||||
class="mt-4 flex h-[calc(100vh-248px)] min-h-125 flex-col"
|
||||
class="mt-4 flex h-[calc(100dvh-248px)] min-h-125 flex-col"
|
||||
header-class="px-4 pt-4 pb-2 md:px-5"
|
||||
body-class="flex min-h-0 flex-1 px-4 pb-3 md:px-5"
|
||||
>
|
||||
@@ -182,6 +182,7 @@ bindAccountLoader(loadAccounts)
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
:pagination="accountPagination"
|
||||
empty-text="暂无账号数据"
|
||||
min-width="1480px"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Copy } from '@lucide/vue'
|
||||
import { Apple, Copy, Monitor } from '@lucide/vue'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
@@ -22,136 +22,57 @@ const emit = defineEmits<{
|
||||
copy: [text: string]
|
||||
}>()
|
||||
|
||||
const activeTab = shallowRef('codex')
|
||||
const activePlatform = shallowRef('unix')
|
||||
|
||||
const tabOptions = [
|
||||
{ label: 'Codex', value: 'codex' },
|
||||
{ label: '环境变量', value: 'env' },
|
||||
{ label: 'curl', value: 'curl' },
|
||||
const platformOptions = [
|
||||
{ label: 'MacOS / Linux', value: 'unix', icon: Apple },
|
||||
{ label: 'Windows', value: 'windows', icon: Monitor },
|
||||
]
|
||||
|
||||
const keyValue = computed(() => props.apiKey?.key ?? '')
|
||||
const displayName = computed(() => props.apiKey?.name || 'API Key')
|
||||
const configPath = computed(() =>
|
||||
activePlatform.value === 'windows' ? '%userprofile%\\.codex/config.toml' : '~/.codex/config.toml',
|
||||
)
|
||||
const authPath = computed(() =>
|
||||
activePlatform.value === 'windows' ? '%userprofile%\\.codex/auth.json' : '~/.codex/auth.json',
|
||||
)
|
||||
const codexAuthJson = computed(() => JSON.stringify({ OPENAI_API_KEY: keyValue.value }, null, 2))
|
||||
|
||||
const codexConfigToml = computed(
|
||||
() => `model_provider = "codex-proxy-rs"
|
||||
() => `model_provider = "OpenAI"
|
||||
model = "gpt-5.5"
|
||||
review_model = "gpt-5.5"
|
||||
model_reasoning_effort = "xhigh"
|
||||
disable_response_storage = true
|
||||
network_access = "enabled"
|
||||
windows_wsl_setup_acknowledged = true
|
||||
|
||||
[model_providers.codex-proxy-rs]
|
||||
name = "codex-proxy-rs"
|
||||
base_url = "${props.apiBaseUrl}"
|
||||
[model_providers.OpenAI]
|
||||
name = "OpenAI"
|
||||
base_url = "${props.serviceRootUrl}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true`,
|
||||
requires_openai_auth = true
|
||||
|
||||
[features]
|
||||
goals = true`,
|
||||
)
|
||||
|
||||
const envConfig = computed(
|
||||
() => `export OPENAI_API_KEY="${keyValue.value}"
|
||||
export OPENAI_BASE_URL="${props.apiBaseUrl}"`,
|
||||
)
|
||||
|
||||
const curlExample = computed(
|
||||
() => `curl "${props.apiBaseUrl}/responses" \\
|
||||
-H "Authorization: Bearer ${keyValue.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "gpt-5.5",
|
||||
"input": "ping",
|
||||
"stream": false
|
||||
}'`,
|
||||
)
|
||||
|
||||
const visibleFiles = computed(() => {
|
||||
if (activeTab.value === 'env') {
|
||||
return [{ path: 'Shell', content: envConfig.value }]
|
||||
}
|
||||
|
||||
if (activeTab.value === 'curl') {
|
||||
return [{ path: '/v1/responses', content: curlExample.value }]
|
||||
}
|
||||
|
||||
return [
|
||||
{ path: '~/.codex/config.toml', content: codexConfigToml.value },
|
||||
{ path: '~/.codex/auth.json', content: codexAuthJson.value },
|
||||
]
|
||||
})
|
||||
const visibleFiles = computed(() => [
|
||||
{ path: configPath.value, content: codexConfigToml.value },
|
||||
{ path: authPath.value, content: codexAuthJson.value },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" title="使用密钥" :description="displayName" width="760px">
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="使用密钥"
|
||||
description="将以下配置文件添加到 Codex CLI 配置目录中"
|
||||
width="760px"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] leading-none font-bold text-(--cp-text-muted)">服务根地址</p>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
|
||||
:title="serviceRootUrl"
|
||||
>
|
||||
{{ serviceRootUrl }}
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制服务根地址"
|
||||
@click="emit('copy', serviceRootUrl)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] leading-none font-bold text-(--cp-text-muted)">
|
||||
OpenAI Base URL
|
||||
</p>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
|
||||
:title="apiBaseUrl"
|
||||
>
|
||||
{{ apiBaseUrl }}
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制 Base URL"
|
||||
@click="emit('copy', apiBaseUrl)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
|
||||
<p class="m-0 text-[11px] leading-none font-bold text-(--cp-text-muted)">Bearer Key</p>
|
||||
<div class="mt-2 flex min-w-0 items-center gap-2">
|
||||
<code
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] font-[650] text-(--cp-text-primary)"
|
||||
:title="keyValue"
|
||||
>
|
||||
{{ keyValue }}
|
||||
</code>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="复制 Bearer Key"
|
||||
@click="emit('copy', keyValue)"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<BaseSegmented v-model="activeTab" :options="tabOptions" />
|
||||
<BaseSegmented v-model="activePlatform" :options="platformOptions" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -177,7 +98,7 @@ const visibleFiles = computed(() => {
|
||||
</BaseButton>
|
||||
</div>
|
||||
<BaseScrollbar
|
||||
max-height="180px"
|
||||
max-height="360px"
|
||||
view-class="mx-3 mb-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-input)"
|
||||
>
|
||||
<pre
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
const relaxedActionCellClass = '!px-4'
|
||||
|
||||
export const apiKeyColumns = [
|
||||
{ key: 'selection', label: '', width: '48px', align: 'center' as const },
|
||||
{ key: 'identity', label: '名称', minWidth: '280px', flex: 1.25 },
|
||||
{ key: 'selection', label: '', width: '48px', minWidth: '48px', align: 'center' as const },
|
||||
{ key: 'identity', label: '名称', minWidth: '220px', flex: 0.9, fixed: 'left' as const },
|
||||
{ key: 'prefix', label: '密钥前缀', minWidth: '300px', flex: 1.35 },
|
||||
{ key: 'enabled', label: '状态', width: '112px', align: 'center' as const },
|
||||
{ key: 'enabled', label: '状态', width: '112px', minWidth: '112px', align: 'center' as const },
|
||||
{
|
||||
key: 'createdAtDisplay',
|
||||
label: '创建时间',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
|
||||
},
|
||||
{
|
||||
key: 'lastUsedAtDisplay',
|
||||
label: '最后使用',
|
||||
width: '176px',
|
||||
minWidth: '176px',
|
||||
cellClass: 'font-mono text-[12px] font-[650] tabular-nums text-(--cp-text-secondary)',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: '184px',
|
||||
minWidth: '184px',
|
||||
fixed: 'right' as const,
|
||||
headerClass: relaxedActionCellClass,
|
||||
cellClass: relaxedActionCellClass,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,7 @@ function importToCcs(apiKey: any) {
|
||||
|
||||
<BaseCard
|
||||
:padded="false"
|
||||
class="mt-5 flex h-[calc(100vh-136px)] min-h-125 flex-col"
|
||||
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
body-class="flex min-h-0 flex-1 px-5 py-3"
|
||||
>
|
||||
|
||||
@@ -250,7 +250,7 @@ export function useDashboard(): any {
|
||||
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
|
||||
{
|
||||
label: '计费',
|
||||
value: `${tokens.totalCostUsd} / ${tokens.todayCostUsd}`,
|
||||
value: tokens.todayCostUsd,
|
||||
tone: 'success',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -41,7 +41,7 @@ async function handleSubmit(): Promise<void> {
|
||||
<LoginBackground />
|
||||
|
||||
<section
|
||||
class="grid min-h-dvh items-center justify-items-center px-5 py-[clamp(24px,5vh,64px)] min-[980px]:justify-items-end min-[980px]:pr-[clamp(48px,17.3vw,332px)] max-[560px]:p-4.5"
|
||||
class="grid min-h-dvh items-center justify-items-center px-5 py-[clamp(24px,5dvh,64px)] min-[980px]:justify-items-end min-[980px]:pr-[clamp(48px,17.3vw,332px)] max-[560px]:p-4.5"
|
||||
aria-label="Codex Proxy RS 登录"
|
||||
>
|
||||
<LoginPanel
|
||||
|
||||
@@ -216,7 +216,7 @@ function themeColor(name: string, fallback: string) {
|
||||
variant="info"
|
||||
width="960px"
|
||||
scrollable
|
||||
body-max-height="min(76vh,820px)"
|
||||
body-max-height="min(76dvh,820px)"
|
||||
body-view-class="grid gap-3"
|
||||
>
|
||||
<template v-if="record">
|
||||
@@ -365,7 +365,7 @@ function themeColor(name: string, fallback: string) {
|
||||
|
||||
<section v-if="record.metadata" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">元数据</h3>
|
||||
<BaseScrollbar max-height="min(32vh, 340px)" :view-class="codeBlockViewClass">
|
||||
<BaseScrollbar max-height="min(32dvh, 340px)" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ JSON.stringify(record.metadata, null, 2) }}</pre>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user