mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
941c4d57fa | ||
|
|
cf046ad9a4 | ||
|
|
7699a954ac | ||
|
|
2c31708d1b | ||
|
|
80f338ce01 | ||
|
|
299088a6e7 | ||
|
|
a23bfca848 | ||
|
|
8951a64a05 | ||
|
|
eb20561893 | ||
|
|
a33637a99e |
@@ -729,21 +729,19 @@ pub(crate) fn dashboard_view(
|
||||
last_used: relative_time(credential.last_used_at, range.end),
|
||||
});
|
||||
}
|
||||
let unavailable_accounts = provider_accounts
|
||||
.total
|
||||
.saturating_sub(provider_accounts.normal);
|
||||
|
||||
DashboardDataView {
|
||||
cards: DashboardCardsView {
|
||||
credentials: DashboardCredentialsCardView {
|
||||
total: format_compact_number(provider_accounts.total),
|
||||
total: provider_accounts.total.to_string(),
|
||||
total_value: provider_accounts.total,
|
||||
available: format_compact_number(provider_accounts.normal),
|
||||
available: provider_accounts.normal.to_string(),
|
||||
available_value: provider_accounts.normal,
|
||||
unavailable: format_compact_number(
|
||||
provider_accounts
|
||||
.total
|
||||
.saturating_sub(provider_accounts.normal),
|
||||
),
|
||||
unavailable_value: provider_accounts
|
||||
.total
|
||||
.saturating_sub(provider_accounts.normal),
|
||||
unavailable: unavailable_accounts.to_string(),
|
||||
unavailable_value: unavailable_accounts,
|
||||
},
|
||||
traffic: DashboardTrafficCardView {
|
||||
today_requests: format_compact_number(today.request_count),
|
||||
|
||||
@@ -221,9 +221,9 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
attempts: AttemptMetrics::default(),
|
||||
totals: Default::default(),
|
||||
provider_accounts: AccountPoolMetrics {
|
||||
total: 807,
|
||||
normal: 15,
|
||||
quota_exhausted: 785,
|
||||
total: 1002,
|
||||
normal: 943,
|
||||
quota_exhausted: 52,
|
||||
rate_limited: 0,
|
||||
disabled: 3,
|
||||
error: 4,
|
||||
@@ -259,9 +259,21 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
&value["data"]["cards"]["credentials"]["unavailableValue"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!(807),
|
||||
&serde_json::json!(15),
|
||||
&serde_json::json!(792)
|
||||
&serde_json::json!(1002),
|
||||
&serde_json::json!(943),
|
||||
&serde_json::json!(59)
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["cards"]["credentials"]["total"],
|
||||
&value["data"]["cards"]["credentials"]["available"],
|
||||
&value["data"]["cards"]["credentials"]["unavailable"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!("1002"),
|
||||
&serde_json::json!("943"),
|
||||
&serde_json::json!("59")
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1288,9 +1288,9 @@ fn parse_oauth_import_tokens(
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
let token = match key.as_str() {
|
||||
"accessToken" => &mut access_token,
|
||||
"refreshToken" => &mut refresh_token,
|
||||
"idToken" => &mut id_token,
|
||||
"accessToken" | "access_token" => &mut access_token,
|
||||
"refreshToken" | "refresh_token" => &mut refresh_token,
|
||||
"idToken" | "id_token" => &mut id_token,
|
||||
_ => {
|
||||
pending.push(value);
|
||||
continue;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// `/codex/responses`
|
||||
pub const CODEX_RESPONSES_PATH: &str = "/codex/responses";
|
||||
/// `/images/generations`
|
||||
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/images/generations";
|
||||
/// `/images/edits`
|
||||
pub const CODEX_IMAGE_EDITS_PATH: &str = "/images/edits";
|
||||
/// `/codex/images/generations`
|
||||
pub const CODEX_IMAGE_GENERATIONS_PATH: &str = "/codex/images/generations";
|
||||
/// `/codex/images/edits`
|
||||
pub const CODEX_IMAGE_EDITS_PATH: &str = "/codex/images/edits";
|
||||
/// `/api/codex/usage`
|
||||
pub const CODEX_USAGE_API_PATH: &str = "/api/codex/usage";
|
||||
/// `/wham/usage`
|
||||
|
||||
@@ -59,6 +59,48 @@ async fn direct_import_persists_opaque_tokens_without_profile_or_token_validatio
|
||||
assert!(account.account.next_refresh_at().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_import_accepts_snake_case_oauth_token_aliases() {
|
||||
let service = CodexCredentialAdminService::new(
|
||||
Arc::new(UnusedRefresher),
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
runtime_policy(),
|
||||
);
|
||||
let prepared = service
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"accounts": [{
|
||||
"platform": "openai",
|
||||
"type": "oauth",
|
||||
"credentials": {
|
||||
"access_token": "snake-access-token",
|
||||
"refresh_token": "snake-refresh-token",
|
||||
"id_token": "snake-id-token"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
.expect("snake_case OAuth token aliases must be accepted");
|
||||
|
||||
let account = prepared.accounts().first().expect("one prepared account");
|
||||
let runtime = CodexCredentialCodec::decode(&account.credential).expect("stored credential");
|
||||
let oauth = runtime.authentication.oauth().expect("OAuth credential");
|
||||
assert_eq!(
|
||||
(
|
||||
oauth.access_token.expose_secret(),
|
||||
oauth
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|token| token.expose_secret()),
|
||||
oauth.id_token.as_ref().map(|token| token.expose_secret()),
|
||||
),
|
||||
(
|
||||
"snake-access-token",
|
||||
Some("snake-refresh-token"),
|
||||
Some("snake-id-token"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_import_projects_access_token_jwt_expiry_without_persisting_refresh_margin() {
|
||||
let expires_at = Utc
|
||||
|
||||
@@ -820,13 +820,13 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
|
||||
let cases = [
|
||||
(
|
||||
ImageRequestKind::Generation,
|
||||
"/images/generations",
|
||||
"/codex/images/generations",
|
||||
br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "background":"transparent", "future_option":{"schema":2}, "future_integer":9007199254740993 }"#.as_slice(),
|
||||
br#"{ "created": 1787212800, "data": [{"b64_json":"AAEC"}], "future": 9007199254740993 }"#.as_slice(),
|
||||
),
|
||||
(
|
||||
ImageRequestKind::Edit,
|
||||
"/images/edits",
|
||||
"/codex/images/edits",
|
||||
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate remains opaque"}"#.as_slice(),
|
||||
br#"{"created":1787212801,"data":[{"b64_json":"AwQF"}],"quality":"high"}"#.as_slice(),
|
||||
),
|
||||
@@ -918,7 +918,7 @@ async fn image_endpoint_returns_the_exact_upstream_error_response() {
|
||||
br#"{ "model":"gpt-image-2", "images":[], "future_invalid":9007199254740993 }"#;
|
||||
let response_body = br#"{ "error":{"message":"future image validation","type":"image_error","code":"future_code"}, "future":9007199254740993 }"#;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/images/edits"))
|
||||
.and(path("/codex/images/edits"))
|
||||
.and(body_bytes(request_body.to_vec()))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(422)
|
||||
|
||||
@@ -195,7 +195,7 @@ fn bare_http_429_remains_a_temporary_rate_limit() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_openai_account_failure_matrix_is_preserved() {
|
||||
fn openai_account_failure_matrix_is_preserved() {
|
||||
let cases = [
|
||||
(
|
||||
400,
|
||||
|
||||
@@ -237,19 +237,19 @@ impl GrokCredentialAdmin {
|
||||
let mut secret = decode_secret(&loaded.credential)?;
|
||||
let mut credentials = serde_json::Map::new();
|
||||
credentials.insert(
|
||||
"access_token".to_owned(),
|
||||
"accessToken".to_owned(),
|
||||
Value::String(std::mem::take(&mut secret.access_token)),
|
||||
);
|
||||
credentials.insert(
|
||||
"refresh_token".to_owned(),
|
||||
"refreshToken".to_owned(),
|
||||
Value::String(std::mem::take(&mut secret.refresh_token)),
|
||||
);
|
||||
if let Some(id_token) = secret.id_token.take() {
|
||||
credentials.insert("id_token".to_owned(), Value::String(id_token));
|
||||
credentials.insert("idToken".to_owned(), Value::String(id_token));
|
||||
}
|
||||
credentials.insert("token_type".to_owned(), Value::String("Bearer".to_owned()));
|
||||
credentials.insert("tokenType".to_owned(), Value::String("Bearer".to_owned()));
|
||||
credentials.insert(
|
||||
"expires_at".to_owned(),
|
||||
"expiresAt".to_owned(),
|
||||
Value::String(
|
||||
loaded
|
||||
.account
|
||||
@@ -260,11 +260,11 @@ impl GrokCredentialAdmin {
|
||||
),
|
||||
);
|
||||
credentials.insert(
|
||||
"base_url".to_owned(),
|
||||
"baseUrl".to_owned(),
|
||||
Value::String(GROK_CLI_BASE_URL.to_owned()),
|
||||
);
|
||||
credentials.insert(
|
||||
"client_id".to_owned(),
|
||||
"clientId".to_owned(),
|
||||
Value::String(OFFICIAL_CLIENT_ID.to_owned()),
|
||||
);
|
||||
credentials.insert(
|
||||
@@ -290,7 +290,7 @@ impl GrokCredentialAdmin {
|
||||
Ok(GrokAccountExport(serde_json::json!({
|
||||
"version": 1,
|
||||
"type": "oauth-account-bundle",
|
||||
"exported_at": exported_at.to_rfc3339(),
|
||||
"exportedAt": exported_at.to_rfc3339(),
|
||||
"accounts": exported_accounts,
|
||||
"proxies": [],
|
||||
})))
|
||||
|
||||
@@ -2076,7 +2076,7 @@ const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
const OAUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const QUOTA_CATALOG_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
// rolling 24h 描述的是上游用量窗口,不代表从本次观测起封禁 24 小时;
|
||||
// 缺少可信 reset 时间时按 sub2api 的短周期探测策略恢复检查。
|
||||
// 缺少可信 reset 时间时按短周期探测策略恢复检查。
|
||||
const EXHAUSTED_QUOTA_FALLBACK_RECHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
|
||||
const EXHAUSTED_QUOTA_REFRESH_RETRY_INTERVAL: Duration = QUOTA_CATALOG_INTERVAL;
|
||||
const CLI_RELEASE_WORKER_OWNER: &str = "xai-cli-release";
|
||||
|
||||
@@ -108,7 +108,7 @@ impl GrokCompactionRequest {
|
||||
self.reasoning_replay_session_id.as_deref()
|
||||
}
|
||||
|
||||
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
|
||||
/// 返回归一化后的 xAI wire 模型。
|
||||
pub(crate) fn upstream_model(&self) -> Option<&str> {
|
||||
self.body.get("model").and_then(Value::as_str)
|
||||
}
|
||||
@@ -286,8 +286,7 @@ pub(crate) fn compaction_wire_events(
|
||||
Value::Null,
|
||||
);
|
||||
let item = compaction_item(summary, encrypted_content);
|
||||
// sub2api 的 compact contract 以真实密文为成功依据,不向客户端延续
|
||||
// 摘要生成阶段的 incomplete 状态。
|
||||
// compact 成功以真实密文为依据,不向客户端延续摘要生成阶段的 incomplete 状态。
|
||||
let terminal_response = compaction_response(
|
||||
completed,
|
||||
terminal_source.or(created_source),
|
||||
|
||||
@@ -91,7 +91,7 @@ impl GrokResponsesRequest {
|
||||
self.reasoning_replay_session_id.as_deref()
|
||||
}
|
||||
|
||||
/// 返回经 sub2api 别名表归一化后的 xAI wire 模型。
|
||||
/// 返回归一化后的 xAI wire 模型。
|
||||
pub(crate) fn upstream_model(&self) -> Option<&str> {
|
||||
self.body.get("model").and_then(Value::as_str)
|
||||
}
|
||||
@@ -1928,8 +1928,8 @@ impl ToolNormalizer {
|
||||
"apply_patch" => self.normalize_apply_patch_tool(tool),
|
||||
"x_search" | "collections_search" | "file_search" | "code_execution"
|
||||
| "code_interpreter" => Ok(vec![Value::Object(without_defer_loading(tool))]),
|
||||
// sub2api 的 xAI Responses 适配层对未获 xAI 接受的工具做静默过滤,
|
||||
// 并在过滤后同步收敛 tool_choice;不要把客户端可选扩展升级成整单 400。
|
||||
// 对未获 xAI 接受的工具做静默过滤,并在过滤后同步收敛 tool_choice;
|
||||
// 不要把客户端可选扩展升级成整单 400。
|
||||
"" | "computer_use_preview" | "image_generation" => Ok(Vec::new()),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
@@ -3517,15 +3517,15 @@ fn normalize_compaction_input(
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let has_sub2api_shape = item.contains_key("summary")
|
||||
let has_structured_compaction_shape = item.contains_key("summary")
|
||||
|| item.contains_key("id")
|
||||
|| item.contains_key("status")
|
||||
|| string_field(item, "type").trim() == "compaction_summary";
|
||||
|
||||
// 旧版 adapter 曾将明文摘要直接写入 encrypted_content,且不带
|
||||
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格按
|
||||
// sub2api 回放为 xAI reasoning 密文 + 可见 conversation summary。
|
||||
if !has_sub2api_shape {
|
||||
// id/status/summary。仅对这一可识别的历史形态保留明文恢复;新形态严格回放为
|
||||
// xAI reasoning 密文 + 可见 conversation summary。
|
||||
if !has_structured_compaction_shape {
|
||||
let Some(summary) = encrypted else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
@@ -363,20 +363,48 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
|
||||
let value = export.into_value();
|
||||
assert_eq!(value["version"], 1);
|
||||
assert_eq!(value["type"], "oauth-account-bundle");
|
||||
assert!(value.get("exportedAt").is_some());
|
||||
assert!(value.get("exported_at").is_none());
|
||||
assert_eq!(value["accounts"][0]["platform"], "grok");
|
||||
assert_eq!(value["accounts"][0]["type"], "oauth");
|
||||
assert_eq!(
|
||||
value["accounts"][0]["credentials"]["base_url"],
|
||||
value["accounts"][0]["credentials"]["baseUrl"],
|
||||
provider_xai::GROK_CLI_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
value["accounts"][0]["credentials"]["access_token"],
|
||||
value["accounts"][0]["credentials"]["accessToken"],
|
||||
"access-export"
|
||||
);
|
||||
assert_eq!(
|
||||
value["accounts"][0]["credentials"]["refreshToken"],
|
||||
"refresh-export"
|
||||
);
|
||||
assert_eq!(value["accounts"][0]["credentials"]["idToken"], "id-export");
|
||||
assert_eq!(value["accounts"][0]["credentials"]["tokenType"], "Bearer");
|
||||
assert!(
|
||||
value["accounts"][0]["credentials"]["expiresAt"]
|
||||
.as_str()
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
value["accounts"][0]["credentials"]["clientId"],
|
||||
provider_xai::OFFICIAL_CLIENT_ID
|
||||
);
|
||||
assert_eq!(
|
||||
value["accounts"][0]["credentials"]["scope"],
|
||||
"openid profile email offline_access grok-cli:access api:access"
|
||||
);
|
||||
for field in [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"token_type",
|
||||
"expires_at",
|
||||
"base_url",
|
||||
"client_id",
|
||||
] {
|
||||
assert!(value["accounts"][0]["credentials"].get(field).is_none());
|
||||
}
|
||||
assert_eq!(value["proxies"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ fn oauth_account_document_should_reject_api_key_credential_field() {
|
||||
fn oauth_account_document_should_ignore_source_wrapper_and_non_auth_metadata() {
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
|
||||
document["type"] = serde_json::Value::String("sub2api-data".to_owned());
|
||||
document["type"] = serde_json::Value::String("external-oauth-data".to_owned());
|
||||
document["version"] = serde_json::Value::from(99);
|
||||
document["proxies"] = serde_json::json!([{"url": "http://127.0.0.1:8080"}]);
|
||||
document["source_extension"] = serde_json::json!({"opaque": true});
|
||||
|
||||
@@ -1534,7 +1534,7 @@ async fn compaction_wire_should_exclude_reasoning_from_summary_content() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_stream_should_accept_short_summary_like_sub2api() {
|
||||
async fn compaction_stream_should_accept_short_summary() {
|
||||
let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse(
|
||||
"<summary>too short</summary>",
|
||||
None,
|
||||
|
||||
@@ -76,7 +76,7 @@ fn encoder_should_preserve_history_order_and_append_summary_prompt() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_preserve_sub2api_compaction_constraints() {
|
||||
fn encoder_should_preserve_compaction_constraints() {
|
||||
let request = encode(json!({
|
||||
"model": "client-model",
|
||||
"input": [
|
||||
@@ -190,7 +190,7 @@ fn encoder_should_consume_only_the_terminal_compaction_trigger() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_sub2api_summary_text_after_trimming_edges() {
|
||||
fn decoder_should_preserve_summary_text_after_trimming_edges() {
|
||||
let raw = valid_summary("checkpoint-only-secret");
|
||||
let summary = decode_summary(&format!(" {raw} \n")).expect("summary");
|
||||
|
||||
@@ -234,7 +234,7 @@ fn decoder_should_preserve_unclosed_leading_analysis() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_compaction_control_tokens_like_sub2api() {
|
||||
fn decoder_should_preserve_compaction_control_tokens() {
|
||||
let raw = valid_summary(
|
||||
"Quoted <summary> and </summary>, <analysis> and </analysis>, \
|
||||
plus <summary_request> and </summary_request>.",
|
||||
@@ -278,7 +278,7 @@ fn decoder_should_preserve_numbered_sections_that_quote_analysis_close() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_accept_short_summary_like_sub2api() {
|
||||
fn decoder_should_accept_short_summary() {
|
||||
let summary = decode_summary("<summary>too short</summary>").expect("short summary");
|
||||
|
||||
assert_eq!(summary, "<summary>too short</summary>");
|
||||
|
||||
@@ -567,7 +567,7 @@ async fn inference_transport_should_classify_free_usage_429_as_account_free_quot
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_transport_should_apply_sub2api_body_aware_400_failures() {
|
||||
async fn inference_transport_should_apply_body_aware_400_failures() {
|
||||
let cases = [
|
||||
(
|
||||
json!({"error": {"code": "subscription:free-usage-exhausted", "message": "free usage exhausted"}}),
|
||||
|
||||
@@ -198,7 +198,7 @@ fn encoder_should_apply_build_defaults_and_normalize_reasoning_effort() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_should_strip_sub2api_grok_unsupported_fields() {
|
||||
fn encoder_should_strip_grok_unsupported_fields() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client-model",
|
||||
"input": "hello",
|
||||
@@ -943,7 +943,7 @@ fn custom_apply_patch_declaration_should_use_patch_parameter() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosted_tool_choice_should_preserve_the_sub2api_specific_choice() {
|
||||
fn hosted_tool_choice_should_preserve_normalized_web_search_choice() {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
"input": "search",
|
||||
@@ -1244,7 +1244,7 @@ fn compaction_history_should_become_plaintext_user_continuation_in_place() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
|
||||
fn structured_compaction_history_should_restore_reasoning_ciphertext_and_visible_summary() {
|
||||
for item_type in ["compaction", "compaction_summary"] {
|
||||
let request = raw_request(json!({
|
||||
"model": "client",
|
||||
@@ -1264,7 +1264,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
|
||||
}));
|
||||
|
||||
let encoded = GrokResponsesRequest::encode(&request, "grok-4.5", &client_key())
|
||||
.expect("sub2api compaction continuation");
|
||||
.expect("structured compaction continuation");
|
||||
let body = Value::Object(encoded.body().clone());
|
||||
|
||||
assert_eq!(
|
||||
@@ -1290,7 +1290,7 @@ fn sub2api_compaction_history_should_restore_reasoning_ciphertext_and_visible_su
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_compaction_history_should_be_dropped_like_sub2api() {
|
||||
fn malformed_compaction_history_should_be_dropped() {
|
||||
for encrypted_content in [
|
||||
None,
|
||||
Some(json!(null)),
|
||||
|
||||
@@ -203,10 +203,13 @@ onBeforeUnmount(() => {
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
|
||||
:class="tone === 'neutral'
|
||||
? 'grid-cols-[minmax(0,1fr)_28px]'
|
||||
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
|
||||
class="grid shrink-0 gap-3 p-4 pb-0 sm:gap-4 sm:p-6 sm:pb-0"
|
||||
:class="[
|
||||
tone === 'neutral'
|
||||
? 'grid-cols-[minmax(0,1fr)_28px]'
|
||||
: 'grid-cols-[auto_minmax(0,1fr)_28px]',
|
||||
description ? 'items-start' : 'items-center',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="tone !== 'neutral'"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string
|
||||
name: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
showLabel: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineSlots<{
|
||||
default?: () => unknown
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const checked = computed(() => model.value === props.value)
|
||||
const indicatorClasses = computed(() => [
|
||||
'relative inline-grid size-4 shrink-0 place-items-center rounded-full transition-colors duration-150 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-cp-disabled text-cp-disabled-icon'
|
||||
: checked.value
|
||||
? 'bg-cp-accent text-cp-accent-on'
|
||||
: 'bg-cp-muted text-transparent group-hover:bg-cp-default-active',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The native radio is nested so the full label remains one accessible click target. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
|
||||
>
|
||||
<input
|
||||
v-model="model"
|
||||
type="radio"
|
||||
class="peer sr-only"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span
|
||||
:class="indicatorClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="size-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
<span v-if="$slots.default" class="min-w-0 flex-1">
|
||||
<slot />
|
||||
</span>
|
||||
<span v-else-if="showLabel" class="text-[13px] leading-none font-emphasis">
|
||||
{{ label }}
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
@@ -2,9 +2,12 @@
|
||||
import type { AccountQuotaWindow } from '../constants'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
|
||||
import {
|
||||
quotaWindowLocalUsageDisplay,
|
||||
quotaWindowPresentation,
|
||||
resolveAccountUsageWindowPresentation,
|
||||
} from './AccountUsageWindow/presenter'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -12,12 +15,25 @@ const props = defineProps<{
|
||||
windows: AccountQuotaWindow[]
|
||||
}>()
|
||||
|
||||
const now = useUiClock()
|
||||
const grouped = computed(() => props.windows.length > 1 && Boolean(props.label))
|
||||
const items = computed(() => props.windows.map((window) => {
|
||||
const presentation = quotaWindowPresentation(window, '4px')
|
||||
const view = resolveAccountUsageWindowPresentation({
|
||||
window,
|
||||
variant: 'detail',
|
||||
showLocalValue: true,
|
||||
now: now.value.getTime(),
|
||||
})
|
||||
|
||||
return {
|
||||
key: window.key,
|
||||
local: view.mode === 'local',
|
||||
localLabel: view.local.label,
|
||||
requestDisplay: view.local.requestDisplay,
|
||||
requestBars: view.local.requestBars,
|
||||
timelineTitle: view.local.timelineTitle,
|
||||
durationDisplay: view.local.durationDisplay,
|
||||
label: grouped.value
|
||||
? window.windowLabelDisplay
|
||||
: window.labelDisplay,
|
||||
@@ -48,49 +64,80 @@ const items = computed(() => props.windows.map((window) => {
|
||||
:class="grouped ? 'grid-cols-2' : 'grid-cols-1'"
|
||||
>
|
||||
<div v-for="item in items" :key="item.key" class="grid min-w-0 gap-1.5">
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
|
||||
<span class="min-w-0 truncate font-bold text-cp-muted-text">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
|
||||
<template v-if="item.local">
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
|
||||
<span class="min-w-0 truncate font-bold text-cp-muted-text">
|
||||
{{ item.localLabel }}
|
||||
</span>
|
||||
<strong
|
||||
v-if="item.localUsageDisplay"
|
||||
class="text-cp-muted-text"
|
||||
:title="`窗口消耗:${item.localUsageDisplay}`"
|
||||
class="shrink-0 font-mono font-heavy tabular-nums text-cp-primary"
|
||||
:title="`窗口请求:${item.requestDisplay} 次`"
|
||||
>
|
||||
{{ item.localUsageDisplay }}
|
||||
{{ item.requestDisplay }} 次
|
||||
</strong>
|
||||
<strong
|
||||
:class="item.percentTextClass"
|
||||
:title="`额度已使用:${item.usedPercentDisplay}`"
|
||||
>
|
||||
{{ item.usedPercentDisplay }}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
|
||||
role="progressbar"
|
||||
:aria-label="item.ariaLabel"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="item.usedPercent ?? undefined"
|
||||
:aria-valuetext="item.usedPercentDisplay"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
|
||||
:class="item.barClass"
|
||||
:style="item.barStyle"
|
||||
<AccountRequestTimeline
|
||||
class="h-1.5 w-full"
|
||||
:bars="item.requestBars"
|
||||
:label="item.timelineTitle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
|
||||
<span class="shrink-0 font-emphasis">重置</span>
|
||||
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
|
||||
{{ item.resetAtDisplay }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
|
||||
<span class="shrink-0 font-emphasis">统计范围</span>
|
||||
<span
|
||||
class="min-w-0 truncate font-mono font-emphasis tabular-nums"
|
||||
:title="`滚动 ${item.durationDisplay}`"
|
||||
>
|
||||
滚动 {{ item.durationDisplay }}
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
|
||||
<span class="min-w-0 truncate font-bold text-cp-muted-text">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
|
||||
<strong
|
||||
v-if="item.localUsageDisplay"
|
||||
class="text-cp-muted-text"
|
||||
:title="`窗口消耗:${item.localUsageDisplay}`"
|
||||
>
|
||||
{{ item.localUsageDisplay }}
|
||||
</strong>
|
||||
<strong
|
||||
:class="item.percentTextClass"
|
||||
:title="`额度已使用:${item.usedPercentDisplay}`"
|
||||
>
|
||||
{{ item.usedPercentDisplay }}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
|
||||
role="progressbar"
|
||||
:aria-label="item.ariaLabel"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="item.usedPercent ?? undefined"
|
||||
:aria-valuetext="item.usedPercentDisplay"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
|
||||
:class="item.barClass"
|
||||
:style="item.barStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
|
||||
<span class="shrink-0 font-emphasis">重置</span>
|
||||
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
|
||||
{{ item.resetAtDisplay }}
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { computed } from 'vue'
|
||||
import BasePopover from '@/components/base/BasePopover.vue'
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import AccountQuotaWindowGroup from './AccountQuotaWindowGroup.vue'
|
||||
import AccountRequestTimeline from './AccountUsageWindow/AccountRequestTimeline.vue'
|
||||
import AccountUsageWindow from './AccountUsageWindow/index.vue'
|
||||
import { resolveAccountUsageWindowPresentation } from './AccountUsageWindow/presenter'
|
||||
|
||||
@@ -37,6 +38,12 @@ const detailItems = computed(() => props.windows.map((window) => {
|
||||
|
||||
return {
|
||||
key: window.key,
|
||||
local: view.mode === 'local',
|
||||
localLabel: view.local.label,
|
||||
requestDisplay: view.local.requestDisplay,
|
||||
requestBars: view.local.requestBars,
|
||||
timelineTitle: view.local.timelineTitle,
|
||||
durationDisplay: view.local.durationDisplay,
|
||||
code: quotaWindowCode(window.windowSeconds, window.role),
|
||||
label: window.windowLabelDisplay,
|
||||
usedPercent: window.usedPercent,
|
||||
@@ -141,45 +148,69 @@ function quotaWindowCode(
|
||||
{{ item.code }}
|
||||
</span>
|
||||
<h4 class="m-0 min-w-0 truncate text-[12px] leading-4 font-heavy text-cp-secondary">
|
||||
{{ item.label }}
|
||||
{{ item.local ? item.localLabel : item.label }}
|
||||
</h4>
|
||||
<strong
|
||||
class="font-mono text-[11px] leading-none font-heavy tabular-nums"
|
||||
:class="item.percentTextClass"
|
||||
:class="item.local ? 'text-cp-primary' : item.percentTextClass"
|
||||
>
|
||||
{{ item.usedPercentDisplay }}
|
||||
{{ item.local ? `${item.requestDisplay} 次` : item.usedPercentDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
|
||||
role="progressbar"
|
||||
:aria-label="item.label"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="item.usedPercent ?? undefined"
|
||||
:aria-valuetext="item.usedPercentDisplay"
|
||||
>
|
||||
<AccountRequestTimeline
|
||||
v-if="item.local"
|
||||
class="mt-2 h-1 w-full"
|
||||
:bars="item.requestBars"
|
||||
:label="item.timelineTitle"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
|
||||
:class="item.barClass"
|
||||
:style="item.barStyle"
|
||||
/>
|
||||
</div>
|
||||
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
|
||||
role="progressbar"
|
||||
:aria-label="item.label"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="item.usedPercent ?? undefined"
|
||||
:aria-valuetext="item.usedPercentDisplay"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none"
|
||||
:class="item.barClass"
|
||||
:style="item.barStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<dl class="mt-2.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[10px] leading-4">
|
||||
<dt class="font-emphasis text-cp-tertiary">
|
||||
窗口消耗
|
||||
</dt>
|
||||
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
|
||||
{{ item.localUsageDisplay }}
|
||||
</dd>
|
||||
<dt class="font-emphasis text-cp-tertiary">
|
||||
重置时间
|
||||
</dt>
|
||||
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
|
||||
{{ item.resetAtDisplay }}
|
||||
</dd>
|
||||
<template v-if="item.local">
|
||||
<dt class="font-emphasis text-cp-tertiary">
|
||||
统计方式
|
||||
</dt>
|
||||
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
|
||||
滚动窗口
|
||||
</dd>
|
||||
<dt class="font-emphasis text-cp-tertiary">
|
||||
统计范围
|
||||
</dt>
|
||||
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
|
||||
{{ item.durationDisplay }}
|
||||
</dd>
|
||||
</template>
|
||||
<template v-else>
|
||||
<dt class="font-emphasis text-cp-tertiary">
|
||||
窗口消耗
|
||||
</dt>
|
||||
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
|
||||
{{ item.localUsageDisplay }}
|
||||
</dd>
|
||||
<dt class="font-emphasis text-cp-tertiary">
|
||||
重置时间
|
||||
</dt>
|
||||
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
|
||||
{{ item.resetAtDisplay }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Account } from '@/api'
|
||||
import { AlertTriangle, RefreshCw, RotateCcw, TicketCheck } from '@lucide/vue'
|
||||
import { AlertTriangle, RefreshCw, TicketCheck } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { computed, shallowRef, useId, watch } from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseRadio from '@/components/base/BaseRadio.vue'
|
||||
import { useAccountResetCredits } from '../composables/useAccountResetCredits'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -21,6 +22,9 @@ const emit = defineEmits<{
|
||||
const {
|
||||
availableCredits,
|
||||
availableCount,
|
||||
selectedCreditId,
|
||||
consumptionCredit,
|
||||
canRequestConsume,
|
||||
hasSnapshot,
|
||||
loading,
|
||||
consuming,
|
||||
@@ -28,6 +32,7 @@ const {
|
||||
ambiguous,
|
||||
showConfirm,
|
||||
loadCredits,
|
||||
selectCredit,
|
||||
requestConsume,
|
||||
cancelConsume,
|
||||
confirmConsume,
|
||||
@@ -37,14 +42,15 @@ const {
|
||||
})
|
||||
|
||||
const panelOpen = shallowRef(false)
|
||||
const confirmTitle = computed(() => ambiguous.value ? '确认上次重置结果?' : '立即重置额度?')
|
||||
const confirmDescription = computed(() => ambiguous.value
|
||||
? '上次请求的结果不确定。本次会复用同一个请求标识,只用于确认或完成原操作。'
|
||||
: '这会消费一张上游主动重置卡。操作完成后将立即重新读取额度窗口。')
|
||||
const modalTitle = computed(() => showConfirm.value ? confirmTitle.value : '主动重置额度')
|
||||
const modalDescription = computed(() => showConfirm.value
|
||||
? confirmDescription.value
|
||||
: '查看可用重置卡,并按需提前开启新的 Codex 额度窗口。')
|
||||
const creditRadioName = `account-reset-credit-${useId()}`
|
||||
const modalTitle = computed(() => {
|
||||
if (!showConfirm.value)
|
||||
return '主动重置额度'
|
||||
return ambiguous.value ? '确认上次重置' : '确认重置额度'
|
||||
})
|
||||
const modalDescription = computed(() =>
|
||||
showConfirm.value ? undefined : '选择一张重置卡后继续',
|
||||
)
|
||||
const triggerLabel = computed(() => {
|
||||
if (ambiguous.value)
|
||||
return '查看主动重置卡,有一项操作待确认'
|
||||
@@ -59,7 +65,7 @@ const triggerLabel = computed(() => {
|
||||
})
|
||||
const showTriggerCount = computed(() => hasSnapshot.value && availableCount.value > 0)
|
||||
const confirmCreditTitle = computed(() =>
|
||||
availableCredits.value[0]?.title || 'Codex 主动重置卡',
|
||||
consumptionCredit.value?.title || 'Codex 主动重置卡',
|
||||
)
|
||||
|
||||
watch(panelOpen, (isOpen) => {
|
||||
@@ -77,6 +83,24 @@ function expiryLabel(value: string | null) {
|
||||
const expiry = dayjs(value)
|
||||
return expiry.isValid() ? `${expiry.format('YYYY-MM-DD HH:mm')} 到期` : '到期时间未知'
|
||||
}
|
||||
|
||||
function creditOptionClasses(creditId: string) {
|
||||
return [
|
||||
'w-full min-w-0 rounded-cp-control px-4 py-3 outline-none transition-colors duration-150 motion-reduce:transition-none',
|
||||
selectedCreditId.value === creditId
|
||||
? 'bg-cp-info-bg'
|
||||
: 'bg-cp-subtle hover:bg-cp-default-hover',
|
||||
]
|
||||
}
|
||||
|
||||
function creditOptionLabel(credit: { title: string | null, expiresAt: string | null }) {
|
||||
return `${credit.title || 'Codex 主动重置卡'},${expiryLabel(credit.expiresAt)}`
|
||||
}
|
||||
|
||||
async function handleConfirmConsume() {
|
||||
if (await confirmConsume())
|
||||
panelOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -115,8 +139,11 @@ function expiryLabel(value: string | null) {
|
||||
<p class="mt-1.5 mb-0 text-[14px] leading-snug font-heavy text-cp-primary">
|
||||
{{ confirmCreditTitle }}
|
||||
</p>
|
||||
<p class="mt-2 mb-0 text-[12px] leading-[1.55] font-emphasis text-cp-secondary">
|
||||
只消费上游重置卡;完成后系统会回读官方额度,不会直接改写本地重置时间。
|
||||
<p
|
||||
v-if="consumptionCredit"
|
||||
class="mt-1 mb-0 font-mono text-[10px] leading-normal font-emphasis text-cp-muted-text"
|
||||
>
|
||||
{{ expiryLabel(consumptionCredit.expiresAt) }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -133,7 +160,7 @@ function expiryLabel(value: string | null) {
|
||||
上次操作结果待确认
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis text-cp-secondary">
|
||||
再次确认会复用原请求标识,不会创建新的消费操作。
|
||||
再确认一次即可。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -162,7 +189,7 @@ function expiryLabel(value: string | null) {
|
||||
<section class="grid gap-2.5">
|
||||
<div class="flex min-h-8 items-center justify-between gap-3">
|
||||
<h3 class="m-0 text-[12px] font-heavy text-cp-muted-text">
|
||||
卡片明细
|
||||
选择重置卡
|
||||
</h3>
|
||||
<BaseIconButton
|
||||
variant="ghost"
|
||||
@@ -187,22 +214,32 @@ function expiryLabel(value: string | null) {
|
||||
{{ loadError }},请刷新重试。
|
||||
</p>
|
||||
|
||||
<div v-else-if="availableCredits.length" class="grid gap-2">
|
||||
<article
|
||||
<div
|
||||
v-else-if="availableCredits.length"
|
||||
class="grid gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="选择要使用的重置卡"
|
||||
>
|
||||
<BaseRadio
|
||||
v-for="credit in availableCredits"
|
||||
:key="credit.id"
|
||||
class="flex min-w-0 items-center gap-3 rounded-cp-control bg-cp-subtle px-4 py-3"
|
||||
:model-value="selectedCreditId"
|
||||
:value="credit.id"
|
||||
:name="creditRadioName"
|
||||
:label="creditOptionLabel(credit)"
|
||||
:disabled="ambiguous || consuming"
|
||||
:class="creditOptionClasses(credit.id)"
|
||||
@update:model-value="selectCredit"
|
||||
>
|
||||
<span class="size-1.5 shrink-0 rounded-full bg-cp-info" aria-hidden="true" />
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 truncate text-[12px] font-heavy text-cp-primary">
|
||||
<span class="block min-w-0">
|
||||
<span class="block truncate text-[12px] font-heavy text-cp-primary">
|
||||
{{ credit.title || 'Codex 主动重置卡' }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 truncate font-mono text-[10px] font-emphasis text-cp-muted-text">
|
||||
</span>
|
||||
<span class="mt-1 block truncate font-mono text-[10px] font-emphasis text-cp-muted-text">
|
||||
{{ expiryLabel(credit.expiresAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</span>
|
||||
</span>
|
||||
</BaseRadio>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-cp-control bg-cp-subtle">
|
||||
@@ -222,8 +259,8 @@ function expiryLabel(value: string | null) {
|
||||
<BaseButton variant="ghost" :disabled="consuming" @click="cancelConsume">
|
||||
返回
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="consuming" @click="confirmConsume">
|
||||
{{ ambiguous ? '确认原操作' : '消费并重置' }}
|
||||
<BaseButton variant="primary" :loading="consuming" @click="handleConfirmConsume">
|
||||
{{ ambiguous ? '再次确认' : '确认重置' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -232,13 +269,10 @@ function expiryLabel(value: string | null) {
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:disabled="loading || consuming || (!ambiguous && availableCount <= 0)"
|
||||
:disabled="loading || consuming || !canRequestConsume"
|
||||
@click="requestConsume"
|
||||
>
|
||||
<template #icon>
|
||||
<RotateCcw class="size-3.5" />
|
||||
</template>
|
||||
{{ ambiguous ? '确认原操作' : '立即重置' }}
|
||||
{{ ambiguous ? '继续确认' : '下一步' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountRequestBar } from './presenter'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
bars: AccountRequestBar[]
|
||||
label: string
|
||||
showNativeTooltip?: boolean
|
||||
}>(),
|
||||
{
|
||||
showNativeTooltip: true,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-stretch gap-px overflow-hidden rounded-full"
|
||||
role="img"
|
||||
:aria-label="label"
|
||||
:title="showNativeTooltip ? label : undefined"
|
||||
>
|
||||
<span
|
||||
v-for="bar in bars"
|
||||
:key="bar.key"
|
||||
class="relative min-w-0 flex-1 bg-cp-default-border"
|
||||
:title="showNativeTooltip ? bar.title : undefined"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-x-0 bottom-0 bg-cp-success"
|
||||
:style="{ height: bar.height }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import type { AccountQuotaWindow } from '../../constants'
|
||||
import type { AccountUsageWindowVariant } from './presenter'
|
||||
import { computed } from 'vue'
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import AccountRequestTimeline from './AccountRequestTimeline.vue'
|
||||
import { resolveAccountUsageWindowPresentation } from './presenter'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -116,25 +117,12 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
|
||||
{{ view.local.requestDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-stretch gap-px"
|
||||
<AccountRequestTimeline
|
||||
:bars="view.local.requestBars"
|
||||
:label="view.local.timelineTitle"
|
||||
:show-native-tooltip="showNativeTooltip"
|
||||
:class="[view.classes.trackShape, view.classes.trackOffset]"
|
||||
role="img"
|
||||
:aria-label="view.local.timelineTitle"
|
||||
:title="view.local.timelineTitle"
|
||||
>
|
||||
<span
|
||||
v-for="bar in view.local.requestBars"
|
||||
:key="bar.key"
|
||||
class="relative min-w-0 flex-1 bg-cp-default-border"
|
||||
:title="bar.title"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-x-0 bottom-0 bg-cp-success"
|
||||
:style="{ height: bar.height }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else :class="view.classes.header">
|
||||
|
||||
@@ -12,6 +12,13 @@ interface AccountRequestBucket {
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
export interface AccountRequestBar {
|
||||
key: string
|
||||
requestCount: number
|
||||
height: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface AccountLocalUsage {
|
||||
requestCount?: number
|
||||
requestCountDisplay?: string
|
||||
@@ -160,6 +167,7 @@ export function resolveAccountUsageWindowPresentation(
|
||||
requestValueVisible: localRequestValueVisible,
|
||||
timelineTitle: `${localRequestLabel} ${localRequestDisplay} 次`,
|
||||
requestBars: requestTimeline(localUsage?.requestBuckets ?? [], input.now),
|
||||
durationDisplay: rollingWindowDurationDisplay(input.window?.windowSeconds),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -242,6 +250,21 @@ function requestCountDisplay(localUsage: AccountLocalUsage | null) {
|
||||
return typeof count === 'number' ? formatInteger(count) : '0'
|
||||
}
|
||||
|
||||
function rollingWindowDurationDisplay(windowSeconds: number | null | undefined) {
|
||||
if (typeof windowSeconds !== 'number' || !Number.isFinite(windowSeconds) || windowSeconds <= 0)
|
||||
return '—'
|
||||
|
||||
const hourSeconds = 60 * 60
|
||||
const daySeconds = 24 * hourSeconds
|
||||
if (windowSeconds <= daySeconds * 2 && windowSeconds % hourSeconds === 0)
|
||||
return `${formatInteger(windowSeconds / hourSeconds)} 小时`
|
||||
if (windowSeconds % daySeconds === 0)
|
||||
return `${formatInteger(windowSeconds / daySeconds)} 天`
|
||||
if (windowSeconds % hourSeconds === 0)
|
||||
return `${formatInteger(windowSeconds / hourSeconds)} 小时`
|
||||
return `${formatInteger(windowSeconds)} 秒`
|
||||
}
|
||||
|
||||
export function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
|
||||
const percent = clamp(window.usedPercent ?? 0, 0, 100)
|
||||
const tone = quotaWindowTone(window.usedPercent)
|
||||
@@ -268,7 +291,7 @@ function quotaWindowTone(usedPercent: number | null): QuotaWindowTone {
|
||||
function requestTimeline(
|
||||
buckets: NonNullable<AccountLocalUsage['requestBuckets']>,
|
||||
now: number,
|
||||
) {
|
||||
): AccountRequestBar[] {
|
||||
const currentHour = Math.floor(now / hourMilliseconds) * hourMilliseconds
|
||||
const bucketCounts = new Map(
|
||||
buckets.map((bucket) => {
|
||||
|
||||
@@ -12,7 +12,8 @@ import { errorMessage } from '@/utils/async'
|
||||
|
||||
interface PendingResetCreditOperation {
|
||||
accountId: string
|
||||
creditId?: string
|
||||
creditId: string
|
||||
credit: AccountResetCredit
|
||||
redeemRequestId: string
|
||||
hasTransportFailure: boolean
|
||||
}
|
||||
@@ -36,13 +37,37 @@ export function useAccountResetCredits(options: {
|
||||
const consuming = shallowRef(false)
|
||||
const loadError = shallowRef('')
|
||||
const showConfirm = shallowRef(false)
|
||||
const selectedCreditId = shallowRef('')
|
||||
const pendingOperation = shallowRef<PendingResetCreditOperation | null>(null)
|
||||
let loadSequence = 0
|
||||
|
||||
const availableCredits = computed(() =>
|
||||
credits.value.filter(credit => credit.status === 'available'),
|
||||
)
|
||||
const selectedCredit = computed(() =>
|
||||
availableCredits.value.find(credit => credit.id === selectedCreditId.value),
|
||||
)
|
||||
const consumptionCredit = computed(() =>
|
||||
pendingOperation.value?.credit ?? selectedCredit.value,
|
||||
)
|
||||
const ambiguous = computed(() => pendingOperation.value?.hasTransportFailure === true)
|
||||
const canRequestConsume = computed(() =>
|
||||
ambiguous.value || (availableCount.value > 0 && selectedCredit.value !== undefined),
|
||||
)
|
||||
|
||||
function reconcileSelectedCredit(accountId: string) {
|
||||
if (accountId !== options.accountId())
|
||||
return
|
||||
|
||||
const operation = pendingOperation.value
|
||||
if (operation?.accountId === accountId) {
|
||||
selectedCreditId.value = operation.creditId
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedCredit.value)
|
||||
selectedCreditId.value = ''
|
||||
}
|
||||
|
||||
function applySnapshot(accountId: string, snapshot: ResetCreditsSnapshot) {
|
||||
snapshotsByAccountId.set(accountId, snapshot)
|
||||
@@ -51,6 +76,7 @@ export function useAccountResetCredits(options: {
|
||||
credits.value = snapshot.credits
|
||||
availableCount.value = snapshot.availableCount
|
||||
hasSnapshot.value = true
|
||||
reconcileSelectedCredit(accountId)
|
||||
}
|
||||
|
||||
function restoreSnapshot(accountId: string) {
|
||||
@@ -58,15 +84,22 @@ export function useAccountResetCredits(options: {
|
||||
credits.value = snapshot?.credits ?? []
|
||||
availableCount.value = snapshot?.availableCount ?? 0
|
||||
hasSnapshot.value = snapshot !== undefined
|
||||
reconcileSelectedCredit(accountId)
|
||||
}
|
||||
|
||||
function selectCredit(creditId: string) {
|
||||
if (pendingOperation.value || consuming.value)
|
||||
return
|
||||
if (!availableCredits.value.some(credit => credit.id === creditId))
|
||||
return
|
||||
selectedCreditId.value = creditId
|
||||
}
|
||||
|
||||
function applyConfirmedConsumption(operation: PendingResetCreditOperation) {
|
||||
const snapshot = snapshotsByAccountId.get(operation.accountId)
|
||||
if (!snapshot)
|
||||
return
|
||||
const creditIndex = operation.creditId
|
||||
? snapshot.credits.findIndex(credit => credit.id === operation.creditId)
|
||||
: snapshot.credits.findIndex(credit => credit.status === 'available')
|
||||
const creditIndex = snapshot.credits.findIndex(credit => credit.id === operation.creditId)
|
||||
const nextCredits = creditIndex < 0
|
||||
? snapshot.credits
|
||||
: snapshot.credits.filter((_, index) => index !== creditIndex)
|
||||
@@ -107,9 +140,13 @@ export function useAccountResetCredits(options: {
|
||||
if (!existing || existing.accountId !== accountId) {
|
||||
if (availableCount.value <= 0)
|
||||
return
|
||||
const credit = selectedCredit.value
|
||||
if (!credit)
|
||||
return
|
||||
pendingOperation.value = {
|
||||
accountId,
|
||||
creditId: availableCredits.value[0]?.id,
|
||||
creditId: credit.id,
|
||||
credit,
|
||||
redeemRequestId: globalThis.crypto.randomUUID(),
|
||||
hasTransportFailure: false,
|
||||
}
|
||||
@@ -123,10 +160,10 @@ export function useAccountResetCredits(options: {
|
||||
pendingOperation.value = null
|
||||
}
|
||||
|
||||
async function confirmConsume() {
|
||||
async function confirmConsume(): Promise<boolean> {
|
||||
const operation = pendingOperation.value
|
||||
if (!operation || consuming.value)
|
||||
return
|
||||
return false
|
||||
|
||||
consuming.value = true
|
||||
try {
|
||||
@@ -142,27 +179,27 @@ export function useAccountResetCredits(options: {
|
||||
if (!confirmed) {
|
||||
toast.error(resetResultMessage(result.code))
|
||||
await loadCredits()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
toast.success(
|
||||
result.code === 'already_redeemed'
|
||||
? '已确认上次操作完成'
|
||||
: '额度已重置',
|
||||
)
|
||||
const successMessage = result.code === 'already_redeemed'
|
||||
? '上次重置已完成'
|
||||
: '额度已重置'
|
||||
applyConfirmedConsumption(operation)
|
||||
await loadCredits()
|
||||
try {
|
||||
const quota = await refreshAccountQuota({ accountId: operation.accountId })
|
||||
if (operation.accountId === options.accountId())
|
||||
options.onAccountUpdated(quota.account)
|
||||
toast.success(successMessage)
|
||||
}
|
||||
catch (error: unknown) {
|
||||
toast.warning(
|
||||
`重置卡已消费,额度快照刷新失败:${errorMessage(error, '请手动刷新额度')}`,
|
||||
`${successMessage},但最新额度加载失败:${errorMessage(error, '请手动刷新额度')}`,
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
catch (error: unknown) {
|
||||
showConfirm.value = false
|
||||
@@ -178,6 +215,7 @@ export function useAccountResetCredits(options: {
|
||||
toast.error(errorMessage(error, '额度重置失败'))
|
||||
await loadCredits()
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
consuming.value = false
|
||||
@@ -188,10 +226,11 @@ export function useAccountResetCredits(options: {
|
||||
options.accountId,
|
||||
(accountId) => {
|
||||
loadSequence += 1
|
||||
pendingOperation.value = null
|
||||
selectedCreditId.value = ''
|
||||
restoreSnapshot(accountId)
|
||||
loading.value = false
|
||||
loadError.value = ''
|
||||
pendingOperation.value = null
|
||||
showConfirm.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -201,6 +240,9 @@ export function useAccountResetCredits(options: {
|
||||
credits,
|
||||
availableCredits,
|
||||
availableCount,
|
||||
selectedCreditId,
|
||||
consumptionCredit,
|
||||
canRequestConsume,
|
||||
hasSnapshot,
|
||||
loading,
|
||||
consuming,
|
||||
@@ -208,6 +250,7 @@ export function useAccountResetCredits(options: {
|
||||
ambiguous,
|
||||
showConfirm,
|
||||
loadCredits,
|
||||
selectCredit,
|
||||
requestConsume,
|
||||
cancelConsume,
|
||||
confirmConsume,
|
||||
|
||||
@@ -194,7 +194,7 @@ function windowRoleOrder(role: AccountQuotaWindow['role']) {
|
||||
|
||||
function quotaLimitLabel(window: AccountQuotaWindow) {
|
||||
if (window.limitId === 'codex')
|
||||
return '主额度'
|
||||
return '通用额度'
|
||||
return window.limitName
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { computed, onMounted, shallowRef } from 'vue'
|
||||
import { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { formatCompactNumber } from '@/utils/number'
|
||||
import { formatCompactNumber, formatInteger } from '@/utils/number'
|
||||
import { normalizeUsageRecord } from '@/views/usage/utils/records'
|
||||
|
||||
export function useDashboard() {
|
||||
@@ -263,7 +263,7 @@ function metricCards(
|
||||
title: '账号',
|
||||
value: credentials.total,
|
||||
valueRaw: credentials.totalValue,
|
||||
valueFormatter: formatCompactNumber,
|
||||
valueFormatter: formatAccountCount,
|
||||
icon: Users,
|
||||
tone: 'normal',
|
||||
details: [
|
||||
@@ -337,6 +337,10 @@ function metricCards(
|
||||
]
|
||||
}
|
||||
|
||||
function formatAccountCount(value: number) {
|
||||
return formatInteger(Math.max(0, Math.round(value)))
|
||||
}
|
||||
|
||||
function sparkline(values: number[], tone: MetricTone) {
|
||||
return values.some(value => value > 0) ? { values, tone } : undefined
|
||||
}
|
||||
@@ -519,7 +523,7 @@ function formatDashboardUsd(value: string) {
|
||||
const amount = Number(normalized.slice(1).replaceAll(',', ''))
|
||||
if (!Number.isFinite(amount))
|
||||
return value
|
||||
if (amount < 1_000)
|
||||
if (amount < 10_000)
|
||||
return `$${amount.toFixed(2)}`
|
||||
return `$${(amount / 1_000).toFixed(3)}k`
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.2.14"
|
||||
version: "3.2.16"
|
||||
|
||||
Reference in New Issue
Block a user