Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex 6c56a23508 chore(release): v3.23.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:51:52 +08:00
b5f3cb402c fix(openai): reject sends on closed WebSocket connections (#381)
Record observed closure before terminal delivery can block, reject queued
unsent commands, and skip commands cancelled before socket writes.
Preserve connection diagnostics and test account egress across quota writes.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:36:40 +08:00
57e284d7d1 fix(openai): preserve stream recovery before semantic output (#380)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:30:26 +08:00
21597f9e98 fix(store): restore observation query optimization (#378)
Scope timing projection barriers to filtered metric and diagnostic queries, preserving ordinary aggregate index and parallel plans.

Refs #377

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:30:05 +08:00
ZyyandCodex fa0b87dd0a refactor(frontend): adopt UI v0.4.0 component APIs
Migrate the management UI to Z components and managed notifications, register the library loading directive, and align fixed dependencies and submodules with the published UI version.

Co-authored-by: Codex <noreply@openai.com>
2026-10-10 14:54:56 +08:00
229 changed files with 3250 additions and 2864 deletions
@@ -1511,6 +1511,8 @@ where
error.pre_delivery_retry(),
Some(crate::error::PreDeliveryRetry::SameAccountConnectionRetry { .. })
);
// Provider 的有界同账号传输恢复是尽力重试策略,不要求把 Sent/Ambiguous
// 改写成安全证明;外部副作用、取消、预算与下游提交仍由 Core 拦截
let transport_recovery = match error.pre_delivery_retry() {
Some(crate::error::PreDeliveryRetry::SameAccountConnectionRetry { transport })
if !execution_effect_observed
@@ -1534,8 +1536,7 @@ where
}) if !execution_effect_observed
&& !self.connection_budget.exhausted()
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous =>
&& !self.delivery_pending =>
{
Some((AttemptTransport::Retry(retry_index), delay))
}
@@ -1543,8 +1544,7 @@ where
if !execution_effect_observed
&& !self.connection_budget.exhausted()
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous =>
&& !self.delivery_pending =>
{
Some((
AttemptTransport::Fallback,
+4 -2
View File
@@ -135,6 +135,7 @@ pub enum PreDeliveryRetry {
max_delay: Duration,
},
/// 固定本次账号,并按 Provider 给出的序号重试当前传输
/// 可用于发送结果不明确时的有界尽力恢复,不等于上游未执行的证明
SameAccountTransportRetry {
/// Provider-owned 传输重试序号,从 1 开始
retry_index: NonZeroU32,
@@ -630,8 +631,8 @@ impl ProviderError {
/// 允许 Core 在首个客户端事件前固定原账号并改用 Provider 备用传输
///
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
/// 下游已提交后的隐藏重放
/// Provider 必须确认备用传输满足当前 continuation scope 并限制恢复次数
/// Core 保持原账号,拒绝预算耗尽、外部副作用或下游已提交后的隐藏重放
#[must_use]
pub fn with_pre_delivery_transport_fallback(mut self) -> Self {
self.set_pre_delivery_transport_fallback();
@@ -647,6 +648,7 @@ impl ProviderError {
///
/// 重试预算和退避策略由 Provider 所属协议定义;Core 只负责同账号钉选、
/// deadline/取消边界以及下游 commit barrier
/// 该意图允许发送结果不明确时尽力恢复,但不修改发送事实或安全重放证明
#[must_use]
pub fn with_pre_delivery_transport_retry(
mut self,
@@ -3493,7 +3493,7 @@ fn ambiguous_pre_delivery_retry_marker_does_not_rotate_account() {
}
#[test]
fn ambiguous_transport_retry_is_rejected_even_with_provider_session_state() {
fn ambiguous_transport_retry_keeps_the_account_without_claiming_replay_safety() {
let operation = generate_operation();
let route_plan = plan(&operation);
let checkpoint = ProviderSessionState::new(
@@ -3513,7 +3513,6 @@ fn ambiguous_transport_retry_is_rejected_even_with_provider_session_state() {
Ok(checkpoint_event),
Err(
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
.with_replay_safe()
.with_pre_delivery_transport_retry(retry_index, Duration::ZERO),
),
],
@@ -3533,14 +3532,25 @@ fn ambiguous_transport_retry_is_rejected_even_with_provider_session_state() {
CancellationToken::new(),
))
.expect("start execution");
let error =
block_on(session.collect_uncommitted()).expect_err("ambiguous send cannot be replayed");
assert!(matches!(error, EngineError::Provider(_)));
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1);
block_on(session.collect_uncommitted()).expect("bounded transport recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit winning response");
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 2);
assert_eq!(
contexts[1].transport(),
AttemptTransport::Retry(retry_index)
);
assert_eq!(
contexts[1].required_account(),
Some(&ProviderAccountId::new("acct_first").unwrap())
);
assert!(contexts[1].excluded_accounts().is_empty());
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 0);
assert_eq!(state.finalizations[0].attempt_count, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].attempt_count, 2);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
assert!(state.send_states.contains(&UpstreamSendState::Ambiguous));
assert_eq!(state.finalizations[0].send_state, UpstreamSendState::Sent);
}
#[test]
@@ -10,7 +10,7 @@ pub(crate) async fn request_metrics(
filter.validate()?;
// 结果计数覆盖范围内全部请求(成功率/失败率的分母分子);用量、缓存、
// 延迟与成本聚合仅统计用量事实(已完整交付客户端的成功响应)
let fact = completed_usage_fact_predicate("mr");
let fact = "mr.is_completed_usage";
let mut query = QueryBuilder::<Postgres>::new(format!(
"select count(*)::bigint as request_count,
count(*) filter (where outcome = 'succeeded')::bigint as success_count,
@@ -87,14 +87,9 @@ pub(crate) async fn request_metrics(
round(percentile_cont(0.95) within group (
order by capacity_used_slots::double precision
/ nullif(capacity_total_slots, 0)
) * 10000)::bigint as capacity_utilization_p95_basis_points
from model_request_observations mr where mr.started_at >= "
) * 10000)::bigint as capacity_utilization_p95_basis_points "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
push_request_metrics_source(&mut query, range, filter);
let row = query
.build()
.fetch_one(pool)
@@ -162,7 +157,7 @@ async fn request_metric_series_inner(
filter.validate()?;
// 与 request_metrics 同一契约:结果计数覆盖全部请求,用量/延迟/成本
// 聚合仅统计用量事实
let fact = completed_usage_fact_predicate("mr");
let fact = "mr.is_completed_usage";
let mut query = QueryBuilder::<Postgres>::new("select ");
push_metric_bucket(&mut query, range, granularity, timezone)?;
query.push(format!(
@@ -248,14 +243,9 @@ async fn request_metric_series_inner(
count(*) filter (where {fact} and cost_source = 'calculated')::bigint
as calculated_count,
count(*) filter (where {fact} and cost_source = 'unavailable')::bigint
as unavailable_count
from model_request_observations mr where mr.started_at >= "
as unavailable_count "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
push_request_metrics_source(&mut query, range, filter);
query.push(" group by bucket_start order by bucket_start");
let rows = query
.build()
@@ -289,6 +279,32 @@ async fn request_metric_series_inner(
fill_metric_gaps(range, granularity, points, timezone)
}
fn push_request_metrics_source(
query: &mut QueryBuilder<Postgres>,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) {
let fact = completed_usage_fact_predicate("mr");
// 先筛选再投影计时与用量口径,供多个聚合复用;屏障只属于计时统计查询
// 显式保留聚合需要的列,避免宽 JSON、快照与诊断轨迹进入中间结果
query.push(format!(
"from (select mr.started_at, mr.outcome, mr.client_status_code,
mr.input_tokens, mr.output_tokens, mr.cached_tokens,
mr.cache_write_tokens, mr.reasoning_tokens, mr.total_tokens,
mr.first_token_ms, mr.latency_ms, mr.admission_decision_ms,
mr.account_selection_wait_ms, mr.upstream_response_ms,
mr.capacity_used_slots, mr.capacity_total_slots, mr.cost_source,
({fact}) as is_completed_usage
from model_request_observations mr where mr.started_at >= "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(query, "mr");
push_usage_filter(query, filter, "mr");
query.push(" offset 0) mr");
}
pub(crate) fn calculated_usage_billing_facts(
pool: &PgPool,
range: ObservabilityRange,
@@ -411,8 +411,9 @@ pub(crate) async fn usage_diagnostics(
push_unrecovered_request_filter(&mut statement, "mr");
push_diagnostic_dimension_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
// 仅在筛选后的诊断投影保留屏障,让均值与分位数复用同一次计时提取
statement.push(
"), aggregated as (
" offset 0), aggregated as (
select dimension_name, cost_currency,
grouping(cost_currency)::integer as currency_grouping,
count(*)::bigint as request_count,
@@ -1,5 +1,7 @@
//! 观测存储测试入口,以及查询范围、过滤与端口合同测试
mod projection;
use futures::TryStreamExt;
use std::{
collections::BTreeMap,
@@ -0,0 +1,93 @@
//! 观测视图升级兼容性与普通聚合的覆盖索引规划回归测试
use chrono::Utc;
use serde_json::Value;
use super::{TestDatabase, seed_observability_facts};
#[tokio::test]
async fn projection_upgrade_preserves_observation_values() {
let Some(db) = TestDatabase::create_through("observation_projection_upgrade", 26).await else {
return;
};
seed_observability_facts(&db.pool, Utc::now())
.await
.unwrap();
sqlx::query(
"update model_requests
set request_observation_json = jsonb_set(request_observation_json,
'{timings,upstream}', '{\"responseMs\":700,\"engineIapiTbtMs\":2.450638}')
where id = 'req_observe_success'",
)
.execute(&db.pool)
.await
.unwrap();
let before: Vec<Value> =
sqlx::query_scalar("select to_jsonb(mr) from model_request_observations mr order by id")
.fetch_all(&db.pool)
.await
.unwrap();
super::super::TEST_MIGRATOR.run(&db.pool).await.unwrap();
let after: Vec<Value> =
sqlx::query_scalar("select to_jsonb(mr) from model_request_observations mr order by id")
.fetch_all(&db.pool)
.await
.unwrap();
assert_eq!(
after, before,
"projection upgrade must preserve every field"
);
let updatable: String = sqlx::query_scalar(
"select is_updatable from information_schema.views
where table_schema = current_schema() and table_name = 'model_request_observations'",
)
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(updatable, "NO", "observation projection remains read-only");
db.close().await;
}
#[tokio::test]
async fn plain_observation_aggregates_can_use_a_covering_index() {
let Some(db) = TestDatabase::create("observation_covering_index").await else {
return;
};
seed_observability_facts(&db.pool, Utc::now())
.await
.unwrap();
let mut transaction = db.pool.begin().await.unwrap();
// 小夹具只验证覆盖索引路径可达,不把耗时阈值或生产规划器成本固定在测试中
sqlx::raw_sql(
"create index observation_plain_cover_idx on model_requests (started_at)
include (input_tokens, cost_amount) where recovered_at is null;
set local enable_seqscan = off",
)
.execute(&mut *transaction)
.await
.unwrap();
let plan: Value = sqlx::query_scalar(
"explain (format json)
select count(*), sum(input_tokens), sum(cost_amount)
from model_request_observations
where recovered_at is null and started_at >= now() - interval '1 day'",
)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert!(
uses_covering_index(&plan[0]["Plan"]),
"plain aggregates must not require fetching observation JSON: {plan}"
);
transaction.rollback().await.unwrap();
db.close().await;
}
fn uses_covering_index(plan: &Value) -> bool {
(plan["Node Type"] == "Index Only Scan" && plan["Index Name"] == "observation_plain_cover_idx")
|| plan["Plans"]
.as_array()
.is_some_and(|children| children.iter().any(uses_covering_index))
}
@@ -850,6 +850,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
&mut failure,
WebSocketRecoveryContext {
policy,
replay_boundary: ReplayBoundary::BeforeSemanticOutput,
requirement: request_transport_requirement,
retry_count: websocket_retry_count,
max_retries: stream_max_retries,
@@ -964,7 +965,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
return;
};
let replay_grace_deadline = pre_commit_events.replay_grace_deadline();
let next = tokio::select! {
biased;
_ = cancellation.cancelled() => Err(MappedProviderFailure::plain(provider_error(
@@ -975,9 +975,8 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
ProviderErrorKind::Timeout,
UpstreamSendState::Sent,
))),
_ = wait_for_replay_grace(replay_grace_deadline) => Ok(PreCommitPoll::GraceElapsed),
chunk = body.next() => match chunk {
Some(Ok(chunk)) => Ok(PreCommitPoll::Upstream(Some(chunk))),
Some(Ok(chunk)) => Ok(Some(chunk)),
Some(Err(error)) => {
log_client_upstream_error(
UpstreamErrorLogContext::new(
@@ -989,17 +988,11 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
);
Err(map_stream_error(error))
}
None => Ok(PreCommitPoll::Upstream(None)),
None => Ok(None),
},
};
let next = match next {
Ok(PreCommitPoll::Upstream(next)) => next,
Ok(PreCommitPoll::GraceElapsed) => {
for event in pre_commit_events.commit_pending(PreCommitReleaseReason::GraceTimeout) {
yield event;
}
continue;
}
Ok(next) => next,
Err(mut failure) => {
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
let rate_limits_changed = if updates.is_empty() {
@@ -1023,15 +1016,12 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
};
if failure.websocket_transport_retryable
&& response_transport == CodexBackendTransport::WebSocket
&& (matches!(
failure.error.send_state(),
UpstreamSendState::Sent | UpstreamSendState::Ambiguous
) || !pre_commit_events.is_committed())
{
apply_websocket_recovery_policy(
&mut failure,
WebSocketRecoveryContext {
policy: WebSocketFailurePolicy::Budgeted,
replay_boundary: ReplayBoundary::from_semantic_output(pre_commit_events.is_committed()),
requirement: request_transport_requirement,
retry_count: websocket_retry_count,
max_retries: stream_max_retries,
@@ -69,28 +69,14 @@ impl MappedProviderFailure {
}
}
pub(super) enum PreCommitPoll<T> {
Upstream(T),
GraceElapsed,
}
pub(super) async fn wait_for_replay_grace(deadline: Option<Instant>) {
if let Some(deadline) = deadline {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
} else {
std::future::pending::<()>().await;
}
}
/// 提交边界前的上游事件预取
///
/// 结构事件保留到宽限期结束、语义输出、终态或 EOF,再提交已缓存 wire
/// 大段配置回显不应提前结束无感换号窗口,原始 chunk 字节数只用于诊断
/// 结构事件保留到语义输出、终态或 EOF,再提交已缓存 wire
/// 一旦提交,后续事件不再具备无痕重放资格
pub(super) struct PreCommitClientEvents {
pending: Vec<ProviderEvent>,
prefetched_bytes: usize,
replay_grace_started_at: Option<Instant>,
started_at: tokio::time::Instant,
committed: bool,
trace: TraceContext,
}
@@ -100,16 +86,15 @@ pub(super) struct PreCommitClientEvents {
pub(super) enum PreCommitReleaseReason {
SemanticOutput,
Terminal,
GraceTimeout,
Eof,
}
impl PreCommitClientEvents {
pub(super) const fn new(trace: TraceContext) -> Self {
pub(super) fn new(trace: TraceContext) -> Self {
Self {
pending: Vec::new(),
prefetched_bytes: 0,
replay_grace_started_at: None,
started_at: tokio::time::Instant::now(),
committed: false,
trace,
}
@@ -130,7 +115,6 @@ impl PreCommitClientEvents {
if self.committed {
return incoming;
}
let starts_replay_grace = incoming.iter().any(ProviderEvent::has_client_event);
self.pending.extend(incoming);
if timing_signals.semantic_output {
return self.commit_pending(PreCommitReleaseReason::SemanticOutput);
@@ -138,9 +122,6 @@ impl PreCommitClientEvents {
if completed {
return self.commit_pending(PreCommitReleaseReason::Terminal);
}
if starts_replay_grace && self.replay_grace_started_at.is_none() {
self.replay_grace_started_at = Some(Instant::now());
}
Vec::new()
}
@@ -170,15 +151,9 @@ impl PreCommitClientEvents {
pub(super) fn take_for_failure(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
self.pending.extend(incoming);
self.prefetched_bytes = 0;
self.replay_grace_started_at = None;
std::mem::take(&mut self.pending)
}
pub(super) fn replay_grace_deadline(&self) -> Option<Instant> {
self.replay_grace_started_at
.and_then(|started| started.checked_add(STREAM_REPLAY_GRACE))
}
pub(super) const fn is_committed(&self) -> bool {
self.committed
}
@@ -186,15 +161,17 @@ impl PreCommitClientEvents {
pub(super) fn commit_pending(&mut self, reason: PreCommitReleaseReason) -> Vec<ProviderEvent> {
// Provider 只记录释放缓存的原因;实际下游提交仍由 Core 记录和裁决
if self.trace.is_enabled() {
self.trace.record("provider.precommit.released", json!({
"reason": reason,
"prefetchedBytes": self.prefetched_bytes,
"waitMs": self.replay_grace_started_at.map_or(0, |started| started.elapsed().as_millis()),
}));
self.trace.record(
"provider.precommit.released",
json!({
"reason": reason,
"prefetchedBytes": self.prefetched_bytes,
"waitMs": self.started_at.elapsed().as_millis(),
}),
);
}
self.committed = true;
self.prefetched_bytes = 0;
self.replay_grace_started_at = None;
std::mem::take(&mut self.pending)
}
}
@@ -484,6 +461,7 @@ pub(super) enum WebSocketFailurePolicy {
pub(super) struct WebSocketRecoveryContext<'a> {
pub(super) policy: WebSocketFailurePolicy,
pub(super) replay_boundary: ReplayBoundary,
pub(super) requirement: TransportRequirement,
pub(super) retry_count: u32,
pub(super) max_retries: u32,
@@ -576,34 +554,10 @@ pub(super) fn apply_websocket_recovery_policy(
.session_transport_recovery
.record_websocket_failure(key, context.max_retries)
});
let send_state = failure.error.send_state();
let post_send = matches!(
send_state,
UpstreamSendState::Sent | UpstreamSendState::Ambiguous
);
if send_state == UpstreamSendState::Ambiguous || (post_send && !failure.error.replay_is_safe())
{
tracing::warn!(
request_id = context.request_id,
attempt_index = context.attempt_index,
account_id = context.account_id,
websocket_failure_kind = failure.error.kind().as_str(),
websocket_failure_code = failure
.error
.diagnostic()
.and_then(ProviderDiagnostic::code)
.unwrap_or(""),
upstream_send_state = ?send_state,
transport_requirement = context.requirement.as_str(),
continuation_recovery_action = "client_replay_required",
session_http_fallback = session_budget_exhausted,
session_affinity_present = context.session_transport_key.is_some(),
session_transport_key_hash = context.session_transport_key_hash.unwrap_or(""),
"OpenAI upstream WebSocket failed after payload send; proxy replay was suppressed"
);
// 已交付响应只影响后续请求的会话传输选择,不能重放当前请求
if matches!(context.replay_boundary, ReplayBoundary::AfterSemanticOutput) {
return;
}
if matches!(
context.requirement,
TransportRequirement::ExactWebSocketContinuation | TransportRequirement::HttpRequired
@@ -611,6 +565,8 @@ pub(super) fn apply_websocket_recovery_policy(
return;
}
// 有效输出前,已发送或结果不明也允许按预算尽力恢复
// 保留发送事实和 replay_is_safe 原值,重试意图不能伪装成上游未执行的证明
// 传输恢复必须保持原账号可调度;最终 HTTP attempt 若仍失败,再按真实 HTTP
// 结果更新账号健康度,避免中间 WS 错误把同账号钉选提前冷却掉
failure.account_failure = None;
@@ -643,6 +599,8 @@ pub(super) fn apply_websocket_recovery_policy(
websocket_retry_count = retry_index.get(),
websocket_max_retries = context.max_retries,
websocket_retry_delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
upstream_send_state = ?failure.error.send_state(),
replay_proved_safe = failure.error.replay_is_safe(),
transport_requirement = context.requirement.as_str(),
"OpenAI upstream WebSocket failed; retrying the same account"
);
@@ -1229,6 +1187,7 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
CodexWebSocketExchangeError::OriginHalfOpenBusy => ("connect", "origin_half_open_busy"),
CodexWebSocketExchangeError::InvalidRequest(_) => ("prepare", "invalid_request"),
CodexWebSocketExchangeError::SendTimeout { .. } => ("send", "send_timeout"),
CodexWebSocketExchangeError::SendNotStarted => ("send", "send_not_started"),
CodexWebSocketExchangeError::ReceiveIdleTimeout { .. } => {
("receive", "receive_idle_timeout")
}
@@ -1311,6 +1270,9 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
CodexWebSocketExchangeError::SharedConnectFailed => {
"OpenAI shared WebSocket connection failed before payload send".to_owned()
}
CodexWebSocketExchangeError::SendNotStarted => {
"OpenAI WebSocket connection closed before payload send started".to_owned()
}
CodexWebSocketExchangeError::SendTimeout { timeout } => {
format!("OpenAI WebSocket payload send timed out after {timeout:?}")
}
@@ -1630,6 +1592,7 @@ pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> Upstr
| CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy
| CodexWebSocketExchangeError::SharedConnectFailed
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::ContinuationUnavailable { .. } => UpstreamSendState::NotSent,
CodexWebSocketExchangeError::Upstream(_)
| CodexWebSocketExchangeError::ConnectionLimitReached(_)
@@ -1667,6 +1630,7 @@ pub(super) fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> Provi
CodexWebSocketExchangeError::Upstream(_) => ProviderErrorKind::Unavailable,
CodexWebSocketExchangeError::ConnectionLimitReached(_) => ProviderErrorKind::RateLimited,
CodexWebSocketExchangeError::Transport(_)
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::StreamEndedBeforeTerminal { .. }
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. } => {
@@ -125,9 +125,6 @@ const WEBSOCKET_TRANSPORT: &str = "websocket";
// 在已观测到的 Codex OAuth 上游 16 MiB 附近消息边界前留出传输 metadata 余量
const WEBSOCKET_HTTP_FALLBACK_THRESHOLD_BYTES: usize = 15 * 1024 * 1024;
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
/// 短暂保留 response.created 等结构事件,让随后到达的明确拒绝可以无感换号;
/// 到期即放行,避免模型长时间思考时让客户端一直收不到首事件
const STREAM_REPLAY_GRACE: Duration = Duration::from_millis(2_500);
// 额度拒绝后的 usage 补查自身执行时长上限;结算等待的随机延迟见 jitter 模块
const QUOTA_FAILURE_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api";
@@ -9,12 +9,14 @@ use gateway_core::engine::response_control::{
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio_tungstenite::tungstenite::{self, Message};
use super::CodexWebSocketExchangeError;
pub(super) const WEBSOCKET_SEND_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub(super) enum PumpCommand {
Send {
message: Message,
ack: oneshot::Sender<Result<(), tungstenite::Error>>,
ack: oneshot::Sender<Result<(), CodexWebSocketExchangeError>>,
},
}
@@ -28,15 +30,16 @@ pub(super) struct PumpControl {
pub(super) async fn send_message(
commands: &mpsc::Sender<PumpCommand>,
message: Message,
) -> Result<(), tungstenite::Error> {
) -> Result<(), CodexWebSocketExchangeError> {
let (ack, rx_ack) = oneshot::channel();
commands
.send(PumpCommand::Send { message, ack })
.await
.map_err(|_| tungstenite::Error::ConnectionClosed)?;
.map_err(|_| CodexWebSocketExchangeError::SendNotStarted)?;
// 回执丢失不能证明未发送,只有 pump 明确拒绝的命令才允许按 NotSent 恢复
rx_ack
.await
.unwrap_or(Err(tungstenite::Error::ConnectionClosed))
.unwrap_or_else(|_| Err(tungstenite::Error::ConnectionClosed.into()))
}
#[async_trait]
@@ -607,13 +607,17 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
}));
trace.capture("upstream.request.body", request.payload_text().as_bytes());
if let Err(error) = send_websocket_request(&websocket, request.payload_text()).await {
let observation = websocket
.observation()
.with_exit_reason("outbound_transport_error");
let not_started = matches!(error, CodexWebSocketExchangeError::SendNotStarted);
let observation = websocket.observation();
let observation = if observation.exit_reason() == "running" {
observation.with_exit_reason("outbound_transport_error")
} else {
observation
};
trace.record(
"upstream.send.failed",
serde_json::json!({
"phase": "websocket_payload", "sendState": "ambiguous",
"phase": "websocket_payload", "sendState": if not_started { "not_sent" } else { "ambiguous" },
"failureReason": error.transport_failure_reason().unwrap_or(observation.exit_reason()),
"connectionId": observation.connection_id().to_string(),
"connectionAgeMs": observation.age_ms(),
@@ -622,9 +626,16 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
}),
);
discard_after_send(websocket, lease, observation.clone()).await;
return Err(post_send_ambiguous(
error.with_connection_observation(observation),
));
let error = if not_started {
if request.continuation().permits_fresh_connection() {
error
} else {
continuation_unavailable(PreviousResponseUnavailableReason::ReusedConnectionLost)
}
} else {
post_send_ambiguous(error)
};
return Err(error.with_connection_observation(observation));
}
trace.record("upstream.payload.sent", serde_json::json!({}));
let connection_local_available = lease.is_some();
@@ -44,6 +44,9 @@ pub enum CodexWebSocketExchangeError {
/// 同一精确会话的单飞建连失败
#[error("shared websocket connection attempt failed before payload send")]
SharedConnectFailed,
/// 连接已关闭或 pump 已退出,业务帧尚未交给底层发送
#[error("websocket connection closed before payload send started")]
SendNotStarted,
/// payload 已可能送达上游,禁止自动重放到其他 transport 或账号
#[error("websocket failed after payload send; replay outcome is ambiguous: {message}")]
PostSendAmbiguous {
@@ -2,7 +2,7 @@
//!
//! 每条上游 WebSocket 连接都由一个后台 pump 任务独占:
//! - 持续读取 socket:一旦观察到 `Close` / EOF / 传输错误,立即把连接标记为 `closed`
//! - 自动回应上游 `Ping`,并按 `ping_interval` 主动 `Ping`;收到任意入站帧即解除本次心跳 deadline
//! - 自动回应上游 `Ping`,并按 `ping_interval` 主动 `Ping`;收到非关闭入站帧即解除本次心跳 deadline
//! - 可选 `liveness_timeout`:长时间无任何入站活动时判定连接失活并退出
//!
//! 因此空闲连接的“死没死”在后台被实时感知;复用方只需零成本读取 [`PumpedWebSocket::is_closed`],
@@ -31,7 +31,7 @@ use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
use super::{
CodexWebSocketCloseError,
CodexWebSocketCloseError, CodexWebSocketExchangeError,
control::{PumpCommand, PumpControl, SharedMessages, send_message},
};
@@ -342,8 +342,11 @@ impl PumpedWebSocket {
}
}
/// 通过 pump 发送一帧,返回底层 `send` 的结果
pub(crate) async fn send(&self, message: Message) -> Result<(), tungstenite::Error> {
/// 通过 pump 发送一帧,区分未开始发送与底层传输失败
pub(crate) async fn send(&self, message: Message) -> Result<(), CodexWebSocketExchangeError> {
if self.is_closed() {
return Err(CodexWebSocketExchangeError::SendNotStarted);
}
send_message(&self.tx_command, message).await
}
@@ -457,13 +460,12 @@ async fn pump_loop(
}
// 背压期间无法读取 socket,不能把这段本地下游阻塞误算成上游静默
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
}
command = rx_command.recv() => {
let Some(command) = command else {
break 'pump PumpExitReason::CommandChannelClosed;
};
if let Some(reason) = handle_command(&mut inner, command).await {
if let Some(reason) = handle_command(&mut inner, command, closed.load(Ordering::Acquire)).await {
break 'pump reason;
}
}
@@ -478,7 +480,7 @@ async fn pump_loop(
let Some(command) = command else {
break 'pump PumpExitReason::CommandChannelClosed;
};
if let Some(reason) = handle_command(&mut inner, command).await {
if let Some(reason) = handle_command(&mut inner, command, closed.load(Ordering::Acquire)).await {
break 'pump reason;
}
}
@@ -486,9 +488,12 @@ async fn pump_loop(
match message {
None => break 'pump PumpExitReason::UpstreamEof,
Some(Ok(message)) => {
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
observe_inbound(connection_id, &mut pending_ping, &message);
// Close 是终止信号,不能刷新链路健康时间或抹掉关闭前的静默时长
if !matches!(message, Message::Close(_)) {
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
observe_inbound(connection_id, &mut pending_ping, &message);
}
match message {
Message::Ping(payload) => {
if let Err(error) = inner.send(Message::Pong(payload)).await {
@@ -511,6 +516,9 @@ async fn pump_loop(
}),
_ => None,
};
if let Some(reason) = &terminal_reason {
record_closed(&closed, &lifecycle, reason);
}
if let Some(reason) = enqueue_inbound(
&tx_message,
&mut pending_inbound,
@@ -531,6 +539,7 @@ async fn pump_loop(
message: err.to_string(),
metric_reason: transport_metric_reason(&err),
};
record_closed(&closed, &lifecycle, &terminal_reason);
if let Some(reason) = enqueue_inbound(
&tx_message,
&mut pending_inbound,
@@ -571,15 +580,17 @@ async fn pump_loop(
}
};
closed.store(true, Ordering::Release);
record_closed(&closed, &lifecycle, &reason);
// 排队命令尚未进入底层发送,关闭握手前即回绝,避免调用方等待或误判为已发送
rx_command.close();
while let Ok(PumpCommand::Send { ack, .. }) = rx_command.try_recv() {
let _ = ack.send(Err(CodexWebSocketExchangeError::SendNotStarted));
}
let observation = {
let closed_at = Instant::now();
let mut lifecycle = lifecycle
let lifecycle = lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned");
lifecycle.closed_at = Some(closed_at);
lifecycle.exit_reason = Some(reason.clone());
lifecycle.observation(connection_id, closed_at)
lifecycle.observation(connection_id, Instant::now())
};
log_pump_exit(
connection_id,
@@ -620,15 +631,27 @@ fn enqueue_inbound(
}
}
async fn handle_command(inner: &mut RawWsStream, command: PumpCommand) -> Option<PumpExitReason> {
async fn handle_command(
inner: &mut RawWsStream,
command: PumpCommand,
closed: bool,
) -> Option<PumpExitReason> {
let PumpCommand::Send { message, ack } = command;
// 发送超时或取消后,尚在队列里的业务帧不应继续打到上游
if ack.is_closed() {
return None;
}
if closed {
let _ = ack.send(Err(CodexWebSocketExchangeError::SendNotStarted));
return None;
}
let is_close = matches!(message, Message::Close(_));
let result = inner.send(message).await;
let transport_error = result
.as_ref()
.err()
.map(|error| (error.to_string(), transport_metric_reason(error)));
let _ = ack.send(result);
let _ = ack.send(result.map_err(CodexWebSocketExchangeError::Transport));
match transport_error {
Some((message, metric_reason)) => Some(PumpExitReason::OutboundTransportError {
message,
@@ -662,6 +685,22 @@ fn record_activity(lifecycle: &Mutex<PumpLifecycleState>, observed_at: Instant)
.last_activity = observed_at;
}
fn record_closed(
closed: &AtomicBool,
lifecycle: &Mutex<PumpLifecycleState>,
reason: &PumpExitReason,
) {
let mut lifecycle = lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned");
// 先记录已观察到的终止事实,再通知复用方;入站背压不能延迟连接失效
if lifecycle.closed_at.is_none() {
lifecycle.closed_at = Some(Instant::now());
lifecycle.exit_reason = Some(reason.clone());
}
closed.store(true, Ordering::Release);
}
pub(crate) fn transport_metric_reason(error: &tungstenite::Error) -> &'static str {
match error {
tungstenite::Error::Io(error) => match error.kind() {
@@ -488,6 +488,37 @@ async fn quota_refresh_synchronizes_plan_changes_without_losing_subtypes() {
}
}
#[tokio::test]
async fn passive_quota_observation_preserves_account_egress() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_passive_quota_egress";
create_account(&store, account_id).await;
let proxy = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:8080").unwrap();
let location = serde_json::from_value::<gateway_core::account::RequestLocation>(json!({
"country": "US", "region": "New York", "city": "New York",
"timezone": "America/New_York"
}))
.unwrap();
store.set_egress(account_id, Some(proxy.clone()), Some(location.clone()));
let account = store.account(account_id).unwrap();
let observation = parse_rate_limits_event(&json!({
"type": "codex.rate_limits",
"plan_type": "pro",
"rate_limits": {"primary": {"used_percent": 5, "window_minutes": 300, "reset_at": 1900000000}}
}))
.unwrap();
quota_service(&store)
.synchronize_passive_rate_limits(&account, &[observation])
.await
.unwrap();
let updated = store.account(account_id).unwrap();
assert!(store.quota_json(account_id).is_some());
assert_eq!(updated.outbound_proxy(), Some(&proxy));
assert_eq!(updated.request_location(), Some(&location));
}
#[tokio::test]
async fn passive_plan_observations_update_account_without_replaying_stale_plan() {
let store = Arc::new(MemoryAccountStore::default());
@@ -4220,7 +4220,9 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
assert_eq!(error.kind(), ProviderErrorKind::Transport);
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert!(!error.replay_is_safe());
assert_eq!(error.pre_delivery_retry(), None);
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == 1)
);
assert_eq!(
error.diagnostic().map(|diagnostic| diagnostic.as_str()),
Some(
@@ -4729,7 +4731,9 @@ async fn ambiguous_websocket_close_reconnects_and_retains_native_continuation()
assert!(saw_websocket_observation);
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert_eq!(error.pre_delivery_retry(), None);
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == 1)
);
assert!(error.upstream_code().is_none());
let close: Value = serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap();
assert_eq!(close["type"], "websocket.close");
@@ -4945,7 +4949,17 @@ async fn repeated_websocket_failures_exhaust_budget_then_use_http_and_report_htt
}
};
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert_eq!(error.pre_delivery_retry(), None);
if index < 2 {
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportRetry { .. })
));
} else {
assert_eq!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportFallback)
);
}
assert!(!error.replay_is_safe());
}
for index in 0..2 {
@@ -5950,7 +5964,7 @@ async fn websocket_turn_state_metadata_is_exposed_through_response_observation()
}
#[tokio::test]
async fn websocket_turn_state_metadata_close_does_not_authorize_replay() {
async fn websocket_metadata_close_requests_bounded_retry_without_claiming_replay_safety() {
const ACCOUNT_ID: &str = "acct_websocket_metadata_close";
const TURN_STATE: &str = "turn-state-before-close";
@@ -6032,7 +6046,9 @@ async fn websocket_turn_state_metadata_close_does_not_authorize_replay() {
assert!(session_update.is_none());
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert!(!error.replay_is_safe());
assert_eq!(error.pre_delivery_retry(), None);
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == 1)
);
assert!(error.upstream_code().is_none());
let close: Value = serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap();
assert_eq!(close["type"], "websocket.close");
@@ -9059,7 +9075,7 @@ async fn ordinary_request_should_hold_created_until_later_failure_can_rotate() {
}
#[tokio::test]
async fn ordinary_request_should_bound_structural_event_replay_grace() {
async fn ordinary_request_should_hold_structural_events_until_upstream_finishes() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_bounded_replay_grace").await;
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
@@ -9079,29 +9095,24 @@ async fn ordinary_request_should_bound_structural_event_replay_grace() {
.await
.expect("prepare provider stream");
let first_event = timeout(Duration::from_secs(4), async {
loop {
let event = stream
.next()
.await
.expect("provider stream must stay open")
.expect("provider event");
if event.has_client_event() {
return event;
assert!(
timeout(Duration::from_secs(3), async {
while let Some(event) = stream.next().await {
assert!(!event.expect("provider event").has_client_event());
}
}
})
.await
.expect("response.created must be released after the bounded grace period");
assert_eq!(
first_event.wire_event().and_then(|wire| wire.event_type()),
Some("response.created")
})
.await
.is_err()
);
release.send(()).expect("finish upstream response");
while let Some(event) = stream.next().await {
event.expect("clean upstream EOF");
}
let events = stream.collect::<Vec<_>>().await;
assert!(events.iter().any(|event| {
event
.as_ref()
.unwrap()
.wire_event()
.is_some_and(|wire| wire.event_type() == Some("response.created"))
}));
server.await.expect("chunked SSE server");
}
@@ -9593,7 +9604,7 @@ async fn exact_websocket_busy_then_replay_scope_relaxation_is_rejected_before_se
}
#[tokio::test]
async fn continuation_prefetch_over_128_kib_should_wait_for_grace_without_protocol_failure() {
async fn continuation_prefetch_over_128_kib_stays_buffered_until_eof_without_protocol_failure() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_prefetch_limit").await;
let padding = "x".repeat(128 * 1024);
@@ -9638,11 +9649,17 @@ async fn continuation_prefetch_over_128_kib_should_wait_for_grace_without_protoc
timeout(Duration::from_secs(1), next_visible.as_mut())
.await
.is_err(),
"large structural events must remain buffered during the grace period"
"large structural events must remain buffered before semantic output"
);
timeout(Duration::from_secs(3), next_visible)
assert!(
timeout(Duration::from_secs(3), next_visible.as_mut())
.await
.is_err()
);
release.send(()).expect("finish upstream response");
timeout(Duration::from_secs(1), next_visible)
.await
.expect("grace expiry must release buffered wire")
.expect("EOF releases original wire")
};
assert_eq!(
@@ -9655,7 +9672,6 @@ async fn continuation_prefetch_over_128_kib_should_wait_for_grace_without_protoc
.raw_sse_frame()
.unwrap()
.to_vec();
release.send(()).expect("finish upstream response");
while let Some(event) = stream.next().await {
if let Some(frame) = event
.expect("clean upstream EOF cannot become a protocol failure")
@@ -11671,7 +11687,7 @@ async fn quota_continuation_stream_rejection_only_projects_before_delivery() {
}
#[tokio::test]
async fn quota_continuation_after_structural_commit_preserves_original_failure() {
async fn late_quota_continuation_rejection_requires_client_replay_before_output() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let original = json!({"type":"response.failed","response":{"id":"resp_quota","error":{"code":"usage_limit_reached","message":"limit reached"}}});
@@ -11687,16 +11703,15 @@ async fn quota_continuation_after_structural_commit_preserves_original_failure()
)
.await
.unwrap();
timeout(Duration::from_secs(3), async {
while let Some(event) = stream.next().await {
if event.unwrap().has_client_event() {
return;
assert!(
timeout(Duration::from_secs(3), async {
while let Some(event) = stream.next().await {
assert!(!event.unwrap().has_client_event());
}
}
panic!("expected grace commit");
})
.await
.expect("bounded grace");
})
.await
.is_err()
);
release.send(()).unwrap();
let mut delivered_failure = false;
let error = loop {
@@ -11710,9 +11725,9 @@ async fn quota_continuation_after_structural_commit_preserves_original_failure()
None => panic!("expected failure"),
}
};
assert!(delivered_failure);
assert!(!error.replay_is_safe());
assert_ne!(
assert!(!delivered_failure);
assert!(error.replay_is_safe());
assert_eq!(
error.continuation_recovery_disposition(),
Some(ContinuationRecoveryDisposition::ClientReplayRequired)
);
@@ -1,4 +1,4 @@
//! 验证结构事件缓冲、交付宽限与取消对重试边界的影响
//! 验证结构事件缓冲、长时间等待与取消对重试边界的影响
use gateway_core::diagnostics::TraceContext;
use gateway_core::engine::provider::ProviderStream;
@@ -16,7 +16,7 @@ fn traced_context(trace: &TraceContext, cancellation: CancellationToken) -> Atte
)
.with_trace(trace.clone()),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
SystemTime::now() + Duration::from_secs(3_600),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
@@ -127,7 +127,7 @@ async fn large_structural_events_preserve_overload_replay_on_http_and_websocket(
.unwrap();
assert!(
timeout(Duration::from_millis(1_500), next_client_event(&mut stream))
timeout(Duration::from_millis(3_500), next_client_event(&mut stream))
.await
.is_err()
);
@@ -156,12 +156,11 @@ async fn large_structural_events_preserve_overload_replay_on_http_and_websocket(
}
#[tokio::test]
async fn later_structural_events_do_not_extend_grace_and_late_overload_is_not_replayable() {
async fn late_overload_after_multiple_structural_events_remains_replayable() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let created = sse(&structural_event("response.created", 96 * 1024));
let progress = sse(&structural_event("response.in_progress", 96 * 1024));
let expected_bytes = created.len() + progress.len();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (release, released) = oneshot::channel();
@@ -184,30 +183,30 @@ async fn later_structural_events_do_not_extend_grace_and_late_overload_is_not_re
)
.await
.unwrap();
let first = timeout(Duration::from_millis(3_500), next_client_event(&mut stream))
.await
.unwrap();
assert_eq!(
first.wire_event().unwrap().event_type(),
Some("response.created")
assert!(
timeout(Duration::from_millis(3_500), next_client_event(&mut stream))
.await
.is_err()
);
release.send(()).unwrap();
let (events, error) = timeout(Duration::from_secs(1), stream_failure(&mut stream))
let (events, mut error) = timeout(Duration::from_secs(1), stream_failure(&mut stream))
.await
.unwrap();
assert!(!error.replay_is_safe());
assert!(error.pre_delivery_retry().is_none());
assert!(events.iter().any(|event| {
event
.wire_event()
.is_some_and(|wire| wire.data() == &overload())
}));
let releases = releases(&trace);
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["attemptIndex"], 1);
assert_eq!(releases[0]["data"]["reason"], "grace_timeout");
assert_eq!(releases[0]["data"]["prefetchedBytes"], expected_bytes);
assert!((2_500..3_500).contains(&releases[0]["data"]["waitMs"].as_u64().unwrap()));
assert!(events.iter().all(|event| !event.has_client_event()));
assert!(error.replay_is_safe());
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransientRetry { .. })
));
assert_eq!(
error
.take_atomic_client_events()
.iter()
.filter_map(|event| event.wire_event()?.event_type())
.collect::<Vec<_>>(),
["response.created", "response.in_progress", "error"]
);
assert!(releases(&trace).is_empty());
server.await.unwrap();
}
@@ -311,3 +310,164 @@ async fn cancelling_buffered_structural_events_does_not_release_or_offer_replay(
release.send(()).unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn twenty_minute_structural_progress_preserves_late_overload_recovery() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (progress, mut progress_requests) = tokio::sync::mpsc::channel::<()>(1);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
read_http_request(&mut socket).await;
socket.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n").await.unwrap();
write_http_chunk(
&mut socket,
&sse(&structural_event("response.created", 1024)),
)
.await;
while progress_requests.recv().await.is_some() {
write_http_chunk(
&mut socket,
&sse(&structural_event("response.in_progress", 1024)),
)
.await;
}
write_http_chunk(&mut socket, &sse(&overload())).await;
socket.write_all(b"0\r\n\r\n").await.unwrap();
});
let trace = TraceContext::new("req_precommit");
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
traced_context(&trace, CancellationToken::new()),
)
.await
.unwrap();
assert!(
timeout(Duration::from_millis(100), next_client_event(&mut stream))
.await
.is_err()
);
// 结构进度保持上游传输活跃,长思考不能额外触发首内容等待期限
for _ in 0..20 {
tokio::time::pause();
tokio::time::advance(Duration::from_secs(60)).await;
tokio::time::resume();
progress.send(()).await.unwrap();
assert!(
timeout(Duration::from_millis(50), next_client_event(&mut stream))
.await
.is_err()
);
}
drop(progress);
let (events, error) = timeout(Duration::from_secs(1), stream_failure(&mut stream))
.await
.unwrap();
assert!(events.iter().all(|event| !event.has_client_event()));
assert!(error.replay_is_safe());
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransientRetry { .. })
));
assert!(releases(&trace).is_empty());
server.await.unwrap();
}
#[tokio::test]
async fn structural_preamble_over_16_mib_preserves_late_overload_recovery() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
// 大段配置回显仍属于前导事件,累计体积不应改变上游失败的恢复方式
let preamble = sse(&structural_event("response.in_progress", 1024 * 1024)).repeat(17);
let (base_url, release, _, server) =
paused_chunked_sse_server(preamble, sse(&overload())).await;
let trace = TraceContext::new("req_precommit");
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
traced_context(&trace, CancellationToken::new()),
)
.await
.unwrap();
assert!(
timeout(Duration::from_secs(1), next_client_event(&mut stream))
.await
.is_err()
);
release.send(()).unwrap();
let (events, mut error) = timeout(Duration::from_secs(10), stream_failure(&mut stream))
.await
.unwrap();
assert!(events.iter().all(|event| !event.has_client_event()));
assert!(error.replay_is_safe());
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransientRetry { .. })
));
let buffered_bytes: usize = error
.take_atomic_client_events()
.iter()
.filter_map(|event| event.wire_event()?.raw_sse_frame())
.map(bytes::Bytes::len)
.sum();
assert!(buffered_bytes > 16 * 1024 * 1024);
assert!(releases(&trace).is_empty());
server.await.unwrap();
}
#[tokio::test]
async fn websocket_disconnect_recovery_honors_request_retry_budget_without_session_affinity() {
for max_retries in [0, 5] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
for _ in 0..=max_retries {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
ws.next().await.unwrap().unwrap();
ws.send(Message::Text(
structural_event("response.created", 1024)
.to_string()
.into(),
))
.await
.unwrap();
ws.close(None).await.unwrap();
}
});
let provider = provider_with_base_url_and_retry_budget(&store, base_url, max_retries);
for retry_count in 0..=max_retries {
let transport = NonZeroU32::new(retry_count)
.map_or(AttemptTransport::Default, AttemptTransport::Retry);
let trace = TraceContext::new("req_precommit");
let mut stream = Arc::clone(&provider)
.execute(
planned_request("openai", generate_operation()),
traced_context(&trace, CancellationToken::new()).with_transport(transport),
)
.await
.unwrap();
let (events, error) = stream_failure(&mut stream).await;
assert!(events.iter().all(|event| !event.has_client_event()));
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert!(!error.replay_is_safe());
if retry_count < max_retries {
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == retry_count + 1)
);
} else {
assert_eq!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportFallback)
);
}
assert!(releases(&trace).is_empty());
}
server.await.unwrap();
}
}
@@ -3,6 +3,158 @@
use super::*;
use gateway_core::engine::response_control::{ResponseControl, ResponseControlUnavailable};
#[tokio::test]
async fn cancelled_queued_control_is_not_forwarded_to_upstream() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
assert_eq!(next_json(&mut ws).await["type"], "response.create");
ws.send(Message::Text(
json!({
"type":"response.output_text.delta", "output_index":0,
"content_index":0, "delta":"partial"
})
.to_string()
.into(),
))
.await
.unwrap();
let received = next_json(&mut ws).await;
assert_eq!(
received["marker"], "active",
"cancelled command must never reach upstream"
);
});
let control = ResponseControl::default();
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", generate_operation()),
interrupt_context(control.clone(), None),
)
.await
.unwrap();
loop {
if stream.next().await.unwrap().unwrap().has_client_event() {
break;
}
}
{
// 单线程 runtime 中只入队一次,未让 pump 执行便取消调用方
let mut cancelled =
Box::pin(control.send(r#"{"type":"future.control","marker":"cancelled"}"#));
assert!(futures::poll!(cancelled.as_mut()).is_pending());
}
timeout(
Duration::from_secs(2),
control.send(r#"{"type":"future.control","marker":"active"}"#),
)
.await
.unwrap()
.unwrap();
timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn controls_reject_closed_socket_without_losing_backpressured_close() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (burst, ready) = oneshot::channel();
let (closed, closing) = oneshot::channel();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
next_json(&mut ws).await;
let delta = Message::Text(
json!({
"type":"response.output_text.delta", "output_index":0,
"content_index":0, "delta":"partial"
})
.to_string()
.into(),
);
ws.send(delta.clone()).await.unwrap();
ready.await.unwrap();
// 填满 exchange 的 16 帧和 pump 的 64 帧,并占住正在转发的一帧
for _ in 0..81 {
ws.send(delta.clone()).await.unwrap();
}
// Pong 证明 pump 已读取前面的突发数据,随后 Close 必须等待下游排空
ws.send(Message::Ping(vec![1].into())).await.unwrap();
assert!(matches!(
ws.next().await.unwrap().unwrap(),
Message::Pong(_)
));
ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: "".into(),
}))
.await
.unwrap();
closed.send(()).unwrap();
// 保留 TCP 直到客户端确认关闭,避免把 Close 场景变成连接重置
let _ = ws.next().await;
});
let control = ResponseControl::default();
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", generate_operation()),
interrupt_context(control.clone(), None),
)
.await
.unwrap();
loop {
if stream.next().await.unwrap().unwrap().has_client_event() {
break;
}
}
burst.send(()).unwrap();
timeout(Duration::from_secs(2), closing)
.await
.unwrap()
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
timeout(
Duration::from_secs(2),
control.send(r#"{"type":"response.interrupt"}"#)
)
.await
.unwrap(),
Err(ResponseControlUnavailable)
);
let (delivered, error) = timeout(Duration::from_secs(2), async {
let mut delivered = 0;
loop {
match stream.next().await.unwrap() {
Ok(event) => delivered += usize::from(event.has_client_event()),
Err(error) => break (delivered, error),
}
}
})
.await
.unwrap();
assert_eq!(delivered, 81);
assert_eq!(error.diagnostic().unwrap().code(), Some("upstream_close"));
assert!(error.diagnostic().unwrap().as_str().contains("1000"));
assert!(
error.pre_delivery_retry().is_none(),
"delivered output cannot be replayed"
);
timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap();
}
async fn next_json(socket: &mut tokio_tungstenite::WebSocketStream<TcpStream>) -> Value {
loop {
match socket.next().await.unwrap().unwrap() {
@@ -665,6 +665,8 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
)
.with_scheduling(current.concurrency_limit(), current.weight())
.with_refresh_schedule(rebuild.has_refresh_token, rebuild.next_refresh_at)
.with_outbound_proxy(current.outbound_proxy().cloned())
.with_request_location(current.request_location().cloned())
}
#[derive(Default)]
@@ -3,6 +3,131 @@
use super::*;
use gateway_core::diagnostics::TraceContext;
#[tokio::test]
async fn closed_connection_handoff_rejects_payload_as_not_sent() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
ws.close(None).await.unwrap();
// pump 的关闭确认是 best-effort;无业务帧的 EOF 同样能证明连接已退出
assert!(
matches!(
ws.next().await,
Some(Ok(Message::Close(_)))
| Some(Err(tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake
)))
),
"no business payload may arrive after the observed close"
);
});
let trace = TraceContext::new("req_closed_handoff");
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
request.local_conversation_id = Some("closed-handoff".to_owned());
request.force_http_sse = false;
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let pending = backend.create_response_stream(
&request,
request_context("req_closed_handoff", Some("chatgpt-account")).with_trace(&trace),
);
tokio::pin!(pending);
// 停在后台建连交接处,等 pump 确认关闭后才恢复业务请求,确定性覆盖检查与发送间的窗口
assert!(futures::poll!(pending.as_mut()).is_pending());
timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap();
let error = pending
.await
.err()
.expect("closed handoff must reject send");
assert!(matches!(error, CodexClientError::WebSocket(
CodexWebSocketExchangeError::ConnectionObserved { source, .. }
) if matches!(*source, CodexWebSocketExchangeError::SendNotStarted)));
let snapshot = trace.snapshot().unwrap();
let events = snapshot["events"].as_array().unwrap();
let failure = events
.iter()
.find(|event| event["stage"] == "upstream.send.failed")
.unwrap();
assert_eq!(failure["data"]["sendState"], "not_sent");
assert_eq!(failure["data"]["failureReason"], "upstream_close");
assert!(
!events
.iter()
.any(|event| event["stage"] == "upstream.payload.sent")
);
}
#[tokio::test]
async fn close_observation_preserves_idle_time_since_last_inbound_activity() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (close, closed) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
ws.next().await.unwrap().unwrap();
ws.send(Message::Text(
json!({
"type": "response.output_text.delta", "delta": "initial"
})
.to_string()
.into(),
))
.await
.unwrap();
closed.await.unwrap();
ws.close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame {
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal,
reason: "".into(),
}))
.await
.unwrap();
});
let trace = TraceContext::new("req_idle_close");
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
request.force_http_sse = false;
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
);
let mut response = backend
.create_response_stream(
&request,
request_context("req_idle_close", Some("chatgpt-account")).with_trace(&trace),
)
.await
.unwrap();
response.body.next().await.unwrap().unwrap();
tokio::time::sleep(Duration::from_millis(80)).await;
close.send(()).unwrap();
let error = response.body.next().await.unwrap().unwrap_err();
let CodexClientError::WebSocket(error) = error else {
panic!("expected websocket close");
};
assert_eq!(error.close_before_terminal().unwrap().code(), Some(1000));
let observation = error.connection_observation().unwrap();
assert_eq!(observation.exit_reason(), "normal_close");
assert!(
observation.idle_ms() >= 80,
"Close must not reset idle time"
);
server.await.unwrap();
}
#[tokio::test]
async fn diagnostics_preserve_abrupt_disconnect_facts_without_response_content() {
for (reuse, send_event) in [(false, false), (false, true), (true, false), (true, true)] {
+1
View File
@@ -24,3 +24,4 @@ d431ebf24669451da06bb6671136bf58cddcc713b43a1a705eb33f9cb4f9b577 0023_error_det
fa99dde0fe0b3d7da6693d76b2735629dde0dc255ea8c53f0354857a4e80669b 0024_preferred_account_affinity.sql
299d347add09b1e6f086732c0db8269efb640db4ecccb948e17c067b572afc43 0025_request_observation.sql
d4b599066d7382a1c9cb6b3c003200046579e4fce3531f232cbd665a9d92c542 0026_codex_privacy_policy.sql
8c9bbecd8315a26e928213a82a2ec651c35976e0643b82caef1d1bcfeee2916f 0027_request_observation_projection.sql
@@ -0,0 +1,70 @@
-- 通用观测投影允许列裁剪与并行规划,避免普通统计承担逐行计时投影的成本
-- 保留 LATERAL 的只读视图语义,重复计时计算由各聚合查询在筛选后约束
create or replace view model_request_observations as
select mr.*,
(mr.request_observation_json #>> '{request,configRevision}')::bigint as config_revision,
(mr.request_observation_json #>> '{request,protocol}') as protocol,
(mr.request_observation_json #>> '{request,endpoint}') as endpoint,
(mr.request_observation_json #>> '{account,name}') as provider_account_name_snapshot,
(mr.request_observation_json #>> '{account,email}') as provider_account_email_snapshot,
(mr.request_observation_json #>> '{account,authenticationKind}') as provider_account_authentication_kind_snapshot,
(mr.request_observation_json #>> '{routing,scope}') as routing_scope,
array(select jsonb_array_elements_text(mr.request_observation_json #> '{routing,groupRefs}')) as routing_group_refs,
(mr.request_observation_json #> '{routing,groupNamesSnapshot}') as routing_group_names_snapshot,
(mr.request_observation_json #>> '{error,providerErrorCode}') as provider_error_code,
(mr.request_observation_json #>> '{error,message}') as error_message,
(mr.request_observation_json #>> '{error,retryAfterMs}')::bigint as retry_after_ms,
(mr.request_observation_json #>> '{request,clientIp}')::inet as client_ip,
(mr.request_observation_json #>> '{request,userAgent}') as user_agent,
(mr.request_observation_json #>> '{request,reasoningEffort}') as reasoning_effort,
(mr.request_observation_json #>> '{request,reasoningPreset}') as reasoning_preset,
(mr.request_observation_json #>> '{request,subagentKind}') as subagent_kind,
(mr.request_observation_json #>> '{request,compact}')::boolean as compact,
measured.transport_decision_wait_ms as transport_decision_wait_ms,
measured.connect_ms as connect_ms,
measured.headers_ms as headers_ms,
measured.first_event_ms as first_event_ms,
measured.first_reasoning_ms as first_reasoning_ms,
measured.first_text_ms as first_text_ms,
measured.first_token_ms as first_token_ms,
measured.latency_ms as latency_ms,
measured.provider_processing_ms as provider_processing_ms,
measured.admission_decision_ms as admission_decision_ms,
measured.account_selection_wait_ms as account_selection_wait_ms,
measured.capacity_used_slots as capacity_used_slots,
measured.capacity_total_slots as capacity_total_slots,
(mr.request_observation_json #>> '{transport,httpVersion}') as http_version,
(mr.request_observation_json #>> '{transport,websocketPool}') as websocket_pool,
(mr.request_observation_json #>> '{transport,connection,id}') as upstream_connection_id,
(mr.request_observation_json #>> '{transport,connection,exitReason}') as upstream_connection_exit_reason,
(mr.request_observation_json #>> '{transport,connection,ageMs}')::bigint as upstream_connection_age_ms,
(mr.request_observation_json #>> '{transport,connection,idleMs}')::bigint as upstream_connection_idle_ms,
(mr.request_observation_json #>> '{response,responseModel}') as upstream_response_model,
(mr.request_observation_json #>> '{continuation,previousResponseIdHash}') as continuation_previous_response_id_hash,
(mr.request_observation_json #>> '{continuation,unavailableReason}') as continuation_unavailable_reason,
(mr.request_observation_json #>> '{recovery,retryDelayMs}')::bigint as recovery_retry_delay_ms,
(mr.request_observation_json #>> '{recovery,totalLatencyMs}')::bigint as recovery_total_latency_ms,
(mr.request_observation_json #>> '{timings,upstream,apiOverheadMs}')::double precision as upstream_api_overhead_ms,
(mr.request_observation_json #>> '{timings,upstream,engineMs}')::double precision as upstream_engine_ms,
(mr.request_observation_json #>> '{timings,upstream,engineIapiTtftMs}')::double precision as upstream_engine_iapi_ttft_ms,
(mr.request_observation_json #>> '{timings,upstream,engineServiceTtftMs}')::double precision as upstream_engine_service_ttft_ms,
(mr.request_observation_json #>> '{timings,upstream,engineIapiTbtMs}')::double precision as upstream_engine_iapi_tbt_ms,
(mr.request_observation_json #>> '{timings,upstream,engineServiceTbtMs}')::double precision as upstream_engine_service_tbt_ms,
measured.upstream_response_ms as upstream_response_ms
from model_requests mr
cross join lateral (
select (mr.request_observation_json #>> '{timings,local,transportDecisionWaitMs}')::bigint as transport_decision_wait_ms,
(mr.request_observation_json #>> '{timings,local,connectMs}')::bigint as connect_ms,
(mr.request_observation_json #>> '{timings,local,headersMs}')::bigint as headers_ms,
(mr.request_observation_json #>> '{timings,local,firstEventMs}')::bigint as first_event_ms,
(mr.request_observation_json #>> '{timings,local,firstReasoningMs}')::bigint as first_reasoning_ms,
(mr.request_observation_json #>> '{timings,local,firstTextMs}')::bigint as first_text_ms,
(mr.request_observation_json #>> '{timings,local,firstTokenMs}')::bigint as first_token_ms,
(mr.request_observation_json #>> '{timings,local,latencyMs}')::bigint as latency_ms,
(mr.request_observation_json #>> '{timings,upstream,processingMs}')::bigint as provider_processing_ms,
(mr.request_observation_json #>> '{scheduling,admissionDecisionMs}')::bigint as admission_decision_ms,
(mr.request_observation_json #>> '{scheduling,accountSelectionWaitMs}')::bigint as account_selection_wait_ms,
(mr.request_observation_json #>> '{scheduling,capacityUsedSlots}')::bigint as capacity_used_slots,
(mr.request_observation_json #>> '{scheduling,capacityTotalSlots}')::bigint as capacity_total_slots,
(mr.request_observation_json #>> '{timings,upstream,responseMs}')::bigint as upstream_response_ms
) measured;
+13 -4
View File
@@ -342,7 +342,7 @@ OAuth 的大 connection-local WS 续接,以及 HTTP `store=false` 成功后标
的 WS 增量,均在发送前返回 `status: 400 / previous_response_not_found`。客户端应清除旧 ID,
携带完整历史及工具调用/输出重试。官方 Codex 支持重连 WS 后完整重发,也可能按自身重试预算
切到 HTTP;其他客户端需要自行实现此合同。代理不缓存 transcript,不把增量输入当作独立新链,
不自动重放发送结果不确定的请求;HTTP 链后续步骤可能增加一次恢复信号、重连及全量上传
连接内续接失去原连接时仍需客户端恢复;HTTP 链后续步骤可能增加一次恢复信号、重连及全量上传
已建立模型执行的 Responses、Images 和 Search HTTP 响应按以下规则返回关联 ID:
`x-gateway-request-id` 为模型执行 ID;`x-request-id` 保留有效上游值,只有上游
@@ -435,6 +435,16 @@ xAI 的 `reasoning.effort` 接受 `none / minimal / low / medium / high / xhigh
#### 容量拒绝与重试
OpenAI Responses 的 `response.created`、`response.in_progress` 等前导事件保留到首次有效文本、
推理、工具活动或终态再交付,SSE 与 WebSocket 使用相同边界。前导缓冲不另设等待时长或累计体积上限,
等待沿用[请求期限](#请求期限)与传输超时配置。WebSocket 独立 Ping 保活;
HTTP 在首个业务事件前保留状态码和响应头,SSE 注释保活从提交响应后开始
WebSocket 在交付有效输出前断线或超时,可按 `stream_max_retries` 做同账号、有退避的尽力重试
(默认最多 5 次),耗尽后仅在请求允许时改用 HTTP。已发送或发送结果不明不单独阻止这项恢复;
次数限制不保证上游只执行一次,诊断保留原始发送状态。精确连接续接、显式终止拒绝、已观测的
外部副作用和下游提交仍限制恢复;取消与请求截止时间随时终止等待
OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足错误时,代理在允许安全重放且
尚未交付输出的前提下,先做最多 3 次同账号重试,再通过现有调度换号。没有有效服务器建议时,
按 500ms 起步的指数退避等待,单次最多 8 秒;有效 `Retry-After` 优先,接受秒数、HTTP 日期和零延迟,
@@ -1911,9 +1921,8 @@ WebSocket 握手默认请求专项计时,客户端显式提供的开关保留
请求与错误事件各自的状态、attempt、时间线阶段、计时和上述失败分类。
`provider.precommit.released` 事件的 `precommitRelease` 保留 `reason / prefetchedBytes / waitMs`,
分别表示 Provider 释放缓存的原因、累计预取字节数和等待毫秒数,不等同于下游已提交。
当前释放原因为 `semantic_output / terminal / grace_timeout / eof`,导出也接受历史记录中的 `byte_limit`。
累计字节数只用于诊断,不触发缓存释放;数值只允许非负安全整数,
缺失或未通过校验的字段为 `null`;其他事件的 `precommitRelease` 为 `null`
当前释放原因为 `semantic_output / terminal / eof`,导出也接受历史记录中的 `grace_timeout / byte_limit`。
数值只允许非负安全整数,缺失或未通过校验的字段为 `null`;其他事件的 `precommitRelease` 为 `null`
诊断包不自动导出 message/raw error、任意 metadata、其他 trace event data、请求响应正文和头部。
`availability` 与 `omitted` 明示未采集、不完整或主动省略的内容,`null` 不代表没有发生错误。
+6 -2
View File
@@ -313,7 +313,7 @@ Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请
### 3.4 前端模块职责
`frontend/src` 持有应用状态和业务交互,基础组件与主题算法由 `@codex-proxy/ui` 提供:
`frontend/src` 持有应用状态和业务交互,基础组件与主题算法由 `@codex-proxy/ui` 提供,组件目录与合同见 [UI 组件架构](../modules/ui/playground/guide/architecture.md):
| 入口 | 职责 |
| --- | --- |
@@ -381,6 +381,10 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Cor
重试与提交边界。具体错误合同见 [数据面接口](api.md#3-openai-数据面与模型目录)
- Provider 可将明确容量拒绝标记为有界同账号退避,Core 在既有安全重放边界内执行,按账号维护请求内
预算,耗尽后复用普通换号路径。该退避消耗总路由预算,与 WS 传输恢复、OAuth 刷新及账号额度冷却分开
- OpenAI 的结构事件由 Provider 保留到有效输出或终态,不按等待时长或累计体积提前交付。
交付前的 WS 传输错误可请求有界同账号尽力恢复,即使发送结果不明也保留此恢复意图;
发送事实与安全重放证明不因此改写。Provider 限定传输次数与续接范围,Core 保持原账号并检查
取消、deadline、已观测外部副作用及下游交付边界
- 换号预算从运行设置冻结到请求计划,选中账号与上一 attempt 不同时计数;首次选号、同账号重试和
选号阶段过滤候选不消耗预算。跨 Provider 候选推进与 continuation 排除重放同样受限,预算耗尽后
以最后一个原始上游错误终态;总路由尝试仍最多 32 次
@@ -642,7 +646,7 @@ Continuation 受原请求的 Client Key、账号范围、Provider 和发送/交
已知为 connection-local 的原生续接进入重放阶段时,在选号前返回 `ClientReplayRequired`,不认领或迁移会话绑定。
正常 native 续接仍由连接池核对原连接及 response ID 是否可用
- xAI 使用客户端提交的完整历史作为重放输入
- scope 外账号、跨 Key 复用或不明确发送结果均 fail closed
- scope 外账号与跨 Key 复用均拒绝;发送结果不明时,仅接受 Provider 明确给出的有界传输恢复意图
OpenAI Provider 解释官方逻辑 `session_id` 与 `thread_id`,按 Client Key 隔离账号绑定,
不将缓存路由键或请求内容推断为账号身份。账号亲和模式随请求策略冻结:
+2
View File
@@ -63,6 +63,8 @@ pnpm --dir frontend dev
后端职责和依赖方向见 [Workspace 边界](architecture.md#3-workspace-边界),页面与共享逻辑见 [前端模块职责](architecture.md#34-前端模块职责)。
先按所属模块定位,再跟踪请求、状态与展示的调用关系
共享 UI 的命名与目录见[组件架构](../modules/ui/playground/guide/architecture.md)
## 源码联调
宿主、组件库和官方插件保留独立仓库,通过 Git 子模块在同一目录开发:
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.2",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.4.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.47.0",
+24 -15
View File
@@ -170,8 +170,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.3.2
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.4.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -381,9 +381,9 @@ packages:
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0':
resolution: {gitHosted: true, integrity: sha512-9s6sXP8QrUZATh80wQnIMi68lORp+HuixhoXM2WANsgULqF0Rq270VHllSCNISUCj6aP8t5ugNDPkIrkAFpu3A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0}
version: 0.3.2
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0':
resolution: {gitHosted: true, integrity: sha512-AhG9idgKHs9AWY1Fyo75wlVcBx3KXD3NEY1dF+sOg84PGAz/7mKq6Fb4xqkYqCT6UrduTjT3WbAw/2VhJJdblQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0}
version: 0.4.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -537,6 +537,11 @@ packages:
peerDependencies:
vue: '>=3.0.1'
'@lucide/vue@1.54.0':
resolution: {integrity: sha512-fq5S0Fk3a3c2VPs+QRiiHZylZV+1pxyTyOYxoH507b/OdiqciUiEm83N7d7xijS8lDlsGFf12ad9AkLGVHRevg==}
peerDependencies:
vue: '>=3.0.1'
'@ota-meshi/ast-token-store@0.3.0':
resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -1319,8 +1324,8 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
dompurify@3.4.16:
resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
@@ -2003,8 +2008,8 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@18.0.14:
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
marked@18.1.0:
resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==}
engines: {node: '>= 20'}
hasBin: true
@@ -2777,17 +2782,17 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/83c92024dec82b5a644d6adca96a140c541684b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/db615f3d72975e0d09847a07d03ed5fe7ba8c8b0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
'@lucide/vue': 1.47.0(vue@3.5.43(typescript@6.0.3))
'@lucide/vue': 1.54.0(vue@3.5.43(typescript@6.0.3))
'@vueuse/core': 15.0.0(vue@3.5.43(typescript@6.0.3))
'@vueuse/integrations': 15.0.0(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(sortablejs@1.15.7)(vue@3.5.43(typescript@6.0.3))
dompurify: 3.4.15
dompurify: 3.4.16
es-toolkit: 1.52.0
gsap: 3.15.0
marked: 18.0.14
marked: 18.1.0
sortablejs: 1.15.7
vue: 3.5.43(typescript@6.0.3)
optionalDependencies:
@@ -2955,6 +2960,10 @@ snapshots:
dependencies:
vue: 3.5.43(typescript@6.0.3)
'@lucide/vue@1.54.0(vue@3.5.43(typescript@6.0.3))':
dependencies:
vue: 3.5.43(typescript@6.0.3)
'@ota-meshi/ast-token-store@0.3.0': {}
'@oxc-project/types@0.149.0': {}
@@ -3581,7 +3590,7 @@ snapshots:
diff-sequences@29.6.3: {}
dompurify@3.4.15:
dompurify@3.4.16:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -4270,7 +4279,7 @@ snapshots:
markdown-table@3.0.4: {}
marked@18.0.14: {}
marked@18.1.0: {}
math-intrinsics@1.1.0: {}
-3
View File
@@ -1,10 +1,7 @@
<script setup lang="ts">
import { BaseToast } from '@codex-proxy/ui'
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
<BaseToast />
</template>
+2 -2
View File
@@ -1,5 +1,5 @@
import type { RequestContext, RequestNext } from './index'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { ApiError, isSessionRequired } from './error'
// 共享刷新 Promise 的同一个失败只提示一次,弱引用不保留已结束请求
@@ -13,7 +13,7 @@ export async function feedbackPlugin(context: RequestContext, next: RequestNext)
if (error instanceof ApiError && error.kind !== 'cancelled' && !isSessionRequired(error)
&& !context.config.silent && context.isCurrent() && !reported.has(error)) {
reported.add(error)
toast.error(error.message)
ZNotification.error({ message: error.message })
}
throw error
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RequestConfig } from '../index'
import type { RequestContext, RequestNext } from './index'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { ApiError, isSessionRequired } from './error'
// 会话失效是后端业务事实,登录凭据错误和 403 不清除已有会话
@@ -54,7 +54,7 @@ function expireSession(error: ApiError, config: RequestConfig, generation: numbe
const alreadyHandled = unauthorizedHandled
handleUnauthorizedOnce()
if (!config.silent && !alreadyHandled)
toast.error(error.message)
ZNotification.error({ message: error.message })
}
throw error
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountGroupRef } from '@/api'
import { BaseCheckbox } from '@codex-proxy/ui'
import { ZCheckbox } from '@codex-proxy/ui'
withDefaults(
defineProps<{
@@ -35,10 +35,9 @@ function updateGroup(groupId: string, selected: boolean) {
>
<span class="flex min-w-0 items-center gap-2.5">
<span class="size-3.5 shrink-0 rounded-sm" :style="{ backgroundColor: group.color }" />
<BaseCheckbox
<ZCheckbox
:model-value="selectedGroupIds.includes(group.id)"
:label="group.name"
show-label
:disabled="disabled || loading"
@update:model-value="updateGroup(group.id, $event)"
/>
+18 -18
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseIconButton, BaseModal, BaseScrollbar, BaseSegmented, BaseSwitch } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton, ZScrollbar, ZSegmented, ZSwitch } from '@codex-proxy/ui'
import { Apple, Copy, Monitor, Upload } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
@@ -52,18 +52,18 @@ function importToCcs() {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
:title="title"
description="保存或合并下方配置后重启 Codex"
size="lg"
width="48rem"
>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented v-model="activePlatform" label="配置平台" :options="platformOptions" />
<BaseSwitch
<ZSegmented v-model="activePlatform" aria-label="配置平台" :options="platformOptions" />
<ZSwitch
v-model="websocketEnabled"
label="切换 WebSocket 配置"
aria-label="切换 WebSocket 配置"
active-text="WS"
inactive-text="WS"
inline-prompt
@@ -78,34 +78,34 @@ function importToCcs() {
>
{{ configPath }}
</span>
<BaseIconButton
variant="secondary"
size="sm"
label="复制"
<ZIconButton
variant="solid"
size="small"
aria-label="复制"
@click="emit('copy', codexConfig)"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
<BaseScrollbar max-height="calc(100dvh - 21rem)">
<ZScrollbar max-height="calc(100dvh - 21rem)">
<div class="mx-3 mb-3 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.65] font-emphasis text-cp-text"
v-text="codexConfig"
/>
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="!keyValue" @click="importToCcs">
<ZButton :disabled="!keyValue" @click="importToCcs">
<Upload class="size-4" />
导入 CCSwitch
</BaseButton>
<BaseButton variant="primary" @click="open = false">
</ZButton>
<ZButton type="primary" @click="open = false">
关闭
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Github } from '@boxicons/vue'
import { BaseModal } from '@codex-proxy/ui'
import { ZDialog } from '@codex-proxy/ui'
import { ExternalLink } from '@lucide/vue'
import { computed } from 'vue'
@@ -49,7 +49,7 @@ const linkItems = [
</script>
<template>
<BaseModal v-model="open" title="关于" size="sm">
<ZDialog v-model="open" title="关于" width="28rem">
<div class="grid gap-5">
<section class="flex min-w-0 items-center gap-3">
<span
@@ -108,5 +108,5 @@ const linkItems = [
{{ versionLine }}
</p>
</div>
</BaseModal>
</ZDialog>
</template>
+1 -1
View File
@@ -31,7 +31,7 @@ const mistGradientId = `${id}-brand-mist`
</radialGradient>
</defs>
<rect class="fill-[var(--cp-brand-mark-bg)]" x="48" y="48" width="416" height="416" rx="88" />
<rect class="fill-(--cp-brand-mark-bg)" x="48" y="48" width="416" height="416" rx="88" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
+3 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseSegmented } from '@codex-proxy/ui'
import { ZSegmented } from '@codex-proxy/ui'
import { LayoutGrid } from '@lucide/vue'
import { formatProviderLabel, PROVIDER_IDS, providerIcon } from '@/utils/providers'
@@ -14,9 +14,9 @@ const options = [
<template>
<div class="flex min-w-0 max-w-full items-center gap-1">
<div class="min-w-0 overflow-x-auto">
<BaseSegmented
<ZSegmented
v-model="provider"
label="按平台筛选"
aria-label="按平台筛选"
display="icon"
:style="{ width: `${options.length * 40 + 4}px` }"
:options="options"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { RequestLocation } from '@/api'
import { BaseFormItem, BaseInput } from '@codex-proxy/ui'
import { ZFormItem, ZInput } from '@codex-proxy/ui'
defineProps<{ disabled?: boolean }>()
const location = defineModel<RequestLocation>({ required: true })
@@ -12,17 +12,17 @@ function update(field: keyof RequestLocation, value: string) {
<template>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="国家代码" required>
<BaseInput :model-value="location.country" :disabled="disabled" aria-label="国家代码" placeholder="请输入两位国家代码" maxlength="2" @update:model-value="update('country', $event)" />
</BaseFormItem>
<BaseFormItem label="地区" required>
<BaseInput :model-value="location.region" :disabled="disabled" aria-label="地区" placeholder="请输入地区" maxlength="128" @update:model-value="update('region', $event)" />
</BaseFormItem>
<BaseFormItem label="城市" required>
<BaseInput :model-value="location.city" :disabled="disabled" aria-label="城市" placeholder="请输入城市" maxlength="128" @update:model-value="update('city', $event)" />
</BaseFormItem>
<BaseFormItem label="IANA 时区" required>
<BaseInput :model-value="location.timezone" :disabled="disabled" aria-label="IANA 时区" placeholder="请输入 IANA 时区" @update:model-value="update('timezone', $event)" />
</BaseFormItem>
<ZFormItem label="国家代码" required>
<ZInput :model-value="location.country" :disabled="disabled" aria-label="国家代码" placeholder="请输入两位国家代码" maxlength="2" @update:model-value="update('country', $event)" />
</ZFormItem>
<ZFormItem label="地区" required>
<ZInput :model-value="location.region" :disabled="disabled" aria-label="地区" placeholder="请输入地区" maxlength="128" @update:model-value="update('region', $event)" />
</ZFormItem>
<ZFormItem label="城市" required>
<ZInput :model-value="location.city" :disabled="disabled" aria-label="城市" placeholder="请输入城市" maxlength="128" @update:model-value="update('city', $event)" />
</ZFormItem>
<ZFormItem label="IANA 时区" required>
<ZInput :model-value="location.timezone" :disabled="disabled" aria-label="IANA 时区" placeholder="请输入 IANA 时区" @update:model-value="update('timezone', $event)" />
</ZFormItem>
</div>
</template>
@@ -4,7 +4,7 @@ import type {
ClientProfilePreview,
ClientProfileSelection,
} from '@/api/modules/settings/profiles'
import { BaseButton, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZSegmented } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getClientProfileOptions, previewClientProfile } from '@/api/modules/settings/profiles'
import { errorMessage } from '@/utils/operation'
@@ -95,9 +95,9 @@ onMounted(() => load())
<template>
<div class="grid min-w-0 gap-4">
<div v-if="allowInherit" class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
<ZSegmented
v-model="profileSource"
label="客户端身份来源"
aria-label="客户端身份来源"
class="shrink-0"
:options="[
{ label: '全局配置', value: 'global' },
@@ -109,9 +109,9 @@ onMounted(() => load())
</div>
<div v-if="loadError" role="alert" class="flex flex-wrap items-center justify-between gap-3 text-cp text-cp-error">
<span>预设加载失败:{{ loadError }}</span>
<BaseButton size="sm" :disabled="disabled || loading" @click="load()">
<ZButton size="small" :disabled="disabled || loading" @click="load()">
重试
</BaseButton>
</ZButton>
</div>
<ClientProfilePresetFields
v-if="!inherited"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ClientProfilePreset, ClientProfilePreview, ClientProfileSelection, CustomClientProfileSelection, PresetClientProfileSelection } from '@/api/modules/settings/profiles'
import { BaseFormItem, BaseInput, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSelect, ZTextarea } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
const props = withDefaults(defineProps<{
@@ -115,11 +115,11 @@ const userAgent = computed({
<template>
<div class="grid gap-4">
<div class="grid gap-4" :class="preset?.client === 'cli' && !custom ? 'sm:grid-cols-3' : 'sm:grid-cols-2'">
<BaseFormItem label="客户端预设">
<BaseSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :placeholder="custom ? '使用自定义用户代理' : undefined" :disabled="disabled || custom || !presets.length" />
</BaseFormItem>
<BaseFormItem v-if="preset?.client === 'cli' && !custom" label="CLI 入口">
<BaseSelect
<ZFormItem label="客户端预设">
<ZSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :placeholder="custom ? '使用自定义用户代理' : undefined" :disabled="disabled || custom || !presets.length" />
</ZFormItem>
<ZFormItem v-if="preset?.client === 'cli' && !custom" label="CLI 入口">
<ZSelect
v-model="cliEntry"
class="w-full"
:options="[
@@ -129,9 +129,9 @@ const userAgent = computed({
]"
:disabled="disabled"
/>
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect
</ZFormItem>
<ZFormItem label="版本策略">
<ZSelect
v-model="versionMode"
class="w-full"
:options="[
@@ -140,14 +140,14 @@ const userAgent = computed({
]"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
</div>
<p v-if="!custom && currentPreset?.reason" class="m-0 text-cp-sm text-cp-text-secondary">
{{ currentPreset.reason }}
</p>
<div v-if="!custom" class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="版本滞后" :error="versionLagError">
<BaseInput
<ZFormItem label="版本滞后" :error="versionLagError">
<ZInput
v-model="versionLag"
aria-label="版本滞后"
type="number"
@@ -158,10 +158,10 @@ const userAgent = computed({
:placeholder="`滞后 1~${maxVersionLag} 个版本,留空不滞后`"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
<div class="grid gap-4" :class="{ 'sm:grid-cols-2': preset?.client === 'desktop' }">
<BaseFormItem label="终端标识">
<BaseInput
<ZFormItem label="终端标识">
<ZInput
v-model="terminal"
:disabled="disabled"
aria-label="终端标识"
@@ -171,9 +171,9 @@ const userAgent = computed({
autocomplete="off"
class="font-mono"
/>
</BaseFormItem>
<BaseFormItem v-if="preset?.client === 'desktop'" label="系统版本">
<BaseInput
</ZFormItem>
<ZFormItem v-if="preset?.client === 'desktop'" label="系统版本">
<ZInput
v-model="osVersion"
:disabled="disabled"
aria-label="系统版本"
@@ -183,11 +183,11 @@ const userAgent = computed({
autocomplete="off"
class="font-mono"
/>
</BaseFormItem>
</ZFormItem>
</div>
</div>
<template v-if="custom">
<BaseTextarea
<ZTextarea
v-model="userAgent"
:rows="3"
:disabled="disabled || (!model?.mode && previewing)"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ClientProfilePreview } from '@/api/modules/settings/profiles'
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
defineProps<{
preview?: Pick<ClientProfilePreview, 'userAgent' | 'versionSource' | 'checkedAtDisplay' | 'error'> & { versionLag?: number | null }
@@ -25,10 +25,10 @@ defineProps<{
>
<template v-if="previewing">
<div class="flex h-lh items-center text-cp-sm" role="status" aria-label="正在解析客户端身份">
<BaseSkeleton shape="text" class="w-4/5" aria-hidden="true" />
<ZSkeleton shape="text" class="w-4/5" aria-hidden="true" />
</div>
<div class="flex h-lh items-center text-cp-xs" aria-hidden="true">
<BaseSkeleton shape="text" class="w-52 max-w-full" />
<ZSkeleton shape="text" class="w-52 max-w-full" />
</div>
</template>
<template v-else-if="preview">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ClientProfileSelection, ProviderRequestProfile, ProviderRequestProfiles, XaiClientProfileSelection } from '@/api/modules/settings/profiles'
import { BaseSegmented } from '@codex-proxy/ui'
import { ZSegmented } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
import { formatProviderLabel, PROVIDER_IDS, providerIcon } from '@/utils/providers'
import ClientProfileEditor from './ClientProfileEditor.vue'
@@ -48,10 +48,10 @@ function updateProfile(providerId: string, value: ProviderRequestProfile | null)
<div class="grid min-w-0 gap-4">
<div v-if="!allowInherit" class="flex flex-wrap items-start justify-between gap-3">
<slot name="heading" />
<BaseSegmented
<ZSegmented
v-model="provider"
class="ml-auto max-w-full shrink-0"
label="客户端身份 Provider"
aria-label="客户端身份 Provider"
:options="providerOptions"
:disabled="disabled"
:style="providerSelectorStyle"
@@ -66,10 +66,10 @@ function updateProfile(providerId: string, value: ProviderRequestProfile | null)
:disabled="disabled"
>
<template #source-extra>
<BaseSegmented
<ZSegmented
v-model="provider"
class="max-w-full shrink-0"
label="上游身份平台"
aria-label="上游身份平台"
:options="providerOptions"
:disabled="disabled"
:style="providerSelectorStyle"
@@ -85,10 +85,10 @@ function updateProfile(providerId: string, value: ProviderRequestProfile | null)
:disabled="disabled"
>
<template #source-extra>
<BaseSegmented
<ZSegmented
v-model="provider"
class="max-w-full shrink-0"
label="上游身份平台"
aria-label="上游身份平台"
:options="providerOptions"
:disabled="disabled"
:style="providerSelectorStyle"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { XaiClientProfilePreview, XaiClientProfileSelection } from '@/api/modules/settings/profiles'
import { BaseButton, BaseFormItem, BaseInput, BaseSegmented, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZFormItem, ZInput, ZSegmented, ZSelect } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getXaiClientProfileOptions, previewXaiClientProfile } from '@/api/modules/settings/profiles'
import { errorMessage } from '@/utils/operation'
@@ -98,9 +98,9 @@ onMounted(() => props.allowInherit && void load())
<template>
<div class="grid min-w-0 gap-4">
<div v-if="allowInherit" class="flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
<ZSegmented
v-model="source"
label="xAI 客户端身份来源"
aria-label="xAI 客户端身份来源"
class="shrink-0"
:options="[{ label: '全局配置', value: 'global' }, { label: '独立配置', value: 'independent' }]"
:disabled="disabled || !globalConfiguration"
@@ -109,26 +109,26 @@ onMounted(() => props.allowInherit && void load())
</div>
<div v-if="loadError" role="alert" class="flex items-center justify-between gap-3 text-cp text-cp-error">
<span>全局配置加载失败:{{ loadError }}</span>
<BaseButton size="sm" @click="load">
<ZButton size="small" @click="load">
重试
</BaseButton>
</ZButton>
</div>
<template v-if="model">
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="客户端类型">
<BaseInput model-value="Grok CLI" readonly :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="版本策略">
<BaseSelect v-model="versionMode" class="w-full" :disabled="disabled" :options="[{ label: '跟随最新版本', value: 'latest' }, { label: '自定义版本', value: 'fixed' }]" />
</BaseFormItem>
<ZFormItem label="客户端类型">
<ZInput model-value="Grok CLI" readonly :disabled="disabled" />
</ZFormItem>
<ZFormItem label="版本策略">
<ZSelect v-model="versionMode" class="w-full" :disabled="disabled" :options="[{ label: '跟随最新版本', value: 'latest' }, { label: '自定义版本', value: 'fixed' }]" />
</ZFormItem>
</div>
<div v-if="model.versionMode === 'fixed'" class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="Grok CLI 版本" required>
<BaseInput :model-value="model.clientVersion ?? ''" :disabled="disabled" placeholder="例如:1.0.13" @update:model-value="updateField('clientVersion', $event)" />
</BaseFormItem>
<BaseFormItem v-for="field in fields" :key="field.key" :label="field.label" required>
<BaseInput :model-value="model[field.key]" :disabled="disabled" @update:model-value="updateField(field.key, $event)" />
</BaseFormItem>
<ZFormItem label="Grok CLI 版本" required>
<ZInput :model-value="model.clientVersion ?? ''" :disabled="disabled" placeholder="例如:1.0.13" @update:model-value="updateField('clientVersion', $event)" />
</ZFormItem>
<ZFormItem v-for="field in fields" :key="field.key" :label="field.label" required>
<ZInput :model-value="model[field.key]" :disabled="disabled" @update:model-value="updateField(field.key, $event)" />
</ZFormItem>
</div>
</template>
<ClientProfilePreviewPanel :preview="preview" :previewing="previewing" :needs-version-input="needsVersionInput" :error="previewError" />
+22 -22
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { InputField } from './schema'
import { BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { ZForm, ZFormItem, ZIconButton, ZInput, ZPopover, ZSelect, ZTextarea } from '@codex-proxy/ui'
import { Braces, Info, List } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef } from 'vue'
import { parseJsonObject } from '@/utils/data'
@@ -94,18 +94,18 @@ defineExpose({ focusField })
<template>
<div v-if="!empty" ref="root" class="grid gap-3">
<BaseForm v-if="showJson">
<BaseFormItem label="输入内容" :error="error">
<ZForm v-if="showJson">
<ZFormItem label="输入内容" :error="error">
<template #extra>
<BaseIconButton v-if="fields !== null" label="使用表单" size="sm" variant="secondary" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<ZIconButton v-if="fields !== null" aria-label="使用表单" size="small" variant="solid" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
</BaseFormItem>
</BaseForm>
<BaseForm v-else>
<BaseFormItem
<ZTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
</ZFormItem>
</ZForm>
<ZForm v-else>
<ZFormItem
v-for="(field, index) in fields"
:key="field.key"
:label="field.label"
@@ -113,32 +113,32 @@ defineExpose({ focusField })
:error="fieldErrors?.[field.key]"
>
<template v-if="field.description" #label-extra>
<BasePopover trigger="hover-click" placement="top-start">
<template #trigger>
<BaseIconButton :label="`${field.label}说明`" class="size-3.5!" :title="undefined">
<ZPopover trigger="hover-click" placement="top-start">
<template #reference>
<ZIconButton :aria-label="`${field.label}说明`" class="size-3.5!" :title="undefined">
<Info class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</template>
<p class="m-0 max-w-72 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
{{ field.description }}
</p>
</BasePopover>
</ZPopover>
</template>
<template v-if="index === 0" #extra>
<BaseIconButton label="编辑 JSON" size="sm" variant="secondary" :disabled="disabled" @click="jsonMode = true">
<ZIconButton aria-label="编辑 JSON" size="small" variant="solid" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseSelect
<ZSelect
v-if="field.values || field.type === 'boolean'"
:model-value="fieldValue(field)"
:options="fieldOptions(field)"
:disabled="disabled"
:data-schema-control="field.key"
class="w-full"
@update:model-value="update(field, $event)"
@update:model-value="update(field, $event ?? '')"
/>
<BaseInput
<ZInput
v-else
:model-value="fieldValue(field)"
:type="field.secret ? 'password' : 'text'"
@@ -148,7 +148,7 @@ defineExpose({ focusField })
autocomplete="off"
@update:model-value="update(field, $event)"
/>
</BaseFormItem>
</BaseForm>
</ZFormItem>
</ZForm>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DashboardHealthTimeline, DashboardHealthTimelinePoint } from '@/api'
import { BaseCard, BasePopover } from '@codex-proxy/ui'
import { ZCard, ZPopover } from '@codex-proxy/ui'
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
@@ -183,7 +183,7 @@ onBeforeUnmount(() => {
</script>
<template>
<BaseCard as="article" :title="timeline.title" :description="timeline.description" class="w-full">
<ZCard as="article" :title="timeline.title" :description="timeline.description" class="w-full">
<template #actions>
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div
@@ -211,18 +211,18 @@ onBeforeUnmount(() => {
<template #body>
<div>
<BasePopover
<ZPopover
v-model="popoverOpen"
trigger="hover"
placement="top"
:offset="12"
:anchor-element="activeAnchor"
:reference-element="activeAnchor"
:disabled="!activePoint"
animate-position
:arrow-surface-class="healthPopoverArrowSurfaceClasses"
class="min-w-0 w-full"
>
<template #trigger>
<template #reference>
<div
ref="timelineGrid" class="grid w-full grid-cols-[repeat(var(--timeline-columns),minmax(0,1fr))] items-end gap-x-0.5 gap-y-1 sm:grid-cols-[repeat(var(--timeline-columns-wide),minmax(0,1fr))]"
:style="{ '--timeline-columns': Math.max(1, Math.ceil(points.length / 2)), '--timeline-columns-wide': Math.max(1, points.length) }"
@@ -263,8 +263,8 @@ onBeforeUnmount(() => {
<div class="w-72 overflow-hidden rounded-cp-lg">
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
</div>
</BasePopover>
</ZPopover>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BasePopover } from '@codex-proxy/ui'
import { ZPopover } from '@codex-proxy/ui'
import { Info } from '@lucide/vue'
@@ -12,11 +12,11 @@ defineProps<{
</script>
<template>
<BasePopover
<ZPopover
trigger="hover"
placement="right"
>
<template #trigger>
<template #reference>
<button
type="button"
class="inline-flex size-4 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
@@ -35,5 +35,5 @@ defineProps<{
</p>
<slot />
</div>
</BasePopover>
</ZPopover>
</template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { BaseTableColumn } from '@codex-proxy/ui'
import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord } from '@/api'
import { BaseTable } from '@codex-proxy/ui'
import { ZTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
@@ -22,7 +22,7 @@ import {
// 使用记录表只负责该领域的单元格呈现;筛选与分页由页面组合。
withDefaults(
defineProps<{
columns: BaseTableColumn<UsageListRecord>[]
columns: TableColumn<UsageListRecord>[]
rows: UsageListRecord[]
loading?: boolean
emptyText?: string
@@ -35,9 +35,9 @@ withDefaults(
</script>
<template>
<BaseTable
<ZTable
:columns="columns"
:rows="rows"
:data="rows"
:loading="loading"
:empty-text="emptyText"
>
@@ -142,5 +142,5 @@ withDefaults(
<template v-if="$slots.actions" #actions="scope">
<slot name="actions" v-bind="scope" />
</template>
</BaseTable>
</ZTable>
</template>
@@ -18,7 +18,7 @@ export const usageRecordColumns = defineTableColumns<UsageListRecord>([
kind: 'status',
size: 'md',
fixedWidth: true,
format: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
formatter: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
+2 -2
View File
@@ -1,4 +1,4 @@
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { shallowRef } from 'vue'
import { ApiError } from '@/api/request'
@@ -42,7 +42,7 @@ export function useAsyncAction(defaults: AsyncActionRunOptions = {}) {
const message = resolveErrorText(error, options.errorText)
if (message) {
toast.error(message)
ZNotification.error({ message })
}
if (options.rethrow) {
+5 -5
View File
@@ -1,4 +1,4 @@
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { useClipboard } from '@vueuse/core'
import { errorMessage } from '@/utils/operation'
@@ -11,7 +11,7 @@ interface CopyTextOptions {
errorFromException?: boolean
}
// 剪贴板复制 + toast 反馈;空值与错误文案语义由调用方按站点配置。
// 剪贴板复制 + notification 反馈;空值与错误文案语义由调用方按站点配置。
export function useCopyText() {
// 普通 HTTP 页面没有 Clipboard API,启用 VueUse 内置的兼容复制路径。
const { copy } = useClipboard({ legacy: true })
@@ -19,15 +19,15 @@ export function useCopyText() {
return async function copyText(value: string, options: CopyTextOptions) {
if (!value) {
if (options.emptyErrorText)
toast.error(options.emptyErrorText)
ZNotification.error({ message: options.emptyErrorText })
return
}
try {
await copy(value)
toast.success(options.successText)
ZNotification.success({ message: options.successText })
}
catch (error: unknown) {
toast.error(options.errorFromException ? errorMessage(error, '复制失败') : '复制失败')
ZNotification.error({ message: options.errorFromException ? errorMessage(error, '复制失败') : '复制失败' })
}
}
}
-156
View File
@@ -1,156 +0,0 @@
import type { Directive, DirectiveBinding } from 'vue'
type LoadingBindingValue
= | boolean
| {
loading?: boolean
text?: string
preserveContent?: boolean
}
interface LoadingState {
overlay: HTMLDivElement
previousPosition: string
managedPosition: boolean
preserveContent: boolean
}
type LoadingElement = HTMLElement & {
__cpLoading?: LoadingState
}
const OVERLAY_BASE_CLASS = [
'absolute',
'inset-0',
'z-50',
'grid',
'min-h-18',
'place-items-center',
'rounded-[inherit]',
].join(' ')
const OVERLAY_MASK_CLASS = 'bg-cp-bg-container/82 backdrop-blur-[4px]'
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
'items-center',
'gap-2.5',
'rounded-xl',
'py-2.25',
'pr-3.5',
'pl-3',
'text-xs',
'leading-[1.15]',
'font-heavy',
'text-cp-text-secondary',
].join(' ')
const MARK_CLASS = [
'inline-block',
'size-4',
'rounded-full',
'animate-spin',
'bg-[radial-gradient(circle_at_center,var(--cp-color-bg-container)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-color-primary)_0deg_105deg,var(--cp-color-primary-hover)_105deg_200deg,var(--cp-color-fill-tertiary)_200deg_360deg)]',
].join(' ')
const LABEL_CLASS = 'whitespace-nowrap'
function normalizeBinding(value: LoadingBindingValue | undefined) {
if (typeof value === 'object' && value !== null) {
return {
loading: Boolean(value.loading),
text: value.text || '加载中',
preserveContent: Boolean(value.preserveContent),
}
}
return {
loading: Boolean(value),
text: '加载中',
preserveContent: false,
}
}
function overlayClass(preserveContent: boolean) {
return preserveContent ? OVERLAY_BASE_CLASS : `${OVERLAY_BASE_CLASS} ${OVERLAY_MASK_CLASS}`
}
function createOverlay(text: string, preserveContent: boolean) {
const overlay = document.createElement('div')
overlay.className = overlayClass(preserveContent)
overlay.setAttribute('role', 'status')
overlay.setAttribute('aria-live', 'polite')
const panel = document.createElement('div')
panel.className = PANEL_CLASS
const mark = document.createElement('span')
mark.className = MARK_CLASS
const label = document.createElement('span')
label.className = LABEL_CLASS
label.dataset.loadingLabel = ''
label.textContent = text
panel.append(mark, label)
overlay.append(panel)
return overlay
}
function showLoading(element: LoadingElement, text: string, preserveContent: boolean) {
if (element.__cpLoading) {
const label = element.__cpLoading.overlay.querySelector<HTMLElement>('[data-loading-label]')
if (label)
label.textContent = text
if (element.__cpLoading.preserveContent !== preserveContent) {
element.__cpLoading.overlay.className = overlayClass(preserveContent)
element.__cpLoading.preserveContent = preserveContent
}
return
}
const previousPosition = element.style.position
const managedPosition = window.getComputedStyle(element).position === 'static'
if (managedPosition) {
element.style.position = 'relative'
}
const overlay = createOverlay(text, preserveContent)
element.append(overlay)
element.setAttribute('aria-busy', 'true')
element.classList.add('overflow-hidden')
element.__cpLoading = {
overlay,
previousPosition,
managedPosition,
preserveContent,
}
}
function hideLoading(element: LoadingElement) {
const state = element.__cpLoading
if (!state)
return
state.overlay.remove()
element.removeAttribute('aria-busy')
element.classList.remove('overflow-hidden')
if (state.managedPosition) {
element.style.position = state.previousPosition
}
element.__cpLoading = undefined
}
function syncLoading(element: LoadingElement, binding: DirectiveBinding<LoadingBindingValue>) {
const { loading, text, preserveContent } = normalizeBinding(binding.value)
if (loading) {
showLoading(element, text, preserveContent)
}
else {
hideLoading(element)
}
}
export const loading: Directive<LoadingElement, LoadingBindingValue> = {
mounted: syncLoading,
updated: syncLoading,
beforeUnmount: hideLoading,
}
+27 -29
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { PluginManagementView } from '@/api'
import { BaseIconButton, BaseMotionIcon, BaseScrollbar } from '@codex-proxy/ui'
import { ZIconButton, ZMotionIcon, ZScrollbar } from '@codex-proxy/ui'
import {
ArrowUpCircle,
Blocks,
@@ -391,12 +391,12 @@ onBeforeUnmount(() => {
class="mx-4 mt-6 grid h-12 shrink-0 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'self-stretch gap-3'"
>
<BaseMotionIcon
<ZMotionIcon
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp"
>
<AppBrandMark class="block size-11 select-none" />
</BaseMotionIcon>
</ZMotionIcon>
<span v-show="brandLabelVisible" ref="brandLabelEl" class="grid min-w-33 content-center overflow-hidden">
<strong class="text-base leading-[1.1] font-heavy text-cp-text"> Codex Proxy </strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
</span>
</div>
<BaseScrollbar class="my-6 w-full flex-1">
<ZScrollbar class="my-6 w-full flex-1">
<div class="px-4">
<nav class="relative grid gap-3" :class="isCollapsed ? 'mx-auto w-11.5' : 'w-full'" aria-label="主导航">
<span
@@ -546,7 +546,7 @@ onBeforeUnmount(() => {
</template>
</nav>
</div>
</BaseScrollbar>
</ZScrollbar>
<div class="mx-4 mb-6 shrink-0" :class="isCollapsed ? 'w-11' : 'self-stretch'">
<div
@@ -562,54 +562,52 @@ onBeforeUnmount(() => {
</span>
<div class="flex items-center" :class="isCollapsed ? 'grid gap-1' : 'gap-1'">
<BaseIconButton
<ZIconButton
v-if="isCollapsed && hasUpdate"
variant="success"
size="md"
:label="updateButtonLabel"
type="success" variant="plain"
size="default"
:aria-label="updateButtonLabel"
@click="openSystemUpdate"
>
<ArrowUpCircle :size="19" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
:size="isCollapsed ? 'md' : 'sm'"
label="退出登录"
variant="destructive"
<ZIconButton
:size="isCollapsed ? 'default' : 'small'"
aria-label="退出登录"
type="danger"
@click="handleLogout"
>
<LogOut :size="isCollapsed ? 19 : 18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
variant="ghost"
:size="isCollapsed ? 'md' : 'sm'"
:label="themeToggleLabel"
<ZIconButton
:size="isCollapsed ? 'default' : 'small'"
:aria-label="themeToggleLabel"
@click="toggleTheme($event)"
>
<Sun v-if="effectiveTheme === 'dark'" :size="isCollapsed ? 19 : 18" />
<Moon v-else :size="isCollapsed ? 19 : 18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton variant="ghost" :size="isCollapsed ? 'md' : 'sm'" label="关于" @click="emit('openAbout')">
<ZIconButton :size="isCollapsed ? 'default' : 'small'" aria-label="关于" @click="emit('openAbout')">
<Info :size="isCollapsed ? 19 : 18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton v-if="mobile" variant="ghost" size="sm" label="关闭侧边栏" @click="emit('close')">
<ZIconButton v-if="mobile" size="small" aria-label="关闭侧边栏" @click="emit('close')">
<PanelLeftClose :size="18" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
<ZIconButton
v-else
variant="ghost"
:size="isCollapsed ? 'md' : 'sm'"
:size="isCollapsed ? 'default' : 'small'"
data-sidebar-toggle
:label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
:aria-label="isCollapsed ? '展开侧边栏' : '收缩侧边栏'"
@click="emit('toggle')"
>
<PanelLeftOpen v-if="isCollapsed" :size="19" />
<PanelLeftClose v-else :size="18" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail } from '@/api'
import { BaseButton, BaseConfirmModal, BaseMarkdown, BaseModal, BasePopover, BaseScrollbar, BaseSegmented, BaseSkeleton, toast } from '@codex-proxy/ui'
import { ZButton, ZConfirmDialog, ZDialog, ZMarkdown, ZNotification, ZPopover, ZScrollbar, ZSegmented, ZSkeleton } from '@codex-proxy/ui'
import {
ArrowUpCircle,
@@ -49,7 +49,7 @@ const {
} = storeToRefs(systemUpdateStore)
const { loadSystem, checkUpdates, changeChannel, updateNow, restartNow } = systemUpdateStore
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateLogScrollbar = useTemplateRef<InstanceType<typeof ZScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
const updateConfirmPreviousTarget = shallowRef('')
@@ -125,10 +125,10 @@ async function handleCheckUpdates(force = true) {
try {
const data = await checkUpdates(force)
if (data?.warning) {
toast.error(data.warning)
ZNotification.error({ message: data.warning })
return
}
toast.success(data?.hasUpdate ? '发现可用更新' : '当前没有可用更新')
ZNotification.success({ message: data?.hasUpdate ? '发现可用更新' : '当前没有可用更新' })
}
catch {}
}
@@ -142,16 +142,16 @@ async function handleUpdateRequest() {
try {
const data = await checkUpdates(true)
if (data?.warning) {
toast.error(data.warning)
ZNotification.error({ message: data.warning })
return
}
if (!data?.hasUpdate || !canUpdate.value) {
toast.success('当前没有可用更新')
ZNotification.success({ message: '当前没有可用更新' })
return
}
const remoteTargetVersion = normalizeSystemVersion(data.latestVersion)
if (!remoteTargetVersion) {
toast.error('远端目标版本为空')
ZNotification.error({ message: '远端目标版本为空' })
return
}
if (previousTargetVersion && previousTargetVersion !== remoteTargetVersion) {
@@ -172,7 +172,7 @@ async function runConfirmedUpdate(targetVersion: string, channel: SystemUpdateCh
try {
const result = await updateNow(targetVersion, channel)
if (result) {
toast.success('更新已开始')
ZNotification.success({ message: '更新已开始' })
}
}
catch {}
@@ -204,7 +204,7 @@ async function handleRestart() {
await restartNow(restartPlan.value)
}
catch (error: unknown) {
toast.error(errorMessage(error, '重启前检查失败'))
ZNotification.error({ message: errorMessage(error, '重启前检查失败') })
}
finally {
checkingRestart.value = false
@@ -220,7 +220,7 @@ async function handleConfirmRestart() {
}
catch (error: unknown) {
restartConfirmOpen.value = false
toast.error(errorMessage(error, '重启失败,请重新检查'))
ZNotification.error({ message: errorMessage(error, '重启失败,请重新检查') })
}
}
@@ -243,13 +243,13 @@ watch(
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="系统更新"
description="检查版本、查看发布说明并执行在线更新"
tone="success"
size="lg"
:dismissible="!restarting"
type="success"
width="48rem"
:show-close="!restarting" :close-on-click-modal="!restarting" :close-on-press-escape="!restarting"
>
<template #icon>
<ArrowUpCircle class="size-4.5 text-cp-success" />
@@ -305,7 +305,7 @@ watch(
class="mt-2 mb-0 h-[1em] truncate font-mono text-cp leading-none font-bold text-cp-text"
:title="item.title || item.value"
>
<BaseSkeleton
<ZSkeleton
v-if="loading || ((checking || changingChannel) && item.key === 'latest')"
class="h-full w-20 max-w-full"
aria-label="正在检查版本"
@@ -326,14 +326,14 @@ watch(
>
{{ updateError || updateInfo?.warning }}
</p>
<BasePopover v-if="lastFailedOperation" placement="bottom-start" class="justify-self-start">
<template #trigger>
<BaseButton variant="ghost" size="sm">
<ZPopover v-if="lastFailedOperation" placement="bottom-start" class="justify-self-start">
<template #reference>
<ZButton variant="text" size="small">
<template #icon>
<History class="size-3.5" />
</template>
上次操作失败
</BaseButton>
</ZButton>
</template>
<div class="grid w-80 max-w-[calc(100vw-2rem)] gap-2 p-3 text-cp-sm">
<div v-if="lastFailedOperation.targetVersion || lastFailedOperation.finishedAt" class="flex flex-wrap gap-x-3 gap-y-1 text-cp-xs text-cp-text-quaternary">
@@ -344,7 +344,7 @@ watch(
{{ lastFailedOperation.error || lastFailedOperation.message || '操作失败' }}
</p>
</div>
</BasePopover>
</ZPopover>
</section>
<section
@@ -356,26 +356,26 @@ watch(
发布说明
</p>
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
<BaseSkeleton v-if="releaseNotesLoading" shape="text" class="w-14" aria-hidden="true" />
<ZSkeleton v-if="releaseNotesLoading" shape="text" class="w-14" aria-hidden="true" />
<template v-else>{{ presentation.releaseVersion }}</template>
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px">
<ZScrollbar class="-mx-4" max-height="160px">
<div
class="relative px-4"
:class="{ 'min-h-40': releaseNotesLoading && !hasReleaseNotes }"
:aria-busy="releaseNotesLoading"
>
<div :class="{ invisible: releaseNotesLoading }" :aria-hidden="releaseNotesLoading || undefined">
<BaseMarkdown :source="updateInfo?.notes" />
<ZMarkdown :source="updateInfo?.notes" />
</div>
<div v-if="releaseNotesLoading" class="absolute inset-x-4 top-0 grid h-full content-start gap-3 overflow-hidden py-1" aria-hidden="true">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-4/5" />
<BaseSkeleton shape="text" class="w-3/5" />
<ZSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-4/5" />
<ZSkeleton shape="text" class="w-3/5" />
</div>
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
<section
@@ -401,7 +401,7 @@ watch(
</span>
</header>
<BaseScrollbar
<ZScrollbar
ref="updateLogScrollbar"
height="260px"
>
@@ -423,27 +423,27 @@ watch(
</p>
</div>
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
</div>
<template #footer>
<div class="mr-auto flex min-w-0 basis-full items-center gap-2 sm:basis-auto">
<BaseSkeleton v-if="loading" class="h-8 w-52" aria-label="正在读取运行通道" />
<BaseSegmented
<ZSkeleton v-if="loading" class="h-8 w-52" aria-label="正在读取运行通道" />
<ZSegmented
v-else
:model-value="selectedChannel"
:options="channelOptions"
label="更新通道"
size="sm"
aria-label="更新通道"
size="small"
:disabled="!canChangeChannel || preparingUpdate || updateConfirmOpen"
@update:model-value="handleChannelChange"
/>
<BasePopover placement="top-start">
<template #trigger>
<BaseButton variant="ghost" size="sm" square aria-label="更新通道说明">
<ZPopover placement="top-start">
<template #reference>
<ZButton variant="text" size="small" square aria-label="更新通道说明">
<CircleHelp class="size-4" />
</BaseButton>
</ZButton>
</template>
<div class="grid w-72 gap-2 p-3 text-cp-sm text-cp-text-secondary">
<p v-for="channel in availableChannels" :key="channel" class="m-0">
@@ -454,10 +454,9 @@ watch(
选择仅用于本次检查,重新打开时按运行版本选择通道,不会自动安装或降级
</p>
</div>
</BasePopover>
</ZPopover>
</div>
<BaseButton
variant="secondary"
<ZButton
:loading="checking"
:disabled="loading || updating || restarting || changingChannel || preparingUpdate"
@click="handleCheckUpdates(true)"
@@ -469,10 +468,10 @@ watch(
<RefreshCw class="size-3.5" />
</template>
检查更新
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-if="needRestart"
variant="primary"
type="primary"
:loading="restarting || checkingRestart"
:disabled="loading || updating || restartConfirmOpen"
@click="handleRestart"
@@ -481,10 +480,10 @@ watch(
<Power class="size-4" />
</template>
{{ presentation.restartButtonLabel }}
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-else-if="hasUpdate || updating"
variant="primary"
type="primary"
:loading="preparingUpdate || updating"
:disabled="!canUpdate || preparingUpdate"
@click="handleUpdateRequest"
@@ -493,11 +492,11 @@ watch(
<Download class="size-4" />
</template>
{{ updating ? '更新中' : '下载并更新' }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="restartConfirmOpen"
title="插件兼容性提醒"
description="重启后保留启用配置,逐个尝试启动插件"
@@ -520,9 +519,9 @@ watch(
</li>
</ul>
</div>
</BaseConfirmModal>
</ZConfirmDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="updateConfirmOpen"
title="发现新的更新版本"
description="所选通道的最新版本已变化"
@@ -552,5 +551,5 @@ watch(
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
</p>
</div>
</BaseConfirmModal>
</ZConfirmDialog>
</template>
+4 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseScrollbar } from '@codex-proxy/ui'
import { ZScrollbar } from '@codex-proxy/ui'
import { storeToRefs } from 'pinia'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
@@ -18,7 +18,7 @@ const { sidebarCollapsed } = storeToRefs(uiStore)
const { version } = storeToRefs(systemUpdateStore)
const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
const pageScrollbarRef = ref<InstanceType<typeof ZScrollbar> | null>(null)
const mobileSidebarOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
const systemUpdateOpen = shallowRef(false)
@@ -60,13 +60,13 @@ watch(
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<BaseScrollbar ref="pageScrollbarRef">
<ZScrollbar ref="pageScrollbarRef">
<div 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>
</div>
</BaseScrollbar>
</ZScrollbar>
</main>
<Teleport to="body">
+2 -2
View File
@@ -1,7 +1,7 @@
import { vLoading } from '@codex-proxy/ui'
import { createApp } from 'vue'
import App from './App.vue'
import { loading } from './directives/loading'
import { authPlugin } from './plugins/auth'
import { router } from './router'
import { pinia } from './stores'
@@ -14,7 +14,7 @@ import './styles/index.css'
const app = createApp(App)
app.directive('loading', loading)
app.directive('loading', vLoading)
app.use(pinia)
useThemeStore(pinia).initializeTheme()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import { BaseFormItem, BaseInput, BaseSegmented } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSegmented } from '@codex-proxy/ui'
defineProps<{ disabled?: boolean, editing?: boolean }>()
const form = defineModel<ApiKeyAccountForm>({ required: true })
@@ -12,20 +12,20 @@ const transportOptions = [
<template>
<div class="grid gap-4">
<BaseFormItem v-if="!editing" label="账号名称" required>
<BaseInput v-model="form.name" placeholder="输入上游名称" :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="Base URL" required>
<ZFormItem v-if="!editing" label="账号名称" required>
<ZInput v-model="form.name" placeholder="输入上游名称" :disabled="disabled" />
</ZFormItem>
<ZFormItem label="Base URL" required>
<template #extra>
<span class="text-cp-xs text-cp-text-quaternary" title="填写 API 前缀,接口路径会自动追加">API 前缀</span>
</template>
<BaseInput v-model="form.base_url" type="url" placeholder="https://api.example.com/v1" :disabled="disabled" autocomplete="off" />
</BaseFormItem>
<BaseFormItem label="API Key" :required="!editing">
<BaseInput v-model="form.apiKey" type="password" :placeholder="editing ? '留空保留当前密钥' : '输入上游密钥'" :disabled="disabled" autocomplete="new-password" />
</BaseFormItem>
<BaseFormItem label="传输方式">
<BaseSegmented v-model="form.transport" label="传输方式" :options="transportOptions" :disabled="disabled" title="WS 优先需要上游支持 Responses WebSocket" />
</BaseFormItem>
<ZInput v-model="form.base_url" type="url" placeholder="https://api.example.com/v1" :disabled="disabled" autocomplete="off" />
</ZFormItem>
<ZFormItem label="API Key" :required="!editing">
<ZInput v-model="form.apiKey" type="password" :placeholder="editing ? '留空保留当前密钥' : '输入上游密钥'" :disabled="disabled" autocomplete="new-password" />
</ZFormItem>
<ZFormItem label="传输方式">
<ZSegmented v-model="form.transport" aria-label="传输方式" :options="transportOptions" :disabled="disabled" title="WS 优先需要上游支持 Responses WebSocket" />
</ZFormItem>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountGroupRef, AccountModelAccess } from '@/api'
import { BaseButton, BaseModal } from '@codex-proxy/ui'
import { ZButton, ZDialog } from '@codex-proxy/ui'
import AccountSettingsFields from './AccountSettingsFields.vue'
defineProps<{
@@ -28,12 +28,12 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="批量编辑账号"
:description="`编辑 ${selectedCount} 个账号`"
size="lg"
:dismissible="!saving"
width="48rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
>
<div class="grid gap-5">
<AccountSettingsFields
@@ -53,17 +53,17 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
<ZButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="saving"
:disabled="selectedCount === 0 || groupsLoading || !hasChanges"
@click="emit('save')"
>
保存更改
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { useAccountConnectionTest } from '../composables/useAccountConnectionTest'
import { BaseButton, BaseIconButton, BaseModal, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton, ZSelect } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
import AccountStatusBadge from './account-status-badge/index.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
@@ -42,12 +42,12 @@ function connectionLogClass(tone: string) {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="测试连接"
description="验证账号凭据、身份绑定与上游模型端点是否可用"
tone="info"
size="lg"
type="info"
width="48rem"
>
<div v-if="account" class="flex flex-col gap-4">
<section
@@ -73,10 +73,9 @@ function connectionLogClass(tone: string) {
<span class="text-cp-sm font-heavy text-cp-text-quaternary">
测试模型
</span>
<BaseIconButton
variant="ghost"
size="sm"
label="刷新上游模型"
<ZIconButton
size="small"
aria-label="刷新上游模型"
:loading="refreshingModels"
:disabled="status === 'running' || loadingModels"
@click="emit('refreshModels')"
@@ -85,9 +84,9 @@ function connectionLogClass(tone: string) {
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
<BaseSelect
<ZSelect
v-model="selectedModel"
aria-label="测试模型"
:options="modelOptions"
@@ -212,17 +211,17 @@ function connectionLogClass(tone: string) {
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
<ZButton @click="open = false">
关闭
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="status === 'running'"
:disabled="!account || loadingModels || refreshingModels || !selectedModel"
@click="emit('test')"
>
{{ logs.length > 0 || error ? '重新测试' : '开始测试' }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -3,7 +3,7 @@ import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import type { Account, AccountGroupRef, AccountModelAccess } from '@/api'
import { BaseButton, BaseFormItem, BaseModal, BaseSegmented, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZFormItem, ZSegmented, ZTextarea } from '@codex-proxy/ui'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { isOpenAiApiKeyAccount, isOpenAiOAuthAccount } from '../utils/upstreamApiKey'
@@ -42,11 +42,11 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="编辑账号"
size="md-wide"
:dismissible="!saving"
width="42rem"
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
>
<div v-if="account" class="grid gap-5">
<div
@@ -66,15 +66,15 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
</div>
<BaseFormItem v-if="isOpenAiOAuthAccount(account)" label="上游传输方式">
<ZFormItem v-if="isOpenAiOAuthAccount(account)" label="上游传输方式">
<p v-if="configurationLoading" role="status" class="m-0 text-cp-sm text-cp-text-secondary">
正在读取上游设置…
</p>
<p v-else-if="!configurationReady" role="alert" class="m-0 text-cp-sm text-cp-error">
传输方式读取失败,其他设置仍可保存
</p>
<BaseSegmented v-else v-model="oauthTransport" label="上游传输方式" :options="transportOptions" :disabled="saving" />
</BaseFormItem>
<ZSegmented v-else v-model="oauthTransport" aria-label="上游传输方式" :options="transportOptions" :disabled="saving" />
</ZFormItem>
<section v-if="isOpenAiApiKeyAccount(account)" class="grid gap-4">
<h3 class="m-0 text-cp font-heavy text-cp-text">
@@ -105,29 +105,29 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
:account-id="account.id"
/>
<BaseFormItem label="备注">
<BaseTextarea
<ZFormItem label="备注">
<ZTextarea
v-model="notes"
:rows="3"
:maxlength="500"
placeholder="最多 500 字,留空可清除备注"
:disabled="saving"
/>
</BaseFormItem>
</ZFormItem>
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
<ZButton :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
</ZButton>
<ZButton
type="primary"
:loading="saving"
:disabled="!account || groupsLoading || (isOpenAiApiKeyAccount(account) && !configurationReady)"
@click="emit('save')"
>
保存更改
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountGroupRef } from '@/api'
import { BaseButton, BaseInput, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZInput, ZSelect } from '@codex-proxy/ui'
import { Download, ListTodo, Pencil, Search, Trash2, Upload } from '@lucide/vue'
import { computed } from 'vue'
@@ -49,7 +49,7 @@ const groupOptions = computed(() => [
<div
class="grid w-full min-w-0 grid-cols-2 items-center gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_7.75rem] xl:flex xl:w-auto xl:flex-none xl:flex-wrap xl:gap-3"
>
<BaseInput
<ZInput
v-model="search"
placeholder="搜索账号"
class="col-span-2 min-w-0 sm:col-span-3 xl:w-80 xl:flex-none"
@@ -57,16 +57,16 @@ const groupOptions = computed(() => [
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
</ZInput>
<BaseSelect
<ZSelect
v-model="status"
:options="accountStatusFilterOptions"
aria-label="按账号状态筛选"
class="w-full min-w-0 xl:w-40 xl:shrink-0"
/>
<BaseSelect
<ZSelect
v-model="group"
:options="groupOptions"
:disabled="groupsLoading"
@@ -83,28 +83,28 @@ const groupOptions = computed(() => [
<div
class="grid w-full grid-cols-2 gap-2 xl:flex xl:w-auto xl:flex-wrap xl:shrink-0 xl:self-end xl:items-center xl:justify-end xl:ml-auto"
>
<BaseButton
<ZButton
v-if="selectedCount > 0"
variant="secondary"
type="default" variant="solid"
class="w-full whitespace-nowrap xl:w-auto"
@click="emit('editSelected')"
>
<Pencil class="size-4 text-cp-link" />
批量编辑账号
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-if="selectedCount > 0"
variant="destructive"
type="danger" variant="plain"
class="w-full whitespace-nowrap xl:w-auto"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
<Trash2 class="size-4" />
删除选中 ({{ selectedCount }})
</BaseButton>
<BaseButton
</ZButton>
<ZButton
v-if="selectedCount > 0"
variant="secondary"
type="default" variant="solid"
class="w-full whitespace-nowrap xl:w-auto"
:loading="exportingAccounts"
:disabled="Boolean(exportDisabledReason)"
@@ -116,25 +116,25 @@ const groupOptions = computed(() => [
<Download class="size-4" />
</template>
导出选中 ({{ selectedCount }})
</BaseButton>
</ZButton>
<div
class="col-span-2 flex min-w-0 items-center justify-end gap-2"
:class="selectedCount > 0 ? 'w-full xl:w-auto' : 'justify-self-end'"
>
<BaseButton v-if="hasImportTasks" variant="secondary" class="whitespace-nowrap" @click="emit('importTasks')">
<ZButton v-if="hasImportTasks" class="whitespace-nowrap" @click="emit('importTasks')">
<ListTodo class="size-4" />
导入任务 <span v-if="activeImportCount" class="font-mono text-cp-link">{{ activeImportCount }}</span>
</BaseButton>
</ZButton>
<slot name="actions" />
<BaseButton
variant="primary"
<ZButton
type="primary"
class="whitespace-nowrap"
:class="selectedCount > 0 ? 'flex-1 xl:flex-none' : undefined"
@click="emit('create')"
>
<Upload class="size-4" />
导入账号
</BaseButton>
</ZButton>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountModelAccess } from '@/api'
import { BaseCheckbox, BaseEmpty, BaseFormItem, BaseIconButton, BaseInput, BaseScrollbar, BaseSegmented } from '@codex-proxy/ui'
import { ZCheckbox, ZEmpty, ZFormItem, ZIconButton, ZInput, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { RefreshCw, Search } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { getAccountModels, refreshAccountModels } from '@/api'
@@ -110,29 +110,29 @@ watch(search, () => {
<template>
<div class="grid gap-3">
<BaseFormItem label="模型限制">
<ZFormItem label="模型限制">
<template v-if="restricted && accountId" #extra>
<BaseIconButton label="刷新模型" size="sm" :loading="loading" :disabled="disabled" @click="load(true)">
<ZIconButton aria-label="刷新模型" size="small" :loading="loading" :disabled="disabled" @click="load(true)">
<template #loading>
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseSegmented v-model="mode" class="w-80 max-w-full" label="模型限制模式" :options="modes" :disabled="disabled" />
</BaseFormItem>
<ZSegmented v-model="mode" class="w-80 max-w-full" aria-label="模型限制模式" :options="modes" :disabled="disabled" />
</ZFormItem>
<template v-if="restricted">
<BaseInput v-model="search" aria-label="搜索或添加模型" placeholder="搜索或输入 ID,回车添加" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<ZInput v-model="search" aria-label="搜索或添加模型" placeholder="搜索或输入 ID,回车添加" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
<template #prefix>
<Search class="size-4" aria-hidden="true" />
</template>
</BaseInput>
</ZInput>
<p v-if="inputError" class="m-0 text-cp-xs text-cp-error-text" role="alert">
{{ inputError }}
</p>
<BaseScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<ZScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid grid-cols-2 gap-2 p-1 sm:grid-cols-3" role="group" aria-label="选择模型" :aria-busy="loading || undefined">
<BaseCheckbox
<ZCheckbox
v-for="item in models"
:key="item.id"
class="min-h-11 min-w-0 rounded-cp px-3 py-2.5 transition-colors duration-150 motion-reduce:transition-none"
@@ -140,21 +140,20 @@ watch(search, () => {
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:title="item.unavailable && accountId ? `${item.id}(当前目录未返回)` : item.id"
show-label
:disabled="disabled"
@update:model-value="select(item.id, $event)"
>
<template #label>
<span class="block truncate">{{ item.id }}</span>
</template>
</BaseCheckbox>
</ZCheckbox>
</div>
</BaseScrollbar>
</ZScrollbar>
<p v-else-if="loading" class="m-0 py-4 text-center text-cp-sm text-cp-text-tertiary" role="status">
加载模型中…
</p>
<BaseEmpty v-else-if="!error" :title="search ? '无匹配模型' : '暂无模型'" :icon="search ? Search : undefined" size="sm" surface="none" />
<BaseEmpty v-if="error" title="模型列表加载失败" description="请刷新后重试" size="sm" surface="none" role="alert" />
<ZEmpty v-else-if="!error" :title="search ? '无匹配模型' : '暂无模型'" :icon="search ? Search : undefined" size="small" surface="none" />
<ZEmpty v-if="error" title="模型列表加载失败" description="请刷新后重试" size="small" surface="none" role="alert" />
</template>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BasePopover, BaseScrollbar } from '@codex-proxy/ui'
import { ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, useId, useTemplateRef, watch } from 'vue'
@@ -29,7 +29,7 @@ watch(() => props.notes, recheckOpen, { flush: 'post' })
</script>
<template>
<BasePopover
<ZPopover
:model-value="open"
class="min-w-0 max-w-full"
trigger="hover-click"
@@ -38,7 +38,7 @@ watch(() => props.notes, recheckOpen, { flush: 'post' })
:hover-delay="240"
@update:model-value="updateOpen"
>
<template #trigger>
<template #reference>
<button
type="button"
class="inline-flex min-w-0 max-w-full touch-manipulation items-center rounded-cp-sm border-0 bg-transparent p-0 text-left text-cp-xs leading-4 text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
@@ -53,11 +53,11 @@ watch(() => props.notes, recheckOpen, { flush: 'post' })
</template>
<section :id="detailId" class="w-max max-w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-cp-lg" role="dialog" aria-label="账号备注">
<BaseScrollbar max-height="min(240px, calc(100dvh - 2rem))">
<ZScrollbar max-height="min(240px, calc(100dvh - 2rem))">
<div class="px-3 py-2.5 text-cp-sm leading-5 whitespace-pre-wrap text-cp-text select-text wrap-anywhere">
{{ notes }}
</div>
</BaseScrollbar>
</ZScrollbar>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { getAccounts } from '@/api'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { ZCard, ZMotionIcon } from '@codex-proxy/ui'
import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import { computed } from 'vue'
@@ -57,7 +57,7 @@ function overviewIconClass(tone: string) {
<template>
<div class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<BaseCard v-for="item in overviewItems" :key="item.label" as="article" padding="compact">
<ZCard v-for="item in overviewItems" :key="item.label" as="article" padding="compact">
<div class="flex items-stretch justify-between gap-3">
<div class="flex min-w-0 flex-col">
<p class="m-0 text-cp-sm leading-none font-heavy text-cp-text-secondary">
@@ -70,13 +70,13 @@ function overviewIconClass(tone: string) {
{{ item.caption }}
</p>
</div>
<BaseMotionIcon
<ZMotionIcon
class="inline-flex size-9 shrink-0 items-center justify-center self-start rounded-lg"
:class="overviewIconClass(item.tone)"
>
<component :is="item.icon" class="size-4.5" />
</BaseMotionIcon>
</ZMotionIcon>
</div>
</BaseCard>
</ZCard>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseFormItem, BaseSelect } from '@codex-proxy/ui'
import { ZFormItem, ZSelect } from '@codex-proxy/ui'
import { computed } from 'vue'
import { useProxyCatalog } from '@/composables/useProxyCatalog'
@@ -34,7 +34,7 @@ const selection = computed({
</script>
<template>
<BaseFormItem label="出站隧道" :error="error">
<BaseSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
</BaseFormItem>
<ZFormItem label="出站隧道" :error="error">
<ZSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" aria-label="出站隧道" />
</ZFormItem>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountGroupRef, AccountModelAccess } from '@/api'
import { BaseFormItem, BaseInput, BaseSwitch } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSwitch } from '@codex-proxy/ui'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import AccountModelAccessField from './AccountModelAccessField.vue'
import AccountProxyField from './AccountProxyField.vue'
@@ -31,16 +31,16 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
<AccountModelAccessField v-model="modelAccess" :account-id="accountId" :disabled="disabled" :allow-preserve="preserveModelAccess" />
<div v-if="showScheduling" class="flex min-h-6 items-center justify-between gap-3">
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
<ZSwitch
v-model="enabled"
label="切换账号调度"
aria-label="切换账号调度"
:disabled="disabled"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="并发限制">
<BaseInput
<ZFormItem label="并发限制">
<ZInput
v-model="concurrencyLimit"
aria-label="账号并发限制"
type="number"
@@ -49,9 +49,9 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
placeholder="留空使用默认值"
:disabled="disabled"
/>
</BaseFormItem>
<BaseFormItem label="权重">
<BaseInput
</ZFormItem>
<ZFormItem label="权重">
<ZInput
v-model="weight"
aria-label="账号调度权重"
type="number"
@@ -60,17 +60,17 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
placeholder="越高越优先,最大 100"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
</div>
<BaseFormItem label="所属分组">
<ZFormItem label="所属分组">
<AccountGroupCheckboxGrid
v-model="selectedGroupIds"
:groups="groups"
:loading="groupsLoading"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseIconButton, BaseMenuItem, BasePopover } from '@codex-proxy/ui'
import { ZIconButton, ZMenuItem, ZPopover } from '@codex-proxy/ui'
import { Download, KeyRound, MoreHorizontal, Pencil, Power, RefreshCw, RotateCcw, Trash2, Wifi } from '@lucide/vue'
import { computed } from 'vue'
@@ -31,35 +31,33 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<template>
<div class="relative flex items-center justify-start gap-1">
<BaseIconButton
variant="ghost"
size="sm"
label="编辑账号"
<ZIconButton
size="small"
aria-label="编辑账号"
@click.stop="emit('edit', account)"
>
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
</ZIconButton>
<BaseIconButton
variant="ghost"
size="sm"
label="删除账号"
<ZIconButton
size="small"
aria-label="删除账号"
:disabled="deleting"
@click.stop="emit('delete', account)"
>
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
</ZIconButton>
<BasePopover placement="bottom-end">
<template #trigger="{ open }">
<BaseIconButton variant="ghost" size="sm" label="更多操作" :pressed="open">
<ZPopover placement="bottom-end">
<template #reference="{ open }">
<ZIconButton size="small" aria-label="更多操作" :pressed="open">
<MoreHorizontal class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<template #default="{ close }">
<div class="w-40 p-1.5">
<BaseMenuItem
<ZMenuItem
:loading="testing"
:disabled="testing"
@click.stop="(close(), emit('test', account))"
@@ -68,8 +66,8 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<Wifi class="size-3.5 text-cp-text-quaternary" />
</template>
测试连接
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
:loading="togglingScheduling"
:disabled="togglingScheduling"
@click.stop="(close(), emit('toggleScheduling', account))"
@@ -78,8 +76,8 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<Power class="size-3.5 text-cp-text-quaternary" />
</template>
{{ account.enabled ? '停用调度' : '启用调度' }}
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
v-if="credentialEligible"
:loading="refreshing"
:disabled="refreshing"
@@ -92,14 +90,14 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<RefreshCw class="size-3.5 text-cp-text-quaternary" />
</template>
刷新令牌
</BaseMenuItem>
<BaseMenuItem v-if="credentialEligible" @click.stop="(close(), emit('reauthorize', account))">
</ZMenuItem>
<ZMenuItem v-if="credentialEligible" @click.stop="(close(), emit('reauthorize', account))">
<template #icon>
<KeyRound class="size-3.5 text-cp-text-quaternary" />
</template>
重新授权
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
v-if="account.provider === 'openai' && account.authenticationKind === 'oauth'"
:loading="downloadingCatalog"
:disabled="downloadingCatalog"
@@ -112,8 +110,8 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<Download class="size-3.5 text-cp-text-quaternary" />
</template>
下载模型目录
</BaseMenuItem>
<BaseMenuItem
</ZMenuItem>
<ZMenuItem
:loading="recovering"
:disabled="recovering"
@click.stop="(close(), emit('recover', account.id))"
@@ -122,9 +120,9 @@ const credentialEligible = computed(() => props.account.authenticationKind === '
<RotateCcw class="size-3.5 text-cp-text-quaternary" />
</template>
恢复状态
</BaseMenuItem>
</ZMenuItem>
</div>
</template>
</BasePopover>
</ZPopover>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { defineTableColumns, ZTable } from '@codex-proxy/ui'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { modelSuccessRateTextClass } from '../constants'
@@ -100,11 +100,11 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
<div class="h-56 min-w-0 xl:h-auto xl:min-h-0 xl:flex-1 xl:basis-0">
<BaseTable
<ZTable
:columns="modelUsageColumns"
:rows="account.usage.models"
:data="account.usage.models"
row-key="model"
density="compact"
size="small"
empty-text="暂无模型用量"
>
<template #successRateDisplay="{ row }">
@@ -112,7 +112,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
{{ row.successRateDisplay }}
</span>
</template>
</BaseTable>
</ZTable>
</div>
</div>
</section>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseButton, BaseFormItem, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZFormItem, ZTextarea } from '@codex-proxy/ui'
import { Upload } from '@lucide/vue'
import { useFileDialog } from '@vueuse/core'
import { onScopeDispose, ref } from 'vue'
@@ -45,16 +45,16 @@ function updateText(value: string) {
</script>
<template>
<BaseFormItem :label="label" required :error="fileError || undefined">
<ZFormItem :label="label" required :error="fileError || undefined">
<template v-if="uploadable" #extra>
<BaseButton size="sm" :disabled="disabled" @click="openFile()">
<ZButton size="small" :disabled="disabled" @click="openFile()">
<template #icon>
<Upload class="size-3.5" aria-hidden="true" />
</template>
上传文件
</BaseButton>
</ZButton>
</template>
<BaseTextarea
<ZTextarea
:model-value="text"
:aria-label="label"
:rows="9"
@@ -62,5 +62,5 @@ function updateText(value: string) {
:disabled="disabled"
@update:model-value="updateText"
/>
</BaseFormItem>
</ZFormItem>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountAuthorizationView } from '../../composables/useAccountAuthorization'
import { BaseButton, BaseForm, BaseFormItem, BaseIconButton, BaseScrollbar, BaseTextarea } from '@codex-proxy/ui'
import { ZButton, ZForm, ZFormItem, ZIconButton, ZScrollbar, ZTextarea } from '@codex-proxy/ui'
import { Copy, KeyRound } from '@lucide/vue'
import { computed } from 'vue'
import { useCopyText } from '@/composables/useCopyText'
@@ -49,40 +49,39 @@ const statusText = computed(() => {
</div>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="secondary"
<ZButton
:loading="loading"
:disabled="disabled || (!authorization.flow && !canStart)"
@click="emit('regenerate')"
>
{{ authUrl ? '重新生成授权链接' : '生成授权链接' }}
</BaseButton>
</ZButton>
</div>
<BaseForm v-if="authUrl">
<BaseFormItem label="授权链接">
<ZForm v-if="authUrl">
<ZFormItem label="授权链接">
<template #extra>
<BaseIconButton
variant="secondary"
size="sm"
<ZIconButton
variant="solid"
size="small"
title="复制链接"
label="复制链接"
aria-label="复制链接"
:disabled="disabled"
@click="copyWithToast(authUrl, { successText: '授权链接已复制' })"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</template>
<BaseScrollbar max-height="92px">
<ZScrollbar max-height="92px">
<div class="rounded-cp bg-(--cp-input-bg) px-3.5 py-3 shadow-cp-tertiary">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="authUrl"
/>
</div>
</BaseScrollbar>
</BaseFormItem>
</BaseForm>
</ZScrollbar>
</ZFormItem>
</ZForm>
<p v-if="statusText" class="m-0 text-cp-sm text-cp-text-secondary" role="status">
{{ statusText }}
@@ -91,16 +90,16 @@ const statusText = computed(() => {
{{ authorization.error }}
</p>
<BaseForm v-if="authorization.flow">
<BaseFormItem :label="callbackLabel" required>
<BaseTextarea
<ZForm v-if="authorization.flow">
<ZFormItem :label="callbackLabel" required>
<ZTextarea
v-model="callback"
:aria-label="callbackLabel"
:rows="4"
:placeholder="callbackPlaceholder"
:disabled="disabled"
/>
</BaseFormItem>
</BaseForm>
</ZFormItem>
</ZForm>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountCreateSource } from '../../utils/accountCreate'
import { BaseSegmented } from '@codex-proxy/ui'
import { ZSegmented } from '@codex-proxy/ui'
import { LayoutGrid } from '@lucide/vue'
import { computed } from 'vue'
import { formatProviderLabel, PROVIDER_IDS, providerIcon } from '@/utils/providers'
@@ -26,12 +26,12 @@ const selected = computed({
</script>
<template>
<BaseSegmented
<ZSegmented
v-model="selected"
class="w-full"
label="选择账号平台"
aria-label="选择账号平台"
:options="options"
:disabled="disabled"
size="lg"
size="large"
/>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountCreateForm } from '../../utils/accountCreate'
import type { AccountGroupRef } from '@/api'
import { BaseFormItem, BaseTextarea } from '@codex-proxy/ui'
import { ZFormItem, ZTextarea } from '@codex-proxy/ui'
import AccountSettingsFields from '../AccountSettingsFields.vue'
import AccountProviderChooser from './AccountProviderChooser.vue'
@@ -37,14 +37,14 @@ const form = defineModel<AccountCreateForm>({ required: true })
:disabled="disabled"
:proxy-error="proxyError"
/>
<BaseFormItem label="备注">
<BaseTextarea
<ZFormItem label="备注">
<ZTextarea
v-model="form.notes"
:rows="3"
:maxlength="500"
placeholder="最多 500 字,可不填"
:disabled="disabled"
/>
</BaseFormItem>
</ZFormItem>
</div>
</template>
@@ -4,7 +4,7 @@ import type { AccountCreateForm, AccountImportMode } from '../../utils/accountCr
import type { Account, AccountGroupRef } from '@/api'
import { Openai, Xai } from '@boxicons/vue'
import { BaseButton, BaseIconButton, BaseModal, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZIconButton, ZSegmented } from '@codex-proxy/ui'
import { Copy, LayoutGrid, Settings2 } from '@lucide/vue'
import { computed } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
@@ -76,13 +76,13 @@ function continueToImport() {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
:title="view.modal.title"
:description="view.modal.description"
:tone="view.modal.tone"
:size="view.modal.size"
:dismissible="!busy"
:type="view.modal.type"
:width="view.modal.width"
:show-close="!busy" :close-on-click-modal="!busy" :close-on-press-escape="!busy"
>
<template #icon>
<Settings2 v-if="view.configuring" class="text-cp-text" :size="20" aria-hidden="true" />
@@ -111,15 +111,15 @@ function continueToImport() {
:provider="account.provider"
:authentication-kind="account.authenticationKind"
/>
<BaseIconButton
variant="secondary"
size="sm"
label="复制账号"
<ZIconButton
variant="solid"
size="small"
aria-label="复制账号"
:disabled="busy || !accountCopyValue"
@click="copyWithToast(accountCopyValue, { successText: '账号已复制' })"
>
<Copy class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
@@ -135,10 +135,10 @@ function continueToImport() {
{{ scheduling.message }}
</p>
<template v-if="!view.configuring">
<BaseSegmented
<ZSegmented
v-if="!reauthorizing && !view.isBatch"
v-model="mode"
label="账号添加方式"
aria-label="账号添加方式"
:options="view.modeOptions"
:disabled="busy"
class="w-full"
@@ -171,18 +171,18 @@ function continueToImport() {
</div>
<template #footer>
<BaseButton v-if="view.configuring || reauthorizing" variant="secondary" :disabled="busy" @click="open = false">
<ZButton v-if="view.configuring || reauthorizing" :disabled="busy" @click="open = false">
取消
</BaseButton>
<BaseButton v-else class="mr-auto" variant="secondary" :disabled="busy" @click="form.step = 'settings'">
</ZButton>
<ZButton v-else class="mr-auto" :disabled="busy" @click="form.step = 'settings'">
上一步
</BaseButton>
<BaseButton v-if="view.configuring" variant="primary" :disabled="!canSelect || !scheduling.valid || Boolean(modelError) || groupsLoading || Boolean(proxyError) || busy" @click="continueToImport">
</ZButton>
<ZButton v-if="view.configuring" type="primary" :disabled="!canSelect || !scheduling.valid || Boolean(modelError) || groupsLoading || Boolean(proxyError) || busy" @click="continueToImport">
继续导入
</BaseButton>
<BaseButton v-else variant="primary" :loading="saving || oauthLoading" :disabled="!view.canSubmit" @click="emit('create')">
</ZButton>
<ZButton v-else type="primary" :loading="saving || oauthLoading" :disabled="!view.canSubmit" @click="emit('create')">
{{ view.submitLabel }}
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -49,8 +49,8 @@ export function resolveAccountCreatePresentation(input: {
modal: {
title: configuring ? '账号设置' : input.reauthorizing ? '重新授权账号' : '导入账号',
description: configuring ? '设置将应用于本次导入的账号' : input.reauthorizing ? '完成授权后更新账号凭据' : description,
tone: configuring ? 'neutral' as const : 'info' as const,
size: configuring ? 'md-wide' as const : 'md' as const,
type: configuring ? 'default' as const : 'info' as const,
width: configuring ? '42rem' : '36rem',
},
oauth: {
callbackLabel: id === 'xai' ? '回调地址或授权码' : '回调地址',
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountImportTaskDetail } from '@/api'
import { BaseButton, BaseEmpty, BaseScrollbar, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZEmpty, ZScrollbar, ZSegmented } from '@codex-proxy/ui'
import { ArrowUpRight, Check, CircleAlert, Square, X } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
@@ -40,9 +40,9 @@ watch(hasAttention, (value) => {
{{ task.createdAtDisplay }} 创建
</p>
</div>
<BaseButton
<ZButton
v-if="!task.finishedAt"
size="sm"
size="small"
:loading="stopping"
:disabled="task.stopRequested || task.counts.pending === 0"
@click="emit('stop')"
@@ -51,10 +51,10 @@ watch(hasAttention, (value) => {
<Square class="size-3 text-cp-error" />
</template>
{{ task.stopRequested || task.counts.pending === 0 ? '等待当前条目结束' : '停止未开始条目' }}
</BaseButton>
<BaseButton v-else size="sm" variant="soft" @click="emit('viewAccounts')">
</ZButton>
<ZButton v-else size="small" type="primary" variant="plain" @click="emit('viewAccounts')">
查看账号 <ArrowUpRight class="size-3.5" />
</BaseButton>
</ZButton>
</div>
<div>
@@ -86,17 +86,17 @@ watch(hasAttention, (value) => {
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
待核对条目可能已入库,请先查看账号,再决定是否重新导入
</p>
<BaseSegmented
<ZSegmented
v-model="filter"
class="ml-auto shrink-0"
label="筛选导入明细"
aria-label="筛选导入明细"
:options="filterOptions"
size="sm"
size="small"
/>
</div>
<div>
<BaseScrollbar :key="`${task.taskId}-${filter}`" max-height="min(18rem, 32dvh)">
<BaseEmpty v-if="!visibleItems.length" size="sm" title="暂无需要处理的条目" surface="none" />
<ZScrollbar :key="`${task.taskId}-${filter}`" max-height="min(18rem, 32dvh)">
<ZEmpty v-if="!visibleItems.length" size="small" title="暂无需要处理的条目" surface="none" />
<ol v-else class="m-0 list-none space-y-1 p-0" aria-label="导入条目结果">
<li
v-for="item in visibleItems"
@@ -120,7 +120,7 @@ watch(hasAttention, (value) => {
</span>
</li>
</ol>
</BaseScrollbar>
</ZScrollbar>
</div>
</div>
</section>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountImportTask, AccountImportTaskDetail } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal, BasePopover, BaseSelect } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton, ZPopover, ZSelect } from '@codex-proxy/ui'
import { CircleAlert, ListTodo } from '@lucide/vue'
import { computed, shallowRef, useId, watch } from 'vue'
import { taskLabel } from './presenter'
@@ -39,41 +39,41 @@ function handleRetentionKeydown(event: KeyboardEvent) {
</script>
<template>
<BaseModal v-model="open" title="导入任务" description="关闭页面后继续导入,再次打开即可查看进度" size="lg">
<ZDialog v-model="open" title="导入任务" description="关闭页面后继续导入,再次打开即可查看进度" width="48rem">
<div v-if="error" role="alert" class="mb-4 flex items-center justify-between gap-3 rounded-cp bg-cp-warning-container p-3 text-xs text-cp-warning-on-container">
<span>进度暂未更新:{{ error.replace(/[。.]\s*$/u, '') }},恢复连接后将自动刷新</span>
<BaseButton size="sm" :loading="loading" @click="emit('refresh')">
<ZButton size="small" :loading="loading" @click="emit('refresh')">
重试
</BaseButton>
</ZButton>
</div>
<BaseEmpty v-if="!tasks.length" class="min-h-[min(20rem,50dvh)] content-center" :icon="ListTodo" :title="loading ? '正在读取导入任务' : '暂无导入任务'" description="新建一次账号导入,即可在这里查看逐条入库结果" />
<ZEmpty v-if="!tasks.length" class="min-h-[min(20rem,50dvh)] content-center" :icon="ListTodo" :title="loading ? '正在读取导入任务' : '暂无导入任务'" description="新建一次账号导入,即可在这里查看逐条入库结果" />
<div v-else class="min-h-[min(20rem,50dvh)] min-w-0">
<div v-if="tasks.length > 1" class="mb-5">
<BaseSelect
<ZSelect
:model-value="selectedId"
:options="taskOptions"
size="sm"
size="small"
aria-label="切换导入任务"
class="w-full min-w-0 sm:max-w-96"
@update:model-value="emit('select', $event)"
@update:model-value="emit('select', $event ?? selectedId)"
/>
</div>
<TaskDetail v-if="detail" :task="detail" :stopping="stopping" @stop="emit('stop')" @view-accounts="emit('viewAccounts')" />
<BaseEmpty v-else class="min-h-[min(16rem,40dvh)] content-center" :title="loading ? '正在读取条目结果' : '请选择一个任务'" surface="none" />
<ZEmpty v-else class="min-h-[min(16rem,40dvh)] content-center" :title="loading ? '正在读取条目结果' : '请选择一个任务'" surface="none" />
</div>
<template #footer>
<div class="flex w-full flex-wrap items-center justify-between gap-3">
<BasePopover v-model="retentionOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseIconButton
label="任务保留说明"
size="sm"
<ZPopover v-model="retentionOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #reference>
<ZIconButton
aria-label="任务保留说明"
size="small"
:aria-expanded="retentionOpen"
:aria-describedby="retentionOpen ? retentionId : undefined"
@keydown="handleRetentionKeydown"
>
<CircleAlert class="size-4" />
</BaseIconButton>
</ZIconButton>
</template>
<div :id="retentionId" role="tooltip" class="grid w-72 max-w-[calc(100vw-2rem)] gap-2 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0">
@@ -83,11 +83,11 @@ function handleRetentionKeydown(event: KeyboardEvent) {
服务重启后任务记录消失,已入库账号保留
</p>
</div>
</BasePopover>
<BaseButton @click="open = false">
</ZPopover>
<ZButton @click="open = false">
关闭
</BaseButton>
</ZButton>
</div>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountProfileStatisticsResponse } from '@/api'
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
import { computed } from 'vue'
import { formatCompactNumber, formatInteger } from '@/utils/format'
@@ -67,7 +67,7 @@ function formatDuration(value: number | null | undefined) {
<dl class="grid min-w-130 grid-cols-5">
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
<BaseSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
<ZSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
<template v-else>
{{ metric.value }}
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
</script>
<template>
@@ -8,20 +8,20 @@ import { BaseSkeleton } from '@codex-proxy/ui'
<section class="grid min-w-0 gap-4 overflow-hidden">
<div class="flex items-center justify-between">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
<ZSkeleton shape="text" class="h-4 w-20" />
<ZSkeleton class="h-7 w-32 rounded-cp opacity-70" />
</div>
<div class="grid min-w-190 gap-1" style="grid-template-columns: repeat(52, minmax(0, 1fr))">
<div v-for="week in 52" :key="week" class="grid gap-1">
<BaseSkeleton v-for="day in 7" :key="day" class="aspect-square rounded-[3px] opacity-60" />
<ZSkeleton v-for="day in 7" :key="day" class="aspect-square rounded-[3px] opacity-60" />
</div>
</div>
</section>
<section class="grid gap-10 sm:grid-cols-2">
<div v-for="column in 2" :key="column" class="grid gap-3">
<BaseSkeleton shape="text" class="h-4 w-28" />
<BaseSkeleton v-for="row in 5" :key="row" shape="text" class="w-full opacity-60" />
<ZSkeleton shape="text" class="h-4 w-28" />
<ZSkeleton v-for="row in 5" :key="row" shape="text" class="w-full opacity-60" />
</div>
</section>
</div>
@@ -51,7 +51,7 @@ const dates = computed(() => [
<dt class="text-cp-xs text-cp-text-secondary">
计费
</dt>
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed break-words text-cp-text">
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed wrap-break-word text-cp-text">
{{ billing }}
</dd>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ProfileActivityLevel, ProfileActivityMode } from './activity'
import type { ProfileActivityCalendar } from '@/api'
import { BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
import { buildProfileActivityGrid, profileActivityCellLabel } from './activity'
@@ -40,14 +40,14 @@ const monthLabels = computed(() =>
Token 活动
</h3>
</div>
<BaseSegmented v-model="mode" label="Token 活动统计方式" size="sm" :options="modeOptions" />
<ZSegmented v-model="mode" aria-label="Token 活动统计方式" size="small" :options="modeOptions" />
</div>
<BaseEmpty
<ZEmpty
v-if="calendar === null"
title="暂无 Token 活动"
description="本次未获取到每日 Token 活动"
size="sm"
size="small"
surface="none"
/>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseButton, BaseEmpty, BaseModal, BaseSkeleton } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZSkeleton } from '@codex-proxy/ui'
import { RefreshCw, TriangleAlert } from '@lucide/vue'
import { toRef } from 'vue'
@@ -24,7 +24,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
</script>
<template>
<BaseModal v-model="open" title="个人信息" size="xl">
<ZDialog v-model="open" title="个人信息" width="64rem">
<div class="flex min-h-0 min-w-0 flex-col gap-6 pb-2">
<div class="grid min-w-0 grid-cols-1 gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:items-center" :class="account.capabilities.profile ? 'lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)]' : undefined">
<AccountProfileHero :account="account" :profile="profile" />
@@ -42,10 +42,10 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
<AccountProfileSkeleton v-if="account.capabilities.profile" />
<div v-else role="status" aria-busy="true">
<span class="sr-only">正在加载订阅信息</span>
<BaseSkeleton class="h-24 w-full rounded-cp-lg" />
<ZSkeleton class="h-24 w-full rounded-cp-lg" />
</div>
</template>
<BaseEmpty
<ZEmpty
v-else-if="error && !profile"
title="个人信息加载失败"
:description="error"
@@ -53,7 +53,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
surface="none"
/>
<template v-else-if="profile">
<BaseEmpty
<ZEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="本次未获取到统计数据,可以点击刷新信息重试"
@@ -68,22 +68,22 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
本次信息刷新未完成,保留已获取的结果
</p>
</template>
<BaseEmpty
<ZEmpty
v-else-if="!loading && !account.capabilities.profile && !subscription"
title="暂无订阅信息"
surface="none"
/>
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
<ZButton @click="open = false">
关闭
</BaseButton>
<BaseButton :aria-busy="loading" :aria-disabled="loading" @click="load">
</ZButton>
<ZButton :aria-busy="loading" :aria-disabled="loading" @click="load">
<template #icon>
<RefreshCw class="size-4" :class="loading ? 'animate-spin motion-reduce:animate-none' : undefined" />
</template>
刷新信息
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseSkeleton } from '@codex-proxy/ui'
import { ZSkeleton } from '@codex-proxy/ui'
</script>
<template>
@@ -9,27 +9,27 @@ import { BaseSkeleton } from '@codex-proxy/ui'
<div class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-16" />
<BaseSkeleton class="h-6 w-28" />
<ZSkeleton shape="text" class="w-16" />
<ZSkeleton class="h-6 w-28" />
</div>
<BaseSkeleton class="mt-2 h-8 w-28" />
<BaseSkeleton class="mt-3 h-3 w-full rounded-full" />
<ZSkeleton class="mt-2 h-8 w-28" />
<ZSkeleton class="mt-3 h-3 w-full rounded-full" />
<div class="mt-2 flex justify-between">
<BaseSkeleton v-for="tick in 5" :key="tick" shape="text" class="h-2.5 w-5" />
<ZSkeleton v-for="tick in 5" :key="tick" shape="text" class="h-2.5 w-5" />
</div>
</div>
<div class="grid gap-2">
<div class="mb-1 flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-20" />
</div>
<div v-for="metric in 2" :key="metric" class="rounded-cp bg-cp-bg-container px-3 py-3">
<BaseSkeleton shape="text" class="w-20" />
<ZSkeleton shape="text" class="w-20" />
<div class="mt-3 grid grid-cols-[1fr_20px_1.2fr] items-center gap-2">
<BaseSkeleton shape="text" class="w-14" />
<BaseSkeleton class="size-3" />
<BaseSkeleton class="h-6 w-24 justify-self-end" />
<ZSkeleton shape="text" class="w-14" />
<ZSkeleton class="size-3" />
<ZSkeleton class="h-6 w-24 justify-self-end" />
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton, ZPopover, ZSegmented } from '@codex-proxy/ui'
import { ChartNoAxesCombined, CircleAlert, RefreshCw } from '@lucide/vue'
import { useIntervalFn, useNow } from '@vueuse/core'
import { computed, ref, toRef, useId, watch } from 'vue'
@@ -62,12 +62,12 @@ function handleExplanationKeydown(event: KeyboardEvent) {
</script>
<template>
<BaseModal
<ZDialog
v-model="open"
title="额度预测"
description="按本周期估算,重置后重新累计"
size="md"
tone="info"
width="36rem"
type="info"
>
<template #icon>
<ChartNoAxesCombined class="size-5 text-cp-primary-text" :stroke-width="1.75" />
@@ -80,23 +80,23 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<ProviderIconGroup :provider="account.provider" size="xs" />
</template>
</AccountIdentityCell>
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
<ZSegmented v-model="period" aria-label="预测周期" :options="options" class="w-48" />
</div>
<div aria-live="polite" :aria-busy="loading || refreshing">
<ForecastSkeleton v-if="loading || refreshing" />
<div v-else-if="error && !report" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载" surface="none" class="min-h-80 content-center">
<ZEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载" surface="none" class="min-h-80 content-center">
<template #action>
<BaseButton variant="secondary" @click="load">
<ZButton @click="load">
重新加载
</BaseButton>
</ZButton>
</template>
</BaseEmpty>
</ZEmpty>
</div>
<template v-else-if="forecast">
<div v-if="unavailableReason" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
<ZEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
</div>
<ForecastCapacity v-else :forecast="forecast" />
</template>
@@ -108,18 +108,17 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
更新于 {{ forecast.source.observedAtDisplay }}
</span>
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseIconButton
label="预测说明"
variant="ghost"
size="sm"
<ZPopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #reference>
<ZIconButton
aria-label="预测说明"
size="small"
:aria-expanded="explanationOpen"
:aria-describedby="explanationOpen ? explanationId : undefined"
@keydown="handleExplanationKeydown"
>
<CircleAlert class="size-3.5" aria-hidden="true" />
</BaseIconButton>
</ZIconButton>
</template>
<section :id="explanationId" role="tooltip" class="grid w-96 max-w-[calc(100vw-2rem)] gap-3 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<h4 class="m-0 font-heavy text-cp-text">
@@ -141,12 +140,12 @@ function handleExplanationKeydown(event: KeyboardEvent) {
预测以数据更新时间为准,等价费用不是实际账单或账户余额
</p>
</section>
</BasePopover>
</ZPopover>
</div>
<BaseButton variant="secondary" @click="open = false">
<ZButton @click="open = false">
关闭
</BaseButton>
<BaseButton variant="primary" :loading="refreshing" :disabled="loading" @click="refresh">
</ZButton>
<ZButton type="primary" :loading="refreshing" :disabled="loading" @click="refresh">
<template #loading>
<RefreshCw class="size-3.5 motion-safe:animate-spin" />
</template>
@@ -154,7 +153,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
<RefreshCw class="size-3.5" />
</template>
刷新额度
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { Account, AccountResetCredit } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton } from '@codex-proxy/ui'
import { AlertTriangle, RefreshCw, TicketCheck } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
@@ -121,12 +121,12 @@ function handleRequestConsume(creditId: string) {
</span>
</button>
<BaseModal
<ZDialog
v-model="panelOpen"
:title="modalTitle"
:tone="confirmation ? 'warning' : 'neutral'"
:size="confirmation ? 'sm' : 'md'"
:dismissible="!consuming"
:type="confirmation ? 'warning' : 'default'"
:width="confirmation ? '28rem' : '36rem'"
:show-close="!consuming" :close-on-click-modal="!consuming" :close-on-press-escape="!consuming"
>
<div v-if="confirmation" class="grid gap-3">
<section class="rounded-cp bg-cp-fill-quaternary px-4 py-3.5">
@@ -158,9 +158,9 @@ function handleRequestConsume(creditId: string) {
请继续确认上次重置结果
</p>
</div>
<BaseButton size="sm" variant="soft" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
<ZButton size="small" type="primary" variant="plain" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
继续确认
</BaseButton>
</ZButton>
</section>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
@@ -178,10 +178,9 @@ function handleRequestConsume(creditId: string) {
>
{{ countLabel }}
</span>
<BaseIconButton
variant="ghost"
size="sm"
label="刷新主动重置卡"
<ZIconButton
size="small"
aria-label="刷新主动重置卡"
:loading="loading"
:disabled="loading || consuming"
@click="loadCredits"
@@ -190,7 +189,7 @@ function handleRequestConsume(creditId: string) {
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
<div :aria-busy="loading || consuming">
@@ -222,22 +221,22 @@ function handleRequestConsume(creditId: string) {
{{ credit.expiry }}
</p>
</div>
<BaseButton
size="sm"
variant="primary"
<ZButton
size="small"
type="primary"
:disabled="!account.capabilities.consumeResetCredit || loading || consuming || ambiguous || availableCount <= 0"
:aria-label="`使用重置:${credit.title},${credit.expiry}`"
@click="handleRequestConsume(credit.id)"
>
使用重置
</BaseButton>
</ZButton>
</li>
</ul>
<BaseEmpty
<ZEmpty
v-else-if="!showCountOnlyAction"
:icon="TicketCheck"
size="sm"
size="small"
surface="none"
title="当前没有可用重置次数"
description="可刷新列表,重新读取上游状态"
@@ -250,22 +249,22 @@ function handleRequestConsume(creditId: string) {
<template v-if="confirmation || showCountOnlyAction" #footer>
<template v-if="confirmation">
<BaseButton variant="secondary" :disabled="consuming" @click="cancelConsume">
<ZButton :disabled="consuming" @click="cancelConsume">
返回
</BaseButton>
<BaseButton variant="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
</ZButton>
<ZButton type="primary" :loading="consuming" :disabled="loading || !canRequestConsume" @click="confirmConsume">
{{ confirmation.ambiguous ? '再次确认' : '确认重置' }}
</BaseButton>
</ZButton>
</template>
<BaseButton
<ZButton
v-else
size="sm"
variant="primary"
size="small"
type="primary"
:disabled="loading || consuming || ambiguous || !canRequestConsume"
@click="requestConsume"
>
使用一次重置(由上游选择)
</BaseButton>
</ZButton>
</template>
</BaseModal>
</ZDialog>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui'
import { ZEmpty, ZIconButton } from '@codex-proxy/ui'
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
@@ -59,37 +59,34 @@ watch(hasPersonalInfo, (available) => {
</p>
</div>
<div v-if="hasActions" class="flex shrink-0 items-center gap-0.5 [&_svg]:size-3.5 [&_svg]:stroke-2">
<BaseIconButton
<ZIconButton
v-if="hasPersonalInfo"
label="查看个人信息"
size="sm"
variant="ghost"
aria-label="查看个人信息"
size="small"
:pressed="profileOpen"
@click="profileOpen = true"
>
<UserRound class="size-3.5" />
</BaseIconButton>
</ZIconButton>
<AccountResetCredits
v-if="account.capabilities.resetCredits"
:account="account"
@consumed="emit('quotaReset', $event)"
/>
<BaseIconButton
<ZIconButton
v-if="hasForecast"
label="预测周/月额度"
size="sm"
variant="ghost"
aria-label="预测周/月额度"
size="small"
aria-haspopup="dialog"
:pressed="forecastOpen"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" />
</BaseIconButton>
<BaseIconButton
</ZIconButton>
<ZIconButton
v-if="account.capabilities.quotaRefresh"
variant="ghost"
size="sm"
label="刷新额度"
size="small"
aria-label="刷新额度"
:loading="refreshing"
:disabled="refreshing"
@click="emit('refreshQuota', account.id)"
@@ -98,12 +95,12 @@ watch(hasPersonalInfo, (available) => {
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-3.5" />
</BaseIconButton>
</ZIconButton>
</div>
</div>
<div v-if="!account.capabilities.quota && quotaEntries.length === 0" class="grid flex-1 place-items-center">
<BaseEmpty title="暂不支持查询上游额度" surface="none" />
<ZEmpty title="暂不支持查询上游额度" surface="none" />
</div>
<div v-else class="grid min-h-0 gap-3">
<AccountQuotaPanelEntry
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '@/api'
import { BasePopover } from '@codex-proxy/ui'
import { ZPopover } from '@codex-proxy/ui'
import { computed } from 'vue'
import AccountRequestTimeline from '@/components/account/account-usage-window/AccountRequestTimeline.vue'
@@ -74,8 +74,8 @@ function quotaWindowCode(windowSeconds: number | null, role: AccountQuotaWindow[
</script>
<template>
<BasePopover class="grid! w-full grid-cols-subgrid" trigger="hover-click" placement="right" :hover-delay="240">
<template #trigger="{ open }">
<ZPopover class="grid! w-full grid-cols-subgrid" trigger="hover-click" placement="right" :hover-delay="240">
<template #reference="{ open }">
<button
type="button"
class="col-span-full grid w-full min-w-0 grid-cols-subgrid cursor-pointer rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
@@ -184,5 +184,5 @@ function quotaWindowCode(windowSeconds: number | null, role: AccountQuotaWindow[
</article>
</div>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountErrorReason, AccountStatus } from '@/api'
import { BasePopover, BaseScrollbar } from '@codex-proxy/ui'
import { ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { computed } from 'vue'
import { resolveAccountStatusPresentation } from './presenter'
@@ -47,8 +47,8 @@ const presentation = computed(() =>
</script>
<template>
<BasePopover :disabled="!presentation.hasDetail" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<ZPopover :disabled="!presentation.hasDetail" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<component
:is="presentation.hasDetail ? 'button' : 'span'"
:type="presentation.hasDetail ? 'button' : undefined"
@@ -140,15 +140,15 @@ const presentation = computed(() =>
<span class="text-cp-xs leading-none font-heavy text-cp-text-tertiary">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-text-quaternary">仅供排查</span>
</div>
<BaseScrollbar class="bg-cp-fill-quaternary" max-height="124px">
<ZScrollbar class="bg-cp-fill-quaternary" max-height="124px">
<div class="px-3 py-2">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-xs leading-[1.55] font-emphasis text-cp-text-secondary"
>{{ presentation.errorText }}</pre>
</div>
</BaseScrollbar>
</ZScrollbar>
</div>
</div>
</section>
</BasePopover>
</ZPopover>
</template>
@@ -1,7 +1,7 @@
import type { Ref } from 'vue'
import type { Account, AccountModelAccess } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { batchUpdateAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -64,20 +64,20 @@ export function useAccountBatchEditor(options: {
return
const modelError = accountModelAccessError(modelAccess.value)
if (modelError) {
toast.warning(modelError)
ZNotification.warning({ message: modelError })
return
}
if (!hasChanges.value) {
toast.warning('请选择需要更新的设置')
ZNotification.warning({ message: '请选择需要更新的设置' })
return
}
const scheduling = parseAccountSchedulingForm(editedFields.value.has('concurrencyLimit') ? concurrencyLimit.value : '', editedFields.value.has('weight') ? weight.value : '1')
if (proxyMode.value === 'proxy' && !proxyId.value.trim()) {
toast.warning('请选择已通过测试的代理')
ZNotification.warning({ message: '请选择已通过测试的代理' })
return
}
if (!scheduling.valid) {
toast.warning(scheduling.message)
ZNotification.warning({ message: scheduling.message })
return
}
@@ -94,7 +94,7 @@ export function useAccountBatchEditor(options: {
})
showBatchEditModal.value = false
options.selectedIds.value = new Set()
toast.success(`已更新 ${accountIds.length} 个账号`)
ZNotification.success({ message: `已更新 ${accountIds.length} 个账号` })
void Promise.allSettled([options.reloadAccounts(), options.reloadGroups()])
}, { onError: () => void options.reloadAccounts() })
}
@@ -1,5 +1,5 @@
import type { Account, AccountModelsResponse } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { CheckCircle2, Clock3, Wifi, XCircle } from '@lucide/vue'
import { useEventSource } from '@vueuse/core'
@@ -424,7 +424,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
return
applyConnectionTestModels(result, refresh)
if (refresh) {
toast.success(`已刷新 ${connectionTestModelOptions.value.length} 个上游模型`)
ZNotification.success({ message: `已刷新 ${connectionTestModelOptions.value.length} 个上游模型` })
}
else if (!connectionTestSelectedModel.value) {
connectionTestError.value = '没有可测试模型'
@@ -1,6 +1,6 @@
import type { Account, AccountModelAccess, ApiKeyConfiguration } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { ref, shallowRef, watch } from 'vue'
import { getAccountDetail, updateAccount } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -96,22 +96,22 @@ export function useAccountEditor(options: {
if (isApiKey) {
const error = apiKeyAccountError(apiKey.value, true)
if (error) {
toast.warning(error)
ZNotification.warning({ message: error })
return
}
}
const modelError = accountModelAccessError(modelAccess.value)
if (modelError) {
toast.warning(modelError)
ZNotification.warning({ message: modelError })
return
}
const scheduling = parseAccountSchedulingForm(concurrencyLimit.value, weight.value)
if (proxyMode.value === 'proxy' && !proxyId.value.trim()) {
toast.warning('请选择已通过测试的代理')
ZNotification.warning({ message: '请选择已通过测试的代理' })
return
}
if (!scheduling.valid) {
toast.warning(scheduling.message)
ZNotification.warning({ message: scheduling.message })
return
}
@@ -140,7 +140,7 @@ export function useAccountEditor(options: {
: undefined,
})
showEditModal.value = false
toast.success('账号已更新')
ZNotification.success({ message: '账号已更新' })
void Promise.allSettled([options.reloadAccounts(), options.reloadGroups()])
})
}
@@ -1,7 +1,7 @@
import type { Ref } from 'vue'
import type { Account, AccountImportTask } from '@/api'
import type { RequestOptions } from '@/api/request'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, ref, shallowReactive, watch } from 'vue'
import {
batchUpdateAccounts,
@@ -104,7 +104,7 @@ export function useAccountMutations(options: {
options.selectedIds.value = remaining
showSingleDeleteModal.value = false
await loadAccounts()
toast.success('账号已删除')
ZNotification.success({ message: '账号已删除' })
},
)
}
@@ -128,17 +128,15 @@ export function useAccountMutations(options: {
}
showDeleteModal.value = false
await loadAccounts()
toast.success(`已删除 ${deletedCount} 个账号`)
ZNotification.success({ message: `已删除 ${deletedCount} 个账号` })
},
{
errorText: false,
onError: (error) => {
void loadAccounts().catch(() => undefined)
toast.error(
deletedCount > 0
? `已删除 ${deletedCount} 个账号,其余未删除:${errorMessage(error, '操作失败')}`
: errorMessage(error, '批量删除失败'),
)
ZNotification.error({ message: deletedCount > 0
? `已删除 ${deletedCount} 个账号,其余未删除:${errorMessage(error, '操作失败')}`
: errorMessage(error, '批量删除失败') })
},
},
)
@@ -149,11 +147,11 @@ export function useAccountMutations(options: {
return
const selected = [...options.selectedIds.value]
if (selected.length === 0) {
toast.warning('请选择要导出的账号')
ZNotification.warning({ message: '请选择要导出的账号' })
return
}
if (exportDisabledReason.value) {
toast.warning(exportDisabledReason.value)
ZNotification.warning({ message: exportDisabledReason.value })
return
}
@@ -165,7 +163,7 @@ export function useAccountMutations(options: {
})
const fileName = payload.fileName
await downloadJson(payload, fileName)
toast.success(`已导出 ${selected.length} 个账号`)
ZNotification.success({ message: `已导出 ${selected.length} 个账号` })
},
{ errorText: '导出失败' },
)
@@ -178,7 +176,7 @@ export function useAccountMutations(options: {
const plan = account.planTypeDisplay.trim().replace(/[^\p{L}\p{N}_-]/gu, '_') || 'unknown-plan'
const name = account.name.trim().replace(/[^\p{L}\p{N}_-]/gu, '_') || 'account'
await downloadJson(result.catalog, `cpr-model-catalog-${plan}-${name}.json`)
toast.success(`已下载模型目录,共 ${result.modelCount} 个模型`)
ZNotification.success({ message: `已下载模型目录,共 ${result.modelCount} 个模型` })
}
catch {}
})
@@ -194,14 +192,14 @@ export function useAccountMutations(options: {
)
await loadAccounts()
if (result.result === 'skipped') {
toast.warning(result.error || 'Token 正在刷新中')
ZNotification.warning({ message: result.error || 'Token 正在刷新中' })
return
}
if (result.result === 'failed') {
toast.error(result.error || '刷新失败')
ZNotification.error({ message: result.error || '刷新失败' })
return
}
toast.success('Token 已刷新')
ZNotification.success({ message: 'Token 已刷新' })
}
catch {}
})
@@ -212,7 +210,7 @@ export function useAccountMutations(options: {
try {
await batchUpdateAccounts({ accountIds: [account.id], enabled: !account.enabled })
await loadAccounts()
toast.success(account.enabled ? '调度已停用' : '调度已启用')
ZNotification.success({ message: account.enabled ? '调度已停用' : '调度已启用' })
}
catch {}
})
@@ -228,7 +226,7 @@ export function useAccountMutations(options: {
selectedIds.delete(accountId)
options.selectedIds.value = selectedIds
}
toast.success('额度已刷新')
ZNotification.success({ message: '额度已刷新' })
}
catch {}
})
@@ -242,10 +240,7 @@ export function useAccountMutations(options: {
await options.replaceAccount(result.account)
}
catch (error: unknown) {
toast.warning(
`额度已重置,但最新额度加载失败:${errorMessage(error, '请手动刷新额度')}`,
{ duration: 5000 },
)
ZNotification.warning({ message: `额度已重置,但最新额度加载失败:${errorMessage(error, '请手动刷新额度')}`, ...({ duration: 5000 }) })
}
}
@@ -259,7 +254,7 @@ export function useAccountMutations(options: {
selectedIds.delete(accountId)
options.selectedIds.value = selectedIds
}
toast.success('账号状态已恢复')
ZNotification.success({ message: '账号状态已恢复' })
}
catch {}
})
@@ -1,5 +1,5 @@
import type { Account, AccountImportTask } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { createAccountImportTask, importAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
@@ -92,7 +92,7 @@ export function useAccountOnboarding(options: {
})
showCreateModal.value = false
options.onImportTaskCreated(task)
toast.success('导入任务已创建')
ZNotification.success({ message: '导入任务已创建' })
})
}
@@ -119,7 +119,7 @@ export function useAccountOnboarding(options: {
})
}
catch (cause) {
toast.error(errorMessage(cause, '启动授权失败'))
ZNotification.error({ message: errorMessage(cause, '启动授权失败') })
}
}
@@ -142,7 +142,7 @@ export function useAccountOnboarding(options: {
async function finishCreate(message: string) {
showCreateModal.value = false
toast.success(message)
ZNotification.success({ message })
await options.reload()
}
@@ -1,6 +1,6 @@
import type { Ref } from 'vue'
import type { Account, AccountQuotaForecastResponse } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { onScopeDispose, shallowRef, watch } from 'vue'
import { getAccountQuotaForecast, refreshAccountQuota } from '@/api'
@@ -66,7 +66,7 @@ export function useAccountQuotaForecast(
return
onAccountUpdated(result.account)
if (accountId.value === targetAccountId && await load())
toast.success('额度已刷新')
ZNotification.success({ message: '额度已刷新' })
}
catch {
if (!disposed && accountId.value === targetAccountId) {
@@ -1,5 +1,5 @@
import type { AccountResetCredit } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ZNotification } from '@codex-proxy/ui'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import {
@@ -189,7 +189,7 @@ export function useAccountResetCredits(options: {
|| (result.code === 'already_redeemed' && operation.hasTransportFailure)
target.pendingOperation = null
if (!confirmed) {
toast.error(resetResultMessage(result.code))
ZNotification.error({ message: resetResultMessage(result.code) })
await loadSessionCredits(target, true)
return false
}
@@ -201,7 +201,7 @@ export function useAccountResetCredits(options: {
if (session.value === target)
showConfirm.value = false
options.onConsumed(operation.accountId)
toast.success(successMessage)
ZNotification.success({ message: successMessage })
applyConfirmedConsumption(target, operation)
await loadSessionCredits(target, true)
return true
@@ -212,11 +212,11 @@ export function useAccountResetCredits(options: {
...operation,
hasTransportFailure: true,
}
toast.warning('消费结果暂不确定,重试会复用同一个请求标识', { duration: 5000 })
ZNotification.warning({ message: '消费结果暂不确定,重试会复用同一个请求标识', ...({ duration: 5000 }) })
}
else {
target.pendingOperation = null
toast.error(errorMessage(error, '额度重置失败'))
ZNotification.error({ message: errorMessage(error, '额度重置失败') })
await loadSessionCredits(target, true)
}
return false
@@ -1,4 +1,4 @@
import type { BaseTableSort } from '@codex-proxy/ui'
import type { TableSort } from '@codex-proxy/ui'
import type { Account } from '@/api'
import { computed, onMounted, shallowRef, watch } from 'vue'
@@ -10,7 +10,7 @@ export function useAccountsQuery() {
const providerQuery = shallowRef('')
const statusQuery = shallowRef('')
const groupQuery = shallowRef('')
const sort = shallowRef<BaseTableSort>()
const sort = shallowRef<TableSort>()
const accountSummary = shallowRef({
total: 0,
normal: 0,
@@ -55,7 +55,7 @@ export function useAccountsQuery() {
void query.execute()
}
function handleSortChange(nextSort: BaseTableSort | undefined) {
function handleSortChange(nextSort: TableSort | undefined) {
sort.value = nextSort
query.page.value = 1
void query.execute()
+2 -2
View File
@@ -31,7 +31,7 @@ export const accountColumns = defineTableColumns<Account>([
kind: 'meta',
size: 'md',
align: 'center',
format: value => formatProviderLabel(typeof value === 'string' ? value : null),
formatter: value => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
@@ -49,7 +49,7 @@ export const accountColumns = defineTableColumns<Account>([
label: '令牌过期',
kind: 'datetime',
sortable: 'expiresAt',
format: value => optionalAccountCell(value),
formatter: value => optionalAccountCell(value),
emptyText: '',
},
{ key: 'actions', label: '操作', kind: 'actions', size: 'lg', hideable: false },
+21 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseCheckbox, BaseConfirmModal, BasePageHeader, BaseTable, BaseTableColumnSettings, BaseTablePagination, useTableColumns } from '@codex-proxy/ui'
import { useTableColumns, ZCard, ZCheckbox, ZConfirmDialog, ZPageHeader, ZPagination, ZTable, ZTableColumnSettings } from '@codex-proxy/ui'
import { ChevronDown } from '@lucide/vue'
import { ref } from 'vue'
@@ -195,7 +195,7 @@ const {
<template>
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
<BasePageHeader
<ZPageHeader
class="h-17"
title="账号管理"
description="维护账号池,查看可用性、配额与使用状态"
@@ -203,7 +203,7 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<BaseCard
<ZCard
class="mt-4 flex flex-col xl:h-[calc(100dvh-250px)] xl:min-h-125"
>
<template #header>
@@ -227,7 +227,7 @@ const {
@edit-selected="openBatchEdit"
>
<template #actions>
<BaseTableColumnSettings
<ZTableColumnSettings
:options="columnOptions"
@change="setColumnVisible"
@reorder="setColumnOrder"
@@ -239,10 +239,10 @@ const {
<template #body>
<div class="flex min-h-0 flex-col xl:h-full">
<BaseTable
<ZTable
class="h-100! min-h-100 flex-none [--cp-table-row-height:72px] xl:h-auto! xl:min-h-0 xl:flex-1"
:columns="visibleColumns"
:rows="accounts"
:data="accounts"
:loading="loading"
:selected-row-keys="selectedRowKeys"
:expanded-row-keys="expandedRowKeys"
@@ -265,18 +265,18 @@ const {
</template>
<template #header-selection>
<BaseCheckbox
<ZCheckbox
:model-value="allSelected"
:indeterminate="indeterminate"
label="选择当前页账号"
aria-label="选择当前页账号"
@update:model-value="toggleAll"
/>
</template>
<template #selection="{ row }">
<BaseCheckbox
<ZCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
aria-label="选择账号"
@update:model-value="toggleSelection(row.id)"
/>
</template>
@@ -357,16 +357,16 @@ const {
<AccountUsagePanel :account="row" />
</div>
</template>
</BaseTable>
<BaseTablePagination
:pagination="accountPagination"
:loading="loading"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
</ZTable>
<ZPagination
v-bind="accountPagination"
:disabled="loading"
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
</template>
</BaseCard>
</ZCard>
<AccountConnectionTestModal
v-model="showConnectionTestModal"
@@ -458,7 +458,7 @@ const {
@save="saveBatchEdit"
/>
<BaseConfirmModal
<ZConfirmDialog
v-model="showDeleteModal"
title="确认删除"
description="删除后该账号将不再参与调度,此操作不可撤销"
@@ -470,9 +470,9 @@ const {
<p class="m-0">
确定要删除选中的 {{ deleteCount }} 个账号吗?此操作不可撤销
</p>
</BaseConfirmModal>
</ZConfirmDialog>
<BaseConfirmModal
<ZConfirmDialog
v-model="showSingleDeleteModal"
title="删除账号"
description="删除后该账号将不再参与调度,此操作不可撤销"
@@ -491,6 +491,6 @@ const {
}}
吗?
</p>
</BaseConfirmModal>
</ZConfirmDialog>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { dashboardSnapshotView, MetricTone } from '../presenter'
import { BaseCard, BaseEmpty } from '@codex-proxy/ui'
import { ZCard, ZEmpty } from '@codex-proxy/ui'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
@@ -160,7 +160,7 @@ const statusBars = computed(() => {
</script>
<template>
<BaseCard as="article" class="w-full xl:h-112.5">
<ZCard as="article" class="w-full xl:h-112.5">
<div
class="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7"
>
@@ -225,7 +225,7 @@ const statusBars = computed(() => {
<div
class="mt-5 flex w-full flex-col gap-2 overflow-hidden xl:mt-6.75 xl:h-82.5 xl:gap-2.5"
>
<BaseEmpty
<ZEmpty
v-if="accounts.length === 0"
title="暂无账号请求记录"
surface="inset"
@@ -340,5 +340,5 @@ const statusBars = computed(() => {
</div>
</section>
</div>
</BaseCard>
</ZCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { dashboardSnapshotView, dashboardTrendView } from '../presenter'
import type { DashboardTrendKind } from '@/api/modules/dashboard'
import { BaseIconButton, BasePageHeader } from '@codex-proxy/ui'
import { ZIconButton, ZPageHeader } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
@@ -53,16 +53,16 @@ const trendKind = defineModel<DashboardTrendKind>('trendKind', { required: true
<template>
<div class="w-full">
<BasePageHeader title="系统概览">
<ZPageHeader title="系统概览">
<template #description>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
</template>
<template #actions>
<BaseIconButton
<ZIconButton
class="text-cp-primary-text"
size="md"
label="刷新概览"
size="default"
aria-label="刷新概览"
:loading="loading || refreshing"
:disabled="loading || refreshing"
@click="emit('refresh')"
@@ -71,9 +71,9 @@ const trendKind = defineModel<DashboardTrendKind>('trendKind', { required: true
<RefreshCw class="animate-spin motion-reduce:animate-none" :size="19" />
</template>
<RefreshCw :size="19" />
</BaseIconButton>
</ZIconButton>
</template>
</BasePageHeader>
</ZPageHeader>
<section
class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-4 2xl:gap-6"
@@ -2,7 +2,7 @@
import type { EChartsOption } from 'echarts'
import type { MetricCardView, MetricTone } from '../presenter'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { ZCard, ZMotionIcon } from '@codex-proxy/ui'
import { computed } from 'vue'
import AnimatedMetricValue from '@/components/AnimatedMetricValue.vue'
@@ -71,14 +71,14 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</script>
<template>
<BaseCard as="article" padding="compact" class="relative h-38.5 w-full">
<ZCard as="article" padding="compact" class="relative h-38.5 w-full">
<div class="flex items-start gap-3">
<BaseMotionIcon
<ZMotionIcon
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg"
:class="metricToneIconClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</BaseMotionIcon>
</ZMotionIcon>
<span class="mt-1 text-cp leading-[1.15] font-emphasis text-cp-text-secondary">{{ metric.title }}</span>
</div>
@@ -128,5 +128,5 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</b>
</span>
</div>
</BaseCard>
</ZCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../presenter'
import { BaseCard, BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { ZCard, ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { toRef } from 'vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useRequestTrendChart } from '../composables/useRequestTrendChart'
@@ -42,11 +42,11 @@ const {
</script>
<template>
<BaseCard as="article" title="使用趋势" class="min-h-95 w-full">
<ZCard as="article" title="使用趋势" class="min-h-95 w-full">
<template #actions>
<BaseSegmented
<ZSegmented
v-model="activeKind"
label="趋势指标"
aria-label="趋势指标"
:options="tabs"
class="w-full max-w-61.5 sm:w-61.5"
@update:model-value="handleTrendChange"
@@ -95,7 +95,7 @@ const {
<div class="relative h-55 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
<BaseEmpty
<ZEmpty
v-else-if="!loading"
surface="none"
:title="error || '暂无趋势数据'"
@@ -104,5 +104,5 @@ const {
</div>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../presenter'
import { BaseCard } from '@codex-proxy/ui'
import { ZCard } from '@codex-proxy/ui'
import { usageRecordColumns } from '@/components/usage/shared/columns'
import UsageRecordsTable from '@/components/usage/UsageRecordsTable.vue'
@@ -17,7 +17,7 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(
</script>
<template>
<BaseCard
<ZCard
as="article"
title="使用记录"
description="最近 10 条成功请求"
@@ -33,5 +33,5 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(
/>
</div>
</template>
</BaseCard>
</ZCard>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { ZCard, ZEmpty, ZSegmented } from '@codex-proxy/ui'
import { Box, CheckCircle2, Monitor, RefreshCw, ShieldCheck, Terminal, TriangleAlert } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
@@ -178,16 +178,16 @@ watch(
</script>
<template>
<BaseCard as="article" class="flex min-h-95 w-full flex-col">
<ZCard as="article" class="flex min-h-95 w-full flex-col">
<template #header>
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
<h2 class="m-0 pt-0.5 text-xl leading-[1.15] font-heavy text-cp-text text-balance">
通用上游身份
</h2>
<div v-if="providerOptions.length > 1" class="min-w-0 max-w-full overflow-x-auto">
<BaseSegmented
<ZSegmented
v-model="activeProvider"
label="上游平台"
aria-label="上游平台"
:options="providerOptions"
:style="{ width: `${providerOptions.length * 40 + 4}px` }"
display="icon"
@@ -197,7 +197,7 @@ watch(
</template>
<template #body>
<BaseEmpty v-if="!profile" title="暂无请求身份" surface="inset" class="min-h-71.75 flex-1 place-content-center" />
<ZEmpty v-if="!profile" title="暂无请求身份" surface="inset" class="min-h-71.75 flex-1 place-content-center" />
<div v-else class="flex flex-1">
<section
@@ -303,5 +303,5 @@ watch(
</section>
</div>
</template>
</BaseCard>
</ZCard>
</template>

Some files were not shown because too many files have changed in this diff Show More