mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 00:07:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c56a23508 | ||
|
|
b5f3cb402c | ||
|
|
57e284d7d1 | ||
|
|
21597f9e98 | ||
|
|
fa0b87dd0a |
@@ -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,
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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
@@ -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` 不代表没有发生错误。
|
||||
|
||||
@@ -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 隔离账号绑定,
|
||||
不将缓存路由键或请求内容推断为账号身份。账号亲和模式随请求策略冻结:
|
||||
|
||||
@@ -63,6 +63,8 @@ pnpm --dir frontend dev
|
||||
后端职责和依赖方向见 [Workspace 边界](architecture.md#3-workspace-边界),页面与共享逻辑见 [前端模块职责](architecture.md#34-前端模块职责)。
|
||||
先按所属模块定位,再跟踪请求、状态与展示的调用关系
|
||||
|
||||
共享 UI 的命名与目录见[组件架构](../modules/ui/playground/guide/architecture.md)
|
||||
|
||||
## 源码联调
|
||||
|
||||
宿主、组件库和官方插件保留独立仓库,通过 Git 子模块在同一目录开发:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+24
-15
@@ -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: {}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { BaseToast } from '@codex-proxy/ui'
|
||||
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<BaseToast />
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '复制失败') : '复制失败' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
+19
-20
@@ -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>
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user